Compare commits

...

11 Commits
dev ... v1.8.0

Author SHA1 Message Date
mateaix
c6f6f10fd0 release: v1.8.0 2026-07-12 17:30:23 +08:00
mateaix
c8bf4e0f89 release: v1.7.0 2026-07-04 20:28:15 +08:00
matevip
db6caea824 release: v1.6.0 2026-06-22 15:17:06 +08:00
matevip
84375da3c5 release: v1.5.0 2026-06-05 07:55:59 +08:00
matevip
68c010ecf9 release: v1.4.0 2026-05-25 09:58:38 +08:00
matevip
493910bf5a release: v1.3.0 (hotfix bundle — #120 + UI/build fixes) 2026-05-14 09:30:43 +08:00
matevip
da8005a8cb release: v1.3.0 (pnpm build fix) 2026-05-13 11:30:28 +08:00
matevip
1d64194e15 release: v1.3.0 2026-05-13 10:14:27 +08:00
matevip
f47cf8c6be release: v1.3.0 2026-05-13 10:05:53 +08:00
matevip
d994be3d04 release: v1.2.0 2026-05-05 20:09:58 +08:00
matevip
9ada305b8a release: v1.1.137 2026-04-29 16:40:05 +08:00
3103 changed files with 503369 additions and 20721 deletions

28
.dockerignore Normal file
View File

@ -0,0 +1,28 @@
# Git and IDE
.git
.idea
*.iml
# Node artifacts
**/node_modules
**/dist
**/.nuxt
**/.output
# Maven build output (will be rebuilt in Docker)
**/target
# Desktop / webchat (not needed for server or sites build)
mateclaw-desktop
# Data and logs
data
*.log
# Misc
.env
*.md
!docs/**/*.md
!matevip-sites/**/*.md
!mateclaw-plugin-api/**
!mateclaw-server/**

View File

@ -1,31 +1,38 @@
# MateClaw 环境变量配置 # MateClaw 环境变量配置
# 复制此文件为 .env 并填写实际值cp .env.example .env # 复制此文件为 .env 并填写实际值cp .env.example .env
# #
# LLM API KeyDashScope、OpenAI 等)无需在此配置,启动后在管理界面「模型管理」中添加。
#
# ⚠️ 所有标注「必填」的项若没配置,`docker compose up` 会直接失败退出,避免把默认/示例值带到生产环境。 # ⚠️ 所有标注「必填」的项若没配置,`docker compose up` 会直接失败退出,避免把默认/示例值带到生产环境。
# ==================== LLM / 搜索 ====================
# 阿里云 DashScope API Key必填
# 申请地址https://dashscope.aliyun.com/
DASHSCOPE_API_KEY=your-dashscope-api-key-here
# Serper 网页搜索 API Key可选用于 WebSearch 工具)
# 申请地址https://serper.dev/
SERPER_API_KEY=
# ==================== 数据库Docker 模式必填) ==================== # ==================== 数据库Docker 模式必填) ====================
#
# ⚠️ Docker 栈已切换到 PostgreSQL 16此前为 MySQL。老部署升级前请先读
# docker-compose.yml 顶部的迁移说明:旧 mysql_data 卷不会被读取,需要先
# mysqldump 再用 pgloader 等工具导入,或钉在切换前的 tag 上继续用 MySQL。
DB_HOST=localhost DB_HOST=localhost
DB_PORT=3306 DB_PORT=5432
DB_NAME=mateclaw DB_NAME=mateclaw
# 应用连接账号(最小权限角色,由 docker/postgres/init/10-app-role.sh 首次
# 初始化时自动创建,仅拥有 mateclaw schema不是超级用户
DB_USERNAME=mateclaw DB_USERNAME=mateclaw
# ⚠️ 必填且请改成强密码(至少 16 位,含大小写+数字+符号)。 # ⚠️ 必填且请改成强密码(至少 16 位,含大小写+数字+符号)。
# docker-compose.yml 会通过 ${DB_PASSWORD:?} 强制要求此项。 # docker-compose.yml 会通过 ${DB_PASSWORD:?} 强制要求此项。
DB_PASSWORD=change-me-strong-user-password DB_PASSWORD=change-me-strong-user-password
# ⚠️ MySQL root 账号密码,仅用于容器内初始化。请改成与 DB_PASSWORD 不同的强密码。 # ⚠️ PostgreSQL 引导超级账号,仅用于容器内初始化和运维。
DB_ROOT_PASSWORD=change-me-strong-root-password # 请改成与 DB_PASSWORD 不同的强密码。
DB_ADMIN_USERNAME=mateclaw_admin
DB_ADMIN_PASSWORD=change-me-strong-admin-password
# ==================== 搜索(可选) ====================
# WebSearch 工具的云端搜索 API可选二选一或都不配不配可用 SearXNG sidecar
SERPER_API_KEY=
TAVILY_API_KEY=
# ==================== 安全(强烈建议覆盖) ==================== # ==================== 安全(强烈建议覆盖) ====================
@ -36,3 +43,123 @@ JWT_SECRET=
# CORS 白名单(逗号分隔,如 https://mateclaw.example.com,https://admin.example.com # CORS 白名单(逗号分隔,如 https://mateclaw.example.com,https://admin.example.com
# 若留空,服务器会允许所有 origin 并在启动日志里 WARN。生产部署务必设置。 # 若留空,服务器会允许所有 origin 并在启动日志里 WARN。生产部署务必设置。
MATECLAW_CORS_ALLOWED_ORIGINS= MATECLAW_CORS_ALLOWED_ORIGINS=
# 公开访问基址(如 https://mateclaw.example.com。用于把智能体生成文件的下载
# 链接拼成绝对地址,便于在 Web 之外IM 消息、复制链接、外部下载)直接打开。
# 留空时回退到当前请求的 host再退回相对路径。反代后部署建议显式设置。
MATECLAW_PUBLIC_BASE_URL=
# 是否公开 Swagger UI / OpenAPI 文档(/swagger-ui.html、/v3/api-docs
# 生产数据库 profilemysql/kingbase/postgres默认 false —— 匿名无法浏览全部
# 端点结构需全局管理员ROLE_ADMIN。仅在内网/预发临时调试时设为 true。
MATECLAW_OPENAPI_EXPOSE_UI=
# SearXNG 会话密钥(容器内部用,留空会用开发默认值)。生产部署请设成 32+ 位随机串。
# openssl rand -hex 32
SEARXNG_SECRET=
# ==================== 浏览器工具(可选) ====================
#
# Docker 镜像已经把 Chromium 打进去了,默认零配置可用。
# 只有在下述场景才需要 override
#
# 1) 把浏览器独立部署成 sidecar 容器,通过 CDP 连接:
# MATECLAW_BROWSER_CDP_URL=http://chrome-sidecar:9222
#
# 2) 指定非 Playwright 打包的浏览器(例如宿主机上已装的 Chrome
# MATECLAW_BROWSER_CHROME_PATH=/usr/bin/google-chrome-stable
#
# 3) 强制使用 Playwright channelchrome / msedge / chrome-beta …):
# MATECLAW_BROWSER_CHANNEL=chrome
MATECLAW_BROWSER_CDP_URL=
MATECLAW_BROWSER_CHROME_PATH=
MATECLAW_BROWSER_CHANNEL=
# ==================== 局域网 部署放开(可选,默认 false 严格模式) ====================
# 浏览器 SSRF 防护:放行本地回环和私有 IP127.0.0.1 / 10.x / 192.168.x /
# 172.16-31.x / IPv6 fc00::/7 等),公网部署务必保持 false否则 SSRF 防护失效
PLAYWRIGHT_ALLOW_PRIVATE_NETWORK=false
# 浏览器忽略 HTTPS 证书错误(自签证书 / IP 直连 HTTPS 场景)
# 公网部署务必保持 false否则中间人攻击可绕过证书校验
PLAYWRIGHT_IGNORE_HTTPS_ERRORS=false
# Playwright 单次操作超时(秒),慢链路 / 大页面可调高
PLAYWRIGHT_DEFAULT_TIMEOUT_SECONDS=30
# Playwright 导航超时(秒),慢网络可调高
PLAYWRIGHT_NAVIGATION_TIMEOUT_SECONDS=30
# snapshot 文本截断长度,超出会返回 truncated:true 提示 LLM 用 selector 缩小范围
PLAYWRIGHT_SNAPSHOT_MAX_LENGTH=20000
# ==================== OpenAI OAuthDocker可选 ====================
#
# OpenAI ChatGPT OAuth 使用 Codex CLI 的 public client + PKCE / device code
# 不需要自定义 client secret。
#
# 默认留空即可。后端会根据访问 Host 自动选择:
# - localhost / 127.0.0.1 / ::1 → LOCALPKCE 回调)
# - IP / 域名 / 反向代理访问 → DEVICE_CODE无缝远程授权
#
# 本机 Docker 若希望像桌面版一样直接通过宿主机浏览器完成
# http://localhost:1455/auth/callback 回调,可显式开启 LOCAL并让容器内
# 临时回调服务监听 0.0.0.0,以便通过 `1455:1455` 端口映射被宿主机访问到:
# MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE=local
# MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST=0.0.0.0
#
# 强制模式调试时也可设为local / device_code / manual_paste
MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE=
MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST=
# ==================== Wiki 知识库目录白名单Docker 模式,可选)====================
#
# Docker 生产部署开启了路径安全校验fail-closed
# 知识库使用「目录扫描」功能时,扫描路径必须在此白名单内,否则返回 400 错误。
# 多个路径用英文逗号分隔;留空则禁止所有目录扫描。
#
# 示例MATE_WIKI_ALLOWED_SOURCE_ROOTS=/data/wiki,/opt/docs
#
# 同时在 docker-compose.yml 的 volumes 里把宿主机目录挂进容器,例如:
# volumes:
# - /your/host/path:/data/wiki
MATE_WIKI_ALLOWED_SOURCE_ROOTS=
# ── Wiki 知识源自动同步(变更监测)总开关 ────────────────────────
# 定时扫描各知识库的源目录、自动消化新文件。默认关闭,运维主动开启。
# AND 语义:全局这个开关开 *且* 某知识库自己的「自动同步」开关也开,
# 该库才会被定时扫描;手动「立即扫描」不受此开关影响。
# 间隔单位毫秒,默认 5 分钟(目前为全局,暂不支持按库配置)。
MATE_WIKI_WATCHER_ENABLED=false
MATE_WIKI_WATCHER_INTERVAL_MS=300000
# ── Skill 工作区目录 ─────────────────────────────────────────────
# 已安装的 skill、运行时积累的 LESSONS.md、skill 运行产物都落在这个目录。
# 默认(容器内)已指向 /app/data/skills由 docker-compose 的 server_data 卷
# 持久化,容器重启不丢,无需额外挂卷。一般无需修改。
# 内置 skill 由 JAR classpath 每次启动现场释放,挂空卷也不会丢内置文件。
# 仅当你想把 skill 目录放到别处(如独立的 bind mount时才覆盖此项
# 并记得在 docker-compose.yml 的 volumes 里把对应宿主机目录挂进容器。
MATECLAW_SKILL_WORKSPACE_ROOT=
# ── Skill ZIP 上传大小上限MB可选────────────────────────────
# 技能包上传/市场安装的应用层上限,默认单文件 1MB、整包 50MB。
# 解包时整包缓存在内存里,整包上限调多大,单次安装峰值内存就可能吃多大。
# 同时注意 Spring 层 spring.servlet.multipart 的上限(默认 100MB/200MB
MATECLAW_SKILL_UPLOAD_MAX_ENTRY_SIZE_MB=
MATECLAW_SKILL_UPLOAD_MAX_TOTAL_SIZE_MB=
# ── Python pip 镜像源(可选)──────────────────────────────────────
# skill 里 Python 脚本缺包时 pip install 走的源。默认用 PyPI 官方源。
# pip 原生读 PIP_INDEX_URL / PIP_TRUSTED_HOST 环境变量,容器内自动继承。
# HTTP 源会自动从 URL 推导 PIP_TRUSTED_HOST自签 HTTPS 需手动填。
# 互联网加速: https://pypi.tuna.tsinghua.edu.cn/simple
# 局域网私有源: http://192.168.1.100:8080/simpletrusted-host 自动推导)
#PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
#PIP_TRUSTED_HOST=
# ── 桌面版补充(非 Docker宿主机直接跑 Java─────────────────────
# 桌面版不继承上面的 Docker 变量。用 Spring 配置注入 Python 子进程;
# 也可直接设系统环境变量 PIP_INDEX_URL / PIP_TRUSTED_HOST覆盖更全
#MATECLAW_PIP_INDEX_URL=
#MATECLAW_PIP_TRUSTED_HOST=
# ── Maven 镜像(国内加速)─────────────────────────────────────────
# 在中国大陆构建时取消注释,将 Aliyun 仓库优先级提前,大幅提速 mvn 拉包。
# 空值(默认)使用 US Maven Central → Google CDN → Aliyun 的顺序。
#MAVEN_FLAGS=-Paliyun-first

74
.github/ISSUE_TEMPLATE/bug-en.yml vendored Normal file
View File

@ -0,0 +1,74 @@
name: "🐛 Bug Report (English)"
description: Report something that's broken. Three required fields — fill them and submit.
title: "[Bug] "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report this. Three things, that's it — any less and we can't locate it; any more wastes your time.
> **Issues without a screenshot, log, or repro steps will be closed.** Not because we don't care — we genuinely can't fix what we can't reproduce.
- type: textarea
id: what
attributes:
label: What broke? (required, attach screenshot)
description: |
One sentence describing the symptom + at least one screenshot (drag it into the text box).
If it's a backend error, paste the stack trace here too (wrapped in ```).
placeholder: |
Example: As a `member`-role user in ws-b, I clicked "Create from template". The new Agent appeared in the default workspace instead of ws-b.
[drag in screenshot / screen recording]
[paste backend stack trace or frontend console error]
validations:
required: true
- type: textarea
id: repro
attributes:
label: How to reproduce? (required, numbered steps)
description: |
Steps that someone with zero context can follow. **A symptom you can't reproduce is a guess, not a bug.**
placeholder: |
1. Log in as admin / admin123, create workspace ws-b
2. Add user bob as ws-b member
3. Log out, log back in as bob, switch UI to ws-b
4. Go to Agents → "Create from template" → pick assistant → apply
5. Switch to default workspace — the Agent shows up here
validations:
required: true
- type: dropdown
id: module
attributes:
label: Affected module (optional, multi-select)
description: Which part of the system? Skip if unsure — helps maintainers triage.
multiple: true
options:
- Backend / 后端
- Frontend (admin UI) / 前端
- Desktop / 桌面端
- Webchat embed widget
- Channel (DingTalk / Feishu / Telegram / Discord / QQ / Slack ...)
- Tool / 工具
- Skill / 技能
- Wiki / 知识库
- Memory / 记忆
- Agent / StateGraph runtime
- Auth / Workspace permission
- Deployment / DB migration
- Other
validations:
required: false
- type: input
id: env
attributes:
label: Environment (required, one line)
description: version / workspace role / browser or client. One line.
placeholder: "v0.x.y / member / Chrome 130 on macOS 14.5"
validations:
required: true

74
.github/ISSUE_TEMPLATE/bug-zh.yml vendored Normal file
View File

@ -0,0 +1,74 @@
name: "🐛 Bug 报告(中文)"
description: 报告一个不工作的功能。三个必填项,写完就交。
title: "[Bug] "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
感谢花时间反馈。三件事,写完就好——少一件我们没法定位,多一件浪费你时间。
> **没截图、没日志、没步骤的 issue 我们会直接关掉**,不是不在乎,是真的修不了。
- type: textarea
id: what
attributes:
label: 出了什么问题?(必填,附截图)
description: |
一句话说清现象 + 至少一张截图(直接拖进文本框即可)。
如果是后端报错,把后端日志也贴这里(用 ``` 包起来)。
placeholder: |
例:作为 member 角色用户,在 ws-b 工作区点「从模板创建」,新建出来的 Agent 出现在了默认工作区,不在 ws-b。
[拖入截图 / 录屏]
[贴出后端 stack trace 或前端 console error]
validations:
required: true
- type: textarea
id: repro
attributes:
label: 怎么复现?(必填,编号步骤)
description: |
让一个完全不知情的人能按步骤复现。**说不出步骤的现象不是 bug是猜想。**
placeholder: |
1. 用 admin / admin123 登录,新建工作区 ws-b
2. 添加用户 bob 为 ws-b 的 member
3. 注销,用 bob 登录,前端切到 ws-b
4. 点 Agents 页面 → 「从模板创建」 → 选 assistant → 应用
5. 切回默认工作区,看到 Agent 出现在了这里
validations:
required: true
- type: dropdown
id: module
attributes:
label: 影响模块(选填,多选)
description: 大致是哪一块?不确定就空着,方便维护者打 label。
multiple: true
options:
- 后端 / Backend
- 前端 / Frontend (admin UI)
- 桌面端 / Desktop
- Webchat 嵌入组件
- Channel钉钉/飞书/Telegram/Discord/QQ/Slack...
- Tool / 工具
- Skill / 技能
- Wiki / 知识库
- Memory / 记忆
- Agent / StateGraph 运行时
- Auth / 工作区权限
- 部署 / 数据库迁移
- 其它
validations:
required: false
- type: input
id: env
attributes:
label: 环境(必填,一行)
description: 版本 / 工作区角色 / 浏览器或客户端。一行写完。
placeholder: "v0.x.y / member / Chrome 130 macOS 14.5"
validations:
required: true

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: 💬 使用问题先看文档 / Check the docs first
url: https://claw.mate.vip/docs
about: 安装、配置、用法问题文档里大多有答案 / Install, config, and usage questions are usually answered in the docs.
- name: 🔒 安全漏洞私下报告 / Report security issues privately
url: https://github.com/matevip/mateclaw/security/advisories/new
about: 安全相关问题请走 Security Advisory不要开公开 issue / Please use Security Advisory for security-related issues, don't open a public issue.

41
.github/ISSUE_TEMPLATE/feature-en.yml vendored Normal file
View File

@ -0,0 +1,41 @@
name: "✨ Feature Request (English)"
description: Propose a new feature or improvement. Start with why, then what.
title: "[Feature] "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
The key to a new feature is not "what it is" but "**who suffers without it, and how**".
If you can't articulate who would use it and why, the feature probably shouldn't be built.
- type: textarea
id: why
attributes:
label: What problem are you solving? (required)
description: |
Describe a real scenario. **Don't jump to "add an XX button"** — first explain why you need that button, and what hurts without it.
placeholder: |
Example: I switch the default model for 5 different Agents every day, and each switch takes 3 clicks in the settings page.
A global "quick switch default model" menu would save me 30 clicks a day.
validations:
required: true
- type: textarea
id: what
attributes:
label: How should it work? (required)
description: |
A paragraph or a few bullets. If you can sketch it or share a mockup, even better (drag in images).
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives you've tried? (optional)
description: |
If you can't think of any, leave it blank. **Don't invent content just to fill the field.**
validations:
required: false

41
.github/ISSUE_TEMPLATE/feature-zh.yml vendored Normal file
View File

@ -0,0 +1,41 @@
name: "✨ 功能建议(中文)"
description: 提一个新功能或改进。先讲为什么,再讲是什么。
title: "[Feature] "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
新功能的关键不是"它是什么",是"**没有它,谁在受什么苦**"。
如果你说不清谁会用、为什么用,这个功能大概率不该做。
- type: textarea
id: why
attributes:
label: 你在解决什么问题?(必填)
description: |
描述真实场景。**不要直接写"应该加一个 XX 按钮"** —— 先说为什么要这个按钮、不加会怎样。
placeholder: |
例:我每天要给 5 个不同的 Agent 切换默认模型,每次都要进设置页改 3 处。
如果有一个"快速切换默认模型"的全局菜单,我每天能少点 30 次鼠标。
validations:
required: true
- type: textarea
id: what
attributes:
label: 你期望它怎么工作?(必填)
description: |
一段话或几个 bullet。如果你能画个草图、贴个 mockup更好直接拖图
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: 你试过的替代方案?(选填)
description: |
如果想不到替代方案,就空着。**不要为了填字段而瞎写。**
validations:
required: false

30
.gitignore vendored
View File

@ -29,6 +29,12 @@ nbbuild/
nbdist/ nbdist/
.nb-gradle/ .nb-gradle/
### frontend build artifacts ###
# Vite's primary output goes to mateclaw-server/.../static; the only thing
# that lands here is rollup-plugin-visualizer's stats.html when running
# ANALYZE=1 pnpm build.
mateclaw-ui/dist/
### maven ### ### maven ###
target/ target/
*.war *.war
@ -92,5 +98,29 @@ deploy/nginx/ssl/*.pem
deploy/.env deploy/.env
# Claude Code local settings # Claude Code local settings
CLAUDE.md
.claude/settings.local.json .claude/settings.local.json
.claude/plans/ .claude/plans/
# 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
# Sandbox / external client work that lives in this directory
# but should not ship in the repo.
outputs/
# This is a pnpm monorepo — pnpm-lock.yaml is the only lockfile we track.
# Ignore stray npm/yarn lockfiles so they are not committed by mistake.
package-lock.json
yarn.lock
# Python bytecode caches generated when skill scripts (e.g. skills/*/scripts/*.py)
# are executed. Never commit or sync these.
__pycache__/
*.pyc

306
README.md
View File

@ -6,16 +6,18 @@
# MateClaw # MateClaw
<p align="center"><b>Build AI that thinks, acts, remembers, and ships.</b></p> <p align="center"><b>Your second brain</b></p>
[![GitHub Repo](https://img.shields.io/badge/GitHub-Repo-black.svg?logo=github)](https://github.com/matevip/mateclaw) <p align="center"><sub><b>Agent Harness · Spring Boot inside · One JAR to ship</b></sub></p>
[![GitHub Repo](https://img.shields.io/badge/GitHub-Repo-black.svg?logo=github)](https://github.com/mateaix/mateclaw)
[![Documentation](https://img.shields.io/badge/Docs-Website-green.svg?logo=readthedocs&label=Docs)](https://claw.mate.vip/docs) [![Documentation](https://img.shields.io/badge/Docs-Website-green.svg?logo=readthedocs&label=Docs)](https://claw.mate.vip/docs)
[![Live Demo](https://img.shields.io/badge/Demo-Online-orange.svg?logo=vercel&label=Demo)](https://claw-demo.mate.vip) [![Live Demo](https://img.shields.io/badge/Demo-Online-orange.svg?logo=vercel&label=Demo)](https://claw-demo.mate.vip)
[![Website](https://img.shields.io/badge/Website-claw.mate.vip-blue.svg?logo=googlechrome&label=Site)](https://claw.mate.vip) [![Website](https://img.shields.io/badge/Website-claw.mate.vip-blue.svg?logo=googlechrome&label=Site)](https://claw.mate.vip)
[![Java Version](https://img.shields.io/badge/Java-17+-blue.svg?logo=openjdk&label=Java)](https://adoptium.net/) [![Java Version](https://img.shields.io/badge/Java-21+-blue.svg?logo=openjdk&label=Java)](https://adoptium.net/)
[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.5-brightgreen.svg?logo=springboot)](https://spring.io/projects/spring-boot) [![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.5-brightgreen.svg?logo=springboot)](https://spring.io/projects/spring-boot)
[![Vue](https://img.shields.io/badge/Vue-3-4FC08D.svg?logo=vuedotjs)](https://vuejs.org/) [![Vue](https://img.shields.io/badge/Vue-3-4FC08D.svg?logo=vuedotjs)](https://vuejs.org/)
[![Last Commit](https://img.shields.io/github/last-commit/matevip/mateclaw)](https://github.com/matevip/mateclaw) [![Last Commit](https://img.shields.io/github/last-commit/mateaix/mateclaw)](https://github.com/mateaix/mateclaw)
[![License](https://img.shields.io/badge/license-Apache--2.0-red.svg?logo=opensourceinitiative&label=License)](LICENSE) [![License](https://img.shields.io/badge/license-Apache--2.0-red.svg?logo=opensourceinitiative&label=License)](LICENSE)
[[Website](https://claw.mate.vip)] [[Live Demo](https://claw-demo.mate.vip)] [[Documentation](https://claw.mate.vip/docs)] [[中文](README_zh.md)] [[Website](https://claw.mate.vip)] [[Live Demo](https://claw-demo.mate.vip)] [[Documentation](https://claw.mate.vip/docs)] [[中文](README_zh.md)]
@ -28,123 +30,127 @@
--- ---
An AI agent. A knowledge engine. A memory system. A tool runtime. A multi-channel presence. > **Other personal AI agents are built for one person. MateClaw is the one your IT department can actually sign off on.**
>
> Multi-user workspaces. Approval-gated sensitive actions. Full audit trail. Spring Boot Actuator health monitoring. Per-channel error isolation so one chat platform's outage doesn't take down the rest. One JAR on your own machine, zero data egress.
>
> **And underneath, a real agent harness.** ReAct + Plan-and-Execute on a StateGraph runtime — not a one-shot RAG call dressed up. Tools, Skills, MCP, and ACP converge on one registry with per-employee binding. Sensitive tool calls flow through an approval gate you can actually inspect. Multi-vendor failover keeps the loop running when a provider doesn't.
**One product. The whole widget.** Most AI tools die when their vendor has a bad day. Most forget you the moment the tab closes. Most give you a chatbox and call it a product.
MateClaw is a personal AI operating system built with **Java + Vue 3**, powered by [Spring AI Alibaba](https://github.com/alibaba/spring-ai-alibaba). It's not a chatbox, not a workflow builder, not just another coding assistant. It's the entire system — from reasoning to remembering to shipping — in one deployment. **MateClaw is the whole widget.** One deployment. Reasoning, knowledge, memory, tools, channels — built together, not bolted on. And when your primary model goes down, the next one picks up mid-sentence.
Three things make it insanely different: ---
1. **Agents do work, not just talk** — ReAct + Plan-and-Execute. Not one-shot answers — iterative reasoning that actually completes tasks ## Three things that make it different
2. **Knowledge is shaped, not just stored** — An LLM Wiki that digests raw material into structured, linked pages. The difference between a warehouse and a library
3. **End-to-end, no compromises** — Web console, desktop app, 7 IM channels, tool guardrails, enterprise auth. One team, one deployment, one experience ### 1 · Your AI doesn't die when a model does
Primary key expired. Vendor returns 401. Network blip. Quota drained.
Other tools hand you a red error card. MateClaw routes to the next healthy provider — DashScope, OpenAI, Anthropic, Gemini, DeepSeek, Kimi, Ollama, LM Studio, MLX, 14+ in total — and the user sees the reply finish. A provider health tracker parks bad vendors in a cooldown window so they don't waste seconds on every turn.
You don't write a retry script. You drag providers into priority order in **Settings → Models** and watch the health dashboard fill with green dots as requests route around failures in real time.
### 2 · Knowledge that links itself
Upload a PDF, a batch of markdown, a scraped page — raw material in.
MateClaw's **LLM Wiki** digests it into structured pages, builds `[[links]]` between them, and remembers where every sentence came from. Click a citation, see the exact source chunk. Ask a question, the page you get is stitched from the right chunks — with references you can verify.
This is the difference between a warehouse and a library.
### 3 · One product, five surfaces
| Surface | What it is |
|---|---|
| **Web Console** | Full admin — digital employees, models, skills, knowledge, security, cron, **runtime console** (see what every employee is doing, force-recycle in one click) |
| **Desktop** | Electron app with a bundled JRE 21. Double-click, run. No Java install |
| **Webchat Widget** | One `<script>` tag embed. Drop it on any site |
| **IM Channels** | DingTalk · Feishu · WeChat Work · WeChat · Telegram · Discord · QQ · Slack |
| **Plugin SDK** | Java module for third-party capability packs |
Same brain. Same memory. Same tools. Different doors.
<p align="center"><b>$0 · No tokens metered. No seats billed. Your server. Your data. Your keys.</b></p>
---
## What's in the box
### Digital employees, not chatbots
You hire coworkers, not chat boxes. Each one has a **Role**, a **Goal**, a **Backstory**, a pixel-art avatar, and a color of their own — five career templates ship ready (Product Researcher · Customer Support · Knowledge Curator · Data Analyst · Executive Assistant). **ReAct** drives iterative reasoning, **Plan-and-Execute** decomposes complex multi-step work, employees can delegate to one another in parallel. Dynamic context pruning, smart truncation, stale-stream cleanup — the boring stuff that makes long conversations actually work.
### Knowledge & memory
- **LLM Wiki** — raw materials digest into linked pages with citations; the **hot cache** auto-injects into every employee's system prompt. **Transformations engine** (1.3.0+) turns the Wiki from a search index into a processing pipeline
- **Workspace memory**`AGENTS.md`, `SOUL.md`, `PROFILE.md`, `MEMORY.md`, daily notes
- **Memory lifecycle** — post-conversation extraction, scheduled consolidation, Dreaming workflows. Workflows can also write directly into an employee's `MEMORY.md` via the `write_memory` step
### Skills · MCP · ACP — three ways to extend capability
- **SKILL.md packages** — manifest + prompt + tool list + **LESSONS.md (gets smarter the more you use it)**. Eight starter templates plus a five-step creation wizard, with **Pre-flight checks** that tell you what's missing before install
- **MCP** — stdio / SSE / Streamable HTTP, plug into any external tool server. **Per-employee binding** (1.3.0+) means a tool you install for one employee doesn't bleed into another's toolbox
- **ACP** — bring top-tier coding agents like Claude Code and Codex in as employees, auto-bridged to skill cards with wrapper tools
- **Tool Guard** — RBAC + approval flow + path protection. Capability needs boundaries
### Business orchestration (1.3.0+)
- **Workflow** — compose multiple employees plus system actions (approval / channel dispatch / write-memory) into a publishable, triggerable, replayable linear DSL. Seven step modes (`sequential` / `fan_out` / `collect` / `conditional` / `await_approval` / `dispatch_channel` / `write_memory`). JSON-first authoring with Monaco + schema validation, or natural-language → draft generation
- **Triggers** — wire system events to workflows or to employee conversations. Six pattern types (`cron` / `webhook` / `channel_message` / `agent_lifecycle` / `content_match` / `workflow_completion`). Default-on event governance: dedup, per-trigger rate limit, bot-self filter, recursion guard, fail-closed unknown patterns
- **Wiki Transformations** — Wiki stops being retrieval-only. User-authored templates run against raw materials or existing pages, with cross-material map-reduce aggregation, reverse-citation extraction, JSON output mode, and per-template model picker
### You see what every employee is doing
**Admin Runtime Console** (`Settings → System → Runtime`) — who's running, what step they're on, how many tokens, one-click force-recycle when stuck. Streaming is staged honestly (thinking / tool / answer), per-event SSE IDs make reconnects safe, multi-employee delegation no longer fights itself, long tasks demand evidence-grounded answers.
### Multimodal creation
Text-to-speech · Speech-to-text · Image · Music · Video · 3D. First-class, not add-ons. **Sidecar routing** (1.3.0+) means a text-only main model + an image attachment no longer dead-ends — a configured vision model describes the image, and the main model answers. **Image edit** lands too: refer to an earlier conversation attachment by `msg:<id>:<idx>` and ask the model to recolor or restyle it. Four **document-generation tools** (`DocxRenderTool` / `XlsxRenderTool` / `PptxRenderTool` / `PdfRenderTool`) render Markdown straight to Office files inside the JVM — no subprocess, no Office install.
### Content Studio (1.8.0+)
A flagship *scene*, not a tool — a seeded "Content Studio" employee turns one sentence into a publishable post: pick-topic → research → draft → illustrate → **de-AI** → lay out → deliver. **WeChat Official Account (公众号)** articles land in your draft box as inline-style HTML with body images uploaded into WeChat; **Xiaohongshu (小红书)** notes package as ≥3 vertical 3:4 cards with an online preview. De-AI-ification runs against a **measurable AI-trace score**; every delivery is compliance-scanned and logged to a **content calendar** that dedups by topic fingerprint.
### Enterprise-ready
RBAC + JWT. **Personal Access Tokens** for headless scripts and CI. **HMAC-SHA-256 outbound webhook signing**. **Distributed Cron lock** so multi-instance deployments don't double-fire. Full audit trail. Flyway-managed schema that auto-heals on upgrade. One JAR to ship. MySQL in production, H2 for dev — nothing to change in your code.
---
## AI is becoming infrastructure
On March 2, 2026, Claude went dark for 4 hours across API, web, and mobile. Three weeks later, another 5 hours. Every company that bet their AI strategy on a single vendor spent those outages staring at red error cards.
This is the same shift databases went through around 2010 and cloud went through around 2018: the winning layer stops being tied to one supplier. **57% of companies now run AI agents in production.** None of them want one vendor's bad day to become their bad day.
**MateClaw is that layer — built the Spring Boot way.**
--- ---
## Why MateClaw ## Why MateClaw
Most AI tools do one thing well. MateClaw does the whole thing. | | MateClaw | [OpenClaw](https://github.com/openclaw/openclaw) | [Hermes Agent](https://github.com/NousResearch/hermes-agent) | [Claude Code](https://github.com/anthropics/claude-code) | [Cursor](https://cursor.com) |
|:---|:---:|:---:|:---:|:---:|:---:|
| **Multi-vendor failover** | **Chain + health tracker + cooldown** | Swap providers via config | Orchestration w/ retry | Anthropic only | One model |
| **Knowledge digestion** | **LLM Wiki + page-level citations** | Canvas + memory | Skills Hub + memory | — | Code index |
| **Multi-user admin** | **RBAC + approval + audit + runtime console** | Config-file first | Single-user CLI | Enterprise tier | Teams plan |
| **Capability extension** | **Skills (LESSONS) + MCP + ACP** | — | — | MCP | MCP |
| **Surfaces** | Web admin + Desktop + Widget + SDK + 8 IM | 25+ chat channels | 15+ channels (CLI-led) | 3 IM preview | IDE only |
| **Stack** | **Java (Spring Boot)** | TypeScript | Python | TypeScript | Electron/TS |
| **License / Price** | **Apache 2.0 · Free** | MIT · Free | MIT · Free | Proprietary · $20200/mo | Proprietary · $0200/mo |
| Capability | MateClaw | [OpenClaw](https://github.com/openclaw/openclaw) | [CoPaw](https://github.com/agentscope-ai/CoPaw) | [QClaw](https://cntechpost.com/2026/03/20/tencent-opens-qclaw-public-testing-amid-fierce-ai-rivalry/) | [Claude Code](https://github.com/anthropics/claude-code) | [Cursor](https://cursor.com) | [Windsurf](https://windsurf.com) | **OpenClaw and Hermes Agent are excellent personal AI platforms** — pick either if you're running one user on one laptop, building your own agent from CLI, and treating everything as config files to hand-tune. Both have bigger communities than MateClaw today.
|:---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| Agent Orchestration | **ReAct + Plan-Execute** | Multi-agent teams | Multi-agent collab | Specialist agents | Agent Teams + subagents | Background Agents (cloud VM) | Cascade engine |
| Knowledge System | **LLM Wiki (digestion)** | Intelligence Mode + Wiki | Personal KB | Knowledge graph | CLAUDE.md (no RAG) | Codebase indexing | No |
| Memory | **Extract + Consolidate + Dream** | SQLite + Dreaming + Wiki | ReMe (hybrid retrieval) | 3-layer memory | 3-layer (CLAUDE.md + auto + files) | No persistent memory | Memories (~48h learning) |
| Tool Guard & Approval | **RBAC + approval flow** | HITL + risk levels | No | No | Permissions + Sandbox + Hooks | No | Turbo Mode (auto-approve) |
| Multi-Channel IM | **7 channels** | 25+ channels | 7 channels | 5 channels | 3 channels (preview) | IDE only | IDE only |
| Web Management UI | **Full admin dashboard** | Control UI | Console UI | Dashboard | Enterprise dashboard | No | No |
| Desktop App | **Electron + bundled JRE** | macOS menu bar | Electron (Beta) | Win/Mac app | Claude Desktop (Mac/Win) | VS Code fork | VS Code fork |
| Multimodal Creation | **TTS/STT/Img/Music/Video** | TTS/Video/Music/Image | Vision input | No | Vision input only | No | No |
| Skill Ecosystem | **ClawHub marketplace** | ClawHub registry | Python skills | Templates | 340+ plugins, 1300+ skills | MCP marketplace | MCP one-click |
| Enterprise Auth | **RBAC + JWT** | Basic (password) | Basic auth | No | SSO/SCIM/RBAC | SSO + Teams | Teams plan |
| Open Source | **Apache 2.0** | MIT | Apache 2.0 | Partial | No (source-available) | No | No |
| Pricing | **Free** | Free | Free | Free (beta) | $20200/mo | $0200/mo | $0200/mo |
| Tech Stack | **Java + Vue 3** | TypeScript | Python + TS | OpenClaw fork | TypeScript | Electron (VS Code) | Electron (VS Code) |
**What makes MateClaw different?** **MateClaw is the version built for teams.** RBAC per digital employee, per model, per tool. An approval flow that pauses risky actions for review. Full audit trail. The Admin Runtime Console gives one operator real-time visibility into 50 employees running across 14 vendors — stuck? force-recycle in one click. Spring Boot inside — drop-in for any Java shop already running production services.
Every product in this table is genuinely strong. Here's where MateClaw carves its own space: Same "whole widget" philosophy. Different center of gravity.
- **Plan-and-Execute orchestration** — Break complex work into ordered steps, execute each, adapt mid-flight. Others have multi-agent, but structured task planning with dynamic replanning is rare
- **LLM Wiki that digests, not just retrieves** — Others index and search. MateClaw's Wiki turns raw material into structured, linked pages with summaries — a search engine vs. an encyclopedia
- **Java ecosystem** — Built for teams already running Spring Boot in production. One JAR, one deploy. No Python runtime, no Node.js dependency chain
- **Complete admin dashboard** — Agents, models, tools, skills, channels, security, cron jobs, token usage — all in one web UI. Not a CLI-first afterthought
- **Full multimodal creation** — TTS, STT, image, music, and video generation as first-class built-in features. OpenClaw matches here; most others don't
- **Free and open, no asterisks** — Apache 2.0. No token billing, no seat pricing, no feature gating. Claude Code starts at $20/mo, Cursor and Windsurf up to $200/mo
--- ---
## Architecture ## Quick start
<p align="center">
<img src="assets/architecture-biz-en.svg" alt="Business Architecture" width="800">
</p>
<details>
<summary><b>Technical Architecture</b></summary>
<p align="center">
<img src="assets/architecture-tech-en.svg" alt="Technical Architecture" width="800">
</p>
</details>
---
## Core Capabilities
### Agent Runtime
- **ReAct agents** — Think, act, observe, repeat. Iterative reasoning that gets things done
- **Plan-and-Execute** — Decompose complex work into ordered steps, then execute each one
- **Dynamic configuration** — Load agent personality, tools, and constraints from the database at runtime
- **Runtime resilience** — Context pruning, smart truncation, stale stream cleanup, and recovery
### Knowledge & Memory
- **LLM Wiki** — AI-powered knowledge base that digests raw materials into structured, linked pages with summaries
- **Workspace memory**`AGENTS.md`, `SOUL.md`, `PROFILE.md`, `MEMORY.md`, daily notes
- **Memory lifecycle** — Post-conversation extraction, scheduled consolidation, dreaming workflows
- **Compound memory** — Understanding improves over time instead of resetting every query
### Tools, Skills & MCP
- **Built-in tools** — Web search, file ops, memory access, date/time, and more
- **MCP integration** — stdio, SSE, and Streamable HTTP transports
- **Skill system** — Installable `SKILL.md` packages with ClawHub marketplace
- **Tool guard** — Approval flows, file-path protection, runtime filtering
### Multimodal Creation
Text-to-speech · Speech-to-text · Image generation · Music generation · Video generation
### Model Flexibility
14+ providers including DashScope, OpenAI, Anthropic, Gemini, DeepSeek, Kimi, Ollama, LM Studio, MLX, and more. Configure everything in the web UI.
### Surfaces
- **Web console** — Chat, agents, tools, skills, knowledge, models, security, settings
- **Desktop app** — Electron with bundled JRE 21, no Java installation needed
- **Channels** — DingTalk, Feishu, WeChat Work, Telegram, Discord, QQ
---
## Quick Start
### Prerequisites
- Java 17+ · Node.js 18+ · pnpm · Maven 3.9+
### Local Development
```bash ```bash
# Backend # Backend
cd mateclaw-server cd mateclaw-server
mvn spring-boot:run # http://localhost:18088 mvn spring-boot:run # http://localhost:18088
# Frontend # Frontend
cd mateclaw-ui cd mateclaw-ui
pnpm install && pnpm dev # http://localhost:5173 pnpm install && pnpm dev # http://localhost:5173
``` ```
Login: `admin` / `admin123` Login: `admin` / `admin123`
@ -156,60 +162,100 @@ cp .env.example .env
docker compose up -d # http://localhost:18080 docker compose up -d # http://localhost:18080
``` ```
### Desktop App ### Desktop
Download from [GitHub Releases](https://github.com/matevip/mateclaw/releases). Bundles JRE 21 — no Java needed. Download from [GitHub Releases](https://github.com/mateaix/mateclaw/releases). Bundles JRE 21. No Java install needed.
--- ---
## Tech Stack ## Architecture
| Layer | Technology | <p align="center">
|-------|------------| <img src="assets/architecture-biz-en.svg" alt="Business Architecture" width="800">
| Backend | Spring Boot 3.5 · Spring AI Alibaba 1.1 | </p>
| Agent | StateGraph Runtime |
| Database | H2 (dev) / MySQL 8.0+ (prod) | <details>
| ORM | MyBatis Plus 3.5 | <summary><b>Technical architecture</b></summary>
| Auth | Spring Security + JWT | <p align="center">
| Frontend | Vue 3 · TypeScript · Vite | <img src="assets/architecture-tech-en.svg" alt="Technical Architecture" width="800">
| UI | Element Plus · TailwindCSS 4 | </p>
| Desktop | Electron · electron-updater | </details>
--- ---
## Project Structure ## Project structure
``` ```
mateclaw/ mateclaw/
├── mateclaw-server/ Spring Boot backend ├── mateclaw-server/ Spring Boot 3.5 backend (Spring AI Alibaba, StateGraph runtime)
├── mateclaw-ui/ Vue 3 SPA frontend ├── mateclaw-ui/ Vue 3 + TypeScript admin SPA (built into the server JAR)
├── mateclaw-desktop/ Electron desktop app ├── mateclaw-webchat/ Embeddable chat widget (UMD / ES bundles)
├── mateclaw-plugin-api/ Java SDK for third-party capability plugins
├── mateclaw-plugin-sample/ Reference plugin implementation
├── docker-compose.yml ├── docker-compose.yml
└── .env.example └── .env.example
``` ```
Desktop binaries ship via [GitHub Releases](https://github.com/mateaix/mateclaw/releases) with a bundled JRE 21 — no Java install needed.
## Tech stack
| Layer | Technology |
|---|---|
| Backend | Spring Boot 3.5 · Spring AI Alibaba 1.1 · MyBatis Plus · Flyway |
| Digital Employee Runtime | StateGraph · ReAct + Plan-Execute · Role / Goal / Backstory · LESSONS self-evolution |
| Orchestration | Workflow (7 step modes · Pebble DSL) · Triggers (6 pattern types · event governance) · Wiki Transformations (1.3.0+) |
| Capability Extension | SKILL.md packages · MCP (stdio / SSE / HTTP · per-agent binding) · ACP bridge (Claude Code / Codex) |
| Database | H2 (dev) · MySQL 8.0+ (prod) |
| Auth | Spring Security + JWT |
| Frontend | Vue 3 · TypeScript · Vite · Element Plus · TailwindCSS 4 |
| Desktop | Electron · electron-updater · JRE 21 (bundled) |
| Widget | Vite library mode · UMD + ES bundles |
--- ---
## Documentation ## Documentation
Full docs at **[claw.mate.vip/docs](https://claw.mate.vip/docs)** Full docs at **[claw.mate.vip/docs](https://claw.mate.vip/docs)** — setup, architecture, each subsystem, API reference.
---
## Roadmap ## Roadmap
- Richer multi-agent collaboration **v1.8.0 (shipped 2026-07-12)** — the employee turns *outward and does a whole job*: **Content Studio**, the first flagship scene built end-to-end on MateClaw's own primitives:
- Smarter model routing
- Deeper multimodal understanding
- Stronger long-term memory
- Richer ClawHub ecosystem
--- - **Content Studio — one sentence to a publishable post** — a seeded "Content Studio" employee runs pick-topic → research → draft → illustrate → de-AI → layout → deliver. **WeChat Official Account (公众号)** image-text articles (inline-style HTML → draft box) and **Xiaohongshu (小红书)** image-first notes (≥3 vertical 3:4 cards + online preview) ship first-class
- **De-AI-ification you can measure** — a heuristic AI-trace score (no LLM, deterministic) drives a detect → rewrite → re-check loop, capped at 3 rounds
- **A publish chain hardened for real operation** — body images uploaded into WeChat (no broken external links), AES-GCM-encrypted secrets, reused service + persisted token, retry + Chinese error hints, a guaranteed fallback cover; draft-box-first, publish approval-gated
- **A content calendar that dedups and remembers** — every delivery is compliance-scanned and auto-recorded, a topic fingerprint stops repeat picks, and a read-only Content Calendar page shows drafted/packaged/published/failed
- **The browser agent sees by reference** — an accessibility-tree ref snapshot + interact-by-ref (click the element, not a pixel), real-browser privacy guardrails, and a controlled CDP escape hatch
- **Sharper attention, tighter loops** — attention anchoring & environment awareness (MCP tool provenance + pinned skill constraints + event notifications), a tool-call loop guard, and a post-mutation verify reminder
Plus: a fast-load pass (initial load down ~78%), a chat context-occupancy panel, cross-KB wikilinks, MCP progress notifications, a Volcano Engine provider, and the public Docker stack on PostgreSQL 16.
Full story in the [v1.8.0 release notes](https://claw.mate.vip/docs/en/releases/1.8.0).
**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).
## Contributing ## Contributing
```bash ```bash
git clone https://github.com/matevip/mateclaw.git git clone https://github.com/mateaix/mateclaw.git
cd mateclaw cd mateclaw
cd mateclaw-server && mvn clean compile cd mateclaw-server && mvn clean compile
cd ../mateclaw-ui && pnpm install && pnpm dev cd ../mateclaw-ui && pnpm install && pnpm dev
@ -217,14 +263,12 @@ cd ../mateclaw-ui && pnpm install && pnpm dev
--- ---
## Why The Name ## Why the name
**Mate** is companion. **Claw** is capability. **Mate** is companion. **Claw** is capability.
A system that stays with you, and a system that grabs work and moves it. Something that stays with you — and grabs work and moves it.
---
## License ## License
[Apache License 2.0](LICENSE) [Apache License 2.0](LICENSE). No asterisks.

View File

@ -4,18 +4,20 @@
<img src="mateclaw-ui/public/logo/mateclaw_logo_s.png" alt="MateClaw Logo" width="120"> <img src="mateclaw-ui/public/logo/mateclaw_logo_s.png" alt="MateClaw Logo" width="120">
</p> </p>
# MateClaw # 太一(MateClaw
<p align="center"><b>让 AI 真正去思考、行动、记忆,并把结果交付出来。</b></p> <p align="center"><b>你的超级大脑</b></p>
[![GitHub 仓库](https://img.shields.io/badge/GitHub-仓库-black.svg?logo=github)](https://github.com/matevip/mateclaw) <p align="center"><sub><b>Agent Harness · Spring Boot 内核 · 一个 JAR 交付</b></sub></p>
[![GitHub 仓库](https://img.shields.io/badge/GitHub-仓库-black.svg?logo=github)](https://github.com/mateaix/mateclaw)
[![文档](https://img.shields.io/badge/文档-在线-green.svg?logo=readthedocs&label=Docs)](https://claw.mate.vip/docs) [![文档](https://img.shields.io/badge/文档-在线-green.svg?logo=readthedocs&label=Docs)](https://claw.mate.vip/docs)
[![在线演示](https://img.shields.io/badge/演示-在线-orange.svg?logo=vercel&label=Demo)](https://claw-demo.mate.vip) [![在线演示](https://img.shields.io/badge/演示-在线-orange.svg?logo=vercel&label=Demo)](https://claw-demo.mate.vip)
[![官网](https://img.shields.io/badge/官网-claw.mate.vip-blue.svg?logo=googlechrome&label=Site)](https://claw.mate.vip) [![官网](https://img.shields.io/badge/官网-claw.mate.vip-blue.svg?logo=googlechrome&label=Site)](https://claw.mate.vip)
[![Java 版本](https://img.shields.io/badge/Java-17+-blue.svg?logo=openjdk&label=Java)](https://adoptium.net/) [![Java 版本](https://img.shields.io/badge/Java-21+-blue.svg?logo=openjdk&label=Java)](https://adoptium.net/)
[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.5-brightgreen.svg?logo=springboot)](https://spring.io/projects/spring-boot) [![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.5-brightgreen.svg?logo=springboot)](https://spring.io/projects/spring-boot)
[![Vue](https://img.shields.io/badge/Vue-3-4FC08D.svg?logo=vuedotjs)](https://vuejs.org/) [![Vue](https://img.shields.io/badge/Vue-3-4FC08D.svg?logo=vuedotjs)](https://vuejs.org/)
[![最后提交](https://img.shields.io/github/last-commit/matevip/mateclaw)](https://github.com/matevip/mateclaw) [![最后提交](https://img.shields.io/github/last-commit/mateaix/mateclaw)](https://github.com/mateaix/mateclaw)
[![许可证](https://img.shields.io/badge/license-Apache--2.0-red.svg?logo=opensourceinitiative&label=License)](LICENSE) [![许可证](https://img.shields.io/badge/license-Apache--2.0-red.svg?logo=opensourceinitiative&label=License)](LICENSE)
[[官网](https://claw.mate.vip)] [[在线演示](https://claw-demo.mate.vip)] [[文档](https://claw.mate.vip/docs)] [[English](README.md)] [[官网](https://claw.mate.vip)] [[在线演示](https://claw-demo.mate.vip)] [[文档](https://claw.mate.vip/docs)] [[English](README.md)]
@ -28,50 +30,141 @@
--- ---
一个智能体引擎。一个知识系统。一个记忆层。一个工具运行时。一个多渠道入口。 > **别的 AI 助手是给一个人用的。MateClaw 是公司允许部署的那一个。**
>
> 多用户工作空间。敏感操作走审批。完整审计日志。Spring Boot Actuator 健康监控。单个渠道挂掉不影响其他渠道的错误隔离。一个 JAR 包跑在自己机器上,数据不出门。
>
> **底下是个真 agent harness。** ReAct + Plan-and-Execute 跑在 StateGraph 运行时上——不是一次 RAG 调用披件外套。工具 · 技能 · MCP · ACP 收敛进同一个注册表,每位员工独立绑定。敏感工具调用走可审计的审批闸门。多厂商故障转移让循环在某家供应商挂掉时也不停。
**一个产品。完整交付。** 大多数 AI 工具一到厂商抽风那天就两手一摊。关一次标签页就忘了你是谁。给你一个聊天框,就敢叫产品。
MateClaw 是基于 **Java + Vue 3** 构建的个人 AI 操作系统,由 [Spring AI Alibaba](https://github.com/alibaba/spring-ai-alibaba) 驱动。它不是聊天框,不是工作流编排器,不只是又一个编码助手。它是从推理到记忆到交付的完整系统——一次部署,全部搞定。 **MateClaw 是完整的一整套。** 一次部署——推理、知识、记忆、工具、多渠道入口,从第一天就一起设计,不是事后拼接。主模型挂了,下一家接着把这句话说完
三件事让它截然不同: ---
1. **智能体做事,不只聊天** — ReAct + 计划执行。不是一问一答——是迭代推理,直到任务真正完成 ## 三件让它与众不同的事
2. **知识被塑造,而非仅仅被存储** — LLM Wiki 把原始资料消化成结构化的链接页面。仓库和图书馆的区别
3. **端到端,不妥协** — Web 控制台、桌面端、7 个 IM 渠道、工具防护、企业认证。一个团队、一次部署、一个体验 ### 1 · 模型挂了AI 不挂
Key 过期。厂商返回 401。网络抖动。配额耗尽。
别的工具丢你一张红色错误卡。MateClaw 自动切到下一家健康的供应商——DashScope、OpenAI、Anthropic、Gemini、DeepSeek、Kimi、Ollama、LM Studio、MLX共 14+ 家——用户只会看到回答正常完成。内置的 **Provider Health Tracker** 会把连续失败的供应商放进冷却窗口,避免每一轮对话都白白撞壁。
你不用写重试脚本。在 **设置 → 模型** 里把供应商拖成你想要的优先顺序,健康面板实时亮起一排绿点——请求绕着故障流过去。
### 2 · 知识会自己长出链接
上传 PDF、一批 markdown、抓下来的网页——原始材料进去。
MateClaw 的 **LLM Wiki** 把它消化成结构化页面,页面之间自己长出 `[[链接]]`,每一句话都记得来自哪里。点开引用抽屉,就能看到原始 chunk。问一个问题得到的页面是从对应片段拼出来的——带可核对的出处。
这是**仓库**和**图书馆**的区别。
### 3 · 一个产品,五个入口
| 入口 | 它是什么 |
|---|---|
| **Web 控制台** | 完整的管理后台——数字员工、模型、技能、知识、安全、定时任务、**运行时控制台**(看见每位员工正在干什么、一键回收) |
| **桌面端** | Electron + 内嵌 JRE 21双击即用无需装 Java |
| **网页嵌入式聊天** | 一个 `<script>` 标签就能嵌进任何网站 |
| **IM 渠道** | 钉钉 · 飞书 · 企业微信 · 微信 · Telegram · Discord · QQ · Slack |
| **插件 SDK** | Java 模块,供第三方扩展能力包 |
同一个大脑。同一份记忆。同一套工具。不同的门。
<p align="center"><b>$0 · 无 token 计费。无座位收费。你的服务器,你的数据,你的 Key。</b></p>
---
## 盒子里有什么
### 数字员工,不是聊天机器人
你雇佣员工,不是开聊天框。每位有**角色**、**目标**、**背景故事**像素艺术头像、专属配色——5 个职业模板(产品研究员 · 客户支持 · 知识管理员 · 数据分析师 · 行政助理)开箱可用。**ReAct** 做迭代推理,**Plan-and-Execute** 做复杂多步任务,员工之间可以并行委派。动态上下文裁剪、智能截断、僵死流清理——让长对话真正能用的那些"不起眼"的基础设施。
### 知识与记忆
- **LLM Wiki** — 原始材料消化成有链接、带引用的结构化页面;**热点缓存**自动注入到员工的 system prompt。**加工器引擎**1.3.0+)把 Wiki 从"搜索索引"升级为"处理流水线"
- **工作区记忆**`AGENTS.md` / `SOUL.md` / `PROFILE.md` / `MEMORY.md` / 每日笔记
- **记忆生命周期** — 对话后自动提取 · 定时整理 · Dreaming 工作流。工作流也可以通过 `write_memory` step 直接写进员工的 `MEMORY.md`
### 技能 · MCP · ACP — 三种"接外部能力"的方式
- **SKILL.md 技能包** — 一份 manifest + prompt + 工具列表 + **LESSONS.md用得越多越聪明**。8 个起步模板 + 5 步创作向导,安装前自动跑 **Pre-flight 检查**告诉你缺什么
- **MCP** — stdio / SSE / Streamable HTTP 三种传输,接入任意外部工具服务器。**每位员工独立绑定**1.3.0+)——一位员工装的工具不会渗到其他人的工具栏里
- **ACP** — 把 Claude Code、Codex 这种顶级编码 Agent 以"员工"身份接入,桥接成技能卡 + 包装工具
- **Tool Guard** — RBAC + 审批流 + 文件路径保护。能力必须有边界
### 业务流程编排1.3.0+
- **工作流Workflow** — 把多位员工 + 系统动作(审批 / 渠道分发 / 写记忆)按线性 step DSL 编排成一条可发布、可触发、可重放的业务流程。7 种 step mode`sequential` / `fan_out` / `collect` / `conditional` / `await_approval` / `dispatch_channel` / `write_memory`。JSON-first 编辑Monaco + JSON schema + Pebble 静态检查),或者用一句话生成草稿
- **触发器Trigger** — 把"系统里发生的事"自动接到工作流或员工对话上。6 种 pattern type`cron` / `webhook` / `channel_message` / `agent_lifecycle` / `content_match` / `workflow_completion`。事件治理默认开去重、per-trigger 限速、bot 自循环过滤、A→B→A 递归保护、未知 pattern fail-closed
- **Wiki 加工器** — Wiki 不再只是被动检索。用户自定义模板对原料或现有页面跑模板,跨原料 map-reduce 聚合reverse-citation 绑定到源 chunkJSON 输出 + 可选 JSON Schema每个模板独立选模型
### 你看得见每位员工正在干什么
**Admin 运行时控制台**`后台 → 系统 → 运行时`)——谁在跑、跑到哪一步、占多少 token、卡住了一键回收。流式分阶段显示思考 / 工具 / 回答SSE 每事件 ID 支持安全重连,多员工协作不打架,长任务必须有真实证据才回答。
### 多模态创作
语音合成 · 语音识别 · 图片 · 音乐 · 视频 · 3D。一等公民不是附加插件。**多模态旁路**1.3.0+)让纯文本主模型遇到图片附件时自动调用配置好的视觉模型转描述,主对话保持便宜。**图像编辑**也到位:用 `msg:<id>:<idx>` 引用会话里更早的某张图,让模型改色、改风格。**4 个文档生成工具**`DocxRenderTool` / `XlsxRenderTool` / `PptxRenderTool` / `PdfRenderTool`)在 JVM 内把 Markdown 直接渲染成 Office 文件——不 fork 子进程、不依赖 npm、不需要装 Office。
### 内容工作室1.8.0+
一个招牌*场景*,不是工具——预置的「内容工作室」员工把一句话变成可发布成品:选题 → 搜集 → 成文 → 配图 → **去 AI 化** → 排版 → 交付。**微信公众号(公众号)** 文章以内联样式 HTML 躺进你的草稿箱,正文图自动上传进微信;**小红书** 笔记打包成 ≥3 张竖版 3:4 卡片并在线预览。去 AI 化对着一个**可度量的 AI 痕迹评分**跑;每次交付都被合规扫描并记进一个按选题指纹去重的**内容日历**。
### 企业就绪
RBAC + JWT。**Personal Access Token** 给无人值守脚本和 CI 用。**Webhook 出站 HMAC-SHA-256 签名**。**Cron 分布式锁**多实例不双发。完整审计事件流。Flyway 管理数据库 schema升级时自愈。一个 JAR 交付。生产用 MySQL开发用 H2代码零改动。
---
## AI 正在变成基础设施
2026 年 3 月 2 日Claude 全球宕机 **4 小时**——API、Web、移动端同时黑屏。三周后又来一次**5 小时**。每一家把 AI 战略押在单一厂商身上的公司,那几个小时只能盯着红色错误卡。
这和 2010 年数据库走过的路、2018 年云走过的路**是同一个转弯**:赢的那一层,不再绑在一家供应商身上。**57% 的公司已经把 AI agent 推进生产**——没有一家希望某个厂商的坏日子变成自己的坏日子。
**MateClaw 就是那一层——用 Spring Boot 方式盖的。**
--- ---
## 为什么选 MateClaw ## 为什么选 MateClaw
大多数 AI 工具只做好一件事。MateClaw 做好整件事。 | | MateClaw | [OpenClaw](https://github.com/openclaw/openclaw) | [Hermes Agent](https://github.com/NousResearch/hermes-agent) | [Claude Code](https://github.com/anthropics/claude-code) | [Cursor](https://cursor.com) |
|:---|:---:|:---:|:---:|:---:|:---:|
| **多厂商失败转移** | **Chain + 健康追踪 + 冷却** | 切换供应商(改配置) | 内置编排重试 | 仅 Anthropic | 单模型 |
| **知识消化式加工** | **Wiki + 页面级引用溯源** | Canvas + 记忆 | Skills Hub + 记忆 | — | 代码索引 |
| **多用户管理** | **RBAC + 审批流 + 审计 + 运行时控制台** | 配置文件优先 | 单用户 CLI | 企业版 | 团队版 |
| **能力扩展接口** | **技能 (LESSONS) + MCP + ACP** | — | — | MCP | MCP |
| **用户触点** | Web 管理台 + 桌面 + 嵌入 + SDK + 8 IM | 25+ 聊天渠道 | 15+ 渠道CLI 为主) | 3 IM预览 | 仅 IDE |
| **技术栈** | **JavaSpring Boot** | TypeScript | Python | TypeScript | Electron/TS |
| **许可 / 定价** | **Apache 2.0 · 免费** | MIT · 免费 | MIT · 免费 | 闭源 · $20200/月 | 闭源 · $0200/月 |
| 能力 | MateClaw | [OpenClaw](https://github.com/openclaw/openclaw) | [CoPaw](https://github.com/agentscope-ai/CoPaw) | [QClaw](https://cntechpost.com/2026/03/20/tencent-opens-qclaw-public-testing-amid-fierce-ai-rivalry/) | [Claude Code](https://github.com/anthropics/claude-code) | [Cursor](https://cursor.com) | [Windsurf](https://windsurf.com) | **OpenClaw 和 Hermes Agent 是优秀的个人 AI 平台**——如果你是一个人、一台笔记本、习惯从 CLI 搭自己的 agent、所有东西都靠手工配置文件调优选它们没问题。两家的社区规模今天都大于 MateClaw。
|:---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| 智能体编排 | **ReAct + 计划执行** | 多智能体团队 | 多智能体协作 | 专家智能体 | Agent Teams + 子智能体 | 后台 Agent云端 VM | Cascade 引擎 |
| 知识系统 | **LLM Wiki消化式** | Intelligence Mode + Wiki | 个人知识库 | 知识图谱 | CLAUDE.md无 RAG | 代码库索引 | 无 |
| 记忆 | **提取 + 整理 + 涌现** | SQLite + Dreaming + Wiki | ReMe混合检索 | 三层记忆 | 三层CLAUDE.md + 自动 + 文件) | 无持久记忆 | Memories~48h 学习) |
| 工具防护与审批 | **RBAC + 审批流** | HITL + 风险等级 | 无 | 无 | 权限 + 沙箱 + Hooks | 无 | Turbo Mode自动放行 |
| 多渠道 IM | **7 个渠道** | 25+ 渠道 | 7 个渠道 | 5 个渠道 | 3 个渠道(预览) | 仅 IDE | 仅 IDE |
| Web 管理界面 | **完整管理仪表盘** | Control UI | Console UI | 控制面板 | 企业版仪表盘 | 无 | 无 |
| 桌面端 | **Electron + 内嵌 JRE** | macOS 菜单栏 | ElectronBeta | Win/Mac 应用 | Claude DesktopMac/Win | VS Code 分支 | VS Code 分支 |
| 多模态创作 | **TTS/STT/图/音乐/视频** | TTS/视频/音乐/图片 | 视觉输入 | 无 | 仅视觉输入 | 无 | 无 |
| 技能生态 | **ClawHub 市场** | ClawHub 注册表 | Python 技能 | 模板 | 340+ 插件, 1300+ 技能 | MCP 市场 | MCP 一键集成 |
| 企业认证 | **RBAC + JWT** | 基础(密码) | 基础认证 | 无 | SSO/SCIM/RBAC | SSO + 团队版 | 团队版 |
| 开源 | **Apache 2.0** | MIT | Apache 2.0 | 部分 | 否(源码可见) | 否 | 否 |
| 定价 | **免费** | 免费 | 免费 | 免费(公测) | $20200/月 | $0200/月 | $0200/月 |
| 技术栈 | **Java + Vue 3** | TypeScript | Python + TS | OpenClaw 衍生 | TypeScript | Electron (VS Code) | Electron (VS Code) |
**MateClaw 的差异化在哪?** **MateClaw 是那个给团队用的版本。** 每位数字员工、每个模型、每个工具都有 RBAC。危险动作自动暂停等审批。完整审计事件流。Admin 运行时控制台让一个运维能实时看到 50 位员工跑在 14 家供应商上的状态——卡住了一键回收。底座是 Spring Boot——任何一家已经在生产跑 Java 服务的公司可以直接并入。
这张表里的每个产品都有真正的实力。MateClaw 的独特空间在这里: **同一套"完整一整套"哲学,不同的重心。**
- **计划-执行编排** — 把复杂工作分解为有序步骤,逐一执行,动态调整计划。别人有多智能体,但结构化任务规划+动态重规划是稀缺能力 ---
- **LLM Wiki 消化式知识库** — 别人索引和搜索。MateClaw 的 Wiki 把原始资料转化为结构化、有链接的页面——搜索引擎和百科全书的区别
- **Java 生态** — 为已经在生产环境运行 Spring Boot 的团队而生。一个 JAR一次部署。无需 Python 运行时,无需 Node.js 依赖链 ## 快速开始
- **完整管理仪表盘** — 智能体、模型、工具、技能、渠道、安全、定时任务、Token 用量——全在一个 Web 界面。不是 CLI 优先的附属品
- **完整多模态创作** — TTS、STT、图片、音乐、视频生成作为内置一等功能。OpenClaw 在这方面同样强;其他竞品不具备 ```bash
- **免费开源,没有星号** — Apache 2.0。无按量计费无按席收费无功能阉割。Claude Code 起步 $20/月Cursor 和 Windsurf 最高 $200/月 # 后端
cd mateclaw-server
mvn spring-boot:run # http://localhost:18088
# 前端
cd mateclaw-ui
pnpm install && pnpm dev # http://localhost:5173
```
默认登录:`admin` / `admin123`
### Docker 部署
```bash
cp .env.example .env
docker compose up -d # http://localhost:18080
```
### 桌面端
从 [GitHub Releases](https://github.com/mateaix/mateclaw/releases) 下载安装包。内嵌 JRE 21无需额外装 Java。
--- ---
@ -90,126 +183,79 @@ MateClaw 是基于 **Java + Vue 3** 构建的个人 AI 操作系统,由 [Sprin
--- ---
## 核心能力
### 智能体引擎
- **ReAct 智能体** — 思考、行动、观察、循环。迭代推理直到完成任务
- **计划-执行智能体** — 将复杂工作分解为有序步骤,逐一执行
- **动态配置** — 运行时从数据库加载智能体的人格、工具和约束
- **运行时韧性** — 上下文裁剪、智能截断、僵死流清理、异常恢复
### 知识与记忆
- **LLM Wiki 知识库** — AI 驱动的知识库,将原始资料消化为结构化、有链接的页面
- **工作区记忆**`AGENTS.md`、`SOUL.md`、`PROFILE.md`、`MEMORY.md`、每日笔记
- **记忆生命周期** — 对话后自动提取、定时整理、记忆涌现工作流
- **记忆应该积累** — 理解随时间加深,而非每次查询都从零开始
### 工具、技能与 MCP
- **内置工具** — 联网搜索、文件操作、记忆访问、日期时间等
- **MCP 集成** — 支持 stdio、SSE、Streamable HTTP 三种传输
- **技能系统** — 可安装的 `SKILL.md` 技能包 + ClawHub 市场
- **工具防护** — 审批流、文件路径保护、运行时过滤
### 多模态创作
语音合成 · 语音识别 · 图片生成 · 音乐生成 · 视频生成
### 模型灵活性
14+ 供应商支持,包括 DashScope、OpenAI、Anthropic、Gemini、DeepSeek、Kimi、Ollama、LM Studio、MLX 等。在 Web 界面中配置一切。
### 用户触点
- **Web 控制台** — 对话、智能体、工具、技能、知识、模型、安全、设置
- **桌面端** — Electron + 内嵌 JRE 21无需安装 Java
- **多渠道** — 钉钉、飞书、企业微信、Telegram、Discord、QQ
---
## 快速开始
### 环境要求
- Java 17+ · Node.js 18+ · pnpm · Maven 3.9+
### 本地开发
```bash
# 后端
cd mateclaw-server
mvn spring-boot:run # http://localhost:18088
# 前端
cd mateclaw-ui
pnpm install && pnpm dev # http://localhost:5173
```
默认登录:`admin` / `admin123`
### Docker 部署
```bash
cp .env.example .env
docker compose up -d # http://localhost:18080
```
### 桌面端
从 [GitHub Releases](https://github.com/matevip/mateclaw/releases) 下载安装包。内嵌 JRE 21无需额外安装 Java。
---
## 技术栈
| 层次 | 技术 |
|------|------|
| 后端 | Spring Boot 3.5 · Spring AI Alibaba 1.1 |
| 智能体 | StateGraph 运行时 |
| 数据库 | H2开发/ MySQL 8.0+(生产)|
| ORM | MyBatis Plus 3.5 |
| 认证 | Spring Security + JWT |
| 前端 | Vue 3 · TypeScript · Vite |
| UI | Element Plus · TailwindCSS 4 |
| 桌面端 | Electron · electron-updater |
---
## 项目结构 ## 项目结构
``` ```
mateclaw/ mateclaw/
├── mateclaw-server/ Spring Boot 后端 ├── mateclaw-server/ Spring Boot 3.5 后端Spring AI Alibaba · StateGraph 运行时)
├── mateclaw-ui/ Vue 3 SPA 前端 ├── mateclaw-ui/ Vue 3 + TypeScript 管理 SPA构建产物打进后端 JAR
├── mateclaw-desktop/ Electron 桌面端 ├── mateclaw-webchat/ 网页嵌入式聊天组件UMD / ES bundle
├── mateclaw-plugin-api/ 第三方能力插件的 Java SDK
├── mateclaw-plugin-sample/ 参考插件实现
├── docker-compose.yml ├── docker-compose.yml
└── .env.example └── .env.example
``` ```
桌面端安装包通过 [GitHub Releases](https://github.com/mateaix/mateclaw/releases) 分发,内嵌 JRE 21——无需安装 Java。
## 技术栈
| 层次 | 技术 |
|---|---|
| 后端 | Spring Boot 3.5 · Spring AI Alibaba 1.1 · MyBatis Plus · Flyway |
| 数字员工运行时 | StateGraph · ReAct + Plan-Execute · 角色 / 目标 / 背景故事 · LESSONS 自我进化 |
| 业务编排 | 工作流7 step mode · Pebble DSL· 触发器6 pattern type · 事件治理)· Wiki 加工器1.3.0+|
| 能力扩展 | SKILL.md 包 · MCPstdio / SSE / HTTP · per-agent 绑定)· ACP 桥接Claude Code / Codex |
| 数据库 | H2开发· MySQL 8.0+(生产)|
| 认证 | Spring Security + JWT |
| 前端 | Vue 3 · TypeScript · Vite · Element Plus · TailwindCSS 4 |
| 桌面端 | Electron · electron-updater · 内嵌 JRE 21 |
| Webchat | Vite library 模式 · UMD + ES bundle |
--- ---
## 文档 ## 文档
完整文档请访问 **[claw.mate.vip/docs](https://claw.mate.vip/docs)** 完整文档 **[claw.mate.vip/docs](https://claw.mate.vip/docs)**——安装、架构、各子系统、API 参考。
---
## 路线图 ## 路线图
- 更丰富的多智能体协作 **v1.8.02026-07-12 发布)** — 员工*转向对外、干完一整件活*:**内容工作室**——第一个完全用 MateClaw 自身原子能力端到端搭起来的招牌场景:
- 更智能的模型路由
- 更深度的多模态理解
- 更强的长期记忆
- 更丰富的 ClawHub 生态
--- - **内容工作室——一句话到可发布成品** — 预置「内容工作室」员工跑通 选题 → 搜集 → 成文 → 配图 → 去 AI 化 → 排版 → 交付。**微信公众号(公众号)** 图文文章(内联样式 HTML → 草稿箱)与 **小红书** 以图为主图文笔记(≥3 张竖版 3:4 卡片 + 在线预览)首批一等公民
- **可度量的去 AI 化** — 启发式 AI 痕迹评分(无 LLM、确定性)驱动 检测 → 改写 → 复检 闭环,硬上限 3 轮
- **为长期投产而加固的发布链** — 正文图上传进微信(不再外链发布即裂)、AES-GCM 加密密钥、服务复用 + token 持久化、重试 + 中文错误提示、兜底封面;草稿箱优先,发表走审批
- **会去重、会记账的内容日历** — 每次交付都合规扫描 + 自动落台账、选题指纹防重复选题、只读内容日历页展示草稿/已打包/已发布/失败
- **浏览器 Agent 按引用去看** — 无障碍树 ref 快照 + 按 ref 交互(点元素而非像素)、真实浏览器隐私护栏、受控 CDP 逃生舱
- **注意力更聚焦、循环更收得住** — 注意力锚定与环境感知(MCP 工具溯源 + skill 约束固定 + 事件通知)、工具调用循环护栏、改动后校验提醒
外加:一次快加载优化(初始加载 ↓约 78%)、聊天上下文占用面板、跨知识库 wikilink、MCP 进度通知、火山方舟供应商,以及公开 Docker 栈切到 PostgreSQL 16。
完整故事见 [v1.8.0 release notes](https://claw.mate.vip/docs/zh/releases/1.8.0)。
**v1.7.02026-07-04 发布)** — 一次*生产化加固*:把它放进真正的协作里之后,那些看不见、收不拢、够不着、装不下、连不通的地方全补上:
- **审批三条链路彻底闭环** — 工作流 `await_approval` 真的推到渠道并 resolve→恢复执行、WebChatAPI-Key渠道能批准/拒绝并重放、飞书/企微点卡片直接 resolve 工作流审批
- **长任务看得见** — 常驻「运行总览」侧栏 + 本轮 Token 明细(缓存命中/未命中/写入 + 推理拆分)+ 子 Agent 成本向上滚加 + 生成文件一键下载
- **装得下真实模型窗口** — 本地模型上下文窗口探测、prefix 注入统一 Token 预算、小上下文降级、工具 schema 预算门——不再被"猜个 32K"坑到预检拒绝或悄悄截断
- **开放出去** — 知识库 / Deep Research 开放 APIAPI-Key + 限流 + SSE、插件化搜索 Provider SPI、MCP 身份透传(把认证用户身份带给 STDIO MCP
- **够得着更远** — 桌面端本地内嵌 / 远程集中部署双模式(`mateclaw-desktop` 源码开放)+ 局域网部署模式放开受控内网访问
- **运营数据一键导出** — Dashboard 9 表 Excel + CLI 命令行离线导出
完整故事见 [v1.7.0 release notes](https://claw.mate.vip/docs/zh/releases/1.7.0)。
**v1.6.02026-06-22 发布)** — 让自驱的数字员工*更快、更会看、更易嵌入*:技能两段式载入 + prefix 压缩(首字节更快)· `execute_code` 原生沙箱代码执行 · 图片跨轮次留存 + `image_analyze` · 可嵌入/无头 webchat 按 `endUserId` 隔离记忆 · 真正可读的 Wiki阅读与管理分离 · 统一 Sources 标签 · 可点击 `[[wikilinks]]`)· 高负载更稳MCP 自愈 · 工具调用恢复 · 计划证据闸门)。完整故事见 [v1.6.0 release notes](https://claw.mate.vip/docs/zh/releases/1.6.0)。
**v1.5.02026-06-04 发布)** — Goal 可勾选清单(模糊评分 → 逐项打勾)· Wiki 自维护(`[[wikilinks]]` · 事实层/经验层 · pageType 模板与权限 · 知识库流水线 · 本地目录接入)· 按拥有者隔离记忆(`owner_key` + 可见域 + `endUserId` 透传)· 每员工绑定主知识库 · 偏好 provider 驱动选型。完整故事见 [v1.5.0 release notes](https://claw.mate.vip/docs/zh/releases/1.5.0)。
**v1.4.02026-05-23 发布)** — 持续目标(锁定目标,每轮自评)· 子员工委派树(最深 3 层 · 同步 / 并行 / 异步 · 一句话组队)· 工具/技能渐进式披露 · 工作空间 RBACOwner / Admin / Member / Viewer· 飞书一等公民(交互卡 / 审批卡 / 流式卡 · 渠道原生工具)。详见 [v1.4.0 release notes](https://claw.mate.vip/docs/zh/releases/1.4.0)。
**v1.3.02026-05-13 发布)** — 工作流引擎 · 6 种 pattern 触发器 · Wiki 加工器 · 每员工独立 MCP 绑定 · 多模态旁路路由 · 4 个 JVM 原生文档生成工具 · 图像编辑。详见 [v1.3.0 release notes](https://claw.mate.vip/docs/zh/releases/1.3.0)。
## 参与贡献 ## 参与贡献
```bash ```bash
git clone https://github.com/matevip/mateclaw.git git clone https://github.com/mateaix/mateclaw.git
cd mateclaw cd mateclaw
cd mateclaw-server && mvn clean compile cd mateclaw-server && mvn clean compile
cd ../mateclaw-ui && pnpm install && pnpm dev cd ../mateclaw-ui && pnpm install && pnpm dev
@ -221,10 +267,8 @@ cd ../mateclaw-ui && pnpm install && pnpm dev
**Mate** 是陪伴。**Claw** 是能力。 **Mate** 是陪伴。**Claw** 是能力。
一个陪在你身边的系统,一个能真正抓住工作、推动它前进的系统。 一个陪在你身边的系统——也是一个真的能抓住工作、把它推向完成的系统。
---
## 许可证 ## 许可证
[Apache License 2.0](LICENSE) [Apache License 2.0](LICENSE)。没有星号。

View File

@ -32,18 +32,19 @@
<!-- ===== Center: Agent Core ===== --> <!-- ===== Center: Agent Core ===== -->
<circle cx="480" cy="280" r="72" fill="url(#warm)" stroke="#d96d46" stroke-width="2" filter="url(#glow)"/> <circle cx="480" cy="280" r="72" fill="url(#warm)" stroke="#d96d46" stroke-width="2" filter="url(#glow)"/>
<circle cx="480" cy="280" r="56" fill="#f6e2d7" stroke="#ebb08f" stroke-width="1"/> <circle cx="480" cy="280" r="56" fill="#f6e2d7" stroke="#ebb08f" stroke-width="1"/>
<text x="480" y="268" text-anchor="middle" font-size="16" font-weight="800" fill="#d96d46">Agent</text> <text x="480" y="268" text-anchor="middle" font-size="16" font-weight="800" fill="#d96d46">Digital Employee</text>
<text x="480" y="288" text-anchor="middle" font-size="11" font-weight="500" fill="#665245">Reason · Plan · Execute</text> <text x="480" y="288" text-anchor="middle" font-size="11" font-weight="500" fill="#665245">Role · Goal · Backstory</text>
<text x="480" y="304" text-anchor="middle" font-size="9" fill="#9b7d6c">ReAct + Plan-Execute</text> <text x="480" y="304" text-anchor="middle" font-size="9" fill="#9b7d6c">ReAct + Plan-Execute</text>
<!-- ===== Top: User Surfaces ===== --> <!-- ===== Top: User Surfaces (5 items) ===== -->
<rect x="310" y="82" width="340" height="68" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/> <rect x="270" y="82" width="420" height="68" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
<rect x="310" y="82" width="340" height="3" rx="1.5" fill="url(#primary)"/> <rect x="270" y="82" width="420" height="3" rx="1.5" fill="url(#primary)"/>
<text x="480" y="108" text-anchor="middle" font-size="13" font-weight="700" fill="#d96d46">User Surfaces</text> <text x="480" y="108" text-anchor="middle" font-size="13" font-weight="700" fill="#d96d46">User Surfaces</text>
<text x="355" y="136" text-anchor="middle" font-size="10" fill="#665245">Web Console</text> <text x="312" y="136" text-anchor="middle" font-size="10" fill="#665245">Web Console</text>
<text x="440" y="136" text-anchor="middle" font-size="10" fill="#665245">Desktop</text> <text x="396" y="136" text-anchor="middle" font-size="10" fill="#665245">Desktop</text>
<text x="520" y="136" text-anchor="middle" font-size="10" fill="#665245">IM Channels</text> <text x="480" y="136" text-anchor="middle" font-size="10" fill="#665245">Webchat</text>
<text x="605" y="136" text-anchor="middle" font-size="10" fill="#665245">API</text> <text x="564" y="136" text-anchor="middle" font-size="10" fill="#665245">IM (8)</text>
<text x="648" y="136" text-anchor="middle" font-size="10" fill="#665245">API</text>
<line x1="480" y1="150" x2="480" y2="208" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/> <line x1="480" y1="150" x2="480" y2="208" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
<polygon points="475,206 480,214 485,206" fill="#d96d46" opacity="0.6"/> <polygon points="475,206 480,214 485,206" fill="#d96d46" opacity="0.6"/>
@ -52,32 +53,38 @@
<rect x="40" y="210" width="4" height="140" rx="2" fill="url(#accent)"/> <rect x="40" y="210" width="4" height="140" rx="2" fill="url(#accent)"/>
<text x="140" y="240" text-anchor="middle" font-size="13" font-weight="700" fill="#184a45">Knowledge</text> <text x="140" y="240" text-anchor="middle" font-size="13" font-weight="700" fill="#184a45">Knowledge</text>
<text x="140" y="264" text-anchor="middle" font-size="10" fill="#665245">LLM Wiki KB</text> <text x="140" y="264" text-anchor="middle" font-size="10" fill="#665245">LLM Wiki KB</text>
<text x="140" y="282" text-anchor="middle" font-size="10" fill="#665245">Structured Digestion</text> <text x="140" y="282" text-anchor="middle" font-size="10" fill="#665245">Structured + Backlinks</text>
<text x="140" y="300" text-anchor="middle" font-size="10" fill="#665245">Memory Extraction</text> <text x="140" y="300" text-anchor="middle" font-size="10" fill="#665245">Citations + Soft Archive</text>
<text x="140" y="318" text-anchor="middle" font-size="10" fill="#665245">Workspace Context</text> <text x="140" y="318" text-anchor="middle" font-size="10" fill="#665245">+ Transformations (1.3.0+)</text>
<text x="140" y="336" text-anchor="middle" font-size="9" fill="#9b7d6c">Shape it, don't just store it</text> <text x="140" y="336" text-anchor="middle" font-size="9" fill="#9b7d6c">A library, not a vector store</text>
<line x1="240" y1="280" x2="408" y2="280" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/> <line x1="240" y1="280" x2="408" y2="280" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
<polygon points="406,275 414,280 406,285" fill="#184a45" opacity="0.6"/> <polygon points="406,275 414,280 406,285" fill="#184a45" opacity="0.6"/>
<!-- ===== Right: Tools & Skills ===== --> <!-- ===== Right Top: Tools & Skills ===== -->
<rect x="720" y="210" width="200" height="140" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/> <rect x="720" y="210" width="200" height="65" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
<rect x="916" y="210" width="4" height="140" rx="2" fill="url(#primary)"/> <rect x="916" y="210" width="4" height="65" rx="2" fill="url(#primary)"/>
<text x="820" y="240" text-anchor="middle" font-size="13" font-weight="700" fill="#d96d46">Tools &amp; Skills</text> <text x="820" y="232" text-anchor="middle" font-size="13" font-weight="700" fill="#d96d46">Skills · MCP · ACP</text>
<text x="820" y="264" text-anchor="middle" font-size="10" fill="#665245">Built-in Tool Suite</text> <text x="820" y="252" text-anchor="middle" font-size="10" fill="#665245">SKILL.md + LESSONS</text>
<text x="820" y="282" text-anchor="middle" font-size="10" fill="#665245">MCP Protocol</text> <text x="820" y="266" text-anchor="middle" font-size="9" fill="#9b7d6c">Even Claude Code joins as a hire</text>
<text x="820" y="300" text-anchor="middle" font-size="10" fill="#665245">Skill Packages + Hub</text> <line x1="552" y1="242" x2="720" y2="242" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
<text x="820" y="318" text-anchor="middle" font-size="10" fill="#665245">Guard + Approval</text> <polygon points="554,237 546,242 554,247" fill="#d96d46" opacity="0.6"/>
<text x="820" y="336" text-anchor="middle" font-size="9" fill="#9b7d6c">Capability needs boundaries</text>
<line x1="552" y1="280" x2="720" y2="280" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
<polygon points="554,275 546,280 554,285" fill="#d96d46" opacity="0.6"/>
<!-- ===== Bottom Left: Memory ===== --> <!-- ===== Right Bottom: Security & Approval ===== -->
<rect x="720" y="285" width="200" height="65" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
<rect x="916" y="285" width="4" height="65" rx="2" fill="url(#accent)"/>
<text x="820" y="307" text-anchor="middle" font-size="13" font-weight="700" fill="#184a45">Security &amp; Approval</text>
<text x="820" y="327" text-anchor="middle" font-size="10" fill="#665245">Tool Guard + Approval Flow</text>
<text x="820" y="341" text-anchor="middle" font-size="9" fill="#9b7d6c">Agentic, not autonomous</text>
<line x1="552" y1="317" x2="720" y2="317" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
<polygon points="554,312 546,317 554,322" fill="#184a45" opacity="0.6"/>
<!-- ===== Bottom Left: Memory · Dreaming ===== -->
<rect x="160" y="400" width="200" height="100" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/> <rect x="160" y="400" width="200" height="100" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
<rect x="160" y="496" width="200" height="4" rx="2" fill="url(#accent)"/> <rect x="160" y="496" width="200" height="4" rx="2" fill="url(#accent)"/>
<text x="260" y="428" text-anchor="middle" font-size="13" font-weight="700" fill="#184a45">Memory</text> <text x="260" y="428" text-anchor="middle" font-size="13" font-weight="700" fill="#184a45">Memory · Dreaming</text>
<text x="260" y="452" text-anchor="middle" font-size="10" fill="#665245">Short-term Context</text> <text x="260" y="452" text-anchor="middle" font-size="10" fill="#665245">Short-term + Extraction</text>
<text x="260" y="470" text-anchor="middle" font-size="10" fill="#665245">Extraction + Consolidation</text> <text x="260" y="470" text-anchor="middle" font-size="10" fill="#665245">Nightly consolidation</text>
<text x="260" y="488" text-anchor="middle" font-size="9" fill="#9b7d6c">Memory should compound</text> <text x="260" y="488" text-anchor="middle" font-size="9" fill="#9b7d6c">It works while you sleep</text>
<line x1="320" y1="400" x2="452" y2="340" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/> <line x1="320" y1="400" x2="452" y2="340" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
<polygon points="449,336 456,342 447,344" fill="#184a45" opacity="0.5"/> <polygon points="449,336 456,342 447,344" fill="#184a45" opacity="0.5"/>
@ -91,12 +98,19 @@
<line x1="640" y1="400" x2="508" y2="340" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/> <line x1="640" y1="400" x2="508" y2="340" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
<polygon points="513,344 504,342 511,336" fill="#d96d46" opacity="0.5"/> <polygon points="513,344 504,342 511,336" fill="#d96d46" opacity="0.5"/>
<!-- ===== Bottom Center: Models ===== --> <!-- ===== Orchestration tier (NEW 1.3.0): Workflow + Trigger ===== -->
<rect x="350" y="358" width="260" height="36" rx="10" fill="url(#primary)" filter="url(#shadow)"/>
<text x="480" y="376" text-anchor="middle" font-size="11" font-weight="700" fill="#ffffff">Orchestration · Workflow + Trigger</text>
<text x="480" y="389" text-anchor="middle" font-size="9" fill="#fde7dd">Events → multi-employee → approval / dispatch / memory</text>
<!-- ===== Bottom Center: Provider Pool + Failover ===== -->
<rect x="370" y="420" width="220" height="64" rx="12" fill="url(#accent)" filter="url(#shadow)"/> <rect x="370" y="420" width="220" height="64" rx="12" fill="url(#accent)" filter="url(#shadow)"/>
<text x="480" y="448" text-anchor="middle" font-size="13" font-weight="700" fill="#ffffff">Model Layer</text> <text x="480" y="442" text-anchor="middle" font-size="13" font-weight="700" fill="#ffffff">Provider Pool · Failover</text>
<text x="480" y="468" text-anchor="middle" font-size="10" fill="#dce8e4">Cloud + Local · 14+ Providers</text> <text x="480" y="460" text-anchor="middle" font-size="10" fill="#dce8e4">Cloud + Local · 14+ providers</text>
<line x1="480" y1="420" x2="480" y2="352" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/> <text x="480" y="475" text-anchor="middle" font-size="9" fill="#dce8e4">Health Tracker · Auto-switch</text>
<polygon points="475,354 480,346 485,354" fill="#184a45" opacity="0.6"/> <!-- Arrow up to agent (short, stops before orchestration band) -->
<line x1="480" y1="420" x2="480" y2="398" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
<polygon points="475,400 480,392 485,400" fill="#184a45" opacity="0.6"/>
<rect x="340" y="530" width="280" height="26" rx="13" fill="url(#primary)"/> <rect x="340" y="530" width="280" height="26" rx="13" fill="url(#primary)"/>
<text x="480" y="548" text-anchor="middle" font-size="11" font-weight="600" fill="#fff" letter-spacing="0.5">Mate is companion. Claw is capability.</text> <text x="480" y="548" text-anchor="middle" font-size="11" font-weight="600" fill="#fff" letter-spacing="0.5">Mate is companion. Claw is capability.</text>

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

@ -33,18 +33,19 @@
<!-- ===== Center: Agent Core ===== --> <!-- ===== Center: Agent Core ===== -->
<circle cx="480" cy="280" r="72" fill="url(#warm)" stroke="#d96d46" stroke-width="2" filter="url(#glow)"/> <circle cx="480" cy="280" r="72" fill="url(#warm)" stroke="#d96d46" stroke-width="2" filter="url(#glow)"/>
<circle cx="480" cy="280" r="56" fill="#f6e2d7" stroke="#ebb08f" stroke-width="1"/> <circle cx="480" cy="280" r="56" fill="#f6e2d7" stroke="#ebb08f" stroke-width="1"/>
<text x="480" y="268" text-anchor="middle" font-size="16" font-weight="800" fill="#d96d46">智能体</text> <text x="480" y="268" text-anchor="middle" font-size="16" font-weight="800" fill="#d96d46">数字员工</text>
<text x="480" y="288" text-anchor="middle" font-size="11" font-weight="500" fill="#665245">推理 · 规划 · 执行</text> <text x="480" y="288" text-anchor="middle" font-size="11" font-weight="500" fill="#665245">角色 · 目标 · 背景故事</text>
<text x="480" y="304" text-anchor="middle" font-size="9" fill="#9b7d6c">ReAct + Plan-Execute</text> <text x="480" y="304" text-anchor="middle" font-size="9" fill="#9b7d6c">ReAct + Plan-Execute</text>
<!-- ===== Top: User Surfaces ===== --> <!-- ===== Top: User Surfaces (5 items) ===== -->
<rect x="310" y="82" width="340" height="68" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/> <rect x="270" y="82" width="420" height="68" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
<rect x="310" y="82" width="340" height="3" rx="1.5" fill="url(#primary)"/> <rect x="270" y="82" width="420" height="3" rx="1.5" fill="url(#primary)"/>
<text x="480" y="108" text-anchor="middle" font-size="13" font-weight="700" fill="#d96d46">用户触点</text> <text x="480" y="108" text-anchor="middle" font-size="13" font-weight="700" fill="#d96d46">用户触点</text>
<text x="355" y="136" text-anchor="middle" font-size="10" fill="#665245">Web 控制台</text> <text x="312" y="136" text-anchor="middle" font-size="10" fill="#665245">Web 控制台</text>
<text x="440" y="136" text-anchor="middle" font-size="10" fill="#665245">桌面端</text> <text x="396" y="136" text-anchor="middle" font-size="10" fill="#665245">桌面端</text>
<text x="520" y="136" text-anchor="middle" font-size="10" fill="#665245">IM 渠道</text> <text x="480" y="136" text-anchor="middle" font-size="10" fill="#665245">Webchat</text>
<text x="605" y="136" text-anchor="middle" font-size="10" fill="#665245">API</text> <text x="564" y="136" text-anchor="middle" font-size="10" fill="#665245">IM 渠道</text>
<text x="648" y="136" text-anchor="middle" font-size="10" fill="#665245">API</text>
<!-- Arrow down --> <!-- Arrow down -->
<line x1="480" y1="150" x2="480" y2="208" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/> <line x1="480" y1="150" x2="480" y2="208" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
<polygon points="475,206 480,214 485,206" fill="#d96d46" opacity="0.6"/> <polygon points="475,206 480,214 485,206" fill="#d96d46" opacity="0.6"/>
@ -53,35 +54,42 @@
<rect x="40" y="210" width="200" height="140" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/> <rect x="40" y="210" width="200" height="140" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
<rect x="40" y="210" width="4" height="140" rx="2" fill="url(#accent)"/> <rect x="40" y="210" width="4" height="140" rx="2" fill="url(#accent)"/>
<text x="140" y="240" text-anchor="middle" font-size="13" font-weight="700" fill="#184a45">知识系统</text> <text x="140" y="240" text-anchor="middle" font-size="13" font-weight="700" fill="#184a45">知识系统</text>
<text x="140" y="264" text-anchor="middle" font-size="10" fill="#665245">Wiki 知识库</text> <text x="140" y="264" text-anchor="middle" font-size="10" fill="#665245">LLM Wiki 知识库</text>
<text x="140" y="282" text-anchor="middle" font-size="10" fill="#665245">结构化消化 + 链接</text> <text x="140" y="282" text-anchor="middle" font-size="10" fill="#665245">结构化消化 + 双向链接</text>
<text x="140" y="300" text-anchor="middle" font-size="10" fill="#665245">记忆提取与整理</text> <text x="140" y="300" text-anchor="middle" font-size="10" fill="#665245">引用溯源 + 软归档</text>
<text x="140" y="318" text-anchor="middle" font-size="10" fill="#665245">工作区上下文文件</text> <text x="140" y="318" text-anchor="middle" font-size="10" fill="#665245">+ 加工器流水线1.3.0+</text>
<text x="140" y="336" text-anchor="middle" font-size="9" fill="#9b7d6c">知识不是存储,是塑造</text> <text x="140" y="336" text-anchor="middle" font-size="9" fill="#9b7d6c">是图书馆,不是向量库</text>
<!-- Arrow right --> <!-- Arrow right -->
<line x1="240" y1="280" x2="408" y2="280" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/> <line x1="240" y1="280" x2="408" y2="280" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
<polygon points="406,275 414,280 406,285" fill="#184a45" opacity="0.6"/> <polygon points="406,275 414,280 406,285" fill="#184a45" opacity="0.6"/>
<!-- ===== Right: Tools & Skills ===== --> <!-- ===== Right Top: Tools & Skills ===== -->
<rect x="720" y="210" width="200" height="140" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/> <rect x="720" y="210" width="200" height="65" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
<rect x="916" y="210" width="4" height="140" rx="2" fill="url(#primary)"/> <rect x="916" y="210" width="4" height="65" rx="2" fill="url(#primary)"/>
<text x="820" y="240" text-anchor="middle" font-size="13" font-weight="700" fill="#d96d46">工具与技能</text> <text x="820" y="232" text-anchor="middle" font-size="13" font-weight="700" fill="#d96d46">技能 · MCP · ACP</text>
<text x="820" y="264" text-anchor="middle" font-size="10" fill="#665245">内置工具集</text> <text x="820" y="252" text-anchor="middle" font-size="10" fill="#665245">SKILL.md + LESSONS</text>
<text x="820" y="282" text-anchor="middle" font-size="10" fill="#665245">MCP 协议扩展</text> <text x="820" y="266" text-anchor="middle" font-size="9" fill="#9b7d6c">Claude Code 也来当员工</text>
<text x="820" y="300" text-anchor="middle" font-size="10" fill="#665245">技能包 + ClawHub</text> <!-- Arrow to Tools -->
<text x="820" y="318" text-anchor="middle" font-size="10" fill="#665245">安全审批与防护</text> <line x1="552" y1="242" x2="720" y2="242" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
<text x="820" y="336" text-anchor="middle" font-size="9" fill="#9b7d6c">能力需要边界</text> <polygon points="554,237 546,242 554,247" fill="#d96d46" opacity="0.6"/>
<!-- Arrow left -->
<line x1="552" y1="280" x2="720" y2="280" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
<polygon points="554,275 546,280 554,285" fill="#d96d46" opacity="0.6"/>
<!-- ===== Bottom Left: Memory ===== --> <!-- ===== Right Bottom: Security & Approval ===== -->
<rect x="720" y="285" width="200" height="65" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
<rect x="916" y="285" width="4" height="65" rx="2" fill="url(#accent)"/>
<text x="820" y="307" text-anchor="middle" font-size="13" font-weight="700" fill="#184a45">安全与审批</text>
<text x="820" y="327" text-anchor="middle" font-size="10" fill="#665245">Tool Guard + 审批流</text>
<text x="820" y="341" text-anchor="middle" font-size="9" fill="#9b7d6c">会动手,不擅自动手</text>
<!-- Arrow to Security -->
<line x1="552" y1="317" x2="720" y2="317" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
<polygon points="554,312 546,317 554,322" fill="#184a45" opacity="0.6"/>
<!-- ===== Bottom Left: Memory · Dreaming ===== -->
<rect x="160" y="400" width="200" height="100" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/> <rect x="160" y="400" width="200" height="100" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
<rect x="160" y="496" width="200" height="4" rx="2" fill="url(#accent)"/> <rect x="160" y="496" width="200" height="4" rx="2" fill="url(#accent)"/>
<text x="260" y="428" text-anchor="middle" font-size="13" font-weight="700" fill="#184a45">记忆层</text> <text x="260" y="428" text-anchor="middle" font-size="13" font-weight="700" fill="#184a45">记忆 · Dreaming</text>
<text x="260" y="452" text-anchor="middle" font-size="10" fill="#665245">短期上下文管理</text> <text x="260" y="452" text-anchor="middle" font-size="10" fill="#665245">短期上下文 + 长期提取</text>
<text x="260" y="470" text-anchor="middle" font-size="10" fill="#665245">长期提取 + 定时整理</text> <text x="260" y="470" text-anchor="middle" font-size="10" fill="#665245">夜里整合,早上接着</text>
<text x="260" y="488" text-anchor="middle" font-size="9" fill="#9b7d6c">记忆应该积累而非消散</text> <text x="260" y="488" text-anchor="middle" font-size="9" fill="#9b7d6c">你睡了它在工作</text>
<!-- Arrow up-right --> <!-- Arrow up-right -->
<line x1="320" y1="400" x2="452" y2="340" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/> <line x1="320" y1="400" x2="452" y2="340" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
<polygon points="449,336 456,342 447,344" fill="#184a45" opacity="0.5"/> <polygon points="449,336 456,342 447,344" fill="#184a45" opacity="0.5"/>
@ -97,13 +105,19 @@
<line x1="640" y1="400" x2="508" y2="340" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/> <line x1="640" y1="400" x2="508" y2="340" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
<polygon points="513,344 504,342 511,336" fill="#d96d46" opacity="0.5"/> <polygon points="513,344 504,342 511,336" fill="#d96d46" opacity="0.5"/>
<!-- ===== Bottom Center: Models ===== --> <!-- ===== Orchestration tier (NEW 1.3.0): Workflow + Trigger ===== -->
<rect x="350" y="358" width="260" height="36" rx="10" fill="url(#primary)" filter="url(#shadow)"/>
<text x="480" y="376" text-anchor="middle" font-size="11" font-weight="700" fill="#ffffff">业务编排 · 工作流 + 触发器</text>
<text x="480" y="389" text-anchor="middle" font-size="9" fill="#fde7dd">事件触发 → 多员工协作 → 审批 / 分发 / 写记忆</text>
<!-- ===== Bottom Center: Models + Failover ===== -->
<rect x="370" y="420" width="220" height="64" rx="12" fill="url(#accent)" filter="url(#shadow)"/> <rect x="370" y="420" width="220" height="64" rx="12" fill="url(#accent)" filter="url(#shadow)"/>
<text x="480" y="448" text-anchor="middle" font-size="13" font-weight="700" fill="#ffffff">模型供应</text> <text x="480" y="442" text-anchor="middle" font-size="13" font-weight="700" fill="#ffffff">模型池 · Failover</text>
<text x="480" y="468" text-anchor="middle" font-size="10" fill="#dce8e4">云端 + 本地 · 14+ 供应商</text> <text x="480" y="460" text-anchor="middle" font-size="10" fill="#dce8e4">云端 + 本地 · 14+ 供应商</text>
<!-- Arrow up --> <text x="480" y="475" text-anchor="middle" font-size="9" fill="#dce8e4">健康追踪 · 自动切换</text>
<line x1="480" y1="420" x2="480" y2="352" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/> <!-- Arrow up to agent (short, stops before orchestration band) -->
<polygon points="475,354 480,346 485,354" fill="#184a45" opacity="0.6"/> <line x1="480" y1="420" x2="480" y2="398" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
<polygon points="475,400 480,392 485,400" fill="#184a45" opacity="0.6"/>
<!-- Footer --> <!-- Footer -->
<rect x="340" y="530" width="280" height="26" rx="13" fill="url(#primary)"/> <rect x="340" y="530" width="280" height="26" rx="13" fill="url(#primary)"/>

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@ -56,37 +56,39 @@
</g> </g>
<g transform="translate(632, 118)"> <g transform="translate(632, 118)">
<rect width="130" height="40" rx="8" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5"/> <rect width="130" height="40" rx="8" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5"/>
<text x="65" y="17" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">Channel Adapters</text> <text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">China IM (5)</text>
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">DingTalk / Feishu / WeCom</text> <text x="65" y="26" text-anchor="middle" font-size="9" fill="#665245">DingTalk · Feishu</text>
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">WeCom · WeChat · QQ</text>
</g> </g>
<g transform="translate(776, 118)"> <g transform="translate(776, 118)">
<rect width="130" height="40" rx="8" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5"/> <rect width="130" height="40" rx="8" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5"/>
<text x="65" y="17" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">Webhooks</text> <text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">Global IM (3)</text>
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">Telegram / Discord / QQ</text> <text x="65" y="26" text-anchor="middle" font-size="9" fill="#665245">Telegram · Discord</text>
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">Slack</text>
</g> </g>
<!-- ===== Layer 2: Agent Engine ===== --> <!-- ===== Layer 2: Agent Engine ===== -->
<rect x="30" y="186" width="900" height="118" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/> <rect x="30" y="186" width="900" height="118" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
<rect x="30" y="186" width="900" height="4" rx="2" fill="url(#accent)"/> <rect x="30" y="186" width="900" height="4" rx="2" fill="url(#accent)"/>
<text x="56" y="210" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">AGENT ENGINE</text> <text x="56" y="210" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">DIGITAL EMPLOYEE RUNTIME</text>
<g transform="translate(56, 222)"> <g transform="translate(56, 222)">
<rect width="172" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/> <rect width="172" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">ReAct Agent</text> <text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">Reasoning Engines</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Think Act Observe</text> <text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">ReAct · Think→Act→Observe</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Iterative Reasoning Loop</text> <text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Plan-Execute · Decompose</text>
</g> </g>
<g transform="translate(244, 222)"> <g transform="translate(244, 222)">
<rect width="172" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/> <rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">Plan-Execute</text> <text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Workflow + Trigger</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Decompose → Step Execute</text> <text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">7 step modes · 6 patterns</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Complex Task Orchestration</text> <text x="86" y="50" text-anchor="middle" font-size="9" fill="#9b7d6c">Business orchestration (1.3.0+)</text>
</g> </g>
<g transform="translate(432, 222)"> <g transform="translate(432, 222)">
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/> <rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Tool System</text> <text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Skills · Tools</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Built-in + MCP + Skills</text> <text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Built-in · MCP · ACP · Skills</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Approval + Guard Rules</text> <text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">SKILL.md + LESSONS + Approval</text>
</g> </g>
<g transform="translate(620, 222)"> <g transform="translate(620, 222)">
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/> <rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
@ -97,8 +99,8 @@
<g transform="translate(808, 222)"> <g transform="translate(808, 222)">
<rect width="108" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/> <rect width="108" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
<text x="54" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Wiki KB</text> <text x="54" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Wiki KB</text>
<text x="54" y="36" text-anchor="middle" font-size="9" fill="#665245">Knowledge</text> <text x="54" y="36" text-anchor="middle" font-size="9" fill="#665245">Knowledge digest</text>
<text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">Digestion</text> <text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">+ Transforms (1.3)</text>
</g> </g>
<!-- ===== Layer 3: Core Services ===== --> <!-- ===== Layer 3: Core Services ===== -->
@ -132,9 +134,9 @@
<g transform="translate(516, 356)"> <g transform="translate(516, 356)">
<rect width="120" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/> <rect width="120" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
<text x="60" y="20" text-anchor="middle" font-size="10" font-weight="600" fill="#184a45">Spring AI</text> <text x="60" y="20" text-anchor="middle" font-size="10" font-weight="600" fill="#184a45">Provider Pool</text>
<text x="60" y="36" text-anchor="middle" font-size="9" fill="#665245">Unified Abstraction</text> <text x="60" y="36" text-anchor="middle" font-size="9" fill="#665245">Spring AI · Failover</text>
<text x="60" y="50" text-anchor="middle" font-size="8" fill="#9b7d6c">Chat + Embedding</text> <text x="60" y="50" text-anchor="middle" font-size="8" fill="#9b7d6c">Health Tracker · Cooldown</text>
</g> </g>
<g transform="translate(650, 356)"> <g transform="translate(650, 356)">
<rect width="130" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/> <rect width="130" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
@ -176,8 +178,9 @@
</g> </g>
<g transform="translate(632, 488)"> <g transform="translate(632, 488)">
<rect width="130" height="40" rx="8" fill="#fff" stroke="#d9cec2" stroke-width="0.5"/> <rect width="130" height="40" rx="8" fill="#fff" stroke="#d9cec2" stroke-width="0.5"/>
<text x="65" y="17" text-anchor="middle" font-size="11" font-weight="600" fill="#1d1612">Cron Scheduler</text> <text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#1d1612">Cron · Event Bus</text>
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">Task Automation</text> <text x="65" y="26" text-anchor="middle" font-size="9" fill="#665245">ShedLock distributed</text>
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">Ambient AI · Proactive</text>
</g> </g>
<g transform="translate(776, 488)"> <g transform="translate(776, 488)">
<rect width="130" height="40" rx="8" fill="#fff" stroke="#d9cec2" stroke-width="0.5"/> <rect width="130" height="40" rx="8" fill="#fff" stroke="#d9cec2" stroke-width="0.5"/>
@ -191,7 +194,7 @@
<line x1="250" y1="438" x2="250" y2="454" stroke="#665245" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/> <line x1="250" y1="438" x2="250" y2="454" stroke="#665245" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
<line x1="710" y1="438" x2="710" y2="454" stroke="#665245" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/> <line x1="710" y1="438" x2="710" y2="454" stroke="#665245" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
<text x="480" y="575" text-anchor="middle" font-size="10" fill="#9b7d6c">Java 17+ · Spring Boot 3.5 · Spring AI Alibaba · Vue 3 · TypeScript · Vite · Electron</text> <text x="480" y="575" text-anchor="middle" font-size="10" fill="#9b7d6c">Java 21+ · Spring Boot 3.5 · Spring AI Alibaba · Vue 3 · TypeScript · Vite · Electron</text>
<rect x="380" y="590" width="200" height="24" rx="12" fill="url(#primary)"/> <rect x="380" y="590" width="200" height="24" rx="12" fill="url(#primary)"/>
<text x="480" y="607" text-anchor="middle" font-size="11" font-weight="600" fill="#fff" letter-spacing="0.5">claw.mate.vip</text> <text x="480" y="607" text-anchor="middle" font-size="11" font-weight="600" fill="#fff" letter-spacing="0.5">claw.mate.vip</text>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -59,49 +59,51 @@
</g> </g>
<g transform="translate(632, 118)"> <g transform="translate(632, 118)">
<rect width="130" height="40" rx="8" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5"/> <rect width="130" height="40" rx="8" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5"/>
<text x="65" y="17" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">多渠道适配器</text> <text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">国内 IM (5)</text>
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">钉钉 / 飞书 / 企微 / TG</text> <text x="65" y="26" text-anchor="middle" font-size="9" fill="#665245">钉钉·飞书·企微</text>
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">微信·QQ</text>
</g> </g>
<g transform="translate(776, 118)"> <g transform="translate(776, 118)">
<rect width="130" height="40" rx="8" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5"/> <rect width="130" height="40" rx="8" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5"/>
<text x="65" y="17" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">Webhook</text> <text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">海外 IM (3)</text>
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">Discord / QQ</text> <text x="65" y="26" text-anchor="middle" font-size="9" fill="#665245">Telegram·Discord</text>
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">Slack</text>
</g> </g>
<!-- ===== Layer 2: Agent Engine ===== --> <!-- ===== Layer 2: Agent Engine ===== -->
<rect x="30" y="186" width="900" height="118" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/> <rect x="30" y="186" width="900" height="118" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
<rect x="30" y="186" width="900" height="4" rx="2" fill="url(#accent)"/> <rect x="30" y="186" width="900" height="4" rx="2" fill="url(#accent)"/>
<text x="56" y="210" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">智能体引擎</text> <text x="56" y="210" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">数字员工运行时</text>
<g transform="translate(56, 222)"> <g transform="translate(56, 222)">
<rect width="172" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/> <rect width="172" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">ReAct Agent</text> <text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">推理双引擎</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">思考 行动 观察</text> <text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">ReAct · 思考→行动→观察</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">循环推理引擎</text> <text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Plan-Execute · 计划分解</text>
</g> </g>
<g transform="translate(244, 222)"> <g transform="translate(244, 222)">
<rect width="172" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/> <rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">Plan-Execute</text> <text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">工作流 + 触发器</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">计划分解 → 逐步执行</text> <text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">7 step mode · 6 pattern</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">复杂任务编排</text> <text x="86" y="50" text-anchor="middle" font-size="9" fill="#9b7d6c">业务流程编排1.3.0+</text>
</g> </g>
<g transform="translate(432, 222)"> <g transform="translate(432, 222)">
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/> <rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">工具系统</text> <text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">技能 · 工具</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">内置 + MCP + 技能包</text> <text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">内置 · MCP · ACP · 技能</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">安全审批 + 防护规则</text> <text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">SKILL.md + LESSONS + 审批</text>
</g> </g>
<g transform="translate(620, 222)"> <g transform="translate(620, 222)">
<rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/> <rect width="172" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">记忆系统</text> <text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">记忆 · Dreaming</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">短期上下文 + 长期提取</text> <text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">短期上下文 + 长期提取</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">定时整理 + 记忆涌现</text> <text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">夜里整合 · 你睡了它在工作</text>
</g> </g>
<g transform="translate(808, 222)"> <g transform="translate(808, 222)">
<rect width="108" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/> <rect width="108" height="68" rx="10" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5" filter="url(#shadowSm)"/>
<text x="54" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Wiki KB</text> <text x="54" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Wiki KB</text>
<text x="54" y="36" text-anchor="middle" font-size="9" fill="#665245">知识消化</text> <text x="54" y="36" text-anchor="middle" font-size="9" fill="#665245">知识消化</text>
<text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">结构化页面</text> <text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">+ 加工器1.3.0</text>
</g> </g>
<!-- ===== Layer 3: Core Services ===== --> <!-- ===== Layer 3: Core Services ===== -->
@ -135,9 +137,9 @@
<g transform="translate(516, 356)"> <g transform="translate(516, 356)">
<rect width="120" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/> <rect width="120" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
<text x="60" y="20" text-anchor="middle" font-size="10" font-weight="600" fill="#184a45">Spring AI</text> <text x="60" y="20" text-anchor="middle" font-size="10" font-weight="600" fill="#184a45">模型池 · Failover</text>
<text x="60" y="36" text-anchor="middle" font-size="9" fill="#665245">统一模型抽象</text> <text x="60" y="36" text-anchor="middle" font-size="9" fill="#665245">Spring AI 统一抽象</text>
<text x="60" y="50" text-anchor="middle" font-size="8" fill="#9b7d6c">Chat + Embedding</text> <text x="60" y="50" text-anchor="middle" font-size="8" fill="#9b7d6c">健康追踪 · 自动切换</text>
</g> </g>
<g transform="translate(650, 356)"> <g transform="translate(650, 356)">
<rect width="130" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/> <rect width="130" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
@ -179,8 +181,9 @@
</g> </g>
<g transform="translate(632, 488)"> <g transform="translate(632, 488)">
<rect width="130" height="40" rx="8" fill="#fff" stroke="#d9cec2" stroke-width="0.5"/> <rect width="130" height="40" rx="8" fill="#fff" stroke="#d9cec2" stroke-width="0.5"/>
<text x="65" y="17" text-anchor="middle" font-size="11" font-weight="600" fill="#1d1612">定时任务</text> <text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#1d1612">Cron · 事件总线</text>
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">Cron 调度引擎</text> <text x="65" y="26" text-anchor="middle" font-size="9" fill="#665245">ShedLock 分布式锁</text>
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">主动交付 · Ambient AI</text>
</g> </g>
<g transform="translate(776, 488)"> <g transform="translate(776, 488)">
<rect width="130" height="40" rx="8" fill="#fff" stroke="#d9cec2" stroke-width="0.5"/> <rect width="130" height="40" rx="8" fill="#fff" stroke="#d9cec2" stroke-width="0.5"/>
@ -195,7 +198,7 @@
<line x1="710" y1="438" x2="710" y2="454" stroke="#665245" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/> <line x1="710" y1="438" x2="710" y2="454" stroke="#665245" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.4"/>
<!-- Footer --> <!-- Footer -->
<text x="480" y="575" text-anchor="middle" font-size="10" fill="#9b7d6c">Java 17+ · Spring Boot 3.5 · Spring AI Alibaba · Vue 3 · TypeScript · Vite · Electron</text> <text x="480" y="575" text-anchor="middle" font-size="10" fill="#9b7d6c">Java 21+ · Spring Boot 3.5 · Spring AI Alibaba · Vue 3 · TypeScript · Vite · Electron</text>
<rect x="380" y="590" width="200" height="24" rx="12" fill="url(#primary)"/> <rect x="380" y="590" width="200" height="24" rx="12" fill="url(#primary)"/>
<text x="480" y="607" text-anchor="middle" font-size="11" font-weight="600" fill="#fff" letter-spacing="0.5">claw.mate.vip</text> <text x="480" y="607" text-anchor="middle" font-size="11" font-weight="600" fill="#fff" letter-spacing="0.5">claw.mate.vip</text>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 811 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@ -1,81 +1,194 @@
version: '3.8' # ============================================================================
# ⚠️ DATABASE ENGINE: PostgreSQL (was MySQL before)
#
# This stack now runs on PostgreSQL 16. Switching the DB engine is a BREAKING
# change for existing deployments: `docker compose up -d` on a host that
# previously ran the MySQL stack starts a FRESH, EMPTY PostgreSQL volume
# (postgres_data). The old `mysql_data` volume is NOT read and the app
# re-seeds default data — existing data is not lost, but it is also NOT
# visible to the new stack.
#
# Treat this as FRESH-INSTALL-ONLY. To carry data across from a MySQL
# deployment, dump the OLD stack BEFORE pulling this change, e.g.:
# docker compose exec mysql mysqldump -u"$DB_USERNAME" -p"$DB_PASSWORD" "$DB_NAME" > mateclaw-mysql.sql
# then load into PostgreSQL with a cross-engine tool such as pgloader —
# there is no automatic MySQL → PostgreSQL migration here. Alternatively,
# keep running MySQL by pinning your checkout to a pre-switch tag and setting
# SPRING_PROFILES_ACTIVE=mysql (the mysql Spring profile remains supported).
# ============================================================================
services: services:
# MySQL 数据库 # PostgreSQL 数据库
# #
# ⚠️ 密码通过环境变量传入,必须从 .env 文件提供。首次部署前: # ⚠️ 密码通过环境变量传入,必须从 .env 文件提供。首次部署前:
# 1. cp .env.example .env # 1. cp .env.example .env
# 2. 编辑 .env 把 DB_ROOT_PASSWORD / DB_PASSWORD 改成强密码 # 2. 编辑 .env 把 DB_ADMIN_PASSWORD / DB_PASSWORD 改成强密码
# 未设置会直接在 `docker compose up` 时报错,避免把默认密码带到生产环境。 # 未设置会直接在 `docker compose up` 时报错,避免把默认密码带到生产环境。
mysql: postgres:
image: mysql:8.0 image: postgres:16
container_name: mateclaw-mysql container_name: mateclaw-postgres
restart: unless-stopped restart: unless-stopped
environment: environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:?DB_ROOT_PASSWORD is required in .env} # Bootstrap/superuser — owns the cluster, used only for init + admin tasks.
MYSQL_DATABASE: ${DB_NAME:-mateclaw} POSTGRES_DB: ${DB_NAME:-mateclaw}
MYSQL_USER: ${DB_USERNAME:-mateclaw} POSTGRES_USER: ${DB_ADMIN_USERNAME:-mateclaw_admin}
MYSQL_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required in .env} POSTGRES_PASSWORD: ${DB_ADMIN_PASSWORD:?DB_ADMIN_PASSWORD is required in .env}
# Least-privilege application role created by the init script below; this
# is the account the server connects with (NOT a superuser).
APP_DB_USERNAME: ${DB_USERNAME:-mateclaw}
APP_DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required in .env}
TZ: Asia/Shanghai TZ: Asia/Shanghai
ports: # No host port on purpose — the DB is only reachable from the compose
- "3306:3306" # network. For ad-hoc inspection use `docker compose exec postgres psql`,
# or temporarily add: ports: ["127.0.0.1:5432:5432"]
volumes: volumes:
- mysql_data:/var/lib/mysql - postgres_data:/var/lib/postgresql/data
- ./mateclaw-server/src/main/resources/db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql # Runs once on first init (empty data dir): creates the restricted app
- ./mateclaw-server/src/main/resources/db/data.sql:/docker-entrypoint-initdb.d/02-data.sql # role and the mateclaw schema it owns. See docker/postgres/init/.
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci - ./docker/postgres/init:/docker-entrypoint-initdb.d:ro
healthcheck: healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] test: ["CMD-SHELL", "pg_isready -U ${DB_ADMIN_USERNAME:-mateclaw_admin} -d ${DB_NAME:-mateclaw}"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
networks:
- mateclaw-net
# SearXNG 搜索引擎keyless 搜索 provider零配置可用 # SearXNG 搜索引擎keyless 搜索 provider
#
# The custom image at docker/searxng/ bakes in settings.yml so the sidecar
# works out of the box (upstream image ships JSON disabled + Limiter enabled,
# both of which silently break mateclaw's SearXNGSearchProvider).
# No host bind-mount — edit docker/searxng/settings.yml and rebuild.
# Internal-only: the app reaches it via the compose network. To debug from
# the host, temporarily add: ports: ["127.0.0.1:8088:8080"]
searxng: searxng:
image: searxng/searxng:latest build:
context: ./docker/searxng
container_name: mateclaw-searxng container_name: mateclaw-searxng
restart: unless-stopped restart: unless-stopped
environment: environment:
- SEARXNG_BASE_URL=http://searxng:8080 - SEARXNG_BASE_URL=http://searxng:8080
volumes: - SEARXNG_SECRET=${SEARXNG_SECRET:-mateclaw-dev-searxng-secret-change-me}
- searxng_data:/etc/searxng - UWSGI_WORKERS=2
ports: - UWSGI_THREADS=4
- "8088:8080"
healthcheck: healthcheck:
# Healthz needs json format, so this also doubles as an integration check.
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/healthz"] test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/healthz"]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
networks:
- mateclaw-net
# MateClaw 后端服务 # MateClaw 后端服务
mateclaw-server: mateclaw-server:
build: build:
context: ./mateclaw-server context: .
dockerfile: Dockerfile dockerfile: mateclaw-server/Dockerfile
args:
MAVEN_FLAGS: ${MAVEN_FLAGS:-}
container_name: mateclaw-server container_name: mateclaw-server
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
mysql: postgres:
condition: service_healthy condition: service_healthy
searxng: searxng:
condition: service_healthy condition: service_healthy
environment: environment:
SPRING_PROFILES_ACTIVE: mysql SPRING_PROFILES_ACTIVE: postgres
DB_HOST: mysql DB_HOST: postgres
DB_PORT: 3306 DB_PORT: 5432
DB_NAME: ${DB_NAME:-mateclaw} DB_NAME: ${DB_NAME:-mateclaw}
DB_USERNAME: ${DB_USERNAME:-mateclaw} DB_USERNAME: ${DB_USERNAME:-mateclaw}
DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required in .env} DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required in .env}
DASHSCOPE_API_KEY: ${DASHSCOPE_API_KEY:?DASHSCOPE_API_KEY is required in .env} # LLM provider keys (DashScope / OpenAI / Anthropic / DeepSeek / Kimi / …) are
# NOT configured via env vars. After startup, add providers in the admin UI:
# Settings → Models → Add Provider
# Keys are stored in mate_model_provider and hot-reloaded.
SERPER_API_KEY: ${SERPER_API_KEY:-} SERPER_API_KEY: ${SERPER_API_KEY:-}
TAVILY_API_KEY: ${TAVILY_API_KEY:-}
JWT_SECRET: ${JWT_SECRET:-} JWT_SECRET: ${JWT_SECRET:-}
MATECLAW_CORS_ALLOWED_ORIGINS: ${MATECLAW_CORS_ALLOWED_ORIGINS:-} MATECLAW_CORS_ALLOWED_ORIGINS: ${MATECLAW_CORS_ALLOWED_ORIGINS:-}
# SearXNG: tell the app where to reach the sidecar container
SEARXNG_BASE_URL: ${SEARXNG_BASE_URL:-http://searxng:8080}
# Browser automation: the runtime image (mcr.microsoft.com/playwright:*)
# bakes Chromium + system libs + fonts in, so the tool works out of the box.
# Override these if you want to attach to an external Chrome (CDP sidecar):
MATECLAW_BROWSER_CDP_URL: ${MATECLAW_BROWSER_CDP_URL:-}
MATECLAW_BROWSER_CHROME_PATH: ${MATECLAW_BROWSER_CHROME_PATH:-}
MATECLAW_BROWSER_CHANNEL: ${MATECLAW_BROWSER_CHANNEL:-}
# SSRF / TLS relaxations for isolated LAN / on-prem deployments.
# Both default to false (strict mode, public-internet safe).
# The .env file uses the PLAYWRIGHT_* prefix (component-oriented naming,
# not product-oriented) — here we translate to the MATECLAW_BROWSER_*
# container env that Spring Boot relaxed-binding maps to BrowserProperties.
# - PLAYWRIGHT_ALLOW_PRIVATE_NETWORK=true: allow loopback / private / link-local
# addresses through the browser SSRF guard. Cloud-metadata endpoints stay
# blocked. Turn on when the agent must drive http://192.168.x.x:port style
# internal services and has no path to the public internet.
# - PLAYWRIGHT_IGNORE_HTTPS_ERRORS=true: ignore HTTPS certificate errors.
# Auto-enables --ignore-certificate-errors at the Chromium command line
# when ALLOW_PRIVATE_NETWORK is also true (so CDP-attached external
# browsers benefit too). Leave false on internet-facing deployments.
MATECLAW_BROWSER_ALLOW_PRIVATE_NETWORK: ${PLAYWRIGHT_ALLOW_PRIVATE_NETWORK:-false}
MATECLAW_BROWSER_IGNORE_HTTPS_ERRORS: ${PLAYWRIGHT_IGNORE_HTTPS_ERRORS:-false}
# Playwright action / navigation timeouts (seconds). Increase for slow
# LAN or large-page scenarios. Defaults match Playwright's own (30s).
MATECLAW_BROWSER_DEFAULT_TIMEOUT_SECONDS: ${PLAYWRIGHT_DEFAULT_TIMEOUT_SECONDS:-30}
MATECLAW_BROWSER_DEFAULT_NAVIGATION_TIMEOUT_SECONDS: ${PLAYWRIGHT_NAVIGATION_TIMEOUT_SECONDS:-30}
# Hard cap on the textual snapshot returned by action=snapshot. Content
# beyond this length is dropped with a truncated:true flag and a hint to
# retry with selector. Results > framework spill threshold (~8000 chars)
# are further spilt to disk by ToolResultStorage.
MATECLAW_BROWSER_SNAPSHOT_MAX_LENGTH: ${PLAYWRIGHT_SNAPSHOT_MAX_LENGTH:-20000}
# OAuth 模式默认保持 autolocalhost 访问走 LOCALIP/域名访问走 DEVICE_CODE。
# 本机 Docker 若要强制使用 localhost:1455 回调,可在 .env 显式设为 local。
MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE: ${MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE:-}
MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST: ${MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST:-0.0.0.0}
# Wiki 知识库目录扫描白名单(逗号分隔,留空则禁止所有目录扫描)。
# 示例MATE_WIKI_ALLOWED_SOURCE_ROOTS=/data/wiki,/opt/docs
# 记得同步在 volumes 里把宿主机路径挂进容器。
MATE_WIKI_ALLOWED_SOURCE_ROOTS: ${MATE_WIKI_ALLOWED_SOURCE_ROOTS:-}
# Wiki 知识源自动同步总开关运维总闸默认关。AND 语义:全局开关与
# 每个知识库自己的「自动同步」开关都开,该库才会被定时扫描。
# 间隔单位毫秒,默认 5 分钟。
MATE_WIKI_WATCHER_ENABLED: ${MATE_WIKI_WATCHER_ENABLED:-false}
MATE_WIKI_WATCHER_INTERVAL_MS: ${MATE_WIKI_WATCHER_INTERVAL_MS:-300000}
# Skill 工作区根目录。放在 /app/data 下,让现有的 server_data 卷一并持久化
# 已安装的 skill、运行时积累的 LESSONS.md 以及 skill 运行产物,容器重启不丢。
# 内置 skill 仍由 JAR classpath 每次启动现场释放,空卷不会丢内置文件。
MATECLAW_SKILL_WORKSPACE_ROOT: ${MATECLAW_SKILL_WORKSPACE_ROOT:-/app/data/skills}
# Skill ZIP 上传/安装大小上限MB。解包过程整包缓存在内存里
# max-total 调多大,单次安装的峰值内存就可能吃多大。
MATECLAW_SKILL_UPLOAD_MAX_ENTRY_SIZE_MB: ${MATECLAW_SKILL_UPLOAD_MAX_ENTRY_SIZE_MB:-1}
MATECLAW_SKILL_UPLOAD_MAX_TOTAL_SIZE_MB: ${MATECLAW_SKILL_UPLOAD_MAX_TOTAL_SIZE_MB:-50}
# pip 镜像源配置可选。skill 里的 Python 脚本缺包时 pip install 会走这个源。
# 留空则用 PyPI 默认源pypi.org。pip 原生读 PIP_INDEX_URL / PIP_TRUSTED_HOST
# 环境变量容器内所有进程JVM、Python 子进程、bash自动继承无需额外配置。
# HTTP 源会自动从 URL 推导 PIP_TRUSTED_HOST自签 HTTPS 需手动填。
# - 互联网加速PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
# - 局域网私有源PIP_INDEX_URL=http://192.168.1.100:8080/simpletrusted-host 自动推导)
PIP_INDEX_URL: ${PIP_INDEX_URL:-}
PIP_TRUSTED_HOST: ${PIP_TRUSTED_HOST:-}
# Chromium needs a real /dev/shm. Docker defaults to 64MB which causes
# SIGBUS / "Target page closed" errors under load. 2GB is the usual
# recommendation for Playwright / headless chrome.
shm_size: 2gb
ports: ports:
- "18080:18080" - "18080:18088" # host:container — app listens on 18088 inside the container
- "1455:1455"
volumes: volumes:
# server_data covers /app/data — wiki-uploads AND the skill workspace
# (MATECLAW_SKILL_WORKSPACE_ROOT=/app/data/skills above), so a single
# volume persists everything. No separate skills volume needed.
- server_data:/app/data - server_data:/app/data
networks:
- mateclaw-net
volumes: volumes:
mysql_data: postgres_data:
server_data: server_data:
searxng_data:
networks:
mateclaw-net:
driver: bridge

View File

@ -0,0 +1,43 @@
#!/bin/sh
# ============================================================================
# Create a least-privilege application role for the MateClaw server.
#
# Runs once, during first container init (empty data dir), as the bootstrap
# superuser (POSTGRES_USER) against POSTGRES_DB. The app role:
# - can log in and CONNECT to the database,
# - owns the `mateclaw` schema (so Flyway can create/alter tables in it),
# - is NOT a superuser and cannot touch other databases/roles.
#
# The server connects as APP_DB_USERNAME / APP_DB_PASSWORD.
# ============================================================================
set -e
# Pass credentials as psql variables (-v) rather than interpolating them into
# the SQL text. The quoted heredoc (<<'EOSQL') keeps the body literal, and psql
# does the quoting: :'var' -> safe string literal, :"var" -> safe identifier.
# CREATE ROLE is generated via format(%I, %L) + \gexec so a password containing
# a quote (or an exotic role name) can't break or inject into the statement.
psql -v ON_ERROR_STOP=1 \
--username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \
-v app_user="$APP_DB_USERNAME" \
-v app_pw="$APP_DB_PASSWORD" \
-v db="$POSTGRES_DB" <<'EOSQL'
SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'app_user', :'app_pw')
WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = :'app_user')
\gexec
-- CONNECT to use the database; CREATE so the role can create schemas in it.
-- CREATE is required because Flyway's init-sql runs CREATE SCHEMA IF NOT
-- EXISTS, and PostgreSQL checks the database-level CREATE privilege *before*
-- the IF NOT EXISTS short-circuit — so even a pre-existing schema is denied
-- without it. Still scoped to this one database; not a cluster superuser.
GRANT CONNECT, CREATE ON DATABASE :"db" TO :"app_user";
-- The app owns its schema so Flyway DDL works, without cluster superuser rights.
CREATE SCHEMA IF NOT EXISTS mateclaw AUTHORIZATION :"app_user";
-- Default to the app schema on every connection from this role.
ALTER ROLE :"app_user" SET search_path TO mateclaw, public;
EOSQL
echo "[init] application role '${APP_DB_USERNAME}' and schema 'mateclaw' ready"

View File

@ -0,0 +1,9 @@
# Custom SearXNG image for MateClaw.
#
# Bakes our settings.yml into /etc/searxng/settings.yml so the sidecar works
# out of the box with no host bind-mount. The upstream image ships JSON output
# disabled and the Limiter plugin enabled — both silently break mateclaw's
# SearXNGSearchProvider, so this override is required.
FROM searxng/searxng:latest
COPY settings.yml /etc/searxng/settings.yml

View File

@ -0,0 +1,61 @@
# SearXNG config for MateClaw's bundled search sidecar.
#
# Two things differ from the upstream default:
# 1. JSON output format is enabled — mateclaw's SearXNGSearchProvider
# queries /search?format=json and fails silently without this.
# 2. The anti-bot Limiter plugin is disabled — it otherwise rejects
# server-side HTTP calls (no JS, no cookies) with HTTP 429.
#
# This file is baked into the custom image via docker/searxng/Dockerfile —
# do NOT bind-mount it from the host (prior host bind-mount broke deploys
# where the host directory did not exist and Docker auto-created an empty
# dir over the path).
#
# See https://docs.searxng.org/admin/settings/ for all knobs.
use_default_settings: true
general:
# Cosmetic only; shown in the UI title.
instance_name: "MateClaw Search"
# Keep this private — no outbound metrics.
donation_url: false
contact_url: false
enable_metrics: false
search:
safe_search: 0
autocomplete: ""
default_lang: "auto"
formats:
- html
- json # REQUIRED for mateclaw integration
server:
# Override the default dev secret; docker-compose passes SEARXNG_SECRET in.
secret_key: "${SEARXNG_SECRET:-please-change-me-to-a-random-32-char-string}"
# Trust Docker's internal network — the reverse-proxy / rate-limit plugin
# uses this to know the caller's real IP.
limiter: false
image_proxy: false
# Bind address matches the container default.
bind_address: "0.0.0.0"
port: 8080
ui:
static_use_hash: true
# The default engine list is huge; keep a tight set of reliable ones.
engines:
- name: duckduckgo
disabled: false
- name: bing
disabled: false
- name: brave
disabled: false
- name: wikipedia
disabled: false
- name: google
disabled: false
- name: startpage
disabled: false

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View File

@ -0,0 +1,170 @@
# MateClaw Desktop - Build & Publish Scripts
## Scripts
| Script | Description |
|--------|-------------|
| `build.sh` | Build backend JAR (frontend + Spring Boot) |
| `download-jre.sh` | Download Adoptium JRE 21 for target platform |
| `build-all-platforms.sh` | One-click build for all platforms (macOS + Windows) |
| `publish-github.sh` | Publish release artifacts to GitHub Releases |
## Quick Start
### Full Build + Publish (recommended)
```bash
cd mateclaw-desktop
# Step 1: Build for all platforms (no publish)
bash scripts/build-all-platforms.sh --all
# Step 2: Publish to GitHub (via proxy)
bash scripts/publish-github.sh --proxy
```
### Step-by-Step Build
```bash
cd mateclaw-desktop
# 1. Build backend JAR (frontend + Spring Boot)
bash scripts/build.sh
# 2. Download JRE for target platform
bash scripts/download-jre.sh mac-arm64 # Apple Silicon
bash scripts/download-jre.sh mac-x64 # Intel Mac
bash scripts/download-jre.sh win-x64 # Windows x64
bash scripts/download-jre.sh win-arm64 # Windows ARM
# 3. Build frontend and package
npm run build
npx electron-builder --mac # macOS
npx electron-builder --win --x64 # Windows x64
npx electron-builder --win --arm64 # Windows ARM
```
## build.sh
Build the backend JAR, includes three steps:
1. Build Vue 3 frontend to `mateclaw-server/src/main/resources/static`
2. Package Spring Boot fat JAR via Maven
3. Copy JAR to `mateclaw-desktop/resources/app.jar`
```bash
bash scripts/build.sh
```
**Prerequisites:** Node.js (pnpm or npm), Maven (or mvnw)
## download-jre.sh
Download Adoptium JRE 21 for the target platform.
```bash
# Auto-detect current platform
bash scripts/download-jre.sh
# Specify platform
bash scripts/download-jre.sh mac-arm64
bash scripts/download-jre.sh mac-x64
bash scripts/download-jre.sh win-x64
bash scripts/download-jre.sh win-arm64
```
JRE will be saved to `resources/jre/{os}-{arch}/`.
## build-all-platforms.sh
Orchestrates the full build pipeline: JAR build -> JRE download -> frontend compile -> electron-builder package.
```bash
bash scripts/build-all-platforms.sh --all # macOS + Windows
bash scripts/build-all-platforms.sh --mac-only # macOS only
bash scripts/build-all-platforms.sh --win-only # Windows only
```
Build artifacts are output to `release/` directory.
## publish-github.sh
Publish build artifacts from `release/` to GitHub Releases via `gh` CLI.
### Basic Usage
```bash
bash scripts/publish-github.sh # Direct upload
bash scripts/publish-github.sh --draft # Create draft release
bash scripts/publish-github.sh --tag=v1.1.0 # Custom tag
```
### With Proxy (for slow or restricted networks)
```bash
bash scripts/publish-github.sh --proxy # Default proxy: 127.0.0.1:7890
bash scripts/publish-github.sh --proxy=192.168.1.1:8080 # Custom proxy
bash scripts/publish-github.sh --proxy --draft # Draft + proxy
```
### Retry Failed Upload
```bash
bash scripts/publish-github.sh --proxy --retry # Delete old release, re-upload
bash scripts/publish-github.sh --proxy --retry --draft # Retry as draft
```
### All Options
| Option | Description |
|--------|-------------|
| `--proxy` | Use proxy `127.0.0.1:7890` for GitHub upload |
| `--proxy=host:port` | Use custom proxy address |
| `--draft` | Create as draft release |
| `--tag=vX.Y.Z` | Custom tag (default: `v{version}` from package.json) |
| `--retry` | Delete existing release for this tag before re-uploading |
**Prerequisites:** [gh CLI](https://cli.github.com/) installed and authenticated (`gh auth login`)
## npm Scripts
These scripts are also available as npm commands:
```bash
npm run setup:jar # build.sh
npm run setup:jre # download-jre.sh
npm run setup # build.sh + download-jre.sh
npm run package:mac # Build frontend + electron-builder --mac
npm run package:win # Build frontend + electron-builder --win
npm run package:all # build-all-platforms.sh --all
npm run publish:github # publish-github.sh
npm run publish:github:draft # publish-github.sh --draft
```
## Typical Workflow
```
build.sh download-jre.sh
| |
v v
resources/app.jar resources/jre/{os}-{arch}/
| |
+--------- electron-builder ---+
|
v
release/
├── MateClaw_1.0.0_arm64.dmg
├── MateClaw_1.0.0_x64.dmg
├── MateClaw_1.0.0_arm64.zip
├── MateClaw_1.0.0_x64.zip
├── MateClaw_1.0.0_arm64_Setup.exe
├── MateClaw_1.0.0_x64_Setup.exe
├── latest-mac.yml
└── latest.yml
|
v
publish-github.sh
|
v
GitHub Releases
```

View File

@ -0,0 +1,151 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
const {
createZipBuffer,
readZipEntries,
resolveDriverDirectory,
trimDriverBundleInAppJar,
ZIP_STORED,
default: afterPack,
} = require('../trim-playwright-driver.cjs')
test('resolveDriverDirectory maps electron platform and arch to Playwright driver folder', () => {
assert.equal(resolveDriverDirectory('darwin', 'x64'), 'driver/mac')
assert.equal(resolveDriverDirectory('darwin', 'arm64'), 'driver/mac-arm64')
assert.equal(resolveDriverDirectory('linux', 'x64'), 'driver/linux')
assert.equal(resolveDriverDirectory('linux', 'arm64'), 'driver/linux-arm64')
assert.equal(resolveDriverDirectory('win32', 'x64'), 'driver/win32_x64')
assert.equal(resolveDriverDirectory('win32', 'arm64'), 'driver/win32_x64')
assert.deepEqual(resolveDriverDirectory('darwin', 'universal'), ['driver/mac', 'driver/mac-arm64'])
})
test('trimDriverBundleInAppJar keeps only the target driver and preserves nested jar as STORED', async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mateclaw-driver-trim-'))
const appJarPath = path.join(tmp, 'app.jar')
const driverBundle = createZipBuffer([
entry('META-INF/MANIFEST.MF', 'Manifest-Version: 1.0\n'),
entry('driver/mac/node', 'mac'),
entry('driver/mac-arm64/node', 'mac-arm64'),
entry('driver/linux/node', 'linux'),
entry('driver/linux-arm64/node', 'linux-arm64'),
entry('driver/win32_x64/node.exe', 'win32'),
entry('com/microsoft/playwright/Driver.class', 'class'),
])
const appJar = createZipBuffer([
entry('BOOT-INF/classpath.idx', '- "BOOT-INF/lib/driver-bundle-1.52.0.jar"\n'),
entry('BOOT-INF/lib/driver-bundle-1.52.0.jar', driverBundle, ZIP_STORED),
entry('BOOT-INF/lib/other.jar', 'other', ZIP_STORED),
])
fs.writeFileSync(appJarPath, appJar)
const result = await trimDriverBundleInAppJar(appJarPath, 'driver/mac-arm64')
assert.equal(result.removedDriverEntries, 4)
const outerEntries = readZipEntries(fs.readFileSync(appJarPath))
const nestedEntry = outerEntries.find((item) => item.name === 'BOOT-INF/lib/driver-bundle-1.52.0.jar')
assert.equal(nestedEntry.method, ZIP_STORED)
const innerEntries = readZipEntries(nestedEntry.data)
const names = innerEntries.map((item) => item.name).sort()
assert.deepEqual(names, [
'META-INF/MANIFEST.MF',
'com/microsoft/playwright/Driver.class',
'driver/mac-arm64/node',
])
})
test('trimDriverBundleInAppJar throws when driver-bundle is missing', async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mateclaw-driver-trim-'))
const appJarPath = path.join(tmp, 'app.jar')
fs.writeFileSync(appJarPath, createZipBuffer([
entry('BOOT-INF/lib/other.jar', 'other', ZIP_STORED),
]))
await assert.rejects(
() => trimDriverBundleInAppJar(appJarPath, 'driver/mac-arm64'),
/Playwright driver-bundle jar not found/
)
})
test('trimDriverBundleInAppJar throws when no target driver entries are kept', async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mateclaw-driver-trim-'))
const appJarPath = path.join(tmp, 'app.jar')
fs.writeFileSync(appJarPath, createAppJarWithDriverBundle())
await assert.rejects(
() => trimDriverBundleInAppJar(appJarPath, 'driver/mac-arm64-v2'),
/No entries kept under driver\/mac-arm64-v2/
)
})
test('trimDriverBundleInAppJar can keep both mac drivers for universal builds', async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mateclaw-driver-trim-'))
const appJarPath = path.join(tmp, 'app.jar')
fs.writeFileSync(appJarPath, createAppJarWithDriverBundle())
const result = await trimDriverBundleInAppJar(appJarPath, ['driver/mac', 'driver/mac-arm64'])
assert.equal(result.keptDriverEntries, 2)
const outerEntries = readZipEntries(fs.readFileSync(appJarPath))
const nestedEntry = outerEntries.find((item) => item.name === 'BOOT-INF/lib/driver-bundle-1.52.0.jar')
const driverNames = readZipEntries(nestedEntry.data)
.filter((item) => item.name.startsWith('driver/'))
.map((item) => item.name)
.sort()
assert.deepEqual(driverNames, [
'driver/mac-arm64/node',
'driver/mac/node',
])
})
test('afterPack throws when app.jar is missing', async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mateclaw-driver-trim-'))
await assert.rejects(
() => afterPack({
appOutDir: tmp,
arch: 'arm64',
electronPlatformName: 'darwin',
packager: {
appInfo: { productFilename: 'MateClaw' },
},
}),
/app\.jar not found/
)
})
function entry(name, data, method) {
return {
name,
data: Buffer.isBuffer(data) ? data : Buffer.from(data),
method,
}
}
function createAppJarWithDriverBundle() {
const driverBundle = createZipBuffer([
entry('META-INF/MANIFEST.MF', 'Manifest-Version: 1.0\n'),
entry('driver/mac/node', 'mac'),
entry('driver/mac-arm64/node', 'mac-arm64'),
entry('driver/linux/node', 'linux'),
entry('driver/linux-arm64/node', 'linux-arm64'),
entry('driver/win32_x64/node.exe', 'win32'),
entry('com/microsoft/playwright/Driver.class', 'class'),
])
return createZipBuffer([
entry('BOOT-INF/classpath.idx', '- "BOOT-INF/lib/driver-bundle-1.52.0.jar"\n'),
entry('BOOT-INF/lib/driver-bundle-1.52.0.jar', driverBundle, ZIP_STORED),
entry('BOOT-INF/lib/other.jar', 'other', ZIP_STORED),
])
}

View File

@ -0,0 +1,153 @@
/**
* scripts/branding.cjs Vite plugin for build-time white-label branding.
*
* Reads brand settings from branding.config.json (or BRAND_* env overrides)
* and replaces hardcoded "MateClaw" strings in all built files source code
* stays untouched.
*
* Supported env overrides:
* BRAND_NAME, BRAND_TAGLINE, BRAND_TEAM, BRAND_COPYRIGHT,
* BRAND_APP_ID, BRAND_GITHUB_URL, BRAND_LOGO_FILE
*/
'use strict'
const fs = require('fs')
const path = require('path')
function loadBrandConfig(rootDir) {
const configPath = path.join(rootDir, 'branding.config.json')
let config = {}
if (fs.existsSync(configPath)) {
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
}
// Env vars override the config file.
const env = process.env
return {
name: env.BRAND_NAME || config.name || 'MateClaw',
tagline: env.BRAND_TAGLINE || config.tagline || 'AI Personal Assistant',
team: env.BRAND_TEAM || config.team || 'MateClaw Team',
copyright: env.BRAND_COPYRIGHT || config.copyright || 'Copyright © 2026 MateClaw Team',
appId: env.BRAND_APP_ID || config.appId || 'vip.mate.mateclaw',
githubUrl: env.BRAND_GITHUB_URL || config.githubUrl || 'https://github.com/matevip/mateclaw',
logoFile: env.BRAND_LOGO_FILE || config.logoFile || 'mateclaw_logo_s.png',
}
}
/**
* Build the string-replacement table.
*
* Order matters: longer/more-specific patterns are replaced first to avoid
* partial matches (e.g. "MateClaw Team" before "MateClaw").
*/
function buildReplacements(brand) {
const replacements = []
// 1. Copyright line (most specific)
replacements.push([
'Copyright © 2026 MateClaw Team',
brand.copyright,
])
// 2. Team name
replacements.push(['MateClaw Team', brand.team])
// 3. GitHub URLs
replacements.push([
'https://github.com/matevip/mateclaw/issues',
brand.githubUrl + '/issues',
])
replacements.push([
'https://github.com/matevip/mateclaw',
brand.githubUrl,
])
// 4. Logo file path
replacements.push([
'mateclaw_logo_s.png',
brand.logoFile,
])
// 5. Tagline
replacements.push([
'AI Personal Assistant',
brand.tagline,
])
// 6. Split-span brand name in App.vue template:
// <span class="mate">Mate</span><span class="claw">Claw</span>
// Replace the inner text so styling classes are preserved but the text
// changes. We split the brand name: first half gets "mate" class, second
// half gets "claw" class. If it's a single word, it all goes in "mate".
var half = Math.ceil(brand.name.length / 2)
var firstPart = brand.name.slice(0, half)
var secondPart = brand.name.slice(half)
replacements.push([
'>Mate</span><span class="claw">Claw<',
'>' + firstPart + '</span><span class="claw">' + secondPart + '<',
])
// 7. Brand name (catch-all, must come last)
// Only replace the exact word "MateClaw", not "mateclaw" (lowercase,
// which is used in H2 database paths and Spring Boot properties that
// are coupled with the server and must NOT change).
replacements.push(['MateClaw', brand.name])
return replacements
}
function applyReplacements(code, replacements) {
var result = code
for (var i = 0; i < replacements.length; i++) {
var from = replacements[i][0]
var to = replacements[i][1]
// Use split/join for reliable literal string replacement (no regex
// escaping issues).
result = result.split(from).join(to)
}
return result
}
/**
* Vite plugin entry point.
*
* Usage in vite.config.ts:
* import { brandingPlugin } from './scripts/branding.cjs'
* plugins: [brandingPlugin()]
*/
function brandingPlugin(options) {
options = options || {}
var rootDir = options.rootDir || process.cwd()
var brand = loadBrandConfig(rootDir)
var replacements = buildReplacements(brand)
var isDefault =
brand.name === 'MateClaw' &&
brand.tagline === 'AI Personal Assistant' &&
brand.team === 'MateClaw Team'
if (!isDefault) {
console.log('[branding] White-label build: "' + brand.name + '" (tagline: "' + brand.tagline + '")')
}
return {
name: 'mateclaw-branding',
enforce: 'pre',
// Transform JS/TS/Vue source before compilation
transform: function (code, id) {
if (id.indexOf('node_modules') !== -1) return null
// Only process source files that might contain brand strings.
if (!/\.(ts|js|vue|html|css|cjs|mjs)$/.test(id)) return null
var result = applyReplacements(code, replacements)
return result !== code ? { code: result, map: null } : null
},
// Transform index.html
transformIndexHtml: function (html) {
return applyReplacements(html, replacements)
},
}
}
module.exports = { brandingPlugin: brandingPlugin, loadBrandConfig: loadBrandConfig, buildReplacements: buildReplacements }

View File

@ -0,0 +1,35 @@
#!/usr/bin/env bash
#
# scripts/build-all-platforms.sh — Build MateClaw desktop packages for all
# platforms (macOS + Windows) in the specified build mode.
#
# Usage:
# scripts/build-all-platforms.sh --all # local mode (default), both platforms
# scripts/build-all-platforms.sh --local # local mode, both platforms
# scripts/build-all-platforms.sh --remote # remote mode, both platforms
#
set -euo pipefail
MODE="--all"
case "${1:-}" in
--all) MODE="local" ;;
--local) MODE="local" ;;
--remote) MODE="remote" ;;
*) echo "Usage: $0 [--all|--local|--remote]"; exit 1 ;;
esac
echo "==> Building all platforms, BUILD_MODE=$MODE"
if [ "$MODE" = "remote" ]; then
echo "==> macOS (remote/lite)"
BUILD_MODE=remote npx electron-builder --mac
echo "==> Windows (remote/lite)"
BUILD_MODE=remote npx electron-builder --win
else
echo "==> macOS (local/full)"
BUILD_MODE=local npx electron-builder --mac
echo "==> Windows (local/full)"
BUILD_MODE=local npx electron-builder --win
fi
echo "==> All builds complete."

View File

@ -0,0 +1,30 @@
#!/usr/bin/env bash
#
# scripts/build.sh — Build the MateClaw Spring Boot backend JAR and place it
# at resources/app.jar so electron-builder can bundle it into the desktop app.
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
SERVER_DIR="$(cd "$PROJECT_ROOT/../mateclaw-server" && pwd)"
RESOURCES_DIR="$PROJECT_ROOT/resources"
echo "==> Building mateclaw-server JAR from $SERVER_DIR"
# Build the Spring Boot fat JAR (skip tests for packaging speed)
cd "$SERVER_DIR"
mvn clean package -DskipTests -Dmaven.test.skip=true -q
# Locate the built JAR
JAR_FILE=$(ls "$SERVER_DIR"/target/mateclaw-server-*.jar 2>/dev/null | head -1)
if [ -z "$JAR_FILE" ]; then
echo "ERROR: Could not find built JAR in $SERVER_DIR/target/"
exit 1
fi
echo "==> Copying $JAR_FILE$RESOURCES_DIR/app.jar"
mkdir -p "$RESOURCES_DIR"
cp "$JAR_FILE" "$RESOURCES_DIR/app.jar"
echo "==> Done. JAR size: $(du -h "$RESOURCES_DIR/app.jar" | cut -f1)"

View File

@ -0,0 +1,86 @@
#!/usr/bin/env bash
#
# scripts/download-jre.sh — Download Eclipse Temurin JRE 21 for the current
# macOS architecture (or both) and extract into resources/jre/<platform>/.
#
# Usage:
# scripts/download-jre.sh # auto-detect current arch
# scripts/download-jre.sh arm64 # arm64 only
# scripts/download-jre.sh x64 # x64 only
# scripts/download-jre.sh all # both arches
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
JRE_DIR="$PROJECT_ROOT/resources/jre"
# Temurin 21 (LTS) JRE downloads via Adoptium API
ADOPTIUM_BASE="https://api.adoptium.net/v3/binary/latest/21/ga/mac"
download_and_extract() {
local arch="$1"
local folder="$2"
local url="$ADOPTIUM_BASE/$arch/jre/hotspot/normal/eclipse?project=jdk"
local tmpfile="$JRE_DIR/jre-mac-$arch.tar.gz"
echo "==> Downloading Temurin 21 JRE for macOS $arch"
mkdir -p "$JRE_DIR"
curl -L --fail -o "$tmpfile" "$url"
echo "==> Extracting to $JRE_DIR/$folder"
rm -rf "$JRE_DIR/$folder"
mkdir -p "$JRE_DIR/$folder"
# Temurin macOS tar.gz extracts to: jdk-21.x.x+jre/Contents/Home/...
# We want $folder/Contents/Home/... so move the inner Contents up.
local extract_tmp="$JRE_DIR/.tmp-$arch"
rm -rf "$extract_tmp"
mkdir -p "$extract_tmp"
tar -xzf "$tmpfile" -C "$extract_tmp"
# Find the extracted top-level directory and move its Contents
local extracted_dir
extracted_dir=$(find "$extract_tmp" -maxdepth 1 -type d -name "jdk-*" | head -1)
if [ -z "$extracted_dir" ]; then
# Fallback: some tarballs extract Contents directly
if [ -d "$extract_tmp/Contents" ]; then
mv "$extract_tmp/Contents" "$JRE_DIR/$folder/Contents"
else
echo "ERROR: Could not find extracted JDK directory"
exit 1
fi
else
mv "$extracted_dir/Contents" "$JRE_DIR/$folder/Contents"
fi
rm -rf "$extract_tmp" "$tmpfile"
# Verify java binary exists
local java_bin="$JRE_DIR/$folder/Contents/Home/bin/java"
if [ -f "$java_bin" ]; then
echo "==> OK: $java_bin"
else
echo "ERROR: java binary not found at $java_bin"
exit 1
fi
}
TARGET="${1:-auto}"
if [ "$TARGET" = "auto" ]; then
case "$(uname -m)" in
arm64) TARGET="arm64" ;;
x86_64) TARGET="x64" ;;
*) echo "Unsupported arch: $(uname -m)"; exit 1 ;;
esac
fi
case "$TARGET" in
arm64) download_and_extract "aarch64" "mac-arm64" ;;
x64) download_and_extract "x64" "mac-x64" ;;
all) download_and_extract "aarch64" "mac-arm64"
download_and_extract "x64" "mac-x64" ;;
*) echo "Usage: $0 [arm64|x64|all]"; exit 1 ;;
esac
echo "==> JRE setup complete."

View File

@ -0,0 +1,324 @@
#!/usr/bin/env bash
#
# Publish MateClaw Desktop release artifacts to GitHub Releases
#
# Prerequisites:
# - gh CLI installed and authenticated (gh auth login)
# - Build artifacts exist in release/ directory (run build-all-platforms.sh first)
#
# Usage:
# bash scripts/publish-github.sh # Create release (direct)
# bash scripts/publish-github.sh --proxy # Create release via proxy (127.0.0.1:7890)
# bash scripts/publish-github.sh --proxy=host:port # Create release via custom proxy
# bash scripts/publish-github.sh --draft # Create draft release
# bash scripts/publish-github.sh --tag=v1.1.0 # Custom tag (default: v{version} from package.json)
# bash scripts/publish-github.sh --retry # Delete existing release and re-upload
# bash scripts/publish-github.sh --proxy --retry --draft # Combine options
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DESKTOP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
RELEASE_DIR="$DESKTOP_DIR/release"
# ─── Parse arguments ───────────────────────────────────────────────────────
DRAFT=""
CUSTOM_TAG=""
USE_PROXY=""
PROXY_ADDR="127.0.0.1:7890"
RETRY=false
NOTES_FILE_OVERRIDE=""
NOTES_LANG="en"
for arg in "$@"; do
case "$arg" in
--draft) DRAFT="--draft" ;;
--tag=*) CUSTOM_TAG="${arg#--tag=}" ;;
--proxy) USE_PROXY=true ;;
--proxy=*) USE_PROXY=true; PROXY_ADDR="${arg#--proxy=}" ;;
--retry) RETRY=true ;;
--notes-file=*) NOTES_FILE_OVERRIDE="${arg#--notes-file=}" ;;
--notes-lang=*) NOTES_LANG="${arg#--notes-lang=}" ;;
-h|--help)
echo "Usage: $0 [--draft] [--tag=vX.Y.Z] [--proxy[=host:port]] [--retry] [--notes-file=path] [--notes-lang=en|zh]"
echo ""
echo "Options:"
echo " --draft Create as draft release"
echo " --tag=vX.Y.Z Custom tag (default: v{version} from package.json)"
echo " --proxy Use proxy 127.0.0.1:7890 for uploading to GitHub"
echo " --proxy=host:port Use custom proxy address"
echo " --retry Delete existing release for this tag before uploading"
echo " --notes-file=path Use this markdown file as release notes (highest priority)"
echo " --notes-lang=en|zh Preferred language for auto-detected notes (default: en)"
echo ""
echo "Release notes resolution order:"
echo " 1. --notes-file=path (explicit override)"
echo " 2. ../docs/{notes-lang}/releases/X.Y.Z.md (curated, preferred lang)"
echo " 3. ../docs/{other-lang}/releases/X.Y.Z.md (curated, fallback lang)"
echo " 4. Tag annotation (if multi-line) (legacy)"
echo " 5. Tagged commit message body (legacy)"
echo " 6. --generate-notes (GitHub auto)"
echo ""
echo "Examples:"
echo " # Step 1: Build locally (no publish)"
echo " bash scripts/build-all-platforms.sh --all"
echo ""
echo " # Step 2: Publish to GitHub"
echo " bash scripts/publish-github.sh --proxy # via proxy, en notes"
echo " bash scripts/publish-github.sh --proxy --notes-lang=zh # zh notes"
echo " bash scripts/publish-github.sh --proxy --draft # draft (preview)"
echo " bash scripts/publish-github.sh --proxy --retry # delete + re-upload"
echo " bash scripts/publish-github.sh --notes-file=NOTES.md # custom notes file"
echo ""
echo "Run build-all-platforms.sh first to generate artifacts."
exit 0
;;
*)
echo "Unknown option: $arg"
echo "Usage: $0 [--draft] [--tag=vX.Y.Z] [--proxy[=host:port]] [--retry] [--notes-file=path] [--notes-lang=en|zh]"
exit 1
;;
esac
done
if [ "$NOTES_LANG" != "en" ] && [ "$NOTES_LANG" != "zh" ]; then
echo "❌ --notes-lang must be 'en' or 'zh' (got: $NOTES_LANG)"
exit 1
fi
# ─── Setup proxy ───────────────────────────────────────────────────────────
if [ "$USE_PROXY" = true ]; then
export https_proxy="http://${PROXY_ADDR}"
export http_proxy="http://${PROXY_ADDR}"
export HTTPS_PROXY="http://${PROXY_ADDR}"
export HTTP_PROXY="http://${PROXY_ADDR}"
echo "🌐 Proxy enabled: ${PROXY_ADDR}"
echo ""
fi
# ─── Check prerequisites ──────────────────────────────────────────────────
if ! command -v gh &>/dev/null; then
echo "❌ gh CLI not found. Install: https://cli.github.com/"
exit 1
fi
if ! gh auth status &>/dev/null; then
echo "❌ gh not authenticated. Run: gh auth login"
exit 1
fi
# ─── Read version from package.json ───────────────────────────────────────
VERSION=$(node -p "require('$DESKTOP_DIR/package.json').version")
TAG="${CUSTOM_TAG:-v${VERSION}}"
echo "╔════════════════════════════════════════════════════════╗"
echo "║ MateClaw Desktop - Publish to GitHub ║"
echo "╚════════════════════════════════════════════════════════╝"
echo ""
echo " Version: $VERSION"
echo " Tag: $TAG"
echo " Mode: ${DRAFT:-release}"
echo " Proxy: ${USE_PROXY:+${PROXY_ADDR}}${USE_PROXY:-off}"
echo " Retry: $RETRY"
echo ""
# ─── Retry: delete existing release ───────────────────────────────────────
if [ "$RETRY" = true ]; then
echo "🔄 Checking for existing release $TAG ..."
if gh release view "$TAG" &>/dev/null; then
echo " Found existing release, deleting..."
gh release delete "$TAG" --yes --cleanup-tag 2>/dev/null || gh release delete "$TAG" --yes 2>/dev/null
echo " ✅ Old release deleted."
else
echo " No existing release found, proceeding."
fi
echo ""
fi
# ─── Collect release artifacts ─────────────────────────────────────────────
if [ ! -d "$RELEASE_DIR" ]; then
echo "❌ Release directory not found: $RELEASE_DIR"
echo " Run build-all-platforms.sh first."
exit 1
fi
ARTIFACTS=()
# macOS artifacts
for pattern in "MateClaw*.dmg" "MateClaw*.zip"; do
while IFS= read -r -d '' f; do
ARTIFACTS+=("$f")
done < <(find "$RELEASE_DIR" -maxdepth 1 -name "$pattern" -print0 2>/dev/null)
done
# Windows artifacts (skip the architecture-merged installer — only ship per-arch builds)
while IFS= read -r -d '' f; do
ARTIFACTS+=("$f")
done < <(find "$RELEASE_DIR" -maxdepth 1 -name "MateClaw*Setup*.exe" \
! -name "MateClaw_${VERSION}_Setup.exe" -print0 2>/dev/null)
# Linux artifacts
while IFS= read -r -d '' f; do
ARTIFACTS+=("$f")
done < <(find "$RELEASE_DIR" -maxdepth 1 -name "MateClaw*.AppImage" -print0 2>/dev/null)
# Auto-update metadata files
for pattern in "latest-mac.yml" "latest.yml" "latest-linux.yml"; do
if [ -f "$RELEASE_DIR/$pattern" ]; then
ARTIFACTS+=("$RELEASE_DIR/$pattern")
fi
done
# blockmap files (for differential updates) — also skip merged installer's blockmap
while IFS= read -r -d '' f; do
ARTIFACTS+=("$f")
done < <(find "$RELEASE_DIR" -maxdepth 1 -name "*.blockmap" \
! -name "MateClaw_${VERSION}_Setup.exe.blockmap" -print0 2>/dev/null)
if [ ${#ARTIFACTS[@]} -eq 0 ]; then
echo "❌ No release artifacts found in $RELEASE_DIR"
echo " Run build-all-platforms.sh first."
exit 1
fi
echo " Artifacts to upload:"
TOTAL_SIZE=0
for f in "${ARTIFACTS[@]}"; do
SIZE_BYTES=$(stat -f%z "$f" 2>/dev/null || stat -c%s "$f" 2>/dev/null || echo 0)
SIZE_HUMAN=$(du -h "$f" | cut -f1)
TOTAL_SIZE=$((TOTAL_SIZE + SIZE_BYTES))
echo "$(basename "$f") ($SIZE_HUMAN)"
done
TOTAL_HUMAN=$(echo "$TOTAL_SIZE" | awk '{
if ($1 >= 1073741824) printf "%.1f GB", $1/1073741824;
else if ($1 >= 1048576) printf "%.0f MB", $1/1048576;
else printf "%.0f KB", $1/1024;
}')
echo ""
echo " Total: $TOTAL_HUMAN"
echo ""
# ─── Resolve release notes (priority chain) ───────────────────────────────
# 1. --notes-file=path (explicit override)
# 2. ../docs/{notes-lang}/releases/X.Y.Z.md (curated, preferred lang)
# 3. ../docs/{other-lang}/releases/X.Y.Z.md (curated, fallback lang)
# 4. Tag annotation (if multi-line) (legacy fallback)
# 5. Tagged commit message body (legacy fallback)
# 6. --generate-notes (GitHub auto)
NOTES_FILE=$(mktemp)
trap "rm -f '$NOTES_FILE'" EXIT
REPO_ROOT="$(cd "$DESKTOP_DIR/.." && pwd)"
NOTES_LANG_OTHER=$([ "$NOTES_LANG" = "en" ] && echo "zh" || echo "en")
NOTES_PRIMARY="$REPO_ROOT/docs/${NOTES_LANG}/releases/${VERSION}.md"
NOTES_FALLBACK="$REPO_ROOT/docs/${NOTES_LANG_OTHER}/releases/${VERSION}.md"
if [ -n "$NOTES_FILE_OVERRIDE" ]; then
if [ ! -f "$NOTES_FILE_OVERRIDE" ]; then
echo "❌ --notes-file path not found: $NOTES_FILE_OVERRIDE"
exit 1
fi
cat "$NOTES_FILE_OVERRIDE" > "$NOTES_FILE"
echo "📝 Using --notes-file: $NOTES_FILE_OVERRIDE"
elif [ -f "$NOTES_PRIMARY" ]; then
cat "$NOTES_PRIMARY" > "$NOTES_FILE"
echo "📝 Using docs/${NOTES_LANG}/releases/${VERSION}.md as release notes."
elif [ -f "$NOTES_FALLBACK" ]; then
cat "$NOTES_FALLBACK" > "$NOTES_FILE"
echo "📝 Using docs/${NOTES_LANG_OTHER}/releases/${VERSION}.md as release notes (preferred '$NOTES_LANG' not found)."
else
# Legacy fallback: tag annotation, then commit body
TAG_MSG=$(git tag -l --format='%(contents)' "$TAG" 2>/dev/null | sed '/^$/d')
if [ -n "$TAG_MSG" ] && [ "$(echo "$TAG_MSG" | wc -l)" -gt 2 ]; then
echo "$TAG_MSG" > "$NOTES_FILE"
echo "📝 Using tag annotation as release notes."
else
COMMIT_MSG=$(git log -1 --format='%B' "$TAG" 2>/dev/null | tail -n +2 | sed '/^$/d')
if [ -n "$COMMIT_MSG" ]; then
echo "$COMMIT_MSG" > "$NOTES_FILE"
echo "📝 Using commit message as release notes."
else
echo "⚠️ No release notes found (no docs/X.Y.Z.md, tag annotation, or commit body), using --generate-notes."
fi
fi
fi
echo ""
# ─── Rewrite latest.yml so it does not reference the merged installer ─────
#
# electron-builder generates a multi-arch merged Windows installer
# (MateClaw_X.Y.Z_Setup.exe, ~600 MB) alongside the per-arch installers
# (MateClaw_X.Y.Z_x64_Setup.exe, MateClaw_X.Y.Z_arm64_Setup.exe). The
# merged build is too large to be worth uploading, but the default
# latest.yml top-level `path:` and `files[0]` both point at it. If we
# upload latest.yml as-is while skipping the merged exe, electron-updater
# on Windows hits 404 on every check and the whole update path breaks.
#
# Fix: rewrite latest.yml in place so `path` / `sha512` / `files[]` only
# reference assets that are actually being uploaded. Use x64 as the
# default top-level target (arm64 clients still match via files[]).
LATEST_YML="$RELEASE_DIR/latest.yml"
MERGED_EXE_NAME="MateClaw_${VERSION}_Setup.exe"
X64_EXE_NAME="MateClaw_${VERSION}_x64_Setup.exe"
ARM64_EXE_NAME="MateClaw_${VERSION}_arm64_Setup.exe"
if [ -f "$LATEST_YML" ]; then
echo "🧹 Rewriting latest.yml to drop the merged installer ..."
(cd "$DESKTOP_DIR" && node -e "
const fs = require('fs');
const yaml = require('js-yaml');
const file = '$LATEST_YML';
const merged = '$MERGED_EXE_NAME';
const x64 = '$X64_EXE_NAME';
const arm64 = '$ARM64_EXE_NAME';
const doc = yaml.load(fs.readFileSync(file, 'utf8'));
const before = (doc.files || []).length;
doc.files = (doc.files || []).filter(f => f.url !== merged);
const x64Entry = doc.files.find(f => f.url === x64);
const arm64Entry = doc.files.find(f => f.url === arm64);
if (!x64Entry || !arm64Entry) {
console.error('FATAL: per-arch installer entries missing from latest.yml');
console.error(' files:', doc.files.map(f => f.url));
process.exit(1);
}
doc.path = x64Entry.url;
doc.sha512 = x64Entry.sha512;
fs.writeFileSync(file, yaml.dump(doc, { lineWidth: -1 }));
console.log(' files[] entries: ' + before + ' → ' + doc.files.length);
console.log(' top-level path: ' + doc.path);
")
echo ""
fi
# ─── Create GitHub Release ─────────────────────────────────────────────────
echo "🚀 Creating GitHub Release $TAG ..."
echo ""
if [ -s "$NOTES_FILE" ]; then
gh release create "$TAG" \
--title "MateClaw $VERSION" \
--notes-file "$NOTES_FILE" \
$DRAFT \
"${ARTIFACTS[@]}"
else
gh release create "$TAG" \
--title "MateClaw $VERSION" \
--generate-notes \
$DRAFT \
"${ARTIFACTS[@]}"
fi
echo ""
echo "✅ Release published successfully!"
echo ""
gh release view "$TAG" --json url -q '.url'

View File

@ -0,0 +1,360 @@
const fs = require('node:fs')
const path = require('node:path')
const zlib = require('node:zlib')
const ZIP_STORED = 0
const ZIP_DEFLATED = 8
const DRIVER_BUNDLE_PATTERN = /^BOOT-INF\/lib\/driver-bundle-[^/]+\.jar$/
const ARCH_X64 = 1
const ARCH_ARM64 = 3
async function afterPack(context) {
const platform = context.electronPlatformName || context.packager?.platform?.name
const arch = normalizeArch(context.arch)
const keepDriverDirectory = resolveDriverDirectory(platform, arch)
const appJarPath = findAppJar(context)
if (!appJarPath) {
throw new Error(`[trim-playwright-driver] app.jar not found in ${context.appOutDir}`)
}
const result = await trimDriverBundleInAppJar(appJarPath, keepDriverDirectory)
const keptLabel = asArray(keepDriverDirectory).join(', ')
console.log(
`[trim-playwright-driver] ${path.relative(process.cwd(), appJarPath)}: ` +
`kept ${keptLabel}, removed ${result.removedDriverEntries} driver entries, ` +
`${formatBytes(result.beforeBytes)} -> ${formatBytes(result.afterBytes)}`
)
}
function findAppJar(context) {
const appOutDir = context.appOutDir
const productFilename = context.packager?.appInfo?.productFilename || context.packager?.appInfo?.productName || 'MateClaw'
const platform = context.electronPlatformName || context.packager?.platform?.name
const candidates = []
if (platform === 'darwin') {
candidates.push(path.join(appOutDir, `${productFilename}.app`, 'Contents', 'Resources', 'app.jar'))
}
candidates.push(path.join(appOutDir, 'resources', 'app.jar'))
for (const candidate of candidates) {
if (fs.existsSync(candidate)) return candidate
}
const found = findFirstFile(appOutDir, 'app.jar', 4)
return found
}
function findFirstFile(root, fileName, maxDepth, depth = 0) {
if (!root || depth > maxDepth || !fs.existsSync(root)) return null
for (const dirent of fs.readdirSync(root, { withFileTypes: true })) {
const fullPath = path.join(root, dirent.name)
if (dirent.isFile() && dirent.name === fileName) return fullPath
if (dirent.isDirectory()) {
const found = findFirstFile(fullPath, fileName, maxDepth, depth + 1)
if (found) return found
}
}
return null
}
async function trimDriverBundleInAppJar(appJarPath, keepDriverDirectory) {
const appJarBuffer = fs.readFileSync(appJarPath)
const outerEntries = readZipEntries(appJarBuffer)
const driverBundleEntry = outerEntries.find((entry) => DRIVER_BUNDLE_PATTERN.test(entry.name))
const keepDriverDirectories = asArray(keepDriverDirectory)
if (!driverBundleEntry) {
throw new Error(`Playwright driver-bundle jar not found in ${appJarPath}`)
}
const innerEntries = readZipEntries(driverBundleEntry.data)
let removedDriverEntries = 0
let keptDriverEntries = 0
const trimmedInnerEntries = innerEntries.filter((entry) => {
if (!entry.name.startsWith('driver/')) return true
if (keepDriverDirectories.some((directory) => entry.name.startsWith(`${directory}/`))) {
keptDriverEntries += 1
return true
}
removedDriverEntries += 1
return false
})
if (keptDriverEntries === 0) {
throw new Error(
`No entries kept under ${keepDriverDirectories.join(', ')}; Playwright driver layout may have changed`
)
}
const trimmedDriverBundle = createZipBuffer(trimmedInnerEntries.map(cloneEntryForWrite))
const rewrittenOuterEntries = outerEntries.map((entry) => {
if (entry.name !== driverBundleEntry.name) return cloneEntryForWrite(entry)
return {
...cloneEntryForWrite(entry),
data: trimmedDriverBundle,
method: ZIP_STORED,
}
})
const rewrittenAppJar = createZipBuffer(rewrittenOuterEntries)
fs.writeFileSync(appJarPath, rewrittenAppJar)
return {
beforeBytes: appJarBuffer.length,
afterBytes: rewrittenAppJar.length,
driverBundleName: driverBundleEntry.name,
keptDriverEntries,
removedDriverEntries,
}
}
function cloneEntryForWrite(entry) {
return {
name: entry.name,
data: Buffer.from(entry.data),
method: entry.method,
date: entry.date,
comment: entry.comment,
externalAttributes: entry.externalAttributes,
}
}
function resolveDriverDirectory(platform, arch) {
const normalizedArch = normalizeArch(arch)
if (platform === 'darwin') {
if (normalizedArch === 'arm64') return 'driver/mac-arm64'
if (normalizedArch === 'x64') return 'driver/mac'
if (normalizedArch === 'universal') return ['driver/mac', 'driver/mac-arm64']
}
if (platform === 'linux') {
if (normalizedArch === 'arm64') return 'driver/linux-arm64'
if (normalizedArch === 'x64') return 'driver/linux'
}
if (platform === 'win32') {
return 'driver/win32_x64'
}
throw new Error(`Unsupported platform/arch for Playwright driver trim: ${platform}/${arch}`)
}
function asArray(value) {
return Array.isArray(value) ? value : [value]
}
function normalizeArch(arch) {
if (arch === ARCH_X64 || arch === 'x64') return 'x64'
if (arch === ARCH_ARM64 || arch === 'arm64') return 'arm64'
if (arch === 'universal' || arch === 4) return 'universal'
return String(arch)
}
function readZipEntries(buffer) {
const eocdOffset = findEndOfCentralDirectory(buffer)
const centralDirectorySize = buffer.readUInt32LE(eocdOffset + 12)
const centralDirectoryOffset = buffer.readUInt32LE(eocdOffset + 16)
const entries = []
let offset = centralDirectoryOffset
const centralDirectoryEnd = centralDirectoryOffset + centralDirectorySize
while (offset < centralDirectoryEnd) {
const signature = buffer.readUInt32LE(offset)
if (signature !== 0x02014b50) {
throw new Error(`Invalid central directory signature at offset ${offset}`)
}
const flags = buffer.readUInt16LE(offset + 8)
const method = buffer.readUInt16LE(offset + 10)
const dosTime = buffer.readUInt16LE(offset + 12)
const dosDate = buffer.readUInt16LE(offset + 14)
const crc = buffer.readUInt32LE(offset + 16)
const compressedSize = buffer.readUInt32LE(offset + 20)
const uncompressedSize = buffer.readUInt32LE(offset + 24)
const fileNameLength = buffer.readUInt16LE(offset + 28)
const extraLength = buffer.readUInt16LE(offset + 30)
const commentLength = buffer.readUInt16LE(offset + 32)
const externalAttributes = buffer.readUInt32LE(offset + 38)
const localHeaderOffset = buffer.readUInt32LE(offset + 42)
const name = buffer.toString('utf8', offset + 46, offset + 46 + fileNameLength)
const comment = buffer.subarray(offset + 46 + fileNameLength + extraLength, offset + 46 + fileNameLength + extraLength + commentLength)
const localSignature = buffer.readUInt32LE(localHeaderOffset)
if (localSignature !== 0x04034b50) {
throw new Error(`Invalid local file header signature for ${name}`)
}
const localNameLength = buffer.readUInt16LE(localHeaderOffset + 26)
const localExtraLength = buffer.readUInt16LE(localHeaderOffset + 28)
const dataOffset = localHeaderOffset + 30 + localNameLength + localExtraLength
const compressedData = buffer.subarray(dataOffset, dataOffset + compressedSize)
let data
if (method === ZIP_STORED) {
data = Buffer.from(compressedData)
} else if (method === ZIP_DEFLATED) {
data = zlib.inflateRawSync(compressedData)
} else {
throw new Error(`Unsupported ZIP method ${method} for ${name}`)
}
if (data.length !== uncompressedSize) {
throw new Error(`Unexpected uncompressed size for ${name}: ${data.length} !== ${uncompressedSize}`)
}
entries.push({
name,
data,
method,
flags,
crc,
date: dosToDate(dosDate, dosTime),
comment: Buffer.from(comment),
externalAttributes,
})
offset += 46 + fileNameLength + extraLength + commentLength
}
return entries
}
function createZipBuffer(entries) {
const localParts = []
const centralParts = []
let offset = 0
for (const entry of entries) {
const nameBuffer = Buffer.from(entry.name)
const data = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data || '')
const method = entry.method ?? ZIP_DEFLATED
const compressedData = method === ZIP_STORED ? data : zlib.deflateRawSync(data)
const crc = crc32(data)
const { dosDate, dosTime } = dateToDos(entry.date)
const localHeader = Buffer.alloc(30 + nameBuffer.length)
localHeader.writeUInt32LE(0x04034b50, 0)
localHeader.writeUInt16LE(20, 4)
localHeader.writeUInt16LE(0x0800, 6)
localHeader.writeUInt16LE(method, 8)
localHeader.writeUInt16LE(dosTime, 10)
localHeader.writeUInt16LE(dosDate, 12)
localHeader.writeUInt32LE(crc, 14)
localHeader.writeUInt32LE(compressedData.length, 18)
localHeader.writeUInt32LE(data.length, 22)
localHeader.writeUInt16LE(nameBuffer.length, 26)
localHeader.writeUInt16LE(0, 28)
nameBuffer.copy(localHeader, 30)
localParts.push(localHeader, compressedData)
const comment = Buffer.isBuffer(entry.comment) ? entry.comment : Buffer.alloc(0)
const centralHeader = Buffer.alloc(46 + nameBuffer.length + comment.length)
centralHeader.writeUInt32LE(0x02014b50, 0)
centralHeader.writeUInt16LE(20, 4)
centralHeader.writeUInt16LE(20, 6)
centralHeader.writeUInt16LE(0x0800, 8)
centralHeader.writeUInt16LE(method, 10)
centralHeader.writeUInt16LE(dosTime, 12)
centralHeader.writeUInt16LE(dosDate, 14)
centralHeader.writeUInt32LE(crc, 16)
centralHeader.writeUInt32LE(compressedData.length, 20)
centralHeader.writeUInt32LE(data.length, 24)
centralHeader.writeUInt16LE(nameBuffer.length, 28)
centralHeader.writeUInt16LE(0, 30)
centralHeader.writeUInt16LE(comment.length, 32)
centralHeader.writeUInt16LE(0, 34)
centralHeader.writeUInt16LE(0, 36)
centralHeader.writeUInt32LE(entry.externalAttributes || 0, 38)
centralHeader.writeUInt32LE(offset, 42)
nameBuffer.copy(centralHeader, 46)
comment.copy(centralHeader, 46 + nameBuffer.length)
centralParts.push(centralHeader)
offset += localHeader.length + compressedData.length
}
const centralDirectoryOffset = offset
const centralDirectory = Buffer.concat(centralParts)
const centralDirectorySize = centralDirectory.length
const eocd = Buffer.alloc(22)
eocd.writeUInt32LE(0x06054b50, 0)
eocd.writeUInt16LE(0, 4)
eocd.writeUInt16LE(0, 6)
eocd.writeUInt16LE(entries.length, 8)
eocd.writeUInt16LE(entries.length, 10)
eocd.writeUInt32LE(centralDirectorySize, 12)
eocd.writeUInt32LE(centralDirectoryOffset, 16)
eocd.writeUInt16LE(0, 20)
return Buffer.concat([...localParts, centralDirectory, eocd])
}
function findEndOfCentralDirectory(buffer) {
const minOffset = Math.max(0, buffer.length - 0xffff - 22)
for (let offset = buffer.length - 22; offset >= minOffset; offset -= 1) {
if (buffer.readUInt32LE(offset) === 0x06054b50) return offset
}
throw new Error('End of central directory not found')
}
function dateToDos(date) {
const value = date instanceof Date ? date : new Date(1980, 0, 1, 0, 0, 0)
const year = Math.max(1980, value.getFullYear())
return {
dosDate: ((year - 1980) << 9) | ((value.getMonth() + 1) << 5) | value.getDate(),
dosTime: (value.getHours() << 11) | (value.getMinutes() << 5) | Math.floor(value.getSeconds() / 2),
}
}
function dosToDate(dosDate, dosTime) {
const day = dosDate & 0x1f
const month = (dosDate >> 5) & 0x0f
const year = ((dosDate >> 9) & 0x7f) + 1980
const second = (dosTime & 0x1f) * 2
const minute = (dosTime >> 5) & 0x3f
const hour = (dosTime >> 11) & 0x1f
return new Date(year, Math.max(0, month - 1), day || 1, hour, minute, second)
}
function crc32(buffer) {
let crc = 0xffffffff
for (let i = 0; i < buffer.length; i += 1) {
crc = CRC_TABLE[(crc ^ buffer[i]) & 0xff] ^ (crc >>> 8)
}
return (crc ^ 0xffffffff) >>> 0
}
function makeCrcTable() {
const table = new Uint32Array(256)
for (let i = 0; i < 256; i += 1) {
let value = i
for (let bit = 0; bit < 8; bit += 1) {
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1
}
table[i] = value >>> 0
}
return table
}
function formatBytes(bytes) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
const CRC_TABLE = makeCrcTable()
module.exports = afterPack
module.exports.default = afterPack
module.exports.createZipBuffer = createZipBuffer
module.exports.readZipEntries = readZipEntries
module.exports.resolveDriverDirectory = resolveDriverDirectory
module.exports.trimDriverBundleInAppJar = trimDriverBundleInAppJar
module.exports.ZIP_STORED = ZIP_STORED
module.exports.ZIP_DEFLATED = ZIP_DEFLATED

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

File diff suppressed because it is too large Load Diff

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

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

@ -4,36 +4,21 @@
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 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> <modelVersion>4.0.0</modelVersion>
<groupId>vip.mate</groupId> <parent>
<groupId>vip.mate</groupId>
<artifactId>mateclaw</artifactId>
<version>${revision}</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>mateclaw-plugin-api</artifactId> <artifactId>mateclaw-plugin-api</artifactId>
<version>1.1.0-SNAPSHOT</version>
<packaging>jar</packaging> <packaging>jar</packaging>
<name>MateClaw Plugin API</name> <name>MateClaw Plugin API</name>
<description>Plugin SDK contract for MateClaw — external plugins depend only on this module</description> <description>Plugin SDK contract for MateClaw - external plugins depend only on this module</description>
<properties>
<java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring-ai.version>1.1.4</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies> <dependencies>
<!-- Spring AI core — for ToolCallback, ChatModel --> <!-- Spring AI core for ToolCallback and ChatModel. -->
<dependency> <dependency>
<groupId>org.springframework.ai</groupId> <groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-model</artifactId> <artifactId>spring-ai-model</artifactId>
@ -44,7 +29,6 @@
<dependency> <dependency>
<groupId>org.slf4j</groupId> <groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId> <artifactId>slf4j-api</artifactId>
<version>2.0.16</version>
<scope>provided</scope> <scope>provided</scope>
</dependency> </dependency>
@ -52,16 +36,7 @@
<dependency> <dependency>
<groupId>com.fasterxml.jackson.core</groupId> <groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId> <artifactId>jackson-databind</artifactId>
<version>2.18.3</version>
<scope>provided</scope> <scope>provided</scope>
</dependency> </dependency>
</dependencies> </dependencies>
<repositories>
<repository>
<id>spring-milestones</id>
<url>https://repo.spring.io/milestone</url>
<snapshots><enabled>false</enabled></snapshots>
</repository>
</repositories>
</project> </project>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,40 +4,24 @@
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 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> <modelVersion>4.0.0</modelVersion>
<groupId>vip.mate</groupId> <parent>
<groupId>vip.mate</groupId>
<artifactId>mateclaw</artifactId>
<version>${revision}</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>mateclaw-plugin-sample</artifactId> <artifactId>mateclaw-plugin-sample</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging> <packaging>jar</packaging>
<name>MateClaw Sample Plugin</name> <name>MateClaw Sample Plugin</name>
<description>A sample plugin demonstrating the MateClaw Plugin SDK</description> <description>A sample plugin demonstrating the MateClaw Plugin SDK</description>
<properties>
<java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring-ai.version>1.1.4</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies> <dependencies>
<!-- MateClaw Plugin API --> <!-- MateClaw Plugin API -->
<dependency> <dependency>
<groupId>vip.mate</groupId> <groupId>vip.mate</groupId>
<artifactId>mateclaw-plugin-api</artifactId> <artifactId>mateclaw-plugin-api</artifactId>
<version>1.1.0-SNAPSHOT</version>
<scope>provided</scope> <scope>provided</scope>
</dependency> </dependency>
@ -52,16 +36,7 @@
<dependency> <dependency>
<groupId>org.slf4j</groupId> <groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId> <artifactId>slf4j-api</artifactId>
<version>2.0.16</version>
<scope>provided</scope> <scope>provided</scope>
</dependency> </dependency>
</dependencies> </dependencies>
<repositories>
<repository>
<id>spring-milestones</id>
<url>https://repo.spring.io/milestone</url>
<snapshots><enabled>false</enabled></snapshots>
</repository>
</repositories>
</project> </project>

View File

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

View File

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

View File

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

View File

@ -1,13 +1,135 @@
# 多阶段构建 # Multi-stage build
FROM maven:3.9-eclipse-temurin-21 AS builder #
WORKDIR /build # Stage 1 — Frontend (Node / pnpm)
COPY pom.xml . # Builds the Vue 3 admin SPA and emits static files to /static inside the
RUN mvn dependency:go-offline -q # build container. These files are later copied into the JAR's classpath so
COPY src ./src # Spring Boot serves the SPA at the root URL.
RUN mvn package -DskipTests -q FROM node:22-alpine AS frontend-builder
# Pin pnpm to a major version so the Docker build doesn't break when the npm
# `latest` tag jumps majors. pnpm v10+ blocks dependency lifecycle scripts by
# default; the allowed packages live under `pnpm.onlyBuiltDependencies` in
# mateclaw-ui/package.json.
RUN npm install -g pnpm@10 --silent
WORKDIR /frontend
# Install dependencies first (layer cache)
COPY mateclaw-ui/package.json mateclaw-ui/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# Copy source and build
COPY mateclaw-ui/ ./
# Override outDir: vite.config.ts writes to ../mateclaw-server/…/static which
# is outside this container; call vite directly to control --outDir.
# NODE_OPTIONS=--max-old-space-size=6144 keeps Rollup's `rendering chunks`
# phase from getting SIGKILL'd by the host kernel's OOM-killer on memory-
# constrained servers. The earlier removal of this flag relied on lazy-
# loading + manualChunks dropping the per-chunk peak, but Rollup still
# minifies several vendor chunks (monaco / mermaid / echarts) in parallel
# so the cumulative working set blows past Node's default ~1.5 GB heap
# and trips the OOM-killer mid-build. The fix is not the heap flag
# itself; it is keeping the build reproducible on smaller hosts.
# Skipping vue-tsc here is intentional — type errors are caught in CI, not in
# the production Docker image build.
RUN NODE_OPTIONS=--max-old-space-size=6144 pnpm exec vite build --outDir /static --emptyOutDir
FROM eclipse-temurin:21-jre-alpine # Stage 2 — Backend (Maven)
FROM maven:3.9-eclipse-temurin-21 AS builder
# Optional Maven extra flags passed at build time.
# Set MAVEN_FLAGS=-Paliyun-first in .env (or via --build-arg) to put Aliyun
# repos first. This speeds up builds inside mainland China.
ARG MAVEN_FLAGS=""
# Inject mirror settings to avoid Maven Central timeouts in restricted networks
COPY mateclaw-server/settings.xml /root/.m2/settings.xml
# Copy the root parent plus module POMs first for Docker layer caching.
WORKDIR /build
COPY pom.xml ./pom.xml
COPY mateclaw-plugin-api/pom.xml mateclaw-plugin-api/pom.xml
COPY mateclaw-server/pom.xml mateclaw-server/pom.xml
COPY mateclaw-plugin-sample/pom.xml mateclaw-plugin-sample/pom.xml
COPY mateclaw-plugin-search-sample/pom.xml mateclaw-plugin-search-sample/pom.xml
# Pre-fetch backend dependencies through the reactor so the parent POM,
# dependencyManagement, and internal module versions all resolve consistently.
RUN mvn -pl mateclaw-server -am dependency:go-offline -q ${MAVEN_FLAGS}
# Copy backend source and inject pre-built frontend into the right classpath location
COPY mateclaw-plugin-api/src mateclaw-plugin-api/src
COPY mateclaw-server/src mateclaw-server/src
COPY --from=frontend-builder /static mateclaw-server/src/main/resources/static
RUN mvn -pl mateclaw-server -am package -Dmaven.test.skip=true -q ${MAVEN_FLAGS}
# Stage 3 — Runtime
#
# Uses Microsoft's official Playwright image (Ubuntu Noble, glibc) with all three
# browsers (Chromium / Firefox / WebKit) and every system library Chromium needs
# pre-installed. This avoids the `playwright install` step and the Alpine/musl
# incompatibility that blocks browser_use on minimal images.
#
# We pin to the exact Playwright version declared in the root pom.xml. If you
# bump the Java dependency, bump this tag in lockstep — Microsoft rebuilds each
# tag with the matching driver, so mismatched versions cause the java driver to
# re-download browsers at runtime (defeating the whole point of this image).
FROM mcr.microsoft.com/playwright:v1.59.0-noble
WORKDIR /app WORKDIR /app
COPY --from=builder /build/target/*.jar app.jar
# JDK 21 is NOT part of the base image (it ships Node for the JS driver).
# Install openjdk-21 explicitly and add CJK fonts so Chinese pages render
# correctly in screenshots and snapshots.
#
# PDF extraction toolchain — DocumentExtractTool tries pdftotext first, then
# Python pdfplumber/pypdf, then falls through to a naive Java parser that
# reads bytes as ISO_8859_1 (mojibake for CJK). Without poppler-utils the
# Docker image always hits the naive path and feeds garbled text to the
# Wiki pipeline.
#
# We install poppler-utils (backend 1) and tesseract (backend 4), which
# together cover the vast majority of PDFs including scanned docs. The
# Python backend is intentionally skipped — pip install against aliyun
# mirrors in CN networks hits transient hash-mismatch failures on cffi /
# cryptography transitive deps, and RFC-051 PR-1c will replace the Python
# hop with JVM-native Tika extraction anyway. Leaving it out keeps the
# image ~200 MB smaller and the build reproducible.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
openjdk-21-jre-headless \
fonts-noto-cjk \
fonts-noto-color-emoji \
poppler-utils \
tesseract-ocr \
tesseract-ocr-chi-sim \
tzdata \
python3-pip \
python-is-python3 \
python3-dev \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Remove PEP 668's EXTERNALLY-MANAGED marker so pip can install packages
# system-wide without --break-system-packages. This is a container — there is
# no host Python environment to protect. Skill scripts and LLM-generated code
# need to `pip install` on the fly; PEP 668 would block every install with
# "error: externally-managed-environment".
RUN rm -f /usr/lib/python3*/EXTERNALLY-MANAGED
# Tell Playwright Java where Microsoft's image stored the browsers.
# BrowserLauncher's BUNDLED strategy will then succeed without extra config.
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
TZ=Asia/Shanghai \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
JAVA_TOOL_OPTIONS="-Duser.timezone=Asia/Shanghai -Dsun.jnu.encoding=UTF-8"
# Default DB profile, overridable by the SPRING_PROFILES_ACTIVE env var
# (compose sets it explicitly: mysql / postgres / kingbase). It must be an ENV,
# not a -D system property on the ENTRYPOINT: a hardcoded
# -Dspring.profiles.active outranks the SPRING_PROFILES_ACTIVE env var and would
# silently pin the profile regardless of what compose passes.
ENV SPRING_PROFILES_ACTIVE=mysql
COPY --from=builder /build/mateclaw-server/target/*.jar app.jar
EXPOSE 18088 EXPOSE 18088
ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=mysql", "app.jar"] EXPOSE 1455
ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@ -4,79 +4,47 @@
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 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> <modelVersion>4.0.0</modelVersion>
<groupId>vip.mate</groupId> <parent>
<groupId>vip.mate</groupId>
<artifactId>mateclaw</artifactId>
<version>${revision}</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>mateclaw-server</artifactId> <artifactId>mateclaw-server</artifactId>
<version>1.1.0</version>
<packaging>jar</packaging> <packaging>jar</packaging>
<name>MateClaw Server</name> <name>MateClaw Server</name>
<description>MateClaw - Java+Vue Personal AI Assistant powered by Spring AI Alibaba</description> <description>MateClaw - Java+Vue Personal AI Assistant powered by Spring AI Alibaba</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.13</version>
<relativePath/>
</parent>
<properties>
<java.version>21</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- Spring AI 1.1.4 正式版 -->
<spring-ai.version>1.1.4</spring-ai.version>
<!-- Spring AI Alibaba 1.1.2.2(对应 Spring AI 1.1.x -->
<spring-ai-alibaba.version>1.1.2.2</spring-ai-alibaba.version>
<mybatis-plus.version>3.5.16</mybatis-plus.version>
<hutool.version>5.8.26</hutool.version>
<springdoc.version>2.8.16</springdoc.version>
<jjwt.version>0.12.6</jjwt.version>
</properties>
<dependencyManagement>
<dependencies>
<!-- Spring AI BOM统一管理 spring-ai-* 版本) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- SpringDoc OpenAPI BOM统一管理 springdoc-* 版本) -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-bom</artifactId>
<version>${springdoc.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies> <dependencies>
<!-- ===== MateClaw Plugin API ===== --> <!-- ===== MateClaw Plugin API ===== -->
<dependency> <dependency>
<groupId>vip.mate</groupId> <groupId>vip.mate</groupId>
<artifactId>mateclaw-plugin-api</artifactId> <artifactId>mateclaw-plugin-api</artifactId>
<version>1.1.0-SNAPSHOT</version>
</dependency> </dependency>
<!-- ===== Web MVC(不引入 WebFlux避免自动切换为响应式模式 ===== --> <!-- ===== Web MVC, excluding WebFlux to keep servlet mode ===== -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId> <artifactId>spring-boot-starter-web</artifactId>
</dependency> </dependency>
<!-- ===== Actuator - exposes Spring AI observation metrics (gen_ai.*) ===== -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- ===== Spring AI Alibaba DashScope ===== --> <!-- ===== Spring AI Alibaba DashScope ===== -->
<!-- <!--
1.1.2.2 需单独指定版本,不在 BOM 中 Version is managed centrally because this artifact is outside the Spring AI BOM.
内置 DashScope ChatModel / EmbeddingModel / ImageModel Provides DashScope ChatModel, EmbeddingModel, and ImageModel support.
--> -->
<dependency> <dependency>
<groupId>com.alibaba.cloud.ai</groupId> <groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-dashscope</artifactId> <artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
<version>${spring-ai-alibaba.version}</version> <!-- Exclude the transitive WebFlux starter to keep MVC mode. -->
<!-- 排除 webflux 传递依赖,保持 MVC 模式 -->
<exclusions> <exclusions>
<exclusion> <exclusion>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
@ -85,11 +53,10 @@
</exclusions> </exclusions>
</dependency> </dependency>
<!-- ===== Spring AI Alibaba Graph CoreStateGraph 工作流引擎) ===== --> <!-- ===== Spring AI Alibaba Graph Core (StateGraph workflow engine) ===== -->
<dependency> <dependency>
<groupId>com.alibaba.cloud.ai</groupId> <groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-graph-core</artifactId> <artifactId>spring-ai-alibaba-graph-core</artifactId>
<version>${spring-ai-alibaba.version}</version>
</dependency> </dependency>
<!-- ===== Spring AI OpenAI Compatible ===== --> <!-- ===== Spring AI OpenAI Compatible ===== -->
@ -98,16 +65,15 @@
<artifactId>spring-ai-openai</artifactId> <artifactId>spring-ai-openai</artifactId>
</dependency> </dependency>
<!-- ===== Spring AI AnthropicClaude 模型支持) ===== --> <!-- ===== Spring AI Anthropic (Claude model support) ===== -->
<dependency> <dependency>
<groupId>org.springframework.ai</groupId> <groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-anthropic</artifactId> <artifactId>spring-ai-anthropic</artifactId>
</dependency> </dependency>
<!-- ===== Spring AI MCP Client(动态 MCP server 连接管理) ===== --> <!-- ===== Spring AI MCP Client (dynamic MCP server connection management) ===== -->
<!-- <!--
使用 spring-ai-mcp-client-spring-boot-starter 引入 MCP 核心库, Pulls in the MCP core library while application code owns the McpSyncClient lifecycle.
但禁用自动配置(我们自己管理 McpSyncClient 生命周期)
--> -->
<dependency> <dependency>
<groupId>org.springframework.ai</groupId> <groupId>org.springframework.ai</groupId>
@ -120,31 +86,29 @@
</exclusions> </exclusions>
</dependency> </dependency>
<!-- ===== H2 内嵌数据库(开发环境) ===== --> <!-- ===== H2 embedded database (development) ===== -->
<dependency> <dependency>
<groupId>com.h2database</groupId> <groupId>com.h2database</groupId>
<artifactId>h2</artifactId> <artifactId>h2</artifactId>
<scope>runtime</scope> <scope>runtime</scope>
</dependency> </dependency>
<!-- ===== MySQL 驱动(生产环境) ===== --> <!-- ===== MySQL driver (production) ===== -->
<dependency> <dependency>
<groupId>com.mysql</groupId> <groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId> <artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope> <scope>runtime</scope>
</dependency> </dependency>
<!-- ===== MyBatis Plus(不引入 JPA避免双 ORM 冲突) ===== --> <!-- ===== MyBatis Plus, without JPA to avoid dual ORM conflicts ===== -->
<dependency> <dependency>
<groupId>com.baomidou</groupId> <groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency> </dependency>
<!-- MyBatis Plus 分页插件3.5.16 拆分为独立模块) --> <!-- MyBatis Plus pagination support is split into a separate module. -->
<dependency> <dependency>
<groupId>com.baomidou</groupId> <groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId> <artifactId>mybatis-plus-jsqlparser</artifactId>
<version>${mybatis-plus.version}</version>
</dependency> </dependency>
<!-- ===== Spring Security ===== --> <!-- ===== Spring Security ===== -->
@ -157,55 +121,49 @@
<dependency> <dependency>
<groupId>io.jsonwebtoken</groupId> <groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId> <artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>io.jsonwebtoken</groupId> <groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId> <artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope> <scope>runtime</scope>
</dependency> </dependency>
<dependency> <dependency>
<groupId>io.jsonwebtoken</groupId> <groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId> <artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope> <scope>runtime</scope>
</dependency> </dependency>
<!-- ===== SpringDoc OpenAPISwagger UI for Spring MVC ===== --> <!-- ===== SpringDoc OpenAPI (Swagger UI for Spring MVC) ===== -->
<dependency> <dependency>
<groupId>org.springdoc</groupId> <groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
</dependency> </dependency>
<!-- ===== Hutool 工具库 ===== --> <!-- ===== Hutool utilities ===== -->
<dependency> <dependency>
<groupId>cn.hutool</groupId> <groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId> <artifactId>hutool-all</artifactId>
<version>${hutool.version}</version>
</dependency> </dependency>
<!-- ===== 钉钉 Stream SDKWebSocket 长连接,无需公网 IP ===== --> <!-- ===== DingTalk Stream SDK (WebSocket long connection, no public IP required) ===== -->
<dependency> <dependency>
<groupId>com.dingtalk.open</groupId> <groupId>com.dingtalk.open</groupId>
<artifactId>dingtalk-stream</artifactId> <artifactId>dingtalk-stream</artifactId>
<version>1.3.5</version>
</dependency> </dependency>
<!-- ===== 飞书 / Lark Open API SDKWebSocket 长连接 + 事件分发) ===== --> <!-- ===== Lark Open API SDK (WebSocket long connection and event dispatch) ===== -->
<dependency> <dependency>
<groupId>com.larksuite.oapi</groupId> <groupId>com.larksuite.oapi</groupId>
<artifactId>oapi-sdk</artifactId> <artifactId>oapi-sdk</artifactId>
<version>2.5.3</version>
</dependency> </dependency>
<!-- ===== Caffeine Cache用于 skill runtime 缓存) ===== --> <!-- ===== Caffeine cache for skill runtime caching ===== -->
<dependency> <dependency>
<groupId>com.github.ben-manes.caffeine</groupId> <groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId> <artifactId>caffeine</artifactId>
</dependency> </dependency>
<!-- ===== SnakeYAML(用于 SKILL.md frontmatter 解析) ===== --> <!-- ===== SnakeYAML for SKILL.md frontmatter parsing ===== -->
<dependency> <dependency>
<groupId>org.yaml</groupId> <groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId> <artifactId>snakeyaml</artifactId>
@ -222,28 +180,24 @@
<dependency> <dependency>
<groupId>com.google.zxing</groupId> <groupId>com.google.zxing</groupId>
<artifactId>core</artifactId> <artifactId>core</artifactId>
<version>3.5.3</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>com.google.zxing</groupId> <groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId> <artifactId>javase</artifactId>
<version>3.5.3</version>
</dependency> </dependency>
<!-- ===== Playwright (Browser Automation) ===== --> <!-- ===== Playwright (Browser Automation) ===== -->
<dependency> <dependency>
<groupId>com.microsoft.playwright</groupId> <groupId>com.microsoft.playwright</groupId>
<artifactId>playwright</artifactId> <artifactId>playwright</artifactId>
<version>1.52.0</version>
</dependency> </dependency>
<!-- ===== JDADiscord Bot Gateway WebSocket 长连接) ===== --> <!-- ===== JDA (Discord Bot Gateway WebSocket long connection) ===== -->
<dependency> <dependency>
<groupId>net.dv8tion</groupId> <groupId>net.dv8tion</groupId>
<artifactId>JDA</artifactId> <artifactId>JDA</artifactId>
<version>5.2.3</version>
<exclusions> <exclusions>
<!-- 排除 audio 相关依赖MateClaw 不需要语音功能) --> <!-- Exclude audio dependencies because voice features are not used. -->
<exclusion> <exclusion>
<groupId>club.minnced</groupId> <groupId>club.minnced</groupId>
<artifactId>opus-java</artifactId> <artifactId>opus-java</artifactId>
@ -251,27 +205,140 @@
</exclusions> </exclusions>
</dependency> </dependency>
<!-- ===== Spring WebSocketTalk Mode ===== --> <!-- ===== Spring WebSocket (Talk Mode) ===== -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId> <artifactId>spring-boot-starter-websocket</artifactId>
</dependency> </dependency>
<!-- ===== Slack SDKSocket Mode + Web API ===== --> <!-- ===== Slack SDK (Socket Mode and Web API) ===== -->
<dependency> <dependency>
<groupId>com.slack.api</groupId> <groupId>com.slack.api</groupId>
<artifactId>slack-api-client</artifactId> <artifactId>slack-api-client</artifactId>
<version>1.44.2</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>com.slack.api</groupId> <groupId>com.slack.api</groupId>
<artifactId>bolt-socket-mode</artifactId> <artifactId>bolt-socket-mode</artifactId>
<version>1.44.2</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.glassfish.tyrus.bundles</groupId> <groupId>org.glassfish.tyrus.bundles</groupId>
<artifactId>tyrus-standalone-client</artifactId> <artifactId>tyrus-standalone-client</artifactId>
<version>2.2.0</version> </dependency>
<!-- ===== Apache POI (in-process .docx generation) ===== -->
<!--
Used by DocxRenderTool to render Markdown into a .docx in-JVM,
replacing the Node.js docx-js subprocess (3-5 min cold install).
-->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
</dependency>
<!-- ===== Apache Batik (SVG rasterization for docx image embedding) ===== -->
<!--
Used by MarkdownDocxRenderer to convert ![alt](*.svg) image references
into PNG bytes that POI can embed via XWPFRun.addPicture(). Without this,
agents that produce architecture diagrams as inline SVG cannot get them
into the final .docx. Rasterization runs in-JVM (no rsvg-convert / cairo
dependency on the host).
-->
<dependency>
<groupId>org.apache.xmlgraphics</groupId>
<artifactId>batik-transcoder</artifactId>
</dependency>
<dependency>
<groupId>org.apache.xmlgraphics</groupId>
<artifactId>batik-codec</artifactId>
</dependency>
<!-- ===== jsoup (HTML cleanup for Wiki ingest) ===== -->
<!--
Used by WikiContentNormalizer to strip nav/footer/script/style/aside
and ad-class nodes from URL/HTML uploads before chunking. Small
(~430KB), no transitive deps, JVM-only, and safe for the desktop bundle.
-->
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
</dependency>
<!-- ===== WxJava (WeChat Official Account SDK) ===== -->
<!--
Used by GzhPublishTool to push generated 图文 articles into the
Official Account draft box (草稿箱): permanent cover-material upload
plus draft creation, and optional free-publish for verified accounts.
weixin-java-mp is the Java 17 / Spring Boot 3 compatible MP module.
-->
<dependency>
<groupId>com.github.binarywang</groupId>
<artifactId>weixin-java-mp</artifactId>
<version>4.6.0</version>
</dependency>
<!-- ===== Apache Tika (Java-side last-resort document extractor) ===== -->
<!--
Wired as the FINAL fallback in DocumentExtractTool's PDF/DOCX/XLSX/PPTX
chains, after every system command + Python + POI-based path has failed.
Used in production primarily by Windows users without Python or poppler
installed; otherwise idle.
Pinned to the precise format modules the extractor calls directly. This
deliberately avoids `tika-parsers-standard-package`, which pulls in mail,
audio, archive, RTF / ODT, scientific, etc. (~80MB). Current footprint:
tika-core (~700KB) + tika-parser-pdf-module (PDFBox ~5MB) +
tika-parser-microsoft-module (POI-scratchpad ~10MB) is about 16MB.
-->
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-core</artifactId>
</dependency>
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-parser-pdf-module</artifactId>
</dependency>
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-parser-microsoft-module</artifactId>
</dependency>
<!-- ===== Markdown -> PDF rendering =====
Flying Saucer ships a single `flying-saucer-pdf` artifact that
writes PDF via OpenPDF (LGPL fork of iText). It does NOT depend on
PDFBox, so it sidesteps a version conflict with the existing
pdfbox dependency. CSS3 paged-media features (@page,
counter(page), counter(pages), @top-center / @bottom-center) are
supported, which the cover / header / footer rendering relies on.
commonmark-java is the reference CommonMark implementation,
actively maintained on a monthly cadence (vs. flexmark, whose
upstream stalled at 0.64.8 in 2023). It parses markdown into the
XHTML Flying Saucer consumes. The alternative LibreOffice path in
PdfRenderTool reuses MarkdownDocxRenderer + a soffice subprocess
and adds no dependencies of its own. -->
<dependency>
<groupId>org.xhtmlrenderer</groupId>
<artifactId>flying-saucer-pdf</artifactId>
</dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark</artifactId>
</dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark-ext-gfm-tables</artifactId>
</dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark-ext-yaml-front-matter</artifactId>
</dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark-ext-gfm-strikethrough</artifactId>
</dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark-ext-autolink</artifactId>
</dependency> </dependency>
<!-- ===== Database Migration (Flyway) ===== --> <!-- ===== Database Migration (Flyway) ===== -->
@ -283,6 +350,30 @@
<groupId>org.flywaydb</groupId> <groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId> <artifactId>flyway-mysql</artifactId>
</dependency> </dependency>
<!-- Flyway PostgreSQL support (used by KingbaseES as well since KingbaseES is PostgreSQL-compatible) -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.7</version>
<scope>runtime</scope>
</dependency>
<!--
KingbaseES (人大金仓) JDBC driver is NOT on Maven Central, so it is
declared in the opt-in `kingbase` Maven profile instead of here.
The default build never resolves it. To build with KingbaseES:
1. install the driver: mvn install:install-file \
-Dfile=${KINGBASE_HOME}/Interface/jdbc/kingbase8-8.6.0.jar \
-DgroupId=com.kingbase8 -DartifactId=kingbase8 \
-Dversion=8.6.0 -Dpackaging=jar
2. build with the profile: mvn package -Pkingbase
No Java code imports com.kingbase8.* — the driver is loaded at
runtime via spring.datasource.driver-class-name only.
-->
<!-- ===== Spring Boot Test ===== --> <!-- ===== Spring Boot Test ===== -->
<dependency> <dependency>
@ -290,6 +381,57 @@
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<!-- ===== ArchUnit architecture invariants =====
test-scope only, guards:
- every ToolCallback implementation overrides call(String, ToolContext)
so decorators (LocaleAwareToolCallback) cannot silently drop ChatOrigin
- CronJobRunner must not carry @Transactional
because it would silently fail under self-invocation
-->
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<scope>test</scope>
</dependency>
<!-- ShedLock: distributed lock for the cron scheduler so a
multi-instance deployment doesn't fire the same job N times.
JDBC mode reuses the existing DataSource, so there is no Redis dependency
on the desktop / single-node footprint. -->
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-spring</artifactId>
</dependency>
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-jdbc-template</artifactId>
</dependency>
<!-- Graph algorithms (community detection, shortest path, centrality)
used by the wiki page-to-page relevance and insights features. -->
<dependency>
<groupId>org.jgrapht</groupId>
<artifactId>jgrapht-core</artifactId>
</dependency>
<!-- PDF parsing for inline image extraction (wiki vision-in pipeline).
Used to walk PDPage resources and pull out PDImageXObject instances
for downstream captioning. -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
</dependency>
<!-- Expression language used by the workflow compiler to evaluate
conditional step expressions and template variable references.
Restricted to a small subset (~20 operators / filters) at the
evaluator wrapper layer; arbitrary template includes / extends
are blocked. -->
<dependency>
<groupId>io.pebbletemplates</groupId>
<artifactId>pebble</artifactId>
</dependency>
</dependencies> </dependencies>
<build> <build>
@ -297,6 +439,13 @@
<plugin> <plugin>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId> <artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
<configuration> <configuration>
<excludes> <excludes>
<exclude> <exclude>
@ -306,6 +455,76 @@
</excludes> </excludes>
</configuration> </configuration>
</plugin> </plugin>
<!-- Populate ${net.bytebuddy:byte-buddy-agent:jar} from the test
classpath so maven-surefire-plugin can attach it statically
(Mockito inline mock maker on JDK 21+ can no longer self-attach). -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<id>resolve-test-classpath-properties</id>
<goals>
<goal>properties</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<!-- Static agent attach for Mockito on JDK 21+. Without this, dynamic
agent loading raises ByteBuddyAgent.AttachmentTypeEvaluator errors
depending on the JVM's startup hardening, making tests pass on one
machine and fail on another. byte-buddy-agent rides in transitively
via mockito-core. -->
<argLine>-javaagent:${net.bytebuddy:byte-buddy-agent:jar}</argLine>
</configuration>
</plugin>
</plugins> </plugins>
</build> </build>
<profiles>
<!--
Profile: focused test run for image / video generation features.
Activate with `mvn test -P media-gen` (or `mvn verify -P media-gen`).
Limits surefire to JUnit 5 tests carrying @Tag("media-gen") so the
full ~50-min suite is skipped when iterating on this surface.
Add a tag to a new test with @Tag("media-gen") to opt it in.
-->
<profile>
<id>media-gen</id>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<groups>media-gen</groups>
</configuration>
</plugin>
</plugins>
</build>
</profile>
<!--
Profile: KingbaseES (人大金仓) JDBC driver.
The driver is not published to Maven Central, so it is kept out of the
default build to keep `mvn package` resolvable for everyone. Install the
driver into the local repository, then build with `mvn package -Pkingbase`.
Runtime selection is via the `kingbase` Spring profile (application-kingbase.yml).
-->
<profile>
<id>kingbase</id>
<dependencies>
<dependency>
<groupId>com.kingbase8</groupId>
<artifactId>kingbase8</artifactId>
<version>8.6.0</version>
<scope>runtime</scope>
</dependency>
</dependencies>
</profile>
</profiles>
</project> </project>

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Minimal Maven settings for the Docker build.
Repository URLs (US Maven Central, Google CDN, Aliyun) are declared directly
in mateclaw-server/pom.xml so a single pom covers both continents — Maven
tries each repository in order and falls over on 404 / unreachable.
Historically this file also contained <mirrors> that redirected Maven Central
to Aliyun. That broke US/EU builds because <mirror> intercepts transparently
and offers no fail-over when the mirror is slow. Keeping this file empty
means pom.xml's repository list is authoritative.
If you need to force a mirror (e.g. behind a corporate proxy), add your own
mirror entries here — they will override the pom repositories.
-->
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0
http://maven.apache.org/xsd/settings-1.0.0.xsd">
<mirrors/>
</settings>

View File

@ -3,42 +3,156 @@ package vip.mate;
import com.baomidou.mybatisplus.annotation.DbType; import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.mybatis.spring.annotation.MapperScan; import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
import javax.sql.DataSource;
import java.sql.Connection;
/** /**
* MateClaw - Personal AI Assistant * MateClaw - Personal AI Assistant
* Powered by Spring AI Alibaba * Powered by Spring AI Alibaba
* *
* @author MateClaw Team * @author MateClaw Team
*/ */
@Slf4j
@SpringBootApplication(exclude = { @SpringBootApplication(exclude = {
// 禁用 Spring AI MCP Client 自动配置 McpClientManager 自行管理生命周期 // Disable Spring AI MCP Client auto-configuration (lifecycle owned by McpClientManager).
org.springframework.ai.mcp.client.common.autoconfigure.McpClientAutoConfiguration.class, org.springframework.ai.mcp.client.common.autoconfigure.McpClientAutoConfiguration.class,
org.springframework.ai.mcp.client.common.autoconfigure.McpToolCallbackAutoConfiguration.class, org.springframework.ai.mcp.client.common.autoconfigure.McpToolCallbackAutoConfiguration.class,
org.springframework.ai.mcp.client.common.autoconfigure.StdioTransportAutoConfiguration.class, org.springframework.ai.mcp.client.common.autoconfigure.StdioTransportAutoConfiguration.class,
org.springframework.ai.mcp.client.common.autoconfigure.annotations.McpClientAnnotationScannerAutoConfiguration.class, org.springframework.ai.mcp.client.common.autoconfigure.annotations.McpClientAnnotationScannerAutoConfiguration.class,
org.springframework.ai.mcp.client.httpclient.autoconfigure.SseHttpClientTransportAutoConfiguration.class, org.springframework.ai.mcp.client.httpclient.autoconfigure.SseHttpClientTransportAutoConfiguration.class,
org.springframework.ai.mcp.client.httpclient.autoconfigure.StreamableHttpHttpClientTransportAutoConfiguration.class, org.springframework.ai.mcp.client.httpclient.autoconfigure.StreamableHttpHttpClientTransportAutoConfiguration.class,
// DashScopeAgent is the Bailian "Application Agent" (Bailian-hosted prompt+tool app),
// not the chat model. We don't use it model configuration is admin-UI driven and
// built by DashScopeChatModelBuilder. Its auto-config strictly requires
// spring.ai.dashscope.api-key to be non-empty at startup, which makes the whole
// ApplicationContext fail when users deploy via Docker without setting the key.
com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeAgentAutoConfiguration.class,
}) })
@EnableScheduling @EnableScheduling
@MapperScan("vip.mate.**.repository") @MapperScan("vip.mate.**.repository")
public class MateClawApplication { public class MateClawApplication {
@Autowired
private DataSource dataSource;
/** Cached DbType for the PaginationInnerInterceptor. */
private volatile DbType resolvedDbType;
public static void main(String[] args) { public static void main(String[] args) {
configureHttpClientDefaults();
SpringApplication.run(MateClawApplication.class, args); SpringApplication.run(MateClawApplication.class, args);
} }
/** /**
* MyBatis Plus 分页插件 * Harden the JDK {@link java.net.http.HttpClient} defaults before any client
* (or the JDK's internal header-allowlist) is initialized.
*
* <ul>
* <li><b>keep-alive timeout</b> the JDK default is 1200s, far longer than a
* typical reverse proxy / API gateway idle window (often 1575s). A pooled
* HTTP/1.1 connection therefore outlives the peer's socket, and the next
* request onto that now-closed socket is reset by the peer before any
* response byte arrives, surfacing as
* {@code "HTTP/1.1 header parser received no bytes"} / {@code Connection reset}.
* Capping it to 15s makes the client evict idle connections before most
* gateways do, eliminating stale reuse. (curl never hits this because it
* opens a fresh connection per invocation.)</li>
* <li><b>allow the {@code Connection} request header</b> {@code Connection}
* is a restricted header the JDK client strips by default; allowing it lets
* the OpenAI-compatible path send {@code Connection: close} to force a fresh
* connection per request against flaky self-hosted gateways.</li>
* </ul>
*
* <p>Both are only set when the operator has not already provided an explicit
* {@code -D} override, so deliberate tuning is respected.
*/
private static void configureHttpClientDefaults() {
if (System.getProperty("jdk.httpclient.keepalive.timeout") == null) {
System.setProperty("jdk.httpclient.keepalive.timeout", "15");
}
String allowRestricted = System.getProperty("jdk.httpclient.allowRestrictedHeaders");
if (allowRestricted == null) {
System.setProperty("jdk.httpclient.allowRestrictedHeaders", "connection");
} else if (!allowRestricted.toLowerCase().contains("connection")) {
System.setProperty("jdk.httpclient.allowRestrictedHeaders", allowRestricted + ",connection");
}
}
/**
* Detect the actual database type from the live DataSource so the
* {@link PaginationInnerInterceptor} always uses the correct dialect,
* even when the JDBC URL is wrapped by a proxy (HikariCP, P6Spy, etc.).
*
* <p>DbType is cached after the first successful detection; a failure
* falls back to the value set in {@code mybatis-plus.global-config.db-config.db-type},
* or eventually to {@link DbType#MYSQL} but by then the connection
* pool would already have failed.
*/
@PostConstruct
void detectDbType() {
try (Connection conn = dataSource.getConnection()) {
String productName = conn.getMetaData().getDatabaseProductName().toLowerCase();
if (productName.contains("kingbase")) {
resolvedDbType = DbType.KINGBASE_ES;
} else if (productName.contains("postgresql")) {
resolvedDbType = DbType.POSTGRE_SQL;
} else if (productName.contains("mysql") || productName.contains("mariadb")) {
resolvedDbType = DbType.MYSQL;
} else if (productName.contains("h2")) {
resolvedDbType = DbType.H2;
} else {
// Let the PaginationInnerInterceptor auto-detect at query time
resolvedDbType = null;
}
if (resolvedDbType != null) {
log.info("Detected database type: {} (product={})", resolvedDbType, productName);
}
} catch (Exception e) {
log.warn("Could not detect database type — PaginationInnerInterceptor will auto-detect on first query: {}",
e.getMessage());
}
}
/**
* MyBatis Plus pagination plugin.
*
* <p>When {@code resolvedDbType} is available the interceptor uses it directly;
* otherwise it falls back to JDBC-URL auto-detection, which works for
* {@code jdbc:kingbase8://} but not for proxied DataSources (RFC-042 P0).
*/ */
@Bean @Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() { public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.H2)); PaginationInnerInterceptor pagination = resolvedDbType != null
? new PaginationInnerInterceptor(resolvedDbType)
: new PaginationInnerInterceptor();
interceptor.addInnerInterceptor(pagination);
return interceptor; return interceptor;
} }
/**
* Print a clear "READY" banner after all post-startup initialization,
* so operators can tell at a glance when the application is ready to serve.
*/
@EventListener(ApplicationReadyEvent.class)
public void onReady() {
log.info("");
log.info("╔══════════════════════════════════════════════════════════════════════╗");
log.info("║ MateClaw is READY ✓ ║");
log.info("║ Web UI → http://localhost:18088 ║");
log.info("║ Swagger → http://localhost:18088/swagger-ui.html ║");
log.info("╚══════════════════════════════════════════════════════════════════════╝");
log.info("");
}
} }

View File

@ -0,0 +1,368 @@
package vip.mate.acp.client;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.extern.slf4j.Slf4j;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
import java.util.function.Function;
/**
* RFC-090 Phase 7 minimal Java ACP (Agent Communication Protocol)
* client over stdio.
*
* <p>Implements just enough of the JSON-RPC 2.0 framing to:
* <ol>
* <li>Spawn the agent process ({@code command} + {@code args}).</li>
* <li>Send {@code initialize} and capture {@code agentCapabilities}
* / {@code protocolVersion}.</li>
* <li>Optionally open a {@code session/new} handshake.</li>
* <li>Tear the process down cleanly.</li>
* </ol>
*
* <p>This is intentionally a one-shot connection tester (RFC §10.2 Q3
* recommended starting order: codex claude opencode qwen). Full
* bidirectional session prompting / streaming / permission requests is
* a future increment that needs a proper async bus and ties into the
* agent graph layer.
*
* <p>Why not the official {@code acp} Python SDK: MateClaw runs on the
* JVM. The protocol is JSON-RPC 2.0 line-delimited over stdio; the
* surface we need for "test connection" is small enough to implement
* directly.
*
* <p>Each {@link AcpStdioClient} instance owns one Process. Use
* try-with-resources or call {@link #close()} explicitly.
*/
@Slf4j
public class AcpStdioClient implements AutoCloseable {
/** ACP protocol version we advertise (matches v1 ACP-compatible agents). */
public static final int PROTOCOL_VERSION = 1;
private final ObjectMapper mapper;
private final Process process;
private final Writer stdin;
private final BufferedReader stdout;
private final Thread readerThread;
private final AtomicLong nextRequestId = new AtomicLong(1);
private final Map<Long, CompletableFuture<JsonNode>> pending = new ConcurrentHashMap<>();
private volatile boolean closed = false;
/**
* RFC-090 Phase 7b invoked when the agent sends a JSON-RPC
* notification (no id). Notification objects passed in have shape
* {@code {jsonrpc, method, params}}; the most common is
* {@code session/update} carrying agent message chunks.
*
* <p>Default no-op so existing test-only callers don't need to set
* a handler. {@link AcpDelegationService} installs an accumulator
* that scrapes {@code agent_message_chunk} text into a
* {@code StringBuilder}.
*/
private volatile Consumer<JsonNode> notificationHandler = msg -> { /* drop */ };
/**
* RFC-090 Phase 7b invoked when the agent sends a JSON-RPC
* request (has id). The handler returns the JSON-RPC
* {@code result} object (or null to send back -32601 method-not-
* implemented). Used for {@code session/request_permission};
* trusted endpoints auto-allow, untrusted ones cancel.
*/
private volatile Function<JsonNode, JsonNode> requestHandler = msg -> null;
private AcpStdioClient(ObjectMapper mapper, Process process) {
this.mapper = mapper;
this.process = process;
this.stdin = new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8);
this.stdout = new BufferedReader(
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8));
this.readerThread = new Thread(this::readLoop, "acp-stdio-reader");
this.readerThread.setDaemon(true);
this.readerThread.start();
}
/**
* Spawn the configured agent process. Caller is responsible for
* closing the returned client; failure to do so leaks a child
* process.
*/
public static AcpStdioClient spawn(ObjectMapper mapper,
String command,
List<String> args,
Map<String, String> envOverrides,
String cwd)
throws IOException {
if (command == null || command.isBlank()) {
throw new IllegalArgumentException("ACP command is required");
}
java.util.List<String> cmdline = new java.util.ArrayList<>();
cmdline.add(command);
if (args != null) cmdline.addAll(args);
ProcessBuilder pb = new ProcessBuilder(cmdline);
Map<String, String> env = pb.environment();
if (envOverrides != null) env.putAll(envOverrides);
if (cwd != null && !cwd.isBlank()) {
pb.directory(new java.io.File(cwd));
}
// Keep stderr separate from stdout so we don't poison JSON-RPC
// framing when the child agent writes a banner / log line.
pb.redirectErrorStream(false);
Process proc = pb.start();
// Drain stderr in the background many CLIs print diagnostics
// there (e.g. Zed agents print version on startup).
Thread errDrain = new Thread(() -> drainStream(proc.getErrorStream()), "acp-stdio-stderr");
errDrain.setDaemon(true);
errDrain.start();
return new AcpStdioClient(mapper, proc);
}
private static void drainStream(java.io.InputStream in) {
try (BufferedReader br = new BufferedReader(
new InputStreamReader(in, StandardCharsets.UTF_8))) {
String line;
while ((line = br.readLine()) != null) {
if (log.isDebugEnabled()) log.debug("[acp-stderr] {}", line);
}
} catch (IOException ignore) {
// Process exited; nothing to do.
}
}
/**
* Send {@code initialize} and wait for the response. Returns the
* response payload's {@code result} object, or throws on protocol
* mismatch / timeout.
*/
public JsonNode initialize(long timeoutMillis) throws IOException, InterruptedException {
ObjectNode params = mapper.createObjectNode();
params.put("protocolVersion", PROTOCOL_VERSION);
// ClientCapabilities we don't yet implement any client-side
// optional features. Send an empty object so strict agents
// don't reject the request.
params.set("clientCapabilities", mapper.createObjectNode());
ObjectNode info = mapper.createObjectNode();
info.put("name", "mateclaw-acp-client");
info.put("version", "1.0.0");
params.set("clientInfo", info);
return sendRequest("initialize", params, timeoutMillis);
}
/**
* Send {@code session/new} establishes a session for prompting.
* For the connection-test path we don't actually prompt, just
* verify the server accepts the handshake.
*
* <p>The {@code cwd} parameter is always written into the request
* body. Zed's ACP Zod schema (used by {@code @zed-industries/claude-
* agent-acp} and the codex variant) marks {@code cwd} as a required
* string and returns {@code -32602 Invalid params} when it's
* missing. If the caller passes null/blank we substitute the JVM
* working directory a workspace-aware default lives in
* {@code AcpRuntimeSupport#resolveCwd}, but this fallback ensures
* the protocol never sees {@code undefined} regardless of caller.
*/
public JsonNode newSession(String cwd, long timeoutMillis)
throws IOException, InterruptedException {
ObjectNode params = mapper.createObjectNode();
String safeCwd = (cwd == null || cwd.isBlank())
? System.getProperty("user.dir", ".")
: cwd;
params.put("cwd", safeCwd);
params.set("mcpServers", mapper.createArrayNode());
return sendRequest("session/new", params, timeoutMillis);
}
/**
* Lower-level request helper. Synchronously awaits the response
* matching the request id. Server-pushed requests (e.g. permission
* prompts) are dropped the test-only connection path doesn't need
* to handle them.
*/
public JsonNode sendRequest(String method, JsonNode params, long timeoutMillis)
throws IOException, InterruptedException {
if (closed) throw new IOException("ACP client is closed");
long id = nextRequestId.getAndIncrement();
CompletableFuture<JsonNode> future = new CompletableFuture<>();
pending.put(id, future);
ObjectNode envelope = mapper.createObjectNode();
envelope.put("jsonrpc", "2.0");
envelope.put("id", id);
envelope.put("method", method);
envelope.set("params", params);
synchronized (stdin) {
stdin.write(mapper.writeValueAsString(envelope));
stdin.write('\n');
stdin.flush();
}
try {
return future.get(timeoutMillis, TimeUnit.MILLISECONDS);
} catch (java.util.concurrent.ExecutionException e) {
Throwable cause = e.getCause();
if (cause instanceof IOException io) throw io;
throw new IOException("ACP request failed: " + (cause != null ? cause.getMessage() : "unknown"));
} catch (java.util.concurrent.TimeoutException e) {
pending.remove(id);
throw new IOException("ACP request timed out after " + timeoutMillis + "ms");
}
}
private void readLoop() {
try {
String line;
while (!closed && (line = stdout.readLine()) != null) {
if (line.isEmpty()) continue;
try {
JsonNode msg = mapper.readTree(line);
routeMessage(msg);
} catch (Exception e) {
log.warn("ACP malformed line, skipping: {}", e.getMessage());
}
}
} catch (IOException e) {
if (!closed) {
log.debug("ACP stdio reader closed: {}", e.getMessage());
}
} finally {
// If the process exited mid-await, fail every pending future.
for (Map.Entry<Long, CompletableFuture<JsonNode>> entry : pending.entrySet()) {
entry.getValue().completeExceptionally(
new IOException("ACP process exited before responding"));
}
pending.clear();
}
}
private void routeMessage(JsonNode msg) {
JsonNode idNode = msg.get("id");
boolean hasId = idNode != null && !idNode.isNull();
boolean hasMethod = msg.has("method");
// (1) Response to one of *our* outbound requests.
if (hasId && idNode.isNumber() && !hasMethod) {
long id = idNode.asLong();
CompletableFuture<JsonNode> future = pending.remove(id);
if (future != null) {
JsonNode error = msg.get("error");
if (error != null && !error.isNull()) {
future.completeExceptionally(
new IOException("ACP error: " + error.toString()));
} else {
future.complete(msg.get("result"));
}
return;
}
}
// (2) Server-initiated request has both method and id.
if (hasMethod && hasId) {
JsonNode result = null;
try {
result = requestHandler.apply(msg);
} catch (Exception e) {
log.warn("ACP requestHandler threw on method '{}': {}",
msg.path("method").asText(""), e.getMessage());
}
sendReplyTo(idNode, result, msg.path("method").asText(""));
return;
}
// (3) Notification has method but no id.
if (hasMethod) {
try {
notificationHandler.accept(msg);
} catch (Exception e) {
log.warn("ACP notificationHandler threw on method '{}': {}",
msg.path("method").asText(""), e.getMessage());
}
}
}
private void sendReplyTo(JsonNode idNode, JsonNode result, String method) {
try {
ObjectNode reply = mapper.createObjectNode();
reply.put("jsonrpc", "2.0");
reply.set("id", idNode);
if (result != null) {
reply.set("result", result);
} else {
ObjectNode error = mapper.createObjectNode();
error.put("code", -32601);
error.put("message", "Method not implemented: " + method);
reply.set("error", error);
}
synchronized (stdin) {
stdin.write(mapper.writeValueAsString(reply));
stdin.write('\n');
stdin.flush();
}
} catch (IOException e) {
log.debug("ACP failed to reply to server-initiated request '{}': {}", method, e.getMessage());
}
}
/**
* Replace the notification handler. Pass {@code null} to fall back
* to the no-op default.
*/
public void setNotificationHandler(Consumer<JsonNode> handler) {
this.notificationHandler = handler != null ? handler : msg -> {};
}
/**
* Replace the server-request handler. Pass {@code null} to fall
* back to the default which returns -32601 for every method.
*/
public void setRequestHandler(Function<JsonNode, JsonNode> handler) {
this.requestHandler = handler != null ? handler : msg -> null;
}
@Override
public void close() {
closed = true;
try {
stdin.close();
} catch (IOException ignore) {
/* best effort */
}
try {
// Give the agent ~1s to exit gracefully after EOF on stdin.
if (!process.waitFor(1, TimeUnit.SECONDS)) {
process.destroy();
if (!process.waitFor(1, TimeUnit.SECONDS)) {
process.destroyForcibly();
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
process.destroyForcibly();
}
try {
stdout.close();
} catch (IOException ignore) {
/* best effort */
}
}
/** Convenience for callers that just want a fresh empty env map. */
public static Map<String, String> emptyEnv() {
return new HashMap<>();
}
}

View File

@ -0,0 +1,87 @@
package vip.mate.acp.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import vip.mate.acp.model.AcpEndpointEntity;
import vip.mate.acp.service.AcpConnectionTester;
import vip.mate.acp.service.AcpEndpointService;
import vip.mate.common.result.R;
import java.util.List;
import java.util.Map;
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
/**
* RFC-090 Phase 7 REST surface for managing ACP endpoints.
*
* <p>Mirrors the McpServers controller so the frontend page can be a
* close cousin of {@code McpServers.vue}.
*/
@Tag(name = "ACP Endpoints (RFC-090 Phase 7)")
@RestController
@RequestMapping("/api/v1/acp/endpoints")
@RequiredArgsConstructor
public class AcpEndpointController {
private final AcpEndpointService service;
private final AcpConnectionTester tester;
@Operation(summary = "List ACP endpoints")
@GetMapping
@RequireWorkspaceRole("admin")
public R<List<AcpEndpointEntity>> list() {
return R.ok(service.list());
}
@Operation(summary = "Get ACP endpoint by id")
@GetMapping("/{id}")
@RequireWorkspaceRole("admin")
public R<AcpEndpointEntity> get(@PathVariable Long id) {
return R.ok(service.get(id));
}
@Operation(summary = "Create a custom ACP endpoint")
@PostMapping
@RequireWorkspaceRole("admin")
public R<AcpEndpointEntity> create(@RequestBody AcpEndpointEntity body) {
return R.ok(service.create(body));
}
@Operation(summary = "Update an ACP endpoint")
@PutMapping("/{id}")
@RequireWorkspaceRole("admin")
public R<AcpEndpointEntity> update(@PathVariable Long id,
@RequestBody AcpEndpointEntity body) {
return R.ok(service.update(id, body));
}
@Operation(summary = "Delete an ACP endpoint (builtins are protected)")
@DeleteMapping("/{id}")
@RequireWorkspaceRole("admin")
public R<Void> delete(@PathVariable Long id) {
service.delete(id);
return R.ok();
}
@Operation(summary = "Enable / disable an ACP endpoint")
@PutMapping("/{id}/toggle")
@RequireWorkspaceRole("admin")
public R<AcpEndpointEntity> toggle(@PathVariable Long id,
@RequestParam boolean enabled) {
return R.ok(service.toggle(id, enabled));
}
/**
* Spawn the configured CLI, run {@code initialize} + {@code
* session/new}, persist the outcome, and return diagnostics.
*/
@Operation(summary = "Test ACP endpoint connection (initialize handshake)")
@PostMapping("/{id}/test")
@RequireWorkspaceRole("admin")
public R<Map<String, Object>> test(@PathVariable Long id) {
AcpEndpointEntity endpoint = service.get(id);
return R.ok(tester.testEndpoint(endpoint));
}
}

View File

@ -0,0 +1,27 @@
package vip.mate.acp.event;
/**
* Lifecycle event for ACP endpoint rows.
*
* <p>Published by {@code AcpEndpointService} whenever a row is created,
* updated, toggled, or deleted. Listened to by
* {@code AcpSkillBridge} so it can re-sync the auto-bridged virtual
* skill cards and their wrapper tool registrations without a full
* application restart.
*
* <p>Mirrors the {@code SkillWorkspaceEvent} pattern a small immutable
* record carrying just enough context for listeners to fan out.
*/
public record AcpEndpointChangedEvent(Long endpointId, String name, Type type) {
public enum Type {
/** Row inserted. */
CREATED,
/** Row attributes updated (command/args/env/etc.). */
UPDATED,
/** {@code enabled} flag flipped. */
TOGGLED,
/** Row deleted. */
DELETED
}
}

View File

@ -0,0 +1,79 @@
package vip.mate.acp.model;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* RFC-090 Phase 7 ACP (Agent Communication Protocol) endpoint registry.
*
* <p>Each row describes one external coding agent that MateClaw can
* delegate to over stdio (codex / claude-code / opencode / qwen-code by
* default). Bundled via Flyway V68 so the user only has to enable the
* row once the matching CLI is on their PATH.
*/
@Data
@TableName("mate_acp_endpoint")
public class AcpEndpointEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** Stable slug, lowercase. Referenced by skill manifests via {@code type: acp} + {@code endpoint:}. */
private String name;
private String displayName;
private String description;
/** Process command, e.g. {@code npx} or {@code codex}. */
private String command;
/**
* JSON array of CLI args, e.g. {@code ["-y","@zed-industries/codex-acp"]}.
* MyBatis Plus stores it as a string; the service layer parses on read.
*/
@TableField(value = "args_json", updateStrategy = FieldStrategy.ALWAYS)
private String argsJson;
/** JSON object of environment variables to inject (merged onto System.getenv()). */
@TableField(value = "env_json", updateStrategy = FieldStrategy.ALWAYS)
private String envJson;
/**
* call_title | call_detail | update_detail (mirrors the ACP
* {@code tool_parse_mode} convention). Drives how the wrapper
* renders ACP tool-call events into MateClaw's stream protocol.
*/
private String toolParseMode;
private Boolean builtin;
/** When true, accept the agent's tool calls without re-prompting the user. */
private Boolean trusted;
private Boolean enabled;
/** Stdio buffer ceiling in bytes; defaults to 50 MiB. */
private Long stdioBufferLimitBytes;
/** UNKNOWN / OK / ERROR — last test result. */
private String lastStatus;
private LocalDateTime lastTestedAt;
@TableField(value = "last_error", updateStrategy = FieldStrategy.ALWAYS)
private String lastError;
private Long workspaceId;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
private Integer deleted;
}

View File

@ -0,0 +1,12 @@
package vip.mate.acp.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import vip.mate.acp.model.AcpEndpointEntity;
/**
* RFC-090 Phase 7 MyBatis Plus mapper for {@link AcpEndpointEntity}.
*/
@Mapper
public interface AcpEndpointMapper extends BaseMapper<AcpEndpointEntity> {
}

View File

@ -0,0 +1,128 @@
package vip.mate.acp.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.acp.client.AcpStdioClient;
import vip.mate.acp.model.AcpEndpointEntity;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* RFC-090 Phase 7 connection tester for ACP endpoints.
*
* <p>Runs the {@code initialize} + {@code session/new} handshake, with
* a generous-but-bounded timeout, and persists the outcome on the row.
* The wired CLI doesn't have to be installed for the user to add a row;
* they can install it later and re-run the test.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AcpConnectionTester {
/** Hard cap so a hung CLI doesn't block the request thread forever. */
private static final long INITIALIZE_TIMEOUT_MS = 15_000L;
private static final long SESSION_NEW_TIMEOUT_MS = 10_000L;
private final ObjectMapper objectMapper;
private final AcpEndpointService endpointService;
private final AcpRuntimeSupport runtimeSupport;
/**
* Spawn the configured agent, exchange initialize + session/new,
* tear it down, and return a structured result. The endpoint row
* is updated with {@code last_status / last_tested_at / last_error}.
*/
public Map<String, Object> testEndpoint(AcpEndpointEntity endpoint) {
long started = System.currentTimeMillis();
Map<String, Object> result = new LinkedHashMap<>();
result.put("name", endpoint.getName());
result.put("command", endpoint.getCommand());
List<String> args = endpointService.parseArgs(endpoint);
Map<String, String> env = endpointService.parseEnv(endpoint);
result.put("args", args);
// Same as AcpDelegationService Zed's ACP server requires a
// non-blank cwd at session/new, so the connection test must
// also default it. The "Test" button used to fail at session/new
// with -32602 even when the CLI itself was healthy.
String resolvedCwd = runtimeSupport.resolveCwd(endpoint, null);
AcpStdioClient client;
try {
client = AcpStdioClient.spawn(objectMapper, endpoint.getCommand(),
args, env, resolvedCwd);
} catch (Exception e) {
return persistAndReturn(endpoint, result, "ERROR",
"Spawn failed: " + e.getMessage(), started);
}
try (AcpStdioClient autoClose = client) {
JsonNode initResp;
try {
initResp = autoClose.initialize(INITIALIZE_TIMEOUT_MS);
} catch (Exception e) {
return persistAndReturn(endpoint, result, "ERROR",
"Initialize failed: " + e.getMessage(), started);
}
if (initResp == null) {
return persistAndReturn(endpoint, result, "ERROR",
"Initialize returned no result", started);
}
int agentProtocolVersion = initResp.path("protocolVersion").asInt(-1);
result.put("protocolVersion", agentProtocolVersion);
if (agentProtocolVersion != AcpStdioClient.PROTOCOL_VERSION) {
String msg = "Protocol mismatch: agent=" + agentProtocolVersion
+ ", client=" + AcpStdioClient.PROTOCOL_VERSION;
return persistAndReturn(endpoint, result, "ERROR", msg, started);
}
// Capture agent capabilities for diagnostics the UI can
// surface this as "supports: file_system, terminal, …".
JsonNode agentCaps = initResp.path("agentCapabilities");
if (!agentCaps.isMissingNode() && !agentCaps.isNull()) {
result.put("agentCapabilities", agentCaps);
}
// session/new validates that the agent really stands up a
// working session, not just initialize handshake.
try {
JsonNode sessionResp = autoClose.newSession(resolvedCwd, SESSION_NEW_TIMEOUT_MS);
if (sessionResp != null && sessionResp.has("sessionId")) {
result.put("sessionId", sessionResp.path("sessionId").asText(""));
}
} catch (Exception e) {
// session/new may fail for legitimate reasons (e.g. agent
// requires auth flow first). Still report OK on initialize
// but flag in the message translated when it smells
// like an auth error so the test page UI shows actionable
// text instead of raw JSON-RPC.
String authHint = runtimeSupport.translateAuthError(endpoint, e.getMessage());
result.put("sessionWarning", authHint != null ? authHint : e.getMessage());
}
} catch (Exception e) {
return persistAndReturn(endpoint, result, "ERROR",
"Connection test crashed: " + e.getMessage(), started);
}
long elapsed = System.currentTimeMillis() - started;
result.put("elapsedMs", elapsed);
return persistAndReturn(endpoint, result, "OK", null, started);
}
private Map<String, Object> persistAndReturn(AcpEndpointEntity endpoint,
Map<String, Object> result,
String status,
String error,
long started) {
endpointService.recordTestResult(endpoint.getId(), status, error);
result.put("status", status);
if (error != null) result.put("error", error);
result.putIfAbsent("elapsedMs", System.currentTimeMillis() - started);
return result;
}
}

View File

@ -0,0 +1,241 @@
package vip.mate.acp.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.acp.client.AcpStdioClient;
import vip.mate.acp.model.AcpEndpointEntity;
import vip.mate.exception.MateClawException;
import java.io.IOException;
import java.time.Duration;
import java.util.List;
import java.util.Map;
/**
* RFC-090 Phase 7b fire-and-forget delegation to an external ACP
* agent.
*
* <p>One {@link #prompt(String, String, String)} call:
* <ol>
* <li>Looks up the endpoint row, refuses if disabled or undefined.</li>
* <li>Spawns a fresh {@link AcpStdioClient} (no session caching in
* v1 stateless tool calls keep failure surface small;
* multi-turn caching can be a follow-up RFC).</li>
* <li>Runs {@code initialize session/new session/prompt}.</li>
* <li>Accumulates {@code agent_message_chunk} text from
* {@code session/update} notifications into the response.</li>
* <li>Auto-allows or cancels {@code session/request_permission}
* based on the endpoint's {@code trusted} flag untrusted
* endpoints reject every permission request, surfacing a
* transparent "this endpoint can't be used non-interactively"
* error to the LLM caller.</li>
* <li>Returns the accumulated text or a JSON error blob on failure.</li>
* </ol>
*
* <p>The streaming surface (chunk-by-chunk relay back through MateClaw's
* own SSE stream) is intentionally not done yet the wrapper tool is
* synchronous so it composes cleanly with the existing ReAct graph.
* When we want native streaming, we'll add a second method that takes
* an {@code Sinks.Many<String>}.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AcpDelegationService {
/** Hard ceiling on a single ACP delegation. Long enough for a
* multi-turn coding session, short enough that a hung agent can't
* permanently block an LLM tool call. */
private static final Duration PROMPT_TIMEOUT = Duration.ofMinutes(5);
private static final long INITIALIZE_TIMEOUT_MS = 15_000L;
private static final long SESSION_NEW_TIMEOUT_MS = 10_000L;
private final ObjectMapper objectMapper;
private final AcpEndpointService endpointService;
private final AcpRuntimeSupport runtimeSupport;
/**
* Run a one-shot ACP prompt against {@code endpointName}. Returns
* the agent's accumulated reply text. Throws
* {@link MateClawException} for configuration / runtime errors so
* the caller (typically a wrapper tool) can serialize a friendly
* JSON error.
*/
public String prompt(String endpointName, String userPrompt, String cwdHint) {
if (endpointName == null || endpointName.isBlank()) {
throw new MateClawException("err.acp.endpoint_required",
"ACP endpoint name is required");
}
if (userPrompt == null || userPrompt.isBlank()) {
throw new MateClawException("err.acp.prompt_required",
"ACP prompt is required");
}
AcpEndpointEntity endpoint = endpointService.findByName(endpointName);
if (endpoint == null) {
throw new MateClawException("err.acp.endpoint_not_found",
"ACP endpoint not found: " + endpointName);
}
if (!Boolean.TRUE.equals(endpoint.getEnabled())) {
throw new MateClawException("err.acp.endpoint_disabled",
"ACP endpoint '" + endpointName + "' is disabled — enable it in Settings ▸ ACP Endpoints");
}
List<String> args = endpointService.parseArgs(endpoint);
Map<String, String> env = endpointService.parseEnv(endpoint);
boolean trusted = !Boolean.FALSE.equals(endpoint.getTrusted());
// Always resolve cwd to a real directory: Zed's ACP Zod schema
// marks cwd as a required string and rejects {@code undefined}
// with -32602. See {@link AcpRuntimeSupport#resolveCwd}.
String resolvedCwd = runtimeSupport.resolveCwd(endpoint, cwdHint);
StringBuilder accumulator = new StringBuilder();
AcpStdioClient client;
try {
client = AcpStdioClient.spawn(objectMapper, endpoint.getCommand(),
args, env, resolvedCwd);
} catch (IOException e) {
throw new MateClawException("err.acp.spawn_failed",
"Failed to spawn ACP agent '" + endpointName + "': " + e.getMessage());
}
try (AcpStdioClient autoClose = client) {
wireHandlers(autoClose, accumulator, trusted, endpointName);
JsonNode initResp = autoClose.initialize(INITIALIZE_TIMEOUT_MS);
if (initResp == null || initResp.path("protocolVersion").asInt(-1)
!= AcpStdioClient.PROTOCOL_VERSION) {
throw new MateClawException("err.acp.protocol_mismatch",
"ACP protocol mismatch with endpoint '" + endpointName + "'");
}
JsonNode session = autoClose.newSession(resolvedCwd, SESSION_NEW_TIMEOUT_MS);
String sessionId = session == null ? null : session.path("sessionId").asText("");
if (sessionId == null || sessionId.isBlank()) {
throw new MateClawException("err.acp.session_failed",
"ACP session/new returned no sessionId for '" + endpointName + "'");
}
ObjectNode promptParams = objectMapper.createObjectNode();
promptParams.put("sessionId", sessionId);
promptParams.set("prompt", buildPromptArray(userPrompt));
autoClose.sendRequest("session/prompt", promptParams, PROMPT_TIMEOUT.toMillis());
} catch (IOException | InterruptedException e) {
if (e instanceof InterruptedException) Thread.currentThread().interrupt();
log.warn("ACP delegation failed for endpoint '{}': {}", endpointName, e.getMessage());
// Upstream CLIs (claude-code / codex / qwen-code) wrap their
// own auth failures in opaque JSON-RPC noise. Recognise the
// 401/403/forbidden/unauthorized fingerprints and rewrite
// the message into something the user can act on, with the
// exact env var name they need to set.
String authHint = runtimeSupport.translateAuthError(endpoint, e.getMessage());
if (authHint != null) {
throw new MateClawException("err.acp.auth_failed", authHint);
}
throw new MateClawException("err.acp.delegation_failed",
"ACP delegation to '" + endpointName + "' failed: " + e.getMessage());
}
return accumulator.toString().trim();
}
private void wireHandlers(AcpStdioClient client, StringBuilder buf,
boolean trusted, String endpointName) {
// Notifications carry session/update messages; agent_message_chunk
// is what we accumulate. Other update kinds (tool_call_*, plan,
// current_mode) are observed but not relayed in v1.
client.setNotificationHandler(msg -> {
String method = msg.path("method").asText("");
if (!"session/update".equals(method)) return;
JsonNode update = msg.path("params").path("update");
if (update.isMissingNode() || update.isNull()) return;
String type = update.path("sessionUpdate").asText(
update.path("type").asText(""));
if ("agent_message_chunk".equals(type) || "agent-message-chunk".equals(type)) {
String text = extractText(update.path("content"));
if (!text.isEmpty()) buf.append(text);
}
});
// Permission requests: trusted endpoints auto-allow the FIRST
// option (which Zed-style agents make the "allow" choice);
// untrusted refuse every request explicitly so the agent
// exits cleanly instead of hanging.
client.setRequestHandler(msg -> {
String method = msg.path("method").asText("");
if (!"session/request_permission".equals(method)) return null;
JsonNode params = msg.path("params");
if (!trusted) {
log.info("[ACP] declining permission for untrusted endpoint '{}'", endpointName);
return cancelledOutcome();
}
JsonNode options = params.path("options");
String optionId = "";
if (options.isArray() && options.size() > 0) {
JsonNode first = options.get(0);
optionId = first.path("optionId").asText(first.path("id").asText(""));
}
if (optionId.isEmpty()) {
return cancelledOutcome();
}
return selectedOutcome(optionId);
});
}
private JsonNode buildPromptArray(String text) {
// Spring AI / Zed ACP prompt format: array of content blocks.
// For now we only emit a single text block; future iterations
// can attach images / file references via additional blocks.
var arr = objectMapper.createArrayNode();
ObjectNode block = objectMapper.createObjectNode();
block.put("type", "text");
block.put("text", text);
arr.add(block);
return arr;
}
/**
* Extract plain text from an ACP {@code content} field. The shape
* varies between agents Zed uses {@code [{type:"text",text:"..."}]},
* some emit a single object, others nest in {@code resource.text}.
* Tolerant extractor that handles all known shapes.
*/
private String extractText(JsonNode content) {
if (content == null || content.isNull()) return "";
if (content.isArray()) {
StringBuilder sb = new StringBuilder();
for (JsonNode item : content) sb.append(extractText(item));
return sb.toString();
}
JsonNode text = content.get("text");
if (text != null && text.isTextual()) return text.asText("");
JsonNode resource = content.get("resource");
if (resource != null) {
JsonNode rt = resource.get("text");
if (rt != null && rt.isTextual()) return rt.asText("");
}
return "";
}
private ObjectNode selectedOutcome(String optionId) {
ObjectNode result = objectMapper.createObjectNode();
ObjectNode outcome = objectMapper.createObjectNode();
outcome.put("outcome", "selected");
outcome.put("optionId", optionId);
result.set("outcome", outcome);
return result;
}
private ObjectNode cancelledOutcome() {
ObjectNode result = objectMapper.createObjectNode();
ObjectNode outcome = objectMapper.createObjectNode();
outcome.put("outcome", "cancelled");
result.set("outcome", outcome);
return result;
}
}

View File

@ -0,0 +1,192 @@
package vip.mate.acp.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import vip.mate.acp.event.AcpEndpointChangedEvent;
import vip.mate.acp.model.AcpEndpointEntity;
import vip.mate.acp.repository.AcpEndpointMapper;
import vip.mate.exception.MateClawException;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* RFC-090 Phase 7 CRUD layer for {@link AcpEndpointEntity}.
*
* <p>Keeps three guarantees:
* <ol>
* <li>Builtin rows ({@code builtin=true}) cannot be hard-deleted
* the user can only disable them. Mirrors {@code SkillService}.</li>
* <li>Names are unique; {@code create} validates against the live
* (non-deleted) set.</li>
* <li>{@code argsJson} / {@code envJson} round-trip through Jackson
* so the controller can hand structured data to the UI without
* leaking string-encoded JSON.</li>
* </ol>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AcpEndpointService {
private final AcpEndpointMapper mapper;
private final ObjectMapper objectMapper;
private final ApplicationEventPublisher eventPublisher;
public List<AcpEndpointEntity> list() {
return mapper.selectList(new LambdaQueryWrapper<AcpEndpointEntity>()
.orderByDesc(AcpEndpointEntity::getBuiltin)
.orderByAsc(AcpEndpointEntity::getName));
}
/**
* Subset of {@link #list()} that returns only enabled rows.
* Used by {@code AcpSkillBridge} to enumerate virtual skill cards
* (one per enabled endpoint).
*/
public List<AcpEndpointEntity> listEnabled() {
return mapper.selectList(new LambdaQueryWrapper<AcpEndpointEntity>()
.eq(AcpEndpointEntity::getEnabled, true)
.orderByAsc(AcpEndpointEntity::getName));
}
public AcpEndpointEntity get(Long id) {
AcpEndpointEntity ep = mapper.selectById(id);
if (ep == null) throw new MateClawException("err.acp.endpoint_not_found",
"ACP endpoint not found: " + id);
return ep;
}
public AcpEndpointEntity findByName(String name) {
return mapper.selectOne(new LambdaQueryWrapper<AcpEndpointEntity>()
.eq(AcpEndpointEntity::getName, name));
}
public AcpEndpointEntity create(AcpEndpointEntity input) {
if (input.getName() == null || input.getName().isBlank()) {
throw new MateClawException("err.acp.name_required", "ACP endpoint name is required");
}
if (input.getCommand() == null || input.getCommand().isBlank()) {
throw new MateClawException("err.acp.command_required", "ACP endpoint command is required");
}
if (findByName(input.getName()) != null) {
throw new MateClawException("err.acp.name_exists",
"ACP endpoint name already exists: " + input.getName());
}
// User-created rows are never builtin; default-enable false so a
// misconfigured row can't auto-spawn a process at startup.
input.setBuiltin(false);
if (input.getEnabled() == null) input.setEnabled(false);
if (input.getTrusted() == null) input.setTrusted(true);
if (input.getToolParseMode() == null || input.getToolParseMode().isBlank()) {
input.setToolParseMode("call_title");
}
if (input.getStdioBufferLimitBytes() == null || input.getStdioBufferLimitBytes() <= 0) {
input.setStdioBufferLimitBytes(50L * 1024L * 1024L);
}
if (input.getWorkspaceId() == null) input.setWorkspaceId(1L);
mapper.insert(input);
log.info("Created ACP endpoint: {}", input.getName());
publish(input, AcpEndpointChangedEvent.Type.CREATED);
return input;
}
public AcpEndpointEntity update(Long id, AcpEndpointEntity patch) {
AcpEndpointEntity existing = get(id);
if (Boolean.TRUE.equals(existing.getBuiltin())
&& patch.getCommand() != null
&& !patch.getCommand().equals(existing.getCommand())) {
throw new MateClawException("err.acp.builtin_command_locked",
"Builtin ACP endpoint command cannot be changed: " + existing.getName());
}
// Allow surgical updates: only fields the caller actually set.
if (patch.getDisplayName() != null) existing.setDisplayName(patch.getDisplayName());
if (patch.getDescription() != null) existing.setDescription(patch.getDescription());
if (patch.getCommand() != null) existing.setCommand(patch.getCommand());
if (patch.getArgsJson() != null) existing.setArgsJson(patch.getArgsJson());
if (patch.getEnvJson() != null) existing.setEnvJson(patch.getEnvJson());
if (patch.getToolParseMode() != null) existing.setToolParseMode(patch.getToolParseMode());
if (patch.getTrusted() != null) existing.setTrusted(patch.getTrusted());
if (patch.getEnabled() != null) existing.setEnabled(patch.getEnabled());
if (patch.getStdioBufferLimitBytes() != null && patch.getStdioBufferLimitBytes() > 0) {
existing.setStdioBufferLimitBytes(patch.getStdioBufferLimitBytes());
}
mapper.updateById(existing);
publish(existing, AcpEndpointChangedEvent.Type.UPDATED);
return existing;
}
public void delete(Long id) {
AcpEndpointEntity existing = get(id);
if (Boolean.TRUE.equals(existing.getBuiltin())) {
throw new MateClawException("err.acp.builtin_readonly",
"Builtin ACP endpoint cannot be deleted: " + existing.getName());
}
mapper.deleteById(id);
log.info("Deleted ACP endpoint: {}", existing.getName());
publish(existing, AcpEndpointChangedEvent.Type.DELETED);
}
public AcpEndpointEntity toggle(Long id, boolean enabled) {
AcpEndpointEntity existing = get(id);
existing.setEnabled(enabled);
mapper.updateById(existing);
publish(existing, AcpEndpointChangedEvent.Type.TOGGLED);
return existing;
}
private void publish(AcpEndpointEntity ep, AcpEndpointChangedEvent.Type type) {
try {
eventPublisher.publishEvent(new AcpEndpointChangedEvent(
ep.getId(), ep.getName(), type));
} catch (Exception e) {
// Listener failures must not break the CRUD path. The bridge
// will resync on the next ApplicationReady tick anyway.
log.warn("Failed to publish AcpEndpointChangedEvent for '{}': {}",
ep.getName(), e.getMessage());
}
}
/** Persist a connection-test outcome on the row. */
public void recordTestResult(Long id, String status, String error) {
AcpEndpointEntity existing = mapper.selectById(id);
if (existing == null) return;
existing.setLastStatus(status);
existing.setLastTestedAt(LocalDateTime.now());
existing.setLastError(error);
mapper.updateById(existing);
}
public List<String> parseArgs(AcpEndpointEntity ep) {
return parseStringList(ep.getArgsJson());
}
public Map<String, String> parseEnv(AcpEndpointEntity ep) {
if (ep.getEnvJson() == null || ep.getEnvJson().isBlank()) return Map.of();
try {
return objectMapper.readValue(ep.getEnvJson(),
new TypeReference<Map<String, String>>() {});
} catch (Exception e) {
log.warn("Failed to parse env_json for ACP endpoint '{}': {}",
ep.getName(), e.getMessage());
return Map.of();
}
}
private List<String> parseStringList(String json) {
if (json == null || json.isBlank()) return Collections.emptyList();
try {
return objectMapper.readValue(json, new TypeReference<List<String>>() {});
} catch (Exception e) {
log.warn("Failed to parse args_json: {}", e.getMessage());
return Collections.emptyList();
}
}
}

View File

@ -0,0 +1,188 @@
package vip.mate.acp.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.acp.model.AcpEndpointEntity;
import vip.mate.workspace.core.model.WorkspaceEntity;
import vip.mate.workspace.core.service.WorkspaceService;
import java.io.File;
import java.util.Locale;
/**
* Shared runtime helpers for ACP code paths.
*
* <p>Two responsibilities, both motivated by upstream ACP servers
* (e.g. {@code @zed-industries/claude-agent-acp}) being strict about
* inputs and noisy in failure modes:
*
* <ul>
* <li>{@link #resolveCwd} pick a non-blank cwd for {@code session/new}.
* Zed's ACP Zod schema marks {@code cwd} as a required string and
* returns {@code -32602 Invalid params} when it's missing. We
* prefer the endpoint's bound workspace {@code base_path} (per-
* workspace context) and fall back to the JVM working directory
* only as a last resort. Never returns null/blank.</li>
*
* <li>{@link #translateAuthError} turn upstream JSON-RPC noise like
* {@code "API Error: 403 {...forbidden...}"} into an actionable
* hint that names the env var the user actually has to set in
* Settings ACP Endpoints (e.g. {@code ANTHROPIC_API_KEY} for
* claude-code, {@code OPENAI_API_KEY} for codex). Returns
* {@code null} when the error doesn't smell like an auth failure.</li>
* </ul>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AcpRuntimeSupport {
private final WorkspaceService workspaceService;
/**
* Resolution order (first non-blank wins):
* <ol>
* <li>Caller-provided hint (skill manifest's {@code acp.cwd},
* wrapper tool {@code cwd} arg, or explicit override).</li>
* <li>Workspace {@code base_path} when the endpoint is bound to a
* workspace and the workspace declares one.</li>
* <li>{@code System.getProperty("user.dir")} the JVM working
* directory at server launch. Reasonable for a single-user
* desktop install, but exposes the server's launch dir to the
* upstream agent, which is why it's last.</li>
* </ol>
*/
public String resolveCwd(AcpEndpointEntity endpoint, String callerHint) {
if (callerHint != null && !callerHint.isBlank()) {
return callerHint;
}
if (endpoint != null && endpoint.getWorkspaceId() != null) {
try {
WorkspaceEntity ws = workspaceService.getById(endpoint.getWorkspaceId());
if (ws != null && ws.getBasePath() != null && !ws.getBasePath().isBlank()) {
File f = new File(ws.getBasePath());
if (f.isDirectory()) return f.getAbsolutePath();
}
} catch (Exception e) {
log.debug("Workspace lookup failed for ACP cwd default (id={}): {}",
endpoint.getWorkspaceId(), e.getMessage());
}
}
return System.getProperty("user.dir", ".");
}
/**
* Detect upstream auth errors and emit an actionable hint string.
* Returns null when the message doesn't match caller should keep
* the original error as-is.
*
* <p>Heuristic: looks for HTTP-like 401/403 markers OR the words
* {@code forbidden / unauthorized / not allowed / api key / token}
* in the original message (case-insensitive). The patterns are loose
* on purpose different ACP CLIs phrase auth errors differently
* and the cost of a false positive (a slightly more verbose error
* banner) is much smaller than a false negative (user staring at a
* raw JSON-RPC blob).
*
* <p>Special case: a claude-code endpoint returning {@code 403
* "Request not allowed"} is almost always the keychain-hijack
* scenario rather than a wrong API key. The third-party
* {@code @zed-industries/claude-agent-acp} package wraps
* {@code @anthropic-ai/claude-agent-sdk}, whose auth dispatcher
* checks the macOS keychain ({@code Claude Code-credentials}) /
* {@code ~/.claude/credentials.json} BEFORE the
* {@code ANTHROPIC_API_KEY} env var. So a host that's done
* {@code claude login} silently shadows whatever API key the user
* configured in the endpoint env, and Anthropic's API rejects the
* subscription OAuth token (first-party-only) with the very
* specific {@code "Request not allowed"} error string. We detect
* that exact combination and surface the keychain-clearing remedy
* instead of the generic "set ANTHROPIC_API_KEY" hint, which
* doesn't apply here.
*/
public String translateAuthError(AcpEndpointEntity endpoint, String originalMessage) {
if (originalMessage == null) return null;
String lower = originalMessage.toLowerCase(Locale.ROOT);
boolean looksLikeAuth =
lower.contains("403")
|| lower.contains("401")
|| lower.contains("forbidden")
|| lower.contains("unauthorized")
|| lower.contains("not allowed")
|| lower.contains("invalid api key")
|| lower.contains("invalid token")
|| lower.contains("authenticate");
if (!looksLikeAuth) return null;
String name = endpoint != null && endpoint.getName() != null ? endpoint.getName() : "(unknown)";
String slug = lower(name);
String command = endpoint != null ? lower(endpoint.getCommand()) : "";
// Keychain-hijack detection must come before the generic env-
// missing branch because both would superficially match.
boolean keychainHijack = lower.contains("request not allowed")
&& (slug.contains("claude") || command.contains("claude-agent-acp"));
if (keychainHijack) {
StringBuilder sb = new StringBuilder();
sb.append("ACP endpoint '").append(name).append("' upstream auth failed with ");
sb.append("'Request not allowed' — almost always means the host CLI's OAuth ");
sb.append("credentials are hijacking the SDK auth path. ");
sb.append("The Claude Agent SDK reads ~/.claude/ / macOS keychain BEFORE the ");
sb.append("ANTHROPIC_API_KEY env var, so the API key you configured here is ");
sb.append("never sent — Anthropic rejects the subscription OAuth token because ");
sb.append("third-party processes aren't allowed to use it. ");
sb.append("To fix: ");
sb.append("(macOS) run `claude logout`, or `security delete-generic-password ");
sb.append("-s \"Claude Code-credentials\"`; ");
sb.append("(Linux / Windows) delete ~/.claude/credentials.json. ");
sb.append("Then click Test connection again. Original: ").append(originalMessage);
return sb.toString();
}
String envVar = expectedAuthEnvVar(endpoint);
StringBuilder sb = new StringBuilder();
sb.append("ACP endpoint '").append(name).append("' upstream auth failed. ");
sb.append("Most likely the endpoint env has no API key. ");
sb.append("Edit Settings ▸ ACP Endpoints → ").append(name).append(" → env, ");
if (envVar != null) {
sb.append("add `{\"").append(envVar).append("\":\"...\"}`");
} else {
sb.append("add the appropriate API key for this CLI");
}
sb.append(". Note: claude-code / codex / qwen-code refuse OAuth tokens from their host CLIs, ");
sb.append("so a real API key is required. Original: ").append(originalMessage);
return sb.toString();
}
/**
* Best-effort guess of the API key env var the upstream CLI expects.
* Returns null when we don't recognise the endpoint caller emits a
* generic "appropriate API key" hint instead.
*/
public String expectedAuthEnvVar(AcpEndpointEntity endpoint) {
if (endpoint == null) return null;
String name = lower(endpoint.getName());
String command = lower(endpoint.getCommand());
// Match by name first (slug is the stable identifier); fall back
// to command keywords for user-defined rows.
if (name.contains("claude") || command.contains("claude-agent-acp") || command.contains("anthropic")) {
return "ANTHROPIC_API_KEY";
}
if (name.contains("codex") || command.contains("codex") || command.contains("openai")) {
return "OPENAI_API_KEY";
}
if (name.contains("qwen") || command.contains("qwen") || command.contains("dashscope")) {
return "DASHSCOPE_API_KEY";
}
if (name.contains("gemini") || command.contains("gemini") || command.contains("google-genai")) {
return "GOOGLE_API_KEY";
}
// opencode multi-model no single canonical env var.
return null;
}
private static String lower(String s) {
return s == null ? "" : s.toLowerCase(Locale.ROOT);
}
}

View File

@ -0,0 +1,221 @@
package vip.mate.activity;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import vip.mate.approval.model.ToolApprovalEntity;
import vip.mate.approval.repository.ToolApprovalMapper;
import vip.mate.audit.model.AuditEventEntity;
import vip.mate.audit.repository.AuditEventMapper;
import vip.mate.audit.service.AuditEventService;
import vip.mate.common.result.R;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
/**
* RFC-090 §4.5 / §7 unified Activity feed.
*
* <p>Merges three sources into one chronologically-ordered stream:
* <ul>
* <li>{@code audit_event} CRUD-style events on agents / channels /
* skills / wiki / workspace (the existing audit log)</li>
* <li>{@code tool_approval} approval requests + their resolution
* (granted / denied / expired). Ties tool gating decisions
* directly to the audit timeline.</li>
* <li>Successful tool calls RFC §4.5 mentions these, but the
* runtime doesn't yet persist a row per successful call.
* Returning an empty bucket keeps the API contract stable so
* the UI can light up automatically once a future commit adds
* persistence.</li>
* </ul>
*
* <p>Pagination is best-effort: each source is paged from index 0
* up to {@code size * 2}, then the merged list is trimmed and offset
* in-memory. For workspaces with >>1k events / day a follow-up should
* push merging into SQL; this is good enough for v1.
*/
@Tag(name = "Activity Feed (RFC-090)")
@RestController
@RequestMapping("/api/v1/activity")
@RequiredArgsConstructor
public class ActivityFeedController {
private final AuditEventService auditEventService;
private final AuditEventMapper auditEventMapper;
private final ToolApprovalMapper toolApprovalMapper;
/**
* RFC-090 §4.5 paginated activity feed.
*
* <p>Pagination strategy:
* <ul>
* <li><b>Single-source filter</b> (source=audit | approval)
* direct {@code BaseMapper.selectPage(...)} on the matching
* table. Both total and records are SQL-accurate.</li>
* <li><b>Combined feed</b> (source unset) fetch
* {@code page*size} rows from each side, merge by time-desc,
* slice to the requested window. {@code total} is the sum
* of {@code selectCount} across both tables exact for
* count, best-effort for time-merge ordering at very deep
* page numbers (the merge buffer is bounded but typical
* use stays within a few hundred rows).</li>
* </ul>
*
* <p>Caps: {@code size} clamped to [1, 200]; {@code page} 1.
*/
@Operation(summary = "Unified activity feed (audit + approval + tool calls)")
@GetMapping("/feed")
@RequireWorkspaceRole("admin")
public R<Map<String, Object>> feed(
@RequestParam(required = false) Long workspaceId,
@RequestParam(required = false) String source,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
if (size <= 0) size = 20;
if (size > 200) size = 200;
if (page <= 0) page = 1;
boolean wantAudit = source == null || source.isBlank() || "audit".equalsIgnoreCase(source);
boolean wantApproval = source == null || source.isBlank() || "approval".equalsIgnoreCase(source);
// Single-source path: direct SQL pagination
if (wantAudit && !wantApproval) {
return R.ok(pageAuditOnly(workspaceId, page, size));
}
if (wantApproval && !wantAudit) {
return R.ok(pageApprovalOnly(page, size));
}
// Combined path: per-source paginate + merge
// Fetch page*size from each side so the merged window contains
// the requested slice even in the worst case where one source
// dominates the timeline. This is wasteful at very deep pages
// but bounded a follow-up can push merging into SQL via a
// UNION ALL view if event volume gets into 10k+/day territory.
int bufferSize = Math.max(size * page, 50);
LambdaQueryWrapper<AuditEventEntity> auditQ = new LambdaQueryWrapper<AuditEventEntity>()
.orderByDesc(AuditEventEntity::getCreateTime);
if (workspaceId != null) auditQ.eq(AuditEventEntity::getWorkspaceId, workspaceId);
IPage<AuditEventEntity> auditPage = auditEventMapper.selectPage(new Page<>(1, bufferSize), auditQ);
LambdaQueryWrapper<ToolApprovalEntity> approvalQ = new LambdaQueryWrapper<ToolApprovalEntity>()
.orderByDesc(ToolApprovalEntity::getCreatedAt);
IPage<ToolApprovalEntity> approvalPage = toolApprovalMapper.selectPage(new Page<>(1, bufferSize), approvalQ);
List<ActivityRow> rows = new ArrayList<>();
for (AuditEventEntity ev : auditPage.getRecords()) rows.add(fromAuditEvent(ev));
for (ToolApprovalEntity ap : approvalPage.getRecords()) rows.add(fromApproval(ap));
rows.sort(Comparator.comparing(ActivityRow::time, Comparator.nullsLast(Comparator.reverseOrder())));
long total = auditPage.getTotal() + approvalPage.getTotal();
int from = Math.min((page - 1) * size, rows.size());
int to = Math.min(from + size, rows.size());
List<ActivityRow> sliced = rows.subList(from, to);
Map<String, Object> resp = new LinkedHashMap<>();
resp.put("page", page);
resp.put("size", size);
resp.put("total", total);
resp.put("records", sliced);
return R.ok(resp);
}
/** Pure SQL pagination on the audit_event table; total + records both
* come from the underlying {@link Page} object. */
private Map<String, Object> pageAuditOnly(Long workspaceId, int page, int size) {
LambdaQueryWrapper<AuditEventEntity> q = new LambdaQueryWrapper<AuditEventEntity>()
.orderByDesc(AuditEventEntity::getCreateTime);
if (workspaceId != null) q.eq(AuditEventEntity::getWorkspaceId, workspaceId);
IPage<AuditEventEntity> p = auditEventMapper.selectPage(new Page<>(page, size), q);
List<ActivityRow> records = new ArrayList<>(p.getRecords().size());
for (AuditEventEntity ev : p.getRecords()) records.add(fromAuditEvent(ev));
Map<String, Object> resp = new LinkedHashMap<>();
resp.put("page", page);
resp.put("size", size);
resp.put("total", p.getTotal());
resp.put("records", records);
return resp;
}
/** Pure SQL pagination on the tool_approval table. */
private Map<String, Object> pageApprovalOnly(int page, int size) {
LambdaQueryWrapper<ToolApprovalEntity> q = new LambdaQueryWrapper<ToolApprovalEntity>()
.orderByDesc(ToolApprovalEntity::getCreatedAt);
IPage<ToolApprovalEntity> p = toolApprovalMapper.selectPage(new Page<>(page, size), q);
List<ActivityRow> records = new ArrayList<>(p.getRecords().size());
for (ToolApprovalEntity ap : p.getRecords()) records.add(fromApproval(ap));
Map<String, Object> resp = new LinkedHashMap<>();
resp.put("page", page);
resp.put("size", size);
resp.put("total", p.getTotal());
resp.put("records", records);
return resp;
}
private ActivityRow fromAuditEvent(AuditEventEntity ev) {
Map<String, Object> detail = new LinkedHashMap<>();
detail.put("detailJson", ev.getDetailJson());
detail.put("userAgent", ev.getUserAgent());
detail.put("workspaceId", ev.getWorkspaceId());
return new ActivityRow(
"audit-" + ev.getId(),
"audit",
ev.getCreateTime(),
ev.getUsername(),
ev.getAction(),
ev.getResourceType(),
ev.getResourceName() != null ? ev.getResourceName() : ev.getResourceId(),
ev.getIpAddress(),
detail);
}
private ActivityRow fromApproval(ToolApprovalEntity ap) {
Map<String, Object> detail = new LinkedHashMap<>();
detail.put("toolArguments", ap.getToolArguments());
detail.put("summary", ap.getSummary());
detail.put("maxSeverity", ap.getMaxSeverity());
detail.put("status", ap.getStatus());
detail.put("resolvedAt", ap.getResolvedAt());
// Map approval status onto an audit-style action so the UI's
// existing action coloring (CREATE / DELETE / etc.) keeps
// working without a special case.
String action = "APPROVAL_" + (ap.getStatus() == null ? "PENDING" : ap.getStatus().toUpperCase());
return new ActivityRow(
"approval-" + ap.getId(),
"approval",
ap.getCreatedAt(),
ap.getResolvedBy() != null ? ap.getResolvedBy() : ap.getRequesterName(),
action,
"TOOL_APPROVAL",
ap.getToolName(),
null,
detail);
}
/**
* Wire-format row. Public record so Jackson serializes it directly
* without needing a separate DTO.
*/
public record ActivityRow(
String id,
String source,
LocalDateTime time,
String username,
String action,
String resourceType,
String resourceName,
String ipAddress,
Map<String, Object> detail
) {}
}

View File

@ -0,0 +1,313 @@
package vip.mate.agent;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import vip.mate.agent.binding.service.AgentBindingService;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.model.AgentEntity;
import vip.mate.exception.MateClawException;
import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.repository.SkillMapper;
import vip.mate.tool.model.AvailableToolDTO;
import vip.mate.tool.service.AvailableToolService;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Agent-callable employee authoring tool.
*
* <p>Lets an agent design and persist a new specialized employee (Agent)
* from a plain-language role spec, then bind a focused capability set to
* it. Pairs with the workflow drafting tool so a single chat turn can plan
* a team of employees and chain them into a workflow:
* design roles {@link #create_employee} for each workflow drafting tool
* referencing the just-created employees.
*
* <p>Workspace is taken from {@link ChatOrigin} on the active
* {@link ToolContext}; the LLM can never write into a foreign workspace
* even if its prompt tried to forge one. Mirrors the create-then-bind
* sequence used when applying an agent template.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AgentAuthoringTool {
private final AgentService agentService;
private final AgentBindingService agentBindingService;
private final SkillMapper skillMapper;
private final AvailableToolService availableToolService;
private final ObjectMapper objectMapper;
/** Cap on names listed per catalog section so the tool result stays small. */
private static final int CATALOG_MAX_PER_SECTION = 200;
@Tool(description = """
Create a new specialized employee (Agent) in the current workspace from a role spec, \
and optionally bind a focused set of skills and tools to it. \
Use this when a task needs a role that does not exist yet design the role, then create it. \
Returns the new agentId (string) and a short summary. \
Leave skillNames/toolNames empty to make a generalist that inherits all globally-enabled capabilities. \
Call list_capability_catalog first to learn the exact skill and tool names you can assign. \
The created employee is enabled immediately and can be referenced by the workflow drafting tool.""")
public String create_employee(
@ToolParam(description = "Employee name, unique within the workspace, e.g. \"market-research-analyst\".")
String name,
@ToolParam(description = "One-line description of the employee's role and responsibility. Shown in pickers and used by the workflow planner to route work.")
String description,
@ToolParam(description = "System prompt that defines the employee's persona, expertise, and working style. Be specific about its specialty.")
String systemPrompt,
@ToolParam(description = "Agent type: \"react\" (single-loop reasoning, default) or \"plan_execute\" (decompose then execute). Leave empty for react.", required = false)
String agentType,
@ToolParam(description = "Optional model name override (must match an enabled model). Leave empty to use the workspace default model.", required = false)
String modelName,
@ToolParam(description = "Skills to bind, as a JSON array of skill names or a comma-separated list, e.g. [\"sql_query\",\"make_plan\"]. Empty = inherit all globally-enabled skills. Names must come from list_capability_catalog.", required = false)
String skillNames,
@ToolParam(description = "Tools to bind, as a JSON array of tool names or a comma-separated list, e.g. [\"web_search\",\"read_file\"]. Empty = inherit all globally-enabled tools. Names must come from list_capability_catalog.", required = false)
String toolNames,
@Nullable ToolContext ctx) {
ChatOrigin origin = ChatOrigin.from(ctx);
Long workspaceId = origin.workspaceId();
if (workspaceId == null || workspaceId <= 0) {
return "[error] Cannot determine the current workspace; invoke this tool within a workspace context.";
}
if (name == null || name.isBlank()) {
return "[error] Employee name is required.";
}
AgentEntity agent = new AgentEntity();
agent.setName(name.trim());
agent.setDescription(blankToNull(description));
if (systemPrompt != null && !systemPrompt.isBlank()) {
agent.setSystemPrompt(systemPrompt);
}
agent.setAgentType(normalizeAgentType(agentType));
agent.setModelName(blankToNull(modelName));
agent.setWorkspaceId(workspaceId);
agent.setCreatorUserId(parseUserId(origin.requesterId()));
AgentEntity created;
try {
created = agentService.createAgent(agent);
} catch (MateClawException e) {
// Duplicate name / blank name surface here as a friendly message
// so the planner can rename and retry instead of aborting.
return "[error] Failed to create employee: " + e.getMessage();
}
List<String> requestedSkills = parseNameList(skillNames);
List<String> requestedTools = parseNameList(toolNames);
List<String> boundSkills = bindSkills(created, workspaceId, requestedSkills);
List<String> boundTools = bindTools(created, requestedTools);
Map<String, Object> result = new LinkedHashMap<>();
result.put("agentId", String.valueOf(created.getId()));
result.put("name", created.getName());
result.put("agentType", created.getAgentType());
result.put("skillsBound", boundSkills.isEmpty() ? "(inherits global defaults)" : boundSkills);
result.put("toolsBound", boundTools.isEmpty() ? "(inherits global defaults)" : boundTools);
result.put("note", "Employee created and enabled. Reference it by name in the workflow drafting tool to chain it into a workflow.");
try {
return objectMapper.writeValueAsString(result);
} catch (Exception e) {
return "Employee created: id=" + created.getId() + " name=" + created.getName();
}
}
@Tool(description = """
List the capabilities you can assign when creating an employee: the enabled skill names \
and the bindable tool names in the current workspace. \
Call this before create_employee so you assign real, resolvable names rather than guessing.""")
public String list_capability_catalog(@Nullable ToolContext ctx) {
ChatOrigin origin = ChatOrigin.from(ctx);
Long workspaceId = origin.workspaceId();
// Skills: builtin (global) + skills owned by this workspace, enabled only.
List<SkillEntity> skills = skillMapper.selectList(new LambdaQueryWrapper<SkillEntity>()
.eq(SkillEntity::getEnabled, true)
.eq(SkillEntity::getDeleted, 0)
.orderByAsc(SkillEntity::getName));
long effectiveWs = workspaceId == null ? 1L : workspaceId;
List<Map<String, String>> skillCatalog = new ArrayList<>();
for (SkillEntity s : skills) {
if (s.getName() == null || s.getName().isBlank()) continue;
boolean builtin = Boolean.TRUE.equals(s.getBuiltin());
long skillWs = s.getWorkspaceId() == null ? 1L : s.getWorkspaceId();
if (!builtin && skillWs != effectiveWs) continue;
Map<String, String> m = new LinkedHashMap<>();
m.put("name", s.getName());
m.put("description", s.getDescription() == null ? "" : s.getDescription());
skillCatalog.add(m);
if (skillCatalog.size() >= CATALOG_MAX_PER_SECTION) break;
}
// Tools: only those the binding service would accept (available == true).
List<Map<String, String>> toolCatalog = new ArrayList<>();
try {
for (AvailableToolDTO t : availableToolService.listAvailable()) {
if (t == null || !t.isAvailable() || t.getName() == null || t.getName().isBlank()) continue;
Map<String, String> m = new LinkedHashMap<>();
m.put("name", t.getName());
m.put("description", t.getDescription() == null ? "" : t.getDescription());
toolCatalog.add(m);
if (toolCatalog.size() >= CATALOG_MAX_PER_SECTION) break;
}
} catch (Exception e) {
log.warn("[AgentAuthoringTool] tool catalog lookup failed: {}", e.getMessage());
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("skills", skillCatalog);
result.put("tools", toolCatalog);
try {
return objectMapper.writeValueAsString(result);
} catch (Exception e) {
return "{\"skills\":[],\"tools\":[]}";
}
}
// ==================== helpers ====================
/**
* Resolve requested skill names to ids within reach of this agent
* (builtin skills are global; otherwise the skill must belong to the
* agent's workspace) and bind them. Returns the names actually bound;
* unresolved names are skipped with a warning so a single typo does not
* abort the whole hire.
*/
private List<String> bindSkills(AgentEntity agent, long workspaceId, List<String> requestedSkills) {
if (requestedSkills.isEmpty()) return List.of();
List<Long> ids = new ArrayList<>();
List<String> boundNames = new ArrayList<>();
for (String raw : requestedSkills) {
String skillName = raw.trim();
if (skillName.isEmpty()) continue;
List<SkillEntity> matches = skillMapper.selectList(new LambdaQueryWrapper<SkillEntity>()
.eq(SkillEntity::getName, skillName)
.eq(SkillEntity::getDeleted, 0));
SkillEntity chosen = matches.stream()
.filter(s -> {
if (Boolean.TRUE.equals(s.getBuiltin())) return true;
long ws = s.getWorkspaceId() == null ? 1L : s.getWorkspaceId();
return ws == workspaceId;
})
.findFirst()
.orElse(null);
if (chosen == null) {
log.warn("[AgentAuthoringTool] skill '{}' not resolvable for workspace {}; skipping", skillName, workspaceId);
continue;
}
ids.add(chosen.getId());
boundNames.add(chosen.getName());
}
if (ids.isEmpty()) return List.of();
try {
// Best-effort: the employee is already persisted, so a late
// binding failure (e.g. a skill row deleted between resolve and
// bind) must not throw out of the tool and strand the caller with
// an error on top of an already-created agent. The agent simply
// keeps the default capability set instead.
agentBindingService.setSkillBindings(agent.getId(), ids);
} catch (Exception e) {
log.warn("[AgentAuthoringTool] skill binding failed for agent {}; left on global defaults: {}",
agent.getId(), e.getMessage());
return List.of();
}
return boundNames;
}
/**
* Filter requested tool names through the picker (only available == true
* names are bindable) and bind them. Returns the names actually bound.
*/
private List<String> bindTools(AgentEntity agent, List<String> requestedTools) {
if (requestedTools.isEmpty()) return List.of();
Set<String> bindable;
try {
bindable = availableToolService.listAvailable().stream()
.filter(AvailableToolDTO::isAvailable)
.map(AvailableToolDTO::getName)
.collect(Collectors.toSet());
} catch (Exception e) {
log.warn("[AgentAuthoringTool] tool picker unavailable; skipping tool bind: {}", e.getMessage());
return List.of();
}
List<String> filtered = new ArrayList<>();
for (String raw : requestedTools) {
String toolName = raw == null ? "" : raw.trim();
if (toolName.isEmpty()) continue;
if (bindable.contains(toolName)) {
filtered.add(toolName);
} else {
log.warn("[AgentAuthoringTool] tool '{}' not bindable; skipping", toolName);
}
}
if (filtered.isEmpty()) return List.of();
try {
// Best-effort, same rationale as bindSkills: never throw after the
// employee has been created.
agentBindingService.setToolBindings(agent.getId(), filtered);
} catch (Exception e) {
log.warn("[AgentAuthoringTool] tool binding failed for agent {}; left on global defaults: {}",
agent.getId(), e.getMessage());
return List.of();
}
return filtered;
}
/** Parse a JSON array of strings or a comma-separated list into a name list. */
private List<String> parseNameList(String raw) {
if (raw == null || raw.isBlank()) return List.of();
String trimmed = raw.trim();
if (trimmed.startsWith("[")) {
try {
List<String> parsed = objectMapper.readValue(trimmed, new TypeReference<List<String>>() {});
return parsed == null ? List.of() : parsed;
} catch (Exception ignored) {
// Fall through to comma split the model occasionally emits a
// malformed array; a comma split still recovers most names.
}
}
List<String> out = new ArrayList<>();
for (String part : trimmed.replace("[", "").replace("]", "").split(",")) {
String p = part.trim().replaceAll("^[\"']|[\"']$", "");
if (!p.isEmpty()) out.add(p);
}
return out;
}
private static String normalizeAgentType(String agentType) {
if (agentType == null || agentType.isBlank()) return "react";
String t = agentType.trim().toLowerCase();
return "plan_execute".equals(t) ? "plan_execute" : "react";
}
private static String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s;
}
/** Best-effort numeric parse of the requester id for creator attribution. */
private static Long parseUserId(String requesterId) {
if (requesterId == null || requesterId.isBlank()) return null;
try {
return Long.parseLong(requesterId.trim());
} catch (NumberFormatException e) {
return null;
}
}
}

View File

@ -3,19 +3,33 @@ package vip.mate.agent;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.event.EventListener; import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.context.ChatOriginHolder;
import vip.mate.agent.event.AgentLifecycleEvent;
import vip.mate.agent.model.AgentEntity; import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper; import vip.mate.agent.repository.AgentMapper;
import vip.mate.exception.MateClawException; import vip.mate.exception.MateClawException;
import vip.mate.llm.chatmodel.ThinkingLevelHolder;
import vip.mate.llm.event.ModelConfigChangedEvent; import vip.mate.llm.event.ModelConfigChangedEvent;
import vip.mate.memory.MemoryProperties;
import vip.mate.memory.lifecycle.MemoryLifecycleMediator;
import vip.mate.memory.lifecycle.TurnContext;
import vip.mate.memory.service.MemoryRecallTracker; import vip.mate.memory.service.MemoryRecallTracker;
import vip.mate.workspace.conversation.model.ConversationEntity;
import vip.mate.workspace.conversation.repository.ConversationMapper;
import java.util.List; import java.util.List;
import java.time.Duration;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import java.util.function.Supplier;
/** /**
* Agent 业务服务 * Agent 业务服务
@ -33,9 +47,34 @@ public class AgentService {
private final AgentMapper agentMapper; private final AgentMapper agentMapper;
private final AgentGraphBuilder agentGraphBuilder; private final AgentGraphBuilder agentGraphBuilder;
private final MemoryRecallTracker memoryRecallTracker; private final MemoryRecallTracker memoryRecallTracker;
private final MemoryLifecycleMediator lifecycleMediator;
private final MemoryProperties memoryProperties;
private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver;
/** Read-only lookup of a conversation's pinned model. Mapper (not service)
* to keep this a leaf dependency with no risk of a bean cycle. */
private final ConversationMapper conversationMapper;
/** 运行时 Agent 实例缓存agentId -> BaseAgent */ /** Field-injected publisher for agent_lifecycle trigger events; the
private final Map<Long, BaseAgent> agentInstances = new ConcurrentHashMap<>(); * trigger module's bridge listens and forwards into ingest. */
@Autowired(required = false)
private ApplicationEventPublisher events;
/**
* C5: tracks in-flight conversations so {@link vip.mate.agent.runtime.EnvironmentEventRouter}
* can push environment-change notifications into the agent's next reasoning
* turn. Field-injected (optional) so existing test constructors of
* {@code AgentService} don't need to supply it.
*/
@Autowired(required = false)
private vip.mate.agent.runtime.RunningConversationRegistry runningConversationRegistry;
/**
* Runtime Agent instance cache. Keyed first by agentId, then by a model
* key, so a conversation that pins a non-default model gets its own graph
* variant instead of mutating the one every other conversation shares.
* The model key is {@code ""} for the Agent / global-default model.
*/
private final Map<Long, Map<String, BaseAgent>> agentInstances = new ConcurrentHashMap<>();
// ==================== CRUD ==================== // ==================== CRUD ====================
@ -48,9 +87,26 @@ public class AgentService {
* 按工作区列出 Agent * 按工作区列出 Agent
*/ */
public List<AgentEntity> listAgentsByWorkspace(Long workspaceId) { public List<AgentEntity> listAgentsByWorkspace(Long workspaceId) {
return agentMapper.selectList(new LambdaQueryWrapper<AgentEntity>() return listAgentsByWorkspace(workspaceId, null);
.eq(AgentEntity::getWorkspaceId, workspaceId) }
.orderByDesc(AgentEntity::getCreateTime));
/**
* 按工作区列出 Agent可选过滤启用状态
*
* @param enabled non-null restricts the result set to agents whose
* {@code enabled} column matches the given value.
* Pass {@code true} from chat selectors so disabled
* agents disappear from the picker; the admin
* management page passes {@code null} to keep
* disabled rows visible for re-enabling.
*/
public List<AgentEntity> listAgentsByWorkspace(Long workspaceId, Boolean enabled) {
LambdaQueryWrapper<AgentEntity> q = new LambdaQueryWrapper<AgentEntity>()
.eq(AgentEntity::getWorkspaceId, workspaceId);
if (enabled != null) {
q.eq(AgentEntity::getEnabled, enabled);
}
return agentMapper.selectList(q.orderByDesc(AgentEntity::getCreateTime));
} }
public AgentEntity getAgent(Long id) { public AgentEntity getAgent(Long id) {
@ -66,19 +122,100 @@ public class AgentService {
if (agent.getAgentType() == null) { if (agent.getAgentType() == null) {
agent.setAgentType("react"); agent.setAgentType("react");
} }
requireUniqueName(agent, null);
agentMapper.insert(agent); agentMapper.insert(agent);
publishLifecycle(agent, "spawned");
return agent; return agent;
} }
public AgentEntity updateAgent(AgentEntity agent) { public AgentEntity updateAgent(AgentEntity agent) {
// Detect enabled-flag flip so the lifecycle event reflects the
// intent rather than every metadata edit. Reading the prior row
// is cheap and gives us a clean diff source.
AgentEntity prior = agentMapper.selectById(agent.getId());
// Only re-validate uniqueness when the name actually changes
// a pure metadata edit (icon, prompt, ...) shouldn't pay the
// SELECT cost or risk a false positive against the row itself.
if (prior != null
&& agent.getName() != null
&& !agent.getName().equals(prior.getName())) {
// Workspace cannot be moved (Controller pins it to prior.workspaceId),
// so reuse it for the lookup even if the incoming DTO left it null.
if (agent.getWorkspaceId() == null) {
agent.setWorkspaceId(prior.getWorkspaceId());
}
requireUniqueName(agent, agent.getId());
}
agentMapper.updateById(agent); agentMapper.updateById(agent);
agentInstances.remove(agent.getId()); agentInstances.remove(agent.getId());
if (prior != null && prior.getEnabled() != null
&& !prior.getEnabled().equals(agent.getEnabled())) {
publishLifecycle(agent,
Boolean.TRUE.equals(agent.getEnabled()) ? "enabled" : "disabled");
}
return agent; return agent;
} }
/**
* Friendly business-code surface for the {@code (workspace_id, name)}
* unique index added in V102.
*
* <p>The wire shape is the project-wide R&lt;T&gt; envelope: HTTP status
* stays 200 (per the convention in {@code R.fail} and the axios
* interceptor in {@code mateclaw-ui/src/api/index.ts}); the 409 lives in
* the response body's {@code code} field so the front-end can branch
* without breaking on an axios error. Without this pre-check the
* duplicate save would surface as an opaque
* {@code DataIntegrityViolation} stack trace.
*
* @param excludeId when non-null, skip this row in the lookup so
* {@link #updateAgent} doesn't mistake the row for its
* own duplicate.
*/
private void requireUniqueName(AgentEntity agent, Long excludeId) {
if (agent.getName() == null || agent.getName().isBlank()) {
throw new MateClawException("err.agent.name_required", 400, "Agent 名称不能为空");
}
Long workspaceId = agent.getWorkspaceId() == null ? 1L : agent.getWorkspaceId();
LambdaQueryWrapper<AgentEntity> q = new LambdaQueryWrapper<AgentEntity>()
.eq(AgentEntity::getWorkspaceId, workspaceId)
.eq(AgentEntity::getName, agent.getName());
if (excludeId != null) {
q.ne(AgentEntity::getId, excludeId);
}
Long count = agentMapper.selectCount(q);
if (count != null && count > 0) {
throw new MateClawException("err.agent.duplicate_name", 409,
"工作区内已存在同名 Agent: " + agent.getName());
}
}
public void deleteAgent(Long id) { public void deleteAgent(Long id) {
AgentEntity prior = agentMapper.selectById(id);
agentMapper.deleteById(id); agentMapper.deleteById(id);
agentInstances.remove(id); agentInstances.remove(id);
if (prior != null) publishLifecycle(prior, "terminated");
}
/**
* Best-effort publish of an {@link AgentLifecycleEvent}. A publish
* failure must never roll back the agent CRUD that just succeeded
* the agent_lifecycle trigger surface is observability, not the
* canonical record.
*/
private void publishLifecycle(AgentEntity agent, String phase) {
if (events == null || agent == null) return;
try {
events.publishEvent(new AgentLifecycleEvent(
agent.getWorkspaceId() == null ? 0L : agent.getWorkspaceId(),
agent.getId() == null ? 0L : agent.getId(),
agent.getName(),
phase,
System.currentTimeMillis()));
} catch (Exception e) {
log.warn("[AgentService] lifecycle publish failed for agent {} ({}): {}",
agent.getId(), phase, e.getMessage());
}
} }
/** /**
@ -88,33 +225,106 @@ public class AgentService {
agentInstances.remove(agentId); agentInstances.remove(agentId);
} }
/**
* Invalidate the cached agent instance whenever one of its workspace files
* changes. The system prompt (which embeds MEMORY.md / PROFILE.md / structured
* memory) is baked into the cached instance at build time, so memory edits made
* via tools, consolidation, or cleanup would otherwise stay invisible until an
* agent config change or restart. Rebuilding on the next turn picks them up.
*/
@org.springframework.context.event.EventListener
public void onWorkspaceFileChanged(vip.mate.workspace.document.event.WorkspaceFileChangedEvent event) {
if (event.agentId() != null) {
agentInstances.remove(event.agentId());
}
}
// ==================== 运行时入口 ==================== // ==================== 运行时入口 ====================
public String chat(Long agentId, String message, String conversationId) { public String chat(Long agentId, String message, String conversationId) {
return chat(agentId, message, conversationId, ChatOrigin.EMPTY);
}
/**
* RFC-063r §2.5: preferred entry accepts the originating
* {@link ChatOrigin} so channel binding and workspace context propagate
* down to {@code @Tool} methods via Spring AI {@link org.springframework.ai.chat.model.ToolContext}.
*/
public String chat(Long agentId, String message, String conversationId, ChatOrigin origin) {
memoryRecallTracker.trackRecalls(agentId, message); memoryRecallTracker.trackRecalls(agentId, message);
BaseAgent agent = getOrBuildAgent(agentId); BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
return agent.chat(message, conversationId); ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
try {
return withLifecycleSync(agentId, message, conversationId,
(msg, convId) -> agent.chat(msg, convId));
} finally {
ChatOriginHolder.clear();
}
}
/**
* Sync chat that also captures token usage and runtime model attribution
* from the agent graph's {@code _usage_final} event. Equivalent to
* subscribing to {@link #chatStructuredStream} and joining all content
* deltas produces the same assistant text as {@link #chat} but exposes
* the usage figures so callers can persist them on the assistant message.
*
* <p>Prefer this entry over {@link #chat} for any path that writes the
* reply to {@code mate_message} (sync HTTP endpoint, voice WebSocket,
* cron task, post-approval replay); the plain {@link #chat} stays as the
* thin wrapper for fire-and-forget invocations where usage is not needed.
*/
public ChatResult chatWithUsage(Long agentId, String message, String conversationId) {
return chatWithUsage(agentId, message, conversationId, ChatOrigin.EMPTY);
}
public ChatResult chatWithUsage(Long agentId, String message, String conversationId, ChatOrigin origin) {
return collectChatResult(chatStructuredStream(agentId, message, conversationId, "", null, origin));
} }
public Flux<String> chatStream(Long agentId, String message, String conversationId) { public Flux<String> chatStream(Long agentId, String message, String conversationId) {
return chatStream(agentId, message, conversationId, ChatOrigin.EMPTY);
}
public Flux<String> chatStream(Long agentId, String message, String conversationId, ChatOrigin origin) {
memoryRecallTracker.trackRecalls(agentId, message); memoryRecallTracker.trackRecalls(agentId, message);
BaseAgent agent = getOrBuildAgent(agentId); BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
return agent.chatStream(message, conversationId); // Capture the origin into a request-scoped holder; cleared on Flux
// termination so the next reactive subscriber doesn't inherit stale state.
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
return Flux.defer(() -> {
ChatOriginHolder.set(captured);
return withLifecycleFlux(agentId, message, conversationId,
(msg, convId) -> agent.chatStream(msg, convId),
chunk -> chunk);
}).doFinally(signal -> ChatOriginHolder.clear());
} }
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId) { public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId) {
return chatStructuredStream(agentId, message, conversationId, "", null); return chatStructuredStream(agentId, message, conversationId, "", null, ChatOrigin.EMPTY);
} }
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId, public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId,
String requesterId) { String requesterId) {
return chatStructuredStream(agentId, message, conversationId, requesterId, null); return chatStructuredStream(agentId, message, conversationId, requesterId, null, ChatOrigin.EMPTY);
}
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId,
String requesterId, ChatOrigin origin) {
return chatStructuredStream(agentId, message, conversationId, requesterId, null, origin);
} }
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId, public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId,
String requesterId, String thinkingLevel) { String requesterId, String thinkingLevel) {
return chatStructuredStream(agentId, message, conversationId, requesterId, thinkingLevel,
ChatOrigin.EMPTY);
}
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId,
String requesterId, String thinkingLevel,
ChatOrigin origin) {
memoryRecallTracker.trackRecalls(agentId, message); memoryRecallTracker.trackRecalls(agentId, message);
BaseAgent agent = getOrBuildAgent(agentId); BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
// 设置请求级思考深度通过 ThreadLocal 传递到 StateGraph 执行 // 设置请求级思考深度通过 ThreadLocal 传递到 StateGraph 执行
if (thinkingLevel != null && !thinkingLevel.isBlank()) { if (thinkingLevel != null && !thinkingLevel.isBlank()) {
@ -129,22 +339,45 @@ public class AgentService {
} }
} }
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
if (agent instanceof StructuredStreamCapable capable) { if (agent instanceof StructuredStreamCapable capable) {
return capable.chatStructuredStream(message, conversationId, return Flux.defer(() -> {
requesterId != null ? requesterId : "") ChatOriginHolder.set(captured);
.doFinally(signal -> ThinkingLevelHolder.clear()); return withLifecycleFlux(agentId, message, conversationId,
(msg, convId) -> capable.chatStructuredStream(msg, convId,
requesterId != null ? requesterId : "")
.doFinally(signal -> ThinkingLevelHolder.clear()),
StreamDelta::content);
})
.doFinally(signal -> ChatOriginHolder.clear());
} }
// 降级不支持结构化流的 Agent包装为纯内容流 // 降级不支持结构化流的 Agent包装为纯内容流
ThinkingLevelHolder.clear(); ThinkingLevelHolder.clear();
return agent.chatStream(message, conversationId) return Flux.defer(() -> {
.map(chunk -> new StreamDelta(chunk, null)); ChatOriginHolder.set(captured);
return withLifecycleFlux(agentId, message, conversationId,
(msg, convId) -> agent.chatStream(msg, convId)
.map(chunk -> new StreamDelta(chunk, null)),
StreamDelta::content);
})
.doFinally(signal -> ChatOriginHolder.clear());
} }
public String execute(Long agentId, String goal, String conversationId) { public String execute(Long agentId, String goal, String conversationId) {
return execute(agentId, goal, conversationId, ChatOrigin.EMPTY);
}
public String execute(Long agentId, String goal, String conversationId, ChatOrigin origin) {
memoryRecallTracker.trackRecalls(agentId, goal); memoryRecallTracker.trackRecalls(agentId, goal);
BaseAgent agent = getOrBuildAgent(agentId); BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
return agent.execute(goal, conversationId); ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
try {
return withLifecycleSync(agentId, goal, conversationId,
(msg, convId) -> agent.execute(msg, convId));
} finally {
ChatOriginHolder.clear();
}
} }
/** /**
@ -158,9 +391,56 @@ public class AgentService {
*/ */
public String chatWithReplay(Long agentId, String userMessage, String conversationId, public String chatWithReplay(Long agentId, String userMessage, String conversationId,
String toolCallPayload) { String toolCallPayload) {
return chatWithReplay(agentId, userMessage, conversationId, toolCallPayload, ChatOrigin.EMPTY);
}
public String chatWithReplay(Long agentId, String userMessage, String conversationId,
String toolCallPayload, ChatOrigin origin) {
memoryRecallTracker.trackRecalls(agentId, userMessage); memoryRecallTracker.trackRecalls(agentId, userMessage);
BaseAgent agent = getOrBuildAgent(agentId); BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
return agent.chatWithReplay(userMessage, conversationId, toolCallPayload); ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
try {
return withLifecycleSync(agentId, userMessage, conversationId,
(msg, convId) -> agent.chatWithReplay(msg, convId, toolCallPayload));
} finally {
ChatOriginHolder.clear();
}
}
/**
* Replay-after-approval that also captures token usage and runtime model
* attribution. Mirrors {@link #chatWithUsage} for the
* approval-resumption path used by {@code ChannelMessageRouter}.
*/
public ChatResult chatWithReplayWithUsage(Long agentId, String userMessage, String conversationId,
String toolCallPayload, ChatOrigin origin) {
return collectChatResult(chatWithReplayStream(agentId, userMessage, conversationId,
toolCallPayload, "", origin != null ? origin : ChatOrigin.EMPTY));
}
/**
* Subscribe to a structured stream and collapse it into a single
* {@link ChatResult}: append all content deltas, capture the trailing
* {@code _usage_final} event for token and model attribution.
*/
private ChatResult collectChatResult(Flux<StreamDelta> stream) {
StringBuilder content = new StringBuilder();
final int[] usage = {0, 0};
final String[] modelInfo = {null, null};
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();
Object model = data.get("runtimeModelName");
Object provider = data.get("runtimeProviderId");
if (model != null) modelInfo[0] = model.toString();
if (provider != null) modelInfo[1] = provider.toString();
} else if (delta.content() != null) {
content.append(delta.content());
}
}).blockLast(Duration.ofMinutes(10));
return new ChatResult(content.toString(), usage[0], usage[1], modelInfo[0], modelInfo[1]);
} }
/** /**
@ -168,20 +448,46 @@ public class AgentService {
*/ */
public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId, public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId,
String toolCallPayload) { String toolCallPayload) {
return chatWithReplayStream(agentId, userMessage, conversationId, toolCallPayload, ""); return chatWithReplayStream(agentId, userMessage, conversationId, toolCallPayload, "", ChatOrigin.EMPTY);
} }
public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId, public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId,
String toolCallPayload, String requesterId) { String toolCallPayload, String requesterId) {
return chatWithReplayStream(agentId, userMessage, conversationId, toolCallPayload, requesterId,
ChatOrigin.EMPTY);
}
public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId,
String toolCallPayload, String requesterId,
ChatOrigin origin) {
memoryRecallTracker.trackRecalls(agentId, userMessage); memoryRecallTracker.trackRecalls(agentId, userMessage);
BaseAgent agent = getOrBuildAgent(agentId); BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
return agent.chatWithReplayStream(userMessage, conversationId, toolCallPayload, ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
requesterId != null ? requesterId : ""); return Flux.defer(() -> {
ChatOriginHolder.set(captured);
return withLifecycleFlux(agentId, userMessage, conversationId,
(msg, convId) -> agent.chatWithReplayStream(msg, convId, toolCallPayload,
requesterId != null ? requesterId : ""),
StreamDelta::content);
})
.doFinally(signal -> ChatOriginHolder.clear());
} }
public AgentState getAgentState(Long agentId) { public AgentState getAgentState(Long agentId) {
BaseAgent agent = agentInstances.get(agentId); Map<String, BaseAgent> variants = agentInstances.get(agentId);
return agent != null ? agent.getState() : AgentState.IDLE; if (variants == null || variants.isEmpty()) {
return AgentState.IDLE;
}
// An Agent may have several cached graph variants (one per pinned
// model). Report the first non-IDLE state so a turn running on any
// variant stays visible.
for (BaseAgent agent : variants.values()) {
AgentState state = agent.getState();
if (state != AgentState.IDLE) {
return state;
}
}
return AgentState.IDLE;
} }
// ==================== 缓存管理 ==================== // ==================== 缓存管理 ====================
@ -208,38 +514,252 @@ public class AgentService {
log.info("Agent caches refreshed after tool guard config change (denied tools may have changed)"); log.info("Agent caches refreshed after tool guard config change (denied tools may have changed)");
} }
/**
* Issue #289: an MCP server connecting / disconnecting / reconnecting
* changes the live tool set, but cached agents snapshot their tools at
* build time. Clear the cache so the next turn rebuilds against the
* current MCP tools instead of replying "from memory" with a stale,
* tool-less graph.
*/
@EventListener
public void onMcpServerChanged(vip.mate.tool.mcp.event.McpServerChangedEvent event) {
refreshAllAgents();
log.info("Agent caches refreshed after MCP server change: {}", event.reason());
}
/**
* Listen for MCP connection-loss events and clear the agent cache.
*
* <p>Previously this listener was intentionally omitted (the design
* doc said "only listen to McpServerChangedEvent, not
* McpConnectionLostEvent") because {@link McpServerService} auto-heals
* and publishes McpServerChangedEvent on reconnect. However, between
* disconnect and reconnect, cached agents still hold the old
* {@code AgentToolSet} snapshot whose MCP tool callbacks point at a
* dead client calls either time out (5 min default) or throw.
*
* <p>Clearing the cache on disconnect ensures the next agent build
* sees the live connection state: {@link McpClientManager} will
* either skip the dead server or fall back to {@code lastGoodCallbacks}
* with proper error handling, rather than letting the LLM discover
* the breakage by timing out.
*
* <p>Cost is low: {@code McpServerService} already debounces reconnect
* attempts by 10s, and {@code refreshAllAgents} is a Map.clear().
* The subsequent reconnect will fire another McpServerChangedEvent,
* which clears the cache again at most two clears per disconnect
* cycle, which is acceptable.
*/
@EventListener
public void onMcpConnectionLost(vip.mate.tool.mcp.event.McpConnectionLostEvent event) {
refreshAllAgents();
log.warn("Agent caches refreshed after MCP connection lost: serverId={}, reason={}",
event.serverId(), event.reason());
}
// ==================== Lifecycle helpers ====================
/**
* Wraps a synchronous agent call with lifecycle mediator hooks.
* When lifecycleMediatorEnabled is off, runs plainInvoke directly (Phase 0 behavior).
*
* P1-1 fix: prefetchAll result is now prepended to userMessage as &lt;memory-context&gt; block.
* P1-4 fix: N/A for sync (no cancel/error signal issue).
*/
private String withLifecycleSync(Long agentId, String message, String conversationId,
java.util.function.BiFunction<String, String, String> invoke) {
safeRegister(conversationId, agentId);
try {
if (!memoryProperties.isLifecycleMediatorEnabled()) {
return invoke.apply(message, conversationId);
}
String ownerKey = memoryOwnerResolver.resolve(ChatOriginHolder.get());
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message, ownerKey);
String memoryContext = lifecycleMediator.beforeLlmCall(ctx);
// Inject memory context into the user message (RFC-037 §3.3)
String enrichedMessage = injectMemoryContext(message, memoryContext);
String result = invoke.apply(enrichedMessage, conversationId);
lifecycleMediator.afterLlmCall(ctx, result != null ? result : "");
return result;
} finally {
safeUnregister(conversationId);
}
}
/**
* Wraps a streaming agent call with lifecycle mediator hooks.
* When lifecycleMediatorEnabled is off, runs plainInvoke directly (Phase 0 behavior).
*
* P1-1 fix: prefetchAll result is now prepended to userMessage.
* P1-4 fix: afterLlmCall only fires on COMPLETE signal, not on cancel/error.
*/
private <T> Flux<T> withLifecycleFlux(Long agentId, String message, String conversationId,
java.util.function.BiFunction<String, String, Flux<T>> invoke,
Function<T, String> contentExtractor) {
safeRegister(conversationId, agentId);
try {
if (!memoryProperties.isLifecycleMediatorEnabled()) {
return invoke.apply(message, conversationId)
.doFinally(s -> safeUnregister(conversationId));
}
String ownerKey = memoryOwnerResolver.resolve(ChatOriginHolder.get());
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message, ownerKey);
String memoryContext = lifecycleMediator.beforeLlmCall(ctx);
String enrichedMessage = injectMemoryContext(message, memoryContext);
StringBuilder reply = new StringBuilder();
return invoke.apply(enrichedMessage, conversationId)
.doOnNext(item -> {
String text = contentExtractor.apply(item);
if (text != null) {
reply.append(text);
}
})
.doOnComplete(() -> lifecycleMediator.afterLlmCall(ctx, reply.toString()))
.doOnError(e -> log.debug("[Memory] Stream error, skipping afterLlmCall: {}", e.getMessage()))
.doFinally(s -> safeUnregister(conversationId));
} catch (Exception e) {
// If invoke.apply() throws before the Flux is constructed, the
// doFinally above never runs clean up here.
safeUnregister(conversationId);
throw e;
}
}
/** C5 helper — null-safe register so tests without the registry don't NPE. */
private void safeRegister(String conversationId, Long agentId) {
if (runningConversationRegistry != null) {
runningConversationRegistry.register(conversationId, agentId);
}
}
/** C5 helper — null-safe unregister so tests without the registry don't NPE. */
private void safeUnregister(String conversationId) {
if (runningConversationRegistry != null) {
runningConversationRegistry.unregister(conversationId);
}
}
/**
* Prepend memory-context block to user message if non-empty.
* Does not pollute build-time system prompt snapshot.
*/
private String injectMemoryContext(String message, String memoryContext) {
if (memoryContext == null || memoryContext.isBlank()) return message;
return memoryContext + "\n\n" + message;
}
// ==================== 内部方法 ==================== // ==================== 内部方法 ====================
private BaseAgent getOrBuildAgent(Long agentId) { /**
return agentInstances.computeIfAbsent(agentId, id -> { * Resolve (and cache) the Agent graph for a conversation, honouring the
AgentEntity entity = getAgent(id); * conversation's pinned model. Conversations with no pin IM channels
if (!Boolean.TRUE.equals(entity.getEnabled())) { * before issue #183 fix, cron, sub-tasks, or rows not yet created
throw new MateClawException("err.agent.disabled", "Agent 已禁用: " + entity.getName()); * resolve to the shared Agent / global-default graph.
*
* <p>Defensive normalisation: a half-populated pair (provider but no
* model, or vice versa) is treated as unpinned. Without this guard, a
* partially-cleared admin UI write could end up cached as a key like
* {@code "volcano::"} which {@link #getOrBuildAgent} would then try to
* build, only to fail at provider-resolution time on every turn.
*/
private BaseAgent getOrBuildAgentForConversation(Long agentId, String conversationId) {
String provider = null;
String modelName = null;
if (conversationId != null && !conversationId.isBlank()) {
ConversationEntity conv = conversationMapper.selectOne(
new LambdaQueryWrapper<ConversationEntity>()
.eq(ConversationEntity::getConversationId, conversationId));
if (conv != null) {
provider = blankToNull(conv.getModelProvider());
modelName = blankToNull(conv.getModelName());
// Half-populated pair treat as unpinned. Pinning requires
// a complete (provider, model) tuple see #183 follow-up
// hardening so a stale row written by an earlier broken
// admin UI release doesn't loop the cache on an invalid key.
if (provider == null || modelName == null) {
provider = null;
modelName = null;
}
} }
return agentGraphBuilder.build(entity); }
}); return getOrBuildAgent(agentId, provider, modelName);
}
/** Map empty / whitespace strings to null so the pinned-check is one branch. */
private static String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s;
}
private BaseAgent getOrBuildAgent(Long agentId) {
return getOrBuildAgent(agentId, null, null);
}
private BaseAgent getOrBuildAgent(Long agentId, String modelProvider, String modelName) {
boolean pinned = modelProvider != null && !modelProvider.isBlank()
&& modelName != null && !modelName.isBlank();
String modelKey = pinned ? modelProvider + "::" + modelName : "";
return agentInstances
.computeIfAbsent(agentId, id -> new ConcurrentHashMap<>())
.computeIfAbsent(modelKey, key -> {
AgentEntity entity = getAgent(agentId);
if (!Boolean.TRUE.equals(entity.getEnabled())) {
throw new MateClawException("err.agent.disabled", "Agent 已禁用: " + entity.getName());
}
return agentGraphBuilder.build(entity, modelProvider, modelName);
});
} }
// ==================== StreamDelta ==================== // ==================== StreamDelta ====================
public record StreamDelta(String content, String thinking, String eventType, Map<String, Object> eventData, boolean persistenceOnly) { public record StreamDelta(String content, String thinking, String eventType, Map<String, Object> eventData,
boolean persistenceOnly, boolean segmentOnly) {
// 兼容构造器广播+持久化 // 兼容构造器广播+持久化
public StreamDelta(String content, String thinking) { public StreamDelta(String content, String thinking) {
this(content, thinking, null, null, false); this(content, thinking, null, null, false, false);
}
// 显式 5-参构造器保留旧调用点对 (content, thinking, eventType, eventData, persistenceOnly) 的兼容
public StreamDelta(String content, String thinking, String eventType,
Map<String, Object> eventData, boolean persistenceOnly) {
this(content, thinking, eventType, eventData, persistenceOnly, false);
} }
/** 仅用于持久化,不再广播(内容已由 NodeStreamingChatHelper 实时广播过) */ /** 仅用于持久化,不再广播(内容已由 NodeStreamingChatHelper 实时广播过) */
public static StreamDelta persistOnly(String content, String thinking) { public static StreamDelta persistOnly(String content, String thinking) {
return new StreamDelta(content, thinking, null, null, true); return new StreamDelta(content, thinking, null, null, true, false);
}
/**
* Per-iteration narrative routing for ReasoningNode / SummarizingNode output.
*
* <p>The accumulator should:
* <ul>
* <li>append the text to the in-flight {@code segments} entry so the UI's
* segmented view still renders the intermediate "I'll look it up…"
* narration between tool cards;</li>
* <li>NOT broadcast already broadcast live by NodeStreamingChatHelper;</li>
* <li>NOT append to the top-level {@code content} StringBuilder, which is
* what gets persisted as {@code mate_message.content}. That field
* should hold the final-answer span only otherwise multiple
* iterations stack into "我来…让我…然后…" walls that next-turn replay
* sees as unanswered chain-of-thought (issue #120 narration leg).</li>
* </ul>
*
* <p>Implies {@code persistenceOnly} (no broadcast) at the accumulator
* layer, but is a stricter promise: <em>nothing</em> reaches the top-level
* persisted content field via this flavor.
*/
public static StreamDelta segmentOnly(String content, String thinking) {
return new StreamDelta(content, thinking, null, null, true, true);
} }
public static StreamDelta empty() { public static StreamDelta empty() {
return new StreamDelta(null, null, null, null, false); return new StreamDelta(null, null, null, null, false, false);
} }
public static StreamDelta event(String type, Map<String, Object> data) { public static StreamDelta event(String type, Map<String, Object> data) {
return new StreamDelta(null, null, type, data, false); return new StreamDelta(null, null, type, data, false, false);
} }
public boolean isEvent() { public boolean isEvent() {
@ -258,4 +778,23 @@ public class AgentService {
return thinking != null ? thinking.length() : 0; return thinking != null ? thinking.length() : 0;
} }
} }
// ==================== ChatResult ====================
/**
* Sync chat result carrying the assistant reply alongside the usage
* attribution that the streaming path exposes via the {@code _usage_final}
* event. Use this when callers need to persist {@code promptTokens} /
* {@code completionTokens} / {@code runtimeModel} / {@code runtimeProvider}
* on the assistant message row but cannot subscribe to the structured
* stream directly (cron tasks, sync HTTP endpoints, voice WebSocket,
* post-approval replays).
*/
public record ChatResult(String content, int promptTokens, int completionTokens,
String runtimeModel, String runtimeProvider) {
public static ChatResult contentOnly(String content) {
return new ChatResult(content != null ? content : "", 0, 0, null, null);
}
}
} }

View File

@ -5,8 +5,8 @@ import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbackProvider; import org.springframework.ai.tool.ToolCallbackProvider;
import java.util.*; import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.LinkedHashMap;
/** /**
* Agent 统一工具集合 * Agent 统一工具集合
@ -14,6 +14,20 @@ import java.util.LinkedHashMap;
* @Tool BeanToolCallbackProviderMCP server 暴露的 tool callbacks * @Tool BeanToolCallbackProviderMCP server 暴露的 tool callbacks
* 统一收集为一致的 ToolCallback 列表 StateGraph 节点使用 * 统一收集为一致的 ToolCallback 列表 StateGraph 节点使用
* *
* <h3>Alias index why one tool has multiple names</h3>
* Each tool can be referenced by several equivalent identifiers:
* <ul>
* <li>{@code @Tool} function name (the runtime truth: {@code cb.getToolDefinition().name()},
* e.g. {@code browser_use})</li>
* <li>Spring bean name (e.g. {@code browserUseTool})</li>
* <li>Java class simple name (e.g. {@code BrowserUseTool} what the seed data and
* legacy {@code mate_agent_tool.tool_name} bindings happen to store)</li>
* </ul>
* Filtering operations ({@link #withAllowedToolsOnly}, {@link #withDeniedToolsFiltered},
* {@link #excluding}) accept any of these aliases, so callers don't need to know which
* naming convention the persistence layer happens to use. This is the same pattern Spring's
* {@code BeanFactory} uses for bean names + aliases.
*
* @author MateClaw Team * @author MateClaw Team
*/ */
public class AgentToolSet { public class AgentToolSet {
@ -21,26 +35,69 @@ public class AgentToolSet {
private final List<Object> toolBeans; private final List<Object> toolBeans;
private final List<ToolCallback> callbacks; private final List<ToolCallback> callbacks;
private final Map<String, ToolCallback> callbackByName; private final Map<String, ToolCallback> callbackByName;
/**
* Alias callbacks. One alias may resolve to multiple callbacks
* (e.g. a Spring bean name pointing at a class that exposes several {@code @Tool} methods),
* which is why values are sets.
*/
private final Map<String, Set<ToolCallback>> aliasIndex;
private AgentToolSet(List<Object> toolBeans, List<ToolCallback> callbacks) { private AgentToolSet(List<Object> toolBeans, List<ToolCallback> callbacks,
Function<Object, String> beanNameResolver) {
this.toolBeans = List.copyOf(toolBeans); this.toolBeans = List.copyOf(toolBeans);
// 按工具名去重内置工具在前先添加MCP 工具在后同名时保留内置工具 // 按工具名去重内置工具在前先添加MCP 工具在后同名时保留内置工具
// 使用 LinkedHashMap 保证插入顺序确保内置工具始终排在 MCP 工具前面影响 LLM 工具选择倾向 // 使用 LinkedHashMap 保证插入顺序确保内置工具始终排在 MCP 工具前面影响 LLM 工具选择倾向
this.callbackByName = callbacks.stream() LinkedHashMap<String, ToolCallback> byName = callbacks.stream()
.collect(Collectors.toMap( .collect(Collectors.toMap(
cb -> cb.getToolDefinition().name(), cb -> cb.getToolDefinition().name(),
cb -> cb, cb -> cb,
(a, b) -> a, (a, b) -> a,
LinkedHashMap::new)); LinkedHashMap::new));
this.callbackByName = byName;
// callbacks 列表也使用去重后的结果避免 Spring AI ToolCallingChatOptions 校验重名报错 // callbacks 列表也使用去重后的结果避免 Spring AI ToolCallingChatOptions 校验重名报错
this.callbacks = List.copyOf(callbackByName.values()); this.callbacks = List.copyOf(byName.values());
this.aliasIndex = buildAliasIndex(this.toolBeans, byName, beanNameResolver);
} }
/**
* Internal constructor for {@link #rebuild} preserves a pre-filtered alias index
* so we don't need {@code beanNameResolver} on every {@code with*} call.
*/
private AgentToolSet(List<Object> toolBeans, List<ToolCallback> callbacks,
Map<String, Set<ToolCallback>> precomputedAliasIndex) {
this.toolBeans = List.copyOf(toolBeans);
LinkedHashMap<String, ToolCallback> byName = callbacks.stream()
.collect(Collectors.toMap(
cb -> cb.getToolDefinition().name(),
cb -> cb,
(a, b) -> a,
LinkedHashMap::new));
this.callbackByName = byName;
this.callbacks = List.copyOf(byName.values());
this.aliasIndex = Map.copyOf(precomputedAliasIndex);
}
/** No-op resolver for callers that don't have access to Spring bean names. */
private static final Function<Object, String> NO_BEAN_NAMES = bean -> null;
/** /**
* 从预构建的 ToolCallback 列表构建工具集用于 i18n 等需要包装 callback 的场景 * 从预构建的 ToolCallback 列表构建工具集用于 i18n 等需要包装 callback 的场景
*/ */
public static AgentToolSet fromCallbacks(List<Object> toolBeans, List<ToolCallback> callbacks) { public static AgentToolSet fromCallbacks(List<Object> toolBeans, List<ToolCallback> callbacks) {
return new AgentToolSet(toolBeans != null ? toolBeans : List.of(), callbacks); return new AgentToolSet(toolBeans != null ? toolBeans : List.of(), callbacks, NO_BEAN_NAMES);
}
/**
* Same as {@link #fromCallbacks(List, List)} but additionally indexes each tool bean by
* its Spring bean name and Java simple class name, so {@link #withAllowedToolsOnly} accepts
* any of those identifiers (in addition to the {@code @Tool} function name).
*
* @param beanNameResolver lookup from a tool bean instance to its Spring bean name;
* may return {@code null} if the bean has no registered name
*/
public static AgentToolSet fromCallbacks(List<Object> toolBeans, List<ToolCallback> callbacks,
Function<Object, String> beanNameResolver) {
return new AgentToolSet(toolBeans != null ? toolBeans : List.of(), callbacks, beanNameResolver);
} }
/** /**
@ -67,36 +124,45 @@ public class AgentToolSet {
} }
} }
return new AgentToolSet(toolBeans != null ? toolBeans : List.of(), allCallbacks); return new AgentToolSet(toolBeans != null ? toolBeans : List.of(), allCallbacks, NO_BEAN_NAMES);
} }
/** /**
* 过滤掉 denied 工具后返回新的 AgentToolSet * 过滤掉 denied 工具后返回新的 AgentToolSet
* denied 工具不会暴露给模型模型完全不知道它们的存在 * denied 工具不会暴露给模型模型完全不知道它们的存在
* *
* @param deniedTools denied 工具名集合为空或 null 时直接返回 this * @param deniedTools denied 工具名集合接受 function name / bean name / class simple name
* 为空或 null 时直接返回 this
*/ */
public AgentToolSet withDeniedToolsFiltered(Set<String> deniedTools) { public AgentToolSet withDeniedToolsFiltered(Set<String> deniedTools) {
if (deniedTools == null || deniedTools.isEmpty()) { if (deniedTools == null || deniedTools.isEmpty()) {
return this; return this;
} }
List<ToolCallback> filtered = new ArrayList<>(callbacks); Set<ToolCallback> denied = resolveAliases(deniedTools);
filtered.removeIf(cb -> deniedTools.contains(cb.getToolDefinition().name())); if (denied.isEmpty()) {
return new AgentToolSet(toolBeans, filtered); return this;
}
List<ToolCallback> filtered = callbacks.stream()
.filter(cb -> !denied.contains(cb))
.toList();
return rebuild(filtered);
} }
/** /**
* 仅保留指定名称的工具白名单模式用于 per-agent 绑定 * 仅保留指定名称的工具白名单模式用于 per-agent 绑定
* *
* @param allowedTools 允许的工具名集合 null 时直接返回 this表示使用全局默认 * @param allowedTools 允许的工具名集合接受 function name / Spring bean name / Java class simple name
* null 时直接返回 this表示使用全局默认
*/ */
public AgentToolSet withAllowedToolsOnly(Set<String> allowedTools) { public AgentToolSet withAllowedToolsOnly(Set<String> allowedTools) {
if (allowedTools == null) { if (allowedTools == null) {
return this; // null = 无绑定使用全局默认 return this; // null = 无绑定使用全局默认
} }
List<ToolCallback> filtered = new ArrayList<>(callbacks); Set<ToolCallback> allowed = resolveAliases(allowedTools);
filtered.removeIf(cb -> !allowedTools.contains(cb.getToolDefinition().name())); List<ToolCallback> filtered = callbacks.stream()
return new AgentToolSet(toolBeans, filtered); .filter(allowed::contains)
.toList();
return rebuild(filtered);
} }
/** /**
@ -122,15 +188,21 @@ public class AgentToolSet {
/** /**
* 返回排除指定工具名后的新 AgentToolSet * 返回排除指定工具名后的新 AgentToolSet
*
* @param toolNames 要排除的工具名集合接受 function name / bean name / class simple name
*/ */
public AgentToolSet excluding(Set<String> toolNames) { public AgentToolSet excluding(Set<String> toolNames) {
if (toolNames == null || toolNames.isEmpty()) { if (toolNames == null || toolNames.isEmpty()) {
return this; return this;
} }
Set<ToolCallback> excluded = resolveAliases(toolNames);
if (excluded.isEmpty()) {
return this;
}
List<ToolCallback> filtered = callbacks.stream() List<ToolCallback> filtered = callbacks.stream()
.filter(cb -> !toolNames.contains(cb.getToolDefinition().name())) .filter(cb -> !excluded.contains(cb))
.toList(); .toList();
return new AgentToolSet(toolBeans, filtered); return rebuild(filtered);
} }
/** /**
@ -146,4 +218,123 @@ public class AgentToolSet {
public int size() { public int size() {
return callbacks.size(); return callbacks.size();
} }
/**
* Resolve a mix of aliases (function name / Spring bean name / Java class simple name)
* to the {@code @Tool} function names they map to. Used to bridge persistence layers
* that key a tool by its class or bean name (e.g. {@code mate_tool.name}) onto the
* runtime callback name ({@code cb.getToolDefinition().name()}). Unknown aliases yield
* nothing.
*/
public Set<String> functionNamesFor(Set<String> aliases) {
if (aliases == null || aliases.isEmpty()) {
return Set.of();
}
return resolveAliases(aliases).stream()
.map(cb -> cb.getToolDefinition().name())
.collect(Collectors.toCollection(LinkedHashSet::new));
}
// ==================== Internals ====================
/**
* Resolve a set of aliases (any mix of function name / bean name / class simple name)
* into the set of {@link ToolCallback} instances they refer to. Unknown aliases are
* silently dropped the caller is expected to be tolerant of stale persistence data.
*/
private Set<ToolCallback> resolveAliases(Set<String> aliases) {
Set<ToolCallback> resolved = new LinkedHashSet<>();
for (String alias : aliases) {
Set<ToolCallback> hits = aliasIndex.get(alias);
if (hits != null) {
resolved.addAll(hits);
}
}
return resolved;
}
/**
* Reconstruct a new {@code AgentToolSet} after filtering callbacks, carrying forward
* only the alias entries whose targets survived. This avoids re-running
* {@link ToolCallbacks#from(Object)} reflection on every {@code with*} call.
*/
private AgentToolSet rebuild(List<ToolCallback> filteredCallbacks) {
Set<ToolCallback> survivors = new HashSet<>(filteredCallbacks);
Map<String, Set<ToolCallback>> filteredAliases = new LinkedHashMap<>();
for (Map.Entry<String, Set<ToolCallback>> e : aliasIndex.entrySet()) {
Set<ToolCallback> kept = new LinkedHashSet<>();
for (ToolCallback cb : e.getValue()) {
if (survivors.contains(cb)) {
kept.add(cb);
}
}
if (!kept.isEmpty()) {
filteredAliases.put(e.getKey(), Set.copyOf(kept));
}
}
return new AgentToolSet(toolBeans, filteredCallbacks, filteredAliases);
}
/**
* Build the alias index. Function names are always indexed (they are the runtime truth);
* bean names and class simple names are indexed when {@code beanNameResolver} is provided
* typically only the production registry has the {@link org.springframework.context.ApplicationContext}
* needed to map bean instances to names. Unit tests that pass empty {@code toolBeans}
* naturally get a function-name-only index.
*/
private static Map<String, Set<ToolCallback>> buildAliasIndex(
List<Object> toolBeans,
Map<String, ToolCallback> callbackByName,
Function<Object, String> beanNameResolver) {
Map<String, Set<ToolCallback>> aliases = new LinkedHashMap<>();
// 1. Always index by function name (the runtime identifier)
for (Map.Entry<String, ToolCallback> e : callbackByName.entrySet()) {
aliases.computeIfAbsent(e.getKey(), k -> new LinkedHashSet<>()).add(e.getValue());
}
// 2. If we have bean info, also index by Spring bean name and Java class simple name.
// A single bean may expose multiple @Tool methods the alias maps to a set.
if (beanNameResolver != null) {
for (Object bean : toolBeans) {
String beanName = beanNameResolver.apply(bean);
String simpleName = bean.getClass().getSimpleName();
// Find which callbacks belong to this bean, looking them up in the
// (possibly i18n-wrapped) callbackByName so we point at the same
// instances the rest of the set uses.
Set<ToolCallback> beanCallbacks = new LinkedHashSet<>();
ToolCallback[] rawCallbacks;
try {
rawCallbacks = ToolCallbacks.from(bean);
} catch (Exception ignored) {
// Defensive: a misbehaving bean shouldn't break the whole tool set
continue;
}
for (ToolCallback raw : rawCallbacks) {
ToolCallback wrapped = callbackByName.get(raw.getToolDefinition().name());
if (wrapped != null) {
beanCallbacks.add(wrapped);
}
}
if (beanCallbacks.isEmpty()) {
continue;
}
if (beanName != null && !beanName.isBlank()) {
aliases.computeIfAbsent(beanName, k -> new LinkedHashSet<>()).addAll(beanCallbacks);
}
if (simpleName != null && !simpleName.isBlank()) {
aliases.computeIfAbsent(simpleName, k -> new LinkedHashSet<>()).addAll(beanCallbacks);
}
}
}
// Freeze inner sets
Map<String, Set<ToolCallback>> frozen = new LinkedHashMap<>();
for (Map.Entry<String, Set<ToolCallback>> e : aliases.entrySet()) {
frozen.put(e.getKey(), Set.copyOf(e.getValue()));
}
return Map.copyOf(frozen);
}
} }

File diff suppressed because it is too large Load Diff

View File

@ -27,6 +27,48 @@ public final class GraphEventPublisher {
public static final String EVENT_STEP_STARTED = "plan_step_started"; public static final String EVENT_STEP_STARTED = "plan_step_started";
public static final String EVENT_STEP_COMPLETED = "plan_step_completed"; public static final String EVENT_STEP_COMPLETED = "plan_step_completed";
public static final String EVENT_TOOL_APPROVAL_REQUESTED = "tool_approval_requested"; public static final String EVENT_TOOL_APPROVAL_REQUESTED = "tool_approval_requested";
/** RFC-06 D-6: lightweight performance summary emitted per-phase. */
public static final String EVENT_PERF_SUMMARY = "perf_summary";
/**
* RFC-052: a tool with returnDirect=true completed; its full result is
* carried in the payload and is intended to be rendered as part of the
* assistant message (renderAs=assistant_message), bypassing the LLM.
*/
public static final String EVENT_TOOL_DIRECT_RESULT = "tool_direct_result";
/**
* Terminal {@link vip.mate.agent.graph.state.FinishReason} for the turn,
* emitted at FinalAnswerNode so channel-side accumulators can persist it
* into message metadata. Downstream filters (e.g. memory promotion gate)
* branch on this structured value instead of doing brittle text matching
* on the assistant content.
*/
public static final String EVENT_FINISH_REASON = "finish_reason";
/**
* User-facing recovery affordances offered after a turn ends in a
* non-transient error. Carries the error type + message + a
* data-driven list of actions ({@code retry}, {@code regenerate},
* {@code report}) so the frontend can render the right buttons
* without hard-coding which categories deserve which actions.
*
* <p>Sibling to {@link #EVENT_FINISH_REASON} (which only carries the
* machine-readable reason). The two are kept separate so legacy
* consumers of {@code finish_reason} don't have to learn a new
* payload shape and so a future graph branch (e.g. evidence-
* insufficient "rerun with the listed files attached") can emit
* feedback affordances without abusing the finish_reason channel.
*/
public static final String EVENT_FEEDBACK = "feedback_event";
/**
* Multimodal sidecar routing decision for the current turn. Emitted once
* per turn before the graph starts streaming; the channel-side accumulator
* stores it under {@code metadata.routing} so the chat UI can show which
* sidecar (if any) was invoked. Underscore-prefixed name keeps it out of
* IM channel rebroadcast (see {@code ChannelMessageRouter}).
*/
public static final String EVENT_ROUTING_DECISION = "_routing_decision";
/** /**
* 事件记录 * 事件记录
@ -44,8 +86,23 @@ public final class GraphEventPublisher {
} }
public static GraphEvent toolStart(String toolName, String arguments) { public static GraphEvent toolStart(String toolName, String arguments) {
return toolStart(null, toolName, arguments);
}
/**
* Emit a tool_call_started event with the LLM-provided tool_call.id so the
* frontend can match start/complete pairs precisely. Without the id, the
* UI uses toolName + status="running" + findLast() to pair completes back
* to the original card; when the LLM fires multiple calls of the same tool
* (e.g. several execute_shell_command in a row) the matching collapses to
* "the most recent running" and earlier cards get stranded with a
* permanent spinner. Pass the id whenever it's available; null is OK for
* legacy callers.
*/
public static GraphEvent toolStart(String toolCallId, String toolName, String arguments) {
long ts = System.currentTimeMillis(); long ts = System.currentTimeMillis();
return new GraphEvent(EVENT_TOOL_START, Map.of( return new GraphEvent(EVENT_TOOL_START, Map.of(
"toolCallId", toolCallId != null ? toolCallId : "",
"toolName", toolName, "toolName", toolName,
"arguments", arguments != null ? arguments : "", "arguments", arguments != null ? arguments : "",
"timestamp", ts "timestamp", ts
@ -53,10 +110,20 @@ public final class GraphEventPublisher {
} }
public static GraphEvent toolComplete(String toolName, String result, boolean success) { public static GraphEvent toolComplete(String toolName, String result, boolean success) {
return toolComplete(null, toolName, result, success);
}
public static GraphEvent toolComplete(String toolCallId, String toolName, String result, boolean success) {
long ts = System.currentTimeMillis(); long ts = System.currentTimeMillis();
// Carry the full tool result; transport-layer chunking lives in
// ChatStreamTracker.broadcastChunked, which splits oversize payloads
// into ordered tool_result_chunk events when they exceed the 8 KB
// single-event budget. The previous unconditional 500-char truncation
// here destroyed data that the front-end could otherwise render in full.
return new GraphEvent(EVENT_TOOL_COMPLETE, Map.of( return new GraphEvent(EVENT_TOOL_COMPLETE, Map.of(
"toolCallId", toolCallId != null ? toolCallId : "",
"toolName", toolName, "toolName", toolName,
"result", result != null ? truncateResult(result) : "", "result", result != null ? result : "",
"success", success, "success", success,
"timestamp", ts "timestamp", ts
), ts); ), ts);
@ -82,9 +149,11 @@ public final class GraphEventPublisher {
public static GraphEvent stepCompleted(int index, String result) { public static GraphEvent stepCompleted(int index, String result) {
long ts = System.currentTimeMillis(); long ts = System.currentTimeMillis();
// Full step result; broadcastChunked splits at the transport layer
// when the payload exceeds the per-event size budget.
return new GraphEvent(EVENT_STEP_COMPLETED, Map.of( return new GraphEvent(EVENT_STEP_COMPLETED, Map.of(
"index", index, "index", index,
"result", result != null ? truncateResult(result) : "", "result", result != null ? result : "",
"timestamp", ts "timestamp", ts
), ts); ), ts);
} }
@ -95,7 +164,7 @@ public final class GraphEventPublisher {
return new GraphEvent(EVENT_TOOL_APPROVAL_REQUESTED, Map.of( return new GraphEvent(EVENT_TOOL_APPROVAL_REQUESTED, Map.of(
"pendingId", pendingId, "pendingId", pendingId,
"toolName", toolName != null ? toolName : "", "toolName", toolName != null ? toolName : "",
"arguments", arguments != null ? truncateResult(arguments) : "", "arguments", arguments != null ? arguments : "",
"reason", reason != null ? reason : "", "reason", reason != null ? reason : "",
"timestamp", ts "timestamp", ts
), ts); ), ts);
@ -112,7 +181,7 @@ public final class GraphEventPublisher {
java.util.Map<String, Object> data = new java.util.LinkedHashMap<>(); java.util.Map<String, Object> data = new java.util.LinkedHashMap<>();
data.put("pendingId", pendingId); data.put("pendingId", pendingId);
data.put("toolName", toolName != null ? toolName : ""); data.put("toolName", toolName != null ? toolName : "");
data.put("arguments", arguments != null ? truncateForBroadcast(arguments) : ""); data.put("arguments", arguments != null ? arguments : "");
data.put("reason", reason != null ? reason : ""); data.put("reason", reason != null ? reason : "");
data.put("summary", summary); data.put("summary", summary);
data.put("maxSeverity", maxSeverity); data.put("maxSeverity", maxSeverity);
@ -121,6 +190,82 @@ public final class GraphEventPublisher {
return new GraphEvent(EVENT_TOOL_APPROVAL_REQUESTED, Map.copyOf(data), ts); return new GraphEvent(EVENT_TOOL_APPROVAL_REQUESTED, Map.copyOf(data), ts);
} }
/**
* RFC-06 D-6: emit a lightweight performance summary for a phase.
* Consumers (dashboard, audit, _usage_final) can aggregate these
* to reconstruct per-turn latency profiles without full tracing.
*
* @param phase e.g. "triage", "reasoning", "tool_execution"
* @param metrics arbitrary key-value pairs (e.g. "retry_count", "backoff_wait_ms")
*/
/**
* RFC-052: emit a tool result that was produced by a returnDirect tool.
* The full text is carried verbatim and the {@code renderAs="assistant_message"}
* hint instructs the SSE consumer (front-end / accumulator) to fold the
* payload into the assistant bubble rather than into a tool card.
*/
public static GraphEvent toolDirectResult(String toolCallId, String toolName, String fullResult) {
long ts = System.currentTimeMillis();
Map<String, Object> data = new java.util.LinkedHashMap<>();
data.put("toolCallId", toolCallId != null ? toolCallId : "");
data.put("toolName", toolName != null ? toolName : "");
data.put("result", fullResult != null ? fullResult : "");
data.put("renderAs", "assistant_message");
data.put("timestamp", ts);
return new GraphEvent(EVENT_TOOL_DIRECT_RESULT, Map.copyOf(data), ts);
}
public static GraphEvent perfSummary(String phase, Map<String, Object> metrics) {
long ts = System.currentTimeMillis();
Map<String, Object> data = new java.util.HashMap<>(metrics);
data.put("phase", phase);
data.put("timestamp", ts);
return new GraphEvent(EVENT_PERF_SUMMARY, Map.copyOf(data), ts);
}
/**
* Terminal {@code finish_reason} event. Emitted from FinalAnswerNode so it
* rides through the same PENDING_EVENTS StreamDelta pipeline that
* channel-side accumulators consume a sibling SSE-only broadcast would
* bypass {@code ChatController.StreamAccumulator.accept(...)} and fail to
* persist the reason into message metadata.
*
* @param reason {@link vip.mate.agent.graph.state.FinishReason#getValue()}
* (e.g. {@code "incomplete"}, {@code "stopped"},
* {@code "evidence_insufficient"}, {@code "normal"}).
*/
public static GraphEvent finishReason(String reason) {
long ts = System.currentTimeMillis();
return new GraphEvent(EVENT_FINISH_REASON, Map.of(
"reason", reason != null ? reason : "",
"timestamp", ts
), ts);
}
/**
* Emit a recovery-affordance event for the frontend. {@code errorType}
* mirrors the {@code NodeStreamingChatHelper.ErrorType} value (e.g.
* {@code AUTH_ERROR}, {@code BILLING}, {@code MODEL_NOT_FOUND}, or
* the generic {@code UNKNOWN}); {@code errorMessage} is the
* user-friendly text already displayed in the bubble; {@code actions}
* is the ordered list of buttons to render. Default offering is the
* standard {@code retry / regenerate / report} triad call sites
* can narrow this if a category has limitations (e.g. AUTH_ERROR
* shouldn't offer "retry" until the key is fixed).
*/
public static GraphEvent feedback(String errorType, String errorMessage,
java.util.List<String> actions) {
long ts = System.currentTimeMillis();
return new GraphEvent(EVENT_FEEDBACK, Map.of(
"errorType", errorType != null ? errorType : "",
"errorMessage", errorMessage != null ? errorMessage : "",
"actions", actions != null && !actions.isEmpty()
? actions
: java.util.List.of("retry", "regenerate", "report"),
"timestamp", ts
), ts);
}
// ===== 提取方法 ===== // ===== 提取方法 =====
/** /**
@ -135,14 +280,111 @@ public final class GraphEventPublisher {
.orElse(List.of()); .orElse(List.of());
} }
/**
* Pass-through; preserved for source/binary compatibility with older callers.
* Truncation at the SSE transport layer is now handled by {@code
* ChatStreamTracker.broadcastChunked} which splits oversize payloads into
* ordered chunk events instead of dropping bytes. Logs a one-time
* deprecation hint when invoked.
*
* @deprecated callers should pass full payloads and let the transport layer
* decide whether to chunk.
*/
@Deprecated
private static String truncateResult(String result) { private static String truncateResult(String result) {
return result.length() > 500 ? result.substring(0, 500) + "..." : result; warnTruncateDeprecation();
return result;
} }
/** /**
* 截断字符串用于直推广播公共方法 Node 直接构造广播数据时使用 * Pass-through, kept for source compatibility with code that built broadcast
* payloads directly. Same deprecation reason as {@link #truncateResult}.
*
* @deprecated callers should pass full payloads.
*/ */
@Deprecated
public static String truncateForBroadcast(String text) { public static String truncateForBroadcast(String text) {
return truncateResult(text); warnTruncateDeprecation();
return text;
}
private static final java.util.concurrent.atomic.AtomicBoolean TRUNCATE_WARNED =
new java.util.concurrent.atomic.AtomicBoolean(false);
private static void warnTruncateDeprecation() {
if (TRUNCATE_WARNED.compareAndSet(false, true)) {
org.slf4j.LoggerFactory.getLogger(GraphEventPublisher.class)
.warn("GraphEventPublisher.truncateResult/truncateForBroadcast are deprecated " +
"no-op pass-throughs; payloads are no longer truncated here. " +
"Move callers to send the full string and rely on " +
"ChatStreamTracker.broadcastChunked for transport-level chunking.");
}
}
// ===== Iteration lifecycle events =====
public static final String EVENT_ITERATION_START = "iteration_start";
public static final String EVENT_ITERATION_END = "iteration_end";
/**
* Marks the entry of an iteration boundary so consumers can group later
* tool / content / thinking events under a single logical step. The
* {@code scope} field distinguishes the parent agent ("parent") from a
* delegated sub-agent ("subagent"); when scope is "subagent" the
* {@code subagentId} payload field is populated by the producer.
*/
public static GraphEvent iterationStart(int index, String reason, String scope, String subagentId) {
long ts = System.currentTimeMillis();
Map<String, Object> data = new java.util.LinkedHashMap<>();
data.put("index", index);
data.put("reason", reason != null ? reason : "");
data.put("scope", scope != null ? scope : "parent");
if (subagentId != null && !subagentId.isEmpty()) {
data.put("subagentId", subagentId);
}
data.put("timestamp", ts);
return new GraphEvent(EVENT_ITERATION_START, Map.copyOf(data), ts);
}
/**
* Closes the matching {@link #iterationStart} boundary. {@code contentChars}
* and {@code thinkingChars} let consumers render a compact "this turn
* produced X content / Y thinking" header without re-aggregating the
* underlying delta events.
*/
public static GraphEvent iterationEnd(int index, String scope, String subagentId,
int contentChars, int thinkingChars) {
long ts = System.currentTimeMillis();
Map<String, Object> data = new java.util.LinkedHashMap<>();
data.put("index", index);
data.put("scope", scope != null ? scope : "parent");
if (subagentId != null && !subagentId.isEmpty()) {
data.put("subagentId", subagentId);
}
data.put("contentChars", contentChars);
data.put("thinkingChars", thinkingChars);
data.put("timestamp", ts);
return new GraphEvent(EVENT_ITERATION_END, Map.copyOf(data), ts);
}
// ===== Warning events =====
public static final String EVENT_WARNING = "warning";
/**
* A user-visible runtime warning. The stream accumulator folds
* {@code message} into the assistant message's {@code metadata.warnings}
* (persisted) and rebroadcasts the event live on SSE, so the chat UI can
* render a warning chip both during streaming and on history reload.
* {@code source} lets consumers group or filter warnings by origin
* (e.g. {@code "loop_guard"}).
*/
public static GraphEvent warning(String message, String source) {
long ts = System.currentTimeMillis();
return new GraphEvent(EVENT_WARNING, Map.of(
"message", message != null ? message : "",
"source", source != null ? source : "",
"timestamp", ts
), ts);
} }
} }

View File

@ -1,33 +0,0 @@
package vip.mate.agent;
/**
* 请求级思考深度的 ThreadLocal 持有器
* <p>
* 用于将前端选择的思考级别从 AgentService 传递到 ReasoningNode
* 避免修改 Agent 缓存实例或 StructuredStreamCapable 接口
* <p>
* 支持的值off / low / medium / high / maxnull 表示跟随模型默认
*
* @author MateClaw Team
*/
public final class ThinkingLevelHolder {
private static final ThreadLocal<String> HOLDER = new ThreadLocal<>();
private ThinkingLevelHolder() {}
public static void set(String level) {
HOLDER.set(level);
}
/**
* 获取当前请求的思考级别null 表示未设置跟随模型默认
*/
public static String get() {
return HOLDER.get();
}
public static void clear() {
HOLDER.remove();
}
}

View File

@ -5,8 +5,11 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import vip.mate.agent.AgentService; import vip.mate.agent.AgentService;
import vip.mate.agent.binding.model.AgentProviderPreference;
import vip.mate.llm.routing.ProviderModelRef;
import vip.mate.agent.binding.model.AgentSkillBinding; import vip.mate.agent.binding.model.AgentSkillBinding;
import vip.mate.agent.binding.model.AgentToolBinding; import vip.mate.agent.binding.model.AgentToolBinding;
import vip.mate.agent.binding.model.AgentWikiKbBinding;
import vip.mate.agent.binding.service.AgentBindingService; import vip.mate.agent.binding.service.AgentBindingService;
import vip.mate.agent.model.AgentEntity; import vip.mate.agent.model.AgentEntity;
import vip.mate.audit.service.AuditEventService; import vip.mate.audit.service.AuditEventService;
@ -51,8 +54,12 @@ public class AgentBindingController {
verifyAgentWorkspace(agentId, workspaceId); verifyAgentWorkspace(agentId, workspaceId);
bindingService.setSkillBindings(agentId, skillIds); bindingService.setSkillBindings(agentId, skillIds);
agentService.invalidateAgentCache(agentId); agentService.invalidateAgentCache(agentId);
// The Vue client always sends an array, but a non-Vue caller (curl /
// SDK) can POST a body of just `null`, which Spring binds to a null
// list. The service tolerates that guard the audit message too.
int count = skillIds == null ? 0 : skillIds.size();
auditEventService.record("UPDATE", "AGENT_SKILL", String.valueOf(agentId), auditEventService.record("UPDATE", "AGENT_SKILL", String.valueOf(agentId),
"skills=" + skillIds.size(), null); "skills=" + count, null);
return R.ok(); return R.ok();
} }
@ -97,8 +104,63 @@ public class AgentBindingController {
verifyAgentWorkspace(agentId, workspaceId); verifyAgentWorkspace(agentId, workspaceId);
bindingService.setToolBindings(agentId, toolNames); bindingService.setToolBindings(agentId, toolNames);
agentService.invalidateAgentCache(agentId); agentService.invalidateAgentCache(agentId);
// Same null-safety rationale as setSkills above.
int count = toolNames == null ? 0 : toolNames.size();
auditEventService.record("UPDATE", "AGENT_TOOL", String.valueOf(agentId), auditEventService.record("UPDATE", "AGENT_TOOL", String.valueOf(agentId),
"tools=" + toolNames.size(), null); "tools=" + count, null);
return R.ok();
}
// ==================== Provider Preferences ====================
@Operation(summary = "获取 Agent 的偏好 Provider 顺序")
@GetMapping("/provider-preferences")
@RequireWorkspaceRole("viewer")
public R<List<AgentProviderPreference>> listProviderPreferences(
@PathVariable Long agentId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyAgentWorkspace(agentId, workspaceId);
return R.ok(bindingService.listProviderPreferences(agentId));
}
@Operation(summary = "批量设置 Agent 的偏好模型链(供应商 + 模型,替换模式)")
@PutMapping("/provider-preferences")
@RequireWorkspaceRole("member")
public R<Void> setProviderPreferences(
@PathVariable Long agentId,
@RequestBody List<ProviderModelRef> preferences,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyAgentWorkspace(agentId, workspaceId);
bindingService.setProviderModelPreferences(agentId, preferences);
agentService.invalidateAgentCache(agentId);
auditEventService.record("UPDATE", "AGENT_PROVIDER_PREF", String.valueOf(agentId),
"entries=" + (preferences == null ? 0 : preferences.size()), null);
return R.ok();
}
// ==================== Knowledge Base Access Scope ====================
@Operation(summary = "获取 Agent 的知识库访问范围")
@GetMapping("/kbs")
@RequireWorkspaceRole("viewer")
public R<List<AgentWikiKbBinding>> listKbs(@PathVariable Long agentId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyAgentWorkspace(agentId, workspaceId);
return R.ok(bindingService.listKbBindings(agentId));
}
@Operation(summary = "批量设置 Agent 的知识库访问范围(替换模式,空表示不限制)")
@PutMapping("/kbs")
@RequireWorkspaceRole("member")
public R<Void> setKbs(@PathVariable Long agentId, @RequestBody List<Long> kbIds,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyAgentWorkspace(agentId, workspaceId);
bindingService.setKbBindings(agentId, kbIds);
agentService.invalidateAgentCache(agentId);
// A non-Vue caller can POST a bare `null`; the service tolerates it.
int count = kbIds == null ? 0 : kbIds.size();
auditEventService.record("UPDATE", "AGENT_WIKI_KB", String.valueOf(agentId),
"kbs=" + count, null);
return R.ok(); return R.ok();
} }
@ -111,7 +173,7 @@ public class AgentBindingController {
} }
long requestedWs = headerWorkspaceId != null ? headerWorkspaceId : 1L; long requestedWs = headerWorkspaceId != null ? headerWorkspaceId : 1L;
if (agent.getWorkspaceId() != null && !agent.getWorkspaceId().equals(requestedWs)) { if (agent.getWorkspaceId() != null && !agent.getWorkspaceId().equals(requestedWs)) {
throw new MateClawException("err.common.wrong_workspace", "资源不属于当前工作区"); throw new MateClawException("err.common.wrong_workspace", 403, "资源不属于当前工作区");
} }
} }
} }

View File

@ -0,0 +1,54 @@
package vip.mate.agent.binding.model;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* RFC-009 Phase 4 PR-3 agent preferred provider routing hint.
*
* <p>An agent with zero rows here uses the global fallback chain order
* (no behavior change from pre-PR-3 deployments). When rows exist,
* {@code AgentGraphBuilder.buildFallbackChain} sorts those provider ids
* to the front by ascending {@code sortOrder}; non-listed providers
* follow in their global priority order.</p>
*
* <p>Pool/cooldown gating still applies: a preferred provider that is
* HARD-removed or cooling down is still skipped by the runtime walker.</p>
*/
@Data
@TableName("mate_agent_provider_preference")
public class AgentProviderPreference {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long agentId;
/** 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;
private Boolean enabled;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
private Integer deleted;
}

View File

@ -17,6 +17,5 @@ public class AgentSkillBinding {
private LocalDateTime createTime; private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE) @TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime; private LocalDateTime updateTime;
@TableLogic
private Integer deleted; private Integer deleted;
} }

View File

@ -16,6 +16,5 @@ public class AgentToolBinding {
private LocalDateTime createTime; private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE) @TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime; private LocalDateTime updateTime;
@TableLogic
private Integer deleted; private Integer deleted;
} }

View File

@ -0,0 +1,27 @@
package vip.mate.agent.binding.model;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Agent knowledge base access scope row.
* <p>
* Each enabled row whitelists one KB for one agent. When an agent has at
* least one row the wiki tools restrict their visible KB set to the bound
* ones; an agent with no rows stays workspace-wide (legacy behavior).
*/
@Data
@TableName("mate_agent_wiki_kb")
public class AgentWikiKbBinding {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long agentId;
private Long kbId;
private Boolean enabled;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
private Integer deleted;
}

View File

@ -0,0 +1,9 @@
package vip.mate.agent.binding.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import vip.mate.agent.binding.model.AgentProviderPreference;
@Mapper
public interface AgentProviderPreferenceMapper extends BaseMapper<AgentProviderPreference> {
}

View File

@ -0,0 +1,9 @@
package vip.mate.agent.binding.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import vip.mate.agent.binding.model.AgentWikiKbBinding;
@Mapper
public interface AgentWikiKbBindingMapper extends BaseMapper<AgentWikiKbBinding> {
}

View File

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

View File

@ -0,0 +1,53 @@
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.AgentSkillBinding;
import vip.mate.agent.binding.repository.AgentSkillBindingMapper;
import vip.mate.skill.event.SkillRemovedEvent;
/**
* Drops {@code mate_agent_skill} rows that pointed at a now-removed skill.
*
* <p>Without this listener, deleting a skill from the skill management page
* leaves orphan binding rows behind:
* <ul>
* <li>the agent edit modal still shows a non-zero badge from
* {@code GET /agents/{id}/skills},</li>
* <li>the picker list (sourced from {@code /skills} enabled set) no longer
* contains a checkbox for that id so the user can't uncheck it, and</li>
* <li>a subsequent {@code PUT /agents/{id}/skills} payload that still
* carries the orphan id is rejected by
* {@code AgentBindingService.setSkillBindings} with
* {@code err.skill.not_found}, leaving the user with no way to clear
* the stale binding.</li>
* </ul>
*
* <p>The event is dispatched synchronously from {@code SkillService} after
* the {@code mate_skill} row deletion, so the cleanup is part of the same
* request and observable in the very next list call.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AgentBindingSkillRemovalListener {
private final AgentSkillBindingMapper skillBindingMapper;
@EventListener
public void onSkillRemoved(SkillRemovedEvent event) {
if (event == null || event.skillId() == null) {
return;
}
int dropped = skillBindingMapper.delete(
new LambdaQueryWrapper<AgentSkillBinding>()
.eq(AgentSkillBinding::getSkillId, event.skillId()));
if (dropped > 0) {
log.info("Cleaned {} agent-skill binding row(s) for removed skill {} (id={})",
dropped, event.skillName(), event.skillId());
}
}
}

View File

@ -0,0 +1,22 @@
package vip.mate.agent.context;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import org.springframework.lang.Nullable;
/**
* Channel-bound target identity used when an agent's response must be delivered
* back to a specific external channel (cron, IM relay, etc.).
*
* <p>Kept as a sub-VO of {@link ChatOrigin} so future channel-related fields do
* not pollute the top-level origin record.
*
* <p>Field evolution rule: only-add, do-not-rename, deprecate-for-90-days before
* physical removal see {@link ChatOrigin}'s class doc.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record ChannelTarget(
@Nullable String targetId,
@Nullable String threadId,
@Nullable String accountId
) {
}

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