mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
release: v1.7.0
This commit is contained in:
parent
db6caea824
commit
c8bf4e0f89
19
.env.example
19
.env.example
@ -34,6 +34,11 @@ MATECLAW_CORS_ALLOWED_ORIGINS=
|
||||
# 留空时回退到当前请求的 host,再退回相对路径。反代后部署建议显式设置。
|
||||
MATECLAW_PUBLIC_BASE_URL=
|
||||
|
||||
# 是否公开 Swagger UI / OpenAPI 文档(/swagger-ui.html、/v3/api-docs)。
|
||||
# 生产数据库 profile(mysql/kingbase/postgres)默认 false —— 匿名无法浏览全部
|
||||
# 端点结构,需全局管理员(ROLE_ADMIN)。仅在内网/预发临时调试时设为 true。
|
||||
MATECLAW_OPENAPI_EXPOSE_UI=
|
||||
|
||||
# SearXNG 会话密钥(容器内部用,留空会用开发默认值)。生产部署请设成 32+ 位随机串。
|
||||
# openssl rand -hex 32
|
||||
SEARXNG_SECRET=
|
||||
@ -55,6 +60,20 @@ MATECLAW_BROWSER_CDP_URL=
|
||||
MATECLAW_BROWSER_CHROME_PATH=
|
||||
MATECLAW_BROWSER_CHANNEL=
|
||||
|
||||
# ==================== 局域网 部署放开(可选,默认 false 严格模式) ====================
|
||||
# 浏览器 SSRF 防护:放行本地回环和私有 IP(127.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 OAuth(Docker,可选) ====================
|
||||
#
|
||||
# OpenAI ChatGPT OAuth 使用 Codex CLI 的 public client + PKCE / device code,
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -105,6 +105,9 @@ CLAUDE.md
|
||||
# Codex CLI local artifacts
|
||||
.codex/
|
||||
|
||||
# Codebase memory (local agent index / graph artifact; do not commit)
|
||||
.codebase-memory/
|
||||
|
||||
# Sync tooling local state (generated each run; report is intentionally tracked)
|
||||
scripts/.*-sync-state.json
|
||||
|
||||
|
||||
32
README.md
32
README.md
@ -10,14 +10,14 @@
|
||||
|
||||
<p align="center"><sub><b>Agent Harness · Spring Boot inside · One JAR to ship</b></sub></p>
|
||||
|
||||
[](https://github.com/matevip/mateclaw)
|
||||
[](https://github.com/mateaix/mateclaw)
|
||||
[](https://claw.mate.vip/docs)
|
||||
[](https://claw-demo.mate.vip)
|
||||
[](https://claw.mate.vip)
|
||||
[](https://adoptium.net/)
|
||||
[](https://spring.io/projects/spring-boot)
|
||||
[](https://vuejs.org/)
|
||||
[](https://github.com/matevip/mateclaw)
|
||||
[](https://github.com/mateaix/mateclaw)
|
||||
[](LICENSE)
|
||||
|
||||
[[Website](https://claw.mate.vip)] [[Live Demo](https://claw-demo.mate.vip)] [[Documentation](https://claw.mate.vip/docs)] [[中文](README_zh.md)]
|
||||
@ -161,7 +161,7 @@ docker compose up -d # http://localhost:18080
|
||||
|
||||
### Desktop
|
||||
|
||||
Download from [GitHub Releases](https://github.com/matevip/mateclaw/releases). Bundles JRE 21. No Java install needed.
|
||||
Download from [GitHub Releases](https://github.com/mateaix/mateclaw/releases). Bundles JRE 21. No Java install needed.
|
||||
|
||||
---
|
||||
|
||||
@ -193,7 +193,7 @@ mateclaw/
|
||||
└── .env.example
|
||||
```
|
||||
|
||||
Desktop binaries ship via [GitHub Releases](https://github.com/matevip/mateclaw/releases) with a bundled JRE 21 — no Java install needed.
|
||||
Desktop binaries ship via [GitHub Releases](https://github.com/mateaix/mateclaw/releases) with a bundled JRE 21 — no Java install needed.
|
||||
|
||||
## Tech stack
|
||||
|
||||
@ -217,25 +217,29 @@ Full docs at **[claw.mate.vip/docs](https://claw.mate.vip/docs)** — setup, arc
|
||||
|
||||
## Roadmap
|
||||
|
||||
**v1.7.0 (shipped 2026-07-04)** — a *productionization pass*: once it's in real collaboration, close every loop you can't see, gather, reach, fit, or connect:
|
||||
|
||||
- **All three approval paths close the loop** — workflow `await_approval` actually pushes to channels and resolves → resumes, the WebChat (API-key) channel can approve/deny and replay, and Feishu/WeCom card clicks resolve workflow approvals directly
|
||||
- **Long tasks are visible** — an always-on Run Overview rail + a per-turn token breakdown (cache hit/miss/write + reasoning split) + sub-agent cost rolled up + one-click generated-file download
|
||||
- **Fits the real model window** — local-model context-window probing, a unified token budget for prefix injection, small-context degradation, and tool-schema budget gating — no more "guess 32K" pre-flight rejections or silent truncation
|
||||
- **Opens up** — a knowledge-base + Deep Research open API (API-key + rate limit + SSE), a pluggable search Provider SPI, and MCP identity forwarding (carry the authenticated user's identity into a STDIO MCP)
|
||||
- **Reaches further** — desktop local-embedded / remote-centralized dual mode (with `mateclaw-desktop` source opened) + a LAN deployment mode for controlled intranet access
|
||||
- **One-click operational data export** — Dashboard 9-sheet Excel + a CLI for offline export
|
||||
|
||||
Full story in the [v1.7.0 release notes](https://claw.mate.vip/docs/en/releases/1.7.0).
|
||||
|
||||
**v1.6.0 (shipped 2026-06-22)** — make the autonomous employee *fast, sharp-eyed, and embeddable*: two-stage skill loading + prefix compression (faster first token) · `execute_code` native sandboxed code execution · vision that persists across turns + `image_analyze` · embeddable/headless webchat with per-`endUserId` memory · a Wiki you actually read (reading split from management · unified Sources tab · clickable `[[wikilinks]]`) · steadier under load (self-healing MCP · tool-call recovery · evidence-gated plans). Full story in the [v1.6.0 release notes](https://claw.mate.vip/docs/en/releases/1.6.0).
|
||||
|
||||
**v1.5.0 (shipped 2026-06-04)** — Goal checklists (fuzzy score → ticked boxes) · self-maintaining Wiki (`[[wikilinks]]` · fact/experience layers · pageType profiles & permissions · KB pipelines · local-directory ingest) · per-owner memory isolation (`owner_key` + visibility scope + `endUserId` passthrough) · per-agent primary knowledge base · provider-preference model routing. Full story in the [v1.5.0 release notes](https://claw.mate.vip/docs/en/releases/1.5.0).
|
||||
|
||||
**v1.4.0 (shipped 2026-05-23)** — Persistent Goals (lock a goal, self-evaluate every turn) · subagent delegation tree (3 levels deep · sync / parallel / async · one-sentence team builder) · progressive tool/skill disclosure · Workspace RBAC (Owner / Admin / Member / Viewer) · Feishu first-class (interactive / approval / streaming cards · channel-native tools). See the [v1.4.0 release notes](https://claw.mate.vip/docs/en/releases/1.4.0).
|
||||
|
||||
**v1.3.0 (shipped 2026-05-13)** — Workflow engine · 6-pattern trigger system · Wiki transformations · per-agent MCP binding · multimodal sidecar routing · four JVM-native document-generation tools · image edit. See the [v1.3.0 release notes](https://claw.mate.vip/docs/en/releases/1.3.0).
|
||||
|
||||
**v1.6.0 (in progress)** — make the autonomous employee *fast, sharp-eyed, and embeddable*:
|
||||
|
||||
- **Faster first token** — two-stage skill loading (base skills resident, scenario skills retrieved on demand by a relevance scorer) plus prefix compression, cutting the cold-start payload that used to blow past a million characters
|
||||
- **Native code execution** — `execute_code` lets an employee write and run sandboxed code to compute, transform data, and assemble multi-format reports, all JVM-side
|
||||
- **Vision that persists** — images stay in context across turns; `image_analyze` re-reads an attachment on demand, so "zoom into that chart" follow-ups work without re-uploading
|
||||
- **Embeddable & headless** — the webchat widget becomes a Web/API surface with multi-session support and per-end-user identity (`endUserId`), isolating memory per end user
|
||||
- **A Wiki you actually read** — reading split from management, a unified Sources tab with per-KB auto-sync, and clickable cross-KB `[[wikilinks]]`
|
||||
- **Steadier under load** — self-healing MCP connections · tool-call recovery on interleaved-thinking models · evidence-gated plan execution
|
||||
|
||||
## Contributing
|
||||
|
||||
```bash
|
||||
git clone https://github.com/matevip/mateclaw.git
|
||||
git clone https://github.com/mateaix/mateclaw.git
|
||||
cd mateclaw
|
||||
cd mateclaw-server && mvn clean compile
|
||||
cd ../mateclaw-ui && pnpm install && pnpm dev
|
||||
|
||||
32
README_zh.md
32
README_zh.md
@ -10,14 +10,14 @@
|
||||
|
||||
<p align="center"><sub><b>Agent Harness · Spring Boot 内核 · 一个 JAR 交付</b></sub></p>
|
||||
|
||||
[](https://github.com/matevip/mateclaw)
|
||||
[](https://github.com/mateaix/mateclaw)
|
||||
[](https://claw.mate.vip/docs)
|
||||
[](https://claw-demo.mate.vip)
|
||||
[](https://claw.mate.vip)
|
||||
[](https://adoptium.net/)
|
||||
[](https://spring.io/projects/spring-boot)
|
||||
[](https://vuejs.org/)
|
||||
[](https://github.com/matevip/mateclaw)
|
||||
[](https://github.com/mateaix/mateclaw)
|
||||
[](LICENSE)
|
||||
|
||||
[[官网](https://claw.mate.vip)] [[在线演示](https://claw-demo.mate.vip)] [[文档](https://claw.mate.vip/docs)] [[English](README.md)]
|
||||
@ -161,7 +161,7 @@ docker compose up -d # http://localhost:18080
|
||||
|
||||
### 桌面端
|
||||
|
||||
从 [GitHub Releases](https://github.com/matevip/mateclaw/releases) 下载安装包。内嵌 JRE 21,无需额外装 Java。
|
||||
从 [GitHub Releases](https://github.com/mateaix/mateclaw/releases) 下载安装包。内嵌 JRE 21,无需额外装 Java。
|
||||
|
||||
---
|
||||
|
||||
@ -193,7 +193,7 @@ mateclaw/
|
||||
└── .env.example
|
||||
```
|
||||
|
||||
桌面端安装包通过 [GitHub Releases](https://github.com/matevip/mateclaw/releases) 分发,内嵌 JRE 21——无需安装 Java。
|
||||
桌面端安装包通过 [GitHub Releases](https://github.com/mateaix/mateclaw/releases) 分发,内嵌 JRE 21——无需安装 Java。
|
||||
|
||||
## 技术栈
|
||||
|
||||
@ -217,25 +217,29 @@ mateclaw/
|
||||
|
||||
## 路线图
|
||||
|
||||
**v1.7.0(2026-07-04 发布)** — 一次*生产化加固*:把它放进真正的协作里之后,那些看不见、收不拢、够不着、装不下、连不通的地方全补上:
|
||||
|
||||
- **审批三条链路彻底闭环** — 工作流 `await_approval` 真的推到渠道并 resolve→恢复执行、WebChat(API-Key)渠道能批准/拒绝并重放、飞书/企微点卡片直接 resolve 工作流审批
|
||||
- **长任务看得见** — 常驻「运行总览」侧栏 + 本轮 Token 明细(缓存命中/未命中/写入 + 推理拆分)+ 子 Agent 成本向上滚加 + 生成文件一键下载
|
||||
- **装得下真实模型窗口** — 本地模型上下文窗口探测、prefix 注入统一 Token 预算、小上下文降级、工具 schema 预算门——不再被"猜个 32K"坑到预检拒绝或悄悄截断
|
||||
- **开放出去** — 知识库 / Deep Research 开放 API(API-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.0(2026-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.0(2026-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.0(2026-05-23 发布)** — 持续目标(锁定目标,每轮自评)· 子员工委派树(最深 3 层 · 同步 / 并行 / 异步 · 一句话组队)· 工具/技能渐进式披露 · 工作空间 RBAC(Owner / Admin / Member / Viewer)· 飞书一等公民(交互卡 / 审批卡 / 流式卡 · 渠道原生工具)。详见 [v1.4.0 release notes](https://claw.mate.vip/docs/zh/releases/1.4.0)。
|
||||
|
||||
**v1.3.0(2026-05-13 发布)** — 工作流引擎 · 6 种 pattern 触发器 · Wiki 加工器 · 每员工独立 MCP 绑定 · 多模态旁路路由 · 4 个 JVM 原生文档生成工具 · 图像编辑。详见 [v1.3.0 release notes](https://claw.mate.vip/docs/zh/releases/1.3.0)。
|
||||
|
||||
**v1.6.0(开发中)** — 让自驱的数字员工*更快、更会看、更易嵌入*:
|
||||
|
||||
- **首字节更快** — 技能两段式载入(基础技能常驻,场景技能由相关性评分器按需检索)+ prefix 压缩,砍掉过去单请求动辄上百万字符的冷启动负载
|
||||
- **原生代码执行** — `execute_code` 让员工自己写、自己跑沙箱代码,完成计算、数据加工与多格式报告生成,全程在 JVM 内
|
||||
- **能记住图的视觉** — 图片跨轮次保留在上下文里;`image_analyze` 按需重新解析某张附件,"放大看那张图表"这类追问无需重新上传
|
||||
- **可嵌入、可无头** — webchat 组件升级为 Web/API 接入面,支持多会话与按终端用户身份(`endUserId`)隔离记忆
|
||||
- **真正可读的 Wiki** — 阅读与管理分离、统一的 Sources 标签页(按知识库自动同步)、可点击的跨库 `[[wikilinks]]`
|
||||
- **高负载更稳** — MCP 连接自愈 · interleaved-thinking 模型的工具调用恢复 · 计划执行的证据闸门
|
||||
|
||||
## 参与贡献
|
||||
|
||||
```bash
|
||||
git clone https://github.com/matevip/mateclaw.git
|
||||
git clone https://github.com/mateaix/mateclaw.git
|
||||
cd mateclaw
|
||||
cd mateclaw-server && mvn clean compile
|
||||
cd ../mateclaw-ui && pnpm install && pnpm dev
|
||||
|
||||
@ -92,6 +92,30 @@ services:
|
||||
MATECLAW_BROWSER_CDP_URL: ${MATECLAW_BROWSER_CDP_URL:-}
|
||||
MATECLAW_BROWSER_CHROME_PATH: ${MATECLAW_BROWSER_CHROME_PATH:-}
|
||||
MATECLAW_BROWSER_CHANNEL: ${MATECLAW_BROWSER_CHANNEL:-}
|
||||
# SSRF / TLS relaxations for isolated LAN / on-prem deployments.
|
||||
# Both default to false (strict mode, public-internet safe).
|
||||
# The .env file uses the PLAYWRIGHT_* prefix (component-oriented naming,
|
||||
# not product-oriented) — here we translate to the MATECLAW_BROWSER_*
|
||||
# container env that Spring Boot relaxed-binding maps to BrowserProperties.
|
||||
# - PLAYWRIGHT_ALLOW_PRIVATE_NETWORK=true: allow loopback / private / link-local
|
||||
# addresses through the browser SSRF guard. Cloud-metadata endpoints stay
|
||||
# blocked. Turn on when the agent must drive http://192.168.x.x:port style
|
||||
# internal services and has no path to the public internet.
|
||||
# - PLAYWRIGHT_IGNORE_HTTPS_ERRORS=true: ignore HTTPS certificate errors.
|
||||
# Auto-enables --ignore-certificate-errors at the Chromium command line
|
||||
# when ALLOW_PRIVATE_NETWORK is also true (so CDP-attached external
|
||||
# browsers benefit too). Leave false on internet-facing deployments.
|
||||
MATECLAW_BROWSER_ALLOW_PRIVATE_NETWORK: ${PLAYWRIGHT_ALLOW_PRIVATE_NETWORK:-false}
|
||||
MATECLAW_BROWSER_IGNORE_HTTPS_ERRORS: ${PLAYWRIGHT_IGNORE_HTTPS_ERRORS:-false}
|
||||
# Playwright action / navigation timeouts (seconds). Increase for slow
|
||||
# LAN or large-page scenarios. Defaults match Playwright's own (30s).
|
||||
MATECLAW_BROWSER_DEFAULT_TIMEOUT_SECONDS: ${PLAYWRIGHT_DEFAULT_TIMEOUT_SECONDS:-30}
|
||||
MATECLAW_BROWSER_DEFAULT_NAVIGATION_TIMEOUT_SECONDS: ${PLAYWRIGHT_NAVIGATION_TIMEOUT_SECONDS:-30}
|
||||
# Hard cap on the textual snapshot returned by action=snapshot. Content
|
||||
# beyond this length is dropped with a truncated:true flag and a hint to
|
||||
# retry with selector. Results > framework spill threshold (~8000 chars)
|
||||
# are further spilt to disk by ToolResultStorage.
|
||||
MATECLAW_BROWSER_SNAPSHOT_MAX_LENGTH: ${PLAYWRIGHT_SNAPSHOT_MAX_LENGTH:-20000}
|
||||
# OAuth 模式默认保持 auto:localhost 访问走 LOCAL,IP/域名访问走 DEVICE_CODE。
|
||||
# 本机 Docker 若要强制使用 localhost:1455 回调,可在 .env 显式设为 local。
|
||||
MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE: ${MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE:-}
|
||||
|
||||
15
mateclaw-desktop/.env.example
Normal file
15
mateclaw-desktop/.env.example
Normal 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
33
mateclaw-desktop/.gitignore
vendored
Normal 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
|
||||
275
mateclaw-desktop/CODESIGNING.md
Normal file
275
mateclaw-desktop/CODESIGNING.md
Normal 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
432
mateclaw-desktop/README.md
Normal 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 Screen(Vue 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. 打包 macOS(DMG + ZIP)和 Windows(NSIS)
|
||||
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 代码签名证书
|
||||
|
||||
推荐使用 EV(Extended 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 运行时
|
||||
67
mateclaw-desktop/RELEASE_NOTES_v1.0.101.md
Normal file
67
mateclaw-desktop/RELEASE_NOTES_v1.0.101.md
Normal 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
|
||||
9
mateclaw-desktop/branding.config.json
Normal file
9
mateclaw-desktop/branding.config.json
Normal 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"
|
||||
}
|
||||
14
mateclaw-desktop/build/entitlements.mac.inherit.plist
Normal file
14
mateclaw-desktop/build/entitlements.mac.inherit.plist
Normal 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>
|
||||
18
mateclaw-desktop/build/entitlements.mac.plist
Normal file
18
mateclaw-desktop/build/entitlements.mac.plist
Normal 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>
|
||||
BIN
mateclaw-desktop/build/icon.icns
Normal file
BIN
mateclaw-desktop/build/icon.icns
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 241 KiB |
BIN
mateclaw-desktop/build/icon.ico
Normal file
BIN
mateclaw-desktop/build/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 279 KiB |
BIN
mateclaw-desktop/build/icon.png
Normal file
BIN
mateclaw-desktop/build/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 241 KiB |
BIN
mateclaw-desktop/build/icon_256.png
Normal file
BIN
mateclaw-desktop/build/icon_256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
139
mateclaw-desktop/electron-builder.cjs
Normal file
139
mateclaw-desktop/electron-builder.cjs
Normal 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
|
||||
87
mateclaw-desktop/electron/main/config.ts
Normal file
87
mateclaw-desktop/electron/main/config.ts
Normal 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 })
|
||||
}
|
||||
1103
mateclaw-desktop/electron/main/index.ts
Normal file
1103
mateclaw-desktop/electron/main/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
205
mateclaw-desktop/electron/main/localBridge.ts
Normal file
205
mateclaw-desktop/electron/main/localBridge.ts
Normal 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
|
||||
}
|
||||
82
mateclaw-desktop/electron/main/localToolsApproval.ts
Normal file
82
mateclaw-desktop/electron/main/localToolsApproval.ts
Normal 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()
|
||||
}
|
||||
122
mateclaw-desktop/electron/main/localToolsConfig.ts
Normal file
122
mateclaw-desktop/electron/main/localToolsConfig.ts
Normal 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()
|
||||
}
|
||||
194
mateclaw-desktop/electron/main/localToolsExecutor.ts
Normal file
194
mateclaw-desktop/electron/main/localToolsExecutor.ts
Normal 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))
|
||||
})
|
||||
}
|
||||
55
mateclaw-desktop/electron/preload/index.ts
Normal file
55
mateclaw-desktop/electron/preload/index.ts
Normal 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)
|
||||
},
|
||||
})
|
||||
27
mateclaw-desktop/index.html
Normal file
27
mateclaw-desktop/index.html
Normal 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>
|
||||
51
mateclaw-desktop/package.json
Normal file
51
mateclaw-desktop/package.json
Normal 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
3942
mateclaw-desktop/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
mateclaw-desktop/public/logo/mateclaw_logo_s.png
Normal file
BIN
mateclaw-desktop/public/logo/mateclaw_logo_s.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
1355
mateclaw-desktop/src/App.vue
Normal file
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
67
mateclaw-desktop/src/env.d.ts
vendored
Normal 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
|
||||
}
|
||||
4
mateclaw-desktop/src/main.ts
Normal file
4
mateclaw-desktop/src/main.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
25
mateclaw-desktop/tsconfig.json
Normal file
25
mateclaw-desktop/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
12
mateclaw-desktop/tsconfig.node.json
Normal file
12
mateclaw-desktop/tsconfig.node.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": false
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
1
mateclaw-desktop/tsconfig.node.tsbuildinfo
Normal file
1
mateclaw-desktop/tsconfig.node.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
72
mateclaw-desktop/vite.config.ts
Normal file
72
mateclaw-desktop/vite.config.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
})
|
||||
@ -5,6 +5,7 @@ import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import vip.mate.plugin.api.channel.PluginChannelAdapter;
|
||||
import vip.mate.plugin.api.memory.PluginMemoryProvider;
|
||||
import vip.mate.plugin.api.search.PluginSearchProvider;
|
||||
|
||||
import java.util.function.Supplier;
|
||||
|
||||
@ -60,6 +61,19 @@ public interface PluginContext {
|
||||
*/
|
||||
void registerMemoryProvider(PluginMemoryProvider provider);
|
||||
|
||||
/**
|
||||
* Register a web-search provider that joins the platform's search provider
|
||||
* chain used by the {@code web_search} tool.
|
||||
* <p>
|
||||
* The provider id must be globally unique — registration fails with a
|
||||
* {@link PluginException} if it clashes with a built-in provider
|
||||
* (serper / tavily / searxng / duckduckgo) or another plugin's provider.
|
||||
*
|
||||
* @param provider the search provider
|
||||
* @throws PluginException if the id is blank or already taken
|
||||
*/
|
||||
void registerSearchProvider(PluginSearchProvider provider);
|
||||
|
||||
/**
|
||||
* Read a configuration value from the plugin's config.
|
||||
*
|
||||
|
||||
@ -17,5 +17,8 @@ public enum PluginType {
|
||||
CHANNEL,
|
||||
|
||||
/** Register new memory providers */
|
||||
MEMORY
|
||||
MEMORY,
|
||||
|
||||
/** Register new web-search providers for the web_search tool */
|
||||
SEARCH
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
50
mateclaw-plugin-search-sample/pom.xml
Normal file
50
mateclaw-plugin-search-sample/pom.xml
Normal 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>
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -38,7 +38,12 @@ import vip.mate.llm.chatmodel.ReasoningEffortResolver;
|
||||
import vip.mate.llm.model.ModelConfigEntity;
|
||||
import vip.mate.llm.model.ModelFamily;
|
||||
import vip.mate.llm.model.ModelProtocol;
|
||||
import vip.mate.agent.context.PrefixBudgetPlan;
|
||||
import vip.mate.agent.context.PrefixBudgetPlanner;
|
||||
import vip.mate.agent.context.TokenEstimator;
|
||||
import vip.mate.llm.model.ModelProviderEntity;
|
||||
import vip.mate.llm.probe.ModelContextWindowResolver;
|
||||
import vip.mate.llm.routing.ProviderModelRef;
|
||||
import vip.mate.llm.routing.ProviderRouter;
|
||||
import vip.mate.llm.service.ModelConfigService;
|
||||
import vip.mate.llm.service.ModelProviderService;
|
||||
@ -47,6 +52,7 @@ import vip.mate.skill.runtime.SkillCatalogRenderer;
|
||||
import vip.mate.skill.service.SkillService;
|
||||
import vip.mate.system.service.SystemSettingService;
|
||||
import vip.mate.tool.ToolRegistry;
|
||||
import vip.mate.tool.disclosure.ToolUsageRecencyTracker;
|
||||
import vip.mate.memory.spi.MemoryManager;
|
||||
import vip.mate.workspace.document.WorkspaceFileService;
|
||||
import vip.mate.tool.guard.service.ToolGuardService;
|
||||
@ -96,6 +102,9 @@ public class AgentGraphBuilder {
|
||||
private final ConversationService conversationService;
|
||||
private final ModelConfigService modelConfigService;
|
||||
private final ModelProviderService modelProviderService;
|
||||
private final ModelContextWindowResolver contextWindowResolver;
|
||||
private final PrefixBudgetPlanner prefixBudgetPlanner;
|
||||
private final ToolUsageRecencyTracker toolUsageRecencyTracker;
|
||||
private final vip.mate.llm.service.ModelCapabilityService modelCapabilityService;
|
||||
private final ProviderRouter providerRouter;
|
||||
private final PlanningService planningService;
|
||||
@ -353,6 +362,12 @@ public class AgentGraphBuilder {
|
||||
|
||||
ModelProtocol protocol = ModelProtocol.fromChatModel(provider.getChatModel());
|
||||
|
||||
// Effective context window: explicit config > local-server probe > null
|
||||
// (downstream keeps its global-default fallback). Without probing, a
|
||||
// local 8k/16k model with maxInputTokens unset budgets against the
|
||||
// 128k global default and the first oversized request fails outright.
|
||||
Integer effectiveMaxInputTokens = contextWindowResolver.resolveMaxInputTokens(provider, runtimeModel);
|
||||
|
||||
// 内置搜索检测(DashScope / Kimi),但不再移除 WebSearchTool — 两者协同而非互斥
|
||||
boolean builtinSearchEnabled = false;
|
||||
Map<String, Object> providerKwargs = modelProviderService.readProviderGenerateKwargs(provider);
|
||||
@ -387,23 +402,45 @@ public class AgentGraphBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
String enhancedPrompt = buildEnhancedPrompt(entity, builtinSearchEnabled);
|
||||
// Prefix injection budget: optional blocks (memory / wiki / skill
|
||||
// catalog / extension catalog / ledger) share a token budget scaled
|
||||
// to the model's effective window. The agent's own prompt and the
|
||||
// tool schemas are never truncated — they are subtracted from the
|
||||
// budget so the optional blocks absorb the squeeze.
|
||||
int basePromptTokens = TokenEstimator.estimateTokens(entity.getSystemPrompt());
|
||||
int toolSchemaTokens = TokenEstimator.estimateToolsTokens(toolSet.callbacks());
|
||||
PrefixBudgetPlan prefixBudgetPlan = prefixBudgetPlanner.plan(
|
||||
effectiveMaxInputTokens, basePromptTokens, toolSchemaTokens);
|
||||
if (basePromptTokens > prefixBudgetPlan.effectiveMaxTokens() / 2) {
|
||||
log.warn("Agent {} 的身份 prompt 约 {} tokens,已超过模型有效窗口 {} 的一半——"
|
||||
+ "系统不会截断用户自写的身份 prompt,请自行精简,否则小上下文模型可能无法响应",
|
||||
entity.getId(), basePromptTokens, prefixBudgetPlan.effectiveMaxTokens());
|
||||
}
|
||||
|
||||
String enhancedPrompt = buildEnhancedPrompt(entity, builtinSearchEnabled, prefixBudgetPlan.memoryTokens());
|
||||
|
||||
// Runtime skill-catalog renderer — captures this agent's bound skills,
|
||||
// effective tool allowlist, model window and workspace; invoked each
|
||||
// turn by the reasoning / step-execution nodes with the skills loaded
|
||||
// so far this run so load_skill pins float to the top of the catalog.
|
||||
SkillCatalogRenderer skillCatalogRenderer = buildSkillCatalogRenderer(
|
||||
entity, boundTools, runtimeModel.getMaxInputTokens());
|
||||
entity, boundTools, effectiveMaxInputTokens);
|
||||
|
||||
// Extension-tool catalog — only for ReAct. The dynamic tool split runs
|
||||
// in ReasoningNode; Plan-Execute keeps advertising every tool (it has no
|
||||
// action node to record enable_tool), so baking the catalog there would
|
||||
// describe an enable_tool flow that can never take effect.
|
||||
// Auto-demotion is likewise ReAct-only: hiding a tool from Plan-Execute
|
||||
// would remove it with no enable_tool path to recover it.
|
||||
boolean isPlanExecute = "plan_execute".equals(entity.getAgentType());
|
||||
Set<String> autoDemotedTools = Set.of();
|
||||
if (!isPlanExecute) {
|
||||
if (prefixBudgetPlan.enabled()) {
|
||||
autoDemotedTools = toolDisclosureService.computeAutoDemotions(
|
||||
toolSet, prefixBudgetPlan.toolSchemaBudgetTokens());
|
||||
}
|
||||
String extensionCatalog = toolDisclosureService.renderExtensionCatalog(
|
||||
toolSet, runtimeModel.getMaxInputTokens());
|
||||
toolSet, effectiveMaxInputTokens, autoDemotedTools);
|
||||
if (extensionCatalog != null && !extensionCatalog.isBlank()) {
|
||||
enhancedPrompt = enhancedPrompt + extensionCatalog;
|
||||
}
|
||||
@ -423,7 +460,8 @@ public class AgentGraphBuilder {
|
||||
log.info("Built StateGraph Plan-Execute agent: {} (maxIterations={}, tools={}, protocol={})",
|
||||
entity.getName(), maxIter, toolSet.size(), protocol.getId());
|
||||
} else {
|
||||
agent = buildReActAgent(toolSet, runtimeModel, maxIter, entity.getId(), skillCatalogRenderer);
|
||||
agent = buildReActAgent(toolSet, runtimeModel, maxIter, entity.getId(), skillCatalogRenderer,
|
||||
prefixBudgetPlan, autoDemotedTools);
|
||||
// StateGraph 路径下工具调用由 ActionNode 控制,始终启用
|
||||
toolCallingEnabled = true;
|
||||
log.info("Built StateGraph ReAct agent: {} (maxIterations={}, tools={}, protocol={})",
|
||||
@ -451,7 +489,7 @@ public class AgentGraphBuilder {
|
||||
agent.userLocale = resolveLocale();
|
||||
agent.temperature = runtimeModel.getTemperature();
|
||||
agent.maxTokens = runtimeModel.getMaxTokens();
|
||||
agent.maxInputTokens = runtimeModel.getMaxInputTokens();
|
||||
agent.maxInputTokens = effectiveMaxInputTokens;
|
||||
agent.topP = runtimeModel.getTopP();
|
||||
agent.toolCallingEnabled = toolCallingEnabled;
|
||||
|
||||
@ -510,11 +548,17 @@ public class AgentGraphBuilder {
|
||||
|
||||
StateGraphReActAgent buildReActAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel,
|
||||
int maxIter, Long agentId, SkillCatalogRenderer skillCatalogRenderer) {
|
||||
return buildReActAgent(toolSet, runtimeModel, maxIter, agentId, skillCatalogRenderer, null, Set.of());
|
||||
}
|
||||
|
||||
StateGraphReActAgent buildReActAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel,
|
||||
int maxIter, Long agentId, SkillCatalogRenderer skillCatalogRenderer,
|
||||
PrefixBudgetPlan prefixBudgetPlan, Set<String> autoDemotedTools) {
|
||||
ChatModel chatModel = buildRuntimeChatModel(runtimeModel);
|
||||
ChatClient chatClient = ChatClient.create(chatModel);
|
||||
String reasoningEffort = resolveReasoningEffortForModel(runtimeModel);
|
||||
CompiledGraph compiledGraph = buildReActGraph(toolSet, chatModel, maxIter, reasoningEffort,
|
||||
runtimeModel, agentId, skillCatalogRenderer);
|
||||
runtimeModel, agentId, skillCatalogRenderer, prefixBudgetPlan, autoDemotedTools);
|
||||
return new StateGraphReActAgent(chatClient, conversationService, compiledGraph,
|
||||
chatModel, conversationWindowManager, toolSet);
|
||||
}
|
||||
@ -565,6 +609,14 @@ public class AgentGraphBuilder {
|
||||
streamTracker, fallbackChain, llmCacheMetricsAggregator, providerHealthTracker,
|
||||
primaryModelConfig != null ? primaryModelConfig.getProvider() : null,
|
||||
providerPool);
|
||||
if (primaryModelConfig != null) {
|
||||
// Feed "prompt too long" rejections back into the window resolver
|
||||
// so the next turn budgets against the server-reported limit.
|
||||
streamingHelper.setContextLimitObserver(errorMessage ->
|
||||
contextWindowResolver.noteContextLimitError(
|
||||
primaryModelConfig.getProvider(),
|
||||
primaryModelConfig.getModelName(), errorMessage));
|
||||
}
|
||||
ToolExecutionExecutor executor = new ToolExecutionExecutor(
|
||||
toolSet, toolGuardService, approvalService, streamTracker,
|
||||
toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry,
|
||||
@ -573,6 +625,7 @@ public class AgentGraphBuilder {
|
||||
// LLM mis-calls a skill name as a tool, the response tells it
|
||||
// the right invocation pattern instead of a dead-end error.
|
||||
executor.setSkillRuntimeService(skillRuntimeService);
|
||||
executor.setUsageRecencyTracker(toolUsageRecencyTracker);
|
||||
// Optional: route child-agent denied-tool audit events through
|
||||
// the audit pipeline. Null when audit is not wired (legacy / test).
|
||||
if (auditEventService != null) {
|
||||
@ -647,6 +700,9 @@ public class AgentGraphBuilder {
|
||||
// Token Usage
|
||||
.addStrategy(MateClawStateKeys.PROMPT_TOKENS, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.COMPLETION_TOKENS, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.CACHE_READ_TOKENS, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.CACHE_WRITE_TOKENS, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.REASONING_TOKENS, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.LLM_CALL_COUNT, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.RUNTIME_MODEL_NAME, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.RUNTIME_PROVIDER_ID, KeyStrategy.REPLACE)
|
||||
@ -829,12 +885,28 @@ public class AgentGraphBuilder {
|
||||
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
|
||||
String reasoningEffort, ModelConfigEntity primaryModelConfig,
|
||||
Long agentId, SkillCatalogRenderer skillCatalogRenderer) {
|
||||
return buildReActGraph(toolSet, chatModel, maxIterations, reasoningEffort,
|
||||
primaryModelConfig, agentId, skillCatalogRenderer, null, Set.of());
|
||||
}
|
||||
|
||||
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
|
||||
String reasoningEffort, ModelConfigEntity primaryModelConfig,
|
||||
Long agentId, SkillCatalogRenderer skillCatalogRenderer,
|
||||
PrefixBudgetPlan prefixBudgetPlan, Set<String> autoDemotedTools) {
|
||||
try {
|
||||
List<vip.mate.llm.failover.FallbackEntry> fallbackChain = buildFallbackChain(primaryModelConfig, agentId);
|
||||
NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper(
|
||||
streamTracker, fallbackChain, llmCacheMetricsAggregator, providerHealthTracker,
|
||||
primaryModelConfig != null ? primaryModelConfig.getProvider() : null,
|
||||
providerPool);
|
||||
if (primaryModelConfig != null) {
|
||||
// Feed "prompt too long" rejections back into the window resolver
|
||||
// so the next turn budgets against the server-reported limit.
|
||||
streamingHelper.setContextLimitObserver(errorMessage ->
|
||||
contextWindowResolver.noteContextLimitError(
|
||||
primaryModelConfig.getProvider(),
|
||||
primaryModelConfig.getModelName(), errorMessage));
|
||||
}
|
||||
ToolExecutionExecutor executor = new ToolExecutionExecutor(
|
||||
toolSet, toolGuardService, approvalService, streamTracker,
|
||||
toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry,
|
||||
@ -843,6 +915,7 @@ public class AgentGraphBuilder {
|
||||
// LLM mis-calls a skill name as a tool, the response tells it
|
||||
// the right invocation pattern instead of a dead-end error.
|
||||
executor.setSkillRuntimeService(skillRuntimeService);
|
||||
executor.setUsageRecencyTracker(toolUsageRecencyTracker);
|
||||
// Optional: route child-agent denied-tool audit events through
|
||||
// the audit pipeline. Null when audit is not wired (legacy / test).
|
||||
if (auditEventService != null) {
|
||||
@ -853,10 +926,21 @@ public class AgentGraphBuilder {
|
||||
// capability from reasoningEffort == null.
|
||||
boolean supportsReasoningEffort = primaryModelConfig != null
|
||||
&& ModelFamily.detect(primaryModelConfig.getModelName()).supportsReasoningEffort();
|
||||
// Honor the model's configured output cap. Passing 0 here made the
|
||||
// node fall back to its 16384 default, so the user-configured
|
||||
// maxTokens never took effect and strict local servers (vLLM's
|
||||
// max_model_len pre-check) rejected the request outright.
|
||||
int configuredMaxOutputTokens = (primaryModelConfig != null
|
||||
&& primaryModelConfig.getMaxTokens() != null
|
||||
&& primaryModelConfig.getMaxTokens() > 0)
|
||||
? primaryModelConfig.getMaxTokens() : 0;
|
||||
ReasoningNode reasoningNode = new ReasoningNode(chatModel, toolSet, reasoningEffort,
|
||||
supportsReasoningEffort,
|
||||
streamingHelper, conversationWindowManager, streamTracker, 0, wikiContextService,
|
||||
streamingHelper, conversationWindowManager, streamTracker,
|
||||
configuredMaxOutputTokens, wikiContextService,
|
||||
skillCatalogRenderer, toolDisclosureService, progressLedgerService);
|
||||
reasoningNode.setPrefixBudgetPlan(prefixBudgetPlan);
|
||||
reasoningNode.setAutoDemotedTools(autoDemotedTools);
|
||||
ActionNode actionNode = new ActionNode(executor, streamTracker);
|
||||
ObservationProcessor observationProcessor = new ObservationProcessor(graphObservationProperties);
|
||||
ObservationNode observationNode = new ObservationNode(observationProcessor, streamTracker);
|
||||
@ -935,6 +1019,9 @@ public class AgentGraphBuilder {
|
||||
// Token Usage
|
||||
.addStrategy(MateClawStateKeys.PROMPT_TOKENS, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.COMPLETION_TOKENS, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.CACHE_READ_TOKENS, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.CACHE_WRITE_TOKENS, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.REASONING_TOKENS, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.RUNTIME_MODEL_NAME, KeyStrategy.REPLACE)
|
||||
.addStrategy(MateClawStateKeys.RUNTIME_PROVIDER_ID, KeyStrategy.REPLACE)
|
||||
// SourceEvidenceLedger: ActionNode 把每轮 ToolResponse 抽取出的
|
||||
@ -1184,68 +1271,101 @@ public class AgentGraphBuilder {
|
||||
String primaryProviderId = primaryModelConfig != null ? primaryModelConfig.getProvider() : null;
|
||||
String primaryModelName = primaryModelConfig != null ? primaryModelConfig.getModelName() : null;
|
||||
|
||||
// RFC-009 PR-3: bias by agent preferences (if any). Listed providers win
|
||||
// their declared order; everything else keeps the global priority order.
|
||||
List<String> preferred = agentId == null
|
||||
? java.util.Collections.emptyList()
|
||||
: agentBindingService.getPreferredProviderIds(agentId);
|
||||
if (!preferred.isEmpty()) {
|
||||
providers = reorderByPreferences(providers, preferred);
|
||||
log.debug("[LlmFailover] agent={} preferences={} -> chain head reordered", agentId, preferred);
|
||||
}
|
||||
|
||||
// RFC-090 §9.2 调整 C — second-pass reorder: lift providers
|
||||
// that satisfy the bound-skill capability set (vision / video /
|
||||
// audio) ahead of those that don't. Stable otherwise so the
|
||||
// user-preferred order still wins among capable providers.
|
||||
// RFC-090 §9.2 调整 C — lift providers that satisfy the bound-skill
|
||||
// capability set (vision / video / audio) ahead of those that don't.
|
||||
// Run before planning so the non-preferred tail inherits this order;
|
||||
// the explicit preferred-model head keeps the user's declared order.
|
||||
try {
|
||||
providers = new ArrayList<>(providerRouter.reorderForCapabilities(agentId, providers));
|
||||
} catch (Exception e) {
|
||||
log.debug("[ProviderRouter] chain reorder failed: {}", e.getMessage());
|
||||
}
|
||||
|
||||
// Preferred-model chain: explicit (provider, model) entries lead in the
|
||||
// user's order — the same provider may repeat with different models —
|
||||
// then every non-preferred provider follows with its default model.
|
||||
List<ProviderModelRef> preferred = agentId == null
|
||||
? java.util.Collections.emptyList()
|
||||
: agentBindingService.getPreferredProviderModels(agentId);
|
||||
List<String> globalProviderIds = providers.stream()
|
||||
.map(ModelProviderEntity::getProviderId)
|
||||
.toList();
|
||||
List<ProviderModelRef> plan = planFallbackOrder(preferred, globalProviderIds);
|
||||
if (!preferred.isEmpty()) {
|
||||
log.debug("[LlmFailover] agent={} preferred-model chain={} -> plan={}", agentId, preferred, plan);
|
||||
}
|
||||
|
||||
// Dedup by exact (provider, model) — seeded with the primary so we never
|
||||
// rebuild the primary call, but OTHER models of the primary provider are
|
||||
// still legitimate fallback entries.
|
||||
List<vip.mate.llm.failover.FallbackEntry> chain = new ArrayList<>();
|
||||
for (ModelProviderEntity p : providers) {
|
||||
// Don't put the primary provider's row into the fallback chain — same-instance
|
||||
// skipping is also done in the runtime walker, but excluding here saves building
|
||||
// a duplicate ChatModel at agent-build time.
|
||||
if (primaryProviderId != null && primaryProviderId.equals(p.getProviderId())) {
|
||||
log.debug("[LlmFailover] skipping primary provider {} in fallback chain", primaryProviderId);
|
||||
continue;
|
||||
}
|
||||
// RFC-009 Phase 4: skip providers known-bad at build time. The runtime walker in
|
||||
// NodeStreamingChatHelper re-checks pool membership per request, so a provider
|
||||
// that re-enters the pool later still gets used (the graph is rebuilt on
|
||||
Set<String> seen = new java.util.HashSet<>();
|
||||
if (primaryProviderId != null && primaryModelName != null) {
|
||||
seen.add(primaryProviderId + "::" + primaryModelName);
|
||||
}
|
||||
for (ProviderModelRef ref : plan) {
|
||||
String pid = ref.providerId();
|
||||
// RFC-009 Phase 4: skip providers known-bad at build time. The runtime
|
||||
// walker re-checks pool membership per request, so a provider that
|
||||
// re-enters the pool later still gets used (graph rebuilt on
|
||||
// ModelConfigChangedEvent).
|
||||
if (providerPool != null && !providerPool.contains(p.getProviderId())) {
|
||||
log.debug("[LlmFailover] skipping provider {} — not in available pool",
|
||||
p.getProviderId());
|
||||
if (providerPool != null && !providerPool.contains(pid)) {
|
||||
log.debug("[LlmFailover] skipping provider {} — not in available pool", pid);
|
||||
continue;
|
||||
}
|
||||
ModelConfigEntity fallbackConfig = pickFallbackModel(p.getProviderId());
|
||||
ModelConfigEntity fallbackConfig = resolveChainModel(ref);
|
||||
if (fallbackConfig == null) {
|
||||
log.debug("[LlmFailover] skipping provider {} — no enabled chat model",
|
||||
p.getProviderId());
|
||||
log.debug("[LlmFailover] skipping {} — no usable chat model", pid);
|
||||
continue;
|
||||
}
|
||||
if (primaryModelName != null && primaryModelName.equals(fallbackConfig.getModelName())) {
|
||||
// Same model name picked for a different provider — exact same call, skip.
|
||||
String key = pid + "::" + fallbackConfig.getModelName();
|
||||
if (!seen.add(key)) {
|
||||
// Exact (provider, model) already queued or equal to the primary.
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
ChatModel m = buildRuntimeChatModel(fallbackConfig, RetryTemplate.builder().maxAttempts(1).build());
|
||||
chain.add(new vip.mate.llm.failover.FallbackEntry(p.getProviderId(), m));
|
||||
log.info("[LlmFailover] chain[{}] = {}/{} (priority={})",
|
||||
chain.size(), p.getProviderId(), fallbackConfig.getModelName(),
|
||||
p.getFallbackPriority());
|
||||
chain.add(new vip.mate.llm.failover.FallbackEntry(pid, m));
|
||||
log.info("[LlmFailover] chain[{}] = {}/{}", chain.size(), pid, fallbackConfig.getModelName());
|
||||
} catch (Exception e) {
|
||||
log.warn("[LlmFailover] skipping provider {} — chat model build failed: {}",
|
||||
p.getProviderId(), e.getMessage());
|
||||
log.warn("[LlmFailover] skipping provider {} — chat model build failed: {}", pid, e.getMessage());
|
||||
}
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a planned chain entry to a concrete chat model. A pinned model
|
||||
* ({@code modelId != null}) is used when it still exists and is enabled;
|
||||
* otherwise we fall back to the provider's default chat model so a deleted
|
||||
* or disabled pin keeps the provider in the chain.
|
||||
*/
|
||||
private ModelConfigEntity resolveChainModel(ProviderModelRef ref) {
|
||||
if (ref.modelId() != null) {
|
||||
try {
|
||||
ModelConfigEntity m = modelConfigService.getModel(ref.modelId());
|
||||
// Honour the pin only when it is a usable chat model that actually
|
||||
// belongs to this entry's provider. The FallbackEntry is keyed by
|
||||
// ref.providerId() for cooldown/pool, so a model from a different
|
||||
// provider would mis-key the chain; an embedding model would never
|
||||
// serve as a chat fallback. Either case falls back to the
|
||||
// provider's default chat model.
|
||||
if (m != null && Boolean.TRUE.equals(m.getEnabled())
|
||||
&& ref.providerId().equals(m.getProvider())
|
||||
&& (m.getModelType() == null || "chat".equals(m.getModelType()))) {
|
||||
return m;
|
||||
}
|
||||
log.info("[LlmFailover] pinned model {} for provider {} not usable "
|
||||
+ "(disabled / wrong provider / non-chat), using provider default",
|
||||
ref.modelId(), ref.providerId());
|
||||
} catch (Exception e) {
|
||||
log.info("[LlmFailover] pinned model {} for provider {} unresolved ({}), using provider default",
|
||||
ref.modelId(), ref.providerId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
return pickFallbackModel(ref.providerId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a chat model to use as a fallback for the given provider:
|
||||
* <ol>
|
||||
@ -1275,33 +1395,40 @@ public class AgentGraphBuilder {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder a provider list by an agent's preference list. Listed provider
|
||||
* ids come first in their preference order; any provider not in the
|
||||
* preference list keeps its original position relative to other unlisted
|
||||
* providers (stable partition). Preference entries that don't match any
|
||||
* actual provider are silently dropped.
|
||||
* Plan the fallback order as a list of (provider, model) refs.
|
||||
*
|
||||
* <p>Head: the agent's explicit preference entries in declared order,
|
||||
* model-granular — the same provider may appear more than once with
|
||||
* different models. Exact (provider, model) duplicates are dropped.
|
||||
*
|
||||
* <p>Tail: every provider not named in the preferences, in the supplied
|
||||
* global order, each using its default model ({@code modelId == null}).
|
||||
*
|
||||
* <p>Preference entries with a blank provider id are ignored. Package-private
|
||||
* for unit testing — see {@code AgentGraphBuilderPreferenceTest}.
|
||||
*/
|
||||
/** Package-private for unit testing — see {@code AgentGraphBuilderPreferenceTest}. */
|
||||
static List<ModelProviderEntity> reorderByPreferences(List<ModelProviderEntity> providers,
|
||||
List<String> preferredOrder) {
|
||||
Map<String, ModelProviderEntity> byId = new java.util.LinkedHashMap<>();
|
||||
for (ModelProviderEntity p : providers) {
|
||||
byId.put(p.getProviderId(), p);
|
||||
}
|
||||
List<ModelProviderEntity> reordered = new ArrayList<>(providers.size());
|
||||
Set<String> placed = new java.util.HashSet<>();
|
||||
for (String prefId : preferredOrder) {
|
||||
ModelProviderEntity p = byId.get(prefId);
|
||||
if (p != null && placed.add(prefId)) {
|
||||
reordered.add(p);
|
||||
static List<ProviderModelRef> planFallbackOrder(List<ProviderModelRef> preferred,
|
||||
List<String> globalProviderIds) {
|
||||
List<ProviderModelRef> plan = new ArrayList<>();
|
||||
Set<String> headEntryKeys = new java.util.HashSet<>();
|
||||
Set<String> headProviderIds = new java.util.HashSet<>();
|
||||
if (preferred != null) {
|
||||
for (ProviderModelRef ref : preferred) {
|
||||
if (ref == null || ref.providerId() == null || ref.providerId().isBlank()) continue;
|
||||
String key = ref.providerId() + "::" + (ref.modelId() == null ? "" : ref.modelId());
|
||||
if (!headEntryKeys.add(key)) continue; // exact (provider, model) dup
|
||||
plan.add(ref);
|
||||
headProviderIds.add(ref.providerId());
|
||||
}
|
||||
}
|
||||
for (ModelProviderEntity p : providers) {
|
||||
if (placed.add(p.getProviderId())) {
|
||||
reordered.add(p);
|
||||
if (globalProviderIds != null) {
|
||||
for (String pid : globalProviderIds) {
|
||||
if (pid == null || pid.isBlank()) continue;
|
||||
if (headProviderIds.contains(pid)) continue; // already led by an explicit entry
|
||||
plan.add(new ProviderModelRef(pid, null));
|
||||
}
|
||||
}
|
||||
return reordered;
|
||||
return plan;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1327,7 +1454,7 @@ public class AgentGraphBuilder {
|
||||
* @throws IllegalArgumentException when an absolute override escapes the
|
||||
* workspace root
|
||||
*/
|
||||
static String resolveAgentBasePath(String agentOverride, String workspaceBase) {
|
||||
public static String resolveAgentBasePath(String agentOverride, String workspaceBase) {
|
||||
boolean hasOverride = agentOverride != null && !agentOverride.isBlank();
|
||||
boolean hasWorkspace = workspaceBase != null && !workspaceBase.isBlank();
|
||||
if (!hasOverride) {
|
||||
@ -1347,6 +1474,15 @@ public class AgentGraphBuilder {
|
||||
return agentOverride;
|
||||
}
|
||||
if (hasWorkspace) {
|
||||
// Relative override resolves under the workspace root; reject any value
|
||||
// that escapes it via "../" so attachment/media/tool I/O stays contained.
|
||||
Path wsRoot = Paths.get(workspaceBase).toAbsolutePath().normalize();
|
||||
Path resolved = wsRoot.resolve(agentOverride).normalize();
|
||||
if (!resolved.startsWith(wsRoot)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Agent workspaceBasePath override must stay inside the workspace root: "
|
||||
+ resolved + " escapes " + wsRoot);
|
||||
}
|
||||
return Paths.get(workspaceBase).resolve(agentOverride).toString();
|
||||
}
|
||||
return agentOverride;
|
||||
@ -1393,6 +1529,10 @@ public class AgentGraphBuilder {
|
||||
""";
|
||||
|
||||
private String buildEnhancedPrompt(AgentEntity entity, boolean builtinSearchEnabled) {
|
||||
return buildEnhancedPrompt(entity, builtinSearchEnabled, Integer.MAX_VALUE);
|
||||
}
|
||||
|
||||
private String buildEnhancedPrompt(AgentEntity entity, boolean builtinSearchEnabled, int memoryBudgetTokens) {
|
||||
// The agent's own systemPrompt encodes its identity (role / goal /
|
||||
// backstory). The memory block from workspace files (AGENTS.md, SOUL.md,
|
||||
// PROFILE.md, MEMORY.md, ...) augments that identity with durable
|
||||
@ -1401,7 +1541,7 @@ public class AgentGraphBuilder {
|
||||
// dropped the identity prompt, so editor-side identity changes never
|
||||
// reached runtime if the agent had any workspace files.
|
||||
String identityPrompt = entity.getSystemPrompt() != null ? entity.getSystemPrompt().trim() : "";
|
||||
String memoryPrompt = memoryManager.buildSystemPromptBlock(entity.getId());
|
||||
String memoryPrompt = memoryManager.buildSystemPromptBlock(entity.getId(), memoryBudgetTokens);
|
||||
StringBuilder basePromptBuilder = new StringBuilder();
|
||||
if (!identityPrompt.isEmpty()) {
|
||||
basePromptBuilder.append(identityPrompt);
|
||||
@ -1480,10 +1620,11 @@ public class AgentGraphBuilder {
|
||||
adopting a KB article as the user's project.
|
||||
|
||||
## Session Search
|
||||
- `session_search(agentId, currentConversationId, mode, query, limit)` — search conversation history
|
||||
- `session_search(agentId, mode, query, limit)` — search conversation history
|
||||
- mode="recent": list recent conversations (titles, times, message counts)
|
||||
- mode="search": keyword full-text search across past messages
|
||||
- Use this to recall previous discussions, look up past decisions, or find context from earlier conversations
|
||||
- Only completed sessions (not currently running) are included in results
|
||||
|
||||
## Tool Usage Guidelines
|
||||
When you have available tools, use them to access local system information, files, or execute commands.
|
||||
|
||||
@ -1218,10 +1218,14 @@ public abstract class BaseAgent {
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析图片文件的绝对路径。
|
||||
* Resolve the absolute path of an image file.
|
||||
* <p>
|
||||
* 上传文件存储在 data/chat-uploads/ 下,是相对于 Spring Boot 工作目录的路径。
|
||||
* MCP 工具的工作目录可能不同,所以这里直接解析为绝对路径。
|
||||
* The storage location of uploaded files is resolved by
|
||||
* {@code ChatUploadLocationResolver} in priority order: the Agent's
|
||||
* workspaceBasePath → the Workspace's basePath → a configurable default
|
||||
* directory ({@code mateclaw.chat.upload.base-dir}, default
|
||||
* {@code data/chat-uploads}). An MCP tool's working directory may differ,
|
||||
* so this resolves directly to an absolute path.
|
||||
*/
|
||||
/**
|
||||
* 构建当前用户消息的 UserMessage(含 multimodal 图片注入)。
|
||||
|
||||
@ -6,6 +6,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import vip.mate.agent.AgentService;
|
||||
import vip.mate.agent.binding.model.AgentProviderPreference;
|
||||
import vip.mate.llm.routing.ProviderModelRef;
|
||||
import vip.mate.agent.binding.model.AgentSkillBinding;
|
||||
import vip.mate.agent.binding.model.AgentToolBinding;
|
||||
import vip.mate.agent.binding.model.AgentWikiKbBinding;
|
||||
@ -122,18 +123,18 @@ public class AgentBindingController {
|
||||
return R.ok(bindingService.listProviderPreferences(agentId));
|
||||
}
|
||||
|
||||
@Operation(summary = "批量设置 Agent 的偏好 Provider 顺序(替换模式)")
|
||||
@Operation(summary = "批量设置 Agent 的偏好模型链(供应商 + 模型,替换模式)")
|
||||
@PutMapping("/provider-preferences")
|
||||
@RequireWorkspaceRole("member")
|
||||
public R<Void> setProviderPreferences(
|
||||
@PathVariable Long agentId,
|
||||
@RequestBody List<String> providerIds,
|
||||
@RequestBody List<ProviderModelRef> preferences,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
verifyAgentWorkspace(agentId, workspaceId);
|
||||
bindingService.setProviderPreferences(agentId, providerIds);
|
||||
bindingService.setProviderModelPreferences(agentId, preferences);
|
||||
agentService.invalidateAgentCache(agentId);
|
||||
auditEventService.record("UPDATE", "AGENT_PROVIDER_PREF", String.valueOf(agentId),
|
||||
"providers=" + providerIds.size(), null);
|
||||
"entries=" + (preferences == null ? 0 : preferences.size()), null);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
|
||||
@ -29,6 +29,16 @@ public class AgentProviderPreference {
|
||||
/** Provider id (matches {@code mate_model_provider.provider_id}). */
|
||||
private String providerId;
|
||||
|
||||
/**
|
||||
* Specific chat model to pin for this entry (matches
|
||||
* {@code mate_model_config.id}). {@code null} means "use the provider's
|
||||
* default chat model" — backward compatible with provider-only
|
||||
* preferences. With this column the same {@code providerId} may appear
|
||||
* in multiple rows, each pinning a different model, forming a per-agent
|
||||
* preferred-model chain.
|
||||
*/
|
||||
private Long modelId;
|
||||
|
||||
/** Lower wins. Two rows with the same value tie-break on provider_id alphabetically. */
|
||||
private Integer sortOrder;
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -18,6 +18,7 @@ import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.agent.repository.AgentMapper;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.llm.routing.AgentBindingResolver;
|
||||
import vip.mate.llm.routing.ProviderModelRef;
|
||||
import vip.mate.skill.acp.AcpSkillBridge;
|
||||
import vip.mate.skill.mcp.McpSkillBridge;
|
||||
import vip.mate.skill.lifecycle.BlockedByBindingRow;
|
||||
@ -935,30 +936,33 @@ public class AgentBindingService implements AgentBindingResolver {
|
||||
* fallback chain order per agent.</p>
|
||||
*/
|
||||
@Override
|
||||
public List<String> getPreferredProviderIds(Long agentId) {
|
||||
public List<ProviderModelRef> getPreferredProviderModels(Long agentId) {
|
||||
if (agentId == null) return Collections.emptyList();
|
||||
return listProviderPreferences(agentId).stream()
|
||||
.filter(p -> Boolean.TRUE.equals(p.getEnabled()))
|
||||
.map(AgentProviderPreference::getProviderId)
|
||||
.map(p -> new ProviderModelRef(p.getProviderId(), p.getModelId()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the full preference list for an agent. {@code providerIds}
|
||||
* is the new ordered preference (index 0 = highest preference).
|
||||
* Empty / null list clears all preferences for the agent.
|
||||
* Replace the full preference list for an agent with (provider, model)
|
||||
* entries. {@code refs} is the new ordered preference (index 0 = highest);
|
||||
* a {@code modelId} of {@code null} pins the provider's default model. The
|
||||
* same provider may appear multiple times with different models, forming a
|
||||
* preferred-model chain. Empty / null list clears all preferences.
|
||||
*/
|
||||
public void setProviderPreferences(Long agentId, List<String> providerIds) {
|
||||
public void setProviderModelPreferences(Long agentId, List<ProviderModelRef> refs) {
|
||||
providerPreferenceMapper.delete(
|
||||
new LambdaQueryWrapper<AgentProviderPreference>()
|
||||
.eq(AgentProviderPreference::getAgentId, agentId));
|
||||
if (providerIds == null) return;
|
||||
if (refs == null) return;
|
||||
int order = 0;
|
||||
for (String providerId : providerIds) {
|
||||
if (providerId == null || providerId.isBlank()) continue;
|
||||
for (ProviderModelRef ref : refs) {
|
||||
if (ref == null || ref.providerId() == null || ref.providerId().isBlank()) continue;
|
||||
AgentProviderPreference row = new AgentProviderPreference();
|
||||
row.setAgentId(agentId);
|
||||
row.setProviderId(providerId.trim());
|
||||
row.setProviderId(ref.providerId().trim());
|
||||
row.setModelId(ref.modelId());
|
||||
row.setSortOrder(order++);
|
||||
row.setEnabled(true);
|
||||
providerPreferenceMapper.insert(row);
|
||||
|
||||
@ -66,7 +66,16 @@ public record ChatOrigin(
|
||||
* can still mint absolute download links. Null for IM/cron origins, which
|
||||
* have no request host; those rely on {@code mateclaw.server.public-base-url}.
|
||||
*/
|
||||
@Nullable String baseUrl
|
||||
@Nullable String baseUrl,
|
||||
/**
|
||||
* Immutable numeric id of the MateClaw user behind this request, when the
|
||||
* requester is an <em>authenticated</em> account (JWT/PAT login via the
|
||||
* web console). Null for non-account origins — webchat visitors, IM
|
||||
* senders, cron — which carry no MateClaw user row. On-behalf-of identity
|
||||
* forwarding uses this to tell "MateClaw authenticated this user" apart
|
||||
* from "this is an external/anonymous identifier" (RFC: identity typing).
|
||||
*/
|
||||
@Nullable Long requesterUserId
|
||||
) {
|
||||
|
||||
/** Key used when this origin is wrapped into a Spring AI {@link ToolContext}. */
|
||||
@ -74,7 +83,7 @@ public record ChatOrigin(
|
||||
|
||||
/** Sentinel used by AgentService default overloads where no origin is supplied. */
|
||||
public static final ChatOrigin EMPTY =
|
||||
new ChatOrigin(null, null, "", null, null, null, null, false, null, null, null, null);
|
||||
new ChatOrigin(null, null, "", null, null, null, null, false, null, null, null, null, null);
|
||||
|
||||
// ---------------- Factories per entry point ----------------
|
||||
|
||||
@ -90,9 +99,25 @@ public record ChatOrigin(
|
||||
@Nullable Long workspaceId,
|
||||
@Nullable String workspaceBasePath,
|
||||
@Nullable String baseUrl) {
|
||||
return web(conversationId, requesterId, workspaceId, workspaceBasePath, baseUrl, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Web-console origin that also carries the authenticated user's immutable
|
||||
* numeric id. Use this overload from the authenticated web entry point so
|
||||
* on-behalf-of identity forwarding can assert "MateClaw authenticated this
|
||||
* user" rather than an external/anonymous identifier.
|
||||
*/
|
||||
public static ChatOrigin web(@Nullable String conversationId,
|
||||
@Nullable String requesterId,
|
||||
@Nullable Long workspaceId,
|
||||
@Nullable String workspaceBasePath,
|
||||
@Nullable String baseUrl,
|
||||
@Nullable Long requesterUserId) {
|
||||
return new ChatOrigin(null, conversationId,
|
||||
requesterId != null ? requesterId : "",
|
||||
workspaceId, workspaceBasePath, null, null, false, null, "web", null, baseUrl);
|
||||
workspaceId, workspaceBasePath, null, null, false, null, "web", null, baseUrl,
|
||||
requesterUserId);
|
||||
}
|
||||
|
||||
public static ChatOrigin cron(@Nullable String conversationId,
|
||||
@ -101,7 +126,7 @@ public record ChatOrigin(
|
||||
@Nullable Long channelId,
|
||||
@Nullable ChannelTarget target) {
|
||||
return new ChatOrigin(null, conversationId, "system",
|
||||
workspaceId, workspaceBasePath, channelId, target, true, null, null, null, null);
|
||||
workspaceId, workspaceBasePath, channelId, target, true, null, null, null, null, null);
|
||||
}
|
||||
|
||||
// ---------------- Wither-style updates ----------------
|
||||
@ -109,27 +134,27 @@ public record ChatOrigin(
|
||||
public ChatOrigin withAgent(@Nullable Long newAgentId) {
|
||||
return new ChatOrigin(newAgentId, conversationId, requesterId,
|
||||
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||
senderName, channelType, chatId, baseUrl);
|
||||
senderName, channelType, chatId, baseUrl, requesterUserId);
|
||||
}
|
||||
|
||||
public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId,
|
||||
@Nullable String newWorkspaceBasePath) {
|
||||
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||
newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||
senderName, channelType, chatId, baseUrl);
|
||||
senderName, channelType, chatId, baseUrl, requesterUserId);
|
||||
}
|
||||
|
||||
public ChatOrigin withConversationId(@Nullable String newConversationId) {
|
||||
return new ChatOrigin(agentId, newConversationId, requesterId,
|
||||
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||
senderName, channelType, chatId, baseUrl);
|
||||
senderName, channelType, chatId, baseUrl, requesterUserId);
|
||||
}
|
||||
|
||||
/** Carry a request-derived public base URL (see {@link #baseUrl()}). */
|
||||
public ChatOrigin withBaseUrl(@Nullable String newBaseUrl) {
|
||||
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||
senderName, channelType, chatId, newBaseUrl);
|
||||
senderName, channelType, chatId, newBaseUrl, requesterUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -143,7 +168,7 @@ public record ChatOrigin(
|
||||
@Nullable String newChatId) {
|
||||
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||
newSenderName, newChannelType, newChatId, baseUrl);
|
||||
newSenderName, newChannelType, newChatId, baseUrl, requesterUserId);
|
||||
}
|
||||
|
||||
// ---------------- Spring AI ToolContext interop ----------------
|
||||
|
||||
@ -3,6 +3,7 @@ package vip.mate.agent.context;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.ai.chat.messages.SystemMessage;
|
||||
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||
@ -118,6 +119,18 @@ public class ConversationWindowManager {
|
||||
private final MemoryManager memoryManager;
|
||||
private final ConversationService conversationService;
|
||||
|
||||
/**
|
||||
* Optional — adaptive compaction trigger for small context windows.
|
||||
* Setter-injected so the many direct test constructions keep the plain
|
||||
* configured ratio (null → previous behavior).
|
||||
*/
|
||||
private PrefixBudgetPlanner prefixBudgetPlanner;
|
||||
|
||||
@Autowired(required = false)
|
||||
public void setPrefixBudgetPlanner(PrefixBudgetPlanner prefixBudgetPlanner) {
|
||||
this.prefixBudgetPlanner = prefixBudgetPlanner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional spill store, injected via setter so unit tests and the two
|
||||
* existing 3-arg constructor callers in tests stay source-compatible.
|
||||
@ -251,7 +264,12 @@ public class ConversationWindowManager {
|
||||
|
||||
int effectiveMax = (maxInputTokens != null && maxInputTokens > 0)
|
||||
? maxInputTokens : properties.getDefaultMaxInputTokens();
|
||||
int triggerThreshold = (int) (effectiveMax * properties.getCompactTriggerRatio());
|
||||
// Small windows compact later (higher trigger ratio): summarizing at
|
||||
// 75% of an 8k window throws away room it cannot afford to lose.
|
||||
double triggerRatio = prefixBudgetPlanner != null
|
||||
? prefixBudgetPlanner.compactTriggerRatioFor(effectiveMax, properties.getCompactTriggerRatio())
|
||||
: properties.getCompactTriggerRatio();
|
||||
int triggerThreshold = (int) (effectiveMax * triggerRatio);
|
||||
|
||||
int systemTokens = TokenEstimator.estimateTokens(systemPrompt);
|
||||
int currentMsgTokens = TokenEstimator.estimateTokens(currentUserMessage) + TokenEstimator.PER_MESSAGE_OVERHEAD;
|
||||
@ -1009,6 +1027,33 @@ public class ConversationWindowManager {
|
||||
+ "' can be called again if its result is needed.]";
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line informative summary for a cleared tool result: tool name,
|
||||
* original size, and the first line as a gist. Far more useful to the
|
||||
* model than a bare "removed" marker — it can decide whether re-running
|
||||
* the tool is worth it without guessing what the output was.
|
||||
*/
|
||||
static String buildInformativeCleared(String toolName, String body) {
|
||||
String safeName = (toolName == null || toolName.isBlank()) ? "tool" : toolName;
|
||||
int length = body == null ? 0 : body.length();
|
||||
String gist = "";
|
||||
if (body != null) {
|
||||
for (String line : body.split("\n", 8)) {
|
||||
String candidate = line.strip();
|
||||
if (!candidate.isEmpty()) {
|
||||
gist = candidate.length() > 80 ? candidate.substring(0, 80) + "…" : candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
StringBuilder sb = new StringBuilder("[").append(safeName)
|
||||
.append(" → ").append(length).append(" chars, cleared to save context");
|
||||
if (!gist.isEmpty()) {
|
||||
sb.append("; began: \"").append(gist).append('"');
|
||||
}
|
||||
return sb.append("; call the tool again if the result is still needed]").toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 1 - Soft trim:对工具结果做 head+tail 裁剪(保留首尾各 200 字符)。
|
||||
* <p>Spill-marker responses are left untouched so their on-disk pointer
|
||||
@ -1063,7 +1108,8 @@ public class ConversationWindowManager {
|
||||
replaced.add(r);
|
||||
continue;
|
||||
}
|
||||
replaced.add(new ToolResponseMessage.ToolResponse(r.id(), r.name(), "[tool result removed]"));
|
||||
replaced.add(new ToolResponseMessage.ToolResponse(r.id(), r.name(),
|
||||
buildInformativeCleared(r.name(), r.responseData())));
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -25,6 +25,7 @@ import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* 节点级流式 LLM 调用辅助
|
||||
@ -162,6 +163,19 @@ public class NodeStreamingChatHelper {
|
||||
this.providerPool = providerPool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional hook fired with the raw error chain whenever the PRIMARY model
|
||||
* rejects a call for exceeding its context window. Lets the caller feed
|
||||
* the server-reported limit back into the context-window resolver so the
|
||||
* next turn budgets against the model's true window. Fallback-model
|
||||
* rejections are not reported — they belong to a different model.
|
||||
*/
|
||||
private Consumer<String> contextLimitObserver;
|
||||
|
||||
public void setContextLimitObserver(Consumer<String> observer) {
|
||||
this.contextLimitObserver = observer;
|
||||
}
|
||||
|
||||
private static List<vip.mate.llm.failover.FallbackEntry> wrap(ChatModel m) {
|
||||
// Legacy single-fallback path: providerId is unknown so health tracking
|
||||
// is silently disabled for that one entry (it gets a synthetic id).
|
||||
@ -585,6 +599,15 @@ public class NodeStreamingChatHelper {
|
||||
private StreamResult streamCallInternal(ChatModel chatModel, Prompt prompt,
|
||||
String conversationId, String phase,
|
||||
boolean broadcast) {
|
||||
// Normalize every assistant tool call in the outgoing history to valid
|
||||
// JSON arguments. The streaming aggregator already does this for the
|
||||
// current turn's calls, but tool calls replayed from persisted history
|
||||
// (e.g. an earlier MCP tool call with empty arguments, or messages
|
||||
// stored by an older build) bypass that path. Strict providers reject
|
||||
// the whole request with HTTP 400 when any function.arguments is not
|
||||
// parseable JSON, so harmonize them here at the single send chokepoint.
|
||||
prompt = normalizeToolCallArguments(prompt);
|
||||
|
||||
// 在开始 LLM 调用前检查停止标志
|
||||
if (streamTracker.isStopRequested(conversationId)) {
|
||||
log.info("[{}] Stop requested before LLM call, aborting: conversationId={}", phase, conversationId);
|
||||
@ -632,7 +655,7 @@ public class NodeStreamingChatHelper {
|
||||
}
|
||||
llmCallCount++;
|
||||
if (attempt > 0) retryCount++;
|
||||
lastResult = doStreamCall(chatModel, prompt, conversationId, phase, broadcast, attempt);
|
||||
lastResult = doStreamCall(chatModel, prompt, conversationId, phase, broadcast, attempt, true);
|
||||
if (lastResult != null) {
|
||||
// PTL: 不重试,直接返回给上层 Node 处理
|
||||
if (lastResult.errorType() == ErrorType.PROMPT_TOO_LONG) {
|
||||
@ -774,7 +797,7 @@ public class NodeStreamingChatHelper {
|
||||
failoverCount++;
|
||||
llmCallCount++;
|
||||
StreamResult fallbackResult = doStreamCall(fallback, prompt, conversationId,
|
||||
phase + "_fallback_" + (i + 1), broadcast, 0);
|
||||
phase + "_fallback_" + (i + 1), broadcast, 0, false);
|
||||
// Accept only fully successful fallbacks. Non-successful results (auth
|
||||
// error, client error, still-rate-limited) propagate to the next
|
||||
// fallback instead of being surfaced as the final result.
|
||||
@ -821,7 +844,7 @@ public class NodeStreamingChatHelper {
|
||||
*/
|
||||
private StreamResult doStreamCall(ChatModel chatModel, Prompt prompt,
|
||||
String conversationId, String phase,
|
||||
boolean broadcast, int attempt) {
|
||||
boolean broadcast, int attempt, boolean primaryCall) {
|
||||
// Collapse every SystemMessage in the prompt into a single SystemMessage
|
||||
// at index 0. Some OpenAI-compatible providers (LM Studio's built-in
|
||||
// server, certain strict vLLM / SGLang deployments) reject 400
|
||||
@ -874,7 +897,7 @@ public class NodeStreamingChatHelper {
|
||||
}
|
||||
|
||||
try {
|
||||
return doStreamCallInner(chatModel, outbound, conversationId, phase, broadcast, attempt);
|
||||
return doStreamCallInner(chatModel, outbound, conversationId, phase, broadcast, attempt, primaryCall);
|
||||
} finally {
|
||||
// Idempotent: if consumer already took the entry, discard is a no-op.
|
||||
if (relayToken != null) {
|
||||
@ -906,7 +929,7 @@ public class NodeStreamingChatHelper {
|
||||
|
||||
private StreamResult doStreamCallInner(ChatModel chatModel, Prompt prompt,
|
||||
String conversationId, String phase,
|
||||
boolean broadcast, int attempt) {
|
||||
boolean broadcast, int attempt, boolean primaryCall) {
|
||||
if (attempt > 0) {
|
||||
long delay = Math.min(backoffBaseMs * (1L << (attempt - 1)), backoffCapMs);
|
||||
// 加入 jitter 防止雷群效应
|
||||
@ -945,9 +968,10 @@ public class NodeStreamingChatHelper {
|
||||
AtomicReference<Throwable> errorRef = new AtomicReference<>();
|
||||
AtomicInteger promptTokens = new AtomicInteger(0);
|
||||
AtomicInteger completionTokens = new AtomicInteger(0);
|
||||
// RFC-014: Anthropic prompt cache 计数(其它 provider 永远为 0)
|
||||
// Prompt cache / reasoning counters; providers that don't report them stay 0.
|
||||
AtomicInteger cacheReadTokens = new AtomicInteger(0);
|
||||
AtomicInteger cacheWriteTokens = new AtomicInteger(0);
|
||||
AtomicInteger reasoningTokens = new AtomicInteger(0);
|
||||
|
||||
// thinking-only soft cap 触发后设为 true,外层轮询线程据此 dispose 订阅。
|
||||
// 注意:内容流的字符级 / 句子级重复检测已整体移除(设计取舍:
|
||||
@ -1133,10 +1157,12 @@ public class NodeStreamingChatHelper {
|
||||
if (usage.getCompletionTokens() != null && usage.getCompletionTokens() > 0) {
|
||||
completionTokens.set(usage.getCompletionTokens().intValue());
|
||||
}
|
||||
// RFC-014: 反射抽取 Anthropic prompt cache 字段(DashScope/OpenAI 自然返回 0)
|
||||
// Reflective extraction of provider-native cache / reasoning
|
||||
// counters (Anthropic / OpenAI-compatible / DashScope).
|
||||
var cache = vip.mate.llm.cache.CacheUsageExtractor.extract(usage);
|
||||
if (cache.cacheReadTokens() > 0) cacheReadTokens.set(cache.cacheReadTokens());
|
||||
if (cache.cacheWriteTokens() > 0) cacheWriteTokens.set(cache.cacheWriteTokens());
|
||||
if (cache.reasoningTokens() > 0) reasoningTokens.set(cache.reasoningTokens());
|
||||
}
|
||||
})
|
||||
.subscribe(
|
||||
@ -1188,7 +1214,8 @@ public class NodeStreamingChatHelper {
|
||||
toolCallAccumulators.size(), conversationId);
|
||||
return assembleStoppedResult(contentAccum, thinkingAccum, toolCallAccumulators,
|
||||
promptTokens.get(), completionTokens.get(),
|
||||
cacheReadTokens.get(), cacheWriteTokens.get(), phase);
|
||||
cacheReadTokens.get(), cacheWriteTokens.get(),
|
||||
reasoningTokens.get(), phase);
|
||||
}
|
||||
log.info("[{}] Stop requested during LLM call, no content accumulated, aborting: conversationId={}",
|
||||
phase, conversationId);
|
||||
@ -1222,6 +1249,7 @@ public class NodeStreamingChatHelper {
|
||||
return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators,
|
||||
promptTokens.get(), completionTokens.get(),
|
||||
cacheReadTokens.get(), cacheWriteTokens.get(),
|
||||
reasoningTokens.get(),
|
||||
phase, true, error.getMessage());
|
||||
}
|
||||
|
||||
@ -1232,6 +1260,17 @@ public class NodeStreamingChatHelper {
|
||||
if (errorType == ErrorType.PROMPT_TOO_LONG) {
|
||||
log.warn("[{}] Prompt too long error, returning to node for compaction: {}",
|
||||
phase, error.getMessage());
|
||||
// Teach the context-window resolver the server-reported limit so
|
||||
// the next turn budgets against the model's true window. Raw
|
||||
// chain (incl. response body) — the friendly text may drop the
|
||||
// numbers. Primary model only; fallbacks are different models.
|
||||
if (primaryCall && contextLimitObserver != null) {
|
||||
try {
|
||||
contextLimitObserver.accept(extractFullErrorChain(error));
|
||||
} catch (Exception observerError) {
|
||||
log.debug("context-limit observer failed: {}", observerError.getMessage());
|
||||
}
|
||||
}
|
||||
return buildErrorResultWithType("Prompt 过长: " + extractUserFriendlyError(error),
|
||||
conversationId, phase, errorType);
|
||||
}
|
||||
@ -1310,7 +1349,8 @@ public class NodeStreamingChatHelper {
|
||||
: null;
|
||||
return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators,
|
||||
promptTokens.get(), completionTokens.get(),
|
||||
cacheReadTokens.get(), cacheWriteTokens.get(), phase,
|
||||
cacheReadTokens.get(), cacheWriteTokens.get(),
|
||||
reasoningTokens.get(), phase,
|
||||
truncated,
|
||||
truncationReason);
|
||||
}
|
||||
@ -1319,7 +1359,8 @@ public class NodeStreamingChatHelper {
|
||||
private StreamResult assembleStoppedResult(StringBuilder contentAccum, StringBuilder thinkingAccum,
|
||||
List<ToolCallAccumulator> toolCallAccumulators,
|
||||
int promptTok, int completionTok,
|
||||
int cacheReadTok, int cacheWriteTok, String phase) {
|
||||
int cacheReadTok, int cacheWriteTok,
|
||||
int reasoningTok, String phase) {
|
||||
List<AssistantMessage.ToolCall> finalToolCalls = buildFinalToolCalls(toolCallAccumulators);
|
||||
String fullContent = contentAccum.toString();
|
||||
String fullThinking = thinkingAccum.toString();
|
||||
@ -1342,14 +1383,14 @@ public class NodeStreamingChatHelper {
|
||||
recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok);
|
||||
return new StreamResult(fullContent, fullThinking, assembledMessage,
|
||||
finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok,
|
||||
true, null, ErrorType.NONE, true, cacheReadTok, cacheWriteTok);
|
||||
true, null, ErrorType.NONE, true, cacheReadTok, cacheWriteTok, reasoningTok);
|
||||
}
|
||||
|
||||
/** 组装最终 StreamResult(成功或 partial) */
|
||||
private StreamResult assembleResult(StringBuilder contentAccum, StringBuilder thinkingAccum,
|
||||
List<ToolCallAccumulator> toolCallAccumulators,
|
||||
int promptTok, int completionTok,
|
||||
int cacheReadTok, int cacheWriteTok,
|
||||
int cacheReadTok, int cacheWriteTok, int reasoningTok,
|
||||
String phase, boolean partial, String errorMsg) {
|
||||
List<AssistantMessage.ToolCall> finalToolCalls = buildFinalToolCalls(toolCallAccumulators);
|
||||
String fullContent = contentAccum.toString();
|
||||
@ -1375,7 +1416,7 @@ public class NodeStreamingChatHelper {
|
||||
recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok);
|
||||
return new StreamResult(fullContent, fullThinking, assembledMessage,
|
||||
finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok,
|
||||
partial, errorMsg, ErrorType.NONE, false, cacheReadTok, cacheWriteTok);
|
||||
partial, errorMsg, ErrorType.NONE, false, cacheReadTok, cacheWriteTok, reasoningTok);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1539,6 +1580,66 @@ public class NodeStreamingChatHelper {
|
||||
return new Prompt(new ArrayList<>(messages.subList(0, end)), prompt.getOptions());
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild any {@link AssistantMessage} whose tool calls carry blank or
|
||||
* non-JSON {@code function.arguments} so the entire outgoing prompt stays
|
||||
* acceptable to strict OpenAI-compatible providers (e.g. aliyun-codingplan,
|
||||
* which 400s the whole request otherwise). Messages with no tool calls, or
|
||||
* whose tool-call arguments are already valid JSON, pass through untouched —
|
||||
* preserving content, metadata, and media. Returns the input unchanged when
|
||||
* nothing needs fixing.
|
||||
*/
|
||||
static Prompt normalizeToolCallArguments(Prompt prompt) {
|
||||
if (prompt == null) {
|
||||
return null;
|
||||
}
|
||||
List<Message> messages = prompt.getInstructions();
|
||||
if (messages == null || messages.isEmpty()) {
|
||||
return prompt;
|
||||
}
|
||||
List<Message> rebuilt = null;
|
||||
for (int i = 0; i < messages.size(); i++) {
|
||||
Message m = messages.get(i);
|
||||
if (!(m instanceof AssistantMessage am)
|
||||
|| am.getToolCalls() == null || am.getToolCalls().isEmpty()) {
|
||||
if (rebuilt != null) rebuilt.add(m);
|
||||
continue;
|
||||
}
|
||||
List<AssistantMessage.ToolCall> fixedCalls = null;
|
||||
List<AssistantMessage.ToolCall> calls = am.getToolCalls();
|
||||
for (int j = 0; j < calls.size(); j++) {
|
||||
AssistantMessage.ToolCall tc = calls.get(j);
|
||||
String safe = sanitizeToolCallArguments(tc.name(), tc.arguments());
|
||||
if (!safe.equals(tc.arguments())) {
|
||||
if (fixedCalls == null) fixedCalls = new ArrayList<>(calls);
|
||||
fixedCalls.set(j, new AssistantMessage.ToolCall(tc.id(), tc.type(), tc.name(), safe));
|
||||
}
|
||||
}
|
||||
if (fixedCalls == null) {
|
||||
if (rebuilt != null) rebuilt.add(m);
|
||||
continue;
|
||||
}
|
||||
if (rebuilt == null) {
|
||||
rebuilt = new ArrayList<>(messages.subList(0, i));
|
||||
}
|
||||
AssistantMessage.Builder builder = AssistantMessage.builder()
|
||||
.content(am.getText() == null ? "" : am.getText())
|
||||
.toolCalls(fixedCalls);
|
||||
if (am.getMetadata() != null && !am.getMetadata().isEmpty()) {
|
||||
builder.properties(am.getMetadata());
|
||||
}
|
||||
if (am.getMedia() != null && !am.getMedia().isEmpty()) {
|
||||
builder.media(am.getMedia());
|
||||
}
|
||||
rebuilt.add(builder.build());
|
||||
}
|
||||
if (rebuilt == null) {
|
||||
return prompt;
|
||||
}
|
||||
log.debug("[normalizeToolCallArguments] normalized non-JSON tool-call arguments in outgoing prompt");
|
||||
return new Prompt(rebuilt, prompt.getOptions());
|
||||
}
|
||||
|
||||
private StreamResult buildErrorResult(String errorMsg, String conversationId, String phase) {
|
||||
log.error("[{}] Building error result for conversation {}: {}", phase, conversationId, errorMsg);
|
||||
if (streamTracker != null && conversationId != null) {
|
||||
@ -1753,17 +1854,19 @@ public class NodeStreamingChatHelper {
|
||||
ErrorType errorType,
|
||||
/** 用户主动停止(stopRequested)导致的提前返回 */
|
||||
boolean stopped,
|
||||
/** RFC-014: Anthropic prompt cache 命中字节数(其它 provider 为 0) */
|
||||
/** Prompt cache 命中 tokens(provider 未上报时为 0) */
|
||||
int cacheReadTokens,
|
||||
/** RFC-014: Anthropic prompt cache 写入字节数(其它 provider 为 0) */
|
||||
int cacheWriteTokens
|
||||
/** Prompt cache 写入 tokens(provider 未上报时为 0) */
|
||||
int cacheWriteTokens,
|
||||
/** 思考(reasoning)阶段消耗的 completion tokens(provider 未上报时为 0) */
|
||||
int reasoningTokens
|
||||
) {
|
||||
/** 兼容旧调用方 — 无 partial/error/stopped 的正常结果 */
|
||||
public StreamResult(String text, String thinking, AssistantMessage assistantMessage,
|
||||
List<AssistantMessage.ToolCall> toolCalls, boolean hasToolCalls,
|
||||
int promptTokens, int completionTokens) {
|
||||
this(text, thinking, assistantMessage, toolCalls, hasToolCalls,
|
||||
promptTokens, completionTokens, false, null, ErrorType.NONE, false, 0, 0);
|
||||
promptTokens, completionTokens, false, null, ErrorType.NONE, false, 0, 0, 0);
|
||||
}
|
||||
|
||||
/** 兼容 10-arg 调用点 */
|
||||
@ -1772,17 +1875,17 @@ public class NodeStreamingChatHelper {
|
||||
int promptTokens, int completionTokens,
|
||||
boolean partial, String errorMessage, ErrorType errorType) {
|
||||
this(text, thinking, assistantMessage, toolCalls, hasToolCalls,
|
||||
promptTokens, completionTokens, partial, errorMessage, errorType, false, 0, 0);
|
||||
promptTokens, completionTokens, partial, errorMessage, errorType, false, 0, 0, 0);
|
||||
}
|
||||
|
||||
/** 兼容 12-arg 调用点(pre-RFC-014) */
|
||||
/** 兼容 11-arg 调用点(无 cache/reasoning 计数) */
|
||||
public StreamResult(String text, String thinking, AssistantMessage assistantMessage,
|
||||
List<AssistantMessage.ToolCall> toolCalls, boolean hasToolCalls,
|
||||
int promptTokens, int completionTokens,
|
||||
boolean partial, String errorMessage, ErrorType errorType,
|
||||
boolean stopped) {
|
||||
this(text, thinking, assistantMessage, toolCalls, hasToolCalls,
|
||||
promptTokens, completionTokens, partial, errorMessage, errorType, stopped, 0, 0);
|
||||
promptTokens, completionTokens, partial, errorMessage, errorType, stopped, 0, 0, 0);
|
||||
}
|
||||
|
||||
/** 是否有不可忽略的错误(无内容 + 有错误) */
|
||||
|
||||
@ -13,6 +13,7 @@ import reactor.core.publisher.Mono;
|
||||
import vip.mate.agent.AgentService;
|
||||
import vip.mate.agent.AgentState;
|
||||
import vip.mate.agent.BaseAgent;
|
||||
import vip.mate.agent.delegation.DelegatedUsageAccumulator;
|
||||
import vip.mate.agent.GraphEventPublisher;
|
||||
import vip.mate.agent.StructuredStreamCapable;
|
||||
import vip.mate.agent.context.ConversationWindowManager;
|
||||
@ -197,6 +198,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
AtomicInteger sentEventCount = new AtomicInteger(0);
|
||||
AtomicInteger finalPromptTokens = new AtomicInteger(0);
|
||||
AtomicInteger finalCompletionTokens = new AtomicInteger(0);
|
||||
AtomicInteger finalCacheReadTokens = new AtomicInteger(0);
|
||||
AtomicInteger finalCacheWriteTokens = new AtomicInteger(0);
|
||||
AtomicInteger finalReasoningTokens = new AtomicInteger(0);
|
||||
AtomicReference<String> finalModelName = new AtomicReference<>("");
|
||||
AtomicReference<String> finalProviderId = new AtomicReference<>("");
|
||||
// 防重保护:同 chatStructuredStream
|
||||
@ -268,6 +272,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
|
||||
finalPromptTokens.set(output.state().value(PROMPT_TOKENS, 0));
|
||||
finalCompletionTokens.set(output.state().value(COMPLETION_TOKENS, 0));
|
||||
finalCacheReadTokens.set(output.state().value(CACHE_READ_TOKENS, 0));
|
||||
finalCacheWriteTokens.set(output.state().value(CACHE_WRITE_TOKENS, 0));
|
||||
finalReasoningTokens.set(output.state().value(REASONING_TOKENS, 0));
|
||||
finalModelName.set(output.state().value(RUNTIME_MODEL_NAME, ""));
|
||||
finalProviderId.set(output.state().value(RUNTIME_PROVIDER_ID, ""));
|
||||
|
||||
@ -282,10 +289,21 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
return deltas;
|
||||
})
|
||||
.concatWith(Mono.fromSupplier(() -> {
|
||||
if (finalPromptTokens.get() > 0 || finalCompletionTokens.get() > 0) {
|
||||
DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance();
|
||||
DelegatedUsageAccumulator.Drained delegated = acc != null
|
||||
? acc.drain(conversationId)
|
||||
: new DelegatedUsageAccumulator.Drained(0, 0);
|
||||
long promptTokens = finalPromptTokens.get() + delegated.promptTokens();
|
||||
long completionTokens = finalCompletionTokens.get() + delegated.completionTokens();
|
||||
if (promptTokens > 0 || completionTokens > 0) {
|
||||
return AgentService.StreamDelta.event("_usage_final", Map.of(
|
||||
"promptTokens", finalPromptTokens.get(),
|
||||
"completionTokens", finalCompletionTokens.get(),
|
||||
"promptTokens", promptTokens,
|
||||
"completionTokens", completionTokens,
|
||||
"delegatedPromptTokens", delegated.promptTokens(),
|
||||
"delegatedCompletionTokens", delegated.completionTokens(),
|
||||
"cacheReadTokens", finalCacheReadTokens.get(),
|
||||
"cacheWriteTokens", finalCacheWriteTokens.get(),
|
||||
"reasoningTokens", finalReasoningTokens.get(),
|
||||
"runtimeModelName", finalModelName.get(),
|
||||
"runtimeProviderId", finalProviderId.get()
|
||||
));
|
||||
@ -304,6 +322,12 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
.doOnError(e -> {
|
||||
log.error("[{}] StateGraph replay stream error: {}", agentName, e.getMessage());
|
||||
setState(AgentState.ERROR);
|
||||
})
|
||||
// Leak guard: discard delegated usage if the turn ends without
|
||||
// emitting _usage_final (error / cancel).
|
||||
.doFinally(sig -> {
|
||||
DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance();
|
||||
if (acc != null) acc.clear(conversationId);
|
||||
});
|
||||
} catch (Exception e) {
|
||||
setState(AgentState.ERROR);
|
||||
@ -333,6 +357,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
// Token usage 追踪(每次 NodeOutput 更新最新累计值,最后一次即最终值)
|
||||
AtomicInteger finalPromptTokens = new AtomicInteger(0);
|
||||
AtomicInteger finalCompletionTokens = new AtomicInteger(0);
|
||||
AtomicInteger finalCacheReadTokens = new AtomicInteger(0);
|
||||
AtomicInteger finalCacheWriteTokens = new AtomicInteger(0);
|
||||
AtomicInteger finalReasoningTokens = new AtomicInteger(0);
|
||||
AtomicReference<String> finalModelName = new AtomicReference<>("");
|
||||
AtomicReference<String> finalProviderId = new AtomicReference<>("");
|
||||
// 防重保护:StateGraph 对每个节点都 emit NodeOutput,FINAL_ANSWER 一旦写入后续节点都携带,
|
||||
@ -418,6 +445,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
// 3. 更新最新累计 token usage
|
||||
finalPromptTokens.set(output.state().value(PROMPT_TOKENS, 0));
|
||||
finalCompletionTokens.set(output.state().value(COMPLETION_TOKENS, 0));
|
||||
finalCacheReadTokens.set(output.state().value(CACHE_READ_TOKENS, 0));
|
||||
finalCacheWriteTokens.set(output.state().value(CACHE_WRITE_TOKENS, 0));
|
||||
finalReasoningTokens.set(output.state().value(REASONING_TOKENS, 0));
|
||||
finalModelName.set(output.state().value(RUNTIME_MODEL_NAME, ""));
|
||||
finalProviderId.set(output.state().value(RUNTIME_PROVIDER_ID, ""));
|
||||
|
||||
@ -434,10 +464,21 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
})
|
||||
// 流正常完成后追加内部 usage 事件
|
||||
.concatWith(Mono.fromSupplier(() -> {
|
||||
if (finalPromptTokens.get() > 0 || finalCompletionTokens.get() > 0) {
|
||||
DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance();
|
||||
DelegatedUsageAccumulator.Drained delegated = acc != null
|
||||
? acc.drain(conversationId)
|
||||
: new DelegatedUsageAccumulator.Drained(0, 0);
|
||||
long promptTokens = finalPromptTokens.get() + delegated.promptTokens();
|
||||
long completionTokens = finalCompletionTokens.get() + delegated.completionTokens();
|
||||
if (promptTokens > 0 || completionTokens > 0) {
|
||||
return AgentService.StreamDelta.event("_usage_final", Map.of(
|
||||
"promptTokens", finalPromptTokens.get(),
|
||||
"completionTokens", finalCompletionTokens.get(),
|
||||
"promptTokens", promptTokens,
|
||||
"completionTokens", completionTokens,
|
||||
"delegatedPromptTokens", delegated.promptTokens(),
|
||||
"delegatedCompletionTokens", delegated.completionTokens(),
|
||||
"cacheReadTokens", finalCacheReadTokens.get(),
|
||||
"cacheWriteTokens", finalCacheWriteTokens.get(),
|
||||
"reasoningTokens", finalReasoningTokens.get(),
|
||||
"runtimeModelName", finalModelName.get(),
|
||||
"runtimeProviderId", finalProviderId.get()
|
||||
));
|
||||
@ -457,6 +498,12 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
.doOnError(e -> {
|
||||
log.error("[{}] StateGraph structured stream error: {}", agentName, e.getMessage());
|
||||
setState(AgentState.ERROR);
|
||||
})
|
||||
// Leak guard: discard delegated usage if the turn ends without
|
||||
// emitting _usage_final (error / cancel).
|
||||
.doFinally(sig -> {
|
||||
DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance();
|
||||
if (acc != null) acc.clear(conversationId);
|
||||
});
|
||||
} catch (Exception e) {
|
||||
setState(AgentState.ERROR);
|
||||
@ -522,6 +569,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
inputs.put(FORCED_TOOL_CALL, "");
|
||||
inputs.put(PROMPT_TOKENS, 0);
|
||||
inputs.put(COMPLETION_TOKENS, 0);
|
||||
inputs.put(CACHE_READ_TOKENS, 0);
|
||||
inputs.put(CACHE_WRITE_TOKENS, 0);
|
||||
inputs.put(REASONING_TOKENS, 0);
|
||||
inputs.put(RUNTIME_MODEL_NAME, modelName != null ? modelName : "");
|
||||
inputs.put(RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
|
||||
inputs.put(TRACE_ID, UUID.randomUUID().toString().substring(0, 8));
|
||||
|
||||
@ -7,6 +7,7 @@ import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||
import org.springframework.ai.chat.model.ToolContext;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import vip.mate.tool.builtin.ToolExecutionContext;
|
||||
import vip.mate.tool.disclosure.ToolUsageRecencyTracker;
|
||||
import vip.mate.agent.AgentToolSet;
|
||||
import vip.mate.agent.GraphEventPublisher;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
@ -251,6 +252,13 @@ public class ToolExecutionExecutor {
|
||||
*/
|
||||
private vip.mate.skill.runtime.SkillRuntimeService skillRuntimeService;
|
||||
|
||||
/** Optional recency feed for budget-driven tool-disclosure demotion. */
|
||||
private ToolUsageRecencyTracker usageRecencyTracker;
|
||||
|
||||
public void setUsageRecencyTracker(ToolUsageRecencyTracker tracker) {
|
||||
this.usageRecencyTracker = tracker;
|
||||
}
|
||||
|
||||
public void setSkillRuntimeService(vip.mate.skill.runtime.SkillRuntimeService s) {
|
||||
this.skillRuntimeService = s;
|
||||
}
|
||||
@ -885,6 +893,12 @@ public class ToolExecutionExecutor {
|
||||
ToolExecutionContext.clear();
|
||||
}
|
||||
|
||||
// Recency feed for budget-driven disclosure demotion: recently used
|
||||
// tools keep their advertised schema, never-used ones demote first.
|
||||
if (usageRecencyTracker != null) {
|
||||
usageRecencyTracker.recordUse(toolName);
|
||||
}
|
||||
|
||||
int rawLen = result != null ? result.length() : 0;
|
||||
// RFC-052: returnDirect tools bypass spill / truncation / LLM context.
|
||||
// Their full text goes to the user verbatim and is never persisted to
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
package vip.mate.agent.graph.executor;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||
import vip.mate.agent.context.StructuredTruncator;
|
||||
import vip.mate.tool.guard.WorkspacePathGuard;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.stereotype.Component;
|
||||
@ -73,6 +75,30 @@ public class ToolResultStorage {
|
||||
this.excludedToolsSnapshot = props.excludedToolsSet();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trust the deterministic spill roots with the workspace path guard at
|
||||
* startup, before any spill happens in this JVM. Without this, a
|
||||
* conversation that spilled in a previous run and is then resumed after a
|
||||
* restart would have its {@code read_file} of the still-on-disk spill path
|
||||
* rejected as a boundary escape until the next spill re-registers the root.
|
||||
* The per-workspace branch ({@code <workspace>/.mateclaw/tool-results}) is
|
||||
* intentionally not registered here — it already sits inside its own
|
||||
* workspace boundary.
|
||||
*/
|
||||
@PostConstruct
|
||||
void registerSpillRootsAsTrusted() {
|
||||
if (!props.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
if (!props.getStorageBaseDir().isEmpty()) {
|
||||
WorkspacePathGuard.addTrustedRoot(props.getStorageBaseDir());
|
||||
}
|
||||
String tmp = System.getProperty("java.io.tmpdir");
|
||||
if (tmp != null && !tmp.isEmpty()) {
|
||||
WorkspacePathGuard.addTrustedRoot(Paths.get(tmp, "mateclaw", "tool-results").toString());
|
||||
}
|
||||
}
|
||||
|
||||
/** D-6: current cumulative spill count (monotonically increasing). */
|
||||
public long getSpillCount() {
|
||||
return spillCount.get();
|
||||
@ -283,18 +309,30 @@ public class ToolResultStorage {
|
||||
|
||||
private Path resolveBaseDir(String workspaceBasePath) {
|
||||
Path base;
|
||||
boolean outsideWorkspace;
|
||||
if (!props.getStorageBaseDir().isEmpty()) {
|
||||
base = Paths.get(props.getStorageBaseDir());
|
||||
outsideWorkspace = true;
|
||||
} else if (workspaceBasePath != null && !workspaceBasePath.isBlank()) {
|
||||
// Inside the workspace boundary already — read_file of these spill
|
||||
// files is permitted without an extra trusted-root registration.
|
||||
base = Paths.get(workspaceBasePath, ".mateclaw", "tool-results");
|
||||
outsideWorkspace = false;
|
||||
} else {
|
||||
String tmp = System.getProperty("java.io.tmpdir");
|
||||
if (tmp == null || tmp.isEmpty()) return null;
|
||||
base = Paths.get(tmp, "mateclaw", "tool-results");
|
||||
outsideWorkspace = true;
|
||||
}
|
||||
// Register so the retention sweep and conversation-delete hook can
|
||||
// reach this root even when the workspace path is no longer in scope.
|
||||
observedRoots.add(base);
|
||||
// A spill directory that lives outside the workspace must be trusted by
|
||||
// the path guard; otherwise the read_file the spill preview tells the
|
||||
// agent to perform is rejected as a workspace-boundary escape.
|
||||
if (outsideWorkspace) {
|
||||
WorkspacePathGuard.addTrustedRoot(base.toString());
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
|
||||
@ -265,12 +265,15 @@ public class FinalAnswerNode implements NodeAction {
|
||||
|
||||
/**
|
||||
* Replace fake {@code /api/v1/files/generated/{id}} URLs (cache-miss)
|
||||
* with a user-visible warning. No-op when no cache is wired (legacy
|
||||
* tests) or when the answer is empty.
|
||||
* with a user-visible warning, and wrap live bare URLs into
|
||||
* {@code [filename](url)} markdown links so the chat shows the file name
|
||||
* instead of the raw id. No-op when no cache is wired (legacy tests) or
|
||||
* when the answer is empty.
|
||||
*/
|
||||
private String scrubFakeUrls(String text) {
|
||||
if (generatedFileCache == null || text == null || text.isEmpty()) return text;
|
||||
return generatedFileCache.scrubMissingReferences(text);
|
||||
return generatedFileCache.linkifyBareReferences(
|
||||
generatedFileCache.scrubMissingReferences(text));
|
||||
}
|
||||
|
||||
private FinishReason parseFinishReason(String reason) {
|
||||
|
||||
@ -22,6 +22,7 @@ import vip.mate.llm.chatmodel.ThinkingLevelHolder;
|
||||
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
||||
import vip.mate.agent.context.ConversationWindowManager;
|
||||
import vip.mate.agent.context.LoopBudgetConfig;
|
||||
import vip.mate.agent.context.PrefixBudgetPlan;
|
||||
import vip.mate.agent.context.LoopMessageBudgeter;
|
||||
import vip.mate.agent.context.RuntimeContextInjector;
|
||||
import vip.mate.agent.context.TokenEstimator;
|
||||
@ -350,6 +351,52 @@ public class ReasoningNode implements NodeAction {
|
||||
*/
|
||||
private final vip.mate.agent.progress.ProgressLedgerService progressLedgerService;
|
||||
|
||||
/**
|
||||
* Token budget for the optional prefix injection blocks, computed at
|
||||
* agent-build time against the model's effective context window. Null
|
||||
* when the graph was assembled without budgeting (tests, legacy paths) —
|
||||
* all injection sites then keep their previous absolute-cap behavior.
|
||||
*/
|
||||
private PrefixBudgetPlan prefixBudgetPlan;
|
||||
|
||||
public void setPrefixBudgetPlan(PrefixBudgetPlan prefixBudgetPlan) {
|
||||
this.prefixBudgetPlan = prefixBudgetPlan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core-tier tools auto-demoted to the extension catalog because the
|
||||
* advertised schemas exceeded the window's tool-schema budget. Decided
|
||||
* once at agent-build time (kept stable for prompt caching); the baked
|
||||
* extension catalog lists them so {@code enable_tool} can surface any of
|
||||
* them back.
|
||||
*/
|
||||
private Set<String> autoDemotedTools = Set.of();
|
||||
|
||||
public void setAutoDemotedTools(Set<String> autoDemotedTools) {
|
||||
this.autoDemotedTools = autoDemotedTools == null ? Set.of() : autoDemotedTools;
|
||||
}
|
||||
|
||||
/** Floor for the window-aware output clamp — an answer needs at least this much room. */
|
||||
private static final int MIN_CLAMPED_OUTPUT_TOKENS = 512;
|
||||
|
||||
/**
|
||||
* Output cap actually sent to the provider. Strict local servers (vLLM)
|
||||
* statically reject {@code max_tokens >= max_model_len}, so when the
|
||||
* effective context window is known and smaller than the configured /
|
||||
* default output cap, clamp to half the window (leaving the other half
|
||||
* for the prompt). No-op when the window is unknown or already larger.
|
||||
*/
|
||||
int effectiveMaxOutputTokens() {
|
||||
int window = (prefixBudgetPlan != null) ? prefixBudgetPlan.effectiveMaxTokens() : 0;
|
||||
if (window > 0 && maxOutputTokens >= window) {
|
||||
int clamped = Math.max(MIN_CLAMPED_OUTPUT_TOKENS, window / 2);
|
||||
log.info("[ReasoningNode] max_tokens {} ≥ 模型窗口 {},钳制为 {}(窗口一半)以避免服务端拒绝",
|
||||
maxOutputTokens, window, clamped);
|
||||
return clamped;
|
||||
}
|
||||
return maxOutputTokens;
|
||||
}
|
||||
|
||||
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort,
|
||||
NodeStreamingChatHelper streamingHelper,
|
||||
ConversationWindowManager conversationWindowManager,
|
||||
@ -470,6 +517,12 @@ public class ReasoningNode implements NodeAction {
|
||||
* otherwise the documented fallback.
|
||||
*/
|
||||
private int loopContextWindowTokens() {
|
||||
// Per-model effective window (explicit config or probed) beats the
|
||||
// global default — the loop budgeter is otherwise blind to small
|
||||
// local models and never trims for them.
|
||||
if (prefixBudgetPlan != null && prefixBudgetPlan.effectiveMaxTokens() > 0) {
|
||||
return prefixBudgetPlan.effectiveMaxTokens();
|
||||
}
|
||||
if (conversationWindowManager != null) {
|
||||
int v = conversationWindowManager.getDefaultMaxInputTokens();
|
||||
if (v > 0) return v;
|
||||
@ -709,13 +762,35 @@ public class ReasoningNode implements NodeAction {
|
||||
// so an enable_tool call earlier in this loop takes effect immediately.
|
||||
// Falls back to the full tool set when no disclosure service is wired.
|
||||
List<ToolCallback> activeCallbacks = (toolDisclosureService != null && toolSet != null)
|
||||
? toolDisclosureService.split(toolSet, accessor.enabledExtensionTools()).activeCallbacks()
|
||||
? toolDisclosureService.split(toolSet, accessor.enabledExtensionTools(), autoDemotedTools)
|
||||
.activeCallbacks()
|
||||
: toolCallbacks;
|
||||
|
||||
ChatOptions options = buildChatOptions(effectiveReasoning, activeCallbacks);
|
||||
|
||||
Prompt prompt = new Prompt(promptMessages, options);
|
||||
|
||||
// Prefix accounting: how much of the window the never-trimmed prefix
|
||||
// (system prompt + runtime context + wiki + skill catalog + ledger)
|
||||
// and the advertised tool schemas consume. Logged on the turn's first
|
||||
// call so a small-window overflow is diagnosable per block instead of
|
||||
// surfacing as an opaque provider 400.
|
||||
int prefixEstimateTokens = TokenEstimator.estimateTokens(nonHistoryPrefix);
|
||||
int toolSchemaEstimateTokens = TokenEstimator.estimateToolsTokens(activeCallbacks);
|
||||
if (accessor.llmCallCount() == 0) {
|
||||
log.info("[ReasoningNode] Prefix accounting conv={}: window={} tokens, prefix={} "
|
||||
+ "(system+context+wiki+skills+ledger), toolSchemas={}, history={}",
|
||||
conversationId, loopContextWindowTokens(), prefixEstimateTokens,
|
||||
toolSchemaEstimateTokens, TokenEstimator.estimateTokens(messages));
|
||||
}
|
||||
// The prefix cannot be compacted (history compaction is the only lever),
|
||||
// so a prefix that alone exceeds the window makes the request doomed —
|
||||
// fail fast with the same PROMPT_TOO_LONG shape a provider rejection
|
||||
// would produce instead of sending it. Gated on budgeting being active
|
||||
// (an estimation false-positive must not block requests otherwise).
|
||||
boolean prefixOverflow = prefixBudgetPlan != null && prefixBudgetPlan.enabled()
|
||||
&& prefixEstimateTokens + toolSchemaEstimateTokens > prefixBudgetPlan.effectiveMaxTokens();
|
||||
|
||||
// ======= LLM 调用区域 =======
|
||||
// nextLlmCallCount 在首次 streamCall 之前计算。
|
||||
// 所有退出路径(正常、stopped、fatal error、CancellationException)都必须写回此值。
|
||||
@ -745,7 +820,19 @@ public class ReasoningNode implements NodeAction {
|
||||
|
||||
NodeStreamingChatHelper.StreamResult result;
|
||||
try {
|
||||
result = streamingHelper.streamCall(chatModel, prompt, conversationId, "reasoning");
|
||||
if (prefixOverflow) {
|
||||
String overflowMessage = "Prompt 前缀估算 " + (prefixEstimateTokens + toolSchemaEstimateTokens)
|
||||
+ " tokens(注入块 " + prefixEstimateTokens + " + 工具 schema " + toolSchemaEstimateTokens
|
||||
+ ")已超过模型上下文窗口 " + prefixBudgetPlan.effectiveMaxTokens()
|
||||
+ " tokens,历史压缩无法解决——请精简 Agent 身份 prompt、减少绑定工具/技能,"
|
||||
+ "或换用更大窗口的模型";
|
||||
log.error("[ReasoningNode] {}", overflowMessage);
|
||||
result = new NodeStreamingChatHelper.StreamResult(null, null, null, List.of(), false,
|
||||
0, 0, false, overflowMessage,
|
||||
NodeStreamingChatHelper.ErrorType.PROMPT_TOO_LONG, false, 0, 0, 0);
|
||||
} else {
|
||||
result = streamingHelper.streamCall(chatModel, prompt, conversationId, "reasoning");
|
||||
}
|
||||
|
||||
// PTL 处理:结构化压缩后重试。复用 nonHistoryPrefix 保证重试
|
||||
// Prompt 仍带 wiki / runtime context;早期的 tail-only 路径会把
|
||||
@ -1119,7 +1206,9 @@ public class ReasoningNode implements NodeAction {
|
||||
if (!projectRecalled && wikiContextService != null && agentIdStr != null && !agentIdStr.isEmpty()) {
|
||||
try {
|
||||
Long parsedAgentId = Long.parseLong(agentIdStr);
|
||||
String wikiRelevant = wikiContextService.buildRelevantContext(parsedAgentId, userMsg);
|
||||
Integer wikiBudgetTokens = (prefixBudgetPlan != null && prefixBudgetPlan.enabled())
|
||||
? prefixBudgetPlan.wikiTokens() : null;
|
||||
String wikiRelevant = wikiContextService.buildRelevantContext(parsedAgentId, userMsg, wikiBudgetTokens);
|
||||
if (wikiRelevant != null && !wikiRelevant.isBlank()) {
|
||||
prefix.add(new UserMessage(wikiRelevant));
|
||||
}
|
||||
@ -1166,11 +1255,11 @@ public class ReasoningNode implements NodeAction {
|
||||
default -> 16384;
|
||||
};
|
||||
builder.thinking(org.springframework.ai.anthropic.api.AnthropicApi.ThinkingType.ENABLED, budgetTokens);
|
||||
builder.maxTokens(budgetTokens + maxOutputTokens);
|
||||
builder.maxTokens(budgetTokens + effectiveMaxOutputTokens());
|
||||
builder.temperature(1.0);
|
||||
log.info("[ReasoningNode] Anthropic extended thinking enabled: model={}, budget={}", currentModel, budgetTokens);
|
||||
} else {
|
||||
builder.maxTokens(maxOutputTokens);
|
||||
builder.maxTokens(effectiveMaxOutputTokens());
|
||||
if (thinkingOn && !isClaudeModel) {
|
||||
log.debug("[ReasoningNode] Anthropic protocol model {} does not support thinking, skipping", currentModel);
|
||||
}
|
||||
@ -1185,7 +1274,7 @@ public class ReasoningNode implements NodeAction {
|
||||
// DashScope rejects max_tokens above its 8192 ceiling with a 400 that
|
||||
// the failover layer misreads as "model not found"; clamp so a
|
||||
// DashScope-backed model never overflows the provider limit.
|
||||
int effectiveMaxTokens = maxOutputTokens;
|
||||
int effectiveMaxTokens = effectiveMaxOutputTokens();
|
||||
if (chatModel instanceof com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel
|
||||
&& effectiveMaxTokens > DASHSCOPE_MAX_OUTPUT_TOKENS) {
|
||||
log.debug("[ReasoningNode] Clamping max_tokens {} -> {} for DashScope-backed model",
|
||||
|
||||
@ -11,6 +11,7 @@ import reactor.core.publisher.Mono;
|
||||
import vip.mate.agent.AgentService;
|
||||
import vip.mate.agent.AgentState;
|
||||
import vip.mate.agent.BaseAgent;
|
||||
import vip.mate.agent.delegation.DelegatedUsageAccumulator;
|
||||
import vip.mate.agent.GraphEventPublisher;
|
||||
import vip.mate.agent.StructuredStreamCapable;
|
||||
import vip.mate.agent.graph.plan.state.PlanStateKeys;
|
||||
@ -97,8 +98,8 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
||||
log.info("[{}] Plan-Execute replay stream: conversationId={}", agentName, conversationId);
|
||||
Map<String, Object> inputs = buildInitialState(userMessage, conversationId);
|
||||
|
||||
// 从 DB 恢复 awaiting_approval 状态的计划上下文
|
||||
PlanningService.PlanResumeContext ctx = planningService.findAwaitingApprovalContext();
|
||||
// 从 DB 恢复 awaiting_approval 状态的计划上下文(按 conversationId 过滤,避免并发会话误取)
|
||||
PlanningService.PlanResumeContext ctx = planningService.findAwaitingApprovalContext(conversationId);
|
||||
if (ctx != null) {
|
||||
inputs.put(PlanStateKeys.PLAN_ID, ctx.planId());
|
||||
inputs.put(PlanStateKeys.PLAN_STEPS, ctx.steps());
|
||||
@ -142,8 +143,15 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
||||
AtomicInteger sentEventCount = new AtomicInteger(0);
|
||||
AtomicInteger finalPromptTokens = new AtomicInteger(0);
|
||||
AtomicInteger finalCompletionTokens = new AtomicInteger(0);
|
||||
AtomicInteger finalCacheReadTokens = new AtomicInteger(0);
|
||||
AtomicInteger finalCacheWriteTokens = new AtomicInteger(0);
|
||||
AtomicInteger finalReasoningTokens = new AtomicInteger(0);
|
||||
AtomicReference<String> finalModelName = new AtomicReference<>("");
|
||||
AtomicReference<String> finalProviderId = new AtomicReference<>("");
|
||||
// Root conversation for this turn — used to roll delegated sub-agent
|
||||
// token usage into the turn's _usage_final and to clear the accumulator
|
||||
// on terminal so an errored turn never leaks an entry.
|
||||
final String usageConversationId = (String) inputs.get(MateClawStateKeys.CONVERSATION_ID);
|
||||
// 去重:记录上一次已持久化的 step 结果和 thinking,防止 PlanSummaryNode 重复 emit 上一步内容
|
||||
AtomicReference<String> lastPersistedStepResult = new AtomicReference<>("");
|
||||
AtomicReference<String> lastPersistedStepThinking = new AtomicReference<>("");
|
||||
@ -203,16 +211,33 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
||||
// 3. 更新最新累计 token usage
|
||||
finalPromptTokens.set(output.state().value(MateClawStateKeys.PROMPT_TOKENS, 0));
|
||||
finalCompletionTokens.set(output.state().value(MateClawStateKeys.COMPLETION_TOKENS, 0));
|
||||
finalCacheReadTokens.set(output.state().value(MateClawStateKeys.CACHE_READ_TOKENS, 0));
|
||||
finalCacheWriteTokens.set(output.state().value(MateClawStateKeys.CACHE_WRITE_TOKENS, 0));
|
||||
finalReasoningTokens.set(output.state().value(MateClawStateKeys.REASONING_TOKENS, 0));
|
||||
finalModelName.set(output.state().value(MateClawStateKeys.RUNTIME_MODEL_NAME, ""));
|
||||
finalProviderId.set(output.state().value(MateClawStateKeys.RUNTIME_PROVIDER_ID, ""));
|
||||
|
||||
return deltas;
|
||||
})
|
||||
.concatWith(Mono.fromSupplier(() -> {
|
||||
if (finalPromptTokens.get() > 0 || finalCompletionTokens.get() > 0) {
|
||||
// Roll delegated sub-agent usage (whole sub-tree, keyed by this
|
||||
// root conversation) into the turn total so the assistant
|
||||
// message reflects what the orchestrator + all children cost.
|
||||
DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance();
|
||||
DelegatedUsageAccumulator.Drained delegated = acc != null
|
||||
? acc.drain(usageConversationId)
|
||||
: new DelegatedUsageAccumulator.Drained(0, 0);
|
||||
long promptTokens = finalPromptTokens.get() + delegated.promptTokens();
|
||||
long completionTokens = finalCompletionTokens.get() + delegated.completionTokens();
|
||||
if (promptTokens > 0 || completionTokens > 0) {
|
||||
return AgentService.StreamDelta.event("_usage_final", Map.of(
|
||||
"promptTokens", finalPromptTokens.get(),
|
||||
"completionTokens", finalCompletionTokens.get(),
|
||||
"promptTokens", promptTokens,
|
||||
"completionTokens", completionTokens,
|
||||
"delegatedPromptTokens", delegated.promptTokens(),
|
||||
"delegatedCompletionTokens", delegated.completionTokens(),
|
||||
"cacheReadTokens", finalCacheReadTokens.get(),
|
||||
"cacheWriteTokens", finalCacheWriteTokens.get(),
|
||||
"reasoningTokens", finalReasoningTokens.get(),
|
||||
"runtimeModelName", finalModelName.get(),
|
||||
"runtimeProviderId", finalProviderId.get()
|
||||
));
|
||||
@ -223,6 +248,13 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
||||
.doOnError(e -> {
|
||||
log.error("[{}] Plan-Execute stream error: {}", agentName, e.getMessage());
|
||||
setState(AgentState.ERROR);
|
||||
})
|
||||
// Leak guard: if the turn ends without emitting _usage_final
|
||||
// (error / cancel), discard any delegated usage left for this
|
||||
// conversation so it can't bleed into a later turn.
|
||||
.doFinally(sig -> {
|
||||
DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance();
|
||||
if (acc != null) acc.clear(usageConversationId);
|
||||
});
|
||||
}
|
||||
|
||||
@ -297,6 +329,9 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
||||
inputs.put(MateClawStateKeys.REQUESTER_ID, "");
|
||||
inputs.put(MateClawStateKeys.PROMPT_TOKENS, 0);
|
||||
inputs.put(MateClawStateKeys.COMPLETION_TOKENS, 0);
|
||||
inputs.put(MateClawStateKeys.CACHE_READ_TOKENS, 0);
|
||||
inputs.put(MateClawStateKeys.CACHE_WRITE_TOKENS, 0);
|
||||
inputs.put(MateClawStateKeys.REASONING_TOKENS, 0);
|
||||
inputs.put(MateClawStateKeys.RUNTIME_MODEL_NAME, modelName != null ? modelName : "");
|
||||
inputs.put(MateClawStateKeys.RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
|
||||
inputs.put(MateClawStateKeys.TRACE_ID, UUID.randomUUID().toString().substring(0, 8));
|
||||
|
||||
@ -33,6 +33,7 @@ import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@ -190,6 +191,48 @@ public class PlanGenerationNode implements NodeAction {
|
||||
return goal;
|
||||
}
|
||||
|
||||
/** Whole injected long-term-memory recall block (any casing). */
|
||||
private static final Pattern MEMORY_CONTEXT_BLOCK =
|
||||
Pattern.compile("(?is)<\\s*memory-context\\s*>.*?</\\s*memory-context\\s*>");
|
||||
/** Stray open/close memory-context fence tags left after block removal. */
|
||||
private static final Pattern MEMORY_CONTEXT_TAG =
|
||||
Pattern.compile("(?i)</?\\s*memory-context\\s*>");
|
||||
/** Marker that introduces the real instruction inside a scheduled-run wrapper. */
|
||||
private static final String CRON_TASK_MARKER = "[任务指令]";
|
||||
/** Suffix appended by a goal-driven re-plan pass; not part of the user's ask. */
|
||||
private static final String FOLLOWUP_MARKER = "[Follow-up guidance]";
|
||||
|
||||
/**
|
||||
* Recovers the user's actual request from the fully-assembled agent prompt so
|
||||
* the persisted/displayed plan goal reads as the task itself, not the
|
||||
* framework scaffolding wrapped around it. The graph receives the goal already
|
||||
* enriched — a {@code <memory-context>…</memory-context>} recall block is
|
||||
* prepended for every turn, scheduled runs add a wrapper whose real payload
|
||||
* sits after {@code [任务指令]}, and a re-plan pass appends a
|
||||
* {@code [Follow-up guidance]} block. Persisting that verbatim left the Plan
|
||||
* board showing "<memory-context> The following is what you…" instead of
|
||||
* the user's goal. Strips, in order: the recall block, the scheduled-run
|
||||
* preamble (keeping only the instruction body), and the follow-up suffix.
|
||||
* Falls back to the raw goal if scrubbing would leave nothing.
|
||||
*/
|
||||
static String displayGoal(String goal) {
|
||||
if (goal == null || goal.isBlank()) {
|
||||
return goal == null ? "" : goal;
|
||||
}
|
||||
String s = MEMORY_CONTEXT_BLOCK.matcher(goal).replaceAll("");
|
||||
s = MEMORY_CONTEXT_TAG.matcher(s).replaceAll("");
|
||||
int task = s.lastIndexOf(CRON_TASK_MARKER);
|
||||
if (task >= 0) {
|
||||
s = s.substring(task + CRON_TASK_MARKER.length());
|
||||
}
|
||||
int followup = s.indexOf(FOLLOWUP_MARKER);
|
||||
if (followup >= 0) {
|
||||
s = s.substring(0, followup);
|
||||
}
|
||||
s = s.strip();
|
||||
return s.isEmpty() ? goal.strip() : s;
|
||||
}
|
||||
|
||||
public PlanGenerationNode(ChatModel chatModel, PlanningService planningService,
|
||||
NodeStreamingChatHelper streamingHelper,
|
||||
ConversationWindowManager conversationWindowManager,
|
||||
@ -260,7 +303,7 @@ public class PlanGenerationNode implements NodeAction {
|
||||
if (goalService.findActiveByConversation(convId) != null) {
|
||||
return null; // respect an existing goal (incl. re-plan passes)
|
||||
}
|
||||
String request = stripInjectedContext(accessor.goal()).strip();
|
||||
String request = displayGoal(accessor.goal());
|
||||
GoalCreateRequest req = new GoalCreateRequest();
|
||||
req.setConversationId(convId);
|
||||
req.setAgentId(origin.agentId());
|
||||
@ -368,10 +411,16 @@ public class PlanGenerationNode implements NodeAction {
|
||||
String agentId = state.value(MateClawStateKeys.AGENT_ID, "");
|
||||
String conversationId = accessor.conversationId();
|
||||
|
||||
log.info("[PlanGeneration] Evaluating goal: {}", goal.length() > 100 ? goal.substring(0, 100) + "..." : goal);
|
||||
// The graph's goal carries framework scaffolding (memory recall block,
|
||||
// scheduled-run wrapper, follow-up suffix). Persist and display the
|
||||
// scrubbed user request so the Plan board shows the actual task; the raw
|
||||
// goal still feeds the triage LLM below.
|
||||
String persistGoal = displayGoal(goal);
|
||||
|
||||
log.info("[PlanGeneration] Evaluating goal: {}", persistGoal.length() > 100 ? persistGoal.substring(0, 100) + "..." : persistGoal);
|
||||
|
||||
List<GraphEventPublisher.GraphEvent> events = new ArrayList<>();
|
||||
events.add(GraphEventPublisher.phase("planning", Map.of("goal", goal)));
|
||||
events.add(GraphEventPublisher.phase("planning", Map.of("goal", persistGoal)));
|
||||
|
||||
// Replay path: plan is already in state (injected by chatWithReplayStream); skip LLM.
|
||||
Long existingPlanId = state.<Long>value(PlanStateKeys.PLAN_ID).orElse(null);
|
||||
@ -508,8 +557,8 @@ public class PlanGenerationNode implements NodeAction {
|
||||
log.warn("[PlanGeneration] Evidence gate overrode direct-answer route; "
|
||||
+ "downgrading to single-step plan so tools can execute (goal: {})",
|
||||
goal.length() > 60 ? goal.substring(0, 60) + "..." : goal);
|
||||
List<String> gatedSteps = List.of(goal);
|
||||
var gatedPlan = planningService.createPlan(agentId, conversationId, goal, gatedSteps);
|
||||
List<String> gatedSteps = List.of(persistGoal);
|
||||
var gatedPlan = planningService.createPlan(agentId, conversationId, persistGoal, gatedSteps);
|
||||
events.add(GraphEventPublisher.planCreated(gatedPlan.getId(), gatedSteps));
|
||||
return PlanStateAccessor.output()
|
||||
.needsPlanning(true)
|
||||
@ -547,7 +596,7 @@ public class PlanGenerationNode implements NodeAction {
|
||||
// can still reach the tools. (Previous behavior dropped back to
|
||||
// direct_answer, which silently stripped tool capability.)
|
||||
log.warn("[PlanGeneration] needs_planning=true with empty steps; falling back to single-step plan");
|
||||
steps = List.of(goal);
|
||||
steps = List.of(persistGoal);
|
||||
}
|
||||
|
||||
// Resolve any per-step agent delegation the planner asked for. Null
|
||||
@ -555,7 +604,7 @@ public class PlanGenerationNode implements NodeAction {
|
||||
List<Long> stepAgentIds = resolveStepAgents(steps,
|
||||
triage != null ? triage.stepAgents() : null,
|
||||
chatOrigin.workspaceId(), agentId);
|
||||
var plan = planningService.createPlan(agentId, conversationId, goal, steps, stepAgentIds);
|
||||
var plan = planningService.createPlan(agentId, conversationId, persistGoal, steps, stepAgentIds);
|
||||
log.info("[PlanGeneration] Plan created: id={}, steps={} ({}){}",
|
||||
plan.getId(), steps.size(), steps.size() == 1 ? "single-step" : "multi-step",
|
||||
stepAgentIds != null ? ", per-step delegation=" + stepAgentIds : "");
|
||||
@ -600,12 +649,12 @@ public class PlanGenerationNode implements NodeAction {
|
||||
// answer. This preserves tool access on the failure path; the previous
|
||||
// "direct answer" fallback silently degraded tool-requiring tasks.
|
||||
try {
|
||||
var plan = planningService.createPlan(agentId, conversationId, goal, List.of(goal));
|
||||
events.add(GraphEventPublisher.planCreated(plan.getId(), List.of(goal)));
|
||||
var plan = planningService.createPlan(agentId, conversationId, persistGoal, List.of(persistGoal));
|
||||
events.add(GraphEventPublisher.planCreated(plan.getId(), List.of(persistGoal)));
|
||||
return PlanStateAccessor.output()
|
||||
.needsPlanning(true)
|
||||
.planId(plan.getId())
|
||||
.planSteps(List.of(goal))
|
||||
.planSteps(List.of(persistGoal))
|
||||
.planValid(true)
|
||||
.currentStepIndex(0)
|
||||
.currentPhase("plan_generated")
|
||||
|
||||
@ -30,6 +30,7 @@ import vip.mate.planning.service.PlanningService;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
import vip.mate.skill.runtime.SkillCatalogRenderer;
|
||||
import vip.mate.tool.builtin.DelegateAgentTool;
|
||||
import vip.mate.tool.builtin.DelegateAgentTool.ChildResult;
|
||||
import vip.mate.tool.builtin.DelegationContext;
|
||||
import vip.mate.tool.builtin.ToolExecutionContext;
|
||||
|
||||
@ -247,6 +248,9 @@ public class StepExecutionNode implements NodeAction {
|
||||
String approvalToolName = null;
|
||||
int stepPromptTokens = 0;
|
||||
int stepCompletionTokens = 0;
|
||||
int stepCacheReadTokens = 0;
|
||||
int stepCacheWriteTokens = 0;
|
||||
int stepReasoningTokens = 0;
|
||||
|
||||
// RFC-052: any returnDirect tool that fires inside this step must
|
||||
// short-circuit the entire plan (not just this step). We accumulate
|
||||
@ -321,6 +325,9 @@ public class StepExecutionNode implements NodeAction {
|
||||
|
||||
stepPromptTokens += result.promptTokens();
|
||||
stepCompletionTokens += result.completionTokens();
|
||||
stepCacheReadTokens += result.cacheReadTokens();
|
||||
stepCacheWriteTokens += result.cacheWriteTokens();
|
||||
stepReasoningTokens += result.reasoningTokens();
|
||||
|
||||
if (!result.thinking().isEmpty()) {
|
||||
stepThinking = result.thinking();
|
||||
@ -443,8 +450,8 @@ public class StepExecutionNode implements NodeAction {
|
||||
.currentPhase("awaiting_approval")
|
||||
.contentStreamed(true)
|
||||
.thinkingStreamed(!stepThinking.isEmpty())
|
||||
.put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
|
||||
.put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
|
||||
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
|
||||
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
|
||||
.events(events)
|
||||
.build();
|
||||
}
|
||||
@ -479,10 +486,8 @@ public class StepExecutionNode implements NodeAction {
|
||||
.contentStreamed(false) // 由 StateGraphPlanExecuteAgent 经 finalSummary 推送
|
||||
.put(MateClawStateKeys.RETURN_DIRECT_TRIGGERED, true)
|
||||
.put(MateClawStateKeys.DIRECT_TOOL_OUTPUTS, List.copyOf(stepDirectOutputs))
|
||||
.put(MateClawStateKeys.PROMPT_TOKENS,
|
||||
state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
|
||||
.put(MateClawStateKeys.COMPLETION_TOKENS,
|
||||
state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
|
||||
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
|
||||
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
|
||||
.events(events)
|
||||
.build();
|
||||
}
|
||||
@ -531,8 +536,8 @@ public class StepExecutionNode implements NodeAction {
|
||||
.currentStepTitle("")
|
||||
.currentStepResult("")
|
||||
.contentStreamed(false)
|
||||
.put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
|
||||
.put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
|
||||
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
|
||||
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
|
||||
.events(events)
|
||||
.build();
|
||||
}
|
||||
@ -589,8 +594,8 @@ public class StepExecutionNode implements NodeAction {
|
||||
.currentStepTitle("")
|
||||
.currentStepResult("")
|
||||
.contentStreamed(false)
|
||||
.put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
|
||||
.put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
|
||||
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
|
||||
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
|
||||
.events(events)
|
||||
.build();
|
||||
}
|
||||
@ -601,8 +606,8 @@ public class StepExecutionNode implements NodeAction {
|
||||
.currentStepResult(shortError)
|
||||
.currentPhase("plan_aborted")
|
||||
.contentStreamed(false)
|
||||
.put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
|
||||
.put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
|
||||
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
|
||||
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
|
||||
.events(events)
|
||||
.build();
|
||||
}
|
||||
@ -645,8 +650,8 @@ public class StepExecutionNode implements NodeAction {
|
||||
.currentPhase("step_completed")
|
||||
.contentStreamed(true)
|
||||
.thinkingStreamed(!stepThinking.isEmpty())
|
||||
.put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
|
||||
.put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
|
||||
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
|
||||
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
|
||||
.events(events)
|
||||
.build();
|
||||
}
|
||||
@ -678,7 +683,7 @@ public class StepExecutionNode implements NodeAction {
|
||||
// Seed the delegation context with the plan's REAL conversation id (from
|
||||
// graph state) so the delegated child conversation is parented to it and
|
||||
// stays hidden from the user's conversation list. The ChatOrigin in the
|
||||
// plan-execute path carries no conversationId, so delegateByAgentId can't
|
||||
// plan-execute path carries no conversationId, so the delegation can't
|
||||
// derive the parent on its own — we provide it here.
|
||||
boolean seeded = false;
|
||||
if (conversationId != null && !conversationId.isBlank()
|
||||
@ -687,20 +692,30 @@ public class StepExecutionNode implements NodeAction {
|
||||
DelegationContext.enter(conversationId, Set.of(), conversationId, null, 0);
|
||||
seeded = true;
|
||||
}
|
||||
String result;
|
||||
ChildResult childResult = null;
|
||||
String delegateError = null;
|
||||
try {
|
||||
result = delegateAgentTool.delegateByAgentId(assignedAgentId, step, chatOrigin);
|
||||
childResult = delegateAgentTool.delegateByAgentIdStructured(assignedAgentId, step, chatOrigin);
|
||||
} catch (Exception e) {
|
||||
log.error("[StepExecution] Delegated step {} threw: {}", stepIndex, e.getMessage(), e);
|
||||
result = "[错误] 委派执行异常:" + e.getMessage();
|
||||
delegateError = e.getMessage();
|
||||
} finally {
|
||||
if (seeded) {
|
||||
DelegationContext.exit();
|
||||
}
|
||||
}
|
||||
|
||||
String finalResult = result != null ? result : "";
|
||||
boolean failed = finalResult.isEmpty() || finalResult.startsWith("[错误]");
|
||||
// Branch on the structured outcome instead of pattern-matching an error
|
||||
// prefix out of the reply text: a successful child with non-empty content
|
||||
// is the only "ok" case; blank / error / missing all count as failure.
|
||||
boolean ok = childResult != null && childResult.success() && !childResult.isBlank();
|
||||
String finalResult = ok
|
||||
? (childResult.result() != null ? childResult.result() : "")
|
||||
: "[错误] 委派执行失败:" + (delegateError != null ? delegateError
|
||||
: childResult != null && childResult.error() != null ? childResult.error()
|
||||
: childResult != null && childResult.isBlank() ? "子 Agent 返回内容为空"
|
||||
: "未知错误");
|
||||
boolean failed = !ok;
|
||||
if (failed) {
|
||||
planningService.updateSubPlanFailure(planId, stepIndex, finalResult);
|
||||
} else {
|
||||
|
||||
@ -258,10 +258,37 @@ public final class PlanStateAccessor {
|
||||
int existingLlmCalls = currentState.value(MateClawStateKeys.LLM_CALL_COUNT, 0);
|
||||
map.put(MateClawStateKeys.PROMPT_TOKENS, existingPrompt + result.promptTokens());
|
||||
map.put(MateClawStateKeys.COMPLETION_TOKENS, existingCompletion + result.completionTokens());
|
||||
map.put(MateClawStateKeys.CACHE_READ_TOKENS,
|
||||
currentState.value(MateClawStateKeys.CACHE_READ_TOKENS, 0) + result.cacheReadTokens());
|
||||
map.put(MateClawStateKeys.CACHE_WRITE_TOKENS,
|
||||
currentState.value(MateClawStateKeys.CACHE_WRITE_TOKENS, 0) + result.cacheWriteTokens());
|
||||
map.put(MateClawStateKeys.REASONING_TOKENS,
|
||||
currentState.value(MateClawStateKeys.REASONING_TOKENS, 0) + result.reasoningTokens());
|
||||
map.put(MateClawStateKeys.LLM_CALL_COUNT, existingLlmCalls + 1);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将一个 step 的累计 usage(含 cache / reasoning 分项)加到 state 已有值上。
|
||||
* StepExecutionNode 在多个出口路径上写回同一组键,统一走这里避免漏项。
|
||||
*/
|
||||
public OutputBuilder addStepUsage(OverAllState currentState,
|
||||
int promptTokens, int completionTokens,
|
||||
int cacheReadTokens, int cacheWriteTokens,
|
||||
int reasoningTokens) {
|
||||
map.put(MateClawStateKeys.PROMPT_TOKENS,
|
||||
currentState.value(MateClawStateKeys.PROMPT_TOKENS, 0) + promptTokens);
|
||||
map.put(MateClawStateKeys.COMPLETION_TOKENS,
|
||||
currentState.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + completionTokens);
|
||||
map.put(MateClawStateKeys.CACHE_READ_TOKENS,
|
||||
currentState.value(MateClawStateKeys.CACHE_READ_TOKENS, 0) + cacheReadTokens);
|
||||
map.put(MateClawStateKeys.CACHE_WRITE_TOKENS,
|
||||
currentState.value(MateClawStateKeys.CACHE_WRITE_TOKENS, 0) + cacheWriteTokens);
|
||||
map.put(MateClawStateKeys.REASONING_TOKENS,
|
||||
currentState.value(MateClawStateKeys.REASONING_TOKENS, 0) + reasoningTokens);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Map<String, Object> build() {
|
||||
return map;
|
||||
}
|
||||
|
||||
@ -537,6 +537,12 @@ public final class MateClawStateAccessor {
|
||||
int existingCompletion = currentState.value(COMPLETION_TOKENS, 0);
|
||||
map.put(PROMPT_TOKENS, existingPrompt + result.promptTokens());
|
||||
map.put(COMPLETION_TOKENS, existingCompletion + result.completionTokens());
|
||||
map.put(CACHE_READ_TOKENS,
|
||||
currentState.value(CACHE_READ_TOKENS, 0) + result.cacheReadTokens());
|
||||
map.put(CACHE_WRITE_TOKENS,
|
||||
currentState.value(CACHE_WRITE_TOKENS, 0) + result.cacheWriteTokens());
|
||||
map.put(REASONING_TOKENS,
|
||||
currentState.value(REASONING_TOKENS, 0) + result.reasoningTokens());
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@ -155,6 +155,12 @@ public final class MateClawStateKeys {
|
||||
// ===== Token Usage 累计(REPLACE 策略,节点内累加后写回)=====
|
||||
public static final String PROMPT_TOKENS = "prompt_tokens";
|
||||
public static final String COMPLETION_TOKENS = "completion_tokens";
|
||||
/** Prompt cache 命中 tokens 累计(provider 未上报时保持 0) */
|
||||
public static final String CACHE_READ_TOKENS = "cache_read_tokens";
|
||||
/** Prompt cache 写入 tokens 累计(provider 未上报时保持 0) */
|
||||
public static final String CACHE_WRITE_TOKENS = "cache_write_tokens";
|
||||
/** 思考(reasoning)tokens 累计(provider 未上报时保持 0) */
|
||||
public static final String REASONING_TOKENS = "reasoning_tokens";
|
||||
|
||||
// ===== 运行时模型快照(REPLACE 策略,buildInitialState 注入)=====
|
||||
public static final String RUNTIME_MODEL_NAME = "runtime_model_name";
|
||||
|
||||
@ -335,6 +335,29 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
||||
approvalMapper.insert(entity);
|
||||
log.info("[ApprovalWorkflow] requested workflow approval row id={}, runId={}, workspace={}, kind={}",
|
||||
entity.getId(), runId, workspaceId, kind);
|
||||
|
||||
// ISSUE #413: register the workflow approval into the in-memory map
|
||||
// so the resolve → resume bridge actually fires. Without this, the
|
||||
// row only lives in DB and ApprovalService.getPending("wf-...") returns
|
||||
// null, so performResolve() short-circuits at the "not pending" guard
|
||||
// and never reaches the WorkflowApprovalResolvedEvent publish in
|
||||
// Phase 4 — leaving ApprovalResumeBridge as dead code. Mirrors the
|
||||
// recoverFromDb() snapshot shape exactly.
|
||||
Instant createdAt = entity.getCreatedAt() != null
|
||||
? entity.getCreatedAt().atZone(ZoneId.systemDefault()).toInstant()
|
||||
: Instant.now();
|
||||
PendingApproval snapshot = new PendingApproval(
|
||||
entity.getPendingId(),
|
||||
entity.getConversationId(),
|
||||
/*userId*/ null,
|
||||
entity.getToolName(),
|
||||
entity.getToolArguments(),
|
||||
/*reason*/ entity.getSummary(),
|
||||
createdAt,
|
||||
"pending");
|
||||
snapshot.setSummary(entity.getSummary());
|
||||
approvalService.registerRecovered(snapshot);
|
||||
|
||||
return entity.getId();
|
||||
} catch (Exception e) {
|
||||
log.warn("[ApprovalWorkflow] requestWorkflowApproval failed: {}", e.getMessage());
|
||||
@ -775,6 +798,17 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
||||
/**
|
||||
* 代理查询方法
|
||||
*/
|
||||
/**
|
||||
* Look up a pending approval by its exact id. Delegates to the underlying
|
||||
* {@link ApprovalService#getPending} so callers that only hold the workflow
|
||||
* facade (e.g. WebChatController) can fetch the precise record for an IDOR
|
||||
* cross-check without falling back to {@code findPendingByConversation}
|
||||
* (which returns the earliest pending, wrong when several coexist).
|
||||
*/
|
||||
public java.util.Optional<PendingApproval> getPending(String pendingId) {
|
||||
return approvalService.getPending(pendingId);
|
||||
}
|
||||
|
||||
public PendingApproval findPendingByConversation(String conversationId) {
|
||||
return approvalService.findPendingByConversation(conversationId);
|
||||
}
|
||||
|
||||
@ -50,7 +50,8 @@ public class AuthService {
|
||||
.eq(UserEntity::getUsername, request.getUsername())
|
||||
.eq(UserEntity::getEnabled, true));
|
||||
|
||||
if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPassword())) {
|
||||
if (user == null || user.getPassword() == null
|
||||
|| !passwordEncoder.matches(request.getPassword(), user.getPassword())) {
|
||||
throw new MateClawException("err.auth.invalid_credentials", 401, "用户名或密码错误");
|
||||
}
|
||||
|
||||
@ -212,7 +213,10 @@ public class AuthService {
|
||||
return userMapper.selectById(userId);
|
||||
}
|
||||
|
||||
private String generateToken(UserEntity user) {
|
||||
/**
|
||||
* 生成 JWT token。SSO 登录路径复用此方法签发格式一致的 token。
|
||||
*/
|
||||
public String generateToken(UserEntity user) {
|
||||
return Jwts.builder()
|
||||
.subject(user.getUsername())
|
||||
.claim("userId", user.getId())
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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) {}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
267
mateclaw-server/src/main/java/vip/mate/auth/sso/SsoService.java
Normal file
267
mateclaw-server/src/main/java/vip/mate/auth/sso/SsoService.java
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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_token。POST /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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
package vip.mate.auth.sso.provider;
|
||||
|
||||
/**
|
||||
* IdP 返回的标准化用户信息。各 Provider 把平台特异字段映射到此结构。
|
||||
*
|
||||
* @param externalId open_id(provider 内唯一)
|
||||
* @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
|
||||
) {}
|
||||
@ -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> {
|
||||
}
|
||||
@ -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> {
|
||||
}
|
||||
@ -44,7 +44,8 @@ public class ChannelChatOriginFactory {
|
||||
? message.getChannelType()
|
||||
: channel.getChannelType(),
|
||||
/* chatId */ message.getChatId(),
|
||||
/* baseUrl */ null); // IM origins have no request host; rely on public-base-url config
|
||||
/* baseUrl */ null, // IM origins have no request host; rely on public-base-url config
|
||||
/* requesterUserId */ null); // IM senders are external platform ids, not MateClaw accounts
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -134,6 +134,13 @@ public class ChannelManager {
|
||||
*/
|
||||
private final ChannelLeaderElection leaderElection;
|
||||
|
||||
/**
|
||||
* Workspace/agent-aware chat-upload resolver, passed into adapters so their
|
||||
* inbound media downloads land under the channel's workspace base path
|
||||
* (falling back to the configured default dir).
|
||||
*/
|
||||
private final vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver;
|
||||
|
||||
/** 运行中的渠道适配器:channelId -> adapter */
|
||||
private final Map<Long, ChannelAdapter> activeAdapters = new HashMap<>();
|
||||
|
||||
@ -1197,14 +1204,16 @@ public class ChannelManager {
|
||||
case "dingtalk" -> new DingTalkChannelAdapter(channel, messageRouter, objectMapper, generatedFileCache);
|
||||
case "feishu" -> new FeishuChannelAdapter(channel, messageRouter, objectMapper,
|
||||
feishuMediaUploader, generatedFileScrubber, feishuStreamingCardManager,
|
||||
feishuCardDispatcher, feishuClientFactory, generatedFileCache, sttService);
|
||||
feishuCardDispatcher, feishuClientFactory, generatedFileCache, sttService,
|
||||
chatUploadLocationResolver);
|
||||
case "telegram" -> new TelegramChannelAdapter(channel, messageRouter, objectMapper);
|
||||
case "discord" -> new DiscordChannelAdapter(channel, messageRouter, objectMapper);
|
||||
case "wecom" -> new WeComChannelAdapter(channel, messageRouter, objectMapper,
|
||||
approvalNotificationService, weComCardDispatcher, weComKeepaliveScheduler,
|
||||
generatedFileCache);
|
||||
generatedFileCache, chatUploadLocationResolver);
|
||||
case "qq" -> new QQChannelAdapter(channel, messageRouter, objectMapper);
|
||||
case "weixin" -> new WeixinChannelAdapter(channel, messageRouter, objectMapper);
|
||||
case "weixin" -> new WeixinChannelAdapter(channel, messageRouter, objectMapper,
|
||||
chatUploadLocationResolver);
|
||||
case "slack" -> new vip.mate.channel.slack.SlackChannelAdapter(channel, messageRouter, objectMapper);
|
||||
case "webchat" -> new vip.mate.channel.webchat.WebChatChannelAdapter(channel, messageRouter, objectMapper);
|
||||
default -> throw new IllegalArgumentException("Unsupported channel type: " + type);
|
||||
|
||||
@ -70,6 +70,13 @@ public class ChannelMessageRouter {
|
||||
@Autowired(required = false)
|
||||
private ApplicationEventPublisher events;
|
||||
|
||||
/** Field-injected for the same reason as {@link #events}: the chat-upload
|
||||
* resolver resolves the workspace-aware TTS output directory on the
|
||||
* voice-reply path. Optional so tests that build the router directly
|
||||
* still work; falls back to the legacy default dir when unset. */
|
||||
@Autowired(required = false)
|
||||
private vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver;
|
||||
|
||||
/** 队列条目:封装消息及其路由上下文 */
|
||||
private record QueueEntry(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {}
|
||||
|
||||
@ -783,7 +790,7 @@ public class ChannelMessageRouter {
|
||||
StringBuilder replyAccumulator = new StringBuilder();
|
||||
final String channelType = adapter.getChannelType();
|
||||
// Token usage + model attribution: capture _usage_final event emitted at stream end
|
||||
final int[] usage = {0, 0}; // [promptTokens, completionTokens]
|
||||
final int[] usage = {0, 0, 0, 0, 0}; // [prompt, completion, cacheRead, cacheWrite, reasoning]
|
||||
final String[] modelInfo = {null, null}; // [runtimeModel, runtimeProvider]
|
||||
agentService.chatStructuredStream(agentId, promptText, conversationId,
|
||||
message.getSenderId(), chatOrigin)
|
||||
@ -793,6 +800,9 @@ public class ChannelMessageRouter {
|
||||
Map<String, Object> data = delta.eventData();
|
||||
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
|
||||
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
|
||||
usage[2] = ((Number) data.getOrDefault("cacheReadTokens", 0)).intValue();
|
||||
usage[3] = ((Number) data.getOrDefault("cacheWriteTokens", 0)).intValue();
|
||||
usage[4] = ((Number) data.getOrDefault("reasoningTokens", 0)).intValue();
|
||||
Object model = data.get("runtimeModelName");
|
||||
Object provider = data.get("runtimeProviderId");
|
||||
if (model != null) modelInfo[0] = model.toString();
|
||||
@ -833,7 +843,7 @@ public class ChannelMessageRouter {
|
||||
String status = isError ? "error" : "completed";
|
||||
MessageEntity saved = conversationService.saveMessage(
|
||||
conversationId, "assistant", reply, null, status,
|
||||
usage[0], usage[1], modelInfo[0], modelInfo[1]);
|
||||
usage[0], usage[1], usage[2], usage[3], usage[4], modelInfo[0], modelInfo[1], null);
|
||||
savedAssistantId = saved != null ? saved.getId() : null;
|
||||
if (!isError) {
|
||||
publishConversationCompletedEvent(agentId, conversationId, message.getContent(), reply, chatOrigin);
|
||||
@ -948,13 +958,16 @@ public class ChannelMessageRouter {
|
||||
// plan_step_* events, leaving the Web Console mirror with no
|
||||
// PlanStepsPanel for IM-routed conversations.
|
||||
// Token usage + model attribution: capture _usage_final event emitted at stream end
|
||||
final int[] usage = {0, 0}; // [promptTokens, completionTokens]
|
||||
final int[] usage = {0, 0, 0, 0, 0}; // [prompt, completion, cacheRead, cacheWrite, reasoning]
|
||||
final String[] modelInfo = {null, null}; // [runtimeModel, runtimeProvider]
|
||||
Flux<AgentService.StreamDelta> mirroredStream = stream.doOnNext(delta -> {
|
||||
if (delta.isEvent() && "_usage_final".equals(delta.eventType())) {
|
||||
Map<String, Object> data = delta.eventData();
|
||||
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
|
||||
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
|
||||
usage[2] = ((Number) data.getOrDefault("cacheReadTokens", 0)).intValue();
|
||||
usage[3] = ((Number) data.getOrDefault("cacheWriteTokens", 0)).intValue();
|
||||
usage[4] = ((Number) data.getOrDefault("reasoningTokens", 0)).intValue();
|
||||
Object model = data.get("runtimeModelName");
|
||||
Object provider = data.get("runtimeProviderId");
|
||||
if (model != null) modelInfo[0] = model.toString();
|
||||
@ -982,7 +995,7 @@ public class ChannelMessageRouter {
|
||||
String status = isError ? "error" : "completed";
|
||||
MessageEntity saved = conversationService.saveMessage(
|
||||
conversationId, "assistant", finalContent, null, status,
|
||||
usage[0], usage[1], modelInfo[0], modelInfo[1]);
|
||||
usage[0], usage[1], usage[2], usage[3], usage[4], modelInfo[0], modelInfo[1], null);
|
||||
if (!isError) {
|
||||
publishConversationCompletedEvent(agentId, conversationId, promptText, finalContent, chatOrigin);
|
||||
}
|
||||
@ -1491,10 +1504,12 @@ public class ChannelMessageRouter {
|
||||
// 构建音频 MessageContentPart
|
||||
String audioUrl = (String) result.get("audioUrl");
|
||||
String fileName = Paths.get(audioUrl).getFileName().toString();
|
||||
Path audioPath = Paths.get("data", "chat-uploads", conversationId, fileName);
|
||||
// TTS output may live under a workspace-scoped dir or the legacy
|
||||
// default dir — probe each candidate root to find the file.
|
||||
Path audioPath = resolveVoiceReplyAudio(conversationId, fileName);
|
||||
|
||||
if (!Files.exists(audioPath)) {
|
||||
log.warn("[voice-reply] TTS output file not found: {}", audioPath);
|
||||
if (audioPath == null) {
|
||||
log.warn("[voice-reply] TTS output file not found for conversation {} ({})", conversationId, fileName);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1516,6 +1531,27 @@ public class ChannelMessageRouter {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the TTS audio file across every candidate upload root. Returns the
|
||||
* first existing match, or {@code null} when the file is absent under every
|
||||
* root. Used by the voice-reply path so workspace-scoped and legacy default
|
||||
* outputs are both found.
|
||||
*/
|
||||
private Path resolveVoiceReplyAudio(String conversationId, String fileName) {
|
||||
if (chatUploadLocationResolver != null) {
|
||||
for (Path root : chatUploadLocationResolver.resolveCandidateUploadRoots(conversationId)) {
|
||||
Path candidate = root.resolve(conversationId).resolve(fileName);
|
||||
if (Files.exists(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback to the legacy default dir when the resolver is absent
|
||||
// (e.g. direct-construction unit tests).
|
||||
Path legacy = Paths.get("data", "chat-uploads", conversationId, fileName);
|
||||
return Files.exists(legacy) ? legacy : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否需要为此消息生成语音回复
|
||||
*/
|
||||
|
||||
@ -229,8 +229,48 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
||||
.build();
|
||||
|
||||
// Package-private for testing: redirect to a temp directory without touching real disk.
|
||||
// Used as the fallback upload root when no ChatUploadLocationResolver is wired
|
||||
// (e.g. direct-construction unit tests set this to a tmp dir).
|
||||
Path chatUploadsRoot = Path.of("data", "chat-uploads");
|
||||
|
||||
/**
|
||||
* Workspace/agent-aware upload-root resolver. Set by the production factory
|
||||
* (ChannelManager); null in unit tests, which override {@link #chatUploadsRoot}
|
||||
* instead. When non-null, attachment reads/writes resolve through it so files
|
||||
* land under the workspace base path; otherwise the legacy
|
||||
* {@link #chatUploadsRoot} field applies.
|
||||
*/
|
||||
vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver;
|
||||
|
||||
/**
|
||||
* Resolve the upload root for a conversation, preferring the wired resolver
|
||||
* (workspace/agent-aware) and falling back to the legacy field. Read paths
|
||||
* should use {@link #candidateChatUploadRoots(String)} to probe both the
|
||||
* workspace-scoped root and the legacy root.
|
||||
*/
|
||||
private java.nio.file.Path chatUploadRootFor(String conversationId) {
|
||||
if (chatUploadLocationResolver != null) {
|
||||
return chatUploadLocationResolver.resolveUploadRoot(conversationId);
|
||||
}
|
||||
return chatUploadsRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered candidate upload roots for a conversation: workspace-scoped first
|
||||
* (when the resolver is wired), then the legacy field. Used by read/scan
|
||||
* paths so attachments written before the workspace-aware relocation are
|
||||
* still found.
|
||||
*/
|
||||
private java.util.List<java.nio.file.Path> candidateChatUploadRoots(String conversationId) {
|
||||
java.util.List<java.nio.file.Path> roots = new java.util.ArrayList<>();
|
||||
if (chatUploadLocationResolver != null) {
|
||||
roots.addAll(chatUploadLocationResolver.resolveCandidateUploadRoots(conversationId));
|
||||
} else {
|
||||
roots.add(chatUploadsRoot);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
public FeishuChannelAdapter(ChannelEntity channelEntity,
|
||||
ChannelMessageRouter messageRouter,
|
||||
ObjectMapper objectMapper) {
|
||||
@ -291,6 +331,28 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
||||
FeishuClientFactory clientFactory,
|
||||
vip.mate.tool.document.GeneratedFileCache generatedFileCache,
|
||||
vip.mate.stt.SttService sttService) {
|
||||
this(channelEntity, messageRouter, objectMapper, mediaUploader,
|
||||
generatedFileScrubber, streamingCardManager, cardDispatcher,
|
||||
clientFactory, generatedFileCache, sttService, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full constructor used by the production factory (ChannelManager). The
|
||||
* trailing {@code chatUploadLocationResolver} enables workspace/agent-aware
|
||||
* attachment storage; {@code null} (or a shorter overload) keeps the legacy
|
||||
* {@code data/chat-uploads} behaviour.
|
||||
*/
|
||||
public FeishuChannelAdapter(ChannelEntity channelEntity,
|
||||
ChannelMessageRouter messageRouter,
|
||||
ObjectMapper objectMapper,
|
||||
FeishuMediaUploader mediaUploader,
|
||||
GeneratedFileScrubber generatedFileScrubber,
|
||||
FeishuStreamingCardManager streamingCardManager,
|
||||
vip.mate.channel.feishu.cards.FeishuCardDispatcher cardDispatcher,
|
||||
FeishuClientFactory clientFactory,
|
||||
vip.mate.tool.document.GeneratedFileCache generatedFileCache,
|
||||
vip.mate.stt.SttService sttService,
|
||||
vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver) {
|
||||
super(channelEntity, messageRouter, objectMapper);
|
||||
this.mediaUploader = mediaUploader;
|
||||
this.generatedFileScrubber = generatedFileScrubber;
|
||||
@ -299,6 +361,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
||||
this.clientFactory = clientFactory;
|
||||
this.generatedFileCache = generatedFileCache;
|
||||
this.sttService = sttService;
|
||||
this.chatUploadLocationResolver = chatUploadLocationResolver;
|
||||
// Feishu WebSocket reconnect: 2s→4s→8s→16s→30s, infinite retry
|
||||
this.backoff = new ExponentialBackoff(2000, 30000, 2.0, -1);
|
||||
}
|
||||
@ -1712,8 +1775,8 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
||||
: maybeDownloadResource(messageId, fileKey, type, fileName);
|
||||
if (dl == null) return null;
|
||||
|
||||
// Save to data/chat-uploads/{conversationId}/
|
||||
Path uploadDir = chatUploadsRoot.resolve(conversationId);
|
||||
// Save under the workspace/agent-aware upload root ({convId}/ subdir)
|
||||
Path uploadDir = chatUploadRootFor(conversationId).resolve(conversationId);
|
||||
Files.createDirectories(uploadDir);
|
||||
String rawName = (dl.fileName() != null && !dl.fileName().isBlank())
|
||||
? dl.fileName() : fileKey;
|
||||
@ -1796,15 +1859,20 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan {@code data/chat-uploads/{conversationId}/} on disk and return
|
||||
* the most recent files as {@link RecentFileEntry}s. Used as a
|
||||
* fallback when the in-memory Caffeine cache has been evicted
|
||||
* (process restart, TTL expiry, GC pressure) but the staged copies
|
||||
* are still on disk.
|
||||
* Scan the conversation's upload dir(s) on disk and return the most recent
|
||||
* files as {@link RecentFileEntry}s. Used as a fallback when the in-memory
|
||||
* Caffeine cache has been evicted (process restart, TTL expiry, GC pressure)
|
||||
* but the staged copies are still on disk. Probes every candidate root
|
||||
* (workspace-scoped + legacy default) so files written before the
|
||||
* workspace-aware relocation are still found.
|
||||
*/
|
||||
private List<RecentFileEntry> loadRecentFilesFromDisk(String conversationId) {
|
||||
long cutoff = System.currentTimeMillis() - RECENT_FILE_TTL_MINUTES * 60_000L;
|
||||
return loadRecentFilesFromDisk(chatUploadsRoot.resolve(conversationId), cutoff);
|
||||
List<RecentFileEntry> merged = new java.util.ArrayList<>();
|
||||
for (Path root : candidateChatUploadRoots(conversationId)) {
|
||||
merged.addAll(loadRecentFilesFromDisk(root.resolve(conversationId), cutoff));
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -9,7 +9,9 @@ import com.lark.oapi.event.cardcallback.model.P2CardActionTriggerData;
|
||||
import com.lark.oapi.event.cardcallback.model.P2CardActionTriggerResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import vip.mate.approval.ApprovalService;
|
||||
import vip.mate.approval.ApprovalWorkflowService;
|
||||
import vip.mate.approval.PendingApproval;
|
||||
import vip.mate.approval.ResolveOutcome;
|
||||
import vip.mate.channel.ChannelMessage;
|
||||
import vip.mate.channel.feishu.FeishuChannelAdapter;
|
||||
import vip.mate.channel.feishu.cards.FeishuCardHandler;
|
||||
@ -59,11 +61,23 @@ import java.util.Optional;
|
||||
public class ToolGuardCardHandler implements FeishuCardHandler {
|
||||
|
||||
private final ApprovalService approvalService;
|
||||
/**
|
||||
* ISSUE #413 P2-B3: needed to resolve workflow-scoped approvals
|
||||
* ({@code wf-} pendingIds) directly from the card click. Workflow
|
||||
* approvals cannot go through the synthetic /approve injection
|
||||
* (their conversationId is {@code workflow:run:{runId}}, which no
|
||||
* IM conversation matches), so the handler resolves them inline —
|
||||
* mirroring the Web / WebChat path. May be null in narrow test
|
||||
* contexts (wf- approvals then fall back to the admin console).
|
||||
*/
|
||||
private final ApprovalWorkflowService approvalWorkflowService;
|
||||
private final ToolGuardButtonValue buttonValue;
|
||||
|
||||
public ToolGuardCardHandler(ApprovalService approvalService,
|
||||
ApprovalWorkflowService approvalWorkflowService,
|
||||
ToolGuardButtonValue buttonValue) {
|
||||
this.approvalService = approvalService;
|
||||
this.approvalWorkflowService = approvalWorkflowService;
|
||||
this.buttonValue = buttonValue;
|
||||
}
|
||||
|
||||
@ -101,6 +115,19 @@ public class ToolGuardCardHandler implements FeishuCardHandler {
|
||||
PendingApproval pending = opt.get();
|
||||
|
||||
// ---- 3. Identity check (fail-closed)
|
||||
// Workflow-scoped approvals (wf- prefix, ISSUE #413 P2-B3) have no
|
||||
// human requester — the userId is null because the run is system-
|
||||
// initiated. Their approval cards are only ever pushed to the
|
||||
// channels declared in await_approval's approverChannels, so any
|
||||
// member of that audience is a legitimate approver; we skip the
|
||||
// requester==clicker guard and resolve inline (the synthetic /approve
|
||||
// injection is a dead end for wf- ids: their conversationId is
|
||||
// workflow:run:{runId}, which no IM conversation matches, so the
|
||||
// router's findPendingByConversation would miss it).
|
||||
if (pendingId.startsWith("wf-")) {
|
||||
return handleWorkflowApproval(pendingId, decoded.toolName(), act, clickerOpenId);
|
||||
}
|
||||
|
||||
// Agent/cron ("system") or unattributed (null) approvals have no human
|
||||
// requester to match the clicker against. A guarded-tool card landing in
|
||||
// a group chat would otherwise let ANY member click Approve and run the
|
||||
@ -142,6 +169,54 @@ public class ToolGuardCardHandler implements FeishuCardHandler {
|
||||
return buildResolvedResponse(decoded.toolName(), act, clickerOpenId);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Workflow-scoped approval (ISSUE #413 P2-B3)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve a {@code wf-} workflow approval directly from the card click,
|
||||
* bypassing the synthetic /approve injection. Workflow approvals live
|
||||
* under a synthetic {@code workflow:run:{runId}} conversationId that no
|
||||
* IM conversation matches, so the router path is a dead end. Instead we
|
||||
* resolve inline (mirroring the Web / WebChat path); the
|
||||
* {@link vip.mate.workflow.runtime.ApprovalResumeBridge} then picks up
|
||||
* the {@code WorkflowApprovalResolvedEvent} published inside
|
||||
* {@code ApprovalWorkflowService.resolve} and resumes the paused run.
|
||||
*
|
||||
* <p>No tool-call replay is needed — a workflow {@code await_approval}
|
||||
* step is a declarative gate, not a tool invocation; resume simply
|
||||
* advances to the next step.
|
||||
*
|
||||
* <p>Identity: any audience member may resolve. The card only reaches
|
||||
* the channels declared in {@code await_approval.approverChannels}
|
||||
* (pushed by {@code AwaitApprovalStepAdapter}'s notify step), so whoever
|
||||
* can see it is a designated approver.
|
||||
*/
|
||||
private P2CardActionTriggerResponse handleWorkflowApproval(String pendingId, String toolName,
|
||||
ToolGuardButtonValue.Action act,
|
||||
String clickerOpenId) {
|
||||
if (approvalWorkflowService == null) {
|
||||
log.warn("[feishu-toolguard] ApprovalWorkflowService unavailable, cannot resolve wf- {} "
|
||||
+ "(use the admin console)", pendingId);
|
||||
return buildErrorResponse("⚠️ 工作流审批需在管理端处理");
|
||||
}
|
||||
String decision = act == ToolGuardButtonValue.Action.APPROVE ? "approved" : "denied";
|
||||
try {
|
||||
ResolveOutcome outcome = approvalWorkflowService.resolve(pendingId, clickerOpenId, decision);
|
||||
if (!outcome.dbSynced()) {
|
||||
// already resolved / superseded — not an error, but tell the clicker.
|
||||
log.info("[feishu-toolguard] wf- {} already resolved: {}", pendingId, outcome.decision());
|
||||
return buildExpiredResponse(toolName);
|
||||
}
|
||||
log.info("[feishu-toolguard] Resolved wf- {} as {} by {} (run resume delegated to bridge)",
|
||||
pendingId, decision, abbrev(clickerOpenId));
|
||||
return buildResolvedResponse(toolName, act, clickerOpenId);
|
||||
} catch (Exception e) {
|
||||
log.error("[feishu-toolguard] Failed to resolve wf- {}: {}", pendingId, e.getMessage(), e);
|
||||
return buildErrorResponse("⚠️ 工作流审批未生效,请重试或在管理端处理");
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Response builders — assemble P2CardActionTriggerResponse{toast,card}
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@ -3,6 +3,7 @@ package vip.mate.channel.feishu.cards.tool_guard;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.approval.ApprovalService;
|
||||
import vip.mate.approval.ApprovalWorkflowService;
|
||||
import vip.mate.channel.feishu.cards.FeishuCardKind;
|
||||
|
||||
/**
|
||||
@ -27,21 +28,27 @@ public class ToolGuardCardKindFactory {
|
||||
public static final String ACTION_PREFIX = ToolGuardButtonValue.ACTION_PREFIX;
|
||||
|
||||
private final ApprovalService approvalService;
|
||||
/** ISSUE #413 P2-B3: resolves workflow-scoped (wf-) approvals from card clicks. */
|
||||
private final ApprovalWorkflowService approvalWorkflowService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public ToolGuardCardKindFactory(ApprovalService approvalService,
|
||||
ApprovalWorkflowService approvalWorkflowService,
|
||||
ObjectMapper objectMapper) {
|
||||
this.approvalService = approvalService;
|
||||
this.approvalWorkflowService = approvalWorkflowService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public FeishuCardKind create() {
|
||||
ToolGuardButtonValue buttonValue = new ToolGuardButtonValue(objectMapper);
|
||||
ToolGuardCardRenderer renderer = new ToolGuardCardRenderer(buttonValue);
|
||||
// Handler no longer needs ApprovalWorkflowService — the canonical
|
||||
// resolve + replay path runs via a synthetic /approve|/deny
|
||||
// message injected back into the router.
|
||||
ToolGuardCardHandler handler = new ToolGuardCardHandler(approvalService, buttonValue);
|
||||
// ISSUE #413 P2-B3: handler needs ApprovalWorkflowService to resolve
|
||||
// wf- workflow approvals inline (the synthetic /approve injection is
|
||||
// a dead end for wf- ids). Regular tool approvals still go through
|
||||
// the synthetic /approve | /deny router path as before.
|
||||
ToolGuardCardHandler handler = new ToolGuardCardHandler(
|
||||
approvalService, approvalWorkflowService, buttonValue);
|
||||
return new FeishuCardKind(KIND_NAME, ACTION_PREFIX, renderer, handler);
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,7 +30,6 @@ import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.io.IOException;
|
||||
import reactor.core.Disposable;
|
||||
|
||||
@ -38,6 +37,8 @@ import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
@ -62,7 +63,7 @@ public class ChatController {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ConversationCompletionPublisher completionPublisher;
|
||||
private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver;
|
||||
private final Path uploadRoot = Paths.get("data", "chat-uploads");
|
||||
private final vip.mate.workspace.core.service.ChatUploadLocationResolver uploadLocationResolver;
|
||||
|
||||
// 使用虚拟线程池处理 SSE(Java 17+ 兼容,Java 21 可用 Executors.newVirtualThreadPerTaskExecutor())
|
||||
private final ExecutorService sseExecutor = Executors.newCachedThreadPool();
|
||||
@ -356,6 +357,9 @@ public class ChatController {
|
||||
persistStatus,
|
||||
accumulator.getPromptTokens(),
|
||||
accumulator.getCompletionTokens(),
|
||||
accumulator.getCacheReadTokens(),
|
||||
accumulator.getCacheWriteTokens(),
|
||||
accumulator.getReasoningTokens(),
|
||||
accumulator.getRuntimeModelName(),
|
||||
accumulator.getRuntimeProviderId(),
|
||||
accumulator.toMetadataJson()); // includes toolCalls metadata
|
||||
@ -435,6 +439,9 @@ public class ChatController {
|
||||
errStatus,
|
||||
accumulator.getPromptTokens(),
|
||||
accumulator.getCompletionTokens(),
|
||||
accumulator.getCacheReadTokens(),
|
||||
accumulator.getCacheWriteTokens(),
|
||||
accumulator.getReasoningTokens(),
|
||||
accumulator.getRuntimeModelName(),
|
||||
accumulator.getRuntimeProviderId(),
|
||||
accumulator.toMetadataJson());
|
||||
@ -555,7 +562,7 @@ public class ChatController {
|
||||
// tools that need a workspace path read it from the agent (origin
|
||||
// is enriched with workspaceBasePath in StateGraph buildInitialState).
|
||||
vip.mate.agent.context.ChatOrigin webOrigin =
|
||||
memoryOrigin(conversationId, username, workspaceId, request.getEndUserId())
|
||||
memoryOrigin(conversationId, username, requesterUserIdOf(auth), workspaceId, request.getEndUserId())
|
||||
.withBaseUrl(requestBaseUrl);
|
||||
Disposable disposable = agentService.chatStructuredStream(agentId, promptText, conversationId, username, request.getThinkingLevel(), webOrigin)
|
||||
.doOnNext(delta -> {
|
||||
@ -628,6 +635,9 @@ public class ChatController {
|
||||
persistStatus,
|
||||
accumulator.getPromptTokens(),
|
||||
accumulator.getCompletionTokens(),
|
||||
accumulator.getCacheReadTokens(),
|
||||
accumulator.getCacheWriteTokens(),
|
||||
accumulator.getReasoningTokens(),
|
||||
accumulator.getRuntimeModelName(),
|
||||
accumulator.getRuntimeProviderId(),
|
||||
accumulator.toMetadataJson());
|
||||
@ -746,6 +756,9 @@ public class ChatController {
|
||||
status,
|
||||
accumulator.getPromptTokens(),
|
||||
accumulator.getCompletionTokens(),
|
||||
accumulator.getCacheReadTokens(),
|
||||
accumulator.getCacheWriteTokens(),
|
||||
accumulator.getReasoningTokens(),
|
||||
accumulator.getRuntimeModelName(),
|
||||
accumulator.getRuntimeProviderId(),
|
||||
accumulator.toMetadataJson());
|
||||
@ -848,6 +861,9 @@ public class ChatController {
|
||||
status,
|
||||
accumulator.getPromptTokens(),
|
||||
accumulator.getCompletionTokens(),
|
||||
accumulator.getCacheReadTokens(),
|
||||
accumulator.getCacheWriteTokens(),
|
||||
accumulator.getReasoningTokens(),
|
||||
accumulator.getRuntimeModelName(),
|
||||
accumulator.getRuntimeProviderId(),
|
||||
accumulator.toMetadataJson());
|
||||
@ -1056,7 +1072,7 @@ public class ChatController {
|
||||
// Carry the web origin so per-owner memory recall (read) and the
|
||||
// post-conversation memory write below agree on the same owner key.
|
||||
vip.mate.agent.context.ChatOrigin webOrigin =
|
||||
memoryOrigin(request.getConversationId(), username, workspaceId, request.getEndUserId());
|
||||
memoryOrigin(request.getConversationId(), username, requesterUserIdOf(auth), workspaceId, request.getEndUserId());
|
||||
AgentService.ChatResult result = agentService.chatWithUsage(agentId, promptText, request.getConversationId(), webOrigin);
|
||||
String response = result.content();
|
||||
conversationService.saveMessage(request.getConversationId(), "assistant", response, null, "completed",
|
||||
@ -1075,7 +1091,9 @@ public class ChatController {
|
||||
Authentication auth) throws IOException {
|
||||
|
||||
String username = auth != null ? auth.getName() : "anonymous";
|
||||
// 校验会话归属(会话可能尚未创建,此时允许上传——后续 stream/chat 会创建并绑定用户)
|
||||
// 校验会话归属(会话可能尚未创建,此时允许上传——后续 stream/chat 会创建并绑定用户)。
|
||||
// 注意:会话尚不存在时,附件暂存到默认目录(resolveUploadRoot 查不到会话即回退);
|
||||
// 会话创建后读取走双重查找,仍能命中。
|
||||
if (conversationService.conversationExists(conversationId)
|
||||
&& !conversationService.isConversationOwner(conversationId, username)) {
|
||||
return R.fail(403, "无权操作该会话");
|
||||
@ -1087,6 +1105,7 @@ public class ChatController {
|
||||
String originalFilename = file.getOriginalFilename() != null ? file.getOriginalFilename() : "file";
|
||||
String safeFilename = Path.of(originalFilename).getFileName().toString().replaceAll("[^a-zA-Z0-9._-]", "_");
|
||||
String storedName = System.currentTimeMillis() + "_" + safeFilename;
|
||||
Path uploadRoot = uploadLocationResolver.resolveUploadRoot(conversationId);
|
||||
Path conversationDir = uploadRoot.resolve(conversationId);
|
||||
Files.createDirectories(conversationDir);
|
||||
Path target = conversationDir.resolve(storedName);
|
||||
@ -1099,8 +1118,8 @@ public class ChatController {
|
||||
response.setFileName(originalFilename);
|
||||
response.setStoredName(storedName);
|
||||
response.setUrl("/api/v1/chat/files/" + conversationId + "/" + storedName);
|
||||
// 使用相对路径,避免暴露服务端绝对路径
|
||||
response.setPath(uploadRoot.resolve(conversationId).resolve(storedName).toString());
|
||||
// 用 root 相对路径,避免暴露服务端绝对路径(uploadRoot 现在恒为绝对路径)。
|
||||
response.setPath(toRelativeUploadPath(uploadRoot, conversationId, storedName));
|
||||
response.setSize(file.getSize());
|
||||
response.setContentType(file.getContentType());
|
||||
return R.ok(response);
|
||||
@ -1119,8 +1138,20 @@ public class ChatController {
|
||||
return ResponseEntity.status(403).build();
|
||||
}
|
||||
|
||||
Path filePath = uploadRoot.resolve(conversationId).resolve(storedName).normalize();
|
||||
if (!Files.exists(filePath) || !filePath.startsWith(uploadRoot.resolve(conversationId).normalize())) {
|
||||
// Check every candidate root (workspace-scoped dir + legacy default dir)
|
||||
// so attachments written before the workspace-aware relocation, and the
|
||||
// current workspace-scoped ones, are both servable. Each candidate keeps
|
||||
// its own startsWith traversal guard.
|
||||
Path filePath = null;
|
||||
for (Path root : uploadLocationResolver.resolveCandidateUploadRoots(conversationId)) {
|
||||
Path conversationDir = root.resolve(conversationId).normalize();
|
||||
Path candidate = conversationDir.resolve(storedName).normalize();
|
||||
if (Files.exists(candidate) && candidate.startsWith(conversationDir)) {
|
||||
filePath = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (filePath == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
@ -1154,17 +1185,33 @@ public class ChatController {
|
||||
* MateClaw user ({@code user:<username>}).
|
||||
*/
|
||||
private vip.mate.agent.context.ChatOrigin memoryOrigin(String conversationId, String username,
|
||||
Long workspaceId, String endUserId) {
|
||||
Long requesterUserId, Long workspaceId,
|
||||
String endUserId) {
|
||||
// Resolve the public base URL here, on the request thread, so it can ride
|
||||
// the origin into async tool execution where no request is bound. Tools
|
||||
// then mint absolute download links without operator config.
|
||||
String baseUrl = resolveRequestBaseUrl();
|
||||
if (endUserId != null && !endUserId.isBlank()) {
|
||||
// Third-party single-account integration: the requester is an external
|
||||
// end-user id, not a MateClaw account — no requesterUserId to assert.
|
||||
return vip.mate.agent.context.ChatOrigin
|
||||
.web(conversationId, endUserId.trim(), workspaceId, null, baseUrl)
|
||||
.withSender(null, "api", null);
|
||||
}
|
||||
return vip.mate.agent.context.ChatOrigin.web(conversationId, username, workspaceId, null, baseUrl);
|
||||
// Authenticated web user: carry the immutable id so on-behalf-of identity
|
||||
// forwarding can assert "MateClaw authenticated this user" (not an anon id).
|
||||
return vip.mate.agent.context.ChatOrigin.web(conversationId, username, workspaceId, null, baseUrl, requesterUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the authenticated user's immutable numeric id from the
|
||||
* {@link Authentication} details (stamped by {@code JwtAuthFilter} for both
|
||||
* the JWT and PAT paths). Null when not authenticated or details absent.
|
||||
*/
|
||||
private Long requesterUserIdOf(org.springframework.security.core.Authentication auth) {
|
||||
if (auth == null) return null;
|
||||
Object details = auth.getDetails();
|
||||
return details instanceof Long id ? id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1343,6 +1390,9 @@ public class ChatController {
|
||||
persistStatus,
|
||||
accumulator.getPromptTokens(),
|
||||
accumulator.getCompletionTokens(),
|
||||
accumulator.getCacheReadTokens(),
|
||||
accumulator.getCacheWriteTokens(),
|
||||
accumulator.getReasoningTokens(),
|
||||
accumulator.getRuntimeModelName(),
|
||||
accumulator.getRuntimeProviderId(),
|
||||
accumulator.toMetadataJson());
|
||||
@ -1395,6 +1445,9 @@ public class ChatController {
|
||||
"failed",
|
||||
accumulator.getPromptTokens(),
|
||||
accumulator.getCompletionTokens(),
|
||||
accumulator.getCacheReadTokens(),
|
||||
accumulator.getCacheWriteTokens(),
|
||||
accumulator.getReasoningTokens(),
|
||||
accumulator.getRuntimeModelName(),
|
||||
accumulator.getRuntimeProviderId(),
|
||||
accumulator.toMetadataJson());
|
||||
@ -1484,6 +1537,31 @@ public class ChatController {
|
||||
return savedAssistant != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the value stored in {@code ChatUploadResponse.path} (and, downstream,
|
||||
* the message content part): a root-relative path like
|
||||
* {@code chat-uploads/{convId}/{storedName}}, never the absolute on-disk
|
||||
* location.
|
||||
* <p>
|
||||
* {@code uploadRoot} is always absolute (the resolver normalizes it via
|
||||
* {@code toAbsolutePath().normalize()}), and this field is purely
|
||||
* informational — it is rendered into the LLM prompt ("附件: foo (path)") and
|
||||
* returned to the client, while retrieval goes through the basename-based
|
||||
* {@code ChatUploadResolver} plus the {@code /api/v1/chat/files/...} URL. So
|
||||
* the absolute form must be avoided: it leaks the server's filesystem layout
|
||||
* into the prompt/response and breaks if the deploy directory ever moves.
|
||||
* <p>
|
||||
* The path is made relative to {@code uploadRoot}'s parent so the trailing
|
||||
* upload sub-directory name is preserved (e.g. {@code chat-uploads/...}), and
|
||||
* separators are normalized to {@code /} so the value is stable across OSes.
|
||||
*/
|
||||
static String toRelativeUploadPath(Path uploadRoot, String conversationId, String storedName) {
|
||||
Path target = uploadRoot.resolve(conversationId).resolve(storedName);
|
||||
Path base = uploadRoot.getParent();
|
||||
Path relative = base != null ? base.relativize(target) : target;
|
||||
return relative.toString().replace('\\', '/');
|
||||
}
|
||||
|
||||
private MessageEntity saveEmptyAssistantPlaceholder(String conversationId, String status,
|
||||
StreamAccumulator accumulator, String source) {
|
||||
log.warn("{} with empty accumulator: conversationId={}, status={}, finishReason={}, phase={}, hasSegments={}",
|
||||
@ -1493,6 +1571,9 @@ public class ChatController {
|
||||
emptyAssistantPlaceholder(status), null, status,
|
||||
accumulator.getPromptTokens(),
|
||||
accumulator.getCompletionTokens(),
|
||||
accumulator.getCacheReadTokens(),
|
||||
accumulator.getCacheWriteTokens(),
|
||||
accumulator.getReasoningTokens(),
|
||||
accumulator.getRuntimeModelName(),
|
||||
accumulator.getRuntimeProviderId(),
|
||||
accumulator.toMetadataJson());
|
||||
@ -1540,6 +1621,19 @@ public class ChatController {
|
||||
}
|
||||
if (promptTokens > 0) payload.put("promptTokens", promptTokens);
|
||||
if (completionTokens > 0) payload.put("completionTokens", completionTokens);
|
||||
// Cache / reasoning detail rides on the persisted row so the live bubble
|
||||
// can render the usage breakdown without waiting for a history reload.
|
||||
if (savedAssistant != null) {
|
||||
if (savedAssistant.getCacheReadTokens() != null && savedAssistant.getCacheReadTokens() > 0) {
|
||||
payload.put("cacheReadTokens", savedAssistant.getCacheReadTokens());
|
||||
}
|
||||
if (savedAssistant.getCacheWriteTokens() != null && savedAssistant.getCacheWriteTokens() > 0) {
|
||||
payload.put("cacheWriteTokens", savedAssistant.getCacheWriteTokens());
|
||||
}
|
||||
if (savedAssistant.getReasoningTokens() != null && savedAssistant.getReasoningTokens() > 0) {
|
||||
payload.put("reasoningTokens", savedAssistant.getReasoningTokens());
|
||||
}
|
||||
}
|
||||
payload.put("persisted", persisted);
|
||||
if (messageCount != null) payload.put("messageCount", messageCount);
|
||||
return payload;
|
||||
@ -1594,6 +1688,9 @@ public class ChatController {
|
||||
status,
|
||||
accumulator.getPromptTokens(),
|
||||
accumulator.getCompletionTokens(),
|
||||
accumulator.getCacheReadTokens(),
|
||||
accumulator.getCacheWriteTokens(),
|
||||
accumulator.getReasoningTokens(),
|
||||
accumulator.getRuntimeModelName(),
|
||||
accumulator.getRuntimeProviderId(),
|
||||
accumulator.toMetadataJson());
|
||||
@ -1717,6 +1814,11 @@ public class ChatController {
|
||||
|| lower.contains("client abort") || lower.contains("closed");
|
||||
}
|
||||
|
||||
/** Markdown link pointing at a generated-file download URL. Used by the
|
||||
* StreamAccumulator to surface generated artifacts in the run-overview rail. */
|
||||
private static final Pattern GENERATED_FILE_LINK_PATTERN =
|
||||
Pattern.compile("\\[([^\\]]+)\\]\\(((?:https?://[^/\\s)\\]]+)?/api/v1/files/generated/[A-Za-z0-9-]+)\\)");
|
||||
|
||||
/**
|
||||
* 流式累积器 — 收集 StreamDelta 事件,持久化到 DB。
|
||||
* <p>
|
||||
@ -1739,9 +1841,14 @@ public class ChatController {
|
||||
private final List<Map<String, Object>> planStepResults = new ArrayList<>();
|
||||
/** RFC-052: tool names whose returnDirect output was folded into the assistant message */
|
||||
private final List<String> directToolNames = new ArrayList<>();
|
||||
/** Generated file artifacts extracted from tool results — surfaced in the run-overview rail. */
|
||||
private final List<Map<String, Object>> generatedFiles = new ArrayList<>();
|
||||
private int segCounter = 0;
|
||||
private int promptTokens = 0;
|
||||
private int completionTokens = 0;
|
||||
private int cacheReadTokens = 0;
|
||||
private int cacheWriteTokens = 0;
|
||||
private int reasoningTokens = 0;
|
||||
private String runtimeModelName = "";
|
||||
private String runtimeProviderId = "";
|
||||
private boolean awaitingApproval = false;
|
||||
@ -1788,6 +1895,9 @@ public class ChatController {
|
||||
Map<String, Object> data = delta.eventData();
|
||||
promptTokens = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
|
||||
completionTokens = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
|
||||
cacheReadTokens = ((Number) data.getOrDefault("cacheReadTokens", 0)).intValue();
|
||||
cacheWriteTokens = ((Number) data.getOrDefault("cacheWriteTokens", 0)).intValue();
|
||||
reasoningTokens = ((Number) data.getOrDefault("reasoningTokens", 0)).intValue();
|
||||
runtimeModelName = String.valueOf(data.getOrDefault("runtimeModelName", ""));
|
||||
runtimeProviderId = String.valueOf(data.getOrDefault("runtimeProviderId", ""));
|
||||
return;
|
||||
@ -2010,6 +2120,30 @@ public class ChatController {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Extract generated-file links from the tool result so the
|
||||
// run-overview rail can surface artifacts without re-scanning
|
||||
// segments on the frontend.
|
||||
extractGeneratedFiles(String.valueOf(data.getOrDefault("result", "")), toolName);
|
||||
}
|
||||
}
|
||||
|
||||
/** Scan a tool result for markdown links pointing at generated-file
|
||||
* download URLs and collect them into {@link #generatedFiles}.
|
||||
* De-duplicates by URL so a link echoed in later tool results doesn't
|
||||
* produce duplicate entries in the run-overview rail. */
|
||||
private void extractGeneratedFiles(String result, String toolName) {
|
||||
if (result == null || result.isBlank()) return;
|
||||
Matcher m = GENERATED_FILE_LINK_PATTERN.matcher(result);
|
||||
while (m.find()) {
|
||||
String url = m.group(2);
|
||||
boolean dup = generatedFiles.stream()
|
||||
.anyMatch(f -> url.equals(String.valueOf(f.get("url"))));
|
||||
if (dup) continue;
|
||||
Map<String, Object> file = new LinkedHashMap<>();
|
||||
file.put("filename", m.group(1));
|
||||
file.put("url", url);
|
||||
file.put("toolName", toolName);
|
||||
generatedFiles.add(file);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2052,6 +2186,9 @@ public class ChatController {
|
||||
String getThinking() { return thinking.toString().trim(); }
|
||||
int getPromptTokens() { return promptTokens; }
|
||||
int getCompletionTokens() { return completionTokens; }
|
||||
int getCacheReadTokens() { return cacheReadTokens; }
|
||||
int getCacheWriteTokens() { return cacheWriteTokens; }
|
||||
int getReasoningTokens() { return reasoningTokens; }
|
||||
String getRuntimeModelName() { return runtimeModelName; }
|
||||
String getRuntimeProviderId() { return runtimeProviderId; }
|
||||
String getCurrentPhase() { return currentPhase; }
|
||||
@ -2133,6 +2270,9 @@ public class ChatController {
|
||||
// historical messages as "data returned directly by tool".
|
||||
metadata.put("directToolNames", directToolNames);
|
||||
}
|
||||
if (!generatedFiles.isEmpty()) {
|
||||
metadata.put("generatedFiles", generatedFiles);
|
||||
}
|
||||
if (!finishReason.isEmpty()) {
|
||||
// Surface graph FinishReason so MemorySummarizationGate and
|
||||
// any other downstream consumer can branch on a structured
|
||||
|
||||
@ -47,6 +47,10 @@ import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
import reactor.core.Disposable;
|
||||
import vip.mate.approval.PendingApproval;
|
||||
import vip.mate.approval.ResolveOutcome;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
|
||||
/**
|
||||
* WebChat 嵌入式对话接口
|
||||
@ -79,6 +83,14 @@ public class WebChatController {
|
||||
private final vip.mate.skill.repository.SkillMapper skillMapper;
|
||||
private final vip.mate.wiki.repository.WikiPageMapper wikiPageMapper;
|
||||
private final vip.mate.wiki.repository.WikiKnowledgeBaseMapper wikiKbMapper;
|
||||
/**
|
||||
* ISSUE #413 P1-A2/A3/A4: drives the approval lifecycle for WebChat
|
||||
* (API-Key) channels. Before this, a tool guarded by ToolGuard would
|
||||
* create a pending approval and park the turn, but the visitor had no
|
||||
* way to resolve it -- the approval hung until the 30-min GC timeout
|
||||
* and the turn was wasted.
|
||||
*/
|
||||
private final vip.mate.approval.ApprovalWorkflowService approvalService;
|
||||
|
||||
/** Visitor-token TTL in seconds (7 days). Mirrors GeneratedFileCache's TTL. */
|
||||
static final long VISITOR_TOKEN_TTL_SECONDS = 7 * 24 * 3600L;
|
||||
@ -200,7 +212,7 @@ public class WebChatController {
|
||||
// delta is not a persistence-only echo of content already streamed by inner nodes.
|
||||
StringBuilder assistantReply = new StringBuilder();
|
||||
// Token usage + model attribution: capture _usage_final event emitted at stream end
|
||||
final int[] usage = {0, 0}; // [promptTokens, completionTokens]
|
||||
final int[] usage = {0, 0, 0, 0, 0}; // [prompt, completion, cacheRead, cacheWrite, reasoning]
|
||||
final String[] modelInfo = {null, null}; // [runtimeModel, runtimeProvider]
|
||||
|
||||
// Attribute memory to this external visitor so each end-user
|
||||
@ -218,6 +230,9 @@ public class WebChatController {
|
||||
Map<String, Object> data = delta.eventData();
|
||||
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
|
||||
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
|
||||
usage[2] = ((Number) data.getOrDefault("cacheReadTokens", 0)).intValue();
|
||||
usage[3] = ((Number) data.getOrDefault("cacheWriteTokens", 0)).intValue();
|
||||
usage[4] = ((Number) data.getOrDefault("reasoningTokens", 0)).intValue();
|
||||
Object model = data.get("runtimeModelName");
|
||||
Object provider = data.get("runtimeProviderId");
|
||||
if (model != null) modelInfo[0] = model.toString();
|
||||
@ -253,7 +268,7 @@ public class WebChatController {
|
||||
if (!reply.isBlank()) {
|
||||
conversationService.saveMessage(
|
||||
conversationId, "assistant", reply, List.of(),
|
||||
"completed", usage[0], usage[1], modelInfo[0], modelInfo[1]);
|
||||
"completed", usage[0], usage[1], usage[2], usage[3], usage[4], modelInfo[0], modelInfo[1], null);
|
||||
}
|
||||
completionPublisher.publish(
|
||||
resolvedAgentId, conversationId, message, reply, "webchat", webchatOwnerKey);
|
||||
@ -1013,9 +1028,12 @@ public class WebChatController {
|
||||
* Disposable 实际中断 Flux;返回 {@code stopped=false} 表示当前没有活跃流
|
||||
* (幂等,不报错)。
|
||||
* <p>
|
||||
* 不做 approval sweep:webchat 渠道目前不暴露 approval UI,且无 MateClaw
|
||||
* username 可传给 {@code denyAllByConversation}。若未来 webchat 接入审批流,
|
||||
* 再单独评估是否补这层。
|
||||
* Approval sweep (ISSUE #413 P1-A4): deny any pending approvals on this
|
||||
* conversation so they do not hang for 30 minutes until the GC timeout.
|
||||
* The visitor username derived from visitorId is the actor -- it resolves
|
||||
* the "no MateClaw username" blocker noted in the old javadoc.
|
||||
* Each denied approval broadcasts a { tool_approval_resolved} SSE
|
||||
* event so the SDK clears its banner immediately.
|
||||
*/
|
||||
@Operation(summary = "停止访客会话线程的进行中流")
|
||||
@PostMapping("/sessions/stop")
|
||||
@ -1049,9 +1067,319 @@ public class WebChatController {
|
||||
conversationId, visitorId, stopped);
|
||||
audit(channel, visitorId, "webchat.stop-session", conversationId,
|
||||
"{\"sessionId\":\"" + sid + "\",\"stopped\":" + stopped + "}");
|
||||
// ISSUE #413 P1-A4: deny pending approvals so they do not linger for
|
||||
// 30 min waiting on a GC timeout. The visitor username is the actor,
|
||||
// mirroring how the web ChatController uses the logged-in username.
|
||||
int deniedCount = 0;
|
||||
try {
|
||||
java.util.List<ResolveOutcome> denied =
|
||||
approvalService.denyAllByConversation(conversationId, webchatUsername(visitorId));
|
||||
deniedCount = denied.size();
|
||||
for (ResolveOutcome o : denied) {
|
||||
try {
|
||||
streamTracker.broadcast(conversationId, "tool_approval_resolved",
|
||||
objectMapper.writeValueAsString(java.util.Map.of(
|
||||
"pendingId", o.pendingId(),
|
||||
"decision", "denied",
|
||||
"toolName", o.toolName() != null ? o.toolName() : "")));
|
||||
} catch (Exception broadcastErr) {
|
||||
log.debug("[WebChat] approval_resolved broadcast failed for {}: {}",
|
||||
o.pendingId(), broadcastErr.getMessage());
|
||||
}
|
||||
}
|
||||
} catch (Exception sweepErr) {
|
||||
log.warn("[WebChat] approval sweep failed for {}: {}", conversationId, sweepErr.getMessage());
|
||||
}
|
||||
if (deniedCount > 0) {
|
||||
log.info("[WebChat] Denied {} pending approval(s) on stop for {}", deniedCount, conversationId);
|
||||
}
|
||||
return R.ok(Map.of("stopped", stopped));
|
||||
}
|
||||
|
||||
/**
|
||||
* 拒绝一个待审批的工具调用 (ISSUE #413 P1-A2)。
|
||||
* <p>
|
||||
* 鉴权同其它会话管理端点 (API Key + visitorToken + 会话归属)。仅允许
|
||||
* 发起对话的访客拒绝自己会话的审批 —— 身份校验通过
|
||||
* {@code webchatUsername(visitorId)} 与 pending.userId 的等价比较
|
||||
* (对位 IM 渠道的 senderId == requester 校验)。
|
||||
* <p>
|
||||
* resolve 后立即广播 {@code tool_approval_resolved} SSE 事件,让 SDK
|
||||
* 实时清理审批 banner。返回同步 JSON (非 SSE),因为 deny 不需要重放工具。
|
||||
*
|
||||
* @param pendingId the approval pendingId returned in the
|
||||
* {@code tool_approval_requested} event
|
||||
*/
|
||||
@Operation(summary = "拒绝访客会话中的待审批工具调用")
|
||||
@PostMapping("/sessions/deny")
|
||||
public R<Map<String, Object>> denySession(
|
||||
@RequestHeader("X-MC-Key") String apiKey,
|
||||
@RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken,
|
||||
@RequestParam String visitorId,
|
||||
@RequestParam(required = false) String sessionId,
|
||||
@RequestParam String pendingId) {
|
||||
ChannelEntity channel = resolveChannel(apiKey);
|
||||
if (channel == null) {
|
||||
return R.fail(401, "Invalid API Key");
|
||||
}
|
||||
if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) {
|
||||
return R.fail(401, "Invalid or missing visitor token");
|
||||
}
|
||||
String sid;
|
||||
try {
|
||||
sid = normalizeSessionId(sessionId);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return R.fail(400, ex.getMessage());
|
||||
}
|
||||
String conversationId = deriveConversationId(apiKey, visitorId, sid);
|
||||
if (!ownsConversation(conversationId, visitorId)) {
|
||||
return R.fail(404, "Session not found");
|
||||
}
|
||||
// IDOR guard (review #415): the caller owns the conversation, but the
|
||||
// pendingId is client-supplied — cross-check that the pending actually
|
||||
// belongs to this conversation before resolving, otherwise a visitor
|
||||
// could resolve / replay another visitor's guarded tool call.
|
||||
// getPending(pendingId) gives the exact record (vs findPendingByConversation
|
||||
// which returns the earliest, wrong when several pendings coexist).
|
||||
var ownedOpt = approvalService.getPending(pendingId);
|
||||
if (ownedOpt.isEmpty()
|
||||
|| !conversationId.equals(ownedOpt.get().getConversationId())) {
|
||||
return R.fail(404, "Pending approval not found for this session");
|
||||
}
|
||||
// Resolve and broadcast outside the persistence transaction: SSE is
|
||||
// not rollback-capable, so the broadcast must follow a committed DB write.
|
||||
String actor = webchatUsername(visitorId);
|
||||
ResolveOutcome outcome = approvalService.resolve(pendingId, actor, "denied");
|
||||
broadcastApprovalResolved(conversationId, outcome);
|
||||
audit(channel, visitorId, "webchat.deny-approval", conversationId,
|
||||
"{\"pendingId\":\"" + escapeJson(pendingId) + "\",\"resolved\":"
|
||||
+ outcome.dbSynced() + "}");
|
||||
return R.ok(Map.of("resolved", outcome.dbSynced(), "decision", outcome.decision()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批准一个待审批的工具调用并重放 (ISSUE #413 P1-A2 + P1-A3)。
|
||||
* <p>
|
||||
* 与 deny 不同,approve 返回 SSE 流:原子消费审批记录后,用捕获的
|
||||
* toolCallPayload 重放工具调用,把工具结果回灌 agent 继续本轮对话。
|
||||
* 重放模式对位 web 渠道的 ChatController —— 复用
|
||||
* {@code chatWithReplayStream} + {@code restoreChatOrigin} 恢复原始
|
||||
* ChatOrigin (webchat origin 在 createPending 时已通过 ChatOriginHolder
|
||||
* 持久化到 approval 行)。
|
||||
* <p>
|
||||
* 重放期间可能再次触发审批 (一个工具批准后 agent 可能调用下一个受保护
|
||||
* 工具) —— 该场景由 {@code tool_approval_requested} 直推事件自然覆盖,
|
||||
* 无需特殊处理。事件投递走与 {@link #chatStream} 相同的 broadcast 路径。
|
||||
*
|
||||
* @param pendingId the approval pendingId returned in the
|
||||
* {@code tool_approval_requested} event
|
||||
*/
|
||||
@Operation(summary = "批准访客会话中的待审批工具调用并重放")
|
||||
@PostMapping(value = "/sessions/approve", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public SseEmitter approveSession(
|
||||
@RequestHeader("X-MC-Key") String apiKey,
|
||||
@RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken,
|
||||
@RequestParam String visitorId,
|
||||
@RequestParam(required = false) String sessionId,
|
||||
@RequestParam String pendingId) {
|
||||
SseEmitter emitter = new Utf8SseEmitter(10 * 60 * 1000L);
|
||||
ChannelEntity channel = resolveChannel(apiKey);
|
||||
if (channel == null) {
|
||||
sendErrorAndComplete(emitter, "Invalid API Key");
|
||||
return emitter;
|
||||
}
|
||||
if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) {
|
||||
sendErrorAndComplete(emitter, "Invalid or missing visitor token");
|
||||
return emitter;
|
||||
}
|
||||
String sid;
|
||||
try {
|
||||
sid = normalizeSessionId(sessionId);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
sendErrorAndComplete(emitter, ex.getMessage());
|
||||
return emitter;
|
||||
}
|
||||
String conversationId = deriveConversationId(apiKey, visitorId, sid);
|
||||
if (!ownsConversation(conversationId, visitorId)) {
|
||||
sendErrorAndComplete(emitter, "Session not found");
|
||||
return emitter;
|
||||
}
|
||||
// IDOR guard (review #415): cross-check the client-supplied pendingId
|
||||
// actually belongs to this conversation before resolving, otherwise a
|
||||
// visitor could approve + replay another visitor's guarded tool call.
|
||||
var ownedApprovalOpt = approvalService.getPending(pendingId);
|
||||
if (ownedApprovalOpt.isEmpty()
|
||||
|| !conversationId.equals(ownedApprovalOpt.get().getConversationId())) {
|
||||
sendErrorAndComplete(emitter, "Pending approval not found for this session");
|
||||
return emitter;
|
||||
}
|
||||
|
||||
emitter.onCompletion(() -> log.debug("[WebChat] approve SSE completed: {}", conversationId));
|
||||
emitter.onTimeout(() -> {
|
||||
log.debug("[WebChat] approve SSE timeout: {}", conversationId);
|
||||
streamTracker.complete(conversationId);
|
||||
});
|
||||
emitter.onError(e -> {
|
||||
log.debug("[WebChat] approve SSE error: {} - {}", conversationId, e.getMessage());
|
||||
streamTracker.complete(conversationId);
|
||||
});
|
||||
|
||||
String actor = webchatUsername(visitorId);
|
||||
sseExecutor.execute(() -> {
|
||||
// Register + attach the emitter FIRST so every downstream branch
|
||||
// (already-resolved, no-agent, error, replay) can broadcast a
|
||||
// terminal event the SDK actually receives. Doing this after
|
||||
// resolveAndConsume left the already-resolved / error paths
|
||||
// broadcasting into a subscriber-less tracker, so the SSE hung
|
||||
// to the 10-min timeout (review #415).
|
||||
streamTracker.register(conversationId);
|
||||
streamTracker.attach(conversationId, emitter);
|
||||
try {
|
||||
// Atomically consume the approval (DB + metadata + memory, single tx).
|
||||
ResolveOutcome consumed = approvalService.resolveAndConsume(pendingId, actor);
|
||||
if (consumed.consumedSnapshot() == null) {
|
||||
// already resolved / not found — emit a terminal done so the
|
||||
// SDK's stream listener closes cleanly instead of hanging.
|
||||
broadcastApprovalResolved(conversationId, consumed);
|
||||
streamTracker.broadcast(conversationId, "done",
|
||||
"{\"status\":\"already_resolved\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Notify the SDK the approval flipped (clears the banner) before
|
||||
// replay output starts streaming.
|
||||
broadcastApprovalResolved(conversationId, consumed);
|
||||
|
||||
PendingApproval snapshot = consumed.consumedSnapshot();
|
||||
Long replayAgentId = snapshot.getAgentId() != null
|
||||
? parseLongOrNull(snapshot.getAgentId()) : null;
|
||||
if (replayAgentId == null) {
|
||||
log.warn("[WebChat] approve: no agentId on consumed approval {}, cannot replay",
|
||||
pendingId);
|
||||
streamTracker.broadcast(conversationId, "done",
|
||||
"{\"status\":\"error\",\"message\":\"No agent bound to approval\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Restore the original ChatOrigin captured at createPending time.
|
||||
// Falls back to a fresh webchat origin when none was persisted
|
||||
// (defensive — mirrors ChatController:304-306).
|
||||
ChatOrigin replayOrigin =
|
||||
approvalService.restoreChatOrigin(snapshot.getChatOrigin());
|
||||
if (replayOrigin == ChatOrigin.EMPTY) {
|
||||
var agent = agentService.getAgent(replayAgentId);
|
||||
Long wsId = agent != null ? agent.getWorkspaceId() : 1L;
|
||||
replayOrigin = ChatOrigin.web(
|
||||
conversationId, actor, wsId, null).withSender(null, "api", null);
|
||||
}
|
||||
|
||||
// Neutral replay prompt (aligned with IM + web channels — naming a
|
||||
// tool here can mislead the LLM on fallthrough).
|
||||
String replayPrompt = "继续执行已批准的工具调用。";
|
||||
StringBuilder assistantReply = new StringBuilder();
|
||||
final int[] usage = {0, 0};
|
||||
final String[] modelInfo = {null, null};
|
||||
|
||||
streamTracker.broadcast(conversationId, "message_start",
|
||||
"{\"role\":\"assistant\"}");
|
||||
|
||||
Disposable disposable = agentService.chatWithReplayStream(
|
||||
replayAgentId, replayPrompt, conversationId,
|
||||
snapshot.getToolCallPayload(), actor, replayOrigin)
|
||||
.doOnNext(delta -> {
|
||||
if (delta.isEvent() && "_usage_final".equals(delta.eventType())) {
|
||||
Map<String, Object> data = delta.eventData();
|
||||
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
|
||||
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
|
||||
usage[2] = ((Number) data.getOrDefault("cacheReadTokens", 0)).intValue();
|
||||
usage[3] = ((Number) data.getOrDefault("cacheWriteTokens", 0)).intValue();
|
||||
usage[4] = ((Number) data.getOrDefault("reasoningTokens", 0)).intValue();
|
||||
Object model = data.get("runtimeModelName");
|
||||
Object provider = data.get("runtimeProviderId");
|
||||
if (model != null) modelInfo[0] = model.toString();
|
||||
if (provider != null) modelInfo[1] = provider.toString();
|
||||
}
|
||||
if (delta.isEvent()) {
|
||||
forwardVisitorEvent(conversationId, delta.eventType(), delta.eventData());
|
||||
}
|
||||
if (delta.content() != null && !delta.content().isEmpty()) {
|
||||
assistantReply.append(delta.content());
|
||||
if (!delta.persistenceOnly()) {
|
||||
streamTracker.broadcast(conversationId, "content_delta",
|
||||
"{\"text\":" + escapeJson(delta.content()) + "}");
|
||||
}
|
||||
}
|
||||
if (delta.thinking() != null && !delta.thinking().isEmpty()
|
||||
&& !delta.persistenceOnly()) {
|
||||
streamTracker.broadcast(conversationId, "thinking_delta",
|
||||
"{\"text\":" + escapeJson(delta.thinking()) + "}");
|
||||
}
|
||||
})
|
||||
.doOnComplete(() -> {
|
||||
String reply = assistantReply.toString();
|
||||
try {
|
||||
if (!reply.isBlank()) {
|
||||
conversationService.saveMessage(
|
||||
conversationId, "assistant", reply, List.of(),
|
||||
"completed", usage[0], usage[1], usage[2], usage[3], usage[4], modelInfo[0], modelInfo[1], null);
|
||||
}
|
||||
} catch (Exception persistErr) {
|
||||
log.warn("[WebChat] approve replay persist failed: {}", persistErr.getMessage());
|
||||
}
|
||||
streamTracker.broadcast(conversationId, "done",
|
||||
"{\"status\":\"completed\"}");
|
||||
streamTracker.complete(conversationId);
|
||||
})
|
||||
.doOnError(e -> {
|
||||
log.error("[WebChat] approve replay stream error: {}", e.getMessage());
|
||||
streamTracker.broadcast(conversationId, "error",
|
||||
"{\"message\":" + escapeJson(e.getMessage()) + "}");
|
||||
streamTracker.complete(conversationId);
|
||||
})
|
||||
.subscribe();
|
||||
streamTracker.setDisposable(conversationId, disposable);
|
||||
} catch (Exception e) {
|
||||
log.error("[WebChat] approve failed for {}: {}", conversationId, e.getMessage());
|
||||
try {
|
||||
streamTracker.broadcast(conversationId, "error",
|
||||
"{\"message\":" + escapeJson(e.getMessage()) + "}");
|
||||
} catch (Exception ignored) {}
|
||||
streamTracker.complete(conversationId);
|
||||
}
|
||||
});
|
||||
audit(channel, visitorId, "webchat.approve-approval", conversationId,
|
||||
"{\"pendingId\":\"" + escapeJson(pendingId) + "\",\"replay\":true}");
|
||||
return emitter;
|
||||
}
|
||||
|
||||
/** Parse a Long leniently; null/blank/non-numeric return null. */
|
||||
private static Long parseLongOrNull(String s) {
|
||||
if (s == null || s.isBlank()) return null;
|
||||
try {
|
||||
return Long.parseLong(s.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast a {@code tool_approval_resolved} event so the SDK clears its
|
||||
* approval banner in real time. Shared by approve / deny / stop-sweep.
|
||||
* (ISSUE #413 P1)
|
||||
*/
|
||||
private void broadcastApprovalResolved(String conversationId, ResolveOutcome outcome) {
|
||||
try {
|
||||
streamTracker.broadcast(conversationId, "tool_approval_resolved",
|
||||
objectMapper.writeValueAsString(Map.of(
|
||||
"pendingId", outcome.pendingId(),
|
||||
"decision", outcome.decision() != null ? outcome.decision() : "",
|
||||
"toolName", outcome.toolName() != null ? outcome.toolName() : "")));
|
||||
} catch (Exception e) {
|
||||
log.debug("[WebChat] approval_resolved broadcast failed for {}: {}",
|
||||
outcome.pendingId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新生成最后一条助手回复。
|
||||
* <p>
|
||||
|
||||
@ -5,11 +5,11 @@ import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import vip.mate.workspace.core.service.ChatUploadLocationResolver;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
@ -39,9 +39,6 @@ import java.util.stream.Stream;
|
||||
@Service
|
||||
public class WebChatFileService {
|
||||
|
||||
/** Shared with the JWT chat upload dir so deleteConversation cleanup applies. */
|
||||
private static final Path UPLOAD_ROOT = Paths.get("data", "chat-uploads");
|
||||
|
||||
/** How long an uploaded-but-unreferenced file lingers before the sweep removes it. */
|
||||
private static final long STAGING_TTL_MS = 60 * 60 * 1000L; // 1 hour
|
||||
|
||||
@ -50,6 +47,7 @@ public class WebChatFileService {
|
||||
private final Set<String> allowedExtensions;
|
||||
private final int maxFilesPerConversation;
|
||||
private final long maxTotalBytesPerConversation;
|
||||
private final ChatUploadLocationResolver uploadLocationResolver;
|
||||
|
||||
/** fileId (== storedName) -> staged metadata, pending a /stream reference. */
|
||||
private final ConcurrentHashMap<String, StagedFile> staged = new ConcurrentHashMap<>();
|
||||
@ -61,7 +59,8 @@ public class WebChatFileService {
|
||||
+ "png,jpg,jpeg,gif,webp,bmp,pdf,txt,md,csv,json,log,"
|
||||
+ "doc,docx,xls,xlsx,ppt,pptx,zip,mp3,wav,m4a,mp4,mov,webm}") String allowedExtensionsCsv,
|
||||
@Value("${mateclaw.webchat.upload.max-files-per-conversation:50}") int maxFilesPerConversation,
|
||||
@Value("${mateclaw.webchat.upload.max-total-mb-per-conversation:200}") long maxTotalMbPerConversation) {
|
||||
@Value("${mateclaw.webchat.upload.max-total-mb-per-conversation:200}") long maxTotalMbPerConversation,
|
||||
ChatUploadLocationResolver uploadLocationResolver) {
|
||||
this.enabled = enabled;
|
||||
this.maxSizeBytes = maxSizeMb * 1024 * 1024;
|
||||
this.allowedExtensions = Arrays.stream(allowedExtensionsCsv.split(","))
|
||||
@ -70,6 +69,7 @@ public class WebChatFileService {
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
this.maxFilesPerConversation = maxFilesPerConversation;
|
||||
this.maxTotalBytesPerConversation = maxTotalMbPerConversation * 1024 * 1024;
|
||||
this.uploadLocationResolver = uploadLocationResolver;
|
||||
}
|
||||
|
||||
/** Metadata for a staged upload. */
|
||||
@ -111,7 +111,7 @@ public class WebChatFileService {
|
||||
|
||||
String originalName = file.getOriginalFilename() != null ? file.getOriginalFilename() : "file";
|
||||
// Strip any directory components, then collapse to a safe charset.
|
||||
String baseName = Paths.get(originalName).getFileName().toString();
|
||||
String baseName = Path.of(originalName).getFileName().toString();
|
||||
String ext = extensionOf(baseName);
|
||||
if (ext.isEmpty() || !allowedExtensions.contains(ext)) {
|
||||
throw new UploadRejectedException("File type not allowed: ." + ext);
|
||||
@ -119,8 +119,9 @@ public class WebChatFileService {
|
||||
String safeName = baseName.replaceAll("[^a-zA-Z0-9._-]", "_");
|
||||
|
||||
String storedName = UUID.randomUUID() + "_" + safeName;
|
||||
Path dir = UPLOAD_ROOT.resolve(conversationId).normalize();
|
||||
if (!dir.startsWith(UPLOAD_ROOT.normalize())) {
|
||||
Path uploadRoot = uploadLocationResolver.resolveUploadRoot(conversationId).normalize();
|
||||
Path dir = uploadRoot.resolve(conversationId).normalize();
|
||||
if (!dir.startsWith(uploadRoot)) {
|
||||
// conversationId is server-derived, so this should never happen; fail closed if it does.
|
||||
throw new UploadRejectedException("Invalid conversation");
|
||||
}
|
||||
@ -169,12 +170,16 @@ public class WebChatFileService {
|
||||
if (storedName == null || storedName.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Path base = UPLOAD_ROOT.resolve(conversationId).normalize();
|
||||
Path file = base.resolve(storedName).normalize();
|
||||
if (!file.startsWith(base) || !Files.exists(file) || !Files.isRegularFile(file)) {
|
||||
return Optional.empty();
|
||||
// Check every candidate root (workspace-scoped dir + legacy default dir)
|
||||
// so files written before the workspace-aware relocation still resolve.
|
||||
for (Path root : uploadLocationResolver.resolveCandidateUploadRoots(conversationId)) {
|
||||
Path base = root.resolve(conversationId).normalize();
|
||||
Path file = base.resolve(storedName).normalize();
|
||||
if (file.startsWith(base) && Files.exists(file) && Files.isRegularFile(file)) {
|
||||
return Optional.of(file);
|
||||
}
|
||||
}
|
||||
return Optional.of(file);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/** Map a content type to the MessageContentPart type the agent/UI understands. */
|
||||
|
||||
@ -280,6 +280,12 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
||||
*/
|
||||
private final vip.mate.tool.document.GeneratedFileCache generatedFileCache;
|
||||
|
||||
/**
|
||||
* Workspace/agent-aware upload-root resolver, set by the production factory.
|
||||
* Null in unit tests (the legacy {@code data/chat-uploads} default applies).
|
||||
*/
|
||||
private vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver;
|
||||
|
||||
public WeComChannelAdapter(ChannelEntity channelEntity,
|
||||
ChannelMessageRouter messageRouter,
|
||||
ObjectMapper objectMapper,
|
||||
@ -297,11 +303,30 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
||||
vip.mate.channel.wecom.cards.WeComCardDispatcher cardDispatcher,
|
||||
WeComKeepaliveScheduler keepaliveScheduler,
|
||||
vip.mate.tool.document.GeneratedFileCache generatedFileCache) {
|
||||
this(channelEntity, messageRouter, objectMapper, approvalNotificationService,
|
||||
cardDispatcher, keepaliveScheduler, generatedFileCache, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full constructor used by the production factory (ChannelManager). The
|
||||
* trailing {@code chatUploadLocationResolver} enables workspace/agent-aware
|
||||
* attachment storage; {@code null} keeps the legacy {@code data/chat-uploads}
|
||||
* behaviour.
|
||||
*/
|
||||
public WeComChannelAdapter(ChannelEntity channelEntity,
|
||||
ChannelMessageRouter messageRouter,
|
||||
ObjectMapper objectMapper,
|
||||
vip.mate.channel.notification.ApprovalNotificationService approvalNotificationService,
|
||||
vip.mate.channel.wecom.cards.WeComCardDispatcher cardDispatcher,
|
||||
WeComKeepaliveScheduler keepaliveScheduler,
|
||||
vip.mate.tool.document.GeneratedFileCache generatedFileCache,
|
||||
vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver) {
|
||||
super(channelEntity, messageRouter, objectMapper);
|
||||
this.approvalNotificationService = approvalNotificationService;
|
||||
this.cardDispatcher = cardDispatcher;
|
||||
this.keepaliveScheduler = keepaliveScheduler;
|
||||
this.generatedFileCache = generatedFileCache;
|
||||
this.chatUploadLocationResolver = chatUploadLocationResolver;
|
||||
// Default to 8 bounded attempts (~4 minutes total at 2s..30s exponential)
|
||||
// so the UI eventually settles in ERROR instead of getting stuck in
|
||||
// RECONNECTING forever. User config still overrides (-1 = infinite).
|
||||
@ -2936,14 +2961,17 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
||||
*/
|
||||
private InboundMediaDownloader.DownloadedMedia downloadInboundMedia(String url, String aesKey, String msgId,
|
||||
String fileNameHint, String conversationId) {
|
||||
// Store under data/chat-uploads/{conversationId} so the existing
|
||||
// Store under the workspace/agent-aware upload root ({convId}/ subdir),
|
||||
// falling back to the legacy data/chat-uploads default, so the existing
|
||||
// /api/v1/chat/files/{convId}/{storedName} endpoint serves the file
|
||||
// back to the chat bubble — the WeCom CDN URL carries a short-lived
|
||||
// signature that expires before a browser can fetch it. The shared
|
||||
// pipeline owns retry/backoff, magic-byte type detection, and the
|
||||
// dedup-named write; the WeCom-specific AES-256-CBC decrypt stays here
|
||||
// inside the byte source so a fetch + decrypt is retried as one unit.
|
||||
Path uploadDir = Path.of("data", "chat-uploads", conversationId);
|
||||
Path uploadDir = (chatUploadLocationResolver != null)
|
||||
? chatUploadLocationResolver.resolveUploadRoot(conversationId).resolve(conversationId)
|
||||
: Path.of("data", "chat-uploads", conversationId);
|
||||
String hint = (fileNameHint == null || fileNameHint.isBlank()) ? null : fileNameHint;
|
||||
return InboundMediaDownloader.download(
|
||||
() -> {
|
||||
|
||||
@ -2,7 +2,9 @@ package vip.mate.channel.wecom.cards.tool_guard;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import vip.mate.approval.ApprovalService;
|
||||
import vip.mate.approval.ApprovalWorkflowService;
|
||||
import vip.mate.approval.PendingApproval;
|
||||
import vip.mate.approval.ResolveOutcome;
|
||||
import vip.mate.channel.ChannelMessage;
|
||||
import vip.mate.channel.wecom.WeComChannelAdapter;
|
||||
import vip.mate.channel.wecom.cards.WeComCardHandler;
|
||||
@ -37,10 +39,20 @@ import java.util.Optional;
|
||||
public class ToolGuardCardHandler implements WeComCardHandler {
|
||||
|
||||
private final ApprovalService approvalService;
|
||||
/**
|
||||
* ISSUE #413 P2-B3: resolves workflow-scoped ({@code wf-}) approvals
|
||||
* inline — the synthetic /approve injection is a dead end for wf- ids
|
||||
* (their conversationId is {@code workflow:run:{runId}}, unmatched by
|
||||
* any IM conversation). May be null in narrow test contexts.
|
||||
*/
|
||||
private final ApprovalWorkflowService approvalWorkflowService;
|
||||
private final ToolGuardButtonKey buttonKey;
|
||||
|
||||
public ToolGuardCardHandler(ApprovalService approvalService, ToolGuardButtonKey buttonKey) {
|
||||
public ToolGuardCardHandler(ApprovalService approvalService,
|
||||
ApprovalWorkflowService approvalWorkflowService,
|
||||
ToolGuardButtonKey buttonKey) {
|
||||
this.approvalService = approvalService;
|
||||
this.approvalWorkflowService = approvalWorkflowService;
|
||||
this.buttonKey = buttonKey;
|
||||
}
|
||||
|
||||
@ -76,6 +88,19 @@ public class ToolGuardCardHandler implements WeComCardHandler {
|
||||
PendingApproval pending = opt.get();
|
||||
|
||||
// ---- 3. Identity check (fail-closed) ----
|
||||
// Workflow-scoped approvals (wf- prefix, ISSUE #413 P2-B3) have no
|
||||
// human requester (userId is null — the run is system-initiated).
|
||||
// Their cards only reach the channels declared in await_approval's
|
||||
// approverChannels, so any audience member is a legitimate approver.
|
||||
// We resolve inline (no synthetic injection: the router path can't
|
||||
// route a workflow:run:{runId} conversationId) and the
|
||||
// ApprovalResumeBridge resumes the run off the resolved event.
|
||||
if (pendingId.startsWith("wf-")) {
|
||||
handleWorkflowApproval(adapter, eventReqId, taskId, pendingId,
|
||||
decoded.toolName(), action, clickerUserId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Agent/cron ("system") or unattributed (null) approvals have no human
|
||||
// requester to match the clicker against; a group card would let any
|
||||
// member resolve a guarded action. Reject here (mirrors the feishu card
|
||||
@ -117,6 +142,48 @@ public class ToolGuardCardHandler implements WeComCardHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Workflow-scoped approval (ISSUE #413 P2-B3)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve a {@code wf-} workflow approval inline, then render the
|
||||
* resolved card — both within the WeCom 5s callback window. The
|
||||
* synthetic /approve injection is bypassed because the router cannot
|
||||
* route a {@code workflow:run:{runId}} conversationId. The
|
||||
* {@link vip.mate.workflow.runtime.ApprovalResumeBridge} picks up the
|
||||
* {@code WorkflowApprovalResolvedEvent} published inside resolve and
|
||||
* resumes the paused run asynchronously.
|
||||
*
|
||||
* <p>Identity: any audience member may resolve — the card only reaches
|
||||
* the channels declared in {@code await_approval.approverChannels}.
|
||||
*/
|
||||
private void handleWorkflowApproval(WeComChannelAdapter adapter, String eventReqId, String taskId,
|
||||
String pendingId, String toolName,
|
||||
ToolGuardButtonKey.Action action, String clickerUserId) {
|
||||
if (approvalWorkflowService == null) {
|
||||
log.warn("[wecom-toolguard] ApprovalWorkflowService unavailable, cannot resolve wf- {} "
|
||||
+ "(use the admin console)", pendingId);
|
||||
renderExpired(adapter, eventReqId, taskId, toolName);
|
||||
return;
|
||||
}
|
||||
String decision = action == ToolGuardButtonKey.Action.APPROVE ? "approved" : "denied";
|
||||
try {
|
||||
ResolveOutcome outcome = approvalWorkflowService.resolve(pendingId, clickerUserId, decision);
|
||||
if (!outcome.dbSynced()) {
|
||||
log.info("[wecom-toolguard] wf- {} already resolved: {}", pendingId, outcome.decision());
|
||||
renderExpired(adapter, eventReqId, taskId, toolName);
|
||||
return;
|
||||
}
|
||||
log.info("[wecom-toolguard] Resolved wf- {} as {} by {} (run resume delegated to bridge)",
|
||||
pendingId, decision, abbrev(clickerUserId));
|
||||
renderResolved(adapter, eventReqId, taskId, toolName, action, clickerUserId);
|
||||
} catch (Exception e) {
|
||||
log.error("[wecom-toolguard] Failed to resolve wf- {}: {}", pendingId, e.getMessage(), e);
|
||||
renderExpired(adapter, eventReqId, taskId, toolName);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Card rendering helpers
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@ -3,6 +3,7 @@ package vip.mate.channel.wecom.cards.tool_guard;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.approval.ApprovalService;
|
||||
import vip.mate.approval.ApprovalWorkflowService;
|
||||
import vip.mate.channel.wecom.cards.WeComCardKind;
|
||||
|
||||
/**
|
||||
@ -34,17 +35,23 @@ public class ToolGuardCardKindFactory {
|
||||
public static final String MESSAGE_TYPE = "tool_guard_approval";
|
||||
|
||||
private final ApprovalService approvalService;
|
||||
/** ISSUE #413 P2-B3: resolves workflow-scoped (wf-) approvals from card clicks. */
|
||||
private final ApprovalWorkflowService approvalWorkflowService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public ToolGuardCardKindFactory(ApprovalService approvalService, ObjectMapper objectMapper) {
|
||||
public ToolGuardCardKindFactory(ApprovalService approvalService,
|
||||
ApprovalWorkflowService approvalWorkflowService,
|
||||
ObjectMapper objectMapper) {
|
||||
this.approvalService = approvalService;
|
||||
this.approvalWorkflowService = approvalWorkflowService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public WeComCardKind create() {
|
||||
ToolGuardButtonKey buttonKey = new ToolGuardButtonKey(objectMapper);
|
||||
ToolGuardCardRenderer renderer = new ToolGuardCardRenderer(buttonKey);
|
||||
ToolGuardCardHandler handler = new ToolGuardCardHandler(approvalService, buttonKey);
|
||||
ToolGuardCardHandler handler = new ToolGuardCardHandler(
|
||||
approvalService, approvalWorkflowService, buttonKey);
|
||||
return new WeComCardKind(
|
||||
"tool_guard_approval",
|
||||
MESSAGE_TYPE,
|
||||
|
||||
@ -148,12 +148,32 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
||||
/** 用于文件 URL 下载的 HttpClient */
|
||||
private HttpClient uploadHttpClient;
|
||||
|
||||
/**
|
||||
* Workspace/agent-aware upload-root resolver, set by the production factory.
|
||||
* Null in unit tests (the legacy {@code data/chat-uploads} default applies).
|
||||
*/
|
||||
private vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver;
|
||||
|
||||
public WeixinChannelAdapter(ChannelEntity channelEntity,
|
||||
ChannelMessageRouter messageRouter,
|
||||
ObjectMapper objectMapper) {
|
||||
super(channelEntity, messageRouter, objectMapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full constructor used by the production factory (ChannelManager). The
|
||||
* trailing {@code chatUploadLocationResolver} enables workspace/agent-aware
|
||||
* attachment storage; {@code null} keeps the legacy {@code data/chat-uploads}
|
||||
* behaviour.
|
||||
*/
|
||||
public WeixinChannelAdapter(ChannelEntity channelEntity,
|
||||
ChannelMessageRouter messageRouter,
|
||||
ObjectMapper objectMapper,
|
||||
vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver) {
|
||||
super(channelEntity, messageRouter, objectMapper);
|
||||
this.chatUploadLocationResolver = chatUploadLocationResolver;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getChannelType() {
|
||||
return CHANNEL_TYPE;
|
||||
@ -666,7 +686,9 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
||||
return null;
|
||||
}
|
||||
|
||||
Path uploadDir = Path.of("data", "chat-uploads", conversationId);
|
||||
Path uploadDir = (chatUploadLocationResolver != null)
|
||||
? chatUploadLocationResolver.resolveUploadRoot(conversationId).resolve(conversationId)
|
||||
: Path.of("data", "chat-uploads", conversationId);
|
||||
return InboundMediaDownloader.download(
|
||||
() -> client.downloadMedia("", aesKey, encryptQueryParam),
|
||||
filenameHint,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
178
mateclaw-server/src/main/java/vip/mate/cli/MateClawCli.java
Normal file
178
mateclaw-server/src/main/java/vip/mate/cli/MateClawCli.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
Loading…
Reference in New Issue
Block a user