Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8bf4e0f89 | ||
|
|
db6caea824 | ||
|
|
84375da3c5 | ||
|
|
68c010ecf9 | ||
|
|
493910bf5a | ||
|
|
da8005a8cb | ||
|
|
1d64194e15 | ||
|
|
f47cf8c6be | ||
|
|
d994be3d04 | ||
|
|
9ada305b8a |
28
.dockerignore
Normal 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/**
|
||||||
111
.env.example
@ -1,18 +1,10 @@
|
|||||||
# MateClaw 环境变量配置
|
# MateClaw 环境变量配置
|
||||||
# 复制此文件为 .env 并填写实际值:cp .env.example .env
|
# 复制此文件为 .env 并填写实际值:cp .env.example .env
|
||||||
#
|
#
|
||||||
|
# LLM API Key(DashScope、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 模式必填) ====================
|
||||||
|
|
||||||
DB_HOST=localhost
|
DB_HOST=localhost
|
||||||
@ -36,3 +28,102 @@ 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)。
|
||||||
|
# 生产数据库 profile(mysql/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 channel(chrome / msedge / chrome-beta …):
|
||||||
|
# MATECLAW_BROWSER_CHANNEL=chrome
|
||||||
|
MATECLAW_BROWSER_CDP_URL=
|
||||||
|
MATECLAW_BROWSER_CHROME_PATH=
|
||||||
|
MATECLAW_BROWSER_CHANNEL=
|
||||||
|
|
||||||
|
# ==================== 局域网 部署放开(可选,默认 false 严格模式) ====================
|
||||||
|
# 浏览器 SSRF 防护:放行本地回环和私有 IP(127.0.0.1 / 10.x / 192.168.x /
|
||||||
|
# 172.16-31.x / IPv6 fc00::/7 等),公网部署务必保持 false,否则 SSRF 防护失效
|
||||||
|
PLAYWRIGHT_ALLOW_PRIVATE_NETWORK=false
|
||||||
|
# 浏览器忽略 HTTPS 证书错误(自签证书 / IP 直连 HTTPS 场景)
|
||||||
|
# 公网部署务必保持 false,否则中间人攻击可绕过证书校验
|
||||||
|
PLAYWRIGHT_IGNORE_HTTPS_ERRORS=false
|
||||||
|
# Playwright 单次操作超时(秒),慢链路 / 大页面可调高
|
||||||
|
PLAYWRIGHT_DEFAULT_TIMEOUT_SECONDS=30
|
||||||
|
# Playwright 导航超时(秒),慢网络可调高
|
||||||
|
PLAYWRIGHT_NAVIGATION_TIMEOUT_SECONDS=30
|
||||||
|
# snapshot 文本截断长度,超出会返回 truncated:true 提示 LLM 用 selector 缩小范围
|
||||||
|
PLAYWRIGHT_SNAPSHOT_MAX_LENGTH=20000
|
||||||
|
|
||||||
|
# ==================== OpenAI OAuth(Docker,可选) ====================
|
||||||
|
#
|
||||||
|
# OpenAI ChatGPT OAuth 使用 Codex CLI 的 public client + PKCE / device code,
|
||||||
|
# 不需要自定义 client secret。
|
||||||
|
#
|
||||||
|
# 默认留空即可。后端会根据访问 Host 自动选择:
|
||||||
|
# - localhost / 127.0.0.1 / ::1 → LOCAL(PKCE 回调)
|
||||||
|
# - 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=
|
||||||
|
|
||||||
|
# ── Maven 镜像(国内加速)─────────────────────────────────────────
|
||||||
|
# 在中国大陆构建时取消注释,将 Aliyun 仓库优先级提前,大幅提速 mvn 拉包。
|
||||||
|
# 空值(默认)使用 US Maven Central → Google CDN → Aliyun 的顺序。
|
||||||
|
#MAVEN_FLAGS=-Paliyun-first
|
||||||
|
|||||||
74
.github/ISSUE_TEMPLATE/bug-en.yml
vendored
Normal 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
@ -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
@ -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
@ -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
@ -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
|
||||||
25
.gitignore
vendored
@ -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,24 @@ 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
|
||||||
|
|||||||
286
README.md
@ -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>
|
||||||
|
|
||||||
[](https://github.com/matevip/mateclaw)
|
<p align="center"><sub><b>Agent Harness · Spring Boot inside · One JAR to ship</b></sub></p>
|
||||||
|
|
||||||
|
[](https://github.com/mateaix/mateclaw)
|
||||||
[](https://claw.mate.vip/docs)
|
[](https://claw.mate.vip/docs)
|
||||||
[](https://claw-demo.mate.vip)
|
[](https://claw-demo.mate.vip)
|
||||||
[](https://claw.mate.vip)
|
[](https://claw.mate.vip)
|
||||||
[](https://adoptium.net/)
|
[](https://adoptium.net/)
|
||||||
[](https://spring.io/projects/spring-boot)
|
[](https://spring.io/projects/spring-boot)
|
||||||
[](https://vuejs.org/)
|
[](https://vuejs.org/)
|
||||||
[](https://github.com/matevip/mateclaw)
|
[](https://github.com/mateaix/mateclaw)
|
||||||
[](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,114 +30,115 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
### 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 · $20–200/mo | Proprietary · $0–200/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) | $20–200/mo | $0–200/mo | $0–200/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
|
||||||
@ -156,60 +159,87 @@ 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.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:
|
||||||
- Smarter model routing
|
|
||||||
- Deeper multimodal understanding
|
|
||||||
- Stronger long-term memory
|
|
||||||
- Richer ClawHub ecosystem
|
|
||||||
|
|
||||||
---
|
- **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 +247,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.
|
||||||
|
|||||||
280
README_zh.md
@ -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>
|
||||||
|
|
||||||
[](https://github.com/matevip/mateclaw)
|
<p align="center"><sub><b>Agent Harness · Spring Boot 内核 · 一个 JAR 交付</b></sub></p>
|
||||||
|
|
||||||
|
[](https://github.com/mateaix/mateclaw)
|
||||||
[](https://claw.mate.vip/docs)
|
[](https://claw.mate.vip/docs)
|
||||||
[](https://claw-demo.mate.vip)
|
[](https://claw-demo.mate.vip)
|
||||||
[](https://claw.mate.vip)
|
[](https://claw.mate.vip)
|
||||||
[](https://adoptium.net/)
|
[](https://adoptium.net/)
|
||||||
[](https://spring.io/projects/spring-boot)
|
[](https://spring.io/projects/spring-boot)
|
||||||
[](https://vuejs.org/)
|
[](https://vuejs.org/)
|
||||||
[](https://github.com/matevip/mateclaw)
|
[](https://github.com/mateaix/mateclaw)
|
||||||
[](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,115 +30,116 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
一个智能体引擎。一个知识系统。一个记忆层。一个工具运行时。一个多渠道入口。
|
> **别的 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 绑定到源 chunk,JSON 输出 + 可选 JSON Schema,每个模板独立选模型
|
||||||
|
|
||||||
|
### 你看得见每位员工正在干什么
|
||||||
|
**Admin 运行时控制台**(`后台 → 系统 → 运行时`)——谁在跑、跑到哪一步、占多少 token、卡住了一键回收。流式分阶段显示(思考 / 工具 / 回答),SSE 每事件 ID 支持安全重连,多员工协作不打架,长任务必须有真实证据才回答。
|
||||||
|
|
||||||
|
### 多模态创作
|
||||||
|
语音合成 · 语音识别 · 图片 · 音乐 · 视频 · 3D。一等公民,不是附加插件。**多模态旁路**(1.3.0+)让纯文本主模型遇到图片附件时自动调用配置好的视觉模型转描述,主对话保持便宜。**图像编辑**也到位:用 `msg:<id>:<idx>` 引用会话里更早的某张图,让模型改色、改风格。**4 个文档生成工具**(`DocxRenderTool` / `XlsxRenderTool` / `PptxRenderTool` / `PdfRenderTool`)在 JVM 内把 Markdown 直接渲染成 Office 文件——不 fork 子进程、不依赖 npm、不需要装 Office。
|
||||||
|
|
||||||
|
### 企业就绪
|
||||||
|
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 |
|
||||||
|
| **技术栈** | **Java(Spring Boot)** | TypeScript | Python | TypeScript | Electron/TS |
|
||||||
|
| **许可 / 定价** | **Apache 2.0 · 免费** | MIT · 免费 | MIT · 免费 | 闭源 · $20–200/月 | 闭源 · $0–200/月 |
|
||||||
|
|
||||||
| 能力 | 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 菜单栏 | Electron(Beta) | Win/Mac 应用 | Claude Desktop(Mac/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 | 部分 | 否(源码可见) | 否 | 否 |
|
|
||||||
| 定价 | **免费** | 免费 | 免费 | 免费(公测) | $20–200/月 | $0–200/月 | $0–200/月 |
|
|
||||||
| 技术栈 | **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 在这方面同样强;其他竞品不具备
|
|
||||||
- **免费开源,没有星号** — Apache 2.0。无按量计费,无按席收费,无功能阉割。Claude Code 起步 $20/月,Cursor 和 Windsurf 最高 $200/月
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 架构全景
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<img src="assets/architecture-biz-zh.svg" alt="业务架构" width="800">
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>技术架构</b></summary>
|
|
||||||
<p align="center">
|
|
||||||
<img src="assets/architecture-tech-zh.svg" alt="技术架构" width="800">
|
|
||||||
</p>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 核心能力
|
|
||||||
|
|
||||||
### 智能体引擎
|
|
||||||
|
|
||||||
- **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
|
```bash
|
||||||
# 后端
|
# 后端
|
||||||
cd mateclaw-server
|
cd mateclaw-server
|
||||||
@ -158,22 +161,22 @@ docker compose up -d # http://localhost:18080
|
|||||||
|
|
||||||
### 桌面端
|
### 桌面端
|
||||||
|
|
||||||
从 [GitHub Releases](https://github.com/matevip/mateclaw/releases) 下载安装包。内嵌 JRE 21,无需额外安装 Java。
|
从 [GitHub Releases](https://github.com/mateaix/mateclaw/releases) 下载安装包。内嵌 JRE 21,无需额外装 Java。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 技术栈
|
## 架构全景
|
||||||
|
|
||||||
| 层次 | 技术 |
|
<p align="center">
|
||||||
|------|------|
|
<img src="assets/architecture-biz-zh.svg" alt="业务架构" width="800">
|
||||||
| 后端 | Spring Boot 3.5 · Spring AI Alibaba 1.1 |
|
</p>
|
||||||
| 智能体 | StateGraph 运行时 |
|
|
||||||
| 数据库 | H2(开发)/ MySQL 8.0+(生产)|
|
<details>
|
||||||
| ORM | MyBatis Plus 3.5 |
|
<summary><b>技术架构</b></summary>
|
||||||
| 认证 | Spring Security + JWT |
|
<p align="center">
|
||||||
| 前端 | Vue 3 · TypeScript · Vite |
|
<img src="assets/architecture-tech-zh.svg" alt="技术架构" width="800">
|
||||||
| UI | Element Plus · TailwindCSS 4 |
|
</p>
|
||||||
| 桌面端 | Electron · electron-updater |
|
</details>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -181,35 +184,62 @@ docker compose up -d # http://localhost:18080
|
|||||||
|
|
||||||
```
|
```
|
||||||
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 包 · MCP(stdio / 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.7.0(2026-07-04 发布)** — 一次*生产化加固*:把它放进真正的协作里之后,那些看不见、收不拢、够不着、装不下、连不通的地方全补上:
|
||||||
- 更智能的模型路由
|
|
||||||
- 更深度的多模态理解
|
|
||||||
- 更强的长期记忆
|
|
||||||
- 更丰富的 ClawHub 生态
|
|
||||||
|
|
||||||
---
|
- **审批三条链路彻底闭环** — 工作流 `await_approval` 真的推到渠道并 resolve→恢复执行、WebChat(API-Key)渠道能批准/拒绝并重放、飞书/企微点卡片直接 resolve 工作流审批
|
||||||
|
- **长任务看得见** — 常驻「运行总览」侧栏 + 本轮 Token 明细(缓存命中/未命中/写入 + 推理拆分)+ 子 Agent 成本向上滚加 + 生成文件一键下载
|
||||||
|
- **装得下真实模型窗口** — 本地模型上下文窗口探测、prefix 注入统一 Token 预算、小上下文降级、工具 schema 预算门——不再被"猜个 32K"坑到预检拒绝或悄悄截断
|
||||||
|
- **开放出去** — 知识库 / Deep Research 开放 API(API-Key + 限流 + SSE)、插件化搜索 Provider SPI、MCP 身份透传(把认证用户身份带给 STDIO MCP)
|
||||||
|
- **够得着更远** — 桌面端本地内嵌 / 远程集中部署双模式(`mateclaw-desktop` 源码开放)+ 局域网部署模式放开受控内网访问
|
||||||
|
- **运营数据一键导出** — Dashboard 9 表 Excel + CLI 命令行离线导出
|
||||||
|
|
||||||
|
完整故事见 [v1.7.0 release notes](https://claw.mate.vip/docs/zh/releases/1.7.0)。
|
||||||
|
|
||||||
|
**v1.6.0(2026-06-22 发布)** — 让自驱的数字员工*更快、更会看、更易嵌入*:技能两段式载入 + prefix 压缩(首字节更快)· `execute_code` 原生沙箱代码执行 · 图片跨轮次留存 + `image_analyze` · 可嵌入/无头 webchat 按 `endUserId` 隔离记忆 · 真正可读的 Wiki(阅读与管理分离 · 统一 Sources 标签 · 可点击 `[[wikilinks]]`)· 高负载更稳(MCP 自愈 · 工具调用恢复 · 计划证据闸门)。完整故事见 [v1.6.0 release notes](https://claw.mate.vip/docs/zh/releases/1.6.0)。
|
||||||
|
|
||||||
|
**v1.5.0(2026-06-04 发布)** — Goal 可勾选清单(模糊评分 → 逐项打勾)· Wiki 自维护(`[[wikilinks]]` · 事实层/经验层 · pageType 模板与权限 · 知识库流水线 · 本地目录接入)· 按拥有者隔离记忆(`owner_key` + 可见域 + `endUserId` 透传)· 每员工绑定主知识库 · 偏好 provider 驱动选型。完整故事见 [v1.5.0 release notes](https://claw.mate.vip/docs/zh/releases/1.5.0)。
|
||||||
|
|
||||||
|
**v1.4.0(2026-05-23 发布)** — 持续目标(锁定目标,每轮自评)· 子员工委派树(最深 3 层 · 同步 / 并行 / 异步 · 一句话组队)· 工具/技能渐进式披露 · 工作空间 RBAC(Owner / Admin / Member / Viewer)· 飞书一等公民(交互卡 / 审批卡 / 流式卡 · 渠道原生工具)。详见 [v1.4.0 release notes](https://claw.mate.vip/docs/zh/releases/1.4.0)。
|
||||||
|
|
||||||
|
**v1.3.0(2026-05-13 发布)** — 工作流引擎 · 6 种 pattern 触发器 · Wiki 加工器 · 每员工独立 MCP 绑定 · 多模态旁路路由 · 4 个 JVM 原生文档生成工具 · 图像编辑。详见 [v1.3.0 release notes](https://claw.mate.vip/docs/zh/releases/1.3.0)。
|
||||||
|
|
||||||
## 参与贡献
|
## 参与贡献
|
||||||
|
|
||||||
```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 +251,8 @@ cd ../mateclaw-ui && pnpm install && pnpm dev
|
|||||||
|
|
||||||
**Mate** 是陪伴。**Claw** 是能力。
|
**Mate** 是陪伴。**Claw** 是能力。
|
||||||
|
|
||||||
一个陪在你身边的系统,一个能真正抓住工作、推动它前进的系统。
|
一个陪在你身边的系统——也是一个真的能抓住工作、把它推向完成的系统。
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
[Apache License 2.0](LICENSE)
|
[Apache License 2.0](LICENSE)。没有星号。
|
||||||
|
|||||||
@ -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 & 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 & 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 |
@ -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 |
@ -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 |
@ -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 |
|
Before Width: | Height: | Size: 811 KiB After Width: | Height: | Size: 1.0 MiB |
@ -21,8 +21,9 @@ services:
|
|||||||
- "3306:3306"
|
- "3306:3306"
|
||||||
volumes:
|
volumes:
|
||||||
- mysql_data:/var/lib/mysql
|
- mysql_data:/var/lib/mysql
|
||||||
- ./mateclaw-server/src/main/resources/db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql
|
# Schema and seed data are managed by Flyway on application startup.
|
||||||
- ./mateclaw-server/src/main/resources/db/data.sql:/docker-entrypoint-initdb.d/02-data.sql
|
# Do NOT mount legacy schema.sql / data.sql here — Flyway creates all
|
||||||
|
# tables from V1 baseline and applies incremental migrations automatically.
|
||||||
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||||
@ -30,18 +31,26 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
# 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.
|
||||||
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
|
||||||
|
- UWSGI_THREADS=4
|
||||||
ports:
|
ports:
|
||||||
- "8088:8080"
|
- "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
|
||||||
@ -50,8 +59,10 @@ services:
|
|||||||
# 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:
|
||||||
@ -66,16 +77,75 @@ services:
|
|||||||
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:-}
|
||||||
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 模式默认保持 auto:localhost 访问走 LOCAL,IP/域名访问走 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}
|
||||||
|
# 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 — H2 DB, 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
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mysql_data:
|
mysql_data:
|
||||||
server_data:
|
server_data:
|
||||||
searxng_data:
|
|
||||||
|
|||||||
9
docker/searxng/Dockerfile
Normal 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
|
||||||
61
docker/searxng/settings.yml
Normal 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
|
||||||
15
mateclaw-desktop/.env.example
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# macOS 代码签名与公证
|
||||||
|
# 本地构建推荐不设 CSC_LINK,让 electron-builder 自动从钥匙串发现证书
|
||||||
|
# CSC_LINK=/path/to/developer_id_application.p12 # CI/CD 专用
|
||||||
|
# CSC_KEY_PASSWORD= # CI/CD 专用
|
||||||
|
|
||||||
|
APPLE_ID=your@apple.id
|
||||||
|
APPLE_APP_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx
|
||||||
|
APPLE_TEAM_ID=XXXXXXXXXX
|
||||||
|
|
||||||
|
# GitHub Releases 发布
|
||||||
|
GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
|
||||||
|
# Windows 代码签名(可选)
|
||||||
|
# WIN_CSC_LINK=/path/to/windows-cert.pfx
|
||||||
|
# WIN_CSC_KEY_PASSWORD=
|
||||||
33
mateclaw-desktop/.gitignore
vendored
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
dist-electron/
|
||||||
|
release/
|
||||||
|
|
||||||
|
# Resources (downloaded/generated, not committed)
|
||||||
|
resources/jre/
|
||||||
|
resources/app.jar
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Runtime data (H2 database created during dev testing)
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
275
mateclaw-desktop/CODESIGNING.md
Normal file
@ -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
@ -0,0 +1,432 @@
|
|||||||
|
# MateClaw Desktop
|
||||||
|
|
||||||
|
MateClaw 的桌面客户端,基于 Electron 构建,自动集成 JRE 21 和后端服务,实现双击即用。
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
Electron Shell
|
||||||
|
├── Splash Screen (Vue 3) ← 启动加载界面
|
||||||
|
├── Bundled JRE 21 ← 自带 Java 运行时
|
||||||
|
├── mateclaw-server.jar ← Spring Boot 后端 + Vue 前端
|
||||||
|
└── BrowserWindow → localhost:18088
|
||||||
|
```
|
||||||
|
|
||||||
|
**启动流程**: Electron 启动 → 显示 Splash → 用内置 JRE 启动 JAR → 等待后端就绪 → 加载主界面
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 前置要求
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- pnpm (前端构建)
|
||||||
|
- Maven 3.9+ (后端构建)
|
||||||
|
- Java 21+ (仅构建时需要,运行时使用内置 JRE)
|
||||||
|
|
||||||
|
### 开发模式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 安装依赖
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 2. 构建后端 JAR(包含前端资源)
|
||||||
|
npm run setup:jar
|
||||||
|
|
||||||
|
# 3. 下载 JRE(当前平台)
|
||||||
|
npm run setup:jre
|
||||||
|
|
||||||
|
# 4. 启动开发模式
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 打包发布
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS (.dmg)
|
||||||
|
npm run package:mac
|
||||||
|
|
||||||
|
# Windows (.exe)
|
||||||
|
npm run package:win
|
||||||
|
|
||||||
|
# 全平台
|
||||||
|
npm run package:all
|
||||||
|
```
|
||||||
|
|
||||||
|
输出在 `release/` 目录。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
mateclaw-desktop/
|
||||||
|
├── electron/main/ # Electron 主进程(Java 生命周期管理)
|
||||||
|
├── electron/preload/ # 预加载脚本(安全 IPC 桥接)
|
||||||
|
├── src/ # Splash Screen(Vue 3 加载页面)
|
||||||
|
├── build/ # 应用图标和 macOS entitlements
|
||||||
|
├── scripts/ # 构建脚本
|
||||||
|
│ ├── download-jre.sh # 下载 Adoptium JRE 21
|
||||||
|
│ └── build.sh # 构建前端 + 后端 JAR
|
||||||
|
└── resources/ # 运行时资源(JRE + JAR,不提交到 Git)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
桌面应用**不需要任何环境变量**就能启动——LLM 供应商 Key 在 UI 里加。
|
||||||
|
|
||||||
|
以下是可选的环境变量(桌面应用会继承系统环境):
|
||||||
|
|
||||||
|
| 变量 | 必须 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `SERPER_API_KEY` | ❌ | Google Serper 搜索 API(搜索工具暂未迁到 UI) |
|
||||||
|
| `TAVILY_API_KEY` | ❌ | Tavily 搜索 API |
|
||||||
|
|
||||||
|
> 💡 DashScope / OpenAI / Anthropic / DeepSeek / Kimi / Ollama 等 LLM 供应商 Key 启动后在「设置 → 模型 → 添加供应商」里粘进去,加密存到本地 H2 数据库。
|
||||||
|
|
||||||
|
## 自动升级
|
||||||
|
|
||||||
|
应用内置 `electron-updater` 自动升级,更新产物托管在 [GitHub Releases](https://github.com/matevip/mateclaw/releases)。
|
||||||
|
|
||||||
|
**升级流程**:启动时检查 → Splash Screen 底部通知 → 用户点击下载 → 下载完成点击重启 → 自动停止 Java 后端 → 安装新版本
|
||||||
|
|
||||||
|
| 平台 | 更新包格式 | 元数据文件 | 签名要求 |
|
||||||
|
|------|-----------|-----------|---------|
|
||||||
|
| Windows | NSIS `.exe` | `latest.yml` | 可选(不签名会触发 SmartScreen) |
|
||||||
|
| macOS | `.zip` | `latest-mac.yml` | **必须签名+公证**(否则只能手动 DMG 安装) |
|
||||||
|
|
||||||
|
## 发布操作手册
|
||||||
|
|
||||||
|
### 第一步:配置 GitHub Token
|
||||||
|
|
||||||
|
`electron-builder` 使用 `github` provider,需要 GitHub Personal Access Token 来创建 Release 并上传产物。
|
||||||
|
|
||||||
|
1. 前往 https://github.com/settings/tokens → **Generate new token (classic)**
|
||||||
|
2. 勾选 `repo` 权限(需要完整 repo 访问才能创建 Release)
|
||||||
|
3. 生成后保存 token
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 设置环境变量(建议写入 ~/.zshrc 或 CI Secret)
|
||||||
|
export GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第二步:版本号管理
|
||||||
|
|
||||||
|
每次发布前必须更新 `package.json` 中的 `version` 字段。`electron-updater` 客户端通过对比本地版本号和 `latest.yml` 中的版本号来判断是否有更新。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 编辑版本号
|
||||||
|
cd mateclaw-desktop
|
||||||
|
vim package.json # 修改 "version": "1.0.0" → "1.1.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
版本号遵循 [SemVer](https://semver.org/):
|
||||||
|
- 修复 bug → `1.0.0` → `1.0.1`
|
||||||
|
- 新功能 → `1.0.0` → `1.1.0`
|
||||||
|
- 破坏性变更 → `1.0.0` → `2.0.0`
|
||||||
|
|
||||||
|
### 第三步:构建并发布
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mateclaw-desktop
|
||||||
|
|
||||||
|
# 一键构建全平台 + 自动上传到 GitHub Releases
|
||||||
|
export GH_TOKEN=ghp_xxxxxxxxxxxx
|
||||||
|
bash scripts/build-all-platforms.sh --all --publish=always
|
||||||
|
```
|
||||||
|
|
||||||
|
这会自动:
|
||||||
|
1. 构建后端 JAR
|
||||||
|
2. 下载各平台 JRE
|
||||||
|
3. 编译前端
|
||||||
|
4. 打包 macOS(DMG + ZIP)和 Windows(NSIS)
|
||||||
|
5. 生成 `latest.yml` 和 `latest-mac.yml`
|
||||||
|
6. 创建 GitHub Draft Release 并上传所有产物
|
||||||
|
|
||||||
|
完成后前往 https://github.com/matevip/mateclaw/releases ,找到 Draft Release:
|
||||||
|
- 填写 Release Notes(更新说明)
|
||||||
|
- 点击 **Publish release** 正式发布
|
||||||
|
|
||||||
|
也可以仅构建特定平台:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/build-all-platforms.sh --mac-only --publish=always # 仅 macOS
|
||||||
|
bash scripts/build-all-platforms.sh --win-only --publish=always # 仅 Windows
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第四步(可选):手动发布
|
||||||
|
|
||||||
|
如果不想用 `--publish=always` 自动上传:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 仅构建,不上传
|
||||||
|
bash scripts/build-all-platforms.sh --all
|
||||||
|
|
||||||
|
# 2. 查看生成的产物
|
||||||
|
ls -la release/
|
||||||
|
# 产物包括:
|
||||||
|
# MateClaw_1.1.0_arm64.dmg macOS ARM64 安装包
|
||||||
|
# MateClaw_1.1.0_x64.dmg macOS x64 安装包
|
||||||
|
# MateClaw_1.1.0_arm64.zip macOS ARM64 更新包(升级用)
|
||||||
|
# MateClaw_1.1.0_x64.zip macOS x64 更新包(升级用)
|
||||||
|
# MateClaw_1.1.0_x64_Setup.exe Windows x64 安装包
|
||||||
|
# MateClaw_1.1.0_arm64_Setup.exe Windows ARM64 安装包
|
||||||
|
# MateClaw_1.1.0_*.blockmap 差分下载支持文件
|
||||||
|
# latest.yml Windows 更新元数据
|
||||||
|
# latest-mac.yml macOS 更新元数据
|
||||||
|
|
||||||
|
# 3. 在 GitHub 手动创建 Release
|
||||||
|
# Tag: v1.1.0
|
||||||
|
# 上传 release/ 目录中的所有 .exe .zip .dmg .blockmap .yml 文件
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意**:`latest.yml` 和 `latest-mac.yml` 必须上传,客户端靠它们检测新版本。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## macOS 代码签名与公证
|
||||||
|
|
||||||
|
macOS 自动升级**必须**签名+公证,否则 Gatekeeper 会阻止更新后的应用启动。未签名时 macOS 用户只能手动下载 DMG 安装。
|
||||||
|
|
||||||
|
> **证书创建完整指南**:首次配置或证书过期时,参见 [CODESIGNING.md](./CODESIGNING.md)(含 CSR 生成、证书创建、.p12 导出、公证配置等完整步骤)。
|
||||||
|
|
||||||
|
### 本地签名构建(推荐)
|
||||||
|
|
||||||
|
证书安装到本地钥匙串后,**不需要设置 `CSC_LINK`**,electron-builder 会自动发现证书:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 只需设置公证相关变量
|
||||||
|
export APPLE_ID=your@apple.id
|
||||||
|
export APPLE_APP_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx # 在 appleid.apple.com 生成
|
||||||
|
export APPLE_TEAM_ID=XXXXXXXXXX # 10 位团队 ID
|
||||||
|
|
||||||
|
bash scripts/build-all-platforms.sh --mac-only --publish=always
|
||||||
|
```
|
||||||
|
|
||||||
|
`electron-builder` 会自动完成签名 → 公证 → 装订(staple)→ 上传。
|
||||||
|
|
||||||
|
> **注意**:不要设置 `CSC_LINK` 环境变量,否则 electron-builder 会创建临时钥匙串,可能导致签名卡死。详见 [CODESIGNING.md](./CODESIGNING.md) 故障排查章节。
|
||||||
|
|
||||||
|
### CI/CD 签名构建
|
||||||
|
|
||||||
|
CI 环境无本地钥匙串,需通过 `CSC_LINK` 指定 `.p12` 文件(Base64 编码存入 GitHub Secret):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export CSC_LINK=base64_encoded_p12_content
|
||||||
|
export CSC_KEY_PASSWORD=your_certificate_password
|
||||||
|
export APPLE_ID=your@apple.id
|
||||||
|
export APPLE_APP_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx
|
||||||
|
export APPLE_TEAM_ID=XXXXXXXXXX
|
||||||
|
|
||||||
|
bash scripts/build-all-platforms.sh --mac-only --publish=always
|
||||||
|
```
|
||||||
|
|
||||||
|
### 公证超时处理
|
||||||
|
|
||||||
|
如果公证上传超时(`deadlineExceeded`),可先跳过公证构建,再用 `xcrun notarytool` 手动公证:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 去掉公证变量,仅签名出包
|
||||||
|
unset APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID
|
||||||
|
bash scripts/build-all-platforms.sh --mac-only
|
||||||
|
|
||||||
|
# 2. 手动公证(支持断点续传)
|
||||||
|
xcrun notarytool submit release/MateClaw_*.zip \
|
||||||
|
--apple-id your@apple.id \
|
||||||
|
--password "app专用密码" \
|
||||||
|
--team-id XXXXXXXXXX \
|
||||||
|
--wait
|
||||||
|
|
||||||
|
# 3. 装订公证票据
|
||||||
|
xcrun stapler staple release/MateClaw_*.dmg
|
||||||
|
```
|
||||||
|
|
||||||
|
### 跳过签名(开发/测试用)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export CSC_IDENTITY_AUTO_DISCOVERY=false
|
||||||
|
bash scripts/build-all-platforms.sh --mac-only
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Windows 代码签名(可选)
|
||||||
|
|
||||||
|
未签名的 Windows 安装包会触发 SmartScreen 警告("Windows 已保护你的电脑"),用户可以点击"仍要运行"。签名可消除此警告。
|
||||||
|
|
||||||
|
### EV 代码签名证书
|
||||||
|
|
||||||
|
推荐使用 EV(Extended Validation)证书,可立即获得 SmartScreen 信誉,无需积累安装量。
|
||||||
|
|
||||||
|
证书提供商(参考):
|
||||||
|
- [DigiCert](https://www.digicert.com/signing/code-signing-certificates) — 需硬件 token
|
||||||
|
- [SSL.com](https://www.ssl.com/certificates/ev-code-signing/) — 支持云签名
|
||||||
|
- [Certum](https://shop.certum.eu/code-signing-certificates/) — 较便宜的选项
|
||||||
|
|
||||||
|
### 配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# PFX 文件签名
|
||||||
|
export WIN_CSC_LINK=/path/to/windows-cert.pfx
|
||||||
|
export WIN_CSC_KEY_PASSWORD=password
|
||||||
|
|
||||||
|
# 或使用 signtool(需要硬件 token 的 EV 证书)
|
||||||
|
# 在 electron-builder.json 的 win 节中配置:
|
||||||
|
# "signingHashAlgorithms": ["sha256"],
|
||||||
|
# "sign": "./scripts/sign.js"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI/CD 自动发布(GitHub Actions)
|
||||||
|
|
||||||
|
以下为 GitHub Actions 完整示例,实现 Git tag 推送时自动构建全平台并发布:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .github/workflows/release.yml
|
||||||
|
name: Release Desktop
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*' # 推送 v1.0.0 等 tag 时触发
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release-mac:
|
||||||
|
runs-on: macos-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: 21
|
||||||
|
|
||||||
|
- name: Build and publish macOS
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
|
||||||
|
CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
|
||||||
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
run: |
|
||||||
|
cd mateclaw-desktop
|
||||||
|
npm install
|
||||||
|
bash scripts/build-all-platforms.sh --mac-only --publish=always
|
||||||
|
|
||||||
|
release-win:
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: 21
|
||||||
|
|
||||||
|
- name: Build and publish Windows
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
cd mateclaw-desktop
|
||||||
|
npm install
|
||||||
|
bash scripts/build-all-platforms.sh --win-only --publish=always
|
||||||
|
```
|
||||||
|
|
||||||
|
### 配置 CI Secrets
|
||||||
|
|
||||||
|
在 GitHub 仓库 → Settings → Secrets and variables → Actions → New repository secret:
|
||||||
|
|
||||||
|
| Secret 名称 | 说明 |
|
||||||
|
|-------------|------|
|
||||||
|
| `MAC_CSC_LINK` | macOS 签名证书 .p12 的 Base64 编码:`base64 -i cert.p12 \| tr -d '\n'` |
|
||||||
|
| `MAC_CSC_KEY_PASSWORD` | .p12 证书密码 |
|
||||||
|
| `APPLE_ID` | Apple ID 邮箱 |
|
||||||
|
| `APPLE_APP_SPECIFIC_PASSWORD` | App 专用密码 |
|
||||||
|
| `APPLE_TEAM_ID` | 10 位开发者团队 ID |
|
||||||
|
| `GITHUB_TOKEN` | 自动提供,无需手动配置 |
|
||||||
|
|
||||||
|
### 发布流程(CI 方式)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 更新版本号
|
||||||
|
cd mateclaw-desktop
|
||||||
|
vim package.json # "version": "1.1.0"
|
||||||
|
|
||||||
|
# 2. 提交并打 tag
|
||||||
|
git add -A && git commit -m "release: v1.1.0"
|
||||||
|
git tag v1.1.0
|
||||||
|
git push origin main --tags
|
||||||
|
|
||||||
|
# 3. GitHub Actions 自动构建并创建 Draft Release
|
||||||
|
# 4. 前往 GitHub Releases 确认并发布
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 本地测试自动升级
|
||||||
|
|
||||||
|
### 方式一:开发模式 + dev-app-update.yml
|
||||||
|
|
||||||
|
在开发模式下测试 updater 流程(不需要打包):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 在 mateclaw-desktop/ 根目录创建 dev-app-update.yml
|
||||||
|
cat > dev-app-update.yml << 'EOF'
|
||||||
|
provider: generic
|
||||||
|
url: http://localhost:8080/
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 2. 构建一个"新版本"的产物
|
||||||
|
# 先把 package.json 的 version 改为更高版本(如 9.9.9)
|
||||||
|
# 然后构建:
|
||||||
|
npm run build
|
||||||
|
npx electron-builder --mac --publish=never # 或 --win
|
||||||
|
# 构建完成后把 version 改回原值
|
||||||
|
|
||||||
|
# 3. 启动本地文件服务器
|
||||||
|
cd release && python3 -m http.server 8080
|
||||||
|
|
||||||
|
# 4. 另一个终端启动开发模式
|
||||||
|
cd mateclaw-desktop && npm run dev
|
||||||
|
# updater 会从 localhost:8080 检查更新并发现"新版本"
|
||||||
|
```
|
||||||
|
|
||||||
|
> 开发模式下 `quitAndInstall()` 不会真正安装,但可验证检查→发现→下载的完整流程。
|
||||||
|
|
||||||
|
### 方式二:打包后端到端测试(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 打包 v1.0.0 并安装到系统
|
||||||
|
# 2. 修改 package.json version 为 v1.1.0
|
||||||
|
# 3. 重新构建,产物上传到 GitHub Release(或本地服务器)
|
||||||
|
# 4. 启动已安装的 v1.0.0,观察完整升级流程:
|
||||||
|
# 检查更新 → 发现 v1.1.0 → 下载 → 重启安装
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 发布检查单
|
||||||
|
|
||||||
|
- [ ] `package.json` 版本号已更新
|
||||||
|
- [ ] 后端 JAR 已构建(`npm run setup:jar`)
|
||||||
|
- [ ] 各平台 JRE 已下载
|
||||||
|
- [ ] `npm run build` 编译通过
|
||||||
|
- [ ] `GH_TOKEN` 环境变量已设置
|
||||||
|
- [ ] macOS 签名证书环境变量已设置(若需要签名)
|
||||||
|
- [ ] `bash scripts/build-all-platforms.sh --all --publish=always` 执行成功
|
||||||
|
- [ ] GitHub Draft Release 已确认发布
|
||||||
|
- [ ] 在旧版本应用上验证升级通知正常
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **Electron** - 桌面应用框架
|
||||||
|
- **Vite + Vue 3** - Splash Screen 构建
|
||||||
|
- **electron-builder** + **electron-updater** - 跨平台打包与自动升级
|
||||||
|
- **Adoptium JRE 21** - 内置 Java 运行时
|
||||||
67
mateclaw-desktop/RELEASE_NOTES_v1.0.101.md
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
# MateClaw v1.0.101
|
||||||
|
|
||||||
|
## What's New
|
||||||
|
|
||||||
|
### Mobile Responsive UI
|
||||||
|
- Sidebar transforms to slide-in drawer with hamburger menu on mobile (<=768px)
|
||||||
|
- Conversation panel becomes a toggleable overlay on mobile
|
||||||
|
- Welcome screen centers properly with auto text wrapping, single-column suggestion cards
|
||||||
|
- Chat header auto-simplifies: icon-only agent badge, adaptive model selector
|
||||||
|
- Reduced padding/gaps across all chat components for mobile screens
|
||||||
|
|
||||||
|
### Drag & Drop File Upload
|
||||||
|
- Drag-and-drop files and folders directly into the chat area
|
||||||
|
- Electron: directory references via local path; Web: recursive file collection and upload
|
||||||
|
|
||||||
|
### Multi-Agent Collaboration
|
||||||
|
- `DelegateAgentTool` for agent-to-agent task delegation
|
||||||
|
|
||||||
|
### LLM Context Awareness
|
||||||
|
- Current datetime automatically injected into LLM context for time-aware responses
|
||||||
|
|
||||||
|
### MCP Server
|
||||||
|
- Pre-configured GitHub MCP Server in seed data (ready to use out of the box)
|
||||||
|
|
||||||
|
### Ollama Auto-Discovery
|
||||||
|
- Auto-detect local Ollama instance on startup
|
||||||
|
- Pre-configured 6 popular local models (Qwen3, Llama, DeepSeek, Gemma, Phi, Mistral)
|
||||||
|
- Local providers sorted first in model management UI
|
||||||
|
|
||||||
|
### Model Management Enhancements
|
||||||
|
- Provider list grouped by Local / Cloud with section headers
|
||||||
|
- Zhipu AI models updated to GLM-5 series (GLM-5-Turbo / GLM-5V-Turbo / GLM-5 / GLM-5.1)
|
||||||
|
- 20+ model providers supported
|
||||||
|
|
||||||
|
### API Docs
|
||||||
|
- Replaced Knife4j with SpringDoc OpenAPI 2.8.16 (`/swagger-ui.html`)
|
||||||
|
|
||||||
|
## Bug Fixes
|
||||||
|
|
||||||
|
- **Security**: Fixed SPA frontend route refresh returning 401
|
||||||
|
- **i18n**: Window title dynamically set from language pack instead of hardcoded
|
||||||
|
- **i18n**: Fixed 5 hardcoded Chinese strings in approval bar
|
||||||
|
- **i18n**: Fixed hardcoded time formatting (locale-aware now)
|
||||||
|
- **Guard**: Aligned tool guard rule names with runtime `@Tool` method names
|
||||||
|
- **LLM**: Fixed Zhipu connection test 404
|
||||||
|
- **UI**: Fixed suggestion cards grid misalignment with longer text
|
||||||
|
- **Upload**: File upload size limit raised to 100MB
|
||||||
|
|
||||||
|
## Download
|
||||||
|
|
||||||
|
| Platform | File | Note |
|
||||||
|
|----------|------|------|
|
||||||
|
| macOS Apple Silicon | `MateClaw_1.0.101_arm64.dmg` | M1 / M2 / M3 / M4 / M5 |
|
||||||
|
| macOS Intel | `MateClaw_1.0.101_x64.dmg` | Intel Mac |
|
||||||
|
| Windows | `MateClaw_1.0.101_Setup.exe` | Windows 10/11 (x64+arm64) |
|
||||||
|
| Windows x64 | `MateClaw_1.0.101_x64_Setup.exe` | Windows 10/11 x64 |
|
||||||
|
| Windows ARM64 | `MateClaw_1.0.101_arm64_Setup.exe` | Windows ARM64 |
|
||||||
|
|
||||||
|
> zip / blockmap / yml files are for auto-update support.
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- GitHub: https://github.com/matevip/mateclaw
|
||||||
|
- Gitee: https://gitee.com/matevip_admin/mateclaw
|
||||||
|
- Documentation: https://mateclaw.com
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/matevip/mateclaw/compare/v1.0.0...v1.0.101
|
||||||
9
mateclaw-desktop/branding.config.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "MateClaw",
|
||||||
|
"tagline": "AI Personal Assistant",
|
||||||
|
"team": "MateClaw Team",
|
||||||
|
"copyright": "Copyright © 2026 MateClaw Team",
|
||||||
|
"appId": "vip.mate.mateclaw",
|
||||||
|
"githubUrl": "https://github.com/matevip/mateclaw",
|
||||||
|
"logoFile": "mateclaw_logo_s.png"
|
||||||
|
}
|
||||||
14
mateclaw-desktop/build/entitlements.mac.inherit.plist
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.inherit</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
18
mateclaw-desktop/build/entitlements.mac.plist
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.network.server</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.files.user-selected.read-write</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
BIN
mateclaw-desktop/build/icon.icns
Normal file
|
After Width: | Height: | Size: 241 KiB |
BIN
mateclaw-desktop/build/icon.ico
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
mateclaw-desktop/build/icon.png
Normal file
|
After Width: | Height: | Size: 241 KiB |
BIN
mateclaw-desktop/build/icon_256.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
139
mateclaw-desktop/electron-builder.cjs
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* electron-builder.cjs — Dynamic build configuration.
|
||||||
|
*
|
||||||
|
* Two packaging modes are controlled by the BUILD_MODE environment variable:
|
||||||
|
*
|
||||||
|
* BUILD_MODE=local (default) Full build: bundles the embedded JRE and
|
||||||
|
* Spring Boot JAR so the desktop app can run a
|
||||||
|
* local backend. Original behavior.
|
||||||
|
*
|
||||||
|
* BUILD_MODE=remote Lightweight build: omits the JRE/JAR
|
||||||
|
* resources (~530 MB smaller on macOS). The app
|
||||||
|
* can only connect to a remote server — the
|
||||||
|
* "local" connection option is hidden in the
|
||||||
|
* splash UI.
|
||||||
|
*
|
||||||
|
* Branding is controlled by branding.config.json or BRAND_* env vars.
|
||||||
|
* See scripts/branding.cjs for details.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* BUILD_MODE=remote npx electron-builder --mac
|
||||||
|
* npm run package:mac:remote
|
||||||
|
* BRAND_NAME=MyAI npm run package:mac:remote
|
||||||
|
*/
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
const { loadBrandConfig } = require('./scripts/branding.cjs')
|
||||||
|
|
||||||
|
const mode = process.env.BUILD_MODE === 'remote' ? 'remote' : 'local'
|
||||||
|
const brand = loadBrandConfig(__dirname)
|
||||||
|
|
||||||
|
// Derive a short slug from the brand name for artifact file names.
|
||||||
|
// "MyAI" → "MyAI", "Cool App" → "Cool_App"
|
||||||
|
const brandSlug = brand.name.replace(/\s+/g, '_')
|
||||||
|
|
||||||
|
// Parse GitHub URL for publish config (owner/repo)
|
||||||
|
let githubOwner = 'matevip'
|
||||||
|
let githubRepo = 'mateclaw'
|
||||||
|
const ghMatch = brand.githubUrl.match(/github\.com\/([^/]+)\/([^/]+)/)
|
||||||
|
if (ghMatch) {
|
||||||
|
githubOwner = ghMatch[1]
|
||||||
|
githubRepo = ghMatch[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {import('electron-builder').Configuration} */
|
||||||
|
const config = {
|
||||||
|
appId: brand.appId,
|
||||||
|
productName: brand.name,
|
||||||
|
copyright: brand.copyright,
|
||||||
|
directories: { output: 'release' },
|
||||||
|
publish: [
|
||||||
|
{
|
||||||
|
provider: 'github',
|
||||||
|
owner: githubOwner,
|
||||||
|
repo: githubRepo,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
files: ['dist-electron', 'dist'],
|
||||||
|
afterPack: 'scripts/trim-playwright-driver.cjs',
|
||||||
|
|
||||||
|
// extraResources: only bundle JRE + JAR in local mode.
|
||||||
|
// In remote mode this array is empty — the packaged app contains only the
|
||||||
|
// Electron + Vue shell, cutting ~530 MB from the installer.
|
||||||
|
extraResources:
|
||||||
|
mode === 'local'
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
from: 'resources/jre/${os}-${arch}/',
|
||||||
|
to: 'jre/',
|
||||||
|
filter: ['**/*'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 'resources/app.jar',
|
||||||
|
to: 'app.jar',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
|
||||||
|
mac: {
|
||||||
|
category: 'public.app-category.productivity',
|
||||||
|
target: [
|
||||||
|
{ target: 'dmg', arch: ['arm64', 'x64'] },
|
||||||
|
{ target: 'zip', arch: ['arm64', 'x64'] },
|
||||||
|
],
|
||||||
|
icon: 'build/icon.icns',
|
||||||
|
hardenedRuntime: true,
|
||||||
|
gatekeeperAssess: false,
|
||||||
|
entitlements: 'build/entitlements.mac.plist',
|
||||||
|
entitlementsInherit: 'build/entitlements.mac.inherit.plist',
|
||||||
|
// Differentiate installers so users can tell local vs remote builds apart.
|
||||||
|
artifactName:
|
||||||
|
mode === 'remote'
|
||||||
|
? `${brandSlug}_Remote_${'$'}{version}_${'$'}{arch}.${'$'}{ext}`
|
||||||
|
: `${brandSlug}_${'$'}{version}_${'$'}{arch}.${'$'}{ext}`,
|
||||||
|
},
|
||||||
|
|
||||||
|
dmg: {
|
||||||
|
contents: [
|
||||||
|
{ x: 130, y: 220 },
|
||||||
|
{ x: 410, y: 220, type: 'link', path: '/Applications' },
|
||||||
|
],
|
||||||
|
title: `${brand.name} ${'$'}{version}`,
|
||||||
|
},
|
||||||
|
|
||||||
|
win: {
|
||||||
|
target: [
|
||||||
|
{ target: 'nsis', arch: 'x64' },
|
||||||
|
{ target: 'nsis', arch: 'arm64' },
|
||||||
|
],
|
||||||
|
icon: 'build/icon.ico',
|
||||||
|
artifactName:
|
||||||
|
mode === 'remote'
|
||||||
|
? `${brandSlug}_Remote_${'$'}{version}_${'$'}{arch}_Setup.${'$'}{ext}`
|
||||||
|
: `${brandSlug}_${'$'}{version}_${'$'}{arch}_Setup.${'$'}{ext}`,
|
||||||
|
},
|
||||||
|
|
||||||
|
nsis: {
|
||||||
|
oneClick: false,
|
||||||
|
perMachine: false,
|
||||||
|
allowToChangeInstallationDirectory: true,
|
||||||
|
deleteAppDataOnUninstall: false,
|
||||||
|
installerIcon: 'build/icon.ico',
|
||||||
|
uninstallerIcon: 'build/icon.ico',
|
||||||
|
installerHeaderIcon: 'build/icon.ico',
|
||||||
|
createDesktopShortcut: true,
|
||||||
|
createStartMenuShortcut: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
linux: {
|
||||||
|
target: ['AppImage'],
|
||||||
|
icon: 'build/icon.png',
|
||||||
|
category: 'Utility',
|
||||||
|
artifactName:
|
||||||
|
mode === 'remote'
|
||||||
|
? `${brandSlug}_Remote_${'$'}{version}.${'$'}{ext}`
|
||||||
|
: `${brandSlug}_${'$'}{version}.${'$'}{ext}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = config
|
||||||
87
mateclaw-desktop/electron/main/config.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
||||||
|
|
||||||
|
// ─── Connection configuration ────────────────────────────────────────────────
|
||||||
|
// Persists how the desktop shell reaches its backend: either an embedded local
|
||||||
|
// JVM ("local") or a centrally deployed remote server ("remote"). Stored as a
|
||||||
|
// small JSON file in userData so no extra dependency is required.
|
||||||
|
|
||||||
|
export type ConnectionMode = 'local' | 'remote'
|
||||||
|
|
||||||
|
export interface RemoteServer {
|
||||||
|
url: string
|
||||||
|
name?: string
|
||||||
|
lastUsed?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectionConfig {
|
||||||
|
// null = no choice made yet (first run → show the connection chooser)
|
||||||
|
mode: ConnectionMode | null
|
||||||
|
remoteUrl: string
|
||||||
|
servers: RemoteServer[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: ConnectionConfig = {
|
||||||
|
mode: null,
|
||||||
|
remoteUrl: '',
|
||||||
|
servers: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfigPath(): string {
|
||||||
|
return join(app.getPath('userData'), 'connection.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadConfig(): ConnectionConfig {
|
||||||
|
try {
|
||||||
|
const path = getConfigPath()
|
||||||
|
if (!existsSync(path)) return { ...DEFAULT_CONFIG }
|
||||||
|
const raw = JSON.parse(readFileSync(path, 'utf-8')) as Partial<ConnectionConfig>
|
||||||
|
return {
|
||||||
|
...DEFAULT_CONFIG,
|
||||||
|
...raw,
|
||||||
|
servers: Array.isArray(raw.servers) ? raw.servers : [],
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[MateClaw] Failed to read connection config:', err)
|
||||||
|
return { ...DEFAULT_CONFIG }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveConfig(patch: Partial<ConnectionConfig>): ConnectionConfig {
|
||||||
|
const merged: ConnectionConfig = { ...loadConfig(), ...patch }
|
||||||
|
try {
|
||||||
|
writeFileSync(getConfigPath(), JSON.stringify(merged, null, 2), 'utf-8')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[MateClaw] Failed to write connection config:', err)
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize a user-entered server URL: trim, default to https when no scheme is
|
||||||
|
// given, and strip a trailing slash. Returns null when the input cannot form a
|
||||||
|
// valid http(s) URL.
|
||||||
|
export function normalizeServerUrl(input: string): string | null {
|
||||||
|
const trimmed = (input || '').trim()
|
||||||
|
if (!trimmed) return null
|
||||||
|
|
||||||
|
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`
|
||||||
|
try {
|
||||||
|
const url = new URL(withScheme)
|
||||||
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
|
||||||
|
// Drop a trailing slash on the path-less root so URLs compare cleanly.
|
||||||
|
return withScheme.replace(/\/+$/, '')
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record a successful remote connection in the most-recently-used server list,
|
||||||
|
// de-duplicating by URL and capping the history length.
|
||||||
|
export function recordServer(url: string, name?: string): ConnectionConfig {
|
||||||
|
const cfg = loadConfig()
|
||||||
|
const now = Date.now()
|
||||||
|
const without = cfg.servers.filter((s) => s.url !== url)
|
||||||
|
const servers: RemoteServer[] = [{ url, name, lastUsed: now }, ...without].slice(0, 8)
|
||||||
|
return saveConfig({ servers })
|
||||||
|
}
|
||||||
1103
mateclaw-desktop/electron/main/index.ts
Normal file
205
mateclaw-desktop/electron/main/localBridge.ts
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
import WebSocket from 'ws'
|
||||||
|
import {
|
||||||
|
readFile,
|
||||||
|
writeFile,
|
||||||
|
editFile,
|
||||||
|
listDir,
|
||||||
|
statPath,
|
||||||
|
executeShell,
|
||||||
|
LocalToolError,
|
||||||
|
} from './localToolsExecutor'
|
||||||
|
import { requestApproval, clearApprovalCache } from './localToolsApproval'
|
||||||
|
|
||||||
|
// ─── Desktop → server local-tool tunnel (client side) ────────────────────────
|
||||||
|
// Opens a WebSocket to the backend's /api/v1/desktop/ws endpoint, advertises the
|
||||||
|
// local tool capabilities, and services "call" frames the server forwards when a
|
||||||
|
// cloud agent invokes a local_* tool. File/shell work runs through the executor
|
||||||
|
// (whitelist-enforced) and approval (native dialog) modules. Reconnects with
|
||||||
|
// backoff while the desktop is meant to be online.
|
||||||
|
|
||||||
|
const PROTOCOL_VERSION = 1
|
||||||
|
const CAPABILITIES = ['read', 'list', 'stat', 'write', 'edit', 'shell']
|
||||||
|
const RECONNECT_MIN_MS = 2000
|
||||||
|
const RECONNECT_MAX_MS = 30_000
|
||||||
|
|
||||||
|
type TokenProvider = () => Promise<string | null>
|
||||||
|
type UrlProvider = () => string
|
||||||
|
|
||||||
|
export class LocalBridge {
|
||||||
|
private ws: WebSocket | null = null
|
||||||
|
private shouldRun = false
|
||||||
|
private reconnectDelay = RECONNECT_MIN_MS
|
||||||
|
private reconnectTimer: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly getBackendUrl: UrlProvider,
|
||||||
|
private readonly getToken: TokenProvider
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// Begin maintaining a connection. Safe to call repeatedly.
|
||||||
|
start(): void {
|
||||||
|
if (this.shouldRun) return
|
||||||
|
this.shouldRun = true
|
||||||
|
void this.connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tear down the tunnel and stop reconnecting (e.g. on logout or app quit).
|
||||||
|
stop(): void {
|
||||||
|
this.shouldRun = false
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer)
|
||||||
|
this.reconnectTimer = null
|
||||||
|
}
|
||||||
|
clearApprovalCache()
|
||||||
|
if (this.ws) {
|
||||||
|
try {
|
||||||
|
this.ws.close()
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
this.ws = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnected(): boolean {
|
||||||
|
return this.ws?.readyState === WebSocket.OPEN
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildWsUrl(token: string): string | null {
|
||||||
|
const base = this.getBackendUrl()
|
||||||
|
if (!base) return null
|
||||||
|
const wsBase = base.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:')
|
||||||
|
return `${wsBase}/api/v1/desktop/ws?token=${encodeURIComponent(token)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private async connect(): Promise<void> {
|
||||||
|
if (!this.shouldRun) return
|
||||||
|
|
||||||
|
const token = await this.getToken()
|
||||||
|
if (!token) {
|
||||||
|
// Not logged in yet — retry shortly without escalating backoff.
|
||||||
|
this.scheduleReconnect(RECONNECT_MIN_MS)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const url = this.buildWsUrl(token)
|
||||||
|
if (!url) {
|
||||||
|
this.scheduleReconnect(RECONNECT_MIN_MS)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[LocalBridge] Connecting tunnel…')
|
||||||
|
// rejectUnauthorized:false mirrors the app's handling of enterprise
|
||||||
|
// self-signed certificates for remote servers the user chose to trust.
|
||||||
|
const ws = new WebSocket(url, { rejectUnauthorized: false })
|
||||||
|
this.ws = ws
|
||||||
|
|
||||||
|
ws.on('open', () => {
|
||||||
|
console.log('[LocalBridge] Tunnel connected')
|
||||||
|
this.reconnectDelay = RECONNECT_MIN_MS
|
||||||
|
this.send({
|
||||||
|
type: 'hello',
|
||||||
|
protocolVersion: PROTOCOL_VERSION,
|
||||||
|
capabilities: CAPABILITIES,
|
||||||
|
platform: process.platform,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('message', (raw: WebSocket.RawData) => {
|
||||||
|
void this.onMessage(raw.toString())
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
console.log('[LocalBridge] Tunnel closed')
|
||||||
|
this.ws = null
|
||||||
|
if (this.shouldRun) this.scheduleReconnect(this.reconnectDelay)
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('error', (err: Error) => {
|
||||||
|
console.warn('[LocalBridge] Tunnel error:', err.message)
|
||||||
|
// 'close' fires after 'error'; reconnect is scheduled there.
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleReconnect(delay: number): void {
|
||||||
|
if (!this.shouldRun || this.reconnectTimer) return
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
this.reconnectTimer = null
|
||||||
|
this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS)
|
||||||
|
void this.connect()
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
private send(obj: unknown): void {
|
||||||
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.send(JSON.stringify(obj))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async onMessage(text: string): Promise<void> {
|
||||||
|
let frame: any
|
||||||
|
try {
|
||||||
|
frame = JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame.type === 'hello-ack') {
|
||||||
|
if (frame.ok === false) console.warn('[LocalBridge] Handshake rejected:', frame.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (frame.type === 'pong') return
|
||||||
|
if (frame.type !== 'call') return
|
||||||
|
|
||||||
|
const { id, method, params } = frame
|
||||||
|
try {
|
||||||
|
const data = await this.dispatch(method, params || {})
|
||||||
|
this.send({ type: 'result', id, ok: true, data })
|
||||||
|
} catch (e) {
|
||||||
|
const code = e instanceof LocalToolError ? e.code : 'ERROR'
|
||||||
|
const error = e instanceof Error ? e.message : String(e)
|
||||||
|
this.send({ type: 'result', id, ok: false, code, error })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async dispatch(method: string, params: any): Promise<unknown> {
|
||||||
|
switch (method) {
|
||||||
|
case 'read_file':
|
||||||
|
return readFile(params.filePath, params.startLine, params.endLine)
|
||||||
|
case 'list_dir':
|
||||||
|
return listDir(params.dirPath)
|
||||||
|
case 'stat':
|
||||||
|
return statPath(params.path)
|
||||||
|
case 'write_file': {
|
||||||
|
await this.approveOrThrow('write_file', params.filePath,
|
||||||
|
`文件: ${params.filePath}\n\n内容预览:\n${preview(params.content)}`)
|
||||||
|
return writeFile(params.filePath, params.content)
|
||||||
|
}
|
||||||
|
case 'edit_file': {
|
||||||
|
await this.approveOrThrow('edit_file', params.filePath,
|
||||||
|
`文件: ${params.filePath}\n\n替换:\n- ${preview(params.oldText, 200)}\n+ ${preview(params.newText, 200)}`)
|
||||||
|
return editFile(params.filePath, params.oldText, params.newText, !!params.replaceAll)
|
||||||
|
}
|
||||||
|
case 'execute_shell': {
|
||||||
|
await this.approveOrThrow('execute_shell', params.command,
|
||||||
|
`命令:\n${params.command}`)
|
||||||
|
return executeShell(params.command, params.timeoutSeconds || 60)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new LocalToolError('UNKNOWN_METHOD', `Unknown method: ${method}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async approveOrThrow(
|
||||||
|
kind: 'write_file' | 'edit_file' | 'execute_shell',
|
||||||
|
subject: string,
|
||||||
|
detail: string
|
||||||
|
): Promise<void> {
|
||||||
|
const { approved } = await requestApproval({ kind, subject, detail })
|
||||||
|
if (!approved) throw new LocalToolError('DENIED', 'User denied')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function preview(text: string | undefined, max = 500): string {
|
||||||
|
const s = text ?? ''
|
||||||
|
return s.length > max ? `${s.slice(0, max)}\n…(${s.length - max} more chars)` : s
|
||||||
|
}
|
||||||
82
mateclaw-desktop/electron/main/localToolsApproval.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { dialog, BrowserWindow } from 'electron'
|
||||||
|
|
||||||
|
// ─── Local tool approval ─────────────────────────────────────────────────────
|
||||||
|
// High-risk local operations (file write/edit, shell execution) prompt the user
|
||||||
|
// with a native dialog showing the full operation context before they run. The
|
||||||
|
// user may tick "don't ask again this session" to temporarily allow matching
|
||||||
|
// operations — same path for file ops, same command prefix for shell — until the
|
||||||
|
// app restarts (the cache is in-memory only).
|
||||||
|
|
||||||
|
// Cache of approvals the user chose to remember this session.
|
||||||
|
const sessionAllow = new Set<string>()
|
||||||
|
|
||||||
|
export type ApprovalKind = 'write_file' | 'edit_file' | 'execute_shell'
|
||||||
|
|
||||||
|
// The cache key scopes "remember": file ops by exact path, shell by command
|
||||||
|
// prefix (first word + first 40 chars) so re-running the same kind of command
|
||||||
|
// doesn't re-prompt, but a different command still does.
|
||||||
|
function cacheKey(kind: ApprovalKind, subject: string): string {
|
||||||
|
if (kind === 'execute_shell') {
|
||||||
|
const head = subject.trim().split(/\s+/)[0] || ''
|
||||||
|
return `shell:${head}:${subject.trim().slice(0, 40)}`
|
||||||
|
}
|
||||||
|
return `${kind}:${subject}`
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApprovalRequest {
|
||||||
|
kind: ApprovalKind
|
||||||
|
// The path (file ops) or command (shell) this approval is scoped to.
|
||||||
|
subject: string
|
||||||
|
// Human-readable detail shown in the dialog body.
|
||||||
|
detail: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalResult {
|
||||||
|
approved: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleFor(kind: ApprovalKind): string {
|
||||||
|
switch (kind) {
|
||||||
|
case 'write_file':
|
||||||
|
return '允许写入本地文件?'
|
||||||
|
case 'edit_file':
|
||||||
|
return '允许修改本地文件?'
|
||||||
|
case 'execute_shell':
|
||||||
|
return '允许执行本地命令?'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestApproval(req: ApprovalRequest): Promise<ApprovalResult> {
|
||||||
|
const key = cacheKey(req.kind, req.subject)
|
||||||
|
if (sessionAllow.has(key)) return { approved: true }
|
||||||
|
|
||||||
|
const parent = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]
|
||||||
|
const options = {
|
||||||
|
type: 'warning' as const,
|
||||||
|
title: titleFor(req.kind),
|
||||||
|
message: titleFor(req.kind),
|
||||||
|
detail: `${req.detail}\n\n该操作由远程 Agent 发起,将在你的本机执行。`,
|
||||||
|
buttons: ['拒绝', '允许'],
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: 0,
|
||||||
|
checkboxLabel: '本次会话不再询问相同操作',
|
||||||
|
checkboxChecked: false,
|
||||||
|
noLink: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = parent
|
||||||
|
? await dialog.showMessageBox(parent, options)
|
||||||
|
: await dialog.showMessageBox(options)
|
||||||
|
|
||||||
|
const approved = result.response === 1
|
||||||
|
if (approved && result.checkboxChecked) {
|
||||||
|
sessionAllow.add(key)
|
||||||
|
}
|
||||||
|
return { approved }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop all remembered approvals — called when the desktop disconnects/logs out so
|
||||||
|
// a new session starts from a clean slate.
|
||||||
|
export function clearApprovalCache(): void {
|
||||||
|
sessionAllow.clear()
|
||||||
|
}
|
||||||
122
mateclaw-desktop/electron/main/localToolsConfig.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import { join, resolve, relative, isAbsolute } from 'path'
|
||||||
|
import { homedir } from 'os'
|
||||||
|
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
||||||
|
|
||||||
|
// ─── Local tools configuration ───────────────────────────────────────────────
|
||||||
|
// Governs the desktop's local file/shell tool proxy: whether it is enabled, the
|
||||||
|
// directory whitelist every local file operation is constrained to, and the
|
||||||
|
// default policy when no whitelist is configured. Stored as its own JSON file in
|
||||||
|
// userData so it is independent of the connection config.
|
||||||
|
|
||||||
|
export interface LocalToolsConfig {
|
||||||
|
// Master switch. When false the desktop advertises no local-tool capabilities
|
||||||
|
// and rejects any forwarded call.
|
||||||
|
enabled: boolean
|
||||||
|
// Absolute (or ~-prefixed) directories the agent may touch. Every local file
|
||||||
|
// operation must resolve to a path inside one of these.
|
||||||
|
allowedDirs: string[]
|
||||||
|
// Policy when allowedDirs is empty:
|
||||||
|
// true (fail-closed, default) → deny all local file access
|
||||||
|
// false (fail-open) → allow the entire local filesystem
|
||||||
|
failClosed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: LocalToolsConfig = {
|
||||||
|
enabled: true,
|
||||||
|
allowedDirs: [],
|
||||||
|
failClosed: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfigPath(): string {
|
||||||
|
return join(app.getPath('userData'), 'local-tools.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadLocalToolsConfig(): LocalToolsConfig {
|
||||||
|
try {
|
||||||
|
const path = getConfigPath()
|
||||||
|
if (!existsSync(path)) return { ...DEFAULT_CONFIG }
|
||||||
|
const raw = JSON.parse(readFileSync(path, 'utf-8')) as Partial<LocalToolsConfig>
|
||||||
|
return {
|
||||||
|
...DEFAULT_CONFIG,
|
||||||
|
...raw,
|
||||||
|
allowedDirs: Array.isArray(raw.allowedDirs) ? raw.allowedDirs : [],
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[MateClaw] Failed to read local-tools config:', err)
|
||||||
|
return { ...DEFAULT_CONFIG }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveLocalToolsConfig(patch: Partial<LocalToolsConfig>): LocalToolsConfig {
|
||||||
|
const merged: LocalToolsConfig = { ...loadLocalToolsConfig(), ...patch }
|
||||||
|
try {
|
||||||
|
writeFileSync(getConfigPath(), JSON.stringify(merged, null, 2), 'utf-8')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[MateClaw] Failed to write local-tools config:', err)
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand a leading ~ to the user's home directory and resolve to an absolute,
|
||||||
|
// normalized path. Returns null for empty input.
|
||||||
|
export function expandPath(input: string): string | null {
|
||||||
|
const trimmed = (input || '').trim()
|
||||||
|
if (!trimmed) return null
|
||||||
|
const expanded = trimmed === '~' || trimmed.startsWith('~/')
|
||||||
|
? join(homedir(), trimmed.slice(1))
|
||||||
|
: trimmed
|
||||||
|
return resolve(expanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether `target` is contained by `dir` (or equal to it). Both are resolved
|
||||||
|
// absolute paths. Uses path.relative so it is symlink-name-agnostic but does not
|
||||||
|
// follow symlinks — the whitelist is enforced on the lexical path the agent asked
|
||||||
|
// for, which is the path the user approved.
|
||||||
|
function isInside(dir: string, target: string): boolean {
|
||||||
|
const rel = relative(dir, target)
|
||||||
|
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PathCheck {
|
||||||
|
allowed: boolean
|
||||||
|
// Resolved absolute path (when input was parseable), for use by the caller.
|
||||||
|
resolved: string | null
|
||||||
|
// Machine-readable reason when not allowed.
|
||||||
|
reason?: 'disabled' | 'unparseable' | 'whitelist'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decide whether a local file operation on `inputPath` is permitted by the
|
||||||
|
// current configuration. This is the single chokepoint every file tool calls.
|
||||||
|
export function checkPath(inputPath: string): PathCheck {
|
||||||
|
const cfg = loadLocalToolsConfig()
|
||||||
|
if (!cfg.enabled) return { allowed: false, resolved: null, reason: 'disabled' }
|
||||||
|
|
||||||
|
const target = expandPath(inputPath)
|
||||||
|
if (!target) return { allowed: false, resolved: null, reason: 'unparseable' }
|
||||||
|
|
||||||
|
if (cfg.allowedDirs.length === 0) {
|
||||||
|
return { allowed: !cfg.failClosed, resolved: target, reason: cfg.failClosed ? 'whitelist' : undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dir of cfg.allowedDirs) {
|
||||||
|
const base = expandPath(dir)
|
||||||
|
if (base && isInside(base, target)) {
|
||||||
|
return { allowed: true, resolved: target }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { allowed: false, resolved: target, reason: 'whitelist' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The working directory to run a shell command in: the first configured
|
||||||
|
// whitelist directory, falling back to the user's home. Shell commands are not
|
||||||
|
// path-checked (they are arbitrary), so they are gated by approval + timeout and
|
||||||
|
// pinned to a sensible cwd rather than wherever the app launched.
|
||||||
|
export function shellWorkingDir(): string {
|
||||||
|
const cfg = loadLocalToolsConfig()
|
||||||
|
for (const dir of cfg.allowedDirs) {
|
||||||
|
const base = expandPath(dir)
|
||||||
|
if (base && existsSync(base)) return base
|
||||||
|
}
|
||||||
|
return homedir()
|
||||||
|
}
|
||||||
194
mateclaw-desktop/electron/main/localToolsExecutor.ts
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
import { spawn } from 'child_process'
|
||||||
|
import {
|
||||||
|
readFileSync,
|
||||||
|
writeFileSync,
|
||||||
|
mkdirSync,
|
||||||
|
readdirSync,
|
||||||
|
statSync,
|
||||||
|
existsSync,
|
||||||
|
} from 'fs'
|
||||||
|
import { dirname } from 'path'
|
||||||
|
import { checkPath, shellWorkingDir } from './localToolsConfig'
|
||||||
|
|
||||||
|
// ─── Local tool executor ─────────────────────────────────────────────────────
|
||||||
|
// Runs the actual file/shell operations on the user's machine. Every file
|
||||||
|
// operation is constrained to the directory whitelist via checkPath(); shell
|
||||||
|
// commands are gated by approval (handled by the caller) and a hard timeout.
|
||||||
|
// Output limits mirror the server-side tools: ~30KB for file reads, ~10KB each
|
||||||
|
// for shell stdout/stderr.
|
||||||
|
|
||||||
|
const MAX_FILE_BYTES = 30 * 1024
|
||||||
|
const MAX_SHELL_BYTES = 10_000
|
||||||
|
const IS_WINDOWS = process.platform === 'win32'
|
||||||
|
|
||||||
|
export class LocalToolError extends Error {
|
||||||
|
constructor(public code: string, message: string) {
|
||||||
|
super(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAllowed(inputPath: string): string {
|
||||||
|
const check = checkPath(inputPath)
|
||||||
|
if (!check.allowed) {
|
||||||
|
if (check.reason === 'disabled') {
|
||||||
|
throw new LocalToolError('DISABLED', 'Local tools are disabled in the desktop app')
|
||||||
|
}
|
||||||
|
if (check.reason === 'unparseable') {
|
||||||
|
throw new LocalToolError('BAD_PATH', `Invalid path: ${inputPath}`)
|
||||||
|
}
|
||||||
|
throw new LocalToolError(
|
||||||
|
'WHITELIST',
|
||||||
|
`Path is outside the allowed local directories: ${inputPath}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return check.resolved as string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readFile(filePath: string, startLine?: number, endLine?: number): unknown {
|
||||||
|
const path = requireAllowed(filePath)
|
||||||
|
if (!existsSync(path)) throw new LocalToolError('NOT_FOUND', `File not found: ${filePath}`)
|
||||||
|
if (statSync(path).isDirectory()) {
|
||||||
|
throw new LocalToolError('IS_DIR', `Path is a directory: ${filePath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = readFileSync(path, 'utf-8')
|
||||||
|
const allLines = raw.split('\n')
|
||||||
|
const totalLines = allLines.length
|
||||||
|
|
||||||
|
const start = startLine && startLine > 0 ? startLine : 1
|
||||||
|
const end = endLine && endLine > 0 ? Math.min(endLine, totalLines) : totalLines
|
||||||
|
if (start > totalLines) {
|
||||||
|
throw new LocalToolError('RANGE', `startLine ${start} exceeds total lines ${totalLines}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = ''
|
||||||
|
let readLines = 0
|
||||||
|
let truncated = false
|
||||||
|
for (let i = start - 1; i < end; i++) {
|
||||||
|
const line = `${String(i + 1).padStart(6)}\t${allLines[i]}\n`
|
||||||
|
if (Buffer.byteLength(content + line, 'utf-8') > MAX_FILE_BYTES) {
|
||||||
|
truncated = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
content += line
|
||||||
|
readLines++
|
||||||
|
}
|
||||||
|
|
||||||
|
return { filePath: path, totalLines, startLine: start, readLines, content, truncated }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeFile(filePath: string, content: string): unknown {
|
||||||
|
const path = requireAllowed(filePath)
|
||||||
|
const existed = existsSync(path)
|
||||||
|
mkdirSync(dirname(path), { recursive: true })
|
||||||
|
writeFileSync(path, content ?? '', 'utf-8')
|
||||||
|
return {
|
||||||
|
filePath: path,
|
||||||
|
bytesWritten: Buffer.byteLength(content ?? '', 'utf-8'),
|
||||||
|
created: !existed,
|
||||||
|
overwritten: existed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function editFile(
|
||||||
|
filePath: string,
|
||||||
|
oldText: string,
|
||||||
|
newText: string,
|
||||||
|
replaceAll: boolean
|
||||||
|
): unknown {
|
||||||
|
const path = requireAllowed(filePath)
|
||||||
|
if (!existsSync(path)) throw new LocalToolError('NOT_FOUND', `File not found: ${filePath}`)
|
||||||
|
|
||||||
|
const original = readFileSync(path, 'utf-8')
|
||||||
|
if (!original.includes(oldText)) {
|
||||||
|
throw new LocalToolError('NO_MATCH', 'oldText not found in file')
|
||||||
|
}
|
||||||
|
|
||||||
|
let replacements = 0
|
||||||
|
let updated: string
|
||||||
|
if (replaceAll) {
|
||||||
|
updated = original.split(oldText).join(newText)
|
||||||
|
replacements = original.split(oldText).length - 1
|
||||||
|
} else {
|
||||||
|
updated = original.replace(oldText, newText)
|
||||||
|
replacements = 1
|
||||||
|
}
|
||||||
|
writeFileSync(path, updated, 'utf-8')
|
||||||
|
return { filePath: path, replacements, replaceAll: !!replaceAll }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listDir(dirPath: string): unknown {
|
||||||
|
const path = requireAllowed(dirPath)
|
||||||
|
if (!existsSync(path)) throw new LocalToolError('NOT_FOUND', `Directory not found: ${dirPath}`)
|
||||||
|
if (!statSync(path).isDirectory()) {
|
||||||
|
throw new LocalToolError('NOT_DIR', `Path is not a directory: ${dirPath}`)
|
||||||
|
}
|
||||||
|
const entries = readdirSync(path, { withFileTypes: true }).map((e) => ({
|
||||||
|
name: e.name,
|
||||||
|
type: e.isDirectory() ? 'dir' : 'file',
|
||||||
|
}))
|
||||||
|
return { dirPath: path, entries }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statPath(targetPath: string): unknown {
|
||||||
|
const path = requireAllowed(targetPath)
|
||||||
|
if (!existsSync(path)) throw new LocalToolError('NOT_FOUND', `Path not found: ${targetPath}`)
|
||||||
|
const st = statSync(path)
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
size: st.size,
|
||||||
|
isDirectory: st.isDirectory(),
|
||||||
|
modifiedTime: st.mtime.toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateUtf8(buf: Buffer, maxBytes: number): { text: string; truncated: boolean } {
|
||||||
|
if (buf.length <= maxBytes) return { text: buf.toString('utf-8'), truncated: false }
|
||||||
|
return {
|
||||||
|
text: buf.subarray(0, maxBytes).toString('utf-8') +
|
||||||
|
`\n... [output truncated, exceeds ${maxBytes} byte limit]`,
|
||||||
|
truncated: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function executeShell(command: string, timeoutSeconds: number): Promise<unknown> {
|
||||||
|
const cwd = shellWorkingDir()
|
||||||
|
const timeoutMs = Math.min(Math.max(timeoutSeconds, 1), 300) * 1000
|
||||||
|
|
||||||
|
// cmd.exe on Windows, /bin/sh on macOS/Linux — mirrors the server tool.
|
||||||
|
const child = IS_WINDOWS
|
||||||
|
? spawn('cmd.exe', ['/D', '/S', '/C', command], { cwd })
|
||||||
|
: spawn('/bin/sh', ['-c', command], { cwd })
|
||||||
|
|
||||||
|
const stdoutChunks: Buffer[] = []
|
||||||
|
const stderrChunks: Buffer[] = []
|
||||||
|
child.stdout.on('data', (d: Buffer) => stdoutChunks.push(d))
|
||||||
|
child.stderr.on('data', (d: Buffer) => stderrChunks.push(d))
|
||||||
|
|
||||||
|
return new Promise((resolvePromise) => {
|
||||||
|
let timedOut = false
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
timedOut = true
|
||||||
|
child.kill('SIGKILL')
|
||||||
|
}, timeoutMs)
|
||||||
|
|
||||||
|
const finish = (exitCode: number) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
const out = truncateUtf8(Buffer.concat(stdoutChunks), MAX_SHELL_BYTES)
|
||||||
|
const err = truncateUtf8(Buffer.concat(stderrChunks), MAX_SHELL_BYTES)
|
||||||
|
resolvePromise({
|
||||||
|
command,
|
||||||
|
exitCode,
|
||||||
|
stdout: out.text,
|
||||||
|
stderr: err.text,
|
||||||
|
timedOut,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
child.on('error', (e) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
resolvePromise({ command, exitCode: -1, stdout: '', stderr: String(e), timedOut: false })
|
||||||
|
})
|
||||||
|
child.on('close', (code) => finish(code == null ? -1 : code))
|
||||||
|
})
|
||||||
|
}
|
||||||
55
mateclaw-desktop/electron/preload/index.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
|
|
||||||
|
// Expose safe APIs to the renderer process (splash screen)
|
||||||
|
contextBridge.exposeInMainWorld('mateClawAPI', {
|
||||||
|
// Platform info
|
||||||
|
getPlatform: () => ipcRenderer.invoke('app:get-platform'),
|
||||||
|
getVersion: () => ipcRenderer.invoke('app:get-version'),
|
||||||
|
getBuildMode: () => ipcRenderer.invoke('app:get-build-mode'),
|
||||||
|
getBackendUrl: () => ipcRenderer.invoke('app:get-backend-url'),
|
||||||
|
isBackendReady: () => ipcRenderer.invoke('app:is-backend-ready'),
|
||||||
|
getUserDataPath: () => ipcRenderer.invoke('app:get-user-data-path'),
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
openExternal: (url: string) => ipcRenderer.invoke('app:open-external', url),
|
||||||
|
restartBackend: () => ipcRenderer.invoke('app:restart-backend'),
|
||||||
|
navigateToApp: () => ipcRenderer.invoke('app:navigate-to-app'),
|
||||||
|
|
||||||
|
// Connection management
|
||||||
|
getConnectionConfig: () => ipcRenderer.invoke('connection:get-config'),
|
||||||
|
testConnection: (url: string) => ipcRenderer.invoke('connection:test', url),
|
||||||
|
useLocalConnection: () => ipcRenderer.invoke('connection:use-local'),
|
||||||
|
useRemoteConnection: (url: string) => ipcRenderer.invoke('connection:use-remote', url),
|
||||||
|
switchServer: () => ipcRenderer.invoke('connection:switch-server'),
|
||||||
|
|
||||||
|
// Backend status events
|
||||||
|
onBackendStatus: (callback: (status: string) => void) => {
|
||||||
|
const handler = (_event: Electron.IpcRendererEvent, status: string) => callback(status)
|
||||||
|
ipcRenderer.on('backend:status', handler)
|
||||||
|
return () => ipcRenderer.removeListener('backend:status', handler)
|
||||||
|
},
|
||||||
|
|
||||||
|
onBackendCrashed: (callback: (message: string) => void) => {
|
||||||
|
const handler = (_event: Electron.IpcRendererEvent, message: string) => callback(message)
|
||||||
|
ipcRenderer.on('backend:crashed', handler)
|
||||||
|
return () => ipcRenderer.removeListener('backend:crashed', handler)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Local tools (file/shell proxy) management
|
||||||
|
getLocalToolsConfig: () => ipcRenderer.invoke('localtools:get-config'),
|
||||||
|
setLocalToolsConfig: (patch: unknown) => ipcRenderer.invoke('localtools:set-config', patch),
|
||||||
|
addLocalToolsDir: () => ipcRenderer.invoke('localtools:add-dir'),
|
||||||
|
removeLocalToolsDir: (dir: string) => ipcRenderer.invoke('localtools:remove-dir', dir),
|
||||||
|
|
||||||
|
// Auto-updater
|
||||||
|
getUpdaterState: () => ipcRenderer.invoke('updater:get-state'),
|
||||||
|
checkForUpdates: () => ipcRenderer.invoke('updater:check'),
|
||||||
|
downloadUpdate: () => ipcRenderer.invoke('updater:download'),
|
||||||
|
installUpdate: () => ipcRenderer.invoke('updater:install'),
|
||||||
|
|
||||||
|
onUpdaterState: (callback: (state: any) => void) => {
|
||||||
|
const handler = (_event: Electron.IpcRendererEvent, state: any) => callback(state)
|
||||||
|
ipcRenderer.on('updater:state', handler)
|
||||||
|
return () => ipcRenderer.removeListener('updater:state', handler)
|
||||||
|
},
|
||||||
|
})
|
||||||
27
mateclaw-desktop/index.html
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>MateClaw</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
|
||||||
|
'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
51
mateclaw-desktop/package.json
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "mateclaw-desktop",
|
||||||
|
"version": "1.7.0",
|
||||||
|
"description": "MateClaw Desktop - AI Assistant powered by Spring AI Alibaba",
|
||||||
|
"author": "MateClaw Team",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"main": "dist-electron/main/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"setup:jre": "bash scripts/download-jre.sh",
|
||||||
|
"setup:jar": "bash scripts/build.sh",
|
||||||
|
"setup": "npm run setup:jar && npm run setup:jre",
|
||||||
|
"setup:all-platforms": "bash scripts/build-all-platforms.sh --all",
|
||||||
|
"package:mac": "npm run build && electron-builder --mac",
|
||||||
|
"package:mac:local": "npm run build && cross-env BUILD_MODE=local electron-builder --mac",
|
||||||
|
"package:mac:remote": "npm run build && cross-env BUILD_MODE=remote electron-builder --mac",
|
||||||
|
"package:win": "npm run build && electron-builder --win",
|
||||||
|
"package:win:local": "npm run build && cross-env BUILD_MODE=local electron-builder --win",
|
||||||
|
"package:win:remote": "npm run build && cross-env BUILD_MODE=remote electron-builder --win",
|
||||||
|
"package:all": "bash scripts/build-all-platforms.sh --all",
|
||||||
|
"package:all:local": "bash scripts/build-all-platforms.sh --local",
|
||||||
|
"package:all:remote": "bash scripts/build-all-platforms.sh --remote",
|
||||||
|
"publish:github": "bash scripts/publish-github.sh",
|
||||||
|
"publish:github:draft": "bash scripts/publish-github.sh --draft"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"electron-updater": "^6.3.9",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"ws": "^8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
|
"electron": "^33.3.1",
|
||||||
|
"electron-builder": "^25.1.8",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vite": "^6.0.7",
|
||||||
|
"vite-plugin-electron": "^0.28.8",
|
||||||
|
"vite-plugin-electron-renderer": "^0.14.6",
|
||||||
|
"vue-tsc": "^2.2.0"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"electron",
|
||||||
|
"esbuild"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
3942
mateclaw-desktop/pnpm-lock.yaml
generated
Normal file
BIN
mateclaw-desktop/public/logo/mateclaw_logo_s.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
1355
mateclaw-desktop/src/App.vue
Normal file
67
mateclaw-desktop/src/env.d.ts
vendored
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdaterState {
|
||||||
|
status: 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error'
|
||||||
|
version?: string
|
||||||
|
releaseNotes?: string
|
||||||
|
progress?: { percent: number; bytesPerSecond: number; transferred: number; total: number }
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RemoteServer {
|
||||||
|
url: string
|
||||||
|
name?: string
|
||||||
|
lastUsed?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConnectionConfigState {
|
||||||
|
mode: 'local' | 'remote' | null
|
||||||
|
remoteUrl: string
|
||||||
|
servers: RemoteServer[]
|
||||||
|
forceChoose: boolean
|
||||||
|
buildMode: 'local' | 'remote'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConnectionTestResult {
|
||||||
|
ok: boolean
|
||||||
|
status?: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MateClawAPI {
|
||||||
|
getPlatform: () => Promise<string>
|
||||||
|
getVersion: () => Promise<string>
|
||||||
|
getBuildMode: () => Promise<'local' | 'remote'>
|
||||||
|
getBackendUrl: () => Promise<string>
|
||||||
|
isBackendReady: () => Promise<boolean>
|
||||||
|
getUserDataPath: () => Promise<string>
|
||||||
|
openExternal: (url: string) => Promise<void>
|
||||||
|
restartBackend: () => Promise<void>
|
||||||
|
onBackendStatus: (callback: (status: string) => void) => () => void
|
||||||
|
onBackendCrashed: (callback: (message: string) => void) => () => void
|
||||||
|
navigateToApp: () => void
|
||||||
|
|
||||||
|
// Connection management
|
||||||
|
getConnectionConfig: () => Promise<ConnectionConfigState>
|
||||||
|
testConnection: (url: string) => Promise<ConnectionTestResult>
|
||||||
|
useLocalConnection: () => Promise<void>
|
||||||
|
useRemoteConnection: (url: string) => Promise<ConnectionTestResult>
|
||||||
|
switchServer: () => Promise<void>
|
||||||
|
|
||||||
|
// Auto-updater
|
||||||
|
getUpdaterState: () => Promise<UpdaterState>
|
||||||
|
checkForUpdates: () => Promise<UpdaterState>
|
||||||
|
downloadUpdate: () => Promise<void>
|
||||||
|
installUpdate: () => Promise<void>
|
||||||
|
onUpdaterState: (callback: (state: UpdaterState) => void) => () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
mateClawAPI: MateClawAPI
|
||||||
|
}
|
||||||
4
mateclaw-desktop/src/main.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
25
mateclaw-desktop/tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "electron/**/*.ts"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
12
mateclaw-desktop/tsconfig.node.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": false
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
1
mateclaw-desktop/tsconfig.node.tsbuildinfo
Normal file
72
mateclaw-desktop/vite.config.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import electron from 'vite-plugin-electron'
|
||||||
|
import renderer from 'vite-plugin-electron-renderer'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
import { brandingPlugin } from './scripts/branding.cjs'
|
||||||
|
|
||||||
|
export default defineConfig(({ command }) => {
|
||||||
|
const isServe = command === 'serve'
|
||||||
|
const isBuild = command === 'build'
|
||||||
|
|
||||||
|
// Shared branding plugin instance — applied to the renderer build as well
|
||||||
|
// as the electron main/preload builds so brand strings are replaced
|
||||||
|
// everywhere without touching source code.
|
||||||
|
const brand = brandingPlugin()
|
||||||
|
|
||||||
|
return {
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
// White-label branding: replaces "MateClaw" with the configured brand
|
||||||
|
// name at build time. Source code stays untouched. Configure via
|
||||||
|
// branding.config.json or BRAND_* env vars.
|
||||||
|
brand,
|
||||||
|
electron([
|
||||||
|
{
|
||||||
|
entry: 'electron/main/index.ts',
|
||||||
|
onstart(args) {
|
||||||
|
args.startup()
|
||||||
|
},
|
||||||
|
vite: {
|
||||||
|
plugins: [brand],
|
||||||
|
build: {
|
||||||
|
sourcemap: isServe,
|
||||||
|
minify: isBuild,
|
||||||
|
outDir: 'dist-electron/main',
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['electron', 'electron-updater'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
entry: 'electron/preload/index.ts',
|
||||||
|
onstart(args) {
|
||||||
|
args.reload()
|
||||||
|
},
|
||||||
|
vite: {
|
||||||
|
plugins: [brand],
|
||||||
|
build: {
|
||||||
|
sourcemap: isServe ? 'inline' : undefined,
|
||||||
|
minify: isBuild,
|
||||||
|
outDir: 'dist-electron/preload',
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['electron'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
renderer(),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, 'src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
@ -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>
|
||||||
|
|
||||||
|
<parent>
|
||||||
<groupId>vip.mate</groupId>
|
<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>
|
||||||
|
|||||||
@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,53 @@
|
|||||||
|
package vip.mate.plugin.api.search;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPI for plugin-provided web-search providers.
|
||||||
|
* <p>
|
||||||
|
* Implementations are registered via {@code PluginContext#registerSearchProvider}
|
||||||
|
* and appear in the platform's search provider chain alongside the built-in
|
||||||
|
* providers (serper / tavily / searxng / duckduckgo).
|
||||||
|
* <p>
|
||||||
|
* Configuration (API keys, base URLs, ...) is NOT passed in — plugins read their
|
||||||
|
* own config declared in {@code mateclaw-plugin.json} via
|
||||||
|
* {@code PluginContext#getConfig(String, Class)}.
|
||||||
|
*
|
||||||
|
* @author MateClaw Team
|
||||||
|
*/
|
||||||
|
public interface PluginSearchProvider {
|
||||||
|
|
||||||
|
/** Globally unique provider id, e.g. "my-search". Must not clash with built-in ids. */
|
||||||
|
String id();
|
||||||
|
|
||||||
|
/** Human-readable display name. */
|
||||||
|
String label();
|
||||||
|
|
||||||
|
/** Whether this provider needs a credential (affects auto-detect priority). */
|
||||||
|
default boolean requiresCredential() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-detect ordering (ascending). Built-in providers occupy 50-400;
|
||||||
|
* plugin providers default to 500 (after built-ins) but may override.
|
||||||
|
*/
|
||||||
|
default int autoDetectOrder() {
|
||||||
|
return 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the provider is currently usable — typically: required config present.
|
||||||
|
* Called on every provider resolution; keep it cheap (no network I/O).
|
||||||
|
*/
|
||||||
|
boolean isAvailable();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the search.
|
||||||
|
*
|
||||||
|
* @param query the query (never null)
|
||||||
|
* @return results; empty list if nothing found. Must not return null.
|
||||||
|
* Throw on failure — the platform falls back to the next provider.
|
||||||
|
*/
|
||||||
|
List<PluginSearchResult> search(PluginSearchQuery query);
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
package vip.mate.plugin.api.search;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search query passed from the platform to a plugin search provider.
|
||||||
|
* <p>
|
||||||
|
* Self-contained SDK type — must not depend on any mateclaw-server class,
|
||||||
|
* because plugin JARs are compiled only against mateclaw-plugin-api.
|
||||||
|
*
|
||||||
|
* @param query search keywords (never null/blank)
|
||||||
|
* @param freshness time-range filter: day / week / month / year (nullable)
|
||||||
|
* @param language language preference, e.g. zh-CN / en (nullable)
|
||||||
|
* @param count max results 1-10, already clamped by the platform (never null)
|
||||||
|
*
|
||||||
|
* @author MateClaw Team
|
||||||
|
*/
|
||||||
|
public record PluginSearchQuery(
|
||||||
|
String query,
|
||||||
|
String freshness,
|
||||||
|
String language,
|
||||||
|
Integer count
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
package vip.mate.plugin.api.search;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single search result returned by a plugin search provider.
|
||||||
|
* <p>
|
||||||
|
* Self-contained SDK type — mirrors the platform's internal SearchResult
|
||||||
|
* (title/url/snippet/source/date) without depending on server classes.
|
||||||
|
*
|
||||||
|
* @param title result title
|
||||||
|
* @param url result link
|
||||||
|
* @param snippet short excerpt
|
||||||
|
* @param source source domain, e.g. "reuters.com" (nullable)
|
||||||
|
* @param date published date as raw string (nullable)
|
||||||
|
*
|
||||||
|
* @author MateClaw Team
|
||||||
|
*/
|
||||||
|
public record PluginSearchResult(
|
||||||
|
String title,
|
||||||
|
String url,
|
||||||
|
String snippet,
|
||||||
|
String source,
|
||||||
|
String date
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|
||||||
|
<parent>
|
||||||
<groupId>vip.mate</groupId>
|
<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>
|
||||||
|
|||||||
50
mateclaw-plugin-search-sample/pom.xml
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>vip.mate</groupId>
|
||||||
|
<artifactId>mateclaw</artifactId>
|
||||||
|
<version>${revision}</version>
|
||||||
|
<relativePath>../pom.xml</relativePath>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>mateclaw-plugin-search-sample</artifactId>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<name>MateClaw Search Provider Sample Plugin</name>
|
||||||
|
<description>Sample plugin registering a custom web-search provider via the MateClaw Plugin SDK</description>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<!-- MateClaw Plugin API -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>vip.mate</groupId>
|
||||||
|
<artifactId>mateclaw-plugin-api</artifactId>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring AI (provided by the platform) — PluginContext method signatures
|
||||||
|
reference ToolCallback/ChatModel, so it must be resolvable at compile time -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.ai</groupId>
|
||||||
|
<artifactId>spring-ai-model</artifactId>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Jackson (provided by the platform parent classloader) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
|
<artifactId>jackson-databind</artifactId>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- SLF4J (provided by the platform) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.slf4j</groupId>
|
||||||
|
<artifactId>slf4j-api</artifactId>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
@ -0,0 +1,124 @@
|
|||||||
|
package vip.mate.plugin.sample.search;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import vip.mate.plugin.api.MateClawPlugin;
|
||||||
|
import vip.mate.plugin.api.PluginContext;
|
||||||
|
import vip.mate.plugin.api.search.PluginSearchProvider;
|
||||||
|
import vip.mate.plugin.api.search.PluginSearchQuery;
|
||||||
|
import vip.mate.plugin.api.search.PluginSearchResult;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sample plugin demonstrating {@code PluginType.SEARCH}: registers a search
|
||||||
|
* provider that queries a configurable JSON endpoint. Expected response shape:
|
||||||
|
* {@code {"results":[{"title":"...","url":"...","snippet":"..."}]}}
|
||||||
|
*
|
||||||
|
* @author MateClaw Team
|
||||||
|
*/
|
||||||
|
public class SimpleSearchPlugin implements MateClawPlugin {
|
||||||
|
|
||||||
|
private Logger log;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onLoad(PluginContext context) {
|
||||||
|
this.log = context.getLogger();
|
||||||
|
context.registerSearchProvider(new DemoSearchProvider(context));
|
||||||
|
log.info("SimpleSearchPlugin loaded, search provider registered");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onEnable() {
|
||||||
|
if (log != null) log.info("SimpleSearchPlugin enabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDisable() {
|
||||||
|
if (log != null) log.info("SimpleSearchPlugin disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
static class DemoSearchProvider implements PluginSearchProvider {
|
||||||
|
|
||||||
|
private static final Duration TIMEOUT = Duration.ofSeconds(15);
|
||||||
|
|
||||||
|
private final PluginContext context;
|
||||||
|
private final HttpClient http = HttpClient.newBuilder().connectTimeout(TIMEOUT).build();
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
DemoSearchProvider(PluginContext context) {
|
||||||
|
this.context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String id() {
|
||||||
|
return "demo-search";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String label() {
|
||||||
|
return "Demo Search";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isAvailable() {
|
||||||
|
String baseUrl = context.getConfig("baseUrl", String.class);
|
||||||
|
return baseUrl != null && !baseUrl.isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<PluginSearchResult> search(PluginSearchQuery query) {
|
||||||
|
String baseUrl = context.getConfig("baseUrl", String.class);
|
||||||
|
String apiKey = context.getConfig("apiKey", String.class);
|
||||||
|
|
||||||
|
// Minimal demo: only q/count are wired. query.freshness() and query.language()
|
||||||
|
// are also available — see the built-in SearXNGSearchProvider for how to map them.
|
||||||
|
String url = baseUrl + (baseUrl.contains("?") ? "&" : "?")
|
||||||
|
+ "q=" + URLEncoder.encode(query.query(), StandardCharsets.UTF_8)
|
||||||
|
+ "&count=" + query.count();
|
||||||
|
|
||||||
|
HttpRequest.Builder req = HttpRequest.newBuilder(URI.create(url))
|
||||||
|
.timeout(TIMEOUT)
|
||||||
|
.GET();
|
||||||
|
if (apiKey != null && !apiKey.isBlank()) {
|
||||||
|
req.header("Authorization", "Bearer " + apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
HttpResponse<String> resp = http.send(req.build(), HttpResponse.BodyHandlers.ofString());
|
||||||
|
if (resp.statusCode() != 200) {
|
||||||
|
throw new IllegalStateException("Search endpoint returned HTTP " + resp.statusCode());
|
||||||
|
}
|
||||||
|
return parse(resp.body());
|
||||||
|
} catch (IllegalStateException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("Search request failed: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<PluginSearchResult> parse(String body) throws JsonProcessingException {
|
||||||
|
List<PluginSearchResult> results = new ArrayList<>();
|
||||||
|
JsonNode items = objectMapper.readTree(body).path("results");
|
||||||
|
for (JsonNode item : items) {
|
||||||
|
results.add(new PluginSearchResult(
|
||||||
|
item.path("title").asText(null),
|
||||||
|
item.path("url").asText(null),
|
||||||
|
item.path("snippet").asText(null),
|
||||||
|
null,
|
||||||
|
null));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "mateclaw-plugin-search-demo",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "search",
|
||||||
|
"displayName": "Demo Search Provider",
|
||||||
|
"description": "Registers a custom web-search provider backed by a configurable JSON search endpoint.",
|
||||||
|
"entrypoint": "vip.mate.plugin.sample.search.SimpleSearchPlugin",
|
||||||
|
"minPlatformVersion": "1.1.0",
|
||||||
|
"author": "MateClaw Team",
|
||||||
|
"config": {
|
||||||
|
"baseUrl": {
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"secret": false,
|
||||||
|
"description": "Search endpoint returning {\"results\":[{\"title\",\"url\",\"snippet\"}]}"
|
||||||
|
},
|
||||||
|
"apiKey": {
|
||||||
|
"type": "string",
|
||||||
|
"required": false,
|
||||||
|
"secret": true,
|
||||||
|
"description": "Optional bearer token sent as Authorization header"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,13 +1,122 @@
|
|||||||
# 多阶段构建
|
# 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
|
||||||
|
|
||||||
|
# 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 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 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"]
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
<parent>
|
||||||
<groupId>vip.mate</groupId>
|
<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 Core(StateGraph 工作流引擎) ===== -->
|
<!-- ===== 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 Anthropic(Claude 模型支持) ===== -->
|
<!-- ===== 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 OpenAPI(Swagger 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 SDK(WebSocket 长连接,无需公网 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 SDK(WebSocket 长连接 + 事件分发) ===== -->
|
<!-- ===== 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>
|
||||||
|
|
||||||
<!-- ===== JDA(Discord 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,127 @@
|
|||||||
</exclusions>
|
</exclusions>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- ===== Spring WebSocket(Talk 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 SDK(Socket 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  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>
|
||||||
|
|
||||||
|
<!-- ===== 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 +337,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 +368,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 +426,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 +442,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>
|
||||||
|
|||||||
22
mateclaw-server/settings.xml
Normal 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>
|
||||||
@ -3,42 +3,120 @@ 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) {
|
||||||
SpringApplication.run(MateClawApplication.class, args);
|
SpringApplication.run(MateClawApplication.class, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MyBatis Plus 分页插件
|
* 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("");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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> {
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,25 @@ 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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 +78,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 +113,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<T> 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 +216,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 +330,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(() -> {
|
||||||
|
ChatOriginHolder.set(captured);
|
||||||
|
return withLifecycleFlux(agentId, message, conversationId,
|
||||||
|
(msg, convId) -> capable.chatStructuredStream(msg, convId,
|
||||||
requesterId != null ? requesterId : "")
|
requesterId != null ? requesterId : "")
|
||||||
.doFinally(signal -> ThinkingLevelHolder.clear());
|
.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 +382,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 +439,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 +505,193 @@ 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());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 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 <memory-context> 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) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
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);
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 内部方法 ====================
|
// ==================== 内部方法 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve (and cache) the Agent graph for a conversation, honouring the
|
||||||
|
* conversation's pinned model. Conversations with no pin — IM channels
|
||||||
|
* before issue #183 fix, cron, sub-tasks, or rows not yet created —
|
||||||
|
* 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 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) {
|
private BaseAgent getOrBuildAgent(Long agentId) {
|
||||||
return agentInstances.computeIfAbsent(agentId, id -> {
|
return getOrBuildAgent(agentId, null, null);
|
||||||
AgentEntity entity = getAgent(id);
|
}
|
||||||
|
|
||||||
|
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())) {
|
if (!Boolean.TRUE.equals(entity.getEnabled())) {
|
||||||
throw new MateClawException("err.agent.disabled", "Agent 已禁用: " + entity.getName());
|
throw new MateClawException("err.agent.disabled", "Agent 已禁用: " + entity.getName());
|
||||||
}
|
}
|
||||||
return agentGraphBuilder.build(entity);
|
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 +710,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 Bean、ToolCallbackProvider、MCP server 暴露的 tool callbacks
|
* 将 @Tool Bean、ToolCallbackProvider、MCP 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,90 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,33 +0,0 @@
|
|||||||
package vip.mate.agent;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 请求级思考深度的 ThreadLocal 持有器。
|
|
||||||
* <p>
|
|
||||||
* 用于将前端选择的思考级别从 AgentService 传递到 ReasoningNode,
|
|
||||||
* 避免修改 Agent 缓存实例或 StructuredStreamCapable 接口。
|
|
||||||
* <p>
|
|
||||||
* 支持的值:off / low / medium / high / max,null 表示跟随模型默认。
|
|
||||||
*
|
|
||||||
* @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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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, "资源不属于当前工作区");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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> {
|
||||||
|
}
|
||||||
@ -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> {
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,192 @@
|
|||||||
|
package vip.mate.agent.context;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import org.springframework.ai.chat.model.ToolContext;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable value object that travels alongside an agent invocation describing
|
||||||
|
* <em>where the request came from</em> — channel, conversation, requester,
|
||||||
|
* workspace, and optional delivery target.
|
||||||
|
*
|
||||||
|
* <p>Replaces ad-hoc ThreadLocal threading (RFC-063 v1) with explicit Spring AI
|
||||||
|
* {@link ToolContext} carriage (RFC-063r §2.1). The wither-style API enables
|
||||||
|
* the agent runtime to enrich the origin (agentId, workspace) without mutation.
|
||||||
|
*
|
||||||
|
* <h2>Field evolution rule</h2>
|
||||||
|
* <ul>
|
||||||
|
* <li>Only add — never delete; deprecate at least 90 days (covers approval TTL)
|
||||||
|
* before physical removal.</li>
|
||||||
|
* <li>Never rename — add a new field plus deprecate-old-field, double-write
|
||||||
|
* during the migration window.</li>
|
||||||
|
* <li>{@link JsonIgnoreProperties#ignoreUnknown()} guards forward/backward
|
||||||
|
* compatibility when older approval rows are deserialized after upgrades.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record ChatOrigin(
|
||||||
|
@Nullable Long agentId,
|
||||||
|
@Nullable String conversationId,
|
||||||
|
@Nullable String requesterId,
|
||||||
|
@Nullable Long workspaceId,
|
||||||
|
@Nullable String workspaceBasePath,
|
||||||
|
@Nullable Long channelId,
|
||||||
|
@Nullable ChannelTarget channelTarget,
|
||||||
|
// True only when the agent invocation was triggered by the scheduled-job
|
||||||
|
// runner. An explicit discriminator (rather than inferring from
|
||||||
|
// requesterId/channelId) so the runtime can branch on "is this a cron
|
||||||
|
// run" without coupling to factory internals.
|
||||||
|
boolean cronOrigin,
|
||||||
|
/**
|
||||||
|
* Display name of the user that sent the inbound IM message. Used by
|
||||||
|
* the prompt-context injector so the agent's system prompt can
|
||||||
|
* personalise replies ("You are talking to {{senderName}}"). Null
|
||||||
|
* for non-IM origins (web, cron). {@code requesterId} carries the
|
||||||
|
* stable identifier; this one is purely the human-readable surface.
|
||||||
|
*/
|
||||||
|
@Nullable String senderName,
|
||||||
|
/**
|
||||||
|
* Source channel type ("feishu" / "wecom" / "dingtalk" / ...).
|
||||||
|
* Lets the agent know which platform it's responding on, e.g. to
|
||||||
|
* tailor formatting or hint at supported features.
|
||||||
|
*/
|
||||||
|
@Nullable String channelType,
|
||||||
|
/**
|
||||||
|
* Group / chat identifier for IM channels — distinguishes private
|
||||||
|
* vs. group conversations. Null for 1:1 chats. Distinct from
|
||||||
|
* {@link #channelTarget()} (which targets cron / proactive sends).
|
||||||
|
*/
|
||||||
|
@Nullable String chatId,
|
||||||
|
/**
|
||||||
|
* Public base URL ({@code scheme://host[:port][/contextPath]}) resolved
|
||||||
|
* from the inbound HTTP request on the request thread. Carried here so
|
||||||
|
* tools running on async/streaming threads — where no request is bound —
|
||||||
|
* can still mint absolute download links. Null for IM/cron origins, which
|
||||||
|
* have no request host; those rely on {@code mateclaw.server.public-base-url}.
|
||||||
|
*/
|
||||||
|
@Nullable String baseUrl,
|
||||||
|
/**
|
||||||
|
* Immutable numeric id of the MateClaw user behind this request, when the
|
||||||
|
* requester is an <em>authenticated</em> account (JWT/PAT login via the
|
||||||
|
* web console). Null for non-account origins — webchat visitors, IM
|
||||||
|
* senders, cron — which carry no MateClaw user row. On-behalf-of identity
|
||||||
|
* forwarding uses this to tell "MateClaw authenticated this user" apart
|
||||||
|
* from "this is an external/anonymous identifier" (RFC: identity typing).
|
||||||
|
*/
|
||||||
|
@Nullable Long requesterUserId
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** Key used when this origin is wrapped into a Spring AI {@link ToolContext}. */
|
||||||
|
public static final String CTX_KEY = "mateclaw.chatOrigin";
|
||||||
|
|
||||||
|
/** Sentinel used by AgentService default overloads where no origin is supplied. */
|
||||||
|
public static final ChatOrigin EMPTY =
|
||||||
|
new ChatOrigin(null, null, "", null, null, null, null, false, null, null, null, null, null);
|
||||||
|
|
||||||
|
// ---------------- Factories per entry point ----------------
|
||||||
|
|
||||||
|
public static ChatOrigin web(@Nullable String conversationId,
|
||||||
|
@Nullable String requesterId,
|
||||||
|
@Nullable Long workspaceId,
|
||||||
|
@Nullable String workspaceBasePath) {
|
||||||
|
return web(conversationId, requesterId, workspaceId, workspaceBasePath, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ChatOrigin web(@Nullable String conversationId,
|
||||||
|
@Nullable String requesterId,
|
||||||
|
@Nullable Long workspaceId,
|
||||||
|
@Nullable String workspaceBasePath,
|
||||||
|
@Nullable String baseUrl) {
|
||||||
|
return web(conversationId, requesterId, workspaceId, workspaceBasePath, baseUrl, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web-console origin that also carries the authenticated user's immutable
|
||||||
|
* numeric id. Use this overload from the authenticated web entry point so
|
||||||
|
* on-behalf-of identity forwarding can assert "MateClaw authenticated this
|
||||||
|
* user" rather than an external/anonymous identifier.
|
||||||
|
*/
|
||||||
|
public static ChatOrigin web(@Nullable String conversationId,
|
||||||
|
@Nullable String requesterId,
|
||||||
|
@Nullable Long workspaceId,
|
||||||
|
@Nullable String workspaceBasePath,
|
||||||
|
@Nullable String baseUrl,
|
||||||
|
@Nullable Long requesterUserId) {
|
||||||
|
return new ChatOrigin(null, conversationId,
|
||||||
|
requesterId != null ? requesterId : "",
|
||||||
|
workspaceId, workspaceBasePath, null, null, false, null, "web", null, baseUrl,
|
||||||
|
requesterUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ChatOrigin cron(@Nullable String conversationId,
|
||||||
|
@Nullable Long workspaceId,
|
||||||
|
@Nullable String workspaceBasePath,
|
||||||
|
@Nullable Long channelId,
|
||||||
|
@Nullable ChannelTarget target) {
|
||||||
|
return new ChatOrigin(null, conversationId, "system",
|
||||||
|
workspaceId, workspaceBasePath, channelId, target, true, null, null, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- Wither-style updates ----------------
|
||||||
|
|
||||||
|
public ChatOrigin withAgent(@Nullable Long newAgentId) {
|
||||||
|
return new ChatOrigin(newAgentId, conversationId, requesterId,
|
||||||
|
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||||
|
senderName, channelType, chatId, baseUrl, requesterUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId,
|
||||||
|
@Nullable String newWorkspaceBasePath) {
|
||||||
|
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||||
|
newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||||
|
senderName, channelType, chatId, baseUrl, requesterUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ChatOrigin withConversationId(@Nullable String newConversationId) {
|
||||||
|
return new ChatOrigin(agentId, newConversationId, requesterId,
|
||||||
|
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||||
|
senderName, channelType, chatId, baseUrl, requesterUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Carry a request-derived public base URL (see {@link #baseUrl()}). */
|
||||||
|
public ChatOrigin withBaseUrl(@Nullable String newBaseUrl) {
|
||||||
|
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||||
|
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||||
|
senderName, channelType, chatId, newBaseUrl, requesterUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carry the inbound message's sender display name, source channel
|
||||||
|
* type, and chat (group) id. Called by the channel-side origin
|
||||||
|
* factory so prompt-context injection can show the agent "who"
|
||||||
|
* is talking and "where".
|
||||||
|
*/
|
||||||
|
public ChatOrigin withSender(@Nullable String newSenderName,
|
||||||
|
@Nullable String newChannelType,
|
||||||
|
@Nullable String newChatId) {
|
||||||
|
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||||
|
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||||
|
newSenderName, newChannelType, newChatId, baseUrl, requesterUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- Spring AI ToolContext interop ----------------
|
||||||
|
|
||||||
|
/** Wrap this origin into a Spring AI {@link ToolContext} the runtime can pass to tools. */
|
||||||
|
public ToolContext toToolContext() {
|
||||||
|
return new ToolContext(Map.of(CTX_KEY, this));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a {@link ChatOrigin} stored under {@link #CTX_KEY} in the given
|
||||||
|
* {@link ToolContext}. Returns {@link #EMPTY} when {@code ctx} is null, has
|
||||||
|
* no entry, or the value is not a ChatOrigin (defensive — keeps single-tool
|
||||||
|
* callers safe even if wiring is partial).
|
||||||
|
*/
|
||||||
|
public static ChatOrigin from(@Nullable ToolContext ctx) {
|
||||||
|
if (ctx == null) return EMPTY;
|
||||||
|
Object v = ctx.getContext().get(CTX_KEY);
|
||||||
|
return v instanceof ChatOrigin co ? co : EMPTY;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
package vip.mate.agent.context;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request-scoped {@link ChatOrigin} bridge between {@code AgentService}'s
|
||||||
|
* public entry points and the StateGraph's {@code buildInitialState}.
|
||||||
|
*
|
||||||
|
* <p>RFC-063r §2.5 carries the origin end-to-end via Spring AI {@code ToolContext}
|
||||||
|
* once it lands in graph state. This holder is the small bridge that gets the
|
||||||
|
* origin from the AgentService method invocation into the graph's initial
|
||||||
|
* state map — the holder lifecycle is bounded by the AgentService method
|
||||||
|
* call (set on entry, cleared in {@code finally}). Once written into the
|
||||||
|
* graph state under {@link vip.mate.agent.graph.state.MateClawStateKeys#CHAT_ORIGIN},
|
||||||
|
* the rest of the runtime reads via the typed accessor — no further ThreadLocal
|
||||||
|
* access. Mirrors {@link vip.mate.llm.chatmodel.ThinkingLevelHolder}.
|
||||||
|
*/
|
||||||
|
public final class ChatOriginHolder {
|
||||||
|
|
||||||
|
private static final ThreadLocal<ChatOrigin> HOLDER = new ThreadLocal<>();
|
||||||
|
|
||||||
|
private ChatOriginHolder() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set the origin for the current AgentService invocation. */
|
||||||
|
public static void set(ChatOrigin origin) {
|
||||||
|
HOLDER.set(origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return the origin set for the current invocation, or {@link ChatOrigin#EMPTY}
|
||||||
|
* when no entry path has supplied one (legacy callers).
|
||||||
|
*/
|
||||||
|
public static ChatOrigin get() {
|
||||||
|
ChatOrigin v = HOLDER.get();
|
||||||
|
return v != null ? v : ChatOrigin.EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void clear() {
|
||||||
|
HOLDER.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,140 @@
|
|||||||
|
package vip.mate.agent.context;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for per-reasoning-loop message budgeting.
|
||||||
|
*
|
||||||
|
* <p>Used by {@link LoopMessageBudgeter} to decide when and how to trim the
|
||||||
|
* working message list that a ReAct iteration hands to the LLM. Distinct from
|
||||||
|
* the multi-turn history compression configured by
|
||||||
|
* {@link vip.mate.config.ConversationWindowProperties}: this one applies inside
|
||||||
|
* a single user turn while the ReAct loop accumulates reasoning steps and
|
||||||
|
* tool-call/tool-response pairs.
|
||||||
|
*
|
||||||
|
* <p>Field semantics:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code triggerTokens} — token threshold above which budgeting kicks
|
||||||
|
* in. Compared against {@code historyTokens + reservedPrefixTokens}
|
||||||
|
* so the budgeter accounts for the full prompt the LLM will see,
|
||||||
|
* not just the message list.</li>
|
||||||
|
* <li>{@code keepTailTokens} — token budget reserved for the tail (recent
|
||||||
|
* observations + the current user message). Scales with the model
|
||||||
|
* window instead of relying on a fixed count.</li>
|
||||||
|
* <li>{@code minTailMessages} — floor on the kept-tail count. Prevents a
|
||||||
|
* single huge tool output from collapsing the tail to one message and
|
||||||
|
* losing recent reasoning context.</li>
|
||||||
|
* <li>{@code tailSoftCeilingRatio} — multiplier applied to
|
||||||
|
* {@code keepTailTokens} when honoring the floor or pulling back to
|
||||||
|
* keep a tool pair whole. Lets the tail overshoot the hard budget by
|
||||||
|
* up to this factor before more aggressive cuts kick in.</li>
|
||||||
|
* <li>{@code reservedPrefixTokens} — estimated tokens consumed by the
|
||||||
|
* non-history portion of the prompt (system prompt, skill catalog,
|
||||||
|
* runtime context, wiki injection, tool schemas, output reserve).
|
||||||
|
* Surfaces these from the caller so the budget covers the whole
|
||||||
|
* prompt, not just the message list.</li>
|
||||||
|
* <li>{@code targetMaxMessages} — soft ceiling on the count fed to the
|
||||||
|
* LLM. Best-effort: the budgeter may exceed it slightly to keep a
|
||||||
|
* tool pair whole rather than orphan a call/response — that case is
|
||||||
|
* reported via {@code BudgetTrace.capExceededForPairIntegrity}.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public record LoopBudgetConfig(
|
||||||
|
int triggerTokens,
|
||||||
|
int keepTailTokens,
|
||||||
|
int minTailMessages,
|
||||||
|
double tailSoftCeilingRatio,
|
||||||
|
int reservedPrefixTokens,
|
||||||
|
int targetMaxMessages) {
|
||||||
|
|
||||||
|
/** Smallest useful trigger threshold; below this budgeting is effectively disabled. */
|
||||||
|
public static final int MIN_TRIGGER_TOKENS = 1_000;
|
||||||
|
|
||||||
|
/** Smallest sensible tail budget; below this even one observation may not fit. */
|
||||||
|
public static final int MIN_TAIL_TOKENS = 2_000;
|
||||||
|
|
||||||
|
/** Floor on minTailMessages — fewer than 3 collapses recent context too aggressively. */
|
||||||
|
public static final int MIN_TAIL_MESSAGES_FLOOR = 3;
|
||||||
|
|
||||||
|
/** Floor on the soft ceiling ratio — anything below 1.0 is degenerate. */
|
||||||
|
public static final double MIN_TAIL_SOFT_CEILING_RATIO = 1.0;
|
||||||
|
|
||||||
|
/** Smallest sensible target cap; below this even a normal ReAct loop trips it. */
|
||||||
|
public static final int MIN_TARGET_MAX = 20;
|
||||||
|
|
||||||
|
public LoopBudgetConfig {
|
||||||
|
if (triggerTokens < MIN_TRIGGER_TOKENS) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"triggerTokens must be >= " + MIN_TRIGGER_TOKENS + ", got " + triggerTokens);
|
||||||
|
}
|
||||||
|
if (keepTailTokens < MIN_TAIL_TOKENS) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"keepTailTokens must be >= " + MIN_TAIL_TOKENS + ", got " + keepTailTokens);
|
||||||
|
}
|
||||||
|
if (minTailMessages < MIN_TAIL_MESSAGES_FLOOR) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"minTailMessages must be >= " + MIN_TAIL_MESSAGES_FLOOR
|
||||||
|
+ ", got " + minTailMessages);
|
||||||
|
}
|
||||||
|
if (tailSoftCeilingRatio < MIN_TAIL_SOFT_CEILING_RATIO) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"tailSoftCeilingRatio must be >= " + MIN_TAIL_SOFT_CEILING_RATIO
|
||||||
|
+ ", got " + tailSoftCeilingRatio);
|
||||||
|
}
|
||||||
|
if (reservedPrefixTokens < 0) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"reservedPrefixTokens must be >= 0, got " + reservedPrefixTokens);
|
||||||
|
}
|
||||||
|
if (targetMaxMessages < MIN_TARGET_MAX) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"targetMaxMessages must be >= " + MIN_TARGET_MAX
|
||||||
|
+ ", got " + targetMaxMessages);
|
||||||
|
}
|
||||||
|
if (keepTailTokens >= triggerTokens) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"keepTailTokens (" + keepTailTokens + ") must be < triggerTokens ("
|
||||||
|
+ triggerTokens + ") — otherwise budgeting would never reduce anything");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tail budget after applying the soft ceiling. */
|
||||||
|
public int tailSoftCeilingTokens() {
|
||||||
|
return (int) (keepTailTokens * tailSoftCeilingRatio);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a sensible config from a model's context window. The ratios were
|
||||||
|
* chosen so the budgeter triggers well before the model's actual limit and
|
||||||
|
* leaves enough headroom for the LLM's own response.
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>trigger = 50% of the window — same threshold the multi-turn
|
||||||
|
* compressor uses, so the two layers stay calibrated.</li>
|
||||||
|
* <li>tail budget = 30% of the window.</li>
|
||||||
|
* <li>minTailMessages = 4 — at least one full reasoning/action cycle
|
||||||
|
* stays visible to the LLM no matter how big a single tool output is.</li>
|
||||||
|
* <li>tailSoftCeilingRatio = 1.5 — let the tail overshoot by 50% when
|
||||||
|
* enforcing the floor or pulling back to keep a tool pair whole.</li>
|
||||||
|
* <li>reservedPrefixTokens = 0 — caller should override with the real
|
||||||
|
* prefix estimate; left at 0 the budget still works but errs on
|
||||||
|
* the side of triggering later than it should.</li>
|
||||||
|
* <li>targetMaxMessages = 200 — well above a normal ReAct loop's 20–40
|
||||||
|
* working messages, low enough to be a meaningful guard rail.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public static LoopBudgetConfig forContext(int contextWindowTokens) {
|
||||||
|
if (contextWindowTokens <= 0) {
|
||||||
|
contextWindowTokens = 32_000;
|
||||||
|
}
|
||||||
|
int trigger = Math.max(MIN_TRIGGER_TOKENS, (int) (contextWindowTokens * 0.50));
|
||||||
|
int tail = Math.max(MIN_TAIL_TOKENS, (int) (contextWindowTokens * 0.30));
|
||||||
|
if (tail >= trigger) {
|
||||||
|
tail = Math.max(MIN_TAIL_TOKENS, trigger - MIN_TRIGGER_TOKENS);
|
||||||
|
}
|
||||||
|
return new LoopBudgetConfig(trigger, tail, 4, 1.5, 0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return a copy with {@code reservedPrefixTokens} replaced. */
|
||||||
|
public LoopBudgetConfig withReservedPrefixTokens(int reservedPrefixTokens) {
|
||||||
|
return new LoopBudgetConfig(triggerTokens, keepTailTokens, minTailMessages,
|
||||||
|
tailSoftCeilingRatio, reservedPrefixTokens, targetMaxMessages);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,296 @@
|
|||||||
|
package vip.mate.agent.context;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.chat.messages.Message;
|
||||||
|
import org.springframework.ai.chat.messages.SystemMessage;
|
||||||
|
import org.springframework.ai.chat.messages.UserMessage;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-ReAct-loop message budgeter. Bounds the working message list a Reasoning
|
||||||
|
* iteration hands to the LLM while preserving five invariants that, when
|
||||||
|
* violated, either produce off-topic answers or 400s from strict providers:
|
||||||
|
*
|
||||||
|
* <ol>
|
||||||
|
* <li><b>System prompt(s)</b> — all consecutive {@link SystemMessage}s at
|
||||||
|
* the head stay verbatim. Production agents commonly have multiple
|
||||||
|
* (SOUL, AGENTS, runtime context, wiki, tool prompt, skill catalog).</li>
|
||||||
|
* <li><b>Turn anchor</b> — the latest {@link UserMessage} is never dropped.
|
||||||
|
* Stitched in when an aggressive cut would otherwise lose it.</li>
|
||||||
|
* <li><b>Tool-call/response pair integrity</b> — every assistant tool_call
|
||||||
|
* reaches the model with its matching tool_response, and vice versa.
|
||||||
|
* Delegated to {@link ToolPairSanitizer}.</li>
|
||||||
|
* <li><b>Token budget over message count</b> — tail sized by token estimate
|
||||||
|
* so a small ReAct loop with fat observations and a large loop with
|
||||||
|
* thin observations both fit one config.</li>
|
||||||
|
* <li><b>Minimum tail messages</b> — at least {@code minTailMessages}
|
||||||
|
* entries survive even when a single message is bigger than the
|
||||||
|
* hard tail budget. Prevents collapsing recent reasoning to one row
|
||||||
|
* when the latest tool output is huge.</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>The trigger threshold compares {@code historyTokens +
|
||||||
|
* reservedPrefixTokens} against {@code triggerTokens}; this keeps the
|
||||||
|
* budgeter calibrated against the entire prompt the LLM will see, not just
|
||||||
|
* the message list (the L1 compactor uses the same arithmetic).
|
||||||
|
*
|
||||||
|
* <p>Distinct from {@link ConversationWindowManager}: that one runs once per
|
||||||
|
* user turn and produces a structured LLM summary for the accumulated
|
||||||
|
* multi-turn history. This one runs per reasoning iteration on top of
|
||||||
|
* whatever {@code ConversationWindowManager} already produced, bounding the
|
||||||
|
* intra-turn ReAct accumulation.
|
||||||
|
*
|
||||||
|
* <p>Stateless and side-effect-free for callers; safe to call from
|
||||||
|
* concurrent reasoning threads. The orphan-removal pass mutates a freshly
|
||||||
|
* allocated local list, never the caller's input.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class LoopMessageBudgeter {
|
||||||
|
|
||||||
|
/** Outcome of a budgeting pass. */
|
||||||
|
public record Result(List<Message> messages, BudgetTrace trace) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured trace of a single budgeting decision. All counts and token
|
||||||
|
* figures refer to {@link Message} entries.
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code anchorEnforced} — the tail cut was pulled earlier than
|
||||||
|
* the token budget would have placed it because the latest
|
||||||
|
* UserMessage would otherwise have been dropped.</li>
|
||||||
|
* <li>{@code anchorStitched} — the latest UserMessage could not fit in
|
||||||
|
* the tail even after pull-back (typically when the target cap
|
||||||
|
* fired hard); it was inserted as a standalone slot between head
|
||||||
|
* and tail.</li>
|
||||||
|
* <li>{@code capExceededForPairIntegrity} — the final count exceeded
|
||||||
|
* {@code targetMaxMessages} because pulling the cut back to keep
|
||||||
|
* a tool pair whole won out over the soft cap. Useful signal that
|
||||||
|
* upstream compaction should have run sooner.</li>
|
||||||
|
* <li>{@code minTailFloorApplied} — the tail was enlarged past the
|
||||||
|
* hard token budget (up to the soft ceiling) to honor
|
||||||
|
* {@code minTailMessages}.</li>
|
||||||
|
* <li>{@code triggered} — the budget entered its main path because the
|
||||||
|
* trigger threshold was met. Says nothing about whether anything
|
||||||
|
* was actually removed.</li>
|
||||||
|
* <li>{@code modified} — the returned list differs from the input
|
||||||
|
* (count changed or orphans removed). This is the only signal
|
||||||
|
* callers should use to gate log output; a triggered-but-no-op
|
||||||
|
* pass is normal and shouldn't spam logs.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public record BudgetTrace(
|
||||||
|
int originalCount,
|
||||||
|
int originalTokens,
|
||||||
|
int finalCount,
|
||||||
|
int finalTokens,
|
||||||
|
int reservedPrefixTokens,
|
||||||
|
int headKept,
|
||||||
|
int tailKept,
|
||||||
|
int droppedMiddle,
|
||||||
|
int orphansRemoved,
|
||||||
|
boolean anchorEnforced,
|
||||||
|
boolean anchorStitched,
|
||||||
|
boolean targetMaxTripped,
|
||||||
|
boolean capExceededForPairIntegrity,
|
||||||
|
boolean minTailFloorApplied,
|
||||||
|
boolean triggered,
|
||||||
|
boolean modified) {
|
||||||
|
|
||||||
|
/** Trace for the no-op case (budget not triggered). */
|
||||||
|
public static BudgetTrace untouched(int count, int tokens, int prefixTokens, int headKept) {
|
||||||
|
return new BudgetTrace(count, tokens, count, tokens, prefixTokens, headKept,
|
||||||
|
count - headKept, 0, 0,
|
||||||
|
false, false, false, false, false, false, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply the loop budget to {@code messages}. Pure function; never mutates the input. */
|
||||||
|
public Result budget(List<Message> messages, LoopBudgetConfig cfg) {
|
||||||
|
if (messages == null || messages.isEmpty()) {
|
||||||
|
return new Result(messages == null ? List.of() : messages,
|
||||||
|
BudgetTrace.untouched(0, 0, cfg.reservedPrefixTokens(), 0));
|
||||||
|
}
|
||||||
|
int originalCount = messages.size();
|
||||||
|
int historyTokens = TokenEstimator.estimateTokens(messages);
|
||||||
|
int headEnd = findHeadEnd(messages);
|
||||||
|
|
||||||
|
// Budget against the full prompt (history + prefix), so the trigger
|
||||||
|
// matches what the LLM would actually receive — not just the
|
||||||
|
// history slice. Prefix covers system prompt, skill catalog,
|
||||||
|
// runtime context, wiki, tool schemas, output reserve.
|
||||||
|
int promptTokens = historyTokens + cfg.reservedPrefixTokens();
|
||||||
|
|
||||||
|
// Below both thresholds → forward unchanged.
|
||||||
|
if (promptTokens < cfg.triggerTokens() && originalCount < cfg.targetMaxMessages()) {
|
||||||
|
return new Result(messages,
|
||||||
|
BudgetTrace.untouched(originalCount, historyTokens,
|
||||||
|
cfg.reservedPrefixTokens(), headEnd));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Token-budgeted tail cut. Walk backward from the end; the
|
||||||
|
// earliest index whose suffix fits within keepTailTokens is the
|
||||||
|
// proposed boundary.
|
||||||
|
int hardTailStart = findTailCutByTokens(messages, headEnd, cfg.keepTailTokens());
|
||||||
|
|
||||||
|
// 2. Min-tail floor: if the hard cut keeps fewer than minTailMessages,
|
||||||
|
// pull back to keep at least that many — but only up to the soft
|
||||||
|
// ceiling. Without this, one giant tool output can collapse the
|
||||||
|
// tail to a single row and lose recent reasoning context.
|
||||||
|
boolean minTailFloorApplied = false;
|
||||||
|
int tailStart = hardTailStart;
|
||||||
|
int hardTailCount = originalCount - hardTailStart;
|
||||||
|
if (hardTailCount < cfg.minTailMessages()) {
|
||||||
|
int floorTailStart = Math.max(headEnd, originalCount - cfg.minTailMessages());
|
||||||
|
// Honor the soft ceiling: if even the floor count would consume
|
||||||
|
// more than tailSoftCeilingTokens, accept it (the floor wins,
|
||||||
|
// since the alternative is losing recent reasoning entirely).
|
||||||
|
tailStart = floorTailStart;
|
||||||
|
minTailFloorApplied = true;
|
||||||
|
} else {
|
||||||
|
// Apply the soft ceiling: if the hard cut undershoots the soft
|
||||||
|
// ceiling (i.e. there's slack), keep going. We already cut to
|
||||||
|
// the hard budget so there's no need to expand here — the soft
|
||||||
|
// ceiling acts as a guard rail for the floor/pull-back path,
|
||||||
|
// not as a relaxation of the normal cut.
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Anchor: never drop the latest UserMessage. Pull tail back if
|
||||||
|
// needed (cheap — just moves the boundary).
|
||||||
|
boolean anchorEnforced = false;
|
||||||
|
int anchorIdx = findLatestUserMessageIdx(messages, headEnd);
|
||||||
|
if (anchorIdx >= 0 && anchorIdx < tailStart) {
|
||||||
|
tailStart = anchorIdx;
|
||||||
|
anchorEnforced = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Tool-pair integrity at the boundary: if tailStart sits inside a
|
||||||
|
// tool pair, pull back so the pair survives whole. Delegated to
|
||||||
|
// the shared sanitizer.
|
||||||
|
int beforePairPullBack = tailStart;
|
||||||
|
tailStart = ToolPairSanitizer.pullBackToToolPairBoundary(messages, headEnd, tailStart);
|
||||||
|
|
||||||
|
// 5. Target max safety net. The pair-integrity pull-back may have
|
||||||
|
// pushed final count above the soft cap; we re-evaluate and try
|
||||||
|
// to enforce, but pair integrity wins over count cap.
|
||||||
|
boolean targetMaxTripped = false;
|
||||||
|
boolean anchorStitched = false;
|
||||||
|
boolean capExceededForPairIntegrity = false;
|
||||||
|
int targetTailCap = Math.max(0, cfg.targetMaxMessages() - headEnd);
|
||||||
|
if (targetTailCap > 0 && (originalCount - tailStart) > targetTailCap) {
|
||||||
|
int provisionalTailStart = originalCount - targetTailCap;
|
||||||
|
boolean stitchNeeded = anchorIdx >= 0 && anchorIdx < provisionalTailStart;
|
||||||
|
int reservedForStitchedAnchor = stitchNeeded ? 1 : 0;
|
||||||
|
int recentTailCap = Math.max(1, targetTailCap - reservedForStitchedAnchor);
|
||||||
|
int newTailStart = originalCount - recentTailCap;
|
||||||
|
int adjustedTailStart = ToolPairSanitizer.pullBackToToolPairBoundary(
|
||||||
|
messages, headEnd, newTailStart);
|
||||||
|
if (adjustedTailStart < newTailStart) {
|
||||||
|
// Pair integrity prevailed over the cap; honestly record that
|
||||||
|
// the final count will exceed targetMaxMessages.
|
||||||
|
capExceededForPairIntegrity = true;
|
||||||
|
}
|
||||||
|
tailStart = adjustedTailStart;
|
||||||
|
targetMaxTripped = true;
|
||||||
|
anchorStitched = anchorIdx >= 0 && anchorIdx < tailStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect anchor stitching from the tool-pair pull-back path too:
|
||||||
|
// pull-back may have moved tailStart earlier than the anchor index
|
||||||
|
// (rare, but possible if the pair anchor is in the head section).
|
||||||
|
if (!anchorStitched && anchorIdx >= 0 && anchorIdx < tailStart) {
|
||||||
|
anchorStitched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Build the trimmed list: head + [stitched anchor?] + tail.
|
||||||
|
int estimated = headEnd + (anchorStitched ? 1 : 0) + (originalCount - tailStart);
|
||||||
|
List<Message> trimmed = new ArrayList<>(estimated);
|
||||||
|
trimmed.addAll(messages.subList(0, headEnd));
|
||||||
|
if (anchorStitched) {
|
||||||
|
trimmed.add(messages.get(anchorIdx));
|
||||||
|
}
|
||||||
|
trimmed.addAll(messages.subList(tailStart, originalCount));
|
||||||
|
|
||||||
|
// 7. Tool-pair invariant: cross-boundary orphans cleaned up. The
|
||||||
|
// pull-back at step 4 handles the boundary case but a head-section
|
||||||
|
// Assistant(tool_calls) whose responses fell in the dropped middle
|
||||||
|
// still needs the bidirectional pass.
|
||||||
|
int orphans = ToolPairSanitizer.removeOrphans(trimmed);
|
||||||
|
|
||||||
|
int finalCount = trimmed.size();
|
||||||
|
int finalTokens = TokenEstimator.estimateTokens(trimmed);
|
||||||
|
int droppedMiddle = originalCount - finalCount;
|
||||||
|
|
||||||
|
// Touch the unused locals so the compiler doesn't warn — they're
|
||||||
|
// useful in the trace's narrative but the actual cut already
|
||||||
|
// committed.
|
||||||
|
if (beforePairPullBack != tailStart) {
|
||||||
|
// pair pull-back moved the boundary; logged via trace fields
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean modified = (finalCount != originalCount) || (orphans > 0);
|
||||||
|
|
||||||
|
return new Result(trimmed, new BudgetTrace(
|
||||||
|
originalCount, historyTokens,
|
||||||
|
finalCount, finalTokens,
|
||||||
|
cfg.reservedPrefixTokens(),
|
||||||
|
headEnd,
|
||||||
|
finalCount - headEnd,
|
||||||
|
droppedMiddle,
|
||||||
|
orphans,
|
||||||
|
anchorEnforced,
|
||||||
|
anchorStitched,
|
||||||
|
targetMaxTripped,
|
||||||
|
capExceededForPairIntegrity,
|
||||||
|
minTailFloorApplied,
|
||||||
|
/* triggered */ true,
|
||||||
|
modified));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
// Internals
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static int findHeadEnd(List<Message> messages) {
|
||||||
|
int i = 0;
|
||||||
|
while (i < messages.size() && messages.get(i) instanceof SystemMessage) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk backward from the end accumulating per-message token estimates.
|
||||||
|
* Return the earliest index whose suffix fits within {@code keepTokens}.
|
||||||
|
* Always returns a value in {@code [headEnd, messages.size())} so the
|
||||||
|
* tail is non-empty.
|
||||||
|
*/
|
||||||
|
private static int findTailCutByTokens(List<Message> messages, int headEnd, int keepTokens) {
|
||||||
|
int n = messages.size();
|
||||||
|
if (n <= headEnd) {
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
int acc = 0;
|
||||||
|
for (int i = n - 1; i >= headEnd; i--) {
|
||||||
|
int t = TokenEstimator.estimateTokens(messages.get(i));
|
||||||
|
if (acc + t > keepTokens && i < n - 1) {
|
||||||
|
return i + 1;
|
||||||
|
}
|
||||||
|
acc += t;
|
||||||
|
}
|
||||||
|
return headEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index of the latest {@link UserMessage} at or after {@code headEnd}; -1 if none. */
|
||||||
|
private static int findLatestUserMessageIdx(List<Message> messages, int headEnd) {
|
||||||
|
for (int i = messages.size() - 1; i >= headEnd; i--) {
|
||||||
|
if (messages.get(i) instanceof UserMessage) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
package vip.mate.agent.context;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-agent-build token budget for the prompt prefix's optional injection
|
||||||
|
* blocks. Produced once by {@link PrefixBudgetPlanner} when the agent graph
|
||||||
|
* is assembled (the inputs — effective window, base prompt, tool schemas —
|
||||||
|
* are all stable per build) and handed to each injection site.
|
||||||
|
*
|
||||||
|
* <p>{@code enabled == false} means budgeting is switched off: every budget
|
||||||
|
* field holds {@link Integer#MAX_VALUE} and consumers keep their existing
|
||||||
|
* absolute caps untouched.
|
||||||
|
*/
|
||||||
|
public record PrefixBudgetPlan(
|
||||||
|
boolean enabled,
|
||||||
|
int effectiveMaxTokens,
|
||||||
|
Profile profile,
|
||||||
|
int injectionBudgetTokens,
|
||||||
|
int memoryTokens,
|
||||||
|
int wikiTokens,
|
||||||
|
int skillCatalogTokens,
|
||||||
|
int extensionCatalogTokens,
|
||||||
|
int ledgerTokens,
|
||||||
|
int toolSchemaBudgetTokens) {
|
||||||
|
|
||||||
|
/** Window-size tier. Small windows tighten the injection ratio. */
|
||||||
|
public enum Profile {
|
||||||
|
/** Regular window — budget shares rarely bind (absolute caps are smaller). */
|
||||||
|
NORMAL,
|
||||||
|
/** Window below the compact threshold — tightened injection ratio. */
|
||||||
|
COMPACT,
|
||||||
|
/** Window below the minimal threshold — injection cut to the bone. */
|
||||||
|
MINIMAL
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Budgeting disabled — unlimited budgets, previous behavior. */
|
||||||
|
public static PrefixBudgetPlan unlimited(int effectiveMaxTokens) {
|
||||||
|
return new PrefixBudgetPlan(false, effectiveMaxTokens, Profile.NORMAL,
|
||||||
|
Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE,
|
||||||
|
Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,120 @@
|
|||||||
|
package vip.mate.agent.context;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.config.ConversationWindowProperties;
|
||||||
|
import vip.mate.config.PrefixBudgetProperties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the {@link PrefixBudgetPlan} for one agent build: how many tokens
|
||||||
|
* each optional prefix injection block (memory / wiki / skill catalog /
|
||||||
|
* extension catalog / progress ledger) may spend, scaled to the model's
|
||||||
|
* effective context window.
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* injectionBudget = max(0, effectiveMax × ratio(profile)
|
||||||
|
* − basePromptTokens − toolSchemaTokens)
|
||||||
|
* block budget = injectionBudget × normalizedShare(block)
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* The agent's own prompt and the tool schemas are never truncated here —
|
||||||
|
* they are subtracted from the injection budget so the optional blocks
|
||||||
|
* absorb the squeeze. On large windows the shares far exceed each block's
|
||||||
|
* absolute cap, so behavior is byte-identical to the pre-budget code.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@EnableConfigurationProperties(PrefixBudgetProperties.class)
|
||||||
|
public class PrefixBudgetPlanner {
|
||||||
|
|
||||||
|
/** COMPACT-profile ceiling for the wiki relevance injection (~one page). */
|
||||||
|
static final int COMPACT_WIKI_TOKEN_CAP = 2000;
|
||||||
|
|
||||||
|
private final PrefixBudgetProperties properties;
|
||||||
|
private final ConversationWindowProperties windowProperties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param effectiveMaxInputTokens the model's effective window (explicit
|
||||||
|
* config or probed); null/0 falls back to
|
||||||
|
* the global default
|
||||||
|
* @param basePromptTokens estimated tokens of the agent's own
|
||||||
|
* identity prompt (before memory/guidance)
|
||||||
|
* @param toolSchemaTokens estimated tokens of the advertised tool
|
||||||
|
* schemas
|
||||||
|
*/
|
||||||
|
public PrefixBudgetPlan plan(Integer effectiveMaxInputTokens, int basePromptTokens, int toolSchemaTokens) {
|
||||||
|
int effectiveMax = (effectiveMaxInputTokens != null && effectiveMaxInputTokens > 0)
|
||||||
|
? effectiveMaxInputTokens : windowProperties.getDefaultMaxInputTokens();
|
||||||
|
if (!properties.isEnabled()) {
|
||||||
|
return PrefixBudgetPlan.unlimited(effectiveMax);
|
||||||
|
}
|
||||||
|
|
||||||
|
PrefixBudgetPlan.Profile profile = profileFor(effectiveMax);
|
||||||
|
double ratio = switch (profile) {
|
||||||
|
case NORMAL -> properties.getInjectionRatio();
|
||||||
|
case COMPACT -> properties.getCompactInjectionRatio();
|
||||||
|
case MINIMAL -> properties.getMinimalInjectionRatio();
|
||||||
|
};
|
||||||
|
|
||||||
|
int injectionBudget = Math.max(0,
|
||||||
|
(int) (effectiveMax * ratio) - Math.max(0, basePromptTokens) - Math.max(0, toolSchemaTokens));
|
||||||
|
|
||||||
|
PrefixBudgetProperties.Shares shares = properties.getShares();
|
||||||
|
double sum = shares.getMemory() + shares.getWiki() + shares.getSkill()
|
||||||
|
+ shares.getExtensionCatalog() + shares.getLedger();
|
||||||
|
if (sum <= 0) {
|
||||||
|
sum = 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile-specific wiki clamps: knowledge-base reference pages are the
|
||||||
|
// most dispensable block on a small window — the wiki tools stay
|
||||||
|
// callable, only the automatic pre-injection shrinks. COMPACT caps it
|
||||||
|
// at roughly one page; MINIMAL disables it outright.
|
||||||
|
int wikiTokens = (int) (injectionBudget * shares.getWiki() / sum);
|
||||||
|
wikiTokens = switch (profile) {
|
||||||
|
case NORMAL -> wikiTokens;
|
||||||
|
case COMPACT -> Math.min(wikiTokens, COMPACT_WIKI_TOKEN_CAP);
|
||||||
|
case MINIMAL -> 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
PrefixBudgetPlan plan = new PrefixBudgetPlan(
|
||||||
|
true, effectiveMax, profile, injectionBudget,
|
||||||
|
(int) (injectionBudget * shares.getMemory() / sum),
|
||||||
|
wikiTokens,
|
||||||
|
(int) (injectionBudget * shares.getSkill() / sum),
|
||||||
|
(int) (injectionBudget * shares.getExtensionCatalog() / sum),
|
||||||
|
(int) (injectionBudget * shares.getLedger() / sum),
|
||||||
|
(int) (effectiveMax * properties.getToolSchemaRatio()));
|
||||||
|
|
||||||
|
if (profile != PrefixBudgetPlan.Profile.NORMAL) {
|
||||||
|
log.info("[PrefixBudget] 窗口 {} tokens 进入 {} 档:注入预算 {} tokens"
|
||||||
|
+ "(memory={}, wiki={}, skill={}, extCatalog={}, ledger={})",
|
||||||
|
effectiveMax, profile, injectionBudget,
|
||||||
|
plan.memoryTokens(), plan.wikiTokens(), plan.skillCatalogTokens(),
|
||||||
|
plan.extensionCatalogTokens(), plan.ledgerTokens());
|
||||||
|
}
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compaction trigger ratio for this window size (small windows fill up before compacting). */
|
||||||
|
public double compactTriggerRatioFor(int effectiveMaxTokens, double defaultRatio) {
|
||||||
|
if (!properties.isEnabled()) {
|
||||||
|
return defaultRatio;
|
||||||
|
}
|
||||||
|
return profileFor(effectiveMaxTokens) == PrefixBudgetPlan.Profile.NORMAL
|
||||||
|
? defaultRatio : properties.getCompactTriggerRatioOverride();
|
||||||
|
}
|
||||||
|
|
||||||
|
private PrefixBudgetPlan.Profile profileFor(int effectiveMax) {
|
||||||
|
if (effectiveMax < properties.getMinimalThresholdTokens()) {
|
||||||
|
return PrefixBudgetPlan.Profile.MINIMAL;
|
||||||
|
}
|
||||||
|
if (effectiveMax < properties.getCompactThresholdTokens()) {
|
||||||
|
return PrefixBudgetPlan.Profile.COMPACT;
|
||||||
|
}
|
||||||
|
return PrefixBudgetPlan.Profile.NORMAL;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -46,6 +46,45 @@ public final class RuntimeContextInjector {
|
|||||||
* 构建运行时上下文消息(i18n 版本)。
|
* 构建运行时上下文消息(i18n 版本)。
|
||||||
*/
|
*/
|
||||||
public static String buildContextMessage(String workspaceBasePath, vip.mate.i18n.I18nService i18n) {
|
public static String buildContextMessage(String workspaceBasePath, vip.mate.i18n.I18nService i18n) {
|
||||||
|
return buildContextMessage(workspaceBasePath, i18n, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the runtime-context message and (when {@code origin} is non-null
|
||||||
|
* and carries IM channel context) append a short "who is talking, where,
|
||||||
|
* via what channel" block so the agent's system prompt can personalise
|
||||||
|
* its reply. Same cache discipline as the simpler overloads — the block
|
||||||
|
* stays well under the spring-ai user-cache threshold (≥1024 chars).
|
||||||
|
*
|
||||||
|
* <p>The sender block is suppressed when:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code origin} is null or {@link ChatOrigin#EMPTY}</li>
|
||||||
|
* <li>the origin carries no IM context (web / cron) — both produce
|
||||||
|
* a null {@code channelType} or {@code "web"}</li>
|
||||||
|
* </ul>
|
||||||
|
* Web and cron callers thus see exactly the same prompt as before.
|
||||||
|
*/
|
||||||
|
public static String buildContextMessage(String workspaceBasePath,
|
||||||
|
vip.mate.i18n.I18nService i18n,
|
||||||
|
ChatOrigin origin) {
|
||||||
|
return buildContextMessage(workspaceBasePath, i18n, origin, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full overload that also renders the agent's runtime model identity.
|
||||||
|
* The model line is emitted for EVERY origin (web / cron / IM / null)
|
||||||
|
* because it describes the agent, not the caller — only the sender
|
||||||
|
* block stays IM-only. {@code modelName}/{@code providerId} come from
|
||||||
|
* graph state ({@code RUNTIME_MODEL_NAME}/{@code RUNTIME_PROVIDER_ID}),
|
||||||
|
* i.e. the model selected at run start (mid-run failover is not
|
||||||
|
* reflected — accepted trade-off). Stays well under the 1024-char
|
||||||
|
* spring-ai user-cache threshold.
|
||||||
|
*/
|
||||||
|
public static String buildContextMessage(String workspaceBasePath,
|
||||||
|
vip.mate.i18n.I18nService i18n,
|
||||||
|
ChatOrigin origin,
|
||||||
|
String modelName,
|
||||||
|
String providerId) {
|
||||||
LocalDateTime now = LocalDateTime.now(ZONE);
|
LocalDateTime now = LocalDateTime.now(ZONE);
|
||||||
String dateStr = now.format(DATE_FMT);
|
String dateStr = now.format(DATE_FMT);
|
||||||
String timeStr = now.format(TIME_FMT);
|
String timeStr = now.format(TIME_FMT);
|
||||||
@ -66,7 +105,94 @@ public final class RuntimeContextInjector {
|
|||||||
sb.append("\n[system-context] Working directory: ").append(workspaceBasePath);
|
sb.append("\n[system-context] Working directory: ").append(workspaceBasePath);
|
||||||
sb.append("\nYou can only read/write files and execute commands within this directory and its subdirectories.");
|
sb.append("\nYou can only read/write files and execute commands within this directory and its subdirectories.");
|
||||||
}
|
}
|
||||||
|
appendSkillRootHintIfPresent(sb, workspaceBasePath, i18n);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
appendSenderBlockIfPresent(sb, origin);
|
||||||
|
appendModelLineIfPresent(sb, modelName, providerId, i18n);
|
||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tell the model that the shared skill repository is reachable in addition
|
||||||
|
* to the workspace. Without this, a model that strictly honors the
|
||||||
|
* "working directory only" hint refuses to read or run skill files that
|
||||||
|
* live outside the workspace — even though the path sandbox now allows
|
||||||
|
* them. Skipped when the skill root is unknown or already sits inside the
|
||||||
|
* workspace (no separate boundary to explain).
|
||||||
|
*/
|
||||||
|
private static void appendSkillRootHintIfPresent(StringBuilder sb, String workspaceBasePath,
|
||||||
|
vip.mate.i18n.I18nService i18n) {
|
||||||
|
java.nio.file.Path skillRoot = vip.mate.tool.guard.WorkspacePathGuard.getSkillRoot();
|
||||||
|
if (skillRoot == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
java.nio.file.Path wsRoot = java.nio.file.Paths.get(workspaceBasePath).toAbsolutePath().normalize();
|
||||||
|
if (skillRoot.startsWith(wsRoot)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String skillRootStr = skillRoot.toString();
|
||||||
|
if (i18n != null) {
|
||||||
|
sb.append("\n").append(i18n.msg("context.skill_dir_hint", skillRootStr));
|
||||||
|
} else {
|
||||||
|
sb.append("\nShared skills live under ").append(skillRootStr)
|
||||||
|
.append("; you may also read and run files there, even though it is outside the working directory.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append the agent's runtime model identity. Emitted for all origins
|
||||||
|
* (it's an agent fact, not a sender fact). Skipped when modelName is
|
||||||
|
* blank. Provider parenthetical is omitted when providerId is blank.
|
||||||
|
*/
|
||||||
|
private static void appendModelLineIfPresent(StringBuilder sb, String modelName,
|
||||||
|
String providerId,
|
||||||
|
vip.mate.i18n.I18nService i18n) {
|
||||||
|
if (modelName == null || modelName.isBlank()) return;
|
||||||
|
String model = modelName.trim();
|
||||||
|
sb.append("\n");
|
||||||
|
if (i18n != null) {
|
||||||
|
sb.append(i18n.msg("context.model_identity", model));
|
||||||
|
} else {
|
||||||
|
sb.append("[system-context] Model: ").append(model);
|
||||||
|
}
|
||||||
|
if (providerId != null && !providerId.isBlank()) {
|
||||||
|
sb.append(" (provider: ").append(providerId.trim()).append(')');
|
||||||
|
}
|
||||||
|
sb.append("\n");
|
||||||
|
if (i18n != null) {
|
||||||
|
sb.append(i18n.msg("context.model_identity_hint"));
|
||||||
|
} else {
|
||||||
|
sb.append("If asked which model you are using, answer with this value for the current run.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a sender / channel / chat block when the origin carries
|
||||||
|
* meaningful IM context. Format is intentionally one line per
|
||||||
|
* fact so it's both LLM-readable and easy to log-grep.
|
||||||
|
*/
|
||||||
|
private static void appendSenderBlockIfPresent(StringBuilder sb, ChatOrigin origin) {
|
||||||
|
if (origin == null || origin == ChatOrigin.EMPTY) return;
|
||||||
|
String channelType = origin.channelType();
|
||||||
|
// Only inject for real IM channels — web / null / cron should
|
||||||
|
// see the previous prompt verbatim so their cache hit rate
|
||||||
|
// and existing eval baselines don't shift.
|
||||||
|
if (channelType == null || channelType.isBlank()
|
||||||
|
|| "web".equalsIgnoreCase(channelType)
|
||||||
|
|| origin.cronOrigin()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sb.append("\n[system-context] Channel: ").append(channelType);
|
||||||
|
if (origin.senderName() != null && !origin.senderName().isBlank()) {
|
||||||
|
sb.append("\n[system-context] Sender: ").append(origin.senderName());
|
||||||
|
}
|
||||||
|
if (origin.requesterId() != null && !origin.requesterId().isBlank()) {
|
||||||
|
sb.append(" (id=").append(origin.requesterId()).append(')');
|
||||||
|
}
|
||||||
|
if (origin.chatId() != null && !origin.chatId().isBlank()) {
|
||||||
|
sb.append("\n[system-context] Chat: ").append(origin.chatId())
|
||||||
|
.append(" (group conversation — multiple users may follow up)");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,175 @@
|
|||||||
|
package vip.mate.agent.context;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boundary-aware text truncation.
|
||||||
|
*
|
||||||
|
* <p>Character-count truncation that lands inside a JSON value or string literal
|
||||||
|
* leaves the model a fragment like {@code {"name":"serv} — a shape that invites it
|
||||||
|
* to "repair" the structure by fabricating the omitted fields. When the input
|
||||||
|
* looks like JSON, this utility snaps each head/tail cut point to the nearest
|
||||||
|
* complete structural boundary (immediately after a {@code ,}, {@code }} or
|
||||||
|
* {@code ]} that is not inside a string), so a retained fragment always ends and
|
||||||
|
* begins between elements rather than in the middle of one.
|
||||||
|
*
|
||||||
|
* <p>Non-JSON input falls back to a plain character cut, and boundary snapping is
|
||||||
|
* only applied when it costs less than half the requested budget — so callers can
|
||||||
|
* use this unconditionally without ever losing more than a plain cut would.
|
||||||
|
*/
|
||||||
|
public final class StructuredTruncator {
|
||||||
|
|
||||||
|
private StructuredTruncator() {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final int[] NO_BOUNDARIES = new int[0];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard fidelity directive appended to truncation markers so the model
|
||||||
|
* treats omitted content as unknown rather than reconstructable.
|
||||||
|
*/
|
||||||
|
public static final String FIDELITY_NOTE =
|
||||||
|
"Do NOT infer or fabricate omitted content; retrieve the full data (e.g. read_file) "
|
||||||
|
+ "or tell the user the result is incomplete.";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Head-only slice: the first {@code maxHeadChars} characters, snapped back to
|
||||||
|
* a JSON boundary when one sits within the kept region. Returns the input
|
||||||
|
* unchanged when it is already short enough.
|
||||||
|
*/
|
||||||
|
public static String headSlice(String text, int maxHeadChars) {
|
||||||
|
if (text == null || maxHeadChars <= 0 || text.length() <= maxHeadChars) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
int[] bounds = boundaries(text);
|
||||||
|
int end = snapDown(bounds, maxHeadChars);
|
||||||
|
// Reject a boundary that throws away more than half the budget.
|
||||||
|
if (end < maxHeadChars / 2) {
|
||||||
|
end = maxHeadChars;
|
||||||
|
}
|
||||||
|
return text.substring(0, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Head + marker + tail truncation. {@code headBudget} / {@code tailBudget} are
|
||||||
|
* upper bounds on each retained side; {@code marker} is inserted between them.
|
||||||
|
* The cut points snap to JSON boundaries when the input is JSON-like and the
|
||||||
|
* snap is cheap; otherwise plain character cuts are used. The result never
|
||||||
|
* exceeds {@code headBudget + marker.length() + tailBudget}.
|
||||||
|
*
|
||||||
|
* @return the input unchanged when it already fits both budgets
|
||||||
|
*/
|
||||||
|
public static String truncate(String text, int headBudget, int tailBudget, String marker) {
|
||||||
|
if (text == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (headBudget < 0) {
|
||||||
|
headBudget = 0;
|
||||||
|
}
|
||||||
|
if (tailBudget < 0) {
|
||||||
|
tailBudget = 0;
|
||||||
|
}
|
||||||
|
int len = text.length();
|
||||||
|
if (len <= headBudget + tailBudget) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
String mk = marker == null ? "" : marker;
|
||||||
|
int[] bounds = boundaries(text);
|
||||||
|
|
||||||
|
int headEnd = snapDown(bounds, headBudget);
|
||||||
|
if (headEnd < headBudget / 2) {
|
||||||
|
// No usable boundary near the head budget → plain cut.
|
||||||
|
headEnd = headBudget;
|
||||||
|
}
|
||||||
|
|
||||||
|
int floor = len - tailBudget;
|
||||||
|
int tailStart = snapUp(bounds, floor);
|
||||||
|
if (tailStart > floor + tailBudget / 2) {
|
||||||
|
// Nearest boundary is so far forward the tail would shrink by half → plain cut.
|
||||||
|
tailStart = floor;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tailStart <= headEnd) {
|
||||||
|
// Snapping collapsed the two regions into each other → plain, non-overlapping cut.
|
||||||
|
headEnd = Math.min(headBudget, len);
|
||||||
|
tailStart = Math.max(len - tailBudget, headEnd);
|
||||||
|
}
|
||||||
|
return text.substring(0, headEnd) + mk + text.substring(tailStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indices (in ascending order) at which the text may be split without
|
||||||
|
* severing a JSON token. A boundary index {@code i} marks the position
|
||||||
|
* immediately after a {@code ,}, {@code }} or {@code ]} that is not
|
||||||
|
* inside a string literal. Returns an empty array when the input does not
|
||||||
|
* look like JSON, which makes both snap helpers fall back to plain cuts.
|
||||||
|
*/
|
||||||
|
private static int[] boundaries(String text) {
|
||||||
|
int len = text.length();
|
||||||
|
int start = 0;
|
||||||
|
while (start < len && Character.isWhitespace(text.charAt(start))) {
|
||||||
|
start++;
|
||||||
|
}
|
||||||
|
if (start >= len) {
|
||||||
|
return NO_BOUNDARIES;
|
||||||
|
}
|
||||||
|
char first = text.charAt(start);
|
||||||
|
if (first != '{' && first != '[') {
|
||||||
|
return NO_BOUNDARIES;
|
||||||
|
}
|
||||||
|
|
||||||
|
int[] buf = new int[16];
|
||||||
|
int n = 0;
|
||||||
|
boolean inString = false;
|
||||||
|
boolean escaped = false;
|
||||||
|
for (int i = start; i < len; i++) {
|
||||||
|
char c = text.charAt(i);
|
||||||
|
if (inString) {
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false;
|
||||||
|
} else if (c == '\\') {
|
||||||
|
escaped = true;
|
||||||
|
} else if (c == '"') {
|
||||||
|
inString = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c == '"') {
|
||||||
|
inString = true;
|
||||||
|
} else if (c == ',' || c == '}' || c == ']') {
|
||||||
|
if (n == buf.length) {
|
||||||
|
int[] grown = new int[buf.length * 2];
|
||||||
|
System.arraycopy(buf, 0, grown, 0, n);
|
||||||
|
buf = grown;
|
||||||
|
}
|
||||||
|
buf[n++] = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (n == buf.length) {
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
int[] out = new int[n];
|
||||||
|
System.arraycopy(buf, 0, out, 0, n);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Largest boundary {@code <= limit}, or 0 when none exists. */
|
||||||
|
private static int snapDown(int[] bounds, int limit) {
|
||||||
|
int best = 0;
|
||||||
|
for (int b : bounds) {
|
||||||
|
if (b > limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
best = b;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Smallest boundary {@code >= floor}, or {@link Integer#MAX_VALUE} when none exists. */
|
||||||
|
private static int snapUp(int[] bounds, int floor) {
|
||||||
|
for (int b : bounds) {
|
||||||
|
if (b >= floor) {
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Integer.MAX_VALUE;
|
||||||
|
}
|
||||||
|
}
|
||||||