Compare commits
No commits in common. "dev" and "v1.0.108" have entirely different histories.
@ -1,28 +0,0 @@
|
|||||||
# 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/**
|
|
||||||
172
.env.example
@ -1,165 +1,17 @@
|
|||||||
# MateClaw 环境变量配置
|
# MateClaw 环境变量配置
|
||||||
# 复制此文件为 .env 并填写实际值:cp .env.example .env
|
# 复制此文件为 .env 并填写实际值
|
||||||
#
|
|
||||||
# LLM API Key(DashScope、OpenAI 等)无需在此配置,启动后在管理界面「模型管理」中添加。
|
|
||||||
#
|
|
||||||
# ⚠️ 所有标注「必填」的项若没配置,`docker compose up` 会直接失败退出,避免把默认/示例值带到生产环境。
|
|
||||||
|
|
||||||
# ==================== 数据库(Docker 模式必填) ====================
|
# 阿里云 DashScope API Key(必填)
|
||||||
#
|
# 申请地址:https://dashscope.aliyun.com/
|
||||||
# ⚠️ Docker 栈已切换到 PostgreSQL 16(此前为 MySQL)。老部署升级前请先读
|
DASHSCOPE_API_KEY=your-dashscope-api-key-here
|
||||||
# docker-compose.yml 顶部的迁移说明:旧 mysql_data 卷不会被读取,需要先
|
|
||||||
# mysqldump 再用 pgloader 等工具导入,或钉在切换前的 tag 上继续用 MySQL。
|
|
||||||
|
|
||||||
DB_HOST=localhost
|
# Serper 网页搜索 API Key(可选,用于 WebSearch 工具)
|
||||||
DB_PORT=5432
|
# 申请地址:https://serper.dev/
|
||||||
DB_NAME=mateclaw
|
|
||||||
|
|
||||||
# 应用连接账号(最小权限角色,由 docker/postgres/init/10-app-role.sh 首次
|
|
||||||
# 初始化时自动创建,仅拥有 mateclaw schema,不是超级用户)。
|
|
||||||
DB_USERNAME=mateclaw
|
|
||||||
|
|
||||||
# ⚠️ 必填且请改成强密码(至少 16 位,含大小写+数字+符号)。
|
|
||||||
# docker-compose.yml 会通过 ${DB_PASSWORD:?} 强制要求此项。
|
|
||||||
DB_PASSWORD=change-me-strong-user-password
|
|
||||||
|
|
||||||
# ⚠️ PostgreSQL 引导超级账号,仅用于容器内初始化和运维。
|
|
||||||
# 请改成与 DB_PASSWORD 不同的强密码。
|
|
||||||
DB_ADMIN_USERNAME=mateclaw_admin
|
|
||||||
DB_ADMIN_PASSWORD=change-me-strong-admin-password
|
|
||||||
|
|
||||||
# ==================== 搜索(可选) ====================
|
|
||||||
|
|
||||||
# WebSearch 工具的云端搜索 API(可选,二选一或都不配;不配可用 SearXNG sidecar)。
|
|
||||||
SERPER_API_KEY=
|
SERPER_API_KEY=
|
||||||
TAVILY_API_KEY=
|
|
||||||
|
|
||||||
# ==================== 安全(强烈建议覆盖) ====================
|
# 数据库配置(Docker 部署时无需修改)
|
||||||
|
DB_HOST=localhost
|
||||||
# JWT 签名密钥。若留空,服务器会用内置默认值并在启动日志里 WARN。
|
DB_PORT=3306
|
||||||
# 生产部署必须设置,至少 32 位随机字符串:openssl rand -base64 48
|
DB_NAME=mateclaw
|
||||||
JWT_SECRET=
|
DB_USERNAME=mateclaw
|
||||||
|
DB_PASSWORD=mateclaw123
|
||||||
# CORS 白名单(逗号分隔,如 https://mateclaw.example.com,https://admin.example.com)。
|
|
||||||
# 若留空,服务器会允许所有 origin 并在启动日志里 WARN。生产部署务必设置。
|
|
||||||
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=
|
|
||||||
|
|
||||||
# ── Skill ZIP 上传大小上限(MB,可选)────────────────────────────
|
|
||||||
# 技能包上传/市场安装的应用层上限,默认单文件 1MB、整包 50MB。
|
|
||||||
# 解包时整包缓存在内存里,整包上限调多大,单次安装峰值内存就可能吃多大。
|
|
||||||
# 同时注意 Spring 层 spring.servlet.multipart 的上限(默认 100MB/200MB)。
|
|
||||||
MATECLAW_SKILL_UPLOAD_MAX_ENTRY_SIZE_MB=
|
|
||||||
MATECLAW_SKILL_UPLOAD_MAX_TOTAL_SIZE_MB=
|
|
||||||
|
|
||||||
# ── Python pip 镜像源(可选)──────────────────────────────────────
|
|
||||||
# skill 里 Python 脚本缺包时 pip install 走的源。默认用 PyPI 官方源。
|
|
||||||
# pip 原生读 PIP_INDEX_URL / PIP_TRUSTED_HOST 环境变量,容器内自动继承。
|
|
||||||
# HTTP 源会自动从 URL 推导 PIP_TRUSTED_HOST;自签 HTTPS 需手动填。
|
|
||||||
# 互联网加速: https://pypi.tuna.tsinghua.edu.cn/simple
|
|
||||||
# 局域网私有源: http://192.168.1.100:8080/simple(trusted-host 自动推导)
|
|
||||||
#PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
|
||||||
#PIP_TRUSTED_HOST=
|
|
||||||
# ── 桌面版补充(非 Docker,宿主机直接跑 Java)─────────────────────
|
|
||||||
# 桌面版不继承上面的 Docker 变量。用 Spring 配置注入 Python 子进程;
|
|
||||||
# 也可直接设系统环境变量 PIP_INDEX_URL / PIP_TRUSTED_HOST(覆盖更全)。
|
|
||||||
#MATECLAW_PIP_INDEX_URL=
|
|
||||||
#MATECLAW_PIP_TRUSTED_HOST=
|
|
||||||
|
|
||||||
# ── Maven 镜像(国内加速)─────────────────────────────────────────
|
|
||||||
# 在中国大陆构建时取消注释,将 Aliyun 仓库优先级提前,大幅提速 mvn 拉包。
|
|
||||||
# 空值(默认)使用 US Maven Central → Google CDN → Aliyun 的顺序。
|
|
||||||
#MAVEN_FLAGS=-Paliyun-first
|
|
||||||
|
|||||||
19
.gitattributes
vendored
@ -1,19 +0,0 @@
|
|||||||
# Line-ending policy.
|
|
||||||
#
|
|
||||||
# Shell scripts are bind-mounted into Linux containers (e.g.
|
|
||||||
# docker/postgres/init/ -> /docker-entrypoint-initdb.d) and executed there.
|
|
||||||
# A CRLF checkout on Windows (core.autocrlf=true is the Git for Windows
|
|
||||||
# default) turns the shebang into "#!/bin/sh\r", which fails with
|
|
||||||
# "cannot execute: required file not found". Pin them to LF everywhere.
|
|
||||||
#
|
|
||||||
# SQL files are pinned to LF too so Flyway migration checksums stay
|
|
||||||
# identical across platforms.
|
|
||||||
|
|
||||||
*.sh text eol=lf
|
|
||||||
*.bash text eol=lf
|
|
||||||
*.sql text eol=lf
|
|
||||||
|
|
||||||
# Windows-native scripts keep CRLF.
|
|
||||||
*.bat text eol=crlf
|
|
||||||
*.cmd text eol=crlf
|
|
||||||
*.ps1 text eol=crlf
|
|
||||||
74
.github/ISSUE_TEMPLATE/bug-en.yml
vendored
@ -1,74 +0,0 @@
|
|||||||
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
@ -1,74 +0,0 @@
|
|||||||
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
@ -1,8 +0,0 @@
|
|||||||
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
@ -1,41 +0,0 @@
|
|||||||
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
@ -1,41 +0,0 @@
|
|||||||
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
|
|
||||||
48
.gitignore
vendored
@ -29,12 +29,6 @@ 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
|
||||||
@ -78,13 +72,8 @@ pom.xml.versionsBackup
|
|||||||
# mateclaw static build output (do not commit)
|
# mateclaw static build output (do not commit)
|
||||||
mateclaw-server/src/main/resources/static/
|
mateclaw-server/src/main/resources/static/
|
||||||
|
|
||||||
# Maven must not materialize an unresolved property as a literal directory.
|
|
||||||
**/${project.build.directory}/
|
|
||||||
|
|
||||||
# mateclaw local runtime data (H2 DB, logs, etc. - do not commit)
|
# mateclaw local runtime data (H2 DB, logs, etc. - do not commit)
|
||||||
mateclaw-server/data/
|
mateclaw-server/data/
|
||||||
.sessions/
|
|
||||||
/data/
|
|
||||||
|
|
||||||
# VitePress build output and cache (do not commit)
|
# VitePress build output and cache (do not commit)
|
||||||
docs/.vitepress/cache/
|
docs/.vitepress/cache/
|
||||||
@ -98,38 +87,5 @@ deploy/nginx/ssl/*.crt
|
|||||||
deploy/nginx/ssl/*.key
|
deploy/nginx/ssl/*.key
|
||||||
deploy/nginx/ssl/*.pem
|
deploy/nginx/ssl/*.pem
|
||||||
|
|
||||||
# Env files (real secrets - do not commit)
|
# Deploy env
|
||||||
# .env matches any level; .env.example / *.env.example are templates and stay tracked.
|
deploy/.env
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
.env.*.local
|
|
||||||
!.env.example
|
|
||||||
!**/.env.example
|
|
||||||
|
|
||||||
# Claude Code local settings
|
|
||||||
CLAUDE.md
|
|
||||||
.claude/settings.local.json
|
|
||||||
.claude/plans/
|
|
||||||
|
|
||||||
# Codex CLI local artifacts
|
|
||||||
.codex/
|
|
||||||
|
|
||||||
# Codebase memory (local agent index / graph artifact; do not commit)
|
|
||||||
.codebase-memory/
|
|
||||||
|
|
||||||
# Sync tooling local state (generated each run; report is intentionally tracked)
|
|
||||||
scripts/.*-sync-state.json
|
|
||||||
|
|
||||||
# Sandbox / external client work that lives in this directory
|
|
||||||
# but should not ship in the repo.
|
|
||||||
outputs/
|
|
||||||
|
|
||||||
# This is a pnpm monorepo — pnpm-lock.yaml is the only lockfile we track.
|
|
||||||
# Ignore stray npm/yarn lockfiles so they are not committed by mistake.
|
|
||||||
package-lock.json
|
|
||||||
yarn.lock
|
|
||||||
|
|
||||||
# Python bytecode caches generated when skill scripts (e.g. skills/*/scripts/*.py)
|
|
||||||
# are executed. Never commit or sync these.
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
|
|||||||
556
README.md
@ -1,327 +1,353 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
|
# MateClaw
|
||||||
|
|
||||||
|
[](https://github.com/matevip/mateclaw)
|
||||||
|
[](https://claw.mate.vip/docs)
|
||||||
|
[](https://claw-demo.mate.vip)
|
||||||
|
[](https://claw.mate.vip)
|
||||||
|
[](https://adoptium.net/)
|
||||||
|
[](https://spring.io/projects/spring-boot)
|
||||||
|
[](https://vuejs.org/)
|
||||||
|
[](https://github.com/matevip/mateclaw)
|
||||||
|
[](LICENSE)
|
||||||
|
[](https://github.com/matevip/mateclaw/stargazers)
|
||||||
|
[](https://github.com/matevip/mateclaw/network)
|
||||||
|
|
||||||
|
[[Website](https://claw.mate.vip)] [[Live Demo](https://claw-demo.mate.vip)] [[Documentation](https://claw.mate.vip/docs)] [[中文](README_zh.md)]
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<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
|
<p align="center"><b>Your AI mate, always ready to lend a claw.</b></p>
|
||||||
|
|
||||||
<p align="center"><b>Your second brain</b></p>
|
|
||||||
|
|
||||||
<p align="center"><sub><b>Pluggable Agent Runtime · Native + DSH · Spring Boot inside</b></sub></p>
|
|
||||||
|
|
||||||
[](https://github.com/mateaix/mateclaw)
|
|
||||||
[](https://claw.mate.vip/docs)
|
|
||||||
[](https://claw-demo.mate.vip)
|
|
||||||
[](https://claw.mate.vip)
|
|
||||||
[](https://adoptium.net/)
|
|
||||||
[](https://spring.io/projects/spring-boot)
|
|
||||||
[](https://vuejs.org/)
|
|
||||||
[](https://github.com/mateaix/mateclaw)
|
|
||||||
[](LICENSE)
|
|
||||||
|
|
||||||
[[Website](https://claw.mate.vip)] [[Live Demo](https://claw-demo.mate.vip)] [[Documentation](https://claw.mate.vip/docs)] [[中文](README_zh.md)]
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p align="center">
|
A personal AI assistant system built with **Java + Vue 3**, powered by [Spring AI Alibaba](https://github.com/alibaba/spring-ai-alibaba). Features multi-agent orchestration, a flexible tool/skill system with MCP protocol support, multi-layer memory, and multi-channel adapters.
|
||||||
<img src="assets/images/preview.png" alt="MateClaw Preview" width="800">
|
|
||||||
</p>
|
|
||||||
|
|
||||||
---
|
> **Core capabilities:**
|
||||||
|
|
||||||
> **Latest stable: v2.2.0 — a pluggable, recoverable Agent Runtime.** Digital employees can now run on MateClaw's native StateGraph engine or the managed DeepSeek Harness (DSH) runtime while keeping one conversation, policy, tool, persistence, and observability plane. Persistent Goals survive bounded turns and backend restarts, and A2A connects governed employees across systems. Read the [v2.2.0 release notes](https://claw.mate.vip/docs/en/releases/2.2.0).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
> **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 in your environment; you control persisted data, and task content is sent only to model, channel, or tool services you explicitly configure.
|
> **Multi-Agent Orchestration** — ReAct (Thought → Action → Observation loop) and Plan-and-Execute (auto-decompose complex tasks into ordered sub-steps). Create multiple independent agents, each with their own personality and tools.
|
||||||
>
|
>
|
||||||
> **And underneath, a real Agent Runtime.** An employee is no longer welded to one reasoning loop. Choose the native StateGraph runtime for ReAct, Plan-and-Execute, Goals, and Team Runs, or run DeepSeek Harness as a managed external loop over authenticated JSON-RPC. Both paths converge on the same conversations, workspace boundaries, Tool Guard, event projection, and lifecycle controls.
|
> **Tool & Skill System** — Built-in tools (web search, date/time) + MCP protocol for external tool integration. Pre-configured GitHub and Filesystem MCP servers — enable and go. Install skill packages from ClawHub marketplace or custom sources.
|
||||||
|
>
|
||||||
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.
|
> **Multi-Layer Memory** — Short-term context window with auto-compression, event-driven post-conversation memory extraction, workspace files (PROFILE.md / MEMORY.md / daily notes), and scheduled memory consolidation.
|
||||||
|
>
|
||||||
**MateClaw is the whole widget.** One deployment. Reasoning, knowledge, memory, tools, channels — built together, not bolted on. And when your primary model is unavailable, the next healthy provider retries the current request.
|
> **Every Channel** — Web console, DingTalk, Feishu, WeChat Work, Telegram, Discord, QQ. One MateClaw, connect as needed.
|
||||||
|
>
|
||||||
|
> **Multi-Provider Models** — 20+ providers: DashScope, OpenAI, Anthropic, Google Gemini, DeepSeek, Kimi, MiniMax, Zhipu AI, Volcano Engine, OpenRouter, Ollama, LM Studio, llama.cpp, MLX, and more. Configure in the web UI.
|
||||||
|
>
|
||||||
|
> **Desktop App** — Electron-based desktop application with auto-update support. Download and double-click to run.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Three things that make it different
|
## Table of Contents
|
||||||
|
|
||||||
### 1 · Your AI doesn't die when a model does
|
- [Quick Start](#quick-start)
|
||||||
|
- [Screenshots](#screenshots)
|
||||||
Primary key expired. Vendor returns 401. Network blip. Quota drained.
|
- [Architecture](#architecture)
|
||||||
|
- [Tech Stack](#tech-stack)
|
||||||
Other tools hand you a red error card. MateClaw tries the next healthy provider in configured order — including built-in and OpenAI-compatible options such as DashScope, OpenAI, Anthropic, Gemini, DeepSeek, Kimi, Ollama, LM Studio, and MLX — and attempts to recover the current request. It returns an error only when the available chain is exhausted. A provider health tracker parks bad vendors in a cooldown window so they don't waste seconds on every turn.
|
- [Features](#features)
|
||||||
|
- [Documentation](#documentation)
|
||||||
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.
|
- [Roadmap](#roadmap)
|
||||||
|
- [Contributing](#contributing)
|
||||||
### 2 · Knowledge that links itself
|
- [Contact Us](#contact-us)
|
||||||
|
- [License](#license)
|
||||||
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 preserves traceable citations for generated content. Open the citation drawer to inspect the corresponding source chunk and verify page or answer references.
|
|
||||||
|
|
||||||
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
|
## Quick Start
|
||||||
|
|
||||||
### Digital employees, not chatbots
|
### Prerequisites
|
||||||
You hire coworkers, not chat boxes. Each one has a **Role**, a **Goal**, a **Backstory**, a runtime, a pixel-art avatar, and a color of their own — six built-in templates ship ready (General Assistant · Product Assistant · Research Analyst · Customer Support · Data Analyst · Code Reviewer). Employee identity and governance stay stable even when the execution engine changes.
|
|
||||||
|
|
||||||
### Agent Runtime: native or DSH (2.2.0+)
|
- Java 17+
|
||||||
The `AgentRuntimeProvider` contract separates an employee from the engine that runs its turn. The **native runtime** keeps ReAct, Plan-and-Execute, persistent Goals, and Team Runs inside MateClaw. The **DSH runtime** manages `dsh-jsonrpc-agent` as an authenticated child process and streams thinking, text, tool calls, usage, completion, and cancellation back as normalized runtime events. DSH owns the external Agent loop; MateClaw still owns the session, workspace, credentials, tools, approvals, messages, and UI projection. Runtime availability and capabilities are validated before startup, and DSH can be installed, verified, connection-tested, enabled, or disabled from the console. [Configure DeepSeek Harness →](https://claw.mate.vip/docs/en/deepseek-harness)
|
- Node.js 18+ & pnpm
|
||||||
|
- Maven 3.9+ (or use `mvnw`)
|
||||||
|
- At least one LLM API Key (e.g., [DashScope](https://dashscope.aliyun.com/))
|
||||||
|
|
||||||
### Durable long tasks: checkpoint, restart, continue (2.2.0+)
|
### Option 1: Local Development
|
||||||
Persistent Goals turn work that takes hours into bounded, recoverable segments. The database preserves the goal checklist, continuation state, attempts, cooldowns, leases, and user input accepted while the worker is busy. After a single backend instance restarts, the supervisor reconciles the interrupted attempt, reads persisted checkpoints and artifacts, and schedules the next safe segment instead of asking you to repeat the task.
|
|
||||||
|
|
||||||
For file-producing work, ask the employee to keep a progress ledger, append small verifiable units, inspect the existing tail after recovery, and complete the Goal only after reproducible acceptance checks pass. The runtime does not promise exactly-once behavior for arbitrary external side effects; payments, sends, publishes, and destructive calls still need provider idempotency or review. [Run and verify durable Goals →](https://claw.mate.vip/docs/en/goals)
|
**1. Start the backend**
|
||||||
|
|
||||||
> Prompt pattern: “Create a persistent Goal first. Save the plan and progress in the workspace, write in small checkpoints, resume from existing evidence after errors or restart, and call `completeGoal` only after every criterion has verifiable evidence.”
|
|
||||||
|
|
||||||
### Team Runs (2.1.0+)
|
|
||||||
One request, one durable **Team Run**. A stable `runId` links the user's objective, task DAG, worker executions, final synthesis, and deliverables. Chat is the outcome surface, Agents Live groups the workers for real-time observation, and Teams owns history and governance — all three consume the same server projection. Worker conversations no longer flood the normal sidebar; summaries and files lead, while tasks, evidence, approvals, and read-only worker records drill down on demand. Underneath, the 2.0 shared board still provides dependency orchestration, parallel dispatch, prerequisite hand-off, execution leases, cancel-interrupt, and human approval gates.
|
|
||||||
|
|
||||||
### 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**. In 2.1, reflection and cross-session recurring-request mining can produce reusable improvements; routine promotion, constrained auto-binding, curator handover/governance, origin policy, snapshots, and restore points keep evolution observable, workspace-scoped, and reversible. Eight starter templates plus a five-step creation wizard, with **Pre-flight checks** 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, which runtime provider owns the turn, what step it is on, how many tokens it uses, and one-click force-recycle when stuck. Native and DSH events enter the same thinking / tool / answer projection; completion, failure, usage, and cancellation retain consistent lifecycle semantics. Per-event SSE IDs make reconnects safe, and Team Runs group member work under one live execution.
|
|
||||||
|
|
||||||
### Multimodal creation
|
|
||||||
Text-to-speech · Speech-to-text · Image · Music · Video · 3D. First-class, not add-ons. **Sidecar routing** (1.3.0+) means a text-only main model + an image attachment no longer dead-ends — a configured vision model describes the image, and the main model answers. **Image edit** lands too: refer to an earlier conversation attachment by `msg:<id>:<idx>` and ask the model to recolor or restyle it. Four **document-generation tools** (`DocxRenderTool` / `XlsxRenderTool` / `PptxRenderTool` / `PdfRenderTool`) render Markdown straight to Office files inside the JVM — no subprocess, no Office install.
|
|
||||||
|
|
||||||
### Content Studio (1.8.0+)
|
|
||||||
A flagship *scene*, not a tool — a seeded "Content Studio" employee turns one sentence into a publishable post: pick-topic → research → draft → illustrate → **de-AI** → lay out → deliver. **WeChat Official Account (公众号)** articles land in your draft box as inline-style HTML with body images uploaded into WeChat; **Xiaohongshu (小红书)** notes package as ≥3 vertical 3:4 cards with an online preview. De-AI-ification runs against a **measurable AI-trace score**; every delivery is compliance-scanned and logged to a **content calendar** that dedups by topic fingerprint.
|
|
||||||
|
|
||||||
### Enterprise-ready
|
|
||||||
RBAC + JWT. **Personal Access Tokens** for headless scripts and CI. **HMAC-SHA-256 outbound webhook signing**. **Distributed Cron lock** so multi-instance deployments don't double-fire. Full audit trail. Flyway-managed schema. One JAR to ship. H2 for development; the public Docker stack defaults to PostgreSQL 16, the MySQL profile remains supported, and the Kingbase driver is opt-in.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## AI is becoming infrastructure
|
|
||||||
|
|
||||||
Model providers rate-limit, networks fail, keys expire, and services become temporarily unavailable. Betting every AI capability on one provider turns an upstream incident into your own outage.
|
|
||||||
|
|
||||||
Once AI enters production, the stable layer should not be tied to one model supplier or one Agent loop. MateClaw absorbs model uncertainty through provider priorities, health tracking, cooldown, and failover, then places native and external execution engines behind one governed Agent Runtime contract.
|
|
||||||
|
|
||||||
**MateClaw is that layer — built the Spring Boot way.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Why 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) |
|
|
||||||
|:---|:---:|:---:|:---:|:---:|:---:|
|
|
||||||
| **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 |
|
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
**MateClaw is the version built for teams.** Digital employees, models, and tools sit behind permissions and workspace boundaries. Approval flows can pause risky actions for review, and key operations enter the audit trail. The Admin Runtime Console centralizes active employee and provider state with force-recycle for stuck runs. Spring Boot inside — a natural fit for Java shops already running production services.
|
|
||||||
|
|
||||||
Same "whole widget" philosophy. Different center of gravity.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend
|
|
||||||
cd mateclaw-server
|
cd mateclaw-server
|
||||||
mvn spring-boot:run # http://localhost:18088
|
export DASHSCOPE_API_KEY=your-key-here
|
||||||
|
mvn spring-boot:run
|
||||||
# Frontend
|
# Backend runs at http://localhost:18088
|
||||||
cd mateclaw-ui
|
# H2 Console: http://localhost:18088/h2-console
|
||||||
npm install && npm run dev # http://localhost:5173
|
# API Docs (SpringDoc OpenAPI): http://localhost:18088/swagger-ui.html
|
||||||
```
|
```
|
||||||
|
|
||||||
Login: `admin` / `admin123`
|
**2. Start the frontend**
|
||||||
|
|
||||||
### Docker
|
```bash
|
||||||
|
cd mateclaw-ui
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
|
# Frontend runs at http://localhost:5173 (proxies /api to :18088)
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Log in**
|
||||||
|
|
||||||
|
Open http://localhost:5173 and log in with `admin` / `admin123`.
|
||||||
|
|
||||||
|
### Option 2: Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
docker compose up -d # http://localhost:18080
|
# Edit .env — fill in DASHSCOPE_API_KEY and other variables
|
||||||
|
|
||||||
|
docker compose up -d
|
||||||
|
# Service runs at http://localhost:18080 (MySQL + backend)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Desktop
|
### Option 3: Desktop Application
|
||||||
|
|
||||||
Download from [GitHub Releases](https://github.com/mateaix/mateclaw/releases). Bundles JRE 21. No Java install needed.
|
Download the installer from [GitHub Releases](https://github.com/matevip/mateclaw/releases):
|
||||||
|
|
||||||
|
| Platform | File | Notes |
|
||||||
|
|----------|------|-------|
|
||||||
|
| **macOS (Apple Silicon)** | `MateClaw_<version>_arm64.dmg` | Recommended for M1/M2/M3/M4/M5 Mac |
|
||||||
|
| **macOS (Apple Silicon)** | `MateClaw_<version>_arm64.zip` | zip format (Apple Silicon) |
|
||||||
|
| **macOS (Intel)** | `MateClaw_<version>_x64.dmg` | For Intel-based Mac |
|
||||||
|
| **macOS (Intel)** | `MateClaw_<version>_x64.zip` | zip format (Intel) |
|
||||||
|
| **Windows (x64)** | `MateClaw_<version>_Setup.exe` | For most Windows PCs (64-bit) |
|
||||||
|
| **Windows (x64)** | `MateClaw_<version>_x64_Setup.exe` | Explicit x64 build |
|
||||||
|
| **Windows (ARM64)** | `MateClaw_<version>_arm64_Setup.exe` | For ARM-based Windows (e.g. Surface Pro X) |
|
||||||
|
|
||||||
|
Double-click to run. The app bundles JRE 21 + the Spring Boot backend, no Java installation needed. Supports auto-update via GitHub Releases.
|
||||||
|
|
||||||
|
> **macOS users**: If macOS blocks the app, right-click → Open → Open again, or go to System Settings → Privacy & Security → Open Anyway.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/images/chat-echarts-demo.png" alt="MateClaw Chat with ECharts Visualization" width="800">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center"><em>Chat console — AI self-introduction with auto-generated ECharts donut chart</em></p>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Project structure
|
|
||||||
|
|
||||||
```
|
```
|
||||||
mateclaw/
|
mateclaw/
|
||||||
├── mateclaw-server/ Spring Boot 3.5 backend (Agent Runtime contract, native StateGraph + DSH)
|
├── mateclaw-server/ # Spring Boot backend
|
||||||
├── mateclaw-ui/ Vue 3 + TypeScript admin SPA (built into the server JAR)
|
│ ├── src/main/java/vip/mate/
|
||||||
├── mateclaw-desktop/ Electron desktop app (local-embedded / remote-centralized)
|
│ │ ├── agent/ # Agent engine (ReAct, Plan-and-Execute, StateGraph)
|
||||||
├── mateclaw-webchat/ Embeddable chat widget (UMD / ES bundles)
|
│ │ ├── planning/ # Task planning (Plan / SubPlan models)
|
||||||
├── mateclaw-plugin-api/ Java SDK for third-party capability plugins
|
│ │ ├── tool/ # Tool system (built-in + MCP adapters)
|
||||||
├── mateclaw-plugin-sample/ Reference plugin implementation
|
│ │ ├── skill/ # Skill management (workspace + ClawHub)
|
||||||
├── mateclaw-plugin-mem0/ Optional Mem0 memory-provider plugin
|
│ │ ├── channel/ # Channel adapters (Web, DingTalk, Feishu, etc.)
|
||||||
├── mateclaw-plugin-search-sample/ Search Provider SPI example
|
│ │ ├── workspace/ # Conversations, messages, workspace files
|
||||||
|
│ │ ├── memory/ # Memory extraction & consolidation
|
||||||
|
│ │ ├── llm/ # Multi-provider model configs
|
||||||
|
│ │ ├── cron/ # Scheduled tasks (CronJob)
|
||||||
|
│ │ ├── auth/ # Spring Security + JWT
|
||||||
|
│ │ └── config/ # Spring bean configurations
|
||||||
|
│ └── src/main/resources/
|
||||||
|
│ ├── application.yml # Main config (H2 for dev)
|
||||||
|
│ ├── prompts/ # Prompt templates
|
||||||
|
│ └── db/ # Schema & seed data (schema.sql, data.sql)
|
||||||
|
├── mateclaw-ui/ # Vue 3 SPA frontend
|
||||||
|
│ └── src/
|
||||||
|
│ ├── views/ # Pages (ChatConsole, AgentWorkspace, SkillMarket, etc.)
|
||||||
|
│ ├── components/ # Reusable components
|
||||||
|
│ ├── stores/ # Pinia stores (domain-driven)
|
||||||
|
│ ├── api/ # Axios HTTP client
|
||||||
|
│ ├── router/ # Vue Router
|
||||||
|
│ ├── types/ # TypeScript types
|
||||||
|
│ └── i18n/ # Internationalization (zh-CN, en-US)
|
||||||
|
├── mateclaw-desktop/ # Electron desktop app
|
||||||
|
├── docs/ # VitePress documentation (zh + en)
|
||||||
├── 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
|
## Tech Stack
|
||||||
|
|
||||||
| Layer | Technology |
|
| Layer | Technology |
|
||||||
|---|---|
|
|-------|-----------|
|
||||||
| Backend | Spring Boot 3.5 · Spring AI Alibaba 1.1 · MyBatis Plus · Flyway |
|
| Backend Framework | Spring Boot 3.5 + Spring AI Alibaba 1.1 |
|
||||||
| Agent Runtime | `AgentRuntimeProvider` contract · Native StateGraph (ReAct + Plan-Execute) · managed DSH JSON-RPC runtime · normalized events / lifecycle / usage · Tool Guard |
|
| LLM Integration | DashScope, OpenAI, Anthropic, Gemini, DeepSeek, Kimi, MiniMax, Zhipu, Volcano Engine, OpenRouter, Ollama, LM Studio, llama.cpp, MLX |
|
||||||
| Orchestration | Workflow (7 step modes · Pebble DSL) · Triggers (6 pattern types · event governance) · Wiki Transformations (1.3.0+) |
|
| Agent Engine | StateGraph (ReAct + Plan-and-Execute) |
|
||||||
| Capability Extension | SKILL.md packages · MCP (stdio / SSE / HTTP · per-agent binding) · ACP bridge (Claude Code / Codex) |
|
| Database | H2 (dev) / MySQL 8.0+ (prod) |
|
||||||
| Database | H2 (dev) · PostgreSQL 16 (Docker default) · MySQL 8.0+ (supported) · Kingbase (opt-in driver) |
|
| ORM | MyBatis Plus 3.5 |
|
||||||
| Auth | Spring Security + JWT |
|
| Authentication | Spring Security + JWT |
|
||||||
| Frontend | Vue 3 · TypeScript · Vite · Element Plus · TailwindCSS 4 |
|
| API Docs | SpringDoc OpenAPI 3 |
|
||||||
| Desktop | Electron · electron-updater · JRE 21 (bundled) |
|
| Frontend | Vue 3 + TypeScript + Vite |
|
||||||
| Widget | Vite library mode · UMD + ES bundles |
|
| State Management | Pinia |
|
||||||
|
| UI Components | Element Plus |
|
||||||
|
| Styling | TailwindCSS 4 |
|
||||||
|
| Desktop | Electron + electron-updater |
|
||||||
|
| Docs Site | VitePress |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Agent System
|
||||||
|
|
||||||
|
- **ReAct Agent** — Thought → Action → Observation reasoning loop with tool calling
|
||||||
|
- **Plan-and-Execute** — Auto-decompose complex tasks into ordered sub-steps with progress tracking
|
||||||
|
- **Dynamic Agent** — Load agent configs from database at runtime
|
||||||
|
- **Multi-Agent** — Create multiple independent agents, each with their own system prompt, tools, and personality
|
||||||
|
|
||||||
|
### Tool & Skill System
|
||||||
|
|
||||||
|
- **Built-in Tools** — Web search (Serper/Tavily), date/time, workspace memory read/write
|
||||||
|
- **MCP Protocol** — Connect external tools via Model Context Protocol (stdio, SSE, and Streamable HTTP transports). Full lifecycle management in the web UI — add, edit, enable/disable, and test connections
|
||||||
|
- **Pre-configured MCP Servers** — GitHub (`@modelcontextprotocol/server-github`) and Filesystem ship out-of-the-box. Enable from the MCP management page and fill in your token — no code changes needed
|
||||||
|
- **Skill Packages** — Install/uninstall skill packages with `SKILL.md` manifests
|
||||||
|
- **ClawHub Marketplace** — Browse and install skills from the ClawHub registry
|
||||||
|
- **Workspace Skills** — Convention-based skill directory at `~/.mateclaw/skills/{name}/`
|
||||||
|
|
||||||
|
### Memory System
|
||||||
|
|
||||||
|
- **Short-Term** — Conversation context window with auto-compression when token budget exceeded
|
||||||
|
- **Post-Conversation Extraction** — Event-driven async LLM analysis, writes to PROFILE.md / MEMORY.md / daily notes
|
||||||
|
- **Memory Consolidation** — Scheduled daily emergence (CronJob at 2:00 AM) merges daily notes into long-term memory
|
||||||
|
- **Workspace Files** — Per-agent AGENTS.md, SOUL.md, PROFILE.md, MEMORY.md, memory/*.md
|
||||||
|
- **Agent Memory Tool** — Agents can read/write their own workspace files during conversations
|
||||||
|
|
||||||
|
### Multi-Channel
|
||||||
|
|
||||||
|
- **Web Console** — SSE streaming with rich message rendering (Markdown, code, plans)
|
||||||
|
- **DingTalk** — Webhook + event subscription
|
||||||
|
- **Feishu (Lark)** — Webhook + event subscription
|
||||||
|
- **WeChat Work** — Callback API
|
||||||
|
- **Telegram** — Bot API with webhook
|
||||||
|
- **Discord** — Bot with slash commands
|
||||||
|
- **QQ** — QQ Bot API
|
||||||
|
|
||||||
|
### Model Providers
|
||||||
|
|
||||||
|
Configure in the web UI (Settings → Models). Supported providers:
|
||||||
|
|
||||||
|
| Provider | Models |
|
||||||
|
|----------|--------|
|
||||||
|
| **Cloud Providers** | |
|
||||||
|
| DashScope (Alibaba) | Qwen3.5-Max, Qwen3.5-Plus, Qwen3-Max, Qwen3-Plus, Qwen-Max, Qwen-Plus, Qwen-Turbo, Qwen-Long, DeepSeek-V3.2 |
|
||||||
|
| ModelScope | Qwen3.5-122B-A10B, GLM-5 |
|
||||||
|
| Aliyun Coding Plan | Qwen3.5-Plus, Qwen3-Coder-Next, GLM-5, GLM-4.7, MiniMax-M2.5, Kimi-K2.5 |
|
||||||
|
| OpenAI | GPT-5.2, GPT-5, GPT-5-Mini, GPT-5-Nano, GPT-4.1, GPT-4.1-Mini, GPT-4.1-Nano, o3, o4-mini, GPT-4o |
|
||||||
|
| Azure OpenAI | GPT-5, GPT-4.1, GPT-4o and more |
|
||||||
|
| Anthropic | Claude Opus 4.6, Claude Sonnet 4.6 (via model discovery) |
|
||||||
|
| Google Gemini | Gemini 3.1 Pro, Gemini 3 Flash, Gemini 2.5 Pro, Gemini 2.5 Flash, Gemini 2.0 Flash |
|
||||||
|
| DeepSeek | DeepSeek-Chat, DeepSeek-Reasoner |
|
||||||
|
| Kimi (Moonshot) | Kimi-K2.5, Kimi-K2-Thinking, Kimi for Coding (CN / International / Code) |
|
||||||
|
| MiniMax | MiniMax-M2.7, MiniMax-M2.5 (International / China) |
|
||||||
|
| Zhipu AI | GLM-5.1, GLM-5, GLM-5-Turbo, GLM-5V-Turbo (CN / International) |
|
||||||
|
| Volcano Engine | Doubao-1.5-Pro-256K, Doubao-1.5-Lite, Doubao-1.5-Thinking-Pro, Doubao-1.5-Vision-Pro |
|
||||||
|
| OpenRouter | GPT-5, Claude Opus 4.6, Gemini 2.5 Pro, Llama 4 Maverick, DeepSeek R1, and 200+ more |
|
||||||
|
| **Local Providers** | |
|
||||||
|
| Ollama | Qwen3, Gemma 4, Gemma 3, Llama 3.1, DeepSeek R1, Mistral (auto-detected on startup) |
|
||||||
|
| LM Studio | Any locally-served model |
|
||||||
|
| llama.cpp | Any locally-served model |
|
||||||
|
| MLX (Apple Silicon) | Any locally-served model |
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **Spring Security + JWT** — Token-based authentication
|
||||||
|
- **Tool Guard** — Approval rules for sensitive tool operations
|
||||||
|
- **File Validation** — Path traversal prevention for workspace files
|
||||||
|
- **Skill Security** — Validation during skill installation
|
||||||
|
|
||||||
|
### Scheduled Tasks
|
||||||
|
|
||||||
|
- **CronJob System** — Create scheduled tasks with 5-field cron expressions
|
||||||
|
- **Memory Consolidation** — Auto-triggered daily for each agent
|
||||||
|
- **Custom Tasks** — Schedule any prompt to run periodically
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
Full docs at **[claw.mate.vip/docs](https://claw.mate.vip/docs)** — setup, architecture, each subsystem, API reference.
|
| Topic | Description |
|
||||||
|
|-------|-------------|
|
||||||
## Roadmap
|
| [Introduction](https://mateclaw.mate.vip/en/intro) | What MateClaw is and core concepts |
|
||||||
|
| [Quick Start](https://mateclaw.mate.vip/en/quickstart) | Install and run (local, Docker, desktop) |
|
||||||
**v2.2.0 (shipped 2026-08-29)** — from one built-in reasoning loop to **a pluggable and recoverable Agent Runtime**:
|
| [Console](https://mateclaw.mate.vip/en/console) | Web UI: chat and agent configuration |
|
||||||
|
| [Agents](https://mateclaw.mate.vip/en/agents) | Agent engine: ReAct, Plan-and-Execute, StateGraph |
|
||||||
- **Runtime contract** — provider registry, session factory, capability validation, normalized event stream, lifecycle, usage, and UI projection decouple employees from execution engines
|
| [Models](https://mateclaw.mate.vip/en/models) | Configure cloud, local, and custom providers |
|
||||||
- **DeepSeek Harness runtime** — managed installation and configuration, authenticated JSON-RPC process bridge, Cordis composition, cancellable streaming, isolated child environment, and host-governed tool dispatch
|
| [Tools](https://mateclaw.mate.vip/en/tools) | Built-in tools and custom tool development |
|
||||||
- **Durable long work** — bounded Goal segments, persisted continuation and input queues, attempts, cooldown, retry, leases, restart recovery, and explicit pause / resume semantics
|
| [Skills](https://mateclaw.mate.vip/en/skills) | Skill packages and ClawHub marketplace |
|
||||||
- **Agent interoperability** — inbound and outbound A2A with Agent Cards, JSON-RPC / SSE tasks, authentication, idempotency, and guarded network boundaries
|
| [MCP](https://mateclaw.mate.vip/en/mcp) | Model Context Protocol integration |
|
||||||
- **Runtime hardening** — tighter workspace ownership, reliable Team Run recovery and deliverable gates, plus consistent long-form output and input handling across approval, stop, and recovery
|
| [Memory](https://mateclaw.mate.vip/en/memory) | Multi-layer memory system |
|
||||||
|
| [Channels](https://mateclaw.mate.vip/en/channels) | DingTalk, Feishu, Telegram, Discord, and more |
|
||||||
Full story in the [v2.2.0 release notes](https://claw.mate.vip/docs/en/releases/2.2.0).
|
| [Security](https://mateclaw.mate.vip/en/security) | Authentication and tool guard |
|
||||||
|
| [Desktop](https://mateclaw.mate.vip/en/desktop) | Desktop application guide |
|
||||||
**v2.1.0 (shipped 2026-08-15)** — from “a board full of tasks” to **one governable team run**:
|
| [API Reference](https://mateclaw.mate.vip/en/api) | REST API documentation |
|
||||||
|
| [Configuration](https://mateclaw.mate.vip/en/config) | Configuration reference |
|
||||||
- **Unified Team Runs** — one `runId` links request, task DAG, worker conversations, events, final synthesis, and deliverables; Chat delivers outcomes, Agents observes live work, Teams governs history
|
| [FAQ](https://mateclaw.mate.vip/en/faq) | Common questions and troubleshooting |
|
||||||
- **Closed skill evolution** — reflection + recurring-request mining + promotion + constrained auto-binding + curator governance + snapshots/restore, conservative by default and isolated per workspace
|
|
||||||
- **Replayable execution** — live `<think>` extraction, every reasoning iteration in emission order with real duration, superseded narration, and linear trajectory export
|
|
||||||
- **Capabilities reach operations** — proactive IM push, targeted Cron delivery, model-specific context windows, progressive tool disclosure, and tool-backed action completion
|
|
||||||
- **Reliability pass** — hardened browser refs/navigation/waits, WebChat/SSE cleanup and upstream idle timeout, Feishu progress, Qwen3-ASR HTTP, batch session deletion, date-partitioned files, and safe 64-bit ids
|
|
||||||
|
|
||||||
Full story in the [v2.1.0 release notes](https://claw.mate.vip/docs/en/releases/2.1.0).
|
|
||||||
|
|
||||||
**v2.0.0 (shipped 2026-07-31)** — from "one person who gets things done" to "a team that collaborates": **Agent Teams** become a standing roster around a shared task board:
|
|
||||||
|
|
||||||
- **Agent teams and a shared task board** — teams / roles (lead · member · reviewer), an eight-status kanban, `blockedBy` dependency orchestration, member-level parallel dispatch, automatic prerequisite hand-off, settled results waking the lead; the Teams page ships an event-driven live board + activity banner + task timelines + deliverable downloads + manual task creation
|
|
||||||
- **An execution chain hardened for long tasks** — execution leases + runtime heartbeats against double execution, cancel that actually interrupts, `in_review` approval gates, retry for failed/stale
|
|
||||||
- **Plan-Execute plans hand over to the board** — steps become tasks, dependencies become parallelism, a parked-plan resume gate synthesizes deterministically
|
|
||||||
- **Workspace isolation fully sealed** — channel-scoped conversation ids; same-named skills coexist per workspace with conversation-scoped runtime resolution
|
|
||||||
- **Channel experience** — magic commands on every channel (`/new` `/clear` `/status` `/stop` `/model` `/help`), WeCom's event-driven progress bubble (live tool trace + per-stage rolling narration)
|
|
||||||
- **Server-side rewind / regenerate** · **explainable auto-approval misses** (reason codes on audit rows + one-click grant creation) · **policy-driven LLM error recovery** (overload vs rate-limit split · `Retry-After`-aware backoff · provider TTL readmission)
|
|
||||||
|
|
||||||
Plus: in-chat attachment preview (pdf / docx / xlsx / html / text), single-source SKILL.md + console bundle-file management, the optional Mem0 plugin memory provider, and the knowledge-graph relation schema whitelist.
|
|
||||||
|
|
||||||
Full story in the [v2.0.0 release notes](https://claw.mate.vip/docs/en/releases/2.0.0).
|
|
||||||
|
|
||||||
**v1.8.0 (shipped 2026-07-12)** — the employee turns *outward and does a whole job*: **Content Studio**, the first flagship scene built end-to-end on MateClaw's own primitives:
|
|
||||||
|
|
||||||
- **Content Studio — one sentence to a publishable post** — a seeded "Content Studio" employee runs pick-topic → research → draft → illustrate → de-AI → layout → deliver. **WeChat Official Account (公众号)** image-text articles (inline-style HTML → draft box) and **Xiaohongshu (小红书)** image-first notes (≥3 vertical 3:4 cards + online preview) ship first-class
|
|
||||||
- **De-AI-ification you can measure** — a heuristic AI-trace score (no LLM, deterministic) drives a detect → rewrite → re-check loop, capped at 3 rounds
|
|
||||||
- **A publish chain hardened for real operation** — body images uploaded into WeChat (no broken external links), AES-GCM-encrypted secrets, reused service + persisted token, retry + Chinese error hints, a guaranteed fallback cover; draft-box-first, publish approval-gated
|
|
||||||
- **A content calendar that dedups and remembers** — every delivery is compliance-scanned and auto-recorded, a topic fingerprint stops repeat picks, and a read-only Content Calendar page shows drafted/packaged/published/failed
|
|
||||||
- **The browser agent sees by reference** — an accessibility-tree ref snapshot + interact-by-ref (click the element, not a pixel), real-browser privacy guardrails, and a controlled CDP escape hatch
|
|
||||||
- **Sharper attention, tighter loops** — attention anchoring & environment awareness (MCP tool provenance + pinned skill constraints + event notifications), a tool-call loop guard, and a post-mutation verify reminder
|
|
||||||
|
|
||||||
Plus: a fast-load pass (initial load down ~78%), a chat context-occupancy panel, cross-KB wikilinks, MCP progress notifications, a Volcano Engine provider, and the public Docker stack on PostgreSQL 16.
|
|
||||||
|
|
||||||
Full story in the [v1.8.0 release notes](https://claw.mate.vip/docs/en/releases/1.8.0).
|
|
||||||
|
|
||||||
**v1.7.0 (shipped 2026-07-04)** — a *productionization pass*: once it's in real collaboration, close every loop you can't see, gather, reach, fit, or connect:
|
|
||||||
|
|
||||||
- **All three approval paths close the loop** — workflow `await_approval` actually pushes to channels and resolves → resumes, the WebChat (API-key) channel can approve/deny and replay, and Feishu/WeCom card clicks resolve workflow approvals directly
|
|
||||||
- **Long tasks are visible** — an always-on Run Overview rail + a per-turn token breakdown (cache hit/miss/write + reasoning split) + sub-agent cost rolled up + one-click generated-file download
|
|
||||||
- **Fits the real model window** — local-model context-window probing, a unified token budget for prefix injection, small-context degradation, and tool-schema budget gating — no more "guess 32K" pre-flight rejections or silent truncation
|
|
||||||
- **Opens up** — a knowledge-base + Deep Research open API (API-key + rate limit + SSE), a pluggable search Provider SPI, and MCP identity forwarding (carry the authenticated user's identity into a STDIO MCP)
|
|
||||||
- **Reaches further** — desktop local-embedded / remote-centralized dual mode (with `mateclaw-desktop` source opened) + a LAN deployment mode for controlled intranet access
|
|
||||||
- **One-click operational data export** — Dashboard 9-sheet Excel + a CLI for offline export
|
|
||||||
|
|
||||||
Full story in the [v1.7.0 release notes](https://claw.mate.vip/docs/en/releases/1.7.0).
|
|
||||||
|
|
||||||
**v1.6.0 (shipped 2026-06-22)** — make the autonomous employee *fast, sharp-eyed, and embeddable*: two-stage skill loading + prefix compression (faster first token) · `execute_code` native sandboxed code execution · vision that persists across turns + `image_analyze` · embeddable/headless webchat with per-`endUserId` memory · a Wiki you actually read (reading split from management · unified Sources tab · clickable `[[wikilinks]]`) · steadier under load (self-healing MCP · tool-call recovery · evidence-gated plans). Full story in the [v1.6.0 release notes](https://claw.mate.vip/docs/en/releases/1.6.0).
|
|
||||||
|
|
||||||
**v1.5.0 (shipped 2026-06-04)** — Goal checklists (fuzzy score → ticked boxes) · self-maintaining Wiki (`[[wikilinks]]` · fact/experience layers · pageType profiles & permissions · KB pipelines · local-directory ingest) · per-owner memory isolation (`owner_key` + visibility scope + `endUserId` passthrough) · per-agent primary knowledge base · provider-preference model routing. Full story in the [v1.5.0 release notes](https://claw.mate.vip/docs/en/releases/1.5.0).
|
|
||||||
|
|
||||||
**v1.4.0 (shipped 2026-05-23)** — Persistent Goals (lock a goal, self-evaluate every turn) · subagent delegation tree (3 levels deep · sync / parallel / async · one-sentence team builder) · progressive tool/skill disclosure · Workspace RBAC (Owner / Admin / Member / Viewer) · Feishu first-class (interactive / approval / streaming cards · channel-native tools). See the [v1.4.0 release notes](https://claw.mate.vip/docs/en/releases/1.4.0).
|
|
||||||
|
|
||||||
**v1.3.0 (shipped 2026-05-13)** — Workflow engine · 6-pattern trigger system · Wiki transformations · per-agent MCP binding · multimodal sidecar routing · four JVM-native document-generation tools · image edit. See the [v1.3.0 release notes](https://claw.mate.vip/docs/en/releases/1.3.0).
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/mateaix/mateclaw.git
|
|
||||||
cd mateclaw
|
|
||||||
cd mateclaw-server && mvn clean compile
|
|
||||||
cd ../mateclaw-ui && npm install && npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Why the name
|
## Roadmap
|
||||||
|
|
||||||
**Mate** is companion. **Claw** is capability.
|
| Area | Item | Status |
|
||||||
|
|------|------|--------|
|
||||||
|
| **Agent** | Multi-agent collaboration and delegation | Planned |
|
||||||
|
| **Agent** | Multimodal input (image, audio, video) | Planned |
|
||||||
|
| **Models** | Small + large model routing | Planned |
|
||||||
|
| **Memory** | Vector DB long-term memory (RAG) | Planned |
|
||||||
|
| **Memory** | Multimodal memory fusion | Planned |
|
||||||
|
| **Skills** | Richer ClawHub ecosystem | In Progress |
|
||||||
|
| **Channels** | WeChat personal (iLink Bot) | Planned |
|
||||||
|
| **Channels** | Email channel | Planned |
|
||||||
|
| **Desktop** | Linux support | Planned |
|
||||||
|
| **Security** | Multi-tenant support | Planned |
|
||||||
|
| **Console** | Plugin marketplace in web UI | Planned |
|
||||||
|
|
||||||
Something that stays with you — and grabs work and moves it.
|
_Status:_ **In Progress** — actively being worked on; **Planned** — queued or under design.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
MateClaw is open to contributions! Whether it's bug fixes, new features, documentation improvements, or new channel/tool integrations — all contributions are welcome.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone https://github.com/matevip/mateclaw.git
|
||||||
|
cd mateclaw
|
||||||
|
|
||||||
|
# Backend
|
||||||
|
cd mateclaw-server
|
||||||
|
mvn clean compile
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd ../mateclaw-ui
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Please read [CONTRIBUTING.md](https://github.com/matevip/mateclaw/blob/main/CONTRIBUTING.md) (if available) before submitting a PR.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contact Us
|
||||||
|
|
||||||
|
<!-- TODO: Fill in social accounts -->
|
||||||
|
|
||||||
|
| Discord | X (Twitter) | DingTalk |
|
||||||
|
|---------|-------------|----------|
|
||||||
|
| Coming soon | Coming soon | Coming soon |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why MateClaw?
|
||||||
|
|
||||||
|
**Mate** — a companion, always by your side. **Claw** — sharp, capable, ready to grab any task. MateClaw is your personal AI mate that lends a claw whenever you need it. Built as a monolith with modular design, it's easy to deploy, extend, and customize.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[Apache License 2.0](LICENSE). No asterisks.
|
MateClaw is released under the [Apache License 2.0](LICENSE).
|
||||||
|
|||||||
550
README_zh.md
@ -1,327 +1,353 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
|
# MateClaw
|
||||||
|
|
||||||
|
[](https://github.com/matevip/mateclaw)
|
||||||
|
[](https://claw.mate.vip/docs)
|
||||||
|
[](https://claw-demo.mate.vip)
|
||||||
|
[](https://claw.mate.vip)
|
||||||
|
[](https://adoptium.net/)
|
||||||
|
[](https://spring.io/projects/spring-boot)
|
||||||
|
[](https://vuejs.org/)
|
||||||
|
[](https://github.com/matevip/mateclaw)
|
||||||
|
[](LICENSE)
|
||||||
|
[](https://github.com/matevip/mateclaw/stargazers)
|
||||||
|
[](https://github.com/matevip/mateclaw/network)
|
||||||
|
|
||||||
|
[[官网](https://claw.mate.vip)] [[在线演示](https://claw-demo.mate.vip)] [[文档](https://claw.mate.vip/docs)] [[English](README.md)]
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<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)
|
<p align="center"><b>懂你所需,利爪随行。</b></p>
|
||||||
|
|
||||||
<p align="center"><b>你的超级大脑</b></p>
|
|
||||||
|
|
||||||
<p align="center"><sub><b>可插拔 Agent Runtime · Native + DSH · Spring Boot 内核</b></sub></p>
|
|
||||||
|
|
||||||
[](https://github.com/mateaix/mateclaw)
|
|
||||||
[](https://claw.mate.vip/docs)
|
|
||||||
[](https://claw-demo.mate.vip)
|
|
||||||
[](https://claw.mate.vip)
|
|
||||||
[](https://adoptium.net/)
|
|
||||||
[](https://spring.io/projects/spring-boot)
|
|
||||||
[](https://vuejs.org/)
|
|
||||||
[](https://github.com/mateaix/mateclaw)
|
|
||||||
[](LICENSE)
|
|
||||||
|
|
||||||
[[官网](https://claw.mate.vip)] [[在线演示](https://claw-demo.mate.vip)] [[文档](https://claw.mate.vip/docs)] [[English](README.md)]
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p align="center">
|
基于 **Java + Vue 3** 的个人 AI 助手系统,由 [Spring AI Alibaba](https://github.com/alibaba/spring-ai-alibaba) 驱动。支持多 Agent 编排、灵活的工具/技能系统与 MCP 协议、多层记忆体系、多渠道接入。
|
||||||
<img src="assets/images/preview.png" alt="MateClaw 预览" width="800">
|
|
||||||
</p>
|
|
||||||
|
|
||||||
---
|
> **核心能力:**
|
||||||
|
|
||||||
> **最新稳定版:v2.2.0 —— 可插拔、可恢复的 Agent Runtime。** 数字员工现在可以选择 MateClaw 原生 StateGraph 引擎或受管理的 DeepSeek Harness(DSH)运行时,同时复用同一套会话、策略、工具、持久化与可观测面;Persistent Goal 可跨有界回合和后端重启继续,A2A 则让受治理的员工跨系统互联。详见 [v2.2.0 更新记录](https://claw.mate.vip/docs/zh/releases/2.2.0)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
> **别的 AI 助手是给一个人用的。MateClaw 是公司允许部署的那一个。**
|
|
||||||
>
|
>
|
||||||
> 多用户工作空间。敏感操作走审批。完整审计日志。Spring Boot Actuator 健康监控。单个渠道挂掉不影响其他渠道的错误隔离。一个 JAR 包跑在自己的环境里;持久化数据由你掌控,任务所需内容只会发送到你主动配置的模型、渠道或工具服务。
|
> **多 Agent 编排** — ReAct(思考→行动→观察循环)和 Plan-and-Execute(自动将复杂任务拆解为有序子步骤)。创建多个独立 Agent,各有专属人格和工具。
|
||||||
>
|
>
|
||||||
> **底下是一套真正的 Agent Runtime。** 员工不再焊死在一套推理循环上:可以用原生 StateGraph 运行 ReAct、Plan-and-Execute、Goal 与 Team Run,也可以通过认证 JSON-RPC 把 DeepSeek Harness 作为受管理的外部循环。两条路径最终进入同一套会话、工作空间边界、Tool Guard、事件投影与生命周期控制。
|
> **工具与技能系统** — 内置工具(网络搜索、日期时间)+ MCP 协议接入外部工具。预置 GitHub 和 Filesystem MCP 服务,启用即用。从 ClawHub 市场或自定义源安装技能包。
|
||||||
|
>
|
||||||
大多数 AI 工具一到厂商抽风那天就两手一摊。关一次标签页就忘了你是谁。给你一个聊天框,就敢叫产品。
|
> **多层记忆** — 短期上下文窗口自动压缩、事件驱动的对话后记忆提取、工作空间文件(PROFILE.md / MEMORY.md / 每日笔记)、定时记忆整合。
|
||||||
|
>
|
||||||
**MateClaw 是完整的一整套。** 一次部署——推理、知识、记忆、工具、多渠道入口,从第一天就一起设计,不是事后拼接。主模型不可用时,系统会按优先级改由下一家健康供应商重新完成当前请求。
|
> **全域触达** — Web 控制台、钉钉、飞书、企业微信、Telegram、Discord、QQ。一个 MateClaw,按需连接。
|
||||||
|
>
|
||||||
|
> **多厂商模型** — 20+ 厂商:DashScope、OpenAI、Anthropic、Google Gemini、DeepSeek、Kimi、MiniMax、智谱、火山引擎、OpenRouter、Ollama、LM Studio、llama.cpp、MLX 等。在 Web 界面中配置。
|
||||||
|
>
|
||||||
|
> **桌面应用** — 基于 Electron 的桌面应用,支持自动更新。下载即用。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 三件让它与众不同的事
|
## 目录
|
||||||
|
|
||||||
### 1 · 模型挂了,AI 不挂
|
- [快速开始](#快速开始)
|
||||||
|
- [截图](#截图)
|
||||||
Key 过期。厂商返回 401。网络抖动。配额耗尽。
|
- [架构](#架构)
|
||||||
|
- [技术栈](#技术栈)
|
||||||
别的工具丢你一张红色错误卡。MateClaw 会按配置顺序尝试下一家健康供应商——DashScope、OpenAI、Anthropic、Gemini、DeepSeek、Kimi、Ollama、LM Studio、MLX 等内置或 OpenAI 兼容供应商——尽可能恢复当前请求;仅当可用链路全部失败时才返回错误。内置的 **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>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 盒子里有什么
|
|
||||||
|
|
||||||
### 数字员工,不是聊天机器人
|
|
||||||
你雇佣员工,不是开聊天框。每位有**角色**、**目标**、**背景故事**、运行时、像素艺术头像与专属配色——6 个内置模板(通用助手 · 产品助理 · 研究分析师 · 客服助理 · 数据分析师 · 代码审查员)开箱可用。即使更换执行引擎,员工身份和治理边界仍保持不变。
|
|
||||||
|
|
||||||
### Agent Runtime:Native 或 DSH(2.2.0+)
|
|
||||||
`AgentRuntimeProvider` contract 把员工与实际执行回合的引擎分开。**Native Runtime** 在 MateClaw 内运行 ReAct、Plan-and-Execute、Persistent Goal 与 Team Run;**DSH Runtime** 把 `dsh-jsonrpc-agent` 作为认证子进程管理,并将思考、文本、工具调用、用量、完成与取消统一映射为 runtime event。DSH 掌管外部 Agent loop,MateClaw 继续掌管 session、workspace、凭证、工具、审批、消息和 UI 投影。启动前会校验 runtime 可用性与能力;控制台可完成 DSH 的安装、配置、校验、连接测试和启停。[配置 DeepSeek Harness →](https://claw.mate.vip/docs/zh/deepseek-harness)
|
|
||||||
|
|
||||||
### 持久长任务:检查点、重启、继续(2.2.0+)
|
|
||||||
Persistent Goal 把需要数小时的工作拆成有界、可恢复的执行段。数据库会保存目标清单、continuation 状态、attempt、冷却、lease,以及员工忙碌期间已经接收的用户输入。单后端实例重启后,supervisor 会先核对被中断的 attempt,读取持久检查点和已有产物,再调度下一段安全工作,不要求用户重新描述任务。
|
|
||||||
|
|
||||||
对于写文件的任务,应要求员工维护进度账本、以小块追加可验证内容、恢复时先检查文件尾部,并且只有在可复现验收全部通过后才完成 Goal。运行时不承诺任意外部副作用严格一次;付款、发送、发布和破坏性操作仍需使用服务商幂等键或人工复核。[运行并验证持久目标 →](https://claw.mate.vip/docs/zh/goals)
|
|
||||||
|
|
||||||
> 提示词模板:“第一步创建持续目标;把计划和进度保存在工作区;按小检查点写入;发生错误或重启后从已有证据继续;只有每条验收标准都有可验证证据时才调用 `completeGoal`。”
|
|
||||||
|
|
||||||
### Team Run(2.1.0+)
|
|
||||||
一次请求对应一个持久化的 **Team Run**。稳定的 `runId` 串起用户目标、任务 DAG、成员执行、最终汇总与交付物。Chat 是成果交付面,Agents Live 按运行聚合成员并展示实时状态,Teams 管理历史与治理;三处读取同一份服务端投影。成员子会话不再挤进普通会话列表,摘要和文件优先展示,任务、证据、审批与只读成员记录按需下钻。底层继续使用 2.0 的共享任务板,保留依赖编排、并行派发、前置结果传递、执行租约、取消中断和人工审批卡点。
|
|
||||||
|
|
||||||
### 知识与记忆
|
|
||||||
- **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**。2.1 可通过对话反思与跨会话重复请求挖掘形成可复用改进,并以候选晋升、受约束自动绑定、curator 治理、来源策略、快照和恢复点保证过程可观察、按工作空间隔离且可回滚;所有自动能力均由独立开关控制。另有 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 运行时控制台**(`后台 → 系统 → 运行时`)——谁在跑、当前回合由哪个 runtime provider 承载、跑到哪一步、占多少 token,卡住可一键回收。Native 与 DSH 事件进入同一套思考 / 工具 / 回答投影,完成、失败、用量和取消保持一致的生命周期语义。SSE 每事件 ID 支持安全重连,Team Run 将成员工作聚合到同一次运行下。
|
|
||||||
|
|
||||||
### 多模态创作
|
|
||||||
语音合成 · 语音识别 · 图片 · 音乐 · 视频 · 3D。一等公民,不是附加插件。**多模态旁路**(1.3.0+)让纯文本主模型遇到图片附件时自动调用配置好的视觉模型转描述,主对话保持便宜。**图像编辑**也到位:用 `msg:<id>:<idx>` 引用会话里更早的某张图,让模型改色、改风格。**4 个文档生成工具**(`DocxRenderTool` / `XlsxRenderTool` / `PptxRenderTool` / `PdfRenderTool`)在 JVM 内把 Markdown 直接渲染成 Office 文件——不 fork 子进程、不依赖 npm、不需要装 Office。
|
|
||||||
|
|
||||||
### 内容工作室(1.8.0+)
|
|
||||||
一个招牌*场景*,不是工具——预置的「内容工作室」员工把一句话变成可发布成品:选题 → 搜集 → 成文 → 配图 → **去 AI 化** → 排版 → 交付。**微信公众号(公众号)**文章以内联样式 HTML 进入草稿箱,正文图自动上传到微信;**小红书**笔记打包成 ≥3 张竖版 3:4 卡片并在线预览。去 AI 化围绕一个**可度量的 AI 痕迹评分**运行;每次交付都经过合规扫描,并记入按选题指纹去重的**内容日历**。
|
|
||||||
|
|
||||||
### 企业就绪
|
|
||||||
RBAC + JWT。**Personal Access Token** 给无人值守脚本和 CI 使用。**Webhook 出站 HMAC-SHA-256 签名**。**Cron 分布式锁**避免多实例重复执行。完整审计事件流。Flyway 管理数据库 schema。一个 JAR 交付。开发环境可用 H2;公开 Docker 栈默认使用 PostgreSQL 16,同时保留 MySQL profile,Kingbase 驱动为按需启用。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## AI 正在变成基础设施
|
|
||||||
|
|
||||||
模型供应商会限流,网络会抖动,Key 会过期,服务也可能临时不可用。把所有 AI 能力押在单一供应商上,会让上游故障直接变成自己的业务故障。
|
|
||||||
|
|
||||||
当 AI 进入生产环境,稳定的一层既不应绑定一家模型供应商,也不应绑定一套 Agent loop。MateClaw 用供应商优先级、健康追踪、冷却与故障转移吸收模型侧不确定性,再把 Native 与外部执行引擎收进同一份受治理的 Agent Runtime contract。
|
|
||||||
|
|
||||||
**MateClaw 就是那一层——用 Spring Boot 方式盖的。**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 为什么选 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/月 |
|
|
||||||
|
|
||||||
**OpenClaw 和 Hermes Agent 是优秀的个人 AI 平台**——如果你是一个人、一台笔记本、习惯从 CLI 搭自己的 agent、所有东西都靠手工配置文件调优,选它们没问题。两家的社区规模今天都大于 MateClaw。
|
|
||||||
|
|
||||||
**MateClaw 是那个给团队用的版本。** 数字员工、模型与工具都纳入权限和工作空间边界。危险动作可暂停等待审批,关键操作进入审计事件流。Admin 运行时控制台集中展示正在执行的员工与供应商状态,卡住时可回收。底座是 Spring Boot,适合并入已有 Java 服务体系。
|
|
||||||
|
|
||||||
**同一套"完整一整套"哲学,不同的重心。**
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
```bash
|
### 前置条件
|
||||||
# 后端
|
|
||||||
cd mateclaw-server
|
|
||||||
mvn spring-boot:run # http://localhost:18088
|
|
||||||
|
|
||||||
# 前端
|
- Java 17+
|
||||||
cd mateclaw-ui
|
- Node.js 18+ & pnpm
|
||||||
npm install && npm run dev # http://localhost:5173
|
- Maven 3.9+(或使用 `mvnw`)
|
||||||
|
- 至少一个 LLM API Key(如 [DashScope](https://dashscope.aliyun.com/))
|
||||||
|
|
||||||
|
### 方式一:本地开发
|
||||||
|
|
||||||
|
**1. 启动后端**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mateclaw-server
|
||||||
|
export DASHSCOPE_API_KEY=your-key-here
|
||||||
|
mvn spring-boot:run
|
||||||
|
# 后端运行在 http://localhost:18088
|
||||||
|
# H2 控制台:http://localhost:18088/h2-console
|
||||||
|
# API 文档(SpringDoc OpenAPI):http://localhost:18088/swagger-ui.html
|
||||||
```
|
```
|
||||||
|
|
||||||
默认登录:`admin` / `admin123`
|
**2. 启动前端**
|
||||||
|
|
||||||
### Docker 部署
|
```bash
|
||||||
|
cd mateclaw-ui
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
|
# 前端运行在 http://localhost:5173(代理 /api 到 :18088)
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. 登录**
|
||||||
|
|
||||||
|
打开 http://localhost:5173,使用 `admin` / `admin123` 登录。
|
||||||
|
|
||||||
|
### 方式二:Docker 部署
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
docker compose up -d # http://localhost:18080
|
# 编辑 .env,填写 DASHSCOPE_API_KEY 等变量
|
||||||
|
|
||||||
|
docker compose up -d
|
||||||
|
# 服务运行在 http://localhost:18080(MySQL + 后端)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 桌面端
|
### 方式三:桌面应用
|
||||||
|
|
||||||
从 [GitHub Releases](https://github.com/mateaix/mateclaw/releases) 下载安装包。内嵌 JRE 21,无需额外装 Java。
|
从 [GitHub Releases](https://github.com/matevip/mateclaw/releases) 下载安装包:
|
||||||
|
|
||||||
|
| 平台 | 文件 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| **macOS (Apple Silicon)** | `MateClaw_<version>_arm64.dmg` | 推荐 M1/M2/M3/M4/M5 Mac 使用 |
|
||||||
|
| **macOS (Apple Silicon)** | `MateClaw_<version>_arm64.zip` | zip 格式(Apple Silicon) |
|
||||||
|
| **macOS (Intel)** | `MateClaw_<version>_x64.dmg` | 适用于 Intel Mac |
|
||||||
|
| **macOS (Intel)** | `MateClaw_<version>_x64.zip` | zip 格式(Intel) |
|
||||||
|
| **Windows (x64)** | `MateClaw_<version>_Setup.exe` | 适用于大多数 Windows 电脑(64 位) |
|
||||||
|
| **Windows (x64)** | `MateClaw_<version>_x64_Setup.exe` | 显式 x64 构建 |
|
||||||
|
| **Windows (ARM64)** | `MateClaw_<version>_arm64_Setup.exe` | 适用于 ARM 架构 Windows(如 Surface Pro X) |
|
||||||
|
|
||||||
|
双击运行。应用内置 JRE 21 + Spring Boot 后端,无需安装 Java。支持从 GitHub Releases 自动更新。
|
||||||
|
|
||||||
|
> **macOS 用户**:如果系统阻止打开,右键 → 打开 → 再次点击打开,或前往系统设置 → 隐私与安全性 → 仍要打开。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 架构全景
|
## 截图
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="assets/architecture-biz-zh.svg" alt="业务架构" width="800">
|
<img src="assets/images/chat-echarts-demo.png" alt="MateClaw 对话界面 — ECharts 可视化" width="800">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<details>
|
<p align="center"><em>对话控制台 — AI 自我介绍并自动生成 ECharts 环形图</em></p>
|
||||||
<summary><b>技术架构</b></summary>
|
|
||||||
<p align="center">
|
|
||||||
<img src="assets/architecture-tech-zh.svg" alt="技术架构" width="800">
|
|
||||||
</p>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 项目结构
|
## 架构
|
||||||
|
|
||||||
```
|
```
|
||||||
mateclaw/
|
mateclaw/
|
||||||
├── mateclaw-server/ Spring Boot 3.5 后端(Agent Runtime contract · Native StateGraph + DSH)
|
├── mateclaw-server/ # Spring Boot 后端
|
||||||
├── mateclaw-ui/ Vue 3 + TypeScript 管理 SPA(构建产物打进后端 JAR)
|
│ ├── src/main/java/vip/mate/
|
||||||
├── mateclaw-desktop/ Electron 桌面端(本地内嵌 / 远程集中双模式)
|
│ │ ├── agent/ # Agent 引擎(ReAct、Plan-and-Execute、StateGraph)
|
||||||
├── mateclaw-webchat/ 网页嵌入式聊天组件(UMD / ES bundle)
|
│ │ ├── planning/ # 任务规划(Plan / SubPlan 模型)
|
||||||
├── mateclaw-plugin-api/ 第三方能力插件的 Java SDK
|
│ │ ├── tool/ # 工具系统(内置 + MCP 适配器)
|
||||||
├── mateclaw-plugin-sample/ 参考插件实现
|
│ │ ├── skill/ # 技能管理(工作空间 + ClawHub)
|
||||||
├── mateclaw-plugin-mem0/ 可选 Mem0 记忆 Provider 插件
|
│ │ ├── channel/ # 渠道适配器(Web、钉钉、飞书等)
|
||||||
├── mateclaw-plugin-search-sample/ 搜索 Provider SPI 示例
|
│ │ ├── workspace/ # 会话、消息、工作空间文件
|
||||||
|
│ │ ├── memory/ # 记忆提取与整合
|
||||||
|
│ │ ├── llm/ # 多厂商模型配置
|
||||||
|
│ │ ├── cron/ # 定时任务(CronJob)
|
||||||
|
│ │ ├── auth/ # Spring Security + JWT
|
||||||
|
│ │ └── config/ # Spring Bean 配置
|
||||||
|
│ └── src/main/resources/
|
||||||
|
│ ├── application.yml # 主配置(开发环境用 H2)
|
||||||
|
│ ├── prompts/ # 提示词模板
|
||||||
|
│ └── db/ # 数据库脚本(schema.sql、data.sql)
|
||||||
|
├── mateclaw-ui/ # Vue 3 SPA 前端
|
||||||
|
│ └── src/
|
||||||
|
│ ├── views/ # 页面(ChatConsole、AgentWorkspace、SkillMarket 等)
|
||||||
|
│ ├── components/ # 复用组件
|
||||||
|
│ ├── stores/ # Pinia 状态管理(领域驱动)
|
||||||
|
│ ├── api/ # Axios HTTP 客户端
|
||||||
|
│ ├── router/ # Vue Router
|
||||||
|
│ ├── types/ # TypeScript 类型
|
||||||
|
│ └── i18n/ # 国际化(zh-CN、en-US)
|
||||||
|
├── mateclaw-desktop/ # Electron 桌面应用
|
||||||
|
├── docs/ # VitePress 文档站(中 + 英)
|
||||||
├── 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 |
|
| 后端框架 | Spring Boot 3.5 + Spring AI Alibaba 1.1 |
|
||||||
| Agent Runtime | `AgentRuntimeProvider` contract · Native StateGraph(ReAct + Plan-Execute)· 受管理的 DSH JSON-RPC runtime · 统一事件 / 生命周期 / 用量 · Tool Guard |
|
| 大模型接入 | DashScope、OpenAI、Anthropic、Gemini、DeepSeek、Kimi、MiniMax、智谱、火山引擎、OpenRouter、Ollama、LM Studio、llama.cpp、MLX |
|
||||||
| 业务编排 | 工作流(7 step mode · Pebble DSL)· 触发器(6 pattern type · 事件治理)· Wiki 加工器(1.3.0+)|
|
| Agent 引擎 | StateGraph(ReAct + Plan-and-Execute) |
|
||||||
| 能力扩展 | SKILL.md 包 · MCP(stdio / SSE / HTTP · per-agent 绑定)· ACP 桥接(Claude Code / Codex) |
|
| 数据库 | H2(开发)/ MySQL 8.0+(生产) |
|
||||||
| 数据库 | H2(开发)· PostgreSQL 16(Docker 默认)· MySQL 8.0+(支持)· Kingbase(按需驱动)|
|
| ORM | MyBatis Plus 3.5 |
|
||||||
| 认证 | Spring Security + JWT |
|
| 认证 | Spring Security + JWT |
|
||||||
| 前端 | Vue 3 · TypeScript · Vite · Element Plus · TailwindCSS 4 |
|
| API 文档 | SpringDoc OpenAPI 3 |
|
||||||
| 桌面端 | Electron · electron-updater · 内嵌 JRE 21 |
|
| 前端框架 | Vue 3 + TypeScript + Vite |
|
||||||
| Webchat | Vite library 模式 · UMD + ES bundle |
|
| 状态管理 | Pinia |
|
||||||
|
| UI 组件 | Element Plus |
|
||||||
|
| 样式 | TailwindCSS 4 |
|
||||||
|
| 桌面端 | Electron + electron-updater |
|
||||||
|
| 文档站 | VitePress |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
### Agent 系统
|
||||||
|
|
||||||
|
- **ReAct Agent** — 思考→行动→观察推理循环,支持工具调用
|
||||||
|
- **Plan-and-Execute** — 自动将复杂任务拆解为有序子步骤,带进度追踪
|
||||||
|
- **动态 Agent** — 运行时从数据库加载 Agent 配置
|
||||||
|
- **多 Agent** — 创建多个独立 Agent,各有专属系统提示词、工具和人格
|
||||||
|
|
||||||
|
### 工具与技能系统
|
||||||
|
|
||||||
|
- **内置工具** — 网络搜索(Serper/Tavily)、日期时间、工作空间记忆读写
|
||||||
|
- **MCP 协议** — 通过 Model Context Protocol 接入外部工具(stdio、SSE、Streamable HTTP 传输)。在 Web 界面中完成全生命周期管理 — 添加、编辑、启用/禁用、测试连接
|
||||||
|
- **预置 MCP 服务** — GitHub(`@modelcontextprotocol/server-github`)和 Filesystem 开箱即用。在 MCP 管理页面启用并填入 Token 即可,无需改代码
|
||||||
|
- **技能包** — 安装/卸载带 `SKILL.md` 清单的技能包
|
||||||
|
- **ClawHub 市场** — 从 ClawHub 注册中心浏览和安装技能
|
||||||
|
- **工作空间技能** — 基于约定的技能目录 `~/.mateclaw/skills/{name}/`
|
||||||
|
|
||||||
|
### 记忆系统
|
||||||
|
|
||||||
|
- **短期记忆** — 会话上下文窗口,Token 超出预算时自动压缩
|
||||||
|
- **对话后提取** — 事件驱动的异步 LLM 分析,写入 PROFILE.md / MEMORY.md / 每日笔记
|
||||||
|
- **记忆整合** — 定时每日涌现(CronJob 凌晨 2:00),将每日笔记合并为长期记忆
|
||||||
|
- **工作空间文件** — 每个 Agent 独立的 AGENTS.md、SOUL.md、PROFILE.md、MEMORY.md、memory/*.md
|
||||||
|
- **Agent 记忆工具** — Agent 在对话中可主动读写自己的工作空间文件
|
||||||
|
|
||||||
|
### 多渠道接入
|
||||||
|
|
||||||
|
- **Web 控制台** — SSE 流式输出,富消息渲染(Markdown、代码、计划)
|
||||||
|
- **钉钉** — Webhook + 事件订阅
|
||||||
|
- **飞书** — Webhook + 事件订阅
|
||||||
|
- **企业微信** — 回调接口
|
||||||
|
- **Telegram** — Bot API + Webhook
|
||||||
|
- **Discord** — Bot + Slash Commands
|
||||||
|
- **QQ** — QQ Bot API
|
||||||
|
|
||||||
|
### 模型厂商
|
||||||
|
|
||||||
|
在 Web 界面中配置(设置 → 模型)。支持的厂商:
|
||||||
|
|
||||||
|
| 厂商 | 模型 |
|
||||||
|
|------|------|
|
||||||
|
| **云端厂商** | |
|
||||||
|
| DashScope(阿里云) | Qwen3.5-Max、Qwen3.5-Plus、Qwen3-Max、Qwen3-Plus、Qwen-Max、Qwen-Plus、Qwen-Turbo、Qwen-Long、DeepSeek-V3.2 |
|
||||||
|
| ModelScope | Qwen3.5-122B-A10B、GLM-5 |
|
||||||
|
| 阿里云百炼 Coding Plan | Qwen3.5-Plus、Qwen3-Coder-Next、GLM-5、GLM-4.7、MiniMax-M2.5、Kimi-K2.5 |
|
||||||
|
| OpenAI | GPT-5.2、GPT-5、GPT-5-Mini、GPT-5-Nano、GPT-4.1、GPT-4.1-Mini、GPT-4.1-Nano、o3、o4-mini、GPT-4o |
|
||||||
|
| Azure OpenAI | GPT-5、GPT-4.1、GPT-4o 等 |
|
||||||
|
| Anthropic | Claude Opus 4.6、Claude Sonnet 4.6(通过模型发现) |
|
||||||
|
| Google Gemini | Gemini 3.1 Pro、Gemini 3 Flash、Gemini 2.5 Pro、Gemini 2.5 Flash、Gemini 2.0 Flash |
|
||||||
|
| DeepSeek | DeepSeek-Chat、DeepSeek-Reasoner |
|
||||||
|
| Kimi(月之暗面) | Kimi-K2.5、Kimi-K2-Thinking、Kimi for Coding(国内版 / 国际版 / Code 版) |
|
||||||
|
| MiniMax | MiniMax-M2.7、MiniMax-M2.5(国际版 / 国内版) |
|
||||||
|
| 智谱 AI | GLM-5.1、GLM-5、GLM-5-Turbo、GLM-5V-Turbo(国内版 / 国际版) |
|
||||||
|
| 火山引擎 | 豆包-1.5-Pro-256K、豆包-1.5-Lite、豆包-1.5-Thinking-Pro、豆包-1.5-Vision-Pro |
|
||||||
|
| OpenRouter | GPT-5、Claude Opus 4.6、Gemini 2.5 Pro、Llama 4 Maverick、DeepSeek R1 及 200+ 模型 |
|
||||||
|
| **本地厂商** | |
|
||||||
|
| Ollama | Qwen3、Gemma 4、Gemma 3、Llama 3.1、DeepSeek R1、Mistral(启动时自动检测) |
|
||||||
|
| LM Studio | 任意本地服务的模型 |
|
||||||
|
| llama.cpp | 任意本地服务的模型 |
|
||||||
|
| MLX(Apple Silicon) | 任意本地服务的模型 |
|
||||||
|
|
||||||
|
### 安全
|
||||||
|
|
||||||
|
- **Spring Security + JWT** — 基于 Token 的认证
|
||||||
|
- **工具防护** — 敏感工具操作的审批规则
|
||||||
|
- **文件校验** — 工作空间文件路径穿越防护
|
||||||
|
- **技能安全** — 技能安装时的安全校验
|
||||||
|
|
||||||
|
### 定时任务
|
||||||
|
|
||||||
|
- **CronJob 系统** — 使用 5 位 cron 表达式创建定时任务
|
||||||
|
- **记忆整合** — 每个 Agent 每日自动触发
|
||||||
|
- **自定义任务** — 调度任意提示词定期执行
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 文档
|
## 文档
|
||||||
|
|
||||||
完整文档 **[claw.mate.vip/docs](https://claw.mate.vip/docs)**——安装、架构、各子系统、API 参考。
|
| 主题 | 说明 |
|
||||||
|
|------|------|
|
||||||
## 路线图
|
| [项目介绍](https://mateclaw.mate.vip/zh/intro) | MateClaw 是什么、核心概念 |
|
||||||
|
| [快速开始](https://mateclaw.mate.vip/zh/quickstart) | 安装与运行(本地、Docker、桌面) |
|
||||||
**v2.2.0(2026-08-29 发布)** —— 从一套内置推理循环走向**可插拔、可恢复的 Agent Runtime**:
|
| [控制台](https://mateclaw.mate.vip/zh/console) | Web 界面:聊天与 Agent 配置 |
|
||||||
|
| [Agent 引擎](https://mateclaw.mate.vip/zh/agents) | ReAct、Plan-and-Execute、StateGraph |
|
||||||
- **Runtime contract** —— provider registry、session factory、能力校验、统一事件流、生命周期、用量与 UI 投影,让员工身份与执行引擎解耦
|
| [模型配置](https://mateclaw.mate.vip/zh/models) | 配置云端、本地和自定义厂商 |
|
||||||
- **DeepSeek Harness runtime** —— 受管理的安装与配置、认证 JSON-RPC 进程桥、Cordis composition、可取消流、子进程环境隔离,以及由宿主治理的工具派发
|
| [工具系统](https://mateclaw.mate.vip/zh/tools) | 内置工具与自定义工具开发 |
|
||||||
- **持久长任务** —— 有界 Goal segment、持久化 continuation / 输入队列、attempt、冷却、重试、租约、重启恢复和显式暂停 / 恢复语义
|
| [技能系统](https://mateclaw.mate.vip/zh/skills) | 技能包与 ClawHub 市场 |
|
||||||
- **Agent 互操作** —— A2A 入站与出站、Agent Card、JSON-RPC / SSE task、认证、幂等与受控网络边界
|
| [MCP](https://mateclaw.mate.vip/zh/mcp) | Model Context Protocol 集成 |
|
||||||
- **Runtime 加固** —— 工作空间归属进一步收口,Team Run 恢复和交付门更可靠,长文本及审批、停止、恢复期间的输入处理更一致
|
| [记忆系统](https://mateclaw.mate.vip/zh/memory) | 多层记忆体系 |
|
||||||
|
| [渠道接入](https://mateclaw.mate.vip/zh/channels) | 钉钉、飞书、Telegram、Discord 等 |
|
||||||
完整内容见 [v2.2.0 更新记录](https://claw.mate.vip/docs/zh/releases/2.2.0)。
|
| [安全机制](https://mateclaw.mate.vip/zh/security) | 认证与工具防护 |
|
||||||
|
| [桌面应用](https://mateclaw.mate.vip/zh/desktop) | 桌面应用使用指南 |
|
||||||
**v2.1.0(2026-08-15 发布)** —— 从“一块摆满任务的看板”到**一次可治理的团队运行**:
|
| [API 参考](https://mateclaw.mate.vip/zh/api) | REST API 文档 |
|
||||||
|
| [配置指南](https://mateclaw.mate.vip/zh/config) | 配置参考 |
|
||||||
- **统一 Team Run** —— 一个 `runId` 串起请求、任务 DAG、成员会话、事件、最终汇总与交付物;Chat 交付成果,Agents 观察实时执行,Teams 管理历史与治理
|
| [常见问题](https://mateclaw.mate.vip/zh/faq) | 常见问题与故障排查 |
|
||||||
- **Skill 自进化闭环** —— 对话反思、重复请求挖掘、候选晋升、受约束自动绑定、curator 治理、快照与恢复;默认保守、显式控制并按工作空间隔离
|
|
||||||
- **可回放执行** —— 实时提取内联 `<think>`,每轮推理按发生顺序展示实际耗时,保留被后续工具调用替代的阶段旁白,并可导出线性 trajectory
|
|
||||||
- **能力进入日常运营** —— 主动 IM 推送、Cron 定向投递、模型级上下文窗口、渐进式工具披露,以及基于实际工具调用结果的行动完成检查
|
|
||||||
- **可靠性加固** —— 浏览器 ref / 导航 / 等待、WebChat 与 SSE 清理及上游空闲超时、飞书进度、Qwen3-ASR HTTP、会话批量删除、文件按日分区和 64 位 ID 精度保护
|
|
||||||
|
|
||||||
完整内容见 [v2.1.0 更新记录](https://claw.mate.vip/docs/zh/releases/2.1.0)。
|
|
||||||
|
|
||||||
**v2.0.0(2026-07-31 发布)** —— 从“一个能干活的人”到“一支能协作的队伍”:**Agent 团队**成为常设编制,围绕共享任务板工作:
|
|
||||||
|
|
||||||
- **Agent 团队与共享任务板** — 团队 / 角色(lead · member · reviewer)、八状态看板、`blockedBy` 依赖编排、成员级并行派发、前置结果自动传递、结果通报唤醒 Lead;Teams 页事件驱动实时看板 + 活动横幅 + 任务时间线 + 交付物下载 + 手动投任务
|
|
||||||
- **为长任务加固的执行链** — 执行租约 + 运行期心跳防双重执行、取消即真实中断、`in_review` 审批卡点、失败/过期可重试
|
|
||||||
- **Plan-Execute 计划整体移交任务板** — 步骤变任务、依赖变并行、停靠恢复门确定性汇总
|
|
||||||
- **工作空间隔离全面收口** — 渠道会话 id 编入渠道标识、同名技能跨工作空间共存且运行时按会话工作空间解析
|
|
||||||
- **渠道体验** — 全渠道魔法命令(`/new` `/clear` `/status` `/stop` `/model` `/help`)、企业微信事件驱动进度气泡(实时工具轨迹 + 分阶段滚动叙述)
|
|
||||||
- **会话回退 / 重新生成服务端语义** · **自动批准未命中可解释**(原因码落审计行 + 一键补策略) · **LLM 错误恢复策略化**(过载/限流分治 · `Retry-After` 回馈退避 · provider TTL 回收)
|
|
||||||
|
|
||||||
外加:聊天附件在线预览(pdf / docx / xlsx / html / 文本)、SKILL.md 单一事实源 + 捆绑文件控制台管理、Mem0 可选插件记忆 provider、知识图谱关系模式白名单。
|
|
||||||
|
|
||||||
完整故事见 [v2.0.0 release notes](https://claw.mate.vip/docs/zh/releases/2.0.0)。
|
|
||||||
|
|
||||||
**v1.8.0(2026-07-12 发布)** — 员工*转向对外、干完一整件活*:**内容工作室**——第一个完全用 MateClaw 自身原子能力端到端搭起来的招牌场景:
|
|
||||||
|
|
||||||
- **内容工作室——一句话到可发布成品** — 预置「内容工作室」员工跑通 选题 → 搜集 → 成文 → 配图 → 去 AI 化 → 排版 → 交付。**微信公众号(公众号)** 图文文章(内联样式 HTML → 草稿箱)与 **小红书** 以图为主图文笔记(≥3 张竖版 3:4 卡片 + 在线预览)首批一等公民
|
|
||||||
- **可度量的去 AI 化** — 启发式 AI 痕迹评分(无 LLM、确定性)驱动 检测 → 改写 → 复检 闭环,硬上限 3 轮
|
|
||||||
- **为长期投产而加固的发布链** — 正文图上传进微信(不再外链发布即裂)、AES-GCM 加密密钥、服务复用 + token 持久化、重试 + 中文错误提示、兜底封面;草稿箱优先,发表走审批
|
|
||||||
- **会去重、会记账的内容日历** — 每次交付都合规扫描 + 自动落台账、选题指纹防重复选题、只读内容日历页展示草稿/已打包/已发布/失败
|
|
||||||
- **浏览器 Agent 按引用去看** — 无障碍树 ref 快照 + 按 ref 交互(点元素而非像素)、真实浏览器隐私护栏、受控 CDP 逃生舱
|
|
||||||
- **注意力更聚焦、循环更收得住** — 注意力锚定与环境感知(MCP 工具溯源 + skill 约束固定 + 事件通知)、工具调用循环护栏、改动后校验提醒
|
|
||||||
|
|
||||||
外加:一次快加载优化(初始加载 ↓约 78%)、聊天上下文占用面板、跨知识库 wikilink、MCP 进度通知、火山方舟供应商,以及公开 Docker 栈切到 PostgreSQL 16。
|
|
||||||
|
|
||||||
完整故事见 [v1.8.0 release notes](https://claw.mate.vip/docs/zh/releases/1.8.0)。
|
|
||||||
|
|
||||||
**v1.7.0(2026-07-04 发布)** — 一次*生产化加固*:把它放进真正的协作里之后,那些看不见、收不拢、够不着、装不下、连不通的地方全补上:
|
|
||||||
|
|
||||||
- **审批三条链路彻底闭环** — 工作流 `await_approval` 真的推到渠道并 resolve→恢复执行、WebChat(API-Key)渠道能批准/拒绝并重放、飞书/企微点卡片直接 resolve 工作流审批
|
|
||||||
- **长任务看得见** — 常驻「运行总览」侧栏 + 本轮 Token 明细(缓存命中/未命中/写入 + 推理拆分)+ 子 Agent 成本向上滚加 + 生成文件一键下载
|
|
||||||
- **装得下真实模型窗口** — 本地模型上下文窗口探测、prefix 注入统一 Token 预算、小上下文降级、工具 schema 预算门——不再被"猜个 32K"坑到预检拒绝或悄悄截断
|
|
||||||
- **开放出去** — 知识库 / Deep Research 开放 API(API-Key + 限流 + SSE)、插件化搜索 Provider SPI、MCP 身份透传(把认证用户身份带给 STDIO MCP)
|
|
||||||
- **够得着更远** — 桌面端本地内嵌 / 远程集中部署双模式(`mateclaw-desktop` 源码开放)+ 局域网部署模式放开受控内网访问
|
|
||||||
- **运营数据一键导出** — Dashboard 9 表 Excel + CLI 命令行离线导出
|
|
||||||
|
|
||||||
完整故事见 [v1.7.0 release notes](https://claw.mate.vip/docs/zh/releases/1.7.0)。
|
|
||||||
|
|
||||||
**v1.6.0(2026-06-22 发布)** — 让自驱的数字员工*更快、更会看、更易嵌入*:技能两段式载入 + prefix 压缩(首字节更快)· `execute_code` 原生沙箱代码执行 · 图片跨轮次留存 + `image_analyze` · 可嵌入/无头 webchat 按 `endUserId` 隔离记忆 · 真正可读的 Wiki(阅读与管理分离 · 统一 Sources 标签 · 可点击 `[[wikilinks]]`)· 高负载更稳(MCP 自愈 · 工具调用恢复 · 计划证据闸门)。完整故事见 [v1.6.0 release notes](https://claw.mate.vip/docs/zh/releases/1.6.0)。
|
|
||||||
|
|
||||||
**v1.5.0(2026-06-04 发布)** — Goal 可勾选清单(模糊评分 → 逐项打勾)· Wiki 自维护(`[[wikilinks]]` · 事实层/经验层 · pageType 模板与权限 · 知识库流水线 · 本地目录接入)· 按拥有者隔离记忆(`owner_key` + 可见域 + `endUserId` 透传)· 每员工绑定主知识库 · 偏好 provider 驱动选型。完整故事见 [v1.5.0 release notes](https://claw.mate.vip/docs/zh/releases/1.5.0)。
|
|
||||||
|
|
||||||
**v1.4.0(2026-05-23 发布)** — 持续目标(锁定目标,每轮自评)· 子员工委派树(最深 3 层 · 同步 / 并行 / 异步 · 一句话组队)· 工具/技能渐进式披露 · 工作空间 RBAC(Owner / Admin / Member / Viewer)· 飞书一等公民(交互卡 / 审批卡 / 流式卡 · 渠道原生工具)。详见 [v1.4.0 release notes](https://claw.mate.vip/docs/zh/releases/1.4.0)。
|
|
||||||
|
|
||||||
**v1.3.0(2026-05-13 发布)** — 工作流引擎 · 6 种 pattern 触发器 · Wiki 加工器 · 每员工独立 MCP 绑定 · 多模态旁路路由 · 4 个 JVM 原生文档生成工具 · 图像编辑。详见 [v1.3.0 release notes](https://claw.mate.vip/docs/zh/releases/1.3.0)。
|
|
||||||
|
|
||||||
## 参与贡献
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/mateaix/mateclaw.git
|
|
||||||
cd mateclaw
|
|
||||||
cd mateclaw-server && mvn clean compile
|
|
||||||
cd ../mateclaw-ui && npm install && npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 为什么叫这个名字
|
## 路线图
|
||||||
|
|
||||||
**Mate** 是陪伴。**Claw** 是能力。
|
| 方向 | 事项 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| **Agent** | 多 Agent 协作与任务委派 | 计划中 |
|
||||||
|
| **Agent** | 多模态输入(图片、音频、视频) | 计划中 |
|
||||||
|
| **模型** | 大小模型智能路由 | 计划中 |
|
||||||
|
| **记忆** | 向量数据库长期记忆(RAG) | 计划中 |
|
||||||
|
| **记忆** | 多模态记忆融合 | 计划中 |
|
||||||
|
| **技能** | 丰富 ClawHub 生态 | 进行中 |
|
||||||
|
| **渠道** | 微信个人号(iLink Bot) | 计划中 |
|
||||||
|
| **渠道** | 邮件渠道 | 计划中 |
|
||||||
|
| **桌面** | Linux 支持 | 计划中 |
|
||||||
|
| **安全** | 多租户支持 | 计划中 |
|
||||||
|
| **控制台** | Web 端插件市场 | 计划中 |
|
||||||
|
|
||||||
一个陪在你身边的系统——也是一个真的能抓住工作、把它推向完成的系统。
|
_状态说明:_ **进行中** — 正在开发;**计划中** — 排期中或设计阶段。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 参与贡献
|
||||||
|
|
||||||
|
MateClaw 欢迎各种形式的贡献!无论是 Bug 修复、新功能、文档改进,还是新的渠道/工具集成,我们都非常欢迎。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 克隆仓库
|
||||||
|
git clone https://github.com/matevip/mateclaw.git
|
||||||
|
cd mateclaw
|
||||||
|
|
||||||
|
# 后端
|
||||||
|
cd mateclaw-server
|
||||||
|
mvn clean compile
|
||||||
|
|
||||||
|
# 前端
|
||||||
|
cd ../mateclaw-ui
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
提交 PR 前请阅读 [CONTRIBUTING.md](https://github.com/matevip/mateclaw/blob/main/CONTRIBUTING.md)(如有)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 联系我们
|
||||||
|
|
||||||
|
<!-- TODO: 补充社交账号 -->
|
||||||
|
|
||||||
|
| Discord | X (Twitter) | 钉钉群 |
|
||||||
|
|---------|-------------|--------|
|
||||||
|
| 即将上线 | 即将上线 | 即将上线 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 为什么叫 MateClaw?
|
||||||
|
|
||||||
|
**Mate** — 伙伴,始终陪伴在你身边。**Claw** — 利爪,锋利有力,随时抓取任何任务。MateClaw 是你的个人 AI 伙伴,在你需要时伸出利爪。采用单体模块化设计,部署简单、扩展灵活、定制方便。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
[Apache License 2.0](LICENSE)。没有星号。
|
MateClaw 基于 [Apache License 2.0](LICENSE) 发布。
|
||||||
|
|||||||
@ -1,118 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 580" font-family="'Inter','SF Pro Display','Segoe UI',sans-serif">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0%" stop-color="#f6f1ea"/>
|
|
||||||
<stop offset="100%" stop-color="#ebe3db"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="primary" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0%" stop-color="#d96d46"/>
|
|
||||||
<stop offset="100%" stop-color="#bb4f27"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="accent" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0%" stop-color="#1e5a54"/>
|
|
||||||
<stop offset="100%" stop-color="#184a45"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="warm" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stop-color="#ffffff"/>
|
|
||||||
<stop offset="100%" stop-color="#fdfaf6"/>
|
|
||||||
</linearGradient>
|
|
||||||
<filter id="shadow">
|
|
||||||
<feDropShadow dx="0" dy="4" stdDeviation="8" flood-color="#3a2013" flood-opacity="0.08"/>
|
|
||||||
</filter>
|
|
||||||
<filter id="glow">
|
|
||||||
<feDropShadow dx="0" dy="2" stdDeviation="6" flood-color="#d96d46" flood-opacity="0.15"/>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<rect width="960" height="580" rx="20" fill="url(#bg)"/>
|
|
||||||
|
|
||||||
<text x="480" y="42" text-anchor="middle" font-size="22" font-weight="800" fill="#1d1612" letter-spacing="-0.5">MateClaw Business Architecture</text>
|
|
||||||
<text x="480" y="62" text-anchor="middle" font-size="11" fill="#9b7d6c" letter-spacing="2">AN AI OPERATING SYSTEM — THE COMPLETE PICTURE</text>
|
|
||||||
|
|
||||||
<!-- ===== 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="56" fill="#f6e2d7" stroke="#ebb08f" stroke-width="1"/>
|
|
||||||
<text x="480" y="264" text-anchor="middle" font-size="15" font-weight="800" fill="#d96d46">Digital Employee</text>
|
|
||||||
<text x="480" y="283" text-anchor="middle" font-size="10" font-weight="500" fill="#665245">Identity · Goal · Governance</text>
|
|
||||||
<text x="480" y="299" text-anchor="middle" font-size="9" fill="#9b7d6c">Native Runtime · DSH Runtime</text>
|
|
||||||
<text x="480" y="313" text-anchor="middle" font-size="8" fill="#9b7d6c">One policy + event plane</text>
|
|
||||||
|
|
||||||
<!-- ===== Top: User Surfaces (5 items) ===== -->
|
|
||||||
<rect x="270" y="82" width="420" height="68" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
|
||||||
<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="312" y="136" text-anchor="middle" font-size="10" fill="#665245">Web Console</text>
|
|
||||||
<text x="396" y="136" text-anchor="middle" font-size="10" fill="#665245">Desktop</text>
|
|
||||||
<text x="480" y="136" text-anchor="middle" font-size="10" fill="#665245">Webchat</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"/>
|
|
||||||
<polygon points="475,206 480,214 485,206" fill="#d96d46" opacity="0.6"/>
|
|
||||||
|
|
||||||
<!-- ===== Left: Knowledge ===== -->
|
|
||||||
<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)"/>
|
|
||||||
<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="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">Citations + Soft Archive</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">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"/>
|
|
||||||
<polygon points="406,275 414,280 406,285" fill="#184a45" opacity="0.6"/>
|
|
||||||
|
|
||||||
<!-- ===== Right Top: Tools & Skills ===== -->
|
|
||||||
<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="65" rx="2" fill="url(#primary)"/>
|
|
||||||
<text x="820" y="232" text-anchor="middle" font-size="13" font-weight="700" fill="#d96d46">Skills · MCP · ACP</text>
|
|
||||||
<text x="820" y="252" text-anchor="middle" font-size="10" fill="#665245">SKILL.md + LESSONS</text>
|
|
||||||
<text x="820" y="266" text-anchor="middle" font-size="9" fill="#9b7d6c">Even Claude Code joins as a hire</text>
|
|
||||||
<line x1="552" y1="242" x2="720" y2="242" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
|
||||||
<polygon points="554,237 546,242 554,247" fill="#d96d46" opacity="0.6"/>
|
|
||||||
|
|
||||||
<!-- ===== 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="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 · Dreaming</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">Nightly consolidation</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"/>
|
|
||||||
<polygon points="449,336 456,342 447,344" fill="#184a45" opacity="0.5"/>
|
|
||||||
|
|
||||||
<!-- ===== Bottom Right: Multimodal ===== -->
|
|
||||||
<rect x="600" y="400" width="200" height="100" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
|
||||||
<rect x="600" y="496" width="200" height="4" rx="2" fill="url(#primary)"/>
|
|
||||||
<text x="700" y="428" text-anchor="middle" font-size="13" font-weight="700" fill="#d96d46">Multimodal</text>
|
|
||||||
<text x="700" y="452" text-anchor="middle" font-size="10" fill="#665245">TTS · STT · Voice</text>
|
|
||||||
<text x="700" y="470" text-anchor="middle" font-size="10" fill="#665245">Image · Music · Video</text>
|
|
||||||
<text x="700" y="488" text-anchor="middle" font-size="9" fill="#9b7d6c">AI beyond text</text>
|
|
||||||
<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"/>
|
|
||||||
|
|
||||||
<!-- ===== Orchestration tier: Team board + Workflow + Trigger ===== -->
|
|
||||||
<rect x="300" y="358" width="360" 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 · Team Board (2.0.0+) + Workflow + Trigger</text>
|
|
||||||
<text x="480" y="389" text-anchor="middle" font-size="9" fill="#fde7dd">Lead decomposes → members run in parallel → approve / deliver</text>
|
|
||||||
|
|
||||||
<!-- ===== Bottom Center: Provider Pool + Failover ===== -->
|
|
||||||
<rect x="370" y="420" width="220" height="64" rx="12" fill="url(#accent)" filter="url(#shadow)"/>
|
|
||||||
<text x="480" y="442" text-anchor="middle" font-size="13" font-weight="700" fill="#ffffff">Provider Pool · Failover</text>
|
|
||||||
<text x="480" y="460" text-anchor="middle" font-size="10" fill="#dce8e4">Cloud + Local · 14+ providers</text>
|
|
||||||
<text x="480" y="475" text-anchor="middle" font-size="9" fill="#dce8e4">Health Tracker · Auto-switch</text>
|
|
||||||
<!-- 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)"/>
|
|
||||||
<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>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 8.9 KiB |
@ -1,126 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 580" font-family="'Inter','PingFang SC','Microsoft YaHei',sans-serif">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0%" stop-color="#f6f1ea"/>
|
|
||||||
<stop offset="100%" stop-color="#ebe3db"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="primary" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0%" stop-color="#d96d46"/>
|
|
||||||
<stop offset="100%" stop-color="#bb4f27"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="accent" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0%" stop-color="#1e5a54"/>
|
|
||||||
<stop offset="100%" stop-color="#184a45"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="warm" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stop-color="#ffffff"/>
|
|
||||||
<stop offset="100%" stop-color="#fdfaf6"/>
|
|
||||||
</linearGradient>
|
|
||||||
<filter id="shadow">
|
|
||||||
<feDropShadow dx="0" dy="4" stdDeviation="8" flood-color="#3a2013" flood-opacity="0.08"/>
|
|
||||||
</filter>
|
|
||||||
<filter id="glow">
|
|
||||||
<feDropShadow dx="0" dy="2" stdDeviation="6" flood-color="#d96d46" flood-opacity="0.15"/>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<rect width="960" height="580" rx="20" fill="url(#bg)"/>
|
|
||||||
|
|
||||||
<!-- Title -->
|
|
||||||
<text x="480" y="42" text-anchor="middle" font-size="22" font-weight="800" fill="#1d1612" letter-spacing="-0.5">MateClaw 业务架构</text>
|
|
||||||
<text x="480" y="62" text-anchor="middle" font-size="11" fill="#9b7d6c" letter-spacing="0.5">BUSINESS ARCHITECTURE — AI 工作系统全景</text>
|
|
||||||
|
|
||||||
<!-- ===== 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="56" fill="#f6e2d7" stroke="#ebb08f" stroke-width="1"/>
|
|
||||||
<text x="480" y="264" text-anchor="middle" font-size="15" font-weight="800" fill="#d96d46">数字员工</text>
|
|
||||||
<text x="480" y="283" text-anchor="middle" font-size="10" font-weight="500" fill="#665245">身份 · 目标 · 治理</text>
|
|
||||||
<text x="480" y="299" text-anchor="middle" font-size="9" fill="#9b7d6c">Native Runtime · DSH Runtime</text>
|
|
||||||
<text x="480" y="313" text-anchor="middle" font-size="8" fill="#9b7d6c">同一策略与事件平面</text>
|
|
||||||
|
|
||||||
<!-- ===== Top: User Surfaces (5 items) ===== -->
|
|
||||||
<rect x="270" y="82" width="420" height="68" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
|
||||||
<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="312" y="136" text-anchor="middle" font-size="10" fill="#665245">Web 控制台</text>
|
|
||||||
<text x="396" y="136" text-anchor="middle" font-size="10" fill="#665245">桌面端</text>
|
|
||||||
<text x="480" y="136" text-anchor="middle" font-size="10" fill="#665245">Webchat</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 -->
|
|
||||||
<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"/>
|
|
||||||
|
|
||||||
<!-- ===== Left: Knowledge ===== -->
|
|
||||||
<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)"/>
|
|
||||||
<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">LLM Wiki 知识库</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="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>
|
|
||||||
<!-- Arrow right -->
|
|
||||||
<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"/>
|
|
||||||
|
|
||||||
<!-- ===== Right Top: Tools & Skills ===== -->
|
|
||||||
<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="65" rx="2" fill="url(#primary)"/>
|
|
||||||
<text x="820" y="232" text-anchor="middle" font-size="13" font-weight="700" fill="#d96d46">技能 · MCP · ACP</text>
|
|
||||||
<text x="820" y="252" text-anchor="middle" font-size="10" fill="#665245">SKILL.md + LESSONS</text>
|
|
||||||
<text x="820" y="266" text-anchor="middle" font-size="9" fill="#9b7d6c">Claude Code 也来当员工</text>
|
|
||||||
<!-- Arrow to Tools -->
|
|
||||||
<line x1="552" y1="242" x2="720" y2="242" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
|
||||||
<polygon points="554,237 546,242 554,247" fill="#d96d46" opacity="0.6"/>
|
|
||||||
|
|
||||||
<!-- ===== 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="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">记忆 · Dreaming</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="488" text-anchor="middle" font-size="9" fill="#9b7d6c">你睡了它在工作</text>
|
|
||||||
<!-- 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"/>
|
|
||||||
<polygon points="449,336 456,342 447,344" fill="#184a45" opacity="0.5"/>
|
|
||||||
|
|
||||||
<!-- ===== Bottom Right: Multimodal ===== -->
|
|
||||||
<rect x="600" y="400" width="200" height="100" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
|
||||||
<rect x="600" y="496" width="200" height="4" rx="2" fill="url(#primary)"/>
|
|
||||||
<text x="700" y="428" text-anchor="middle" font-size="13" font-weight="700" fill="#d96d46">多模态创作</text>
|
|
||||||
<text x="700" y="452" text-anchor="middle" font-size="10" fill="#665245">语音合成 · 语音识别</text>
|
|
||||||
<text x="700" y="470" text-anchor="middle" font-size="10" fill="#665245">图片 · 音乐 · 视频生成</text>
|
|
||||||
<text x="700" y="488" text-anchor="middle" font-size="9" fill="#9b7d6c">AI 不只是文字</text>
|
|
||||||
<!-- Arrow up-left -->
|
|
||||||
<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"/>
|
|
||||||
|
|
||||||
<!-- ===== Orchestration tier: Team board + Workflow + Trigger ===== -->
|
|
||||||
<rect x="310" y="358" width="340" 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">业务编排 · 团队任务板(2.0.0+)+ 工作流 + 触发器</text>
|
|
||||||
<text x="480" y="389" text-anchor="middle" font-size="9" fill="#fde7dd">Lead 拆解派发 → 成员并行执行 → 审批 / 交付物 / 分发 / 写记忆</text>
|
|
||||||
|
|
||||||
<!-- ===== Bottom Center: Models + Failover ===== -->
|
|
||||||
<rect x="370" y="420" width="220" height="64" rx="12" fill="url(#accent)" filter="url(#shadow)"/>
|
|
||||||
<text x="480" y="442" text-anchor="middle" font-size="13" font-weight="700" fill="#ffffff">模型池 · Failover</text>
|
|
||||||
<text x="480" y="460" text-anchor="middle" font-size="10" fill="#dce8e4">云端 + 本地 · 14+ 供应商</text>
|
|
||||||
<text x="480" y="475" text-anchor="middle" font-size="9" fill="#dce8e4">健康追踪 · 自动切换</text>
|
|
||||||
<!-- 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"/>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<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 是陪伴,Claw 是能力</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 9.2 KiB |
@ -1,200 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 640" font-family="'Inter','SF Pro Display','Segoe UI',sans-serif">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0%" stop-color="#f6f1ea"/>
|
|
||||||
<stop offset="100%" stop-color="#ebe3db"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="primary" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0%" stop-color="#d96d46"/>
|
|
||||||
<stop offset="100%" stop-color="#bb4f27"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="accent" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0%" stop-color="#1e5a54"/>
|
|
||||||
<stop offset="100%" stop-color="#184a45"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="warm" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stop-color="#ffffff"/>
|
|
||||||
<stop offset="100%" stop-color="#fdfaf6"/>
|
|
||||||
</linearGradient>
|
|
||||||
<filter id="shadow">
|
|
||||||
<feDropShadow dx="0" dy="4" stdDeviation="8" flood-color="#3a2013" flood-opacity="0.08"/>
|
|
||||||
</filter>
|
|
||||||
<filter id="shadowSm">
|
|
||||||
<feDropShadow dx="0" dy="2" stdDeviation="4" flood-color="#3a2013" flood-opacity="0.06"/>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<rect width="960" height="640" rx="20" fill="url(#bg)"/>
|
|
||||||
|
|
||||||
<text x="480" y="42" text-anchor="middle" font-size="22" font-weight="800" fill="#1d1612" letter-spacing="-0.5">MateClaw Technical Architecture</text>
|
|
||||||
<text x="480" y="62" text-anchor="middle" font-size="11" fill="#9b7d6c" letter-spacing="2">THE WHOLE WIDGET</text>
|
|
||||||
|
|
||||||
<!-- ===== Layer 1: Presentation ===== -->
|
|
||||||
<rect x="30" y="82" width="900" height="88" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
|
||||||
<rect x="30" y="82" width="900" height="4" rx="2" fill="url(#primary)"/>
|
|
||||||
<text x="56" y="106" font-size="11" font-weight="700" fill="#d96d46" letter-spacing="1">PRESENTATION</text>
|
|
||||||
|
|
||||||
<g transform="translate(56, 118)">
|
|
||||||
<rect width="130" height="40" rx="8" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5"/>
|
|
||||||
<text x="65" y="17" text-anchor="middle" font-size="11" font-weight="600" fill="#1d1612">Vue 3 SPA</text>
|
|
||||||
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">Element Plus + Tailwind</text>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(200, 118)">
|
|
||||||
<rect width="130" height="40" rx="8" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5"/>
|
|
||||||
<text x="65" y="17" text-anchor="middle" font-size="11" font-weight="600" fill="#1d1612">Electron Desktop</text>
|
|
||||||
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">Bundled JRE 21</text>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(344, 118)">
|
|
||||||
<rect width="130" height="40" rx="8" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5"/>
|
|
||||||
<text x="65" y="17" text-anchor="middle" font-size="11" font-weight="600" fill="#1d1612">SSE Streaming</text>
|
|
||||||
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">Real-time Chat + Tools</text>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(488, 118)">
|
|
||||||
<rect width="130" height="40" rx="8" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5"/>
|
|
||||||
<text x="65" y="17" text-anchor="middle" font-size="11" font-weight="600" fill="#1d1612">REST API</text>
|
|
||||||
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">SpringDoc OpenAPI</text>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(632, 118)">
|
|
||||||
<rect width="130" height="40" rx="8" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5"/>
|
|
||||||
<text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">China IM (5)</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 transform="translate(776, 118)">
|
|
||||||
<rect width="130" height="40" rx="8" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5"/>
|
|
||||||
<text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">Global IM (3)</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>
|
|
||||||
|
|
||||||
<!-- ===== Layer 2: Agent Runtime ===== -->
|
|
||||||
<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)"/>
|
|
||||||
<text x="56" y="210" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">AGENT RUNTIME · NORMALIZED EVENTS & GOVERNANCE</text>
|
|
||||||
|
|
||||||
<g transform="translate(56, 222)">
|
|
||||||
<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">Runtime Contract</text>
|
|
||||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Provider · Session · Capability</text>
|
|
||||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Lifecycle · Usage · Projection</text>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(244, 222)">
|
|
||||||
<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">Native Runtime</text>
|
|
||||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">StateGraph · ReAct</text>
|
|
||||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#9b7d6c">Plan-Execute · Goals · Teams</text>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(432, 222)">
|
|
||||||
<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">DSH Runtime</text>
|
|
||||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Managed JSON-RPC Process</text>
|
|
||||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">DeepSeek Harness · Cordis</text>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(620, 222)">
|
|
||||||
<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">Host Governance</text>
|
|
||||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Workspace · Tool Guard</text>
|
|
||||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Approval · Credentials</text>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(808, 222)">
|
|
||||||
<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">Tool Plane</text>
|
|
||||||
<text x="54" y="36" text-anchor="middle" font-size="9" fill="#665245">Skills · MCP</text>
|
|
||||||
<text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">ACP · Built-in</text>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- ===== Layer 3: Core Services ===== -->
|
|
||||||
<rect x="30" y="320" width="440" height="118" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
|
||||||
<rect x="30" y="320" width="440" height="4" rx="2" fill="url(#primary)"/>
|
|
||||||
<text x="56" y="344" font-size="11" font-weight="700" fill="#d96d46" letter-spacing="1">CORE SERVICES</text>
|
|
||||||
|
|
||||||
<g transform="translate(56, 356)">
|
|
||||||
<rect width="120" height="68" rx="10" fill="#fff" stroke="#d9cec2" stroke-width="0.5" filter="url(#shadowSm)"/>
|
|
||||||
<text x="60" y="20" text-anchor="middle" font-size="10" font-weight="600" fill="#1d1612">Spring Boot 3.5</text>
|
|
||||||
<text x="60" y="36" text-anchor="middle" font-size="9" fill="#9b7d6c">App Framework</text>
|
|
||||||
<text x="60" y="50" text-anchor="middle" font-size="8" fill="#9b7d6c">Spring MVC + SSE</text>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(190, 356)">
|
|
||||||
<rect width="120" height="68" rx="10" fill="#fff" stroke="#d9cec2" stroke-width="0.5" filter="url(#shadowSm)"/>
|
|
||||||
<text x="60" y="20" text-anchor="middle" font-size="10" font-weight="600" fill="#1d1612">Spring Security</text>
|
|
||||||
<text x="60" y="36" text-anchor="middle" font-size="9" fill="#9b7d6c">Auth Layer</text>
|
|
||||||
<text x="60" y="50" text-anchor="middle" font-size="8" fill="#9b7d6c">JWT + RBAC</text>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(324, 356)">
|
|
||||||
<rect width="130" height="68" rx="10" fill="#fff" stroke="#d9cec2" stroke-width="0.5" filter="url(#shadowSm)"/>
|
|
||||||
<text x="65" y="20" text-anchor="middle" font-size="10" font-weight="600" fill="#1d1612">MyBatis Plus</text>
|
|
||||||
<text x="65" y="36" text-anchor="middle" font-size="9" fill="#9b7d6c">Persistence</text>
|
|
||||||
<text x="65" y="50" text-anchor="middle" font-size="8" fill="#9b7d6c">Logical Delete + Mapping</text>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- ===== Layer 3b: Model Providers ===== -->
|
|
||||||
<rect x="490" y="320" width="440" height="118" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
|
||||||
<rect x="490" y="320" width="440" height="4" rx="2" fill="url(#accent)"/>
|
|
||||||
<text x="516" y="344" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">MODEL PROVIDERS</text>
|
|
||||||
|
|
||||||
<g transform="translate(516, 356)">
|
|
||||||
<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">Provider Pool</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">Health Tracker · Cooldown</text>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(650, 356)">
|
|
||||||
<rect width="130" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
|
|
||||||
<text x="65" y="20" text-anchor="middle" font-size="10" font-weight="600" fill="#184a45">Cloud Models</text>
|
|
||||||
<text x="65" y="36" text-anchor="middle" font-size="9" fill="#665245">DashScope · OpenAI</text>
|
|
||||||
<text x="65" y="50" text-anchor="middle" font-size="8" fill="#9b7d6c">Anthropic · Gemini · More</text>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(794, 356)">
|
|
||||||
<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">Local Models</text>
|
|
||||||
<text x="60" y="36" text-anchor="middle" font-size="9" fill="#665245">Ollama · LM Studio</text>
|
|
||||||
<text x="60" y="50" text-anchor="middle" font-size="8" fill="#9b7d6c">MLX · llama.cpp</text>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- ===== Layer 4: Data & Infra ===== -->
|
|
||||||
<rect x="30" y="454" width="900" height="88" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
|
||||||
<rect x="30" y="454" width="900" height="4" rx="2" fill="#665245"/>
|
|
||||||
<text x="56" y="478" font-size="11" font-weight="700" fill="#665245" letter-spacing="1">DATA & INFRASTRUCTURE</text>
|
|
||||||
|
|
||||||
<g transform="translate(56, 488)">
|
|
||||||
<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">H2 / MySQL 8</text>
|
|
||||||
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">Relational Storage</text>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(200, 488)">
|
|
||||||
<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">File System</text>
|
|
||||||
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">Memory + Wiki + Files</text>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(344, 488)">
|
|
||||||
<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">MCP Protocol</text>
|
|
||||||
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">stdio / SSE / HTTP</text>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(488, 488)">
|
|
||||||
<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">Docker Compose</text>
|
|
||||||
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">One-click Deploy</text>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(632, 488)">
|
|
||||||
<rect width="130" height="40" rx="8" fill="#fff" stroke="#d9cec2" stroke-width="0.5"/>
|
|
||||||
<text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#1d1612">Cron · Event Bus</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 transform="translate(776, 488)">
|
|
||||||
<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">Multimodal</text>
|
|
||||||
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">TTS / STT / Img / Music / Vid</text>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- Flow arrows -->
|
|
||||||
<line x1="480" y1="170" x2="480" y2="186" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
|
||||||
<line x1="480" y1="304" x2="480" y2="320" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
|
||||||
<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"/>
|
|
||||||
|
|
||||||
<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)"/>
|
|
||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 14 KiB |
@ -1,204 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 640" font-family="'Inter','PingFang SC','Microsoft YaHei',sans-serif">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0%" stop-color="#f6f1ea"/>
|
|
||||||
<stop offset="100%" stop-color="#ebe3db"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="primary" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0%" stop-color="#d96d46"/>
|
|
||||||
<stop offset="100%" stop-color="#bb4f27"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="accent" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0%" stop-color="#1e5a54"/>
|
|
||||||
<stop offset="100%" stop-color="#184a45"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="warm" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stop-color="#ffffff"/>
|
|
||||||
<stop offset="100%" stop-color="#fdfaf6"/>
|
|
||||||
</linearGradient>
|
|
||||||
<filter id="shadow">
|
|
||||||
<feDropShadow dx="0" dy="4" stdDeviation="8" flood-color="#3a2013" flood-opacity="0.08"/>
|
|
||||||
</filter>
|
|
||||||
<filter id="shadowSm">
|
|
||||||
<feDropShadow dx="0" dy="2" stdDeviation="4" flood-color="#3a2013" flood-opacity="0.06"/>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<!-- Background -->
|
|
||||||
<rect width="960" height="640" rx="20" fill="url(#bg)"/>
|
|
||||||
|
|
||||||
<!-- Title -->
|
|
||||||
<text x="480" y="42" text-anchor="middle" font-size="22" font-weight="800" fill="#1d1612" letter-spacing="-0.5">MateClaw 技术架构</text>
|
|
||||||
<text x="480" y="62" text-anchor="middle" font-size="11" fill="#9b7d6c" letter-spacing="0.5">TECHNICAL ARCHITECTURE</text>
|
|
||||||
|
|
||||||
<!-- ===== Layer 1: Presentation ===== -->
|
|
||||||
<rect x="30" y="82" width="900" height="88" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
|
||||||
<rect x="30" y="82" width="900" height="4" rx="2" fill="url(#primary)"/>
|
|
||||||
<text x="56" y="106" font-size="11" font-weight="700" fill="#d96d46" letter-spacing="1">表现层</text>
|
|
||||||
|
|
||||||
<!-- Presentation items -->
|
|
||||||
<g transform="translate(56, 118)">
|
|
||||||
<rect width="130" height="40" rx="8" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5"/>
|
|
||||||
<text x="65" y="17" text-anchor="middle" font-size="11" font-weight="600" fill="#1d1612">Vue 3 SPA</text>
|
|
||||||
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">Element Plus + Tailwind</text>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(200, 118)">
|
|
||||||
<rect width="130" height="40" rx="8" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5"/>
|
|
||||||
<text x="65" y="17" text-anchor="middle" font-size="11" font-weight="600" fill="#1d1612">Electron 桌面端</text>
|
|
||||||
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">JRE 21 内嵌</text>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(344, 118)">
|
|
||||||
<rect width="130" height="40" rx="8" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5"/>
|
|
||||||
<text x="65" y="17" text-anchor="middle" font-size="11" font-weight="600" fill="#1d1612">SSE 流式传输</text>
|
|
||||||
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">实时对话 + 工具流</text>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(488, 118)">
|
|
||||||
<rect width="130" height="40" rx="8" fill="#f6e2d7" stroke="#ebb08f" stroke-width="0.5"/>
|
|
||||||
<text x="65" y="17" text-anchor="middle" font-size="11" font-weight="600" fill="#1d1612">REST API</text>
|
|
||||||
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">SpringDoc OpenAPI</text>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(632, 118)">
|
|
||||||
<rect width="130" height="40" rx="8" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5"/>
|
|
||||||
<text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">国内 IM (5)</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 transform="translate(776, 118)">
|
|
||||||
<rect width="130" height="40" rx="8" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5"/>
|
|
||||||
<text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#184a45">海外 IM (3)</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>
|
|
||||||
|
|
||||||
<!-- ===== Layer 2: Agent Runtime ===== -->
|
|
||||||
<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)"/>
|
|
||||||
<text x="56" y="210" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">AGENT RUNTIME · 统一事件与治理</text>
|
|
||||||
|
|
||||||
<g transform="translate(56, 222)">
|
|
||||||
<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">Runtime Contract</text>
|
|
||||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Provider · Session · 能力</text>
|
|
||||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">生命周期 · 用量 · 投影</text>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(244, 222)">
|
|
||||||
<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">Native Runtime</text>
|
|
||||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">StateGraph · ReAct</text>
|
|
||||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#9b7d6c">Plan-Execute · Goal · Team</text>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(432, 222)">
|
|
||||||
<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">DSH Runtime</text>
|
|
||||||
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">受管理 JSON-RPC 进程</text>
|
|
||||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">DeepSeek Harness · Cordis</text>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(620, 222)">
|
|
||||||
<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="36" text-anchor="middle" font-size="9" fill="#665245">Workspace · Tool Guard</text>
|
|
||||||
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">审批 · 凭证隔离</text>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(808, 222)">
|
|
||||||
<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">工具平面</text>
|
|
||||||
<text x="54" y="36" text-anchor="middle" font-size="9" fill="#665245">技能 · MCP</text>
|
|
||||||
<text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">ACP · 内置工具</text>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- ===== Layer 3: Core Services ===== -->
|
|
||||||
<rect x="30" y="320" width="440" height="118" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
|
||||||
<rect x="30" y="320" width="440" height="4" rx="2" fill="url(#primary)"/>
|
|
||||||
<text x="56" y="344" font-size="11" font-weight="700" fill="#d96d46" letter-spacing="1">核心服务层</text>
|
|
||||||
|
|
||||||
<g transform="translate(56, 356)">
|
|
||||||
<rect width="120" height="68" rx="10" fill="#fff" stroke="#d9cec2" stroke-width="0.5" filter="url(#shadowSm)"/>
|
|
||||||
<text x="60" y="20" text-anchor="middle" font-size="10" font-weight="600" fill="#1d1612">Spring Boot 3.5</text>
|
|
||||||
<text x="60" y="36" text-anchor="middle" font-size="9" fill="#9b7d6c">应用框架</text>
|
|
||||||
<text x="60" y="50" text-anchor="middle" font-size="8" fill="#9b7d6c">Spring MVC + SSE</text>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(190, 356)">
|
|
||||||
<rect width="120" height="68" rx="10" fill="#fff" stroke="#d9cec2" stroke-width="0.5" filter="url(#shadowSm)"/>
|
|
||||||
<text x="60" y="20" text-anchor="middle" font-size="10" font-weight="600" fill="#1d1612">Spring Security</text>
|
|
||||||
<text x="60" y="36" text-anchor="middle" font-size="9" fill="#9b7d6c">认证授权</text>
|
|
||||||
<text x="60" y="50" text-anchor="middle" font-size="8" fill="#9b7d6c">JWT + RBAC</text>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(324, 356)">
|
|
||||||
<rect width="130" height="68" rx="10" fill="#fff" stroke="#d9cec2" stroke-width="0.5" filter="url(#shadowSm)"/>
|
|
||||||
<text x="65" y="20" text-anchor="middle" font-size="10" font-weight="600" fill="#1d1612">MyBatis Plus</text>
|
|
||||||
<text x="65" y="36" text-anchor="middle" font-size="9" fill="#9b7d6c">数据持久层</text>
|
|
||||||
<text x="65" y="50" text-anchor="middle" font-size="8" fill="#9b7d6c">逻辑删除 + 自动映射</text>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- ===== Layer 3b: Model Providers ===== -->
|
|
||||||
<rect x="490" y="320" width="440" height="118" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
|
||||||
<rect x="490" y="320" width="440" height="4" rx="2" fill="url(#accent)"/>
|
|
||||||
<text x="516" y="344" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">模型供应层</text>
|
|
||||||
|
|
||||||
<g transform="translate(516, 356)">
|
|
||||||
<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">模型池 · Failover</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">健康追踪 · 自动切换</text>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(650, 356)">
|
|
||||||
<rect width="130" height="68" rx="10" fill="#dce8e4" stroke="#5ca69d" stroke-width="0.5" filter="url(#shadowSm)"/>
|
|
||||||
<text x="65" y="20" text-anchor="middle" font-size="10" font-weight="600" fill="#184a45">云端模型</text>
|
|
||||||
<text x="65" y="36" text-anchor="middle" font-size="9" fill="#665245">DashScope · OpenAI</text>
|
|
||||||
<text x="65" y="50" text-anchor="middle" font-size="8" fill="#9b7d6c">Anthropic · Gemini · 更多</text>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(794, 356)">
|
|
||||||
<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">本地模型</text>
|
|
||||||
<text x="60" y="36" text-anchor="middle" font-size="9" fill="#665245">Ollama · LM Studio</text>
|
|
||||||
<text x="60" y="50" text-anchor="middle" font-size="8" fill="#9b7d6c">MLX · llama.cpp</text>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- ===== Layer 4: Data & Infra ===== -->
|
|
||||||
<rect x="30" y="454" width="900" height="88" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
|
|
||||||
<rect x="30" y="454" width="900" height="4" rx="2" fill="#665245"/>
|
|
||||||
<text x="56" y="478" font-size="11" font-weight="700" fill="#665245" letter-spacing="1">数据与基础设施</text>
|
|
||||||
|
|
||||||
<g transform="translate(56, 488)">
|
|
||||||
<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">H2 / MySQL 8</text>
|
|
||||||
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">关系型存储</text>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(200, 488)">
|
|
||||||
<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="31" text-anchor="middle" font-size="9" fill="#9b7d6c">记忆 + Wiki + 附件</text>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(344, 488)">
|
|
||||||
<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">MCP 协议</text>
|
|
||||||
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">stdio / SSE / HTTP</text>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(488, 488)">
|
|
||||||
<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">Docker Compose</text>
|
|
||||||
<text x="65" y="31" text-anchor="middle" font-size="9" fill="#9b7d6c">一键部署</text>
|
|
||||||
</g>
|
|
||||||
<g transform="translate(632, 488)">
|
|
||||||
<rect width="130" height="40" rx="8" fill="#fff" stroke="#d9cec2" stroke-width="0.5"/>
|
|
||||||
<text x="65" y="14" text-anchor="middle" font-size="11" font-weight="600" fill="#1d1612">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 transform="translate(776, 488)">
|
|
||||||
<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="31" text-anchor="middle" font-size="9" fill="#9b7d6c">TTS / STT / 图 / 音 / 视</text>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- Flow arrows -->
|
|
||||||
<line x1="480" y1="170" x2="480" y2="186" stroke="#d96d46" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
|
||||||
<line x1="480" y1="304" x2="480" y2="320" stroke="#184a45" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
|
||||||
<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"/>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<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)"/>
|
|
||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 14 KiB |
BIN
assets/images/chat-echarts-demo.png
Normal file
|
After Width: | Height: | Size: 482 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
@ -1,194 +1,54 @@
|
|||||||
# ============================================================================
|
version: '3.8'
|
||||||
# ⚠️ DATABASE ENGINE: PostgreSQL (was MySQL before)
|
|
||||||
#
|
|
||||||
# This stack now runs on PostgreSQL 16. Switching the DB engine is a BREAKING
|
|
||||||
# change for existing deployments: `docker compose up -d` on a host that
|
|
||||||
# previously ran the MySQL stack starts a FRESH, EMPTY PostgreSQL volume
|
|
||||||
# (postgres_data). The old `mysql_data` volume is NOT read and the app
|
|
||||||
# re-seeds default data — existing data is not lost, but it is also NOT
|
|
||||||
# visible to the new stack.
|
|
||||||
#
|
|
||||||
# Treat this as FRESH-INSTALL-ONLY. To carry data across from a MySQL
|
|
||||||
# deployment, dump the OLD stack BEFORE pulling this change, e.g.:
|
|
||||||
# docker compose exec mysql mysqldump -u"$DB_USERNAME" -p"$DB_PASSWORD" "$DB_NAME" > mateclaw-mysql.sql
|
|
||||||
# then load into PostgreSQL with a cross-engine tool such as pgloader —
|
|
||||||
# there is no automatic MySQL → PostgreSQL migration here. Alternatively,
|
|
||||||
# keep running MySQL by pinning your checkout to a pre-switch tag and setting
|
|
||||||
# SPRING_PROFILES_ACTIVE=mysql (the mysql Spring profile remains supported).
|
|
||||||
# ============================================================================
|
|
||||||
services:
|
services:
|
||||||
# PostgreSQL 数据库
|
# MySQL 数据库
|
||||||
#
|
mysql:
|
||||||
# ⚠️ 密码通过环境变量传入,必须从 .env 文件提供。首次部署前:
|
image: mysql:8.0
|
||||||
# 1. cp .env.example .env
|
container_name: mateclaw-mysql
|
||||||
# 2. 编辑 .env 把 DB_ADMIN_PASSWORD / DB_PASSWORD 改成强密码
|
|
||||||
# 未设置会直接在 `docker compose up` 时报错,避免把默认密码带到生产环境。
|
|
||||||
postgres:
|
|
||||||
image: postgres:16
|
|
||||||
container_name: mateclaw-postgres
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
# Bootstrap/superuser — owns the cluster, used only for init + admin tasks.
|
MYSQL_ROOT_PASSWORD: mateclaw123
|
||||||
POSTGRES_DB: ${DB_NAME:-mateclaw}
|
MYSQL_DATABASE: mateclaw
|
||||||
POSTGRES_USER: ${DB_ADMIN_USERNAME:-mateclaw_admin}
|
MYSQL_USER: mateclaw
|
||||||
POSTGRES_PASSWORD: ${DB_ADMIN_PASSWORD:?DB_ADMIN_PASSWORD is required in .env}
|
MYSQL_PASSWORD: mateclaw123
|
||||||
# Least-privilege application role created by the init script below; this
|
|
||||||
# is the account the server connects with (NOT a superuser).
|
|
||||||
APP_DB_USERNAME: ${DB_USERNAME:-mateclaw}
|
|
||||||
APP_DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required in .env}
|
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
# No host port on purpose — the DB is only reachable from the compose
|
ports:
|
||||||
# network. For ad-hoc inspection use `docker compose exec postgres psql`,
|
- "3306:3306"
|
||||||
# or temporarily add: ports: ["127.0.0.1:5432:5432"]
|
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- mysql_data:/var/lib/mysql
|
||||||
# Runs once on first init (empty data dir): creates the restricted app
|
- ./mateclaw-server/src/main/resources/db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql
|
||||||
# role and the mateclaw schema it owns. See docker/postgres/init/.
|
- ./mateclaw-server/src/main/resources/db/data.sql:/docker-entrypoint-initdb.d/02-data.sql
|
||||||
- ./docker/postgres/init:/docker-entrypoint-initdb.d:ro
|
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${DB_ADMIN_USERNAME:-mateclaw_admin} -d ${DB_NAME:-mateclaw}"]
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
networks:
|
|
||||||
- mateclaw-net
|
|
||||||
|
|
||||||
# SearXNG 搜索引擎(keyless 搜索 provider)
|
|
||||||
#
|
|
||||||
# The custom image at docker/searxng/ bakes in settings.yml so the sidecar
|
|
||||||
# works out of the box (upstream image ships JSON disabled + Limiter enabled,
|
|
||||||
# both of which silently break mateclaw's SearXNGSearchProvider).
|
|
||||||
# No host bind-mount — edit docker/searxng/settings.yml and rebuild.
|
|
||||||
# Internal-only: the app reaches it via the compose network. To debug from
|
|
||||||
# the host, temporarily add: ports: ["127.0.0.1:8088:8080"]
|
|
||||||
searxng:
|
|
||||||
build:
|
|
||||||
context: ./docker/searxng
|
|
||||||
container_name: mateclaw-searxng
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
- SEARXNG_BASE_URL=http://searxng:8080
|
|
||||||
- SEARXNG_SECRET=${SEARXNG_SECRET:-mateclaw-dev-searxng-secret-change-me}
|
|
||||||
- UWSGI_WORKERS=2
|
|
||||||
- UWSGI_THREADS=4
|
|
||||||
healthcheck:
|
|
||||||
# Healthz needs json format, so this also doubles as an integration check.
|
|
||||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/healthz"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
networks:
|
|
||||||
- mateclaw-net
|
|
||||||
|
|
||||||
# MateClaw 后端服务
|
# MateClaw 后端服务
|
||||||
mateclaw-server:
|
mateclaw-server:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: ./mateclaw-server
|
||||||
dockerfile: mateclaw-server/Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
|
||||||
MAVEN_FLAGS: ${MAVEN_FLAGS:-}
|
|
||||||
container_name: mateclaw-server
|
container_name: mateclaw-server
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
mysql:
|
||||||
condition: service_healthy
|
|
||||||
searxng:
|
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
SPRING_PROFILES_ACTIVE: postgres
|
SPRING_PROFILES_ACTIVE: mysql
|
||||||
DB_HOST: postgres
|
DB_HOST: mysql
|
||||||
DB_PORT: 5432
|
DB_PORT: 3306
|
||||||
DB_NAME: ${DB_NAME:-mateclaw}
|
DB_NAME: mateclaw
|
||||||
DB_USERNAME: ${DB_USERNAME:-mateclaw}
|
DB_USERNAME: mateclaw
|
||||||
DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required in .env}
|
DB_PASSWORD: mateclaw123
|
||||||
# LLM provider keys (DashScope / OpenAI / Anthropic / DeepSeek / Kimi / …) are
|
DASHSCOPE_API_KEY: ${DASHSCOPE_API_KEY}
|
||||||
# NOT configured via env vars. After startup, add providers in the admin UI:
|
|
||||||
# Settings → Models → Add Provider
|
|
||||||
# Keys are stored in mate_model_provider and hot-reloaded.
|
|
||||||
SERPER_API_KEY: ${SERPER_API_KEY:-}
|
SERPER_API_KEY: ${SERPER_API_KEY:-}
|
||||||
TAVILY_API_KEY: ${TAVILY_API_KEY:-}
|
|
||||||
JWT_SECRET: ${JWT_SECRET:-}
|
|
||||||
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}
|
|
||||||
# Skill ZIP 上传/安装大小上限(MB)。解包过程整包缓存在内存里,
|
|
||||||
# max-total 调多大,单次安装的峰值内存就可能吃多大。
|
|
||||||
MATECLAW_SKILL_UPLOAD_MAX_ENTRY_SIZE_MB: ${MATECLAW_SKILL_UPLOAD_MAX_ENTRY_SIZE_MB:-1}
|
|
||||||
MATECLAW_SKILL_UPLOAD_MAX_TOTAL_SIZE_MB: ${MATECLAW_SKILL_UPLOAD_MAX_TOTAL_SIZE_MB:-50}
|
|
||||||
# pip 镜像源配置(可选)。skill 里的 Python 脚本缺包时 pip install 会走这个源。
|
|
||||||
# 留空则用 PyPI 默认源(pypi.org)。pip 原生读 PIP_INDEX_URL / PIP_TRUSTED_HOST
|
|
||||||
# 环境变量,容器内所有进程(JVM、Python 子进程、bash)自动继承,无需额外配置。
|
|
||||||
# HTTP 源会自动从 URL 推导 PIP_TRUSTED_HOST;自签 HTTPS 需手动填。
|
|
||||||
# - 互联网加速:PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
|
||||||
# - 局域网私有源:PIP_INDEX_URL=http://192.168.1.100:8080/simple(trusted-host 自动推导)
|
|
||||||
PIP_INDEX_URL: ${PIP_INDEX_URL:-}
|
|
||||||
PIP_TRUSTED_HOST: ${PIP_TRUSTED_HOST:-}
|
|
||||||
# Chromium needs a real /dev/shm. Docker defaults to 64MB which causes
|
|
||||||
# SIGBUS / "Target page closed" errors under load. 2GB is the usual
|
|
||||||
# recommendation for Playwright / headless chrome.
|
|
||||||
shm_size: 2gb
|
|
||||||
ports:
|
ports:
|
||||||
- "18080:18088" # host:container — app listens on 18088 inside the container
|
- "18080:18080"
|
||||||
- "1455:1455"
|
|
||||||
volumes:
|
volumes:
|
||||||
# server_data covers /app/data — wiki-uploads AND the skill workspace
|
|
||||||
# (MATECLAW_SKILL_WORKSPACE_ROOT=/app/data/skills above), so a single
|
|
||||||
# volume persists everything. No separate skills volume needed.
|
|
||||||
- server_data:/app/data
|
- server_data:/app/data
|
||||||
networks:
|
|
||||||
- mateclaw-net
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
mysql_data:
|
||||||
server_data:
|
server_data:
|
||||||
|
|
||||||
networks:
|
|
||||||
mateclaw-net:
|
|
||||||
driver: bridge
|
|
||||||
|
|||||||
@ -1,43 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# ============================================================================
|
|
||||||
# Create a least-privilege application role for the MateClaw server.
|
|
||||||
#
|
|
||||||
# Runs once, during first container init (empty data dir), as the bootstrap
|
|
||||||
# superuser (POSTGRES_USER) against POSTGRES_DB. The app role:
|
|
||||||
# - can log in and CONNECT to the database,
|
|
||||||
# - owns the `mateclaw` schema (so Flyway can create/alter tables in it),
|
|
||||||
# - is NOT a superuser and cannot touch other databases/roles.
|
|
||||||
#
|
|
||||||
# The server connects as APP_DB_USERNAME / APP_DB_PASSWORD.
|
|
||||||
# ============================================================================
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Pass credentials as psql variables (-v) rather than interpolating them into
|
|
||||||
# the SQL text. The quoted heredoc (<<'EOSQL') keeps the body literal, and psql
|
|
||||||
# does the quoting: :'var' -> safe string literal, :"var" -> safe identifier.
|
|
||||||
# CREATE ROLE is generated via format(%I, %L) + \gexec so a password containing
|
|
||||||
# a quote (or an exotic role name) can't break or inject into the statement.
|
|
||||||
psql -v ON_ERROR_STOP=1 \
|
|
||||||
--username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \
|
|
||||||
-v app_user="$APP_DB_USERNAME" \
|
|
||||||
-v app_pw="$APP_DB_PASSWORD" \
|
|
||||||
-v db="$POSTGRES_DB" <<'EOSQL'
|
|
||||||
SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'app_user', :'app_pw')
|
|
||||||
WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = :'app_user')
|
|
||||||
\gexec
|
|
||||||
|
|
||||||
-- CONNECT to use the database; CREATE so the role can create schemas in it.
|
|
||||||
-- CREATE is required because Flyway's init-sql runs CREATE SCHEMA IF NOT
|
|
||||||
-- EXISTS, and PostgreSQL checks the database-level CREATE privilege *before*
|
|
||||||
-- the IF NOT EXISTS short-circuit — so even a pre-existing schema is denied
|
|
||||||
-- without it. Still scoped to this one database; not a cluster superuser.
|
|
||||||
GRANT CONNECT, CREATE ON DATABASE :"db" TO :"app_user";
|
|
||||||
|
|
||||||
-- The app owns its schema so Flyway DDL works, without cluster superuser rights.
|
|
||||||
CREATE SCHEMA IF NOT EXISTS mateclaw AUTHORIZATION :"app_user";
|
|
||||||
|
|
||||||
-- Default to the app schema on every connection from this role.
|
|
||||||
ALTER ROLE :"app_user" SET search_path TO mateclaw, public;
|
|
||||||
EOSQL
|
|
||||||
|
|
||||||
echo "[init] application role '${APP_DB_USERNAME}' and schema 'mateclaw' ready"
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,117 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- 修复:知识库原始材料重复入库
|
|
||||||
-- 适用:MySQL 8.0+(使用 JSON 函数处理 source_raw_ids)
|
|
||||||
-- 说明:同一 (kb_id, source_path) 可能因文件内容变更
|
|
||||||
-- 被多次 INSERT 而形成多行。本脚本保留最新行,
|
|
||||||
-- 并级联清理其关联的 chunk、citation、page。
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
-- ──────────────────────────────────────────────────────────
|
|
||||||
-- STEP 0:预览(只读,不改数据,先跑这一步确认影响范围)
|
|
||||||
-- ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
-- 0-A:查看所有重复组(按 kb_id + source_path 分组,count > 1)
|
|
||||||
SELECT
|
|
||||||
kb_id,
|
|
||||||
source_path,
|
|
||||||
COUNT(*) AS duplicate_count,
|
|
||||||
MAX(id) AS keep_id,
|
|
||||||
GROUP_CONCAT(id ORDER BY id DESC) AS all_ids
|
|
||||||
FROM mate_wiki_raw_material
|
|
||||||
WHERE source_path IS NOT NULL
|
|
||||||
GROUP BY kb_id, source_path
|
|
||||||
HAVING COUNT(*) > 1;
|
|
||||||
|
|
||||||
-- 0-B:查看待删除的具体行(排除每组最新的那一行)
|
|
||||||
SELECT
|
|
||||||
r.id, r.kb_id, r.source_path,
|
|
||||||
r.content_hash, r.processing_status, r.create_time
|
|
||||||
FROM mate_wiki_raw_material r
|
|
||||||
WHERE r.source_path IS NOT NULL
|
|
||||||
AND r.id NOT IN (
|
|
||||||
SELECT MAX(id)
|
|
||||||
FROM mate_wiki_raw_material
|
|
||||||
WHERE source_path IS NOT NULL
|
|
||||||
GROUP BY kb_id, source_path
|
|
||||||
)
|
|
||||||
ORDER BY r.kb_id, r.source_path, r.id;
|
|
||||||
|
|
||||||
|
|
||||||
-- ──────────────────────────────────────────────────────────
|
|
||||||
-- STEP 1:开事务,执行清理(确认 STEP 0 结果后再运行)
|
|
||||||
-- ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
START TRANSACTION;
|
|
||||||
|
|
||||||
-- 1-A:把待删除的 raw id 暂存到临时表,后续步骤复用
|
|
||||||
CREATE TEMPORARY TABLE IF NOT EXISTS _stale_raw_ids AS
|
|
||||||
SELECT id AS raw_id, kb_id
|
|
||||||
FROM mate_wiki_raw_material
|
|
||||||
WHERE source_path IS NOT NULL
|
|
||||||
AND id NOT IN (
|
|
||||||
SELECT MAX(id)
|
|
||||||
FROM mate_wiki_raw_material
|
|
||||||
WHERE source_path IS NOT NULL
|
|
||||||
GROUP BY kb_id, source_path
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 1-B:删除这些 raw 产生的 citation(通过 chunk_id 关联)
|
|
||||||
DELETE c
|
|
||||||
FROM mate_wiki_page_citation c
|
|
||||||
INNER JOIN mate_wiki_chunk ch ON c.chunk_id = ch.id
|
|
||||||
INNER JOIN _stale_raw_ids s ON ch.raw_id = s.raw_id;
|
|
||||||
|
|
||||||
-- 1-C:删除 chunk
|
|
||||||
DELETE ch
|
|
||||||
FROM mate_wiki_chunk ch
|
|
||||||
INNER JOIN _stale_raw_ids s ON ch.raw_id = s.raw_id;
|
|
||||||
|
|
||||||
-- 1-D:删除仅由该 raw 派生的 page(source_raw_ids 数组长度为 1)
|
|
||||||
-- 使用 JSON_CONTAINS 判断 page 是否引用了待删 raw
|
|
||||||
DELETE p
|
|
||||||
FROM mate_wiki_page p
|
|
||||||
WHERE JSON_LENGTH(p.source_raw_ids) = 1
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM _stale_raw_ids s
|
|
||||||
WHERE JSON_CONTAINS(p.source_raw_ids, CAST(s.raw_id AS CHAR))
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 1-E:对多来源 page,将待删 raw 从 source_raw_ids 中移除
|
|
||||||
-- 通过 JSON_TABLE 把数组展开再重组,排除掉 stale raw id
|
|
||||||
UPDATE mate_wiki_page p
|
|
||||||
SET p.source_raw_ids = (
|
|
||||||
SELECT JSON_ARRAYAGG(jt.v)
|
|
||||||
FROM JSON_TABLE(p.source_raw_ids, '$[*]' COLUMNS (v BIGINT PATH '$')) jt
|
|
||||||
WHERE jt.v NOT IN (SELECT raw_id FROM _stale_raw_ids)
|
|
||||||
)
|
|
||||||
WHERE JSON_LENGTH(p.source_raw_ids) > 1
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM _stale_raw_ids s
|
|
||||||
WHERE JSON_CONTAINS(p.source_raw_ids, CAST(s.raw_id AS CHAR))
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 1-F:删除 stale raw 行
|
|
||||||
DELETE r
|
|
||||||
FROM mate_wiki_raw_material r
|
|
||||||
INNER JOIN _stale_raw_ids s ON r.id = s.raw_id;
|
|
||||||
|
|
||||||
-- 1-G:确认结果
|
|
||||||
SELECT
|
|
||||||
'stale raws deleted' AS action,
|
|
||||||
ROW_COUNT() AS affected_rows;
|
|
||||||
|
|
||||||
SELECT
|
|
||||||
'remaining duplicates' AS check_item,
|
|
||||||
COUNT(*) AS count
|
|
||||||
FROM mate_wiki_raw_material
|
|
||||||
WHERE source_path IS NOT NULL
|
|
||||||
GROUP BY kb_id, source_path
|
|
||||||
HAVING COUNT(*) > 1;
|
|
||||||
|
|
||||||
-- 确认无误后提交;如有问题改为 ROLLBACK
|
|
||||||
COMMIT;
|
|
||||||
-- ROLLBACK;
|
|
||||||
|
|
||||||
DROP TEMPORARY TABLE IF EXISTS _stale_raw_ids;
|
|
||||||
@ -1,144 +0,0 @@
|
|||||||
# 插件化搜索 Provider + 搜索设置页重构 设计文档
|
|
||||||
|
|
||||||
日期:2026-07-03
|
|
||||||
状态:待评审
|
|
||||||
相关:`vip.mate.tool.search`(现有搜索 provider 链)、`mateclaw-plugin-api`(插件 SDK)、`/settings/system` 搜索设置区块
|
|
||||||
|
|
||||||
## 1. 背景与问题
|
|
||||||
|
|
||||||
### 1.1 自定义搜索 provider 没有插件化路径
|
|
||||||
|
|
||||||
当前 `SearchProviderRegistry` 通过 Spring 构造器注入 `List<SearchProvider>` 收集 provider,只认同一 `ApplicationContext` 里的 bean。要新增一个搜索源,唯一办法是**在 `vip.mate.tool.search` 源码树里加 `@Component` 类并重新编译部署整个 server**。
|
|
||||||
|
|
||||||
而项目已有一套真正的运行时插件系统(`mateclaw-plugin-api` + `PluginManager`):独立 jar 丢进 `~/.mateclaw/plugins/` 或工作区 `plugins/`,`URLClassLoader` 隔离加载,支持运行时 enable/disable,配置走 manifest 声明的 schema(`mateclaw-plugin.json` 的 `config` 字段)+ `plugin` 表 `config_json` 持久化 + `PUT /api/v1/plugins/{name}/config` 接口。但 `PluginType` 只有 `TOOL / PROVIDER(LLM) / CHANNEL / MEMORY` 四类,**没有 SEARCH**,`PluginContext` 也没有对应注册方法。
|
|
||||||
|
|
||||||
LLM provider 已有"内置 `@Component` 链 + 插件注册表"双轨并存的先例(`ModelProviderService.pluginChatModels`),搜索 provider 缺的就是同构的第二轨。
|
|
||||||
|
|
||||||
### 1.2 搜索设置 UI 平铺、下拉菜单硬编码
|
|
||||||
|
|
||||||
`/settings/system` 的搜索区块把 4 个 provider 的开关/key/url 共 9 个配置项拍平在一个列表里;主 provider 下拉菜单是写死的两个 `<option>`(serper/tavily),`searxng`/`duckduckgo` 无法显式选中,只能靠后端自动探测兜底;管理员也无法看到"当前实际生效的是哪个 provider"。
|
|
||||||
|
|
||||||
### 1.3 插件配置表单缺失(前端)
|
|
||||||
|
|
||||||
后端 `PluginInfo` 已返回 `configSchema`(来自 manifest)和脱敏后的 `currentConfig`,`updateConfig()` 已有 schema 白名单 + required 校验,前端 `pluginApi.updateConfig` 客户端也已存在——但 `Plugins.vue` 没有任何配置编辑 UI,这条链路在前端是死代码。所有类型的插件目前都无法在界面上配置。
|
|
||||||
|
|
||||||
## 2. 目标 / 非目标
|
|
||||||
|
|
||||||
**目标**
|
|
||||||
1. 第三方以独立 jar 形式提供搜索 provider:实现 SDK 接口 + manifest 声明,丢进 plugins 目录即用,**mateclaw-server 源码零改动**。
|
|
||||||
2. 搜索设置页:主 provider 选择动态化(含插件 provider 与"自动选择")、按 provider 分组折叠、显示当前实际生效的 provider。
|
|
||||||
3. 补上 schema 驱动的插件配置表单(服务所有插件类型,不只 search)。
|
|
||||||
|
|
||||||
**非目标**
|
|
||||||
- 不改内置 4 个 provider 的配置存储方式(继续走 `SystemSettingsDTO` / `mate_system_setting`)。
|
|
||||||
- 不删除、不重命名 `GET/PUT /api/v1/settings` 现有字段(无破坏性改动)。
|
|
||||||
- 不做搜索结果聚合/多 provider 并发查询。
|
|
||||||
|
|
||||||
## 3. 设计
|
|
||||||
|
|
||||||
### 3.1 SDK 侧(`mateclaw-plugin-api`)
|
|
||||||
|
|
||||||
新增 `vip.mate.plugin.api.search` 包,接口**不依赖任何 server 类**(jar 隔离加载下的硬约束;对比核心 `SearchProvider` 依赖 `SystemSettingsDTO`,SDK 版必须自包含):
|
|
||||||
|
|
||||||
```java
|
|
||||||
public interface PluginSearchProvider {
|
|
||||||
String id(); // 全局唯一,如 "my-search"
|
|
||||||
String label(); // 显示名
|
|
||||||
default boolean requiresCredential() { return true; }
|
|
||||||
default int autoDetectOrder() { return 500; } // 默认排在内置 provider(50~400)之后
|
|
||||||
boolean isAvailable(); // 插件自查:如 context.getConfig 拿 key 判空
|
|
||||||
List<PluginSearchResult> search(PluginSearchQuery query);
|
|
||||||
}
|
|
||||||
|
|
||||||
public record PluginSearchQuery(String query, String freshness, String language, Integer count) {}
|
|
||||||
public record PluginSearchResult(String title, String url, String snippet, String source, String date) {}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `PluginType` 增加 `SEARCH`。
|
|
||||||
- `PluginContext` 增加 `void registerSearchProvider(PluginSearchProvider provider);`。
|
|
||||||
(接口新增方法对已编译的存量插件无影响——它们不调用即可。)
|
|
||||||
- 插件的配置(API key 等)**不进搜索设置页**,走插件系统自己的机制:manifest `config` 声明 schema,运行时 `context.getConfig(key, type)` 读取。职责天然分离:搜索设置页只管"选谁",插件页管"配它"。
|
|
||||||
|
|
||||||
### 3.2 Server 桥接侧
|
|
||||||
|
|
||||||
**`bridge/PluginSearchBridge.java`**(模式照抄 `PluginChannelBridge`):把 `PluginSearchProvider` 适配成核心 `SearchProvider`:
|
|
||||||
- `search(SearchQuery, SystemSettingsDTO)` → 转调插件 `search(PluginSearchQuery)`,忽略 DTO;
|
|
||||||
- 结果转核心 `SearchResult`,`providerId` 填插件 provider id;
|
|
||||||
- `isAvailable(SystemSettingsDTO)` → 委托插件无参 `isAvailable()`;
|
|
||||||
- 插件抛出的异常原样上抛(`WebSearchService.tryProvider()` 已有 catch-and-fallback 语义)。
|
|
||||||
|
|
||||||
**`SearchProviderRegistry` 可变化**:从"构造时定死的 immutable list"改为两层合并视图:
|
|
||||||
- 基底:Spring 注入的内置 provider(不变);
|
|
||||||
- 插件区:`ConcurrentHashMap<String, SearchProvider>`,新增 `registerPluginProvider(SearchProvider)` / `unregisterPluginProvider(String id)`;
|
|
||||||
- `allSorted()` / `getById()` / `resolve()` 全部查合并视图,排序仍按 `autoDetectOrder`;
|
|
||||||
- **id 冲突拒绝注册**(插件 id 与内置或已注册插件 id 重复时抛 `PluginException`,不允许顶掉 serper 等内置项)。
|
|
||||||
|
|
||||||
**生命周期**(与现有四类完全对称):
|
|
||||||
- `PluginContextImpl.registerSearchProvider()` → 包 bridge 后调 registry 注册,记录到 `LoadedPlugin`;
|
|
||||||
- `disablePlugin()` 与加载失败 rollback 路径各加一个 `searchProviderRegistry.unregisterPluginProvider(...)`(best-effort,同现有风格);
|
|
||||||
- 插件被 disable 后,若它正是 `searchProvider` 显式指定项,`resolve()` 因 `getById()` 查不到而自动落入 auto-detect 分支——行为安全,无需额外处理。
|
|
||||||
|
|
||||||
### 3.3 动态 provider catalog 接口
|
|
||||||
|
|
||||||
`GET /api/v1/settings/search-providers`(`SystemSettingController`,`@RequireWorkspaceRole("admin")`),只读:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"providers": [
|
|
||||||
{ "id": "serper", "label": "Serper (Google)", "builtin": true, "requiresCredential": true, "available": false },
|
|
||||||
{ "id": "my-search","label": "My Search", "builtin": false, "requiresCredential": true, "available": true,
|
|
||||||
"pluginName": "my-search-plugin" }
|
|
||||||
],
|
|
||||||
"resolved": { "id": "my-search", "source": "configured" }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- 数据源:`SearchProviderRegistry.allSorted()`(合并视图,插件 provider 自动出现)+ `resolve(config)`(暴露"当前实际生效"与原因:`configured` / `auto-detect` / `keyless-fallback`)。
|
|
||||||
- `pluginName` 供前端渲染"去插件页配置"跳转。
|
|
||||||
- 不含任何敏感值。
|
|
||||||
|
|
||||||
### 3.4 搜索设置页重构(`views/Settings/System/index.vue`)
|
|
||||||
|
|
||||||
- **主 provider 选择**:选项从 catalog 接口动态渲染,新增首项"自动选择(推荐)"——对应 `searchProvider=""`(后端 `resolve()` 对空值本就走 auto-detect,无需引入 `"auto"` 特殊值)。下方常驻一行状态提示:`✓ 当前实际生效: Xxx(原因)`。
|
|
||||||
- **分组折叠卡片**:每个 provider 一张可折叠卡片,标题行 = 名称 + 徽标(已配置/未配置/生效中),默认只展开"当前生效"的那张。
|
|
||||||
- 内置 provider:卡片内是现有的 key/url 输入框(字段与保存逻辑不变,仍走 `PUT /api/v1/settings`);
|
|
||||||
- 插件 provider:卡片内不放表单,显示"该 Provider 由插件 {pluginName} 提供,请在插件页配置" + 跳转链接。
|
|
||||||
- 现有保存语义不变(API key 仅在用户输入新值时提交)。
|
|
||||||
|
|
||||||
### 3.5 插件配置表单(`views/Plugins.vue`,纯前端)
|
|
||||||
|
|
||||||
插件卡片增加"配置"入口(有 `configSchema` 时显示),弹出 schema 驱动的通用表单:
|
|
||||||
- 按 `configSchema` 渲染字段:`secret=true` → password 输入框(placeholder 显示脱敏值,留空表示不修改);其余按 `type` 渲染 text/number/boolean;`required` 标星并做前端必填校验(后端已有兜底校验);`description` 作为字段提示。
|
|
||||||
- 提交走已存在的 `pluginApi.updateConfig`;保存后刷新列表。
|
|
||||||
- 该表单对所有 `PluginType` 通用,非 search 专属。
|
|
||||||
- 注意:manifest `ConfigField.type` 是自由字符串,前端对未知 type 一律降级为 text 输入。
|
|
||||||
|
|
||||||
### 3.6 参考实现(`mateclaw-plugin-sample`)
|
|
||||||
|
|
||||||
sample 模块增加一个最小 `PluginSearchProvider` 实现(如包装一个可配 baseUrl+apiKey 的通用 HTTP 搜索 API),manifest 声明 `type: "search"` + config schema——同时充当文档示例与集成测试素材。
|
|
||||||
|
|
||||||
## 4. 交付拆分(遵循上游单一关注点规范)
|
|
||||||
|
|
||||||
- **上游 issue 先行**:动手前在 mateaix/mateclaw 提 issue 说明设计(本文档摘要),获认可后实施。
|
|
||||||
- **PR-1(后端 + SDK)**:`PluginType.SEARCH` + SDK 接口/record + `PluginSearchBridge` + registry 可变化 + `PluginContextImpl`/`PluginManager` 生命周期 + sample 参考实现 + 单测。
|
|
||||||
- **PR-2(接口 + 前端)**:catalog 接口 + 搜索设置页分组折叠重构 + Plugins.vue schema 配置表单。PR-2 不依赖 PR-1 合并(catalog 对纯内置 provider 同样成立),但先后合并时插件 provider 自动出现在下拉中。
|
|
||||||
|
|
||||||
## 5. 测试
|
|
||||||
|
|
||||||
**PR-1**
|
|
||||||
- registry:注册/反注册/合并排序/`resolve()` 三分支含插件项/id 冲突拒绝。
|
|
||||||
- bridge:`SearchQuery`↔`PluginSearchQuery`、`SearchResult` 转换、异常透传。
|
|
||||||
- 生命周期:disable 后 registry 查不到该 id;显式指定的插件 provider 被 disable 后 resolve 落回 auto-detect。
|
|
||||||
- sample 插件 jar 端到端:打包 → 放插件目录 → 启动加载 → `getAllToolCallbacks` 路径外单独验证 `web_search` 走插件 provider。
|
|
||||||
|
|
||||||
**PR-2**
|
|
||||||
- catalog 接口:内置/插件混合列表、resolved 三种 source、无敏感值泄露。
|
|
||||||
- 前端:下拉动态渲染、"自动选择"存空串、折叠展开状态、secret 字段留空不覆盖。
|
|
||||||
|
|
||||||
## 6. 兼容性与风险
|
|
||||||
|
|
||||||
- 存量插件:`PluginType` 加枚举值 + `PluginContext` 加方法,均为增量,不影响已编译插件。
|
|
||||||
- `GET/PUT /api/v1/settings` 字段不动,旧前端/脚本不受影响。
|
|
||||||
- `SearchProviderRegistry` 由不可变转可变:并发读多写少,`ConcurrentHashMap` + 每次读时合并排序(provider 总数 <10,无性能顾虑)。
|
|
||||||
- 插件 provider 质量不可控:`WebSearchService` 现有 15s 超时属于各 provider 自身实现,插件侧超时由插件自负;catch-and-fallback 链保证坏插件不拖垮搜索功能(最多浪费一次尝试)。
|
|
||||||
- 安全:插件 jar 本身即任意代码执行(现有插件系统的既定信任模型,本设计不扩大攻击面);catalog 接口仅 admin 可见。
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
# 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
@ -1,33 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,275 +0,0 @@
|
|||||||
# 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 安装。
|
|
||||||
@ -1,432 +0,0 @@
|
|||||||
# 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 运行时
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
<?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>
|
|
||||||
|
Before Width: | Height: | Size: 241 KiB |
|
Before Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 241 KiB |
|
Before Width: | Height: | Size: 52 KiB |
@ -1,139 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
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 })
|
|
||||||
}
|
|
||||||
@ -1,205 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,82 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
@ -1,122 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
@ -1,194 +0,0 @@
|
|||||||
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))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
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)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "mateclaw-desktop",
|
|
||||||
"version": "2.3.0-SNAPSHOT",
|
|
||||||
"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
|
Before Width: | Height: | Size: 67 KiB |
@ -1,170 +0,0 @@
|
|||||||
# MateClaw Desktop - Build & Publish Scripts
|
|
||||||
|
|
||||||
## Scripts
|
|
||||||
|
|
||||||
| Script | Description |
|
|
||||||
|--------|-------------|
|
|
||||||
| `build.sh` | Build backend JAR (frontend + Spring Boot) |
|
|
||||||
| `download-jre.sh` | Download Adoptium JRE 21 for target platform |
|
|
||||||
| `build-all-platforms.sh` | One-click build for all platforms (macOS + Windows) |
|
|
||||||
| `publish-github.sh` | Publish release artifacts to GitHub Releases |
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### Full Build + Publish (recommended)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd mateclaw-desktop
|
|
||||||
|
|
||||||
# Step 1: Build for all platforms (no publish)
|
|
||||||
bash scripts/build-all-platforms.sh --all
|
|
||||||
|
|
||||||
# Step 2: Publish to GitHub (via proxy)
|
|
||||||
bash scripts/publish-github.sh --proxy
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step-by-Step Build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd mateclaw-desktop
|
|
||||||
|
|
||||||
# 1. Build backend JAR (frontend + Spring Boot)
|
|
||||||
bash scripts/build.sh
|
|
||||||
|
|
||||||
# 2. Download JRE for target platform
|
|
||||||
bash scripts/download-jre.sh mac-arm64 # Apple Silicon
|
|
||||||
bash scripts/download-jre.sh mac-x64 # Intel Mac
|
|
||||||
bash scripts/download-jre.sh win-x64 # Windows x64
|
|
||||||
bash scripts/download-jre.sh win-arm64 # Windows ARM
|
|
||||||
|
|
||||||
# 3. Build frontend and package
|
|
||||||
npm run build
|
|
||||||
npx electron-builder --mac # macOS
|
|
||||||
npx electron-builder --win --x64 # Windows x64
|
|
||||||
npx electron-builder --win --arm64 # Windows ARM
|
|
||||||
```
|
|
||||||
|
|
||||||
## build.sh
|
|
||||||
|
|
||||||
Build the backend JAR, includes three steps:
|
|
||||||
|
|
||||||
1. Build Vue 3 frontend to `mateclaw-server/src/main/resources/static`
|
|
||||||
2. Package Spring Boot fat JAR via Maven
|
|
||||||
3. Copy JAR to `mateclaw-desktop/resources/app.jar`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash scripts/build.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
**Prerequisites:** Node.js (pnpm or npm), Maven (or mvnw)
|
|
||||||
|
|
||||||
## download-jre.sh
|
|
||||||
|
|
||||||
Download Adoptium JRE 21 for the target platform.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Auto-detect current platform
|
|
||||||
bash scripts/download-jre.sh
|
|
||||||
|
|
||||||
# Specify platform
|
|
||||||
bash scripts/download-jre.sh mac-arm64
|
|
||||||
bash scripts/download-jre.sh mac-x64
|
|
||||||
bash scripts/download-jre.sh win-x64
|
|
||||||
bash scripts/download-jre.sh win-arm64
|
|
||||||
```
|
|
||||||
|
|
||||||
JRE will be saved to `resources/jre/{os}-{arch}/`.
|
|
||||||
|
|
||||||
## build-all-platforms.sh
|
|
||||||
|
|
||||||
Orchestrates the full build pipeline: JAR build -> JRE download -> frontend compile -> electron-builder package.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash scripts/build-all-platforms.sh --all # macOS + Windows
|
|
||||||
bash scripts/build-all-platforms.sh --mac-only # macOS only
|
|
||||||
bash scripts/build-all-platforms.sh --win-only # Windows only
|
|
||||||
```
|
|
||||||
|
|
||||||
Build artifacts are output to `release/` directory.
|
|
||||||
|
|
||||||
## publish-github.sh
|
|
||||||
|
|
||||||
Publish build artifacts from `release/` to GitHub Releases via `gh` CLI.
|
|
||||||
|
|
||||||
### Basic Usage
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash scripts/publish-github.sh # Direct upload
|
|
||||||
bash scripts/publish-github.sh --draft # Create draft release
|
|
||||||
bash scripts/publish-github.sh --tag=v1.1.0 # Custom tag
|
|
||||||
```
|
|
||||||
|
|
||||||
### With Proxy (for slow or restricted networks)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash scripts/publish-github.sh --proxy # Default proxy: 127.0.0.1:7890
|
|
||||||
bash scripts/publish-github.sh --proxy=192.168.1.1:8080 # Custom proxy
|
|
||||||
bash scripts/publish-github.sh --proxy --draft # Draft + proxy
|
|
||||||
```
|
|
||||||
|
|
||||||
### Retry Failed Upload
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash scripts/publish-github.sh --proxy --retry # Delete old release, re-upload
|
|
||||||
bash scripts/publish-github.sh --proxy --retry --draft # Retry as draft
|
|
||||||
```
|
|
||||||
|
|
||||||
### All Options
|
|
||||||
|
|
||||||
| Option | Description |
|
|
||||||
|--------|-------------|
|
|
||||||
| `--proxy` | Use proxy `127.0.0.1:7890` for GitHub upload |
|
|
||||||
| `--proxy=host:port` | Use custom proxy address |
|
|
||||||
| `--draft` | Create as draft release |
|
|
||||||
| `--tag=vX.Y.Z` | Custom tag (default: `v{version}` from package.json) |
|
|
||||||
| `--retry` | Delete existing release for this tag before re-uploading |
|
|
||||||
|
|
||||||
**Prerequisites:** [gh CLI](https://cli.github.com/) installed and authenticated (`gh auth login`)
|
|
||||||
|
|
||||||
## npm Scripts
|
|
||||||
|
|
||||||
These scripts are also available as npm commands:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run setup:jar # build.sh
|
|
||||||
npm run setup:jre # download-jre.sh
|
|
||||||
npm run setup # build.sh + download-jre.sh
|
|
||||||
npm run package:mac # Build frontend + electron-builder --mac
|
|
||||||
npm run package:win # Build frontend + electron-builder --win
|
|
||||||
npm run package:all # build-all-platforms.sh --all
|
|
||||||
npm run publish:github # publish-github.sh
|
|
||||||
npm run publish:github:draft # publish-github.sh --draft
|
|
||||||
```
|
|
||||||
|
|
||||||
## Typical Workflow
|
|
||||||
|
|
||||||
```
|
|
||||||
build.sh download-jre.sh
|
|
||||||
| |
|
|
||||||
v v
|
|
||||||
resources/app.jar resources/jre/{os}-{arch}/
|
|
||||||
| |
|
|
||||||
+--------- electron-builder ---+
|
|
||||||
|
|
|
||||||
v
|
|
||||||
release/
|
|
||||||
├── MateClaw_1.0.0_arm64.dmg
|
|
||||||
├── MateClaw_1.0.0_x64.dmg
|
|
||||||
├── MateClaw_1.0.0_arm64.zip
|
|
||||||
├── MateClaw_1.0.0_x64.zip
|
|
||||||
├── MateClaw_1.0.0_arm64_Setup.exe
|
|
||||||
├── MateClaw_1.0.0_x64_Setup.exe
|
|
||||||
├── latest-mac.yml
|
|
||||||
└── latest.yml
|
|
||||||
|
|
|
||||||
v
|
|
||||||
publish-github.sh
|
|
||||||
|
|
|
||||||
v
|
|
||||||
GitHub Releases
|
|
||||||
```
|
|
||||||
@ -1,151 +0,0 @@
|
|||||||
const assert = require('node:assert/strict')
|
|
||||||
const fs = require('node:fs')
|
|
||||||
const os = require('node:os')
|
|
||||||
const path = require('node:path')
|
|
||||||
const test = require('node:test')
|
|
||||||
|
|
||||||
const {
|
|
||||||
createZipBuffer,
|
|
||||||
readZipEntries,
|
|
||||||
resolveDriverDirectory,
|
|
||||||
trimDriverBundleInAppJar,
|
|
||||||
ZIP_STORED,
|
|
||||||
default: afterPack,
|
|
||||||
} = require('../trim-playwright-driver.cjs')
|
|
||||||
|
|
||||||
test('resolveDriverDirectory maps electron platform and arch to Playwright driver folder', () => {
|
|
||||||
assert.equal(resolveDriverDirectory('darwin', 'x64'), 'driver/mac')
|
|
||||||
assert.equal(resolveDriverDirectory('darwin', 'arm64'), 'driver/mac-arm64')
|
|
||||||
assert.equal(resolveDriverDirectory('linux', 'x64'), 'driver/linux')
|
|
||||||
assert.equal(resolveDriverDirectory('linux', 'arm64'), 'driver/linux-arm64')
|
|
||||||
assert.equal(resolveDriverDirectory('win32', 'x64'), 'driver/win32_x64')
|
|
||||||
assert.equal(resolveDriverDirectory('win32', 'arm64'), 'driver/win32_x64')
|
|
||||||
assert.deepEqual(resolveDriverDirectory('darwin', 'universal'), ['driver/mac', 'driver/mac-arm64'])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('trimDriverBundleInAppJar keeps only the target driver and preserves nested jar as STORED', async () => {
|
|
||||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mateclaw-driver-trim-'))
|
|
||||||
const appJarPath = path.join(tmp, 'app.jar')
|
|
||||||
|
|
||||||
const driverBundle = createZipBuffer([
|
|
||||||
entry('META-INF/MANIFEST.MF', 'Manifest-Version: 1.0\n'),
|
|
||||||
entry('driver/mac/node', 'mac'),
|
|
||||||
entry('driver/mac-arm64/node', 'mac-arm64'),
|
|
||||||
entry('driver/linux/node', 'linux'),
|
|
||||||
entry('driver/linux-arm64/node', 'linux-arm64'),
|
|
||||||
entry('driver/win32_x64/node.exe', 'win32'),
|
|
||||||
entry('com/microsoft/playwright/Driver.class', 'class'),
|
|
||||||
])
|
|
||||||
|
|
||||||
const appJar = createZipBuffer([
|
|
||||||
entry('BOOT-INF/classpath.idx', '- "BOOT-INF/lib/driver-bundle-1.52.0.jar"\n'),
|
|
||||||
entry('BOOT-INF/lib/driver-bundle-1.52.0.jar', driverBundle, ZIP_STORED),
|
|
||||||
entry('BOOT-INF/lib/other.jar', 'other', ZIP_STORED),
|
|
||||||
])
|
|
||||||
|
|
||||||
fs.writeFileSync(appJarPath, appJar)
|
|
||||||
|
|
||||||
const result = await trimDriverBundleInAppJar(appJarPath, 'driver/mac-arm64')
|
|
||||||
assert.equal(result.removedDriverEntries, 4)
|
|
||||||
|
|
||||||
const outerEntries = readZipEntries(fs.readFileSync(appJarPath))
|
|
||||||
const nestedEntry = outerEntries.find((item) => item.name === 'BOOT-INF/lib/driver-bundle-1.52.0.jar')
|
|
||||||
assert.equal(nestedEntry.method, ZIP_STORED)
|
|
||||||
|
|
||||||
const innerEntries = readZipEntries(nestedEntry.data)
|
|
||||||
const names = innerEntries.map((item) => item.name).sort()
|
|
||||||
|
|
||||||
assert.deepEqual(names, [
|
|
||||||
'META-INF/MANIFEST.MF',
|
|
||||||
'com/microsoft/playwright/Driver.class',
|
|
||||||
'driver/mac-arm64/node',
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('trimDriverBundleInAppJar throws when driver-bundle is missing', async () => {
|
|
||||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mateclaw-driver-trim-'))
|
|
||||||
const appJarPath = path.join(tmp, 'app.jar')
|
|
||||||
|
|
||||||
fs.writeFileSync(appJarPath, createZipBuffer([
|
|
||||||
entry('BOOT-INF/lib/other.jar', 'other', ZIP_STORED),
|
|
||||||
]))
|
|
||||||
|
|
||||||
await assert.rejects(
|
|
||||||
() => trimDriverBundleInAppJar(appJarPath, 'driver/mac-arm64'),
|
|
||||||
/Playwright driver-bundle jar not found/
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('trimDriverBundleInAppJar throws when no target driver entries are kept', async () => {
|
|
||||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mateclaw-driver-trim-'))
|
|
||||||
const appJarPath = path.join(tmp, 'app.jar')
|
|
||||||
fs.writeFileSync(appJarPath, createAppJarWithDriverBundle())
|
|
||||||
|
|
||||||
await assert.rejects(
|
|
||||||
() => trimDriverBundleInAppJar(appJarPath, 'driver/mac-arm64-v2'),
|
|
||||||
/No entries kept under driver\/mac-arm64-v2/
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('trimDriverBundleInAppJar can keep both mac drivers for universal builds', async () => {
|
|
||||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mateclaw-driver-trim-'))
|
|
||||||
const appJarPath = path.join(tmp, 'app.jar')
|
|
||||||
fs.writeFileSync(appJarPath, createAppJarWithDriverBundle())
|
|
||||||
|
|
||||||
const result = await trimDriverBundleInAppJar(appJarPath, ['driver/mac', 'driver/mac-arm64'])
|
|
||||||
assert.equal(result.keptDriverEntries, 2)
|
|
||||||
|
|
||||||
const outerEntries = readZipEntries(fs.readFileSync(appJarPath))
|
|
||||||
const nestedEntry = outerEntries.find((item) => item.name === 'BOOT-INF/lib/driver-bundle-1.52.0.jar')
|
|
||||||
const driverNames = readZipEntries(nestedEntry.data)
|
|
||||||
.filter((item) => item.name.startsWith('driver/'))
|
|
||||||
.map((item) => item.name)
|
|
||||||
.sort()
|
|
||||||
|
|
||||||
assert.deepEqual(driverNames, [
|
|
||||||
'driver/mac-arm64/node',
|
|
||||||
'driver/mac/node',
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('afterPack throws when app.jar is missing', async () => {
|
|
||||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mateclaw-driver-trim-'))
|
|
||||||
|
|
||||||
await assert.rejects(
|
|
||||||
() => afterPack({
|
|
||||||
appOutDir: tmp,
|
|
||||||
arch: 'arm64',
|
|
||||||
electronPlatformName: 'darwin',
|
|
||||||
packager: {
|
|
||||||
appInfo: { productFilename: 'MateClaw' },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
/app\.jar not found/
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
function entry(name, data, method) {
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
data: Buffer.isBuffer(data) ? data : Buffer.from(data),
|
|
||||||
method,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createAppJarWithDriverBundle() {
|
|
||||||
const driverBundle = createZipBuffer([
|
|
||||||
entry('META-INF/MANIFEST.MF', 'Manifest-Version: 1.0\n'),
|
|
||||||
entry('driver/mac/node', 'mac'),
|
|
||||||
entry('driver/mac-arm64/node', 'mac-arm64'),
|
|
||||||
entry('driver/linux/node', 'linux'),
|
|
||||||
entry('driver/linux-arm64/node', 'linux-arm64'),
|
|
||||||
entry('driver/win32_x64/node.exe', 'win32'),
|
|
||||||
entry('com/microsoft/playwright/Driver.class', 'class'),
|
|
||||||
])
|
|
||||||
|
|
||||||
return createZipBuffer([
|
|
||||||
entry('BOOT-INF/classpath.idx', '- "BOOT-INF/lib/driver-bundle-1.52.0.jar"\n'),
|
|
||||||
entry('BOOT-INF/lib/driver-bundle-1.52.0.jar', driverBundle, ZIP_STORED),
|
|
||||||
entry('BOOT-INF/lib/other.jar', 'other', ZIP_STORED),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
@ -1,153 +0,0 @@
|
|||||||
/**
|
|
||||||
* scripts/branding.cjs — Vite plugin for build-time white-label branding.
|
|
||||||
*
|
|
||||||
* Reads brand settings from branding.config.json (or BRAND_* env overrides)
|
|
||||||
* and replaces hardcoded "MateClaw" strings in all built files — source code
|
|
||||||
* stays untouched.
|
|
||||||
*
|
|
||||||
* Supported env overrides:
|
|
||||||
* BRAND_NAME, BRAND_TAGLINE, BRAND_TEAM, BRAND_COPYRIGHT,
|
|
||||||
* BRAND_APP_ID, BRAND_GITHUB_URL, BRAND_LOGO_FILE
|
|
||||||
*/
|
|
||||||
'use strict'
|
|
||||||
|
|
||||||
const fs = require('fs')
|
|
||||||
const path = require('path')
|
|
||||||
|
|
||||||
function loadBrandConfig(rootDir) {
|
|
||||||
const configPath = path.join(rootDir, 'branding.config.json')
|
|
||||||
let config = {}
|
|
||||||
if (fs.existsSync(configPath)) {
|
|
||||||
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Env vars override the config file.
|
|
||||||
const env = process.env
|
|
||||||
return {
|
|
||||||
name: env.BRAND_NAME || config.name || 'MateClaw',
|
|
||||||
tagline: env.BRAND_TAGLINE || config.tagline || 'AI Personal Assistant',
|
|
||||||
team: env.BRAND_TEAM || config.team || 'MateClaw Team',
|
|
||||||
copyright: env.BRAND_COPYRIGHT || config.copyright || 'Copyright © 2026 MateClaw Team',
|
|
||||||
appId: env.BRAND_APP_ID || config.appId || 'vip.mate.mateclaw',
|
|
||||||
githubUrl: env.BRAND_GITHUB_URL || config.githubUrl || 'https://github.com/matevip/mateclaw',
|
|
||||||
logoFile: env.BRAND_LOGO_FILE || config.logoFile || 'mateclaw_logo_s.png',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the string-replacement table.
|
|
||||||
*
|
|
||||||
* Order matters: longer/more-specific patterns are replaced first to avoid
|
|
||||||
* partial matches (e.g. "MateClaw Team" before "MateClaw").
|
|
||||||
*/
|
|
||||||
function buildReplacements(brand) {
|
|
||||||
const replacements = []
|
|
||||||
|
|
||||||
// 1. Copyright line (most specific)
|
|
||||||
replacements.push([
|
|
||||||
'Copyright © 2026 MateClaw Team',
|
|
||||||
brand.copyright,
|
|
||||||
])
|
|
||||||
|
|
||||||
// 2. Team name
|
|
||||||
replacements.push(['MateClaw Team', brand.team])
|
|
||||||
|
|
||||||
// 3. GitHub URLs
|
|
||||||
replacements.push([
|
|
||||||
'https://github.com/matevip/mateclaw/issues',
|
|
||||||
brand.githubUrl + '/issues',
|
|
||||||
])
|
|
||||||
replacements.push([
|
|
||||||
'https://github.com/matevip/mateclaw',
|
|
||||||
brand.githubUrl,
|
|
||||||
])
|
|
||||||
|
|
||||||
// 4. Logo file path
|
|
||||||
replacements.push([
|
|
||||||
'mateclaw_logo_s.png',
|
|
||||||
brand.logoFile,
|
|
||||||
])
|
|
||||||
|
|
||||||
// 5. Tagline
|
|
||||||
replacements.push([
|
|
||||||
'AI Personal Assistant',
|
|
||||||
brand.tagline,
|
|
||||||
])
|
|
||||||
|
|
||||||
// 6. Split-span brand name in App.vue template:
|
|
||||||
// <span class="mate">Mate</span><span class="claw">Claw</span>
|
|
||||||
// Replace the inner text so styling classes are preserved but the text
|
|
||||||
// changes. We split the brand name: first half gets "mate" class, second
|
|
||||||
// half gets "claw" class. If it's a single word, it all goes in "mate".
|
|
||||||
var half = Math.ceil(brand.name.length / 2)
|
|
||||||
var firstPart = brand.name.slice(0, half)
|
|
||||||
var secondPart = brand.name.slice(half)
|
|
||||||
replacements.push([
|
|
||||||
'>Mate</span><span class="claw">Claw<',
|
|
||||||
'>' + firstPart + '</span><span class="claw">' + secondPart + '<',
|
|
||||||
])
|
|
||||||
|
|
||||||
// 7. Brand name (catch-all, must come last)
|
|
||||||
// Only replace the exact word "MateClaw", not "mateclaw" (lowercase,
|
|
||||||
// which is used in H2 database paths and Spring Boot properties that
|
|
||||||
// are coupled with the server and must NOT change).
|
|
||||||
replacements.push(['MateClaw', brand.name])
|
|
||||||
|
|
||||||
return replacements
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyReplacements(code, replacements) {
|
|
||||||
var result = code
|
|
||||||
for (var i = 0; i < replacements.length; i++) {
|
|
||||||
var from = replacements[i][0]
|
|
||||||
var to = replacements[i][1]
|
|
||||||
// Use split/join for reliable literal string replacement (no regex
|
|
||||||
// escaping issues).
|
|
||||||
result = result.split(from).join(to)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Vite plugin entry point.
|
|
||||||
*
|
|
||||||
* Usage in vite.config.ts:
|
|
||||||
* import { brandingPlugin } from './scripts/branding.cjs'
|
|
||||||
* plugins: [brandingPlugin()]
|
|
||||||
*/
|
|
||||||
function brandingPlugin(options) {
|
|
||||||
options = options || {}
|
|
||||||
var rootDir = options.rootDir || process.cwd()
|
|
||||||
var brand = loadBrandConfig(rootDir)
|
|
||||||
var replacements = buildReplacements(brand)
|
|
||||||
|
|
||||||
var isDefault =
|
|
||||||
brand.name === 'MateClaw' &&
|
|
||||||
brand.tagline === 'AI Personal Assistant' &&
|
|
||||||
brand.team === 'MateClaw Team'
|
|
||||||
|
|
||||||
if (!isDefault) {
|
|
||||||
console.log('[branding] White-label build: "' + brand.name + '" (tagline: "' + brand.tagline + '")')
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: 'mateclaw-branding',
|
|
||||||
enforce: 'pre',
|
|
||||||
|
|
||||||
// Transform JS/TS/Vue source before compilation
|
|
||||||
transform: function (code, id) {
|
|
||||||
if (id.indexOf('node_modules') !== -1) return null
|
|
||||||
// Only process source files that might contain brand strings.
|
|
||||||
if (!/\.(ts|js|vue|html|css|cjs|mjs)$/.test(id)) return null
|
|
||||||
var result = applyReplacements(code, replacements)
|
|
||||||
return result !== code ? { code: result, map: null } : null
|
|
||||||
},
|
|
||||||
|
|
||||||
// Transform index.html
|
|
||||||
transformIndexHtml: function (html) {
|
|
||||||
return applyReplacements(html, replacements)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { brandingPlugin: brandingPlugin, loadBrandConfig: loadBrandConfig, buildReplacements: buildReplacements }
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# scripts/build-all-platforms.sh — Build MateClaw desktop packages for all
|
|
||||||
# platforms (macOS + Windows) in the specified build mode.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# scripts/build-all-platforms.sh --all # local mode (default), both platforms
|
|
||||||
# scripts/build-all-platforms.sh --local # local mode, both platforms
|
|
||||||
# scripts/build-all-platforms.sh --remote # remote mode, both platforms
|
|
||||||
#
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
MODE="--all"
|
|
||||||
case "${1:-}" in
|
|
||||||
--all) MODE="local" ;;
|
|
||||||
--local) MODE="local" ;;
|
|
||||||
--remote) MODE="remote" ;;
|
|
||||||
*) echo "Usage: $0 [--all|--local|--remote]"; exit 1 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
echo "==> Building all platforms, BUILD_MODE=$MODE"
|
|
||||||
|
|
||||||
if [ "$MODE" = "remote" ]; then
|
|
||||||
echo "==> macOS (remote/lite)"
|
|
||||||
BUILD_MODE=remote npx electron-builder --mac
|
|
||||||
echo "==> Windows (remote/lite)"
|
|
||||||
BUILD_MODE=remote npx electron-builder --win
|
|
||||||
else
|
|
||||||
echo "==> macOS (local/full)"
|
|
||||||
BUILD_MODE=local npx electron-builder --mac
|
|
||||||
echo "==> Windows (local/full)"
|
|
||||||
BUILD_MODE=local npx electron-builder --win
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "==> All builds complete."
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# scripts/build.sh — Build the MateClaw Spring Boot backend JAR and place it
|
|
||||||
# at resources/app.jar so electron-builder can bundle it into the desktop app.
|
|
||||||
#
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
||||||
SERVER_DIR="$(cd "$PROJECT_ROOT/../mateclaw-server" && pwd)"
|
|
||||||
RESOURCES_DIR="$PROJECT_ROOT/resources"
|
|
||||||
|
|
||||||
echo "==> Building mateclaw-server JAR from $SERVER_DIR"
|
|
||||||
|
|
||||||
# Build the Spring Boot fat JAR (skip tests for packaging speed)
|
|
||||||
cd "$SERVER_DIR"
|
|
||||||
mvn clean package -DskipTests -Dmaven.test.skip=true -q
|
|
||||||
|
|
||||||
# Locate the built JAR
|
|
||||||
JAR_FILE=$(ls "$SERVER_DIR"/target/mateclaw-server-*.jar 2>/dev/null | head -1)
|
|
||||||
if [ -z "$JAR_FILE" ]; then
|
|
||||||
echo "ERROR: Could not find built JAR in $SERVER_DIR/target/"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "==> Copying $JAR_FILE → $RESOURCES_DIR/app.jar"
|
|
||||||
mkdir -p "$RESOURCES_DIR"
|
|
||||||
cp "$JAR_FILE" "$RESOURCES_DIR/app.jar"
|
|
||||||
|
|
||||||
echo "==> Done. JAR size: $(du -h "$RESOURCES_DIR/app.jar" | cut -f1)"
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# scripts/download-jre.sh — Download Eclipse Temurin JRE 21 for the current
|
|
||||||
# macOS architecture (or both) and extract into resources/jre/<platform>/.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# scripts/download-jre.sh # auto-detect current arch
|
|
||||||
# scripts/download-jre.sh arm64 # arm64 only
|
|
||||||
# scripts/download-jre.sh x64 # x64 only
|
|
||||||
# scripts/download-jre.sh all # both arches
|
|
||||||
#
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
||||||
JRE_DIR="$PROJECT_ROOT/resources/jre"
|
|
||||||
|
|
||||||
# Temurin 21 (LTS) JRE downloads via Adoptium API
|
|
||||||
ADOPTIUM_BASE="https://api.adoptium.net/v3/binary/latest/21/ga/mac"
|
|
||||||
|
|
||||||
download_and_extract() {
|
|
||||||
local arch="$1"
|
|
||||||
local folder="$2"
|
|
||||||
local url="$ADOPTIUM_BASE/$arch/jre/hotspot/normal/eclipse?project=jdk"
|
|
||||||
local tmpfile="$JRE_DIR/jre-mac-$arch.tar.gz"
|
|
||||||
|
|
||||||
echo "==> Downloading Temurin 21 JRE for macOS $arch"
|
|
||||||
mkdir -p "$JRE_DIR"
|
|
||||||
curl -L --fail -o "$tmpfile" "$url"
|
|
||||||
|
|
||||||
echo "==> Extracting to $JRE_DIR/$folder"
|
|
||||||
rm -rf "$JRE_DIR/$folder"
|
|
||||||
mkdir -p "$JRE_DIR/$folder"
|
|
||||||
|
|
||||||
# Temurin macOS tar.gz extracts to: jdk-21.x.x+jre/Contents/Home/...
|
|
||||||
# We want $folder/Contents/Home/... so move the inner Contents up.
|
|
||||||
local extract_tmp="$JRE_DIR/.tmp-$arch"
|
|
||||||
rm -rf "$extract_tmp"
|
|
||||||
mkdir -p "$extract_tmp"
|
|
||||||
tar -xzf "$tmpfile" -C "$extract_tmp"
|
|
||||||
|
|
||||||
# Find the extracted top-level directory and move its Contents
|
|
||||||
local extracted_dir
|
|
||||||
extracted_dir=$(find "$extract_tmp" -maxdepth 1 -type d -name "jdk-*" | head -1)
|
|
||||||
if [ -z "$extracted_dir" ]; then
|
|
||||||
# Fallback: some tarballs extract Contents directly
|
|
||||||
if [ -d "$extract_tmp/Contents" ]; then
|
|
||||||
mv "$extract_tmp/Contents" "$JRE_DIR/$folder/Contents"
|
|
||||||
else
|
|
||||||
echo "ERROR: Could not find extracted JDK directory"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
mv "$extracted_dir/Contents" "$JRE_DIR/$folder/Contents"
|
|
||||||
fi
|
|
||||||
|
|
||||||
rm -rf "$extract_tmp" "$tmpfile"
|
|
||||||
|
|
||||||
# Verify java binary exists
|
|
||||||
local java_bin="$JRE_DIR/$folder/Contents/Home/bin/java"
|
|
||||||
if [ -f "$java_bin" ]; then
|
|
||||||
echo "==> OK: $java_bin"
|
|
||||||
else
|
|
||||||
echo "ERROR: java binary not found at $java_bin"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
TARGET="${1:-auto}"
|
|
||||||
if [ "$TARGET" = "auto" ]; then
|
|
||||||
case "$(uname -m)" in
|
|
||||||
arm64) TARGET="arm64" ;;
|
|
||||||
x86_64) TARGET="x64" ;;
|
|
||||||
*) echo "Unsupported arch: $(uname -m)"; exit 1 ;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
|
|
||||||
case "$TARGET" in
|
|
||||||
arm64) download_and_extract "aarch64" "mac-arm64" ;;
|
|
||||||
x64) download_and_extract "x64" "mac-x64" ;;
|
|
||||||
all) download_and_extract "aarch64" "mac-arm64"
|
|
||||||
download_and_extract "x64" "mac-x64" ;;
|
|
||||||
*) echo "Usage: $0 [arm64|x64|all]"; exit 1 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
echo "==> JRE setup complete."
|
|
||||||
@ -1,324 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# Publish MateClaw Desktop release artifacts to GitHub Releases
|
|
||||||
#
|
|
||||||
# Prerequisites:
|
|
||||||
# - gh CLI installed and authenticated (gh auth login)
|
|
||||||
# - Build artifacts exist in release/ directory (run build-all-platforms.sh first)
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# bash scripts/publish-github.sh # Create release (direct)
|
|
||||||
# bash scripts/publish-github.sh --proxy # Create release via proxy (127.0.0.1:7890)
|
|
||||||
# bash scripts/publish-github.sh --proxy=host:port # Create release via custom proxy
|
|
||||||
# bash scripts/publish-github.sh --draft # Create draft release
|
|
||||||
# bash scripts/publish-github.sh --tag=v1.1.0 # Custom tag (default: v{version} from package.json)
|
|
||||||
# bash scripts/publish-github.sh --retry # Delete existing release and re-upload
|
|
||||||
# bash scripts/publish-github.sh --proxy --retry --draft # Combine options
|
|
||||||
#
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
DESKTOP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
||||||
RELEASE_DIR="$DESKTOP_DIR/release"
|
|
||||||
|
|
||||||
# ─── Parse arguments ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
DRAFT=""
|
|
||||||
CUSTOM_TAG=""
|
|
||||||
USE_PROXY=""
|
|
||||||
PROXY_ADDR="127.0.0.1:7890"
|
|
||||||
RETRY=false
|
|
||||||
NOTES_FILE_OVERRIDE=""
|
|
||||||
NOTES_LANG="en"
|
|
||||||
|
|
||||||
for arg in "$@"; do
|
|
||||||
case "$arg" in
|
|
||||||
--draft) DRAFT="--draft" ;;
|
|
||||||
--tag=*) CUSTOM_TAG="${arg#--tag=}" ;;
|
|
||||||
--proxy) USE_PROXY=true ;;
|
|
||||||
--proxy=*) USE_PROXY=true; PROXY_ADDR="${arg#--proxy=}" ;;
|
|
||||||
--retry) RETRY=true ;;
|
|
||||||
--notes-file=*) NOTES_FILE_OVERRIDE="${arg#--notes-file=}" ;;
|
|
||||||
--notes-lang=*) NOTES_LANG="${arg#--notes-lang=}" ;;
|
|
||||||
-h|--help)
|
|
||||||
echo "Usage: $0 [--draft] [--tag=vX.Y.Z] [--proxy[=host:port]] [--retry] [--notes-file=path] [--notes-lang=en|zh]"
|
|
||||||
echo ""
|
|
||||||
echo "Options:"
|
|
||||||
echo " --draft Create as draft release"
|
|
||||||
echo " --tag=vX.Y.Z Custom tag (default: v{version} from package.json)"
|
|
||||||
echo " --proxy Use proxy 127.0.0.1:7890 for uploading to GitHub"
|
|
||||||
echo " --proxy=host:port Use custom proxy address"
|
|
||||||
echo " --retry Delete existing release for this tag before uploading"
|
|
||||||
echo " --notes-file=path Use this markdown file as release notes (highest priority)"
|
|
||||||
echo " --notes-lang=en|zh Preferred language for auto-detected notes (default: en)"
|
|
||||||
echo ""
|
|
||||||
echo "Release notes resolution order:"
|
|
||||||
echo " 1. --notes-file=path (explicit override)"
|
|
||||||
echo " 2. ../docs/{notes-lang}/releases/X.Y.Z.md (curated, preferred lang)"
|
|
||||||
echo " 3. ../docs/{other-lang}/releases/X.Y.Z.md (curated, fallback lang)"
|
|
||||||
echo " 4. Tag annotation (if multi-line) (legacy)"
|
|
||||||
echo " 5. Tagged commit message body (legacy)"
|
|
||||||
echo " 6. --generate-notes (GitHub auto)"
|
|
||||||
echo ""
|
|
||||||
echo "Examples:"
|
|
||||||
echo " # Step 1: Build locally (no publish)"
|
|
||||||
echo " bash scripts/build-all-platforms.sh --all"
|
|
||||||
echo ""
|
|
||||||
echo " # Step 2: Publish to GitHub"
|
|
||||||
echo " bash scripts/publish-github.sh --proxy # via proxy, en notes"
|
|
||||||
echo " bash scripts/publish-github.sh --proxy --notes-lang=zh # zh notes"
|
|
||||||
echo " bash scripts/publish-github.sh --proxy --draft # draft (preview)"
|
|
||||||
echo " bash scripts/publish-github.sh --proxy --retry # delete + re-upload"
|
|
||||||
echo " bash scripts/publish-github.sh --notes-file=NOTES.md # custom notes file"
|
|
||||||
echo ""
|
|
||||||
echo "Run build-all-platforms.sh first to generate artifacts."
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Unknown option: $arg"
|
|
||||||
echo "Usage: $0 [--draft] [--tag=vX.Y.Z] [--proxy[=host:port]] [--retry] [--notes-file=path] [--notes-lang=en|zh]"
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$NOTES_LANG" != "en" ] && [ "$NOTES_LANG" != "zh" ]; then
|
|
||||||
echo "❌ --notes-lang must be 'en' or 'zh' (got: $NOTES_LANG)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ─── Setup proxy ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
if [ "$USE_PROXY" = true ]; then
|
|
||||||
export https_proxy="http://${PROXY_ADDR}"
|
|
||||||
export http_proxy="http://${PROXY_ADDR}"
|
|
||||||
export HTTPS_PROXY="http://${PROXY_ADDR}"
|
|
||||||
export HTTP_PROXY="http://${PROXY_ADDR}"
|
|
||||||
echo "🌐 Proxy enabled: ${PROXY_ADDR}"
|
|
||||||
echo ""
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ─── Check prerequisites ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
if ! command -v gh &>/dev/null; then
|
|
||||||
echo "❌ gh CLI not found. Install: https://cli.github.com/"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! gh auth status &>/dev/null; then
|
|
||||||
echo "❌ gh not authenticated. Run: gh auth login"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ─── Read version from package.json ───────────────────────────────────────
|
|
||||||
|
|
||||||
VERSION=$(node -p "require('$DESKTOP_DIR/package.json').version")
|
|
||||||
TAG="${CUSTOM_TAG:-v${VERSION}}"
|
|
||||||
|
|
||||||
echo "╔════════════════════════════════════════════════════════╗"
|
|
||||||
echo "║ MateClaw Desktop - Publish to GitHub ║"
|
|
||||||
echo "╚════════════════════════════════════════════════════════╝"
|
|
||||||
echo ""
|
|
||||||
echo " Version: $VERSION"
|
|
||||||
echo " Tag: $TAG"
|
|
||||||
echo " Mode: ${DRAFT:-release}"
|
|
||||||
echo " Proxy: ${USE_PROXY:+${PROXY_ADDR}}${USE_PROXY:-off}"
|
|
||||||
echo " Retry: $RETRY"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ─── Retry: delete existing release ───────────────────────────────────────
|
|
||||||
|
|
||||||
if [ "$RETRY" = true ]; then
|
|
||||||
echo "🔄 Checking for existing release $TAG ..."
|
|
||||||
if gh release view "$TAG" &>/dev/null; then
|
|
||||||
echo " Found existing release, deleting..."
|
|
||||||
gh release delete "$TAG" --yes --cleanup-tag 2>/dev/null || gh release delete "$TAG" --yes 2>/dev/null
|
|
||||||
echo " ✅ Old release deleted."
|
|
||||||
else
|
|
||||||
echo " No existing release found, proceeding."
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ─── Collect release artifacts ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
if [ ! -d "$RELEASE_DIR" ]; then
|
|
||||||
echo "❌ Release directory not found: $RELEASE_DIR"
|
|
||||||
echo " Run build-all-platforms.sh first."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
ARTIFACTS=()
|
|
||||||
|
|
||||||
# macOS artifacts
|
|
||||||
for pattern in "MateClaw*.dmg" "MateClaw*.zip"; do
|
|
||||||
while IFS= read -r -d '' f; do
|
|
||||||
ARTIFACTS+=("$f")
|
|
||||||
done < <(find "$RELEASE_DIR" -maxdepth 1 -name "$pattern" -print0 2>/dev/null)
|
|
||||||
done
|
|
||||||
|
|
||||||
# Windows artifacts (skip the architecture-merged installer — only ship per-arch builds)
|
|
||||||
while IFS= read -r -d '' f; do
|
|
||||||
ARTIFACTS+=("$f")
|
|
||||||
done < <(find "$RELEASE_DIR" -maxdepth 1 -name "MateClaw*Setup*.exe" \
|
|
||||||
! -name "MateClaw_${VERSION}_Setup.exe" -print0 2>/dev/null)
|
|
||||||
|
|
||||||
# Linux artifacts
|
|
||||||
while IFS= read -r -d '' f; do
|
|
||||||
ARTIFACTS+=("$f")
|
|
||||||
done < <(find "$RELEASE_DIR" -maxdepth 1 -name "MateClaw*.AppImage" -print0 2>/dev/null)
|
|
||||||
|
|
||||||
# Auto-update metadata files
|
|
||||||
for pattern in "latest-mac.yml" "latest.yml" "latest-linux.yml"; do
|
|
||||||
if [ -f "$RELEASE_DIR/$pattern" ]; then
|
|
||||||
ARTIFACTS+=("$RELEASE_DIR/$pattern")
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# blockmap files (for differential updates) — also skip merged installer's blockmap
|
|
||||||
while IFS= read -r -d '' f; do
|
|
||||||
ARTIFACTS+=("$f")
|
|
||||||
done < <(find "$RELEASE_DIR" -maxdepth 1 -name "*.blockmap" \
|
|
||||||
! -name "MateClaw_${VERSION}_Setup.exe.blockmap" -print0 2>/dev/null)
|
|
||||||
|
|
||||||
if [ ${#ARTIFACTS[@]} -eq 0 ]; then
|
|
||||||
echo "❌ No release artifacts found in $RELEASE_DIR"
|
|
||||||
echo " Run build-all-platforms.sh first."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo " Artifacts to upload:"
|
|
||||||
TOTAL_SIZE=0
|
|
||||||
for f in "${ARTIFACTS[@]}"; do
|
|
||||||
SIZE_BYTES=$(stat -f%z "$f" 2>/dev/null || stat -c%s "$f" 2>/dev/null || echo 0)
|
|
||||||
SIZE_HUMAN=$(du -h "$f" | cut -f1)
|
|
||||||
TOTAL_SIZE=$((TOTAL_SIZE + SIZE_BYTES))
|
|
||||||
echo " • $(basename "$f") ($SIZE_HUMAN)"
|
|
||||||
done
|
|
||||||
TOTAL_HUMAN=$(echo "$TOTAL_SIZE" | awk '{
|
|
||||||
if ($1 >= 1073741824) printf "%.1f GB", $1/1073741824;
|
|
||||||
else if ($1 >= 1048576) printf "%.0f MB", $1/1048576;
|
|
||||||
else printf "%.0f KB", $1/1024;
|
|
||||||
}')
|
|
||||||
echo ""
|
|
||||||
echo " Total: $TOTAL_HUMAN"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ─── Resolve release notes (priority chain) ───────────────────────────────
|
|
||||||
# 1. --notes-file=path (explicit override)
|
|
||||||
# 2. ../docs/{notes-lang}/releases/X.Y.Z.md (curated, preferred lang)
|
|
||||||
# 3. ../docs/{other-lang}/releases/X.Y.Z.md (curated, fallback lang)
|
|
||||||
# 4. Tag annotation (if multi-line) (legacy fallback)
|
|
||||||
# 5. Tagged commit message body (legacy fallback)
|
|
||||||
# 6. --generate-notes (GitHub auto)
|
|
||||||
|
|
||||||
NOTES_FILE=$(mktemp)
|
|
||||||
trap "rm -f '$NOTES_FILE'" EXIT
|
|
||||||
|
|
||||||
REPO_ROOT="$(cd "$DESKTOP_DIR/.." && pwd)"
|
|
||||||
NOTES_LANG_OTHER=$([ "$NOTES_LANG" = "en" ] && echo "zh" || echo "en")
|
|
||||||
NOTES_PRIMARY="$REPO_ROOT/docs/${NOTES_LANG}/releases/${VERSION}.md"
|
|
||||||
NOTES_FALLBACK="$REPO_ROOT/docs/${NOTES_LANG_OTHER}/releases/${VERSION}.md"
|
|
||||||
|
|
||||||
if [ -n "$NOTES_FILE_OVERRIDE" ]; then
|
|
||||||
if [ ! -f "$NOTES_FILE_OVERRIDE" ]; then
|
|
||||||
echo "❌ --notes-file path not found: $NOTES_FILE_OVERRIDE"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
cat "$NOTES_FILE_OVERRIDE" > "$NOTES_FILE"
|
|
||||||
echo "📝 Using --notes-file: $NOTES_FILE_OVERRIDE"
|
|
||||||
elif [ -f "$NOTES_PRIMARY" ]; then
|
|
||||||
cat "$NOTES_PRIMARY" > "$NOTES_FILE"
|
|
||||||
echo "📝 Using docs/${NOTES_LANG}/releases/${VERSION}.md as release notes."
|
|
||||||
elif [ -f "$NOTES_FALLBACK" ]; then
|
|
||||||
cat "$NOTES_FALLBACK" > "$NOTES_FILE"
|
|
||||||
echo "📝 Using docs/${NOTES_LANG_OTHER}/releases/${VERSION}.md as release notes (preferred '$NOTES_LANG' not found)."
|
|
||||||
else
|
|
||||||
# Legacy fallback: tag annotation, then commit body
|
|
||||||
TAG_MSG=$(git tag -l --format='%(contents)' "$TAG" 2>/dev/null | sed '/^$/d')
|
|
||||||
if [ -n "$TAG_MSG" ] && [ "$(echo "$TAG_MSG" | wc -l)" -gt 2 ]; then
|
|
||||||
echo "$TAG_MSG" > "$NOTES_FILE"
|
|
||||||
echo "📝 Using tag annotation as release notes."
|
|
||||||
else
|
|
||||||
COMMIT_MSG=$(git log -1 --format='%B' "$TAG" 2>/dev/null | tail -n +2 | sed '/^$/d')
|
|
||||||
if [ -n "$COMMIT_MSG" ]; then
|
|
||||||
echo "$COMMIT_MSG" > "$NOTES_FILE"
|
|
||||||
echo "📝 Using commit message as release notes."
|
|
||||||
else
|
|
||||||
echo "⚠️ No release notes found (no docs/X.Y.Z.md, tag annotation, or commit body), using --generate-notes."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ─── Rewrite latest.yml so it does not reference the merged installer ─────
|
|
||||||
#
|
|
||||||
# electron-builder generates a multi-arch merged Windows installer
|
|
||||||
# (MateClaw_X.Y.Z_Setup.exe, ~600 MB) alongside the per-arch installers
|
|
||||||
# (MateClaw_X.Y.Z_x64_Setup.exe, MateClaw_X.Y.Z_arm64_Setup.exe). The
|
|
||||||
# merged build is too large to be worth uploading, but the default
|
|
||||||
# latest.yml top-level `path:` and `files[0]` both point at it. If we
|
|
||||||
# upload latest.yml as-is while skipping the merged exe, electron-updater
|
|
||||||
# on Windows hits 404 on every check and the whole update path breaks.
|
|
||||||
#
|
|
||||||
# Fix: rewrite latest.yml in place so `path` / `sha512` / `files[]` only
|
|
||||||
# reference assets that are actually being uploaded. Use x64 as the
|
|
||||||
# default top-level target (arm64 clients still match via files[]).
|
|
||||||
|
|
||||||
LATEST_YML="$RELEASE_DIR/latest.yml"
|
|
||||||
MERGED_EXE_NAME="MateClaw_${VERSION}_Setup.exe"
|
|
||||||
X64_EXE_NAME="MateClaw_${VERSION}_x64_Setup.exe"
|
|
||||||
ARM64_EXE_NAME="MateClaw_${VERSION}_arm64_Setup.exe"
|
|
||||||
|
|
||||||
if [ -f "$LATEST_YML" ]; then
|
|
||||||
echo "🧹 Rewriting latest.yml to drop the merged installer ..."
|
|
||||||
(cd "$DESKTOP_DIR" && node -e "
|
|
||||||
const fs = require('fs');
|
|
||||||
const yaml = require('js-yaml');
|
|
||||||
const file = '$LATEST_YML';
|
|
||||||
const merged = '$MERGED_EXE_NAME';
|
|
||||||
const x64 = '$X64_EXE_NAME';
|
|
||||||
const arm64 = '$ARM64_EXE_NAME';
|
|
||||||
const doc = yaml.load(fs.readFileSync(file, 'utf8'));
|
|
||||||
const before = (doc.files || []).length;
|
|
||||||
doc.files = (doc.files || []).filter(f => f.url !== merged);
|
|
||||||
const x64Entry = doc.files.find(f => f.url === x64);
|
|
||||||
const arm64Entry = doc.files.find(f => f.url === arm64);
|
|
||||||
if (!x64Entry || !arm64Entry) {
|
|
||||||
console.error('FATAL: per-arch installer entries missing from latest.yml');
|
|
||||||
console.error(' files:', doc.files.map(f => f.url));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
doc.path = x64Entry.url;
|
|
||||||
doc.sha512 = x64Entry.sha512;
|
|
||||||
fs.writeFileSync(file, yaml.dump(doc, { lineWidth: -1 }));
|
|
||||||
console.log(' files[] entries: ' + before + ' → ' + doc.files.length);
|
|
||||||
console.log(' top-level path: ' + doc.path);
|
|
||||||
")
|
|
||||||
echo ""
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ─── Create GitHub Release ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
echo "🚀 Creating GitHub Release $TAG ..."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
if [ -s "$NOTES_FILE" ]; then
|
|
||||||
gh release create "$TAG" \
|
|
||||||
--title "MateClaw $VERSION" \
|
|
||||||
--notes-file "$NOTES_FILE" \
|
|
||||||
$DRAFT \
|
|
||||||
"${ARTIFACTS[@]}"
|
|
||||||
else
|
|
||||||
gh release create "$TAG" \
|
|
||||||
--title "MateClaw $VERSION" \
|
|
||||||
--generate-notes \
|
|
||||||
$DRAFT \
|
|
||||||
"${ARTIFACTS[@]}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "✅ Release published successfully!"
|
|
||||||
echo ""
|
|
||||||
gh release view "$TAG" --json url -q '.url'
|
|
||||||
@ -1,360 +0,0 @@
|
|||||||
const fs = require('node:fs')
|
|
||||||
const path = require('node:path')
|
|
||||||
const zlib = require('node:zlib')
|
|
||||||
|
|
||||||
const ZIP_STORED = 0
|
|
||||||
const ZIP_DEFLATED = 8
|
|
||||||
const DRIVER_BUNDLE_PATTERN = /^BOOT-INF\/lib\/driver-bundle-[^/]+\.jar$/
|
|
||||||
|
|
||||||
const ARCH_X64 = 1
|
|
||||||
const ARCH_ARM64 = 3
|
|
||||||
|
|
||||||
async function afterPack(context) {
|
|
||||||
const platform = context.electronPlatformName || context.packager?.platform?.name
|
|
||||||
const arch = normalizeArch(context.arch)
|
|
||||||
const keepDriverDirectory = resolveDriverDirectory(platform, arch)
|
|
||||||
const appJarPath = findAppJar(context)
|
|
||||||
|
|
||||||
if (!appJarPath) {
|
|
||||||
throw new Error(`[trim-playwright-driver] app.jar not found in ${context.appOutDir}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await trimDriverBundleInAppJar(appJarPath, keepDriverDirectory)
|
|
||||||
const keptLabel = asArray(keepDriverDirectory).join(', ')
|
|
||||||
console.log(
|
|
||||||
`[trim-playwright-driver] ${path.relative(process.cwd(), appJarPath)}: ` +
|
|
||||||
`kept ${keptLabel}, removed ${result.removedDriverEntries} driver entries, ` +
|
|
||||||
`${formatBytes(result.beforeBytes)} -> ${formatBytes(result.afterBytes)}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function findAppJar(context) {
|
|
||||||
const appOutDir = context.appOutDir
|
|
||||||
const productFilename = context.packager?.appInfo?.productFilename || context.packager?.appInfo?.productName || 'MateClaw'
|
|
||||||
const platform = context.electronPlatformName || context.packager?.platform?.name
|
|
||||||
|
|
||||||
const candidates = []
|
|
||||||
if (platform === 'darwin') {
|
|
||||||
candidates.push(path.join(appOutDir, `${productFilename}.app`, 'Contents', 'Resources', 'app.jar'))
|
|
||||||
}
|
|
||||||
candidates.push(path.join(appOutDir, 'resources', 'app.jar'))
|
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (fs.existsSync(candidate)) return candidate
|
|
||||||
}
|
|
||||||
|
|
||||||
const found = findFirstFile(appOutDir, 'app.jar', 4)
|
|
||||||
return found
|
|
||||||
}
|
|
||||||
|
|
||||||
function findFirstFile(root, fileName, maxDepth, depth = 0) {
|
|
||||||
if (!root || depth > maxDepth || !fs.existsSync(root)) return null
|
|
||||||
for (const dirent of fs.readdirSync(root, { withFileTypes: true })) {
|
|
||||||
const fullPath = path.join(root, dirent.name)
|
|
||||||
if (dirent.isFile() && dirent.name === fileName) return fullPath
|
|
||||||
if (dirent.isDirectory()) {
|
|
||||||
const found = findFirstFile(fullPath, fileName, maxDepth, depth + 1)
|
|
||||||
if (found) return found
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function trimDriverBundleInAppJar(appJarPath, keepDriverDirectory) {
|
|
||||||
const appJarBuffer = fs.readFileSync(appJarPath)
|
|
||||||
const outerEntries = readZipEntries(appJarBuffer)
|
|
||||||
const driverBundleEntry = outerEntries.find((entry) => DRIVER_BUNDLE_PATTERN.test(entry.name))
|
|
||||||
const keepDriverDirectories = asArray(keepDriverDirectory)
|
|
||||||
|
|
||||||
if (!driverBundleEntry) {
|
|
||||||
throw new Error(`Playwright driver-bundle jar not found in ${appJarPath}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const innerEntries = readZipEntries(driverBundleEntry.data)
|
|
||||||
let removedDriverEntries = 0
|
|
||||||
let keptDriverEntries = 0
|
|
||||||
|
|
||||||
const trimmedInnerEntries = innerEntries.filter((entry) => {
|
|
||||||
if (!entry.name.startsWith('driver/')) return true
|
|
||||||
if (keepDriverDirectories.some((directory) => entry.name.startsWith(`${directory}/`))) {
|
|
||||||
keptDriverEntries += 1
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
removedDriverEntries += 1
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
if (keptDriverEntries === 0) {
|
|
||||||
throw new Error(
|
|
||||||
`No entries kept under ${keepDriverDirectories.join(', ')}; Playwright driver layout may have changed`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedDriverBundle = createZipBuffer(trimmedInnerEntries.map(cloneEntryForWrite))
|
|
||||||
const rewrittenOuterEntries = outerEntries.map((entry) => {
|
|
||||||
if (entry.name !== driverBundleEntry.name) return cloneEntryForWrite(entry)
|
|
||||||
return {
|
|
||||||
...cloneEntryForWrite(entry),
|
|
||||||
data: trimmedDriverBundle,
|
|
||||||
method: ZIP_STORED,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const rewrittenAppJar = createZipBuffer(rewrittenOuterEntries)
|
|
||||||
fs.writeFileSync(appJarPath, rewrittenAppJar)
|
|
||||||
|
|
||||||
return {
|
|
||||||
beforeBytes: appJarBuffer.length,
|
|
||||||
afterBytes: rewrittenAppJar.length,
|
|
||||||
driverBundleName: driverBundleEntry.name,
|
|
||||||
keptDriverEntries,
|
|
||||||
removedDriverEntries,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cloneEntryForWrite(entry) {
|
|
||||||
return {
|
|
||||||
name: entry.name,
|
|
||||||
data: Buffer.from(entry.data),
|
|
||||||
method: entry.method,
|
|
||||||
date: entry.date,
|
|
||||||
comment: entry.comment,
|
|
||||||
externalAttributes: entry.externalAttributes,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDriverDirectory(platform, arch) {
|
|
||||||
const normalizedArch = normalizeArch(arch)
|
|
||||||
|
|
||||||
if (platform === 'darwin') {
|
|
||||||
if (normalizedArch === 'arm64') return 'driver/mac-arm64'
|
|
||||||
if (normalizedArch === 'x64') return 'driver/mac'
|
|
||||||
if (normalizedArch === 'universal') return ['driver/mac', 'driver/mac-arm64']
|
|
||||||
}
|
|
||||||
|
|
||||||
if (platform === 'linux') {
|
|
||||||
if (normalizedArch === 'arm64') return 'driver/linux-arm64'
|
|
||||||
if (normalizedArch === 'x64') return 'driver/linux'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (platform === 'win32') {
|
|
||||||
return 'driver/win32_x64'
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Unsupported platform/arch for Playwright driver trim: ${platform}/${arch}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function asArray(value) {
|
|
||||||
return Array.isArray(value) ? value : [value]
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeArch(arch) {
|
|
||||||
if (arch === ARCH_X64 || arch === 'x64') return 'x64'
|
|
||||||
if (arch === ARCH_ARM64 || arch === 'arm64') return 'arm64'
|
|
||||||
if (arch === 'universal' || arch === 4) return 'universal'
|
|
||||||
return String(arch)
|
|
||||||
}
|
|
||||||
|
|
||||||
function readZipEntries(buffer) {
|
|
||||||
const eocdOffset = findEndOfCentralDirectory(buffer)
|
|
||||||
const centralDirectorySize = buffer.readUInt32LE(eocdOffset + 12)
|
|
||||||
const centralDirectoryOffset = buffer.readUInt32LE(eocdOffset + 16)
|
|
||||||
const entries = []
|
|
||||||
|
|
||||||
let offset = centralDirectoryOffset
|
|
||||||
const centralDirectoryEnd = centralDirectoryOffset + centralDirectorySize
|
|
||||||
|
|
||||||
while (offset < centralDirectoryEnd) {
|
|
||||||
const signature = buffer.readUInt32LE(offset)
|
|
||||||
if (signature !== 0x02014b50) {
|
|
||||||
throw new Error(`Invalid central directory signature at offset ${offset}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const flags = buffer.readUInt16LE(offset + 8)
|
|
||||||
const method = buffer.readUInt16LE(offset + 10)
|
|
||||||
const dosTime = buffer.readUInt16LE(offset + 12)
|
|
||||||
const dosDate = buffer.readUInt16LE(offset + 14)
|
|
||||||
const crc = buffer.readUInt32LE(offset + 16)
|
|
||||||
const compressedSize = buffer.readUInt32LE(offset + 20)
|
|
||||||
const uncompressedSize = buffer.readUInt32LE(offset + 24)
|
|
||||||
const fileNameLength = buffer.readUInt16LE(offset + 28)
|
|
||||||
const extraLength = buffer.readUInt16LE(offset + 30)
|
|
||||||
const commentLength = buffer.readUInt16LE(offset + 32)
|
|
||||||
const externalAttributes = buffer.readUInt32LE(offset + 38)
|
|
||||||
const localHeaderOffset = buffer.readUInt32LE(offset + 42)
|
|
||||||
const name = buffer.toString('utf8', offset + 46, offset + 46 + fileNameLength)
|
|
||||||
const comment = buffer.subarray(offset + 46 + fileNameLength + extraLength, offset + 46 + fileNameLength + extraLength + commentLength)
|
|
||||||
|
|
||||||
const localSignature = buffer.readUInt32LE(localHeaderOffset)
|
|
||||||
if (localSignature !== 0x04034b50) {
|
|
||||||
throw new Error(`Invalid local file header signature for ${name}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const localNameLength = buffer.readUInt16LE(localHeaderOffset + 26)
|
|
||||||
const localExtraLength = buffer.readUInt16LE(localHeaderOffset + 28)
|
|
||||||
const dataOffset = localHeaderOffset + 30 + localNameLength + localExtraLength
|
|
||||||
const compressedData = buffer.subarray(dataOffset, dataOffset + compressedSize)
|
|
||||||
|
|
||||||
let data
|
|
||||||
if (method === ZIP_STORED) {
|
|
||||||
data = Buffer.from(compressedData)
|
|
||||||
} else if (method === ZIP_DEFLATED) {
|
|
||||||
data = zlib.inflateRawSync(compressedData)
|
|
||||||
} else {
|
|
||||||
throw new Error(`Unsupported ZIP method ${method} for ${name}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.length !== uncompressedSize) {
|
|
||||||
throw new Error(`Unexpected uncompressed size for ${name}: ${data.length} !== ${uncompressedSize}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
entries.push({
|
|
||||||
name,
|
|
||||||
data,
|
|
||||||
method,
|
|
||||||
flags,
|
|
||||||
crc,
|
|
||||||
date: dosToDate(dosDate, dosTime),
|
|
||||||
comment: Buffer.from(comment),
|
|
||||||
externalAttributes,
|
|
||||||
})
|
|
||||||
|
|
||||||
offset += 46 + fileNameLength + extraLength + commentLength
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries
|
|
||||||
}
|
|
||||||
|
|
||||||
function createZipBuffer(entries) {
|
|
||||||
const localParts = []
|
|
||||||
const centralParts = []
|
|
||||||
let offset = 0
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const nameBuffer = Buffer.from(entry.name)
|
|
||||||
const data = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data || '')
|
|
||||||
const method = entry.method ?? ZIP_DEFLATED
|
|
||||||
const compressedData = method === ZIP_STORED ? data : zlib.deflateRawSync(data)
|
|
||||||
const crc = crc32(data)
|
|
||||||
const { dosDate, dosTime } = dateToDos(entry.date)
|
|
||||||
|
|
||||||
const localHeader = Buffer.alloc(30 + nameBuffer.length)
|
|
||||||
localHeader.writeUInt32LE(0x04034b50, 0)
|
|
||||||
localHeader.writeUInt16LE(20, 4)
|
|
||||||
localHeader.writeUInt16LE(0x0800, 6)
|
|
||||||
localHeader.writeUInt16LE(method, 8)
|
|
||||||
localHeader.writeUInt16LE(dosTime, 10)
|
|
||||||
localHeader.writeUInt16LE(dosDate, 12)
|
|
||||||
localHeader.writeUInt32LE(crc, 14)
|
|
||||||
localHeader.writeUInt32LE(compressedData.length, 18)
|
|
||||||
localHeader.writeUInt32LE(data.length, 22)
|
|
||||||
localHeader.writeUInt16LE(nameBuffer.length, 26)
|
|
||||||
localHeader.writeUInt16LE(0, 28)
|
|
||||||
nameBuffer.copy(localHeader, 30)
|
|
||||||
|
|
||||||
localParts.push(localHeader, compressedData)
|
|
||||||
|
|
||||||
const comment = Buffer.isBuffer(entry.comment) ? entry.comment : Buffer.alloc(0)
|
|
||||||
const centralHeader = Buffer.alloc(46 + nameBuffer.length + comment.length)
|
|
||||||
centralHeader.writeUInt32LE(0x02014b50, 0)
|
|
||||||
centralHeader.writeUInt16LE(20, 4)
|
|
||||||
centralHeader.writeUInt16LE(20, 6)
|
|
||||||
centralHeader.writeUInt16LE(0x0800, 8)
|
|
||||||
centralHeader.writeUInt16LE(method, 10)
|
|
||||||
centralHeader.writeUInt16LE(dosTime, 12)
|
|
||||||
centralHeader.writeUInt16LE(dosDate, 14)
|
|
||||||
centralHeader.writeUInt32LE(crc, 16)
|
|
||||||
centralHeader.writeUInt32LE(compressedData.length, 20)
|
|
||||||
centralHeader.writeUInt32LE(data.length, 24)
|
|
||||||
centralHeader.writeUInt16LE(nameBuffer.length, 28)
|
|
||||||
centralHeader.writeUInt16LE(0, 30)
|
|
||||||
centralHeader.writeUInt16LE(comment.length, 32)
|
|
||||||
centralHeader.writeUInt16LE(0, 34)
|
|
||||||
centralHeader.writeUInt16LE(0, 36)
|
|
||||||
centralHeader.writeUInt32LE(entry.externalAttributes || 0, 38)
|
|
||||||
centralHeader.writeUInt32LE(offset, 42)
|
|
||||||
nameBuffer.copy(centralHeader, 46)
|
|
||||||
comment.copy(centralHeader, 46 + nameBuffer.length)
|
|
||||||
centralParts.push(centralHeader)
|
|
||||||
|
|
||||||
offset += localHeader.length + compressedData.length
|
|
||||||
}
|
|
||||||
|
|
||||||
const centralDirectoryOffset = offset
|
|
||||||
const centralDirectory = Buffer.concat(centralParts)
|
|
||||||
const centralDirectorySize = centralDirectory.length
|
|
||||||
|
|
||||||
const eocd = Buffer.alloc(22)
|
|
||||||
eocd.writeUInt32LE(0x06054b50, 0)
|
|
||||||
eocd.writeUInt16LE(0, 4)
|
|
||||||
eocd.writeUInt16LE(0, 6)
|
|
||||||
eocd.writeUInt16LE(entries.length, 8)
|
|
||||||
eocd.writeUInt16LE(entries.length, 10)
|
|
||||||
eocd.writeUInt32LE(centralDirectorySize, 12)
|
|
||||||
eocd.writeUInt32LE(centralDirectoryOffset, 16)
|
|
||||||
eocd.writeUInt16LE(0, 20)
|
|
||||||
|
|
||||||
return Buffer.concat([...localParts, centralDirectory, eocd])
|
|
||||||
}
|
|
||||||
|
|
||||||
function findEndOfCentralDirectory(buffer) {
|
|
||||||
const minOffset = Math.max(0, buffer.length - 0xffff - 22)
|
|
||||||
for (let offset = buffer.length - 22; offset >= minOffset; offset -= 1) {
|
|
||||||
if (buffer.readUInt32LE(offset) === 0x06054b50) return offset
|
|
||||||
}
|
|
||||||
throw new Error('End of central directory not found')
|
|
||||||
}
|
|
||||||
|
|
||||||
function dateToDos(date) {
|
|
||||||
const value = date instanceof Date ? date : new Date(1980, 0, 1, 0, 0, 0)
|
|
||||||
const year = Math.max(1980, value.getFullYear())
|
|
||||||
return {
|
|
||||||
dosDate: ((year - 1980) << 9) | ((value.getMonth() + 1) << 5) | value.getDate(),
|
|
||||||
dosTime: (value.getHours() << 11) | (value.getMinutes() << 5) | Math.floor(value.getSeconds() / 2),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function dosToDate(dosDate, dosTime) {
|
|
||||||
const day = dosDate & 0x1f
|
|
||||||
const month = (dosDate >> 5) & 0x0f
|
|
||||||
const year = ((dosDate >> 9) & 0x7f) + 1980
|
|
||||||
const second = (dosTime & 0x1f) * 2
|
|
||||||
const minute = (dosTime >> 5) & 0x3f
|
|
||||||
const hour = (dosTime >> 11) & 0x1f
|
|
||||||
return new Date(year, Math.max(0, month - 1), day || 1, hour, minute, second)
|
|
||||||
}
|
|
||||||
|
|
||||||
function crc32(buffer) {
|
|
||||||
let crc = 0xffffffff
|
|
||||||
for (let i = 0; i < buffer.length; i += 1) {
|
|
||||||
crc = CRC_TABLE[(crc ^ buffer[i]) & 0xff] ^ (crc >>> 8)
|
|
||||||
}
|
|
||||||
return (crc ^ 0xffffffff) >>> 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeCrcTable() {
|
|
||||||
const table = new Uint32Array(256)
|
|
||||||
for (let i = 0; i < 256; i += 1) {
|
|
||||||
let value = i
|
|
||||||
for (let bit = 0; bit < 8; bit += 1) {
|
|
||||||
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1
|
|
||||||
}
|
|
||||||
table[i] = value >>> 0
|
|
||||||
}
|
|
||||||
return table
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBytes(bytes) {
|
|
||||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
|
||||||
}
|
|
||||||
|
|
||||||
const CRC_TABLE = makeCrcTable()
|
|
||||||
|
|
||||||
module.exports = afterPack
|
|
||||||
module.exports.default = afterPack
|
|
||||||
module.exports.createZipBuffer = createZipBuffer
|
|
||||||
module.exports.readZipEntries = readZipEntries
|
|
||||||
module.exports.resolveDriverDirectory = resolveDriverDirectory
|
|
||||||
module.exports.trimDriverBundleInAppJar = trimDriverBundleInAppJar
|
|
||||||
module.exports.ZIP_STORED = ZIP_STORED
|
|
||||||
module.exports.ZIP_DEFLATED = ZIP_DEFLATED
|
|
||||||
67
mateclaw-desktop/src/env.d.ts
vendored
@ -1,67 +0,0 @@
|
|||||||
/// <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
|
|
||||||
}
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
import { createApp } from 'vue'
|
|
||||||
import App from './App.vue'
|
|
||||||
|
|
||||||
createApp(App).mount('#app')
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"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" }]
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2020",
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"composite": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"noEmit": false
|
|
||||||
},
|
|
||||||
"include": ["vite.config.ts"]
|
|
||||||
}
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
<?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-api</artifactId>
|
|
||||||
<packaging>jar</packaging>
|
|
||||||
|
|
||||||
<name>MateClaw Plugin API</name>
|
|
||||||
<description>Plugin SDK contract for MateClaw - external plugins depend only on this module</description>
|
|
||||||
|
|
||||||
<dependencies>
|
|
||||||
<!-- Spring AI core for ToolCallback and ChatModel. -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.ai</groupId>
|
|
||||||
<artifactId>spring-ai-model</artifactId>
|
|
||||||
<scope>provided</scope>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- Logging API -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.slf4j</groupId>
|
|
||||||
<artifactId>slf4j-api</artifactId>
|
|
||||||
<scope>provided</scope>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- Jackson for manifest parsing -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.fasterxml.jackson.core</groupId>
|
|
||||||
<artifactId>jackson-databind</artifactId>
|
|
||||||
<scope>provided</scope>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
|
||||||
</project>
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
package vip.mate.plugin.api;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Plugin SPI contract — all MateClaw plugins implement this interface.
|
|
||||||
*
|
|
||||||
* <p>Lifecycle:
|
|
||||||
* <ol>
|
|
||||||
* <li>{@link #onLoad(PluginContext)} — called when the plugin is loaded, platform context injected</li>
|
|
||||||
* <li>{@link #onEnable()} — called when the plugin is enabled, register features to the platform</li>
|
|
||||||
* <li>{@link #onDisable()} — called when the plugin is disabled, clean up resources</li>
|
|
||||||
* </ol>
|
|
||||||
*
|
|
||||||
* @author MateClaw Team
|
|
||||||
*/
|
|
||||||
public interface MateClawPlugin {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the plugin is loaded. Use the provided context to register
|
|
||||||
* tools, providers, channels, or memory providers.
|
|
||||||
*
|
|
||||||
* @param context platform context providing registration APIs
|
|
||||||
*/
|
|
||||||
void onLoad(PluginContext context);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the plugin is enabled. Perform any startup logic here.
|
|
||||||
*/
|
|
||||||
void onEnable();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the plugin is disabled. Clean up resources, close connections, etc.
|
|
||||||
*/
|
|
||||||
void onDisable();
|
|
||||||
}
|
|
||||||
@ -1,93 +0,0 @@
|
|||||||
package vip.mate.plugin.api;
|
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.springframework.ai.chat.model.ChatModel;
|
|
||||||
import org.springframework.ai.tool.ToolCallback;
|
|
||||||
import vip.mate.plugin.api.channel.PluginChannelAdapter;
|
|
||||||
import vip.mate.plugin.api.memory.PluginMemoryProvider;
|
|
||||||
import vip.mate.plugin.api.search.PluginSearchProvider;
|
|
||||||
|
|
||||||
import java.util.function.Supplier;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Platform API provided to plugins for registering capabilities.
|
|
||||||
*
|
|
||||||
* @author MateClaw Team
|
|
||||||
*/
|
|
||||||
public interface PluginContext {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register a tool that will be available to agents.
|
|
||||||
*
|
|
||||||
* @param tool the tool callback
|
|
||||||
*/
|
|
||||||
void registerTool(ToolCallback tool);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register a tool with an availability check.
|
|
||||||
* <p>
|
|
||||||
* The check function is evaluated lazily each time the agent tool set is built.
|
|
||||||
* When it returns {@code false}, the tool is silently excluded from the agent's
|
|
||||||
* available tools — useful for tools that require an external API key or dependency.
|
|
||||||
*
|
|
||||||
* @param tool the tool callback
|
|
||||||
* @param availabilityCheck returns true if the tool should be available
|
|
||||||
*/
|
|
||||||
void registerTool(ToolCallback tool, Supplier<Boolean> availabilityCheck);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register a custom LLM provider.
|
|
||||||
*
|
|
||||||
* @param providerId unique provider identifier
|
|
||||||
* @param chatModel the chat model implementation
|
|
||||||
*/
|
|
||||||
void registerProvider(String providerId, ChatModel chatModel);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register a messaging channel adapter.
|
|
||||||
*
|
|
||||||
* @param channel the channel adapter
|
|
||||||
*/
|
|
||||||
void registerChannel(PluginChannelAdapter channel);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register a memory provider.
|
|
||||||
* <p>
|
|
||||||
* Only one external memory provider is allowed at a time.
|
|
||||||
* If another plugin has already registered one, a {@link PluginException} is thrown.
|
|
||||||
*
|
|
||||||
* @param provider the memory provider
|
|
||||||
* @throws PluginException if an external memory provider is already registered
|
|
||||||
*/
|
|
||||||
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.
|
|
||||||
*
|
|
||||||
* @param key the config key
|
|
||||||
* @param type the expected type
|
|
||||||
* @param <T> the type
|
|
||||||
* @return the config value, or null if not set
|
|
||||||
*/
|
|
||||||
<T> T getConfig(String key, Class<T> type);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a logger instance for this plugin.
|
|
||||||
*
|
|
||||||
* @return the logger
|
|
||||||
*/
|
|
||||||
Logger getLogger();
|
|
||||||
}
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
package vip.mate.plugin.api;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Runtime exception for plugin-related errors.
|
|
||||||
*
|
|
||||||
* @author MateClaw Team
|
|
||||||
*/
|
|
||||||
public class PluginException extends RuntimeException {
|
|
||||||
|
|
||||||
public PluginException(String message) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
public PluginException(String message, Throwable cause) {
|
|
||||||
super(message, cause);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,156 +0,0 @@
|
|||||||
package vip.mate.plugin.api;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Plugin manifest model — deserialized from {@code mateclaw-plugin.json} in the plugin JAR root.
|
|
||||||
*
|
|
||||||
* @author MateClaw Team
|
|
||||||
*/
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
public class PluginManifest {
|
|
||||||
|
|
||||||
private String name;
|
|
||||||
private String version;
|
|
||||||
private String type;
|
|
||||||
private String displayName;
|
|
||||||
private String description;
|
|
||||||
private String entrypoint;
|
|
||||||
|
|
||||||
@JsonProperty("minPlatformVersion")
|
|
||||||
private String minPlatformVersion;
|
|
||||||
|
|
||||||
private String author;
|
|
||||||
|
|
||||||
private Map<String, ConfigField> config;
|
|
||||||
|
|
||||||
// ==================== Getters & Setters ====================
|
|
||||||
|
|
||||||
public String getName() { return name; }
|
|
||||||
public void setName(String name) { this.name = name; }
|
|
||||||
|
|
||||||
public String getVersion() { return version; }
|
|
||||||
public void setVersion(String version) { this.version = version; }
|
|
||||||
|
|
||||||
public String getType() { return type; }
|
|
||||||
public void setType(String type) { this.type = type; }
|
|
||||||
|
|
||||||
public String getDisplayName() { return displayName; }
|
|
||||||
public void setDisplayName(String displayName) { this.displayName = displayName; }
|
|
||||||
|
|
||||||
public String getDescription() { return description; }
|
|
||||||
public void setDescription(String description) { this.description = description; }
|
|
||||||
|
|
||||||
public String getEntrypoint() { return entrypoint; }
|
|
||||||
public void setEntrypoint(String entrypoint) { this.entrypoint = entrypoint; }
|
|
||||||
|
|
||||||
public String getMinPlatformVersion() { return minPlatformVersion; }
|
|
||||||
public void setMinPlatformVersion(String minPlatformVersion) { this.minPlatformVersion = minPlatformVersion; }
|
|
||||||
|
|
||||||
public String getAuthor() { return author; }
|
|
||||||
public void setAuthor(String author) { this.author = author; }
|
|
||||||
|
|
||||||
public Map<String, ConfigField> getConfig() { return config; }
|
|
||||||
public void setConfig(Map<String, ConfigField> config) { this.config = config; }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate that required fields are present.
|
|
||||||
*
|
|
||||||
* @throws PluginException if validation fails
|
|
||||||
*/
|
|
||||||
public void validate() {
|
|
||||||
if (name == null || name.isBlank()) {
|
|
||||||
throw new PluginException("Plugin manifest missing required field: name");
|
|
||||||
}
|
|
||||||
if (version == null || version.isBlank()) {
|
|
||||||
throw new PluginException("Plugin manifest missing required field: version");
|
|
||||||
}
|
|
||||||
if (entrypoint == null || entrypoint.isBlank()) {
|
|
||||||
throw new PluginException("Plugin manifest missing required field: entrypoint");
|
|
||||||
}
|
|
||||||
if (type == null || type.isBlank()) {
|
|
||||||
throw new PluginException("Plugin manifest missing required field: type");
|
|
||||||
}
|
|
||||||
// Validate type is a known enum value
|
|
||||||
try {
|
|
||||||
PluginType.valueOf(type.toUpperCase());
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
throw new PluginException("Unknown plugin type '" + type +
|
|
||||||
"'. Allowed: " + java.util.Arrays.toString(PluginType.values()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if this plugin is compatible with the given platform version.
|
|
||||||
*
|
|
||||||
* @param platformVersion current platform version (e.g. "1.1.0")
|
|
||||||
* @return true if compatible (minPlatformVersion <= platformVersion)
|
|
||||||
*/
|
|
||||||
public boolean isCompatibleWith(String platformVersion) {
|
|
||||||
if (minPlatformVersion == null || minPlatformVersion.isBlank()) {
|
|
||||||
return true; // No constraint
|
|
||||||
}
|
|
||||||
return compareVersions(platformVersion, minPlatformVersion) >= 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple semver comparison: returns negative if v1 < v2, 0 if equal, positive if v1 > v2.
|
|
||||||
*/
|
|
||||||
private static int compareVersions(String v1, String v2) {
|
|
||||||
String[] parts1 = v1.split("\\.");
|
|
||||||
String[] parts2 = v2.split("\\.");
|
|
||||||
int len = Math.max(parts1.length, parts2.length);
|
|
||||||
for (int i = 0; i < len; i++) {
|
|
||||||
int p1 = i < parts1.length ? parseIntSafe(parts1[i]) : 0;
|
|
||||||
int p2 = i < parts2.length ? parseIntSafe(parts2[i]) : 0;
|
|
||||||
if (p1 != p2) return Integer.compare(p1, p2);
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int parseIntSafe(String s) {
|
|
||||||
try {
|
|
||||||
// Strip non-numeric suffixes like "-SNAPSHOT"
|
|
||||||
return Integer.parseInt(s.replaceAll("[^0-9].*", ""));
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the plugin type as enum.
|
|
||||||
*/
|
|
||||||
public PluginType getPluginType() {
|
|
||||||
if (type == null) return null;
|
|
||||||
try {
|
|
||||||
return PluginType.valueOf(type.toUpperCase());
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
throw new PluginException("Unknown plugin type: " + type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Config Field ====================
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
public static class ConfigField {
|
|
||||||
private String type;
|
|
||||||
private boolean required;
|
|
||||||
private boolean secret;
|
|
||||||
private String description;
|
|
||||||
|
|
||||||
public String getType() { return type; }
|
|
||||||
public void setType(String type) { this.type = type; }
|
|
||||||
|
|
||||||
public boolean isRequired() { return required; }
|
|
||||||
public void setRequired(boolean required) { this.required = required; }
|
|
||||||
|
|
||||||
public boolean isSecret() { return secret; }
|
|
||||||
public void setSecret(boolean secret) { this.secret = secret; }
|
|
||||||
|
|
||||||
public String getDescription() { return description; }
|
|
||||||
public void setDescription(String description) { this.description = description; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
package vip.mate.plugin.api;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Plugin types covering the main extension scenarios.
|
|
||||||
*
|
|
||||||
* @author MateClaw Team
|
|
||||||
*/
|
|
||||||
public enum PluginType {
|
|
||||||
|
|
||||||
/** Register new agent tools */
|
|
||||||
TOOL,
|
|
||||||
|
|
||||||
/** Register new LLM providers */
|
|
||||||
PROVIDER,
|
|
||||||
|
|
||||||
/** Register new messaging channels */
|
|
||||||
CHANNEL,
|
|
||||||
|
|
||||||
/** Register new memory providers */
|
|
||||||
MEMORY,
|
|
||||||
|
|
||||||
/** Register new web-search providers for the web_search tool */
|
|
||||||
SEARCH
|
|
||||||
}
|
|
||||||
@ -1,78 +0,0 @@
|
|||||||
package vip.mate.plugin.api.channel;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simplified channel adapter interface for plugins.
|
|
||||||
* <p>
|
|
||||||
* Plugins implement this interface to register new messaging channels.
|
|
||||||
* The platform wraps it in an internal ChannelAdapter via a bridge.
|
|
||||||
*
|
|
||||||
* @author MateClaw Team
|
|
||||||
*/
|
|
||||||
public interface PluginChannelAdapter {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the channel (establish connections, register webhooks, etc.).
|
|
||||||
*/
|
|
||||||
void start();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the channel (disconnect, clean up resources).
|
|
||||||
*/
|
|
||||||
void stop();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the channel is currently running.
|
|
||||||
*/
|
|
||||||
boolean isRunning();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The channel type identifier, e.g. "line", "whatsapp".
|
|
||||||
*/
|
|
||||||
String getChannelType();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Human-readable display name.
|
|
||||||
*/
|
|
||||||
default String getDisplayName() {
|
|
||||||
return getChannelType();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a text message to a target.
|
|
||||||
*
|
|
||||||
* @param targetId target identifier (user/group/channel ID)
|
|
||||||
* @param content message content (Markdown format)
|
|
||||||
*/
|
|
||||||
void sendMessage(String targetId, String content);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle an incoming message from the channel.
|
|
||||||
* <p>
|
|
||||||
* The platform will call this when a webhook or push message arrives.
|
|
||||||
* Plugins should process the message and use their internal routing logic.
|
|
||||||
*
|
|
||||||
* @param senderId sender identifier
|
|
||||||
* @param content message text
|
|
||||||
* @param rawData raw message data (JSON string) for advanced processing
|
|
||||||
*/
|
|
||||||
default void onMessage(String senderId, String content, String rawData) {
|
|
||||||
// Default no-op; plugins override if they need to handle incoming messages
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this channel supports proactive sending (without webhook context).
|
|
||||||
*/
|
|
||||||
default boolean supportsProactiveSend() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Proactively send a message (without webhook callback context).
|
|
||||||
*
|
|
||||||
* @param targetId target identifier
|
|
||||||
* @param content message content
|
|
||||||
*/
|
|
||||||
default void proactiveSend(String targetId, String content) {
|
|
||||||
throw new UnsupportedOperationException(getChannelType() + " does not support proactive send");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,122 +0,0 @@
|
|||||||
package vip.mate.plugin.api.memory;
|
|
||||||
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Memory provider interface for plugins.
|
|
||||||
* <p>
|
|
||||||
* Mirrors the platform's internal MemoryProvider SPI with no server-internal dependencies.
|
|
||||||
* The platform wraps it via a bridge.
|
|
||||||
*
|
|
||||||
* @author MateClaw Team
|
|
||||||
*/
|
|
||||||
public interface PluginMemoryProvider extends AutoCloseable {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unique provider identifier, e.g. "vector_memory", "graph_memory".
|
|
||||||
*/
|
|
||||||
String id();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ordering for system prompt assembly and lifecycle dispatch.
|
|
||||||
* Lower values run first. Builtin = 0.
|
|
||||||
*/
|
|
||||||
default int order() {
|
|
||||||
return 200;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Runtime availability check. Should not make network calls.
|
|
||||||
*/
|
|
||||||
default boolean isAvailable() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* System prompt contribution. Called once at agent build time.
|
|
||||||
*
|
|
||||||
* @param agentId the agent ID
|
|
||||||
* @return text to include in system prompt, or empty string to skip
|
|
||||||
*/
|
|
||||||
default String systemPromptBlock(Long agentId) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pre-turn context recall. Called before each LLM API call.
|
|
||||||
*
|
|
||||||
* @param agentId the agent ID
|
|
||||||
* @param userQuery the current user message
|
|
||||||
* @return context text to inject, or empty string
|
|
||||||
*/
|
|
||||||
default String prefetch(Long agentId, String userQuery) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pre-turn context recall with per-owner isolation. Called by the platform
|
|
||||||
* when an owner key (e.g. {@code "user:42"}, {@code "feishu:sender_abc"})
|
|
||||||
* is resolved for the current conversation.
|
|
||||||
* <p>
|
|
||||||
* Default implementation degrades to the two-arg variant, dropping the
|
|
||||||
* owner key. External providers that need per-owner recall (e.g. Mem0)
|
|
||||||
* should override this to use {@code ownerKey} as their per-user identifier.
|
|
||||||
*
|
|
||||||
* @param agentId the agent ID
|
|
||||||
* @param userQuery the current user message
|
|
||||||
* @param ownerKey memory owner key (e.g. {@code "user:42"}), or null if unknown
|
|
||||||
* @return context text to inject, or empty string
|
|
||||||
*/
|
|
||||||
default String prefetch(Long agentId, String userQuery, String ownerKey) {
|
|
||||||
return prefetch(agentId, userQuery);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Post-turn sync. Called after LLM response is available.
|
|
||||||
* Should be non-blocking (async).
|
|
||||||
*/
|
|
||||||
default void syncTurn(Long agentId, String conversationId,
|
|
||||||
String userMessage, String assistantReply) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Post-turn sync with per-owner isolation. Called by the platform with the
|
|
||||||
* same {@code ownerKey} that was resolved for this turn's prefetch, so
|
|
||||||
* providers can persist the turn under the same per-user identifier they
|
|
||||||
* recall by.
|
|
||||||
* <p>
|
|
||||||
* Default implementation degrades to the four-arg variant, dropping the
|
|
||||||
* owner key. External providers that isolate memory per end-user should
|
|
||||||
* override this so that written memories stay reachable by owner-scoped
|
|
||||||
* recall.
|
|
||||||
*
|
|
||||||
* @param agentId the agent ID
|
|
||||||
* @param conversationId the conversation ID
|
|
||||||
* @param userMessage user's message text
|
|
||||||
* @param assistantReply assistant's reply text
|
|
||||||
* @param ownerKey memory owner key (e.g. {@code "user:42"}), or null if unknown
|
|
||||||
*/
|
|
||||||
default void syncTurn(Long agentId, String conversationId,
|
|
||||||
String userMessage, String assistantReply, String ownerKey) {
|
|
||||||
syncTurn(agentId, conversationId, userMessage, assistantReply);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tool beans this provider wants to expose to the agent.
|
|
||||||
*/
|
|
||||||
default List<Object> getToolBeans() {
|
|
||||||
return Collections.emptyList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Session end hook.
|
|
||||||
*/
|
|
||||||
default void onSessionEnd(Long agentId, String conversationId) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Release provider-owned resources when the plugin is unloaded. */
|
|
||||||
@Override
|
|
||||||
default void close() {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
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
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
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
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
<?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-mem0</artifactId>
|
|
||||||
<packaging>jar</packaging>
|
|
||||||
|
|
||||||
<name>MateClaw Mem0 Memory Provider Plugin</name>
|
|
||||||
<description>
|
|
||||||
Optional community plugin that bridges MateClaw's memory system to a self-hosted
|
|
||||||
Mem0 service (FastAPI + pgvector + Neo4j). Provides semantic recall via Mem0's
|
|
||||||
REST API alongside the built-in local memory providers. Not in the default stack;
|
|
||||||
users must deploy Mem0 separately and install this JAR into the plugins/ directory.
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<!-- Test only -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.junit.jupiter</groupId>
|
|
||||||
<artifactId>junit-jupiter</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.assertj</groupId>
|
|
||||||
<artifactId>assertj-core</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
<!-- slf4j-simple: gives the plugin a real logger during tests so
|
|
||||||
LoggerFactory.getLogger doesn't fall back to NOP silently -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.slf4j</groupId>
|
|
||||||
<artifactId>slf4j-simple</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
|
||||||
</project>
|
|
||||||
@ -1,182 +0,0 @@
|
|||||||
package vip.mate.plugin.mem0;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
|
||||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
|
||||||
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.http.HttpClient;
|
|
||||||
import java.net.http.HttpRequest;
|
|
||||||
import java.net.http.HttpResponse;
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thin HTTP client for a self-hosted Mem0 REST API.
|
|
||||||
* <p>
|
|
||||||
* Covers the two endpoints used by {@link Mem0Provider}:
|
|
||||||
* <ul>
|
|
||||||
* <li>{@code POST /memories/} — add a turn (user + assistant message) for extraction</li>
|
|
||||||
* <li>{@code POST /memories/search/} — semantic recall by query + user_id</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <p>Failure semantics: every call either returns a parsed result or throws
|
|
||||||
* {@link Mem0Exception}. Callers are expected to catch and degrade gracefully
|
|
||||||
* (return empty recall / log sync failures).
|
|
||||||
*
|
|
||||||
* @author MateClaw Team
|
|
||||||
*/
|
|
||||||
class Mem0Client {
|
|
||||||
|
|
||||||
private final Mem0Config config;
|
|
||||||
private final HttpClient http;
|
|
||||||
private final ObjectMapper mapper = new ObjectMapper();
|
|
||||||
|
|
||||||
Mem0Client(Mem0Config config) {
|
|
||||||
this.config = config;
|
|
||||||
this.http = HttpClient.newBuilder()
|
|
||||||
.connectTimeout(Duration.ofMillis(config.timeoutMs()))
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Push a conversation turn to Mem0 for extraction.
|
|
||||||
*
|
|
||||||
* @param userId Mem0 user_id, typically MateClaw's ownerKey
|
|
||||||
* @param agentId Mem0 agent_id, typically MateClaw's agentId
|
|
||||||
* @param conversationId optional conversation identifier (stored as metadata)
|
|
||||||
* @param userMessage user's message text
|
|
||||||
* @param assistantReply assistant's reply text
|
|
||||||
*/
|
|
||||||
void addMemories(String userId, String agentId, String conversationId,
|
|
||||||
String userMessage, String assistantReply) {
|
|
||||||
ObjectNode body = mapper.createObjectNode();
|
|
||||||
body.put("user_id", userId);
|
|
||||||
if (agentId != null && !agentId.isBlank()) {
|
|
||||||
body.put("agent_id", agentId);
|
|
||||||
}
|
|
||||||
ArrayNode messages = body.putArray("messages");
|
|
||||||
if (userMessage != null && !userMessage.isBlank()) {
|
|
||||||
ObjectNode m = messages.addObject();
|
|
||||||
m.put("role", "user");
|
|
||||||
m.put("content", userMessage);
|
|
||||||
}
|
|
||||||
if (assistantReply != null && !assistantReply.isBlank()) {
|
|
||||||
ObjectNode m = messages.addObject();
|
|
||||||
m.put("role", "assistant");
|
|
||||||
m.put("content", assistantReply);
|
|
||||||
}
|
|
||||||
if (conversationId != null && !conversationId.isBlank()) {
|
|
||||||
ObjectNode meta = body.putObject("metadata");
|
|
||||||
meta.put("conversation_id", conversationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
post("/memories/", body);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Semantic recall.
|
|
||||||
*
|
|
||||||
* @param userId Mem0 user_id (ownerKey)
|
|
||||||
* @param agentId Mem0 agent_id
|
|
||||||
* @param query user query text
|
|
||||||
* @return list of memory strings, possibly empty; never null
|
|
||||||
*/
|
|
||||||
List<String> searchMemories(String userId, String agentId, String query) {
|
|
||||||
ObjectNode body = mapper.createObjectNode();
|
|
||||||
body.put("query", query);
|
|
||||||
body.put("user_id", userId);
|
|
||||||
if (agentId != null && !agentId.isBlank()) {
|
|
||||||
body.put("agent_id", agentId);
|
|
||||||
}
|
|
||||||
body.put("limit", config.maxResults());
|
|
||||||
|
|
||||||
JsonNode resp = post("/memories/search/", body);
|
|
||||||
JsonNode results = resp.path("results");
|
|
||||||
List<String> out = new ArrayList<>();
|
|
||||||
if (results.isArray()) {
|
|
||||||
for (JsonNode r : results) {
|
|
||||||
String mem = r.path("memory").asText("");
|
|
||||||
if (!mem.isBlank()) {
|
|
||||||
out.add(mem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared POST helper. Returns the parsed JSON body on 2xx.
|
|
||||||
*
|
|
||||||
* @throws Mem0Exception on non-2xx response or IO error
|
|
||||||
*/
|
|
||||||
private JsonNode post(String path, ObjectNode body) {
|
|
||||||
String url = config.normalizedBaseUrl() + path;
|
|
||||||
try {
|
|
||||||
String payload = mapper.writeValueAsString(body);
|
|
||||||
HttpRequest.Builder req = HttpRequest.newBuilder(URI.create(url))
|
|
||||||
.timeout(Duration.ofMillis(config.timeoutMs()))
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(payload));
|
|
||||||
if (config.apiKey() != null && !config.apiKey().isBlank()) {
|
|
||||||
req.header("Authorization", "Bearer " + config.apiKey());
|
|
||||||
}
|
|
||||||
|
|
||||||
HttpResponse<String> resp = http.send(req.build(), HttpResponse.BodyHandlers.ofString());
|
|
||||||
int code = resp.statusCode();
|
|
||||||
if (code < 200 || code >= 300) {
|
|
||||||
throw new Mem0Exception("Mem0 " + path + " returned HTTP " + code
|
|
||||||
+ ": " + truncate(resp.body(), 500));
|
|
||||||
}
|
|
||||||
return mapper.readTree(resp.body() == null ? "{}" : resp.body());
|
|
||||||
} catch (Mem0Exception e) {
|
|
||||||
throw e;
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new Mem0Exception("Mem0 " + path + " request failed: " + e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String truncate(String s, int max) {
|
|
||||||
if (s == null) return "";
|
|
||||||
return s.length() > max ? s.substring(0, max) + "..." : s;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test-only accessor for verifying configuration wiring.
|
|
||||||
*/
|
|
||||||
Mem0Config config() {
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test-only helper to inspect what would be POSTed without sending.
|
|
||||||
* Builds the same payload as {@link #addMemories} and returns it as a Map.
|
|
||||||
*/
|
|
||||||
Map<String, Object> buildAddPayload(String userId, String agentId, String conversationId,
|
|
||||||
String userMessage, String assistantReply) {
|
|
||||||
ObjectNode body = mapper.createObjectNode();
|
|
||||||
body.put("user_id", userId);
|
|
||||||
if (agentId != null && !agentId.isBlank()) {
|
|
||||||
body.put("agent_id", agentId);
|
|
||||||
}
|
|
||||||
ArrayNode messages = body.putArray("messages");
|
|
||||||
if (userMessage != null && !userMessage.isBlank()) {
|
|
||||||
ObjectNode m = messages.addObject();
|
|
||||||
m.put("role", "user");
|
|
||||||
m.put("content", userMessage);
|
|
||||||
}
|
|
||||||
if (assistantReply != null && !assistantReply.isBlank()) {
|
|
||||||
ObjectNode m = messages.addObject();
|
|
||||||
m.put("role", "assistant");
|
|
||||||
m.put("content", assistantReply);
|
|
||||||
}
|
|
||||||
if (conversationId != null && !conversationId.isBlank()) {
|
|
||||||
ObjectNode meta = body.putObject("metadata");
|
|
||||||
meta.put("conversation_id", conversationId);
|
|
||||||
}
|
|
||||||
return mapper.convertValue(body, Map.class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
package vip.mate.plugin.mem0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mem0 plugin configuration snapshot.
|
|
||||||
* <p>
|
|
||||||
* Read once from {@link vip.mate.plugin.api.PluginContext#getConfig} at plugin
|
|
||||||
* load time and passed to {@link Mem0Client} / {@link Mem0Provider}. Snapshot
|
|
||||||
* semantics — config changes require a plugin reload.
|
|
||||||
*
|
|
||||||
* @param baseUrl Mem0 REST API base URL, e.g. {@code http://localhost:8080}
|
|
||||||
* @param apiKey optional bearer token; null/blank means no Authorization header
|
|
||||||
* @param searchEnabled whether prefetch should query Mem0 /memories/search/
|
|
||||||
* @param syncEnabled whether syncTurn should POST to Mem0 /memories/
|
|
||||||
* @param maxResults cap on memories returned per recall
|
|
||||||
* @param timeoutMs HTTP timeout for both recall and sync
|
|
||||||
* @param syncQueueCapacity maximum number of turns waiting for asynchronous sync
|
|
||||||
* @author MateClaw Team
|
|
||||||
*/
|
|
||||||
record Mem0Config(
|
|
||||||
String baseUrl,
|
|
||||||
String apiKey,
|
|
||||||
boolean searchEnabled,
|
|
||||||
boolean syncEnabled,
|
|
||||||
int maxResults,
|
|
||||||
int timeoutMs,
|
|
||||||
int syncQueueCapacity
|
|
||||||
) {
|
|
||||||
static final int DEFAULT_MAX_RESULTS = 5;
|
|
||||||
static final int DEFAULT_TIMEOUT_MS = 3000;
|
|
||||||
static final int DEFAULT_SYNC_QUEUE_CAPACITY = 256;
|
|
||||||
|
|
||||||
Mem0Config(String baseUrl, String apiKey, boolean searchEnabled, boolean syncEnabled,
|
|
||||||
int maxResults, int timeoutMs) {
|
|
||||||
this(baseUrl, apiKey, searchEnabled, syncEnabled, maxResults, timeoutMs,
|
|
||||||
DEFAULT_SYNC_QUEUE_CAPACITY);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this provider should participate at all.
|
|
||||||
* Mem0 without a base URL is unusable; treat as unavailable.
|
|
||||||
*/
|
|
||||||
boolean isUsable() {
|
|
||||||
return baseUrl != null && !baseUrl.isBlank();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Strip trailing slashes from the base URL to avoid double-slash in path joins.
|
|
||||||
*/
|
|
||||||
String normalizedBaseUrl() {
|
|
||||||
String url = baseUrl;
|
|
||||||
while (url.endsWith("/")) {
|
|
||||||
url = url.substring(0, url.length() - 1);
|
|
||||||
}
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
package vip.mate.plugin.mem0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Raised when a Mem0 REST call fails (non-2xx response, IO error, timeout).
|
|
||||||
* <p>
|
|
||||||
* Sync failures are caught by {@link Mem0Provider}; recall failures propagate
|
|
||||||
* to the platform provider boundary for timeout/circuit-breaker accounting.
|
|
||||||
*
|
|
||||||
* @author MateClaw Team
|
|
||||||
*/
|
|
||||||
class Mem0Exception extends RuntimeException {
|
|
||||||
|
|
||||||
Mem0Exception(String message) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
Mem0Exception(String message, Throwable cause) {
|
|
||||||
super(message, cause);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,116 +0,0 @@
|
|||||||
package vip.mate.plugin.mem0;
|
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import vip.mate.plugin.api.MateClawPlugin;
|
|
||||||
import vip.mate.plugin.api.PluginContext;
|
|
||||||
|
|
||||||
import java.net.URI;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MateClaw plugin entrypoint that registers {@link Mem0Provider} with the
|
|
||||||
* platform's memory subsystem.
|
|
||||||
* <p>
|
|
||||||
* Lifecycle:
|
|
||||||
* <ol>
|
|
||||||
* <li>{@code onLoad} — read config from {@link PluginContext}, build
|
|
||||||
* {@link Mem0Config} → {@link Mem0Client} → {@link Mem0Provider},
|
|
||||||
* then {@code context.registerMemoryProvider(provider)}.
|
|
||||||
* If the config is incomplete (no baseUrl), the provider is registered
|
|
||||||
* but reports {@code isAvailable()=false} — the platform silently
|
|
||||||
* skips it.</li>
|
|
||||||
* <li>{@code onEnable} / {@code onDisable} — lifecycle log only.</li>
|
|
||||||
* </ol>
|
|
||||||
*
|
|
||||||
* <p>This plugin is NOT part of the default stack. Users must:
|
|
||||||
* <ol>
|
|
||||||
* <li>Self-host a Mem0 service (FastAPI + pgvector + optional Neo4j)</li>
|
|
||||||
* <li>Drop the built JAR into the platform's {@code plugins/} directory</li>
|
|
||||||
* <li>Configure {@code baseUrl} (and optionally {@code apiKey}) via the
|
|
||||||
* plugin admin UI</li>
|
|
||||||
* </ol>
|
|
||||||
*
|
|
||||||
* @author MateClaw Team
|
|
||||||
*/
|
|
||||||
public class Mem0Plugin implements MateClawPlugin {
|
|
||||||
|
|
||||||
private static final String CONFIG_BASE_URL = "baseUrl";
|
|
||||||
private static final String CONFIG_API_KEY = "apiKey";
|
|
||||||
private static final String CONFIG_SEARCH_ENABLED = "searchEnabled";
|
|
||||||
private static final String CONFIG_SYNC_ENABLED = "syncEnabled";
|
|
||||||
private static final String CONFIG_MAX_RESULTS = "maxResults";
|
|
||||||
private static final String CONFIG_TIMEOUT_MS = "timeoutMs";
|
|
||||||
private static final String CONFIG_SYNC_QUEUE_CAPACITY = "syncQueueCapacity";
|
|
||||||
|
|
||||||
private Logger log;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onLoad(PluginContext context) {
|
|
||||||
this.log = context.getLogger();
|
|
||||||
|
|
||||||
Mem0Config config = readConfig(context);
|
|
||||||
if (!config.isUsable()) {
|
|
||||||
log.warn("Mem0 plugin loaded without baseUrl — provider will stay unavailable. "
|
|
||||||
+ "Configure 'baseUrl' in the plugin config to enable.");
|
|
||||||
}
|
|
||||||
|
|
||||||
Mem0Client client = new Mem0Client(config);
|
|
||||||
Mem0Provider provider = new Mem0Provider(config, client, log);
|
|
||||||
try {
|
|
||||||
context.registerMemoryProvider(provider);
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
provider.close();
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("Mem0 plugin loaded: baseUrl={}, searchEnabled={}, syncEnabled={}, maxResults={}, timeoutMs={}, syncQueueCapacity={}",
|
|
||||||
maskUrl(config.baseUrl()), config.searchEnabled(), config.syncEnabled(),
|
|
||||||
config.maxResults(), config.timeoutMs(), config.syncQueueCapacity());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onEnable() {
|
|
||||||
if (log != null) log.info("Mem0 plugin enabled");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onDisable() {
|
|
||||||
if (log != null) log.info("Mem0 plugin disabled");
|
|
||||||
}
|
|
||||||
|
|
||||||
private Mem0Config readConfig(PluginContext ctx) {
|
|
||||||
String baseUrl = ctx.getConfig(CONFIG_BASE_URL, String.class);
|
|
||||||
String apiKey = ctx.getConfig(CONFIG_API_KEY, String.class);
|
|
||||||
Boolean searchEnabled = ctx.getConfig(CONFIG_SEARCH_ENABLED, Boolean.class);
|
|
||||||
Boolean syncEnabled = ctx.getConfig(CONFIG_SYNC_ENABLED, Boolean.class);
|
|
||||||
Integer maxResults = ctx.getConfig(CONFIG_MAX_RESULTS, Integer.class);
|
|
||||||
Integer timeoutMs = ctx.getConfig(CONFIG_TIMEOUT_MS, Integer.class);
|
|
||||||
Integer syncQueueCapacity = ctx.getConfig(CONFIG_SYNC_QUEUE_CAPACITY, Integer.class);
|
|
||||||
|
|
||||||
return new Mem0Config(
|
|
||||||
baseUrl,
|
|
||||||
apiKey,
|
|
||||||
searchEnabled == null ? true : searchEnabled,
|
|
||||||
syncEnabled == null ? true : syncEnabled,
|
|
||||||
maxResults == null ? Mem0Config.DEFAULT_MAX_RESULTS : maxResults,
|
|
||||||
timeoutMs == null ? Mem0Config.DEFAULT_TIMEOUT_MS : timeoutMs,
|
|
||||||
syncQueueCapacity == null ? Mem0Config.DEFAULT_SYNC_QUEUE_CAPACITY
|
|
||||||
: Math.max(1, syncQueueCapacity)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mask credentials in the URL when logging. Keeps the scheme + host,
|
|
||||||
* strips any user info and path.
|
|
||||||
*/
|
|
||||||
private static String maskUrl(String url) {
|
|
||||||
if (url == null || url.isBlank()) return "(unset)";
|
|
||||||
try {
|
|
||||||
URI u = URI.create(url);
|
|
||||||
String host = u.getHost();
|
|
||||||
int port = u.getPort();
|
|
||||||
return u.getScheme() + "://" + host + (port > 0 ? ":" + port : "");
|
|
||||||
} catch (Exception e) {
|
|
||||||
return "(malformed)";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,207 +0,0 @@
|
|||||||
package vip.mate.plugin.mem0;
|
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import vip.mate.plugin.api.memory.PluginMemoryProvider;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.concurrent.ArrayBlockingQueue;
|
|
||||||
import java.util.concurrent.RejectedExecutionException;
|
|
||||||
import java.util.concurrent.ThreadPoolExecutor;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Memory provider that bridges MateClaw's per-turn lifecycle to a self-hosted
|
|
||||||
* Mem0 service.
|
|
||||||
* <p>
|
|
||||||
* Behavior matrix:
|
|
||||||
* <ul>
|
|
||||||
* <li>{@code systemPromptBlock} — no-op (returns ""), aligns with SessionSearchProvider</li>
|
|
||||||
* <li>{@code prefetch(agentId, query, ownerKey)} — when {@code searchEnabled}
|
|
||||||
* and {@code ownerKey} is non-blank, calls {@code POST /memories/search/}
|
|
||||||
* and returns a {@code [Mem0 Recall]} block. Failures propagate to the
|
|
||||||
* platform's timeout/circuit-breaker boundary.</li>
|
|
||||||
* <li>{@code syncTurn(agentId, conversationId, messages, ownerKey)} — when
|
|
||||||
* {@code syncEnabled} and {@code ownerKey} is non-blank, asynchronously
|
|
||||||
* pushes the turn to {@code POST /memories/} under {@code user_id =
|
|
||||||
* ownerKey}, the same identifier prefetch recalls by. Failures are
|
|
||||||
* logged and swallowed; never blocks the response path. The bounded
|
|
||||||
* queue drops new writes when saturated. The four-arg
|
|
||||||
* variant (no ownerKey) skips — writing under any other identifier
|
|
||||||
* would produce memories that owner-scoped recall can never surface.</li>
|
|
||||||
* <li>{@code getToolBeans} — empty (no agent-facing tools in v1)</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <p>Per-owner isolation: {@code ownerKey} (e.g. {@code "user:42"}) is passed
|
|
||||||
* verbatim as Mem0's {@code user_id}; {@code agentId} as Mem0's {@code agent_id}.
|
|
||||||
* When {@code ownerKey} is null/blank, both recall and sync are skipped — Mem0
|
|
||||||
* requires {@code user_id}.
|
|
||||||
*
|
|
||||||
* <p>Asynchronous sync: a single-thread daemon executor with a bounded queue
|
|
||||||
* prevents an unavailable Mem0 service from growing heap usage without limit.
|
|
||||||
*
|
|
||||||
* @author MateClaw Team
|
|
||||||
*/
|
|
||||||
class Mem0Provider implements PluginMemoryProvider {
|
|
||||||
|
|
||||||
static final String ID = "mem0";
|
|
||||||
|
|
||||||
private final Mem0Config config;
|
|
||||||
private final Mem0Client client;
|
|
||||||
private final Logger log;
|
|
||||||
private final ThreadPoolExecutor async;
|
|
||||||
private final AtomicLong droppedSyncCount = new AtomicLong();
|
|
||||||
|
|
||||||
Mem0Provider(Mem0Config config, Mem0Client client, Logger log) {
|
|
||||||
this.config = config;
|
|
||||||
this.client = client;
|
|
||||||
this.log = log;
|
|
||||||
this.async = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS,
|
|
||||||
new ArrayBlockingQueue<>(Math.max(1, config.syncQueueCapacity())), r -> {
|
|
||||||
Thread t = new Thread(r, "mem0-sync");
|
|
||||||
t.setDaemon(true);
|
|
||||||
return t;
|
|
||||||
}, new ThreadPoolExecutor.AbortPolicy());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String id() {
|
|
||||||
return ID;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int order() {
|
|
||||||
// Same as the SPI default; declared explicitly for clarity.
|
|
||||||
return 200;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean isAvailable() {
|
|
||||||
// Provider is "available" if at least one of recall/sync can fire.
|
|
||||||
return config.isUsable() && (config.searchEnabled() || config.syncEnabled());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String systemPromptBlock(Long agentId) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String prefetch(Long agentId, String userQuery) {
|
|
||||||
// Two-arg variant: no owner key → cannot isolate per-user → skip.
|
|
||||||
// Mem0 requires user_id; without it the call would either fail or
|
|
||||||
// return global memories breaking per-owner isolation.
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String prefetch(Long agentId, String userQuery, String ownerKey) {
|
|
||||||
if (!config.searchEnabled()) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
if (ownerKey == null || ownerKey.isBlank()) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
if (userQuery == null || userQuery.isBlank()) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
List<String> memories = client.searchMemories(
|
|
||||||
ownerKey, agentId == null ? null : agentId.toString(), userQuery);
|
|
||||||
if (memories.isEmpty()) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return formatRecallBlock(memories);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void syncTurn(Long agentId, String conversationId,
|
|
||||||
String userMessage, String assistantReply) {
|
|
||||||
// Four-arg variant: no owner key → skip. Mem0 keys memories by user_id;
|
|
||||||
// writing under any fallback identifier (e.g. agentId) would store
|
|
||||||
// memories that owner-scoped prefetch can never recall.
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void syncTurn(Long agentId, String conversationId,
|
|
||||||
String userMessage, String assistantReply, String ownerKey) {
|
|
||||||
if (!config.syncEnabled()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (ownerKey == null || ownerKey.isBlank()) {
|
|
||||||
// Same guard as prefetch: Mem0 requires user_id; without the owner
|
|
||||||
// key the write would break per-owner isolation.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if ((userMessage == null || userMessage.isBlank())
|
|
||||||
&& (assistantReply == null || assistantReply.isBlank())) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
async.execute(() -> {
|
|
||||||
try {
|
|
||||||
client.addMemories(ownerKey, agentId == null ? null : agentId.toString(),
|
|
||||||
conversationId, userMessage, assistantReply);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.debug("[Mem0] syncTurn failed for agent={} owner={}: {}",
|
|
||||||
agentId, ownerKey, e.getMessage());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (RejectedExecutionException e) {
|
|
||||||
long dropped = droppedSyncCount.incrementAndGet();
|
|
||||||
log.warn("[Mem0] sync queue full or provider closed; dropped turn for agent={} owner={} (totalDropped={})",
|
|
||||||
agentId, ownerKey, dropped);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int queuedSyncCount() {
|
|
||||||
return async.getQueue().size();
|
|
||||||
}
|
|
||||||
|
|
||||||
long droppedSyncCount() {
|
|
||||||
return droppedSyncCount.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean isClosed() {
|
|
||||||
return async.isShutdown();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void close() {
|
|
||||||
async.shutdown();
|
|
||||||
List<Runnable> dropped = List.of();
|
|
||||||
try {
|
|
||||||
long drainMs = Math.min(1000L, Math.max(100L, config.timeoutMs()));
|
|
||||||
if (!async.awaitTermination(drainMs, TimeUnit.MILLISECONDS)) {
|
|
||||||
dropped = async.shutdownNow();
|
|
||||||
}
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
dropped = async.shutdownNow();
|
|
||||||
}
|
|
||||||
if (!dropped.isEmpty()) {
|
|
||||||
droppedSyncCount.addAndGet(dropped.size());
|
|
||||||
log.warn("[Mem0] provider closed with {} queued sync turn(s) discarded", dropped.size());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onSessionEnd(Long agentId, String conversationId) {
|
|
||||||
// No Mem0-specific session cleanup needed in v1.
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format the recalled memories into a labeled block.
|
|
||||||
* <p>
|
|
||||||
* The {@code [Mem0 Recall]} label is intentional: it lets the LLM
|
|
||||||
* distinguish this block from the local providers' output and avoid
|
|
||||||
* treating it as authoritative PROFILE.md content.
|
|
||||||
*/
|
|
||||||
private String formatRecallBlock(List<String> memories) {
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
sb.append("[Mem0 Recall — semantic matches from external service, treat as hints]\n");
|
|
||||||
for (int i = 0; i < memories.size(); i++) {
|
|
||||||
sb.append(i + 1).append(". ").append(memories.get(i)).append('\n');
|
|
||||||
}
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "mateclaw-plugin-mem0",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"type": "memory",
|
|
||||||
"displayName": "Mem0 Memory Provider (Optional)",
|
|
||||||
"description": "Bridges MateClaw's memory system to a self-hosted Mem0 service. Adds semantic recall from Mem0 alongside the built-in local memory providers. Requires a separately deployed Mem0 service (FastAPI + pgvector). Not part of the default stack.",
|
|
||||||
"entrypoint": "vip.mate.plugin.mem0.Mem0Plugin",
|
|
||||||
"minPlatformVersion": "2.0.0",
|
|
||||||
"author": "MateClaw Team",
|
|
||||||
"config": {
|
|
||||||
"baseUrl": {
|
|
||||||
"type": "string",
|
|
||||||
"required": true,
|
|
||||||
"secret": false,
|
|
||||||
"description": "Mem0 REST API base URL, e.g. http://localhost:8080"
|
|
||||||
},
|
|
||||||
"apiKey": {
|
|
||||||
"type": "string",
|
|
||||||
"required": false,
|
|
||||||
"secret": true,
|
|
||||||
"description": "Optional bearer token sent as Authorization header to Mem0"
|
|
||||||
},
|
|
||||||
"searchEnabled": {
|
|
||||||
"type": "boolean",
|
|
||||||
"required": false,
|
|
||||||
"secret": false,
|
|
||||||
"description": "Enable semantic recall via Mem0 /memories/search/. Default true."
|
|
||||||
},
|
|
||||||
"syncEnabled": {
|
|
||||||
"type": "boolean",
|
|
||||||
"required": false,
|
|
||||||
"secret": false,
|
|
||||||
"description": "Enable pushing each turn to Mem0 /memories/. Default true."
|
|
||||||
},
|
|
||||||
"maxResults": {
|
|
||||||
"type": "integer",
|
|
||||||
"required": false,
|
|
||||||
"secret": false,
|
|
||||||
"description": "Max number of memories returned per recall. Default 5."
|
|
||||||
},
|
|
||||||
"timeoutMs": {
|
|
||||||
"type": "integer",
|
|
||||||
"required": false,
|
|
||||||
"secret": false,
|
|
||||||
"description": "HTTP timeout in milliseconds for both recall and sync. Default 3000."
|
|
||||||
},
|
|
||||||
"syncQueueCapacity": {
|
|
||||||
"type": "integer",
|
|
||||||
"required": false,
|
|
||||||
"secret": false,
|
|
||||||
"description": "Maximum pending asynchronous sync turns. New writes are dropped when full. Default 256."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,163 +0,0 @@
|
|||||||
package vip.mate.plugin.mem0;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import com.sun.net.httpserver.HttpExchange;
|
|
||||||
import com.sun.net.httpserver.HttpHandler;
|
|
||||||
import com.sun.net.httpserver.HttpServer;
|
|
||||||
import org.junit.jupiter.api.AfterEach;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.InputStream;
|
|
||||||
import java.net.InetSocketAddress;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|
||||||
|
|
||||||
class Mem0ClientTest {
|
|
||||||
|
|
||||||
private HttpServer server;
|
|
||||||
private Mem0Client client;
|
|
||||||
private final AtomicReference<String> lastPath = new AtomicReference<>();
|
|
||||||
private final AtomicReference<String> lastBody = new AtomicReference<>();
|
|
||||||
private final AtomicReference<String> lastAuthHeader = new AtomicReference<>();
|
|
||||||
private final ObjectMapper mapper = new ObjectMapper();
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() throws IOException {
|
|
||||||
// Capture request details so each test can assert what was sent.
|
|
||||||
HttpHandler handler = this::handle;
|
|
||||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
|
||||||
server.createContext("/", handler);
|
|
||||||
server.start();
|
|
||||||
|
|
||||||
String baseUrl = "http://127.0.0.1:" + server.getAddress().getPort();
|
|
||||||
Mem0Config config = new Mem0Config(baseUrl, "test-token", true, true, 5, 3000);
|
|
||||||
client = new Mem0Client(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterEach
|
|
||||||
void tearDown() {
|
|
||||||
if (server != null) server.stop(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handle(HttpExchange exchange) throws IOException {
|
|
||||||
lastPath.set(exchange.getRequestURI().getPath());
|
|
||||||
lastAuthHeader.set(exchange.getRequestHeaders().getFirst("Authorization"));
|
|
||||||
try (InputStream in = exchange.getRequestBody()) {
|
|
||||||
lastBody.set(new String(in.readAllBytes(), StandardCharsets.UTF_8));
|
|
||||||
}
|
|
||||||
String path = exchange.getRequestURI().getPath();
|
|
||||||
if ("/memories/".equals(path) || "/memories/search/".equals(path)) {
|
|
||||||
byte[] resp;
|
|
||||||
if ("/memories/".equals(path)) {
|
|
||||||
resp = "{\"results\":[{\"id\":\"m1\",\"memory\":\"x\",\"event\":\"ADD\"}]}".getBytes(StandardCharsets.UTF_8);
|
|
||||||
} else {
|
|
||||||
resp = "{\"results\":[{\"id\":\"m1\",\"memory\":\"likes Go\",\"score\":0.9},{\"id\":\"m2\",\"memory\":\"works at Acme\",\"score\":0.7}]}".getBytes(StandardCharsets.UTF_8);
|
|
||||||
}
|
|
||||||
exchange.getResponseHeaders().add("Content-Type", "application/json");
|
|
||||||
exchange.sendResponseHeaders(200, resp.length);
|
|
||||||
exchange.getResponseBody().write(resp);
|
|
||||||
} else {
|
|
||||||
byte[] resp = "{\"error\":\"not found\"}".getBytes(StandardCharsets.UTF_8);
|
|
||||||
exchange.sendResponseHeaders(404, resp.length);
|
|
||||||
exchange.getResponseBody().write(resp);
|
|
||||||
}
|
|
||||||
exchange.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void addMemories_postsToMemoriesEndpointWithCorrectPayload() throws Exception {
|
|
||||||
client.addMemories("user:42", "1", "conv-abc", "hello", "world");
|
|
||||||
|
|
||||||
assertThat(lastPath.get()).isEqualTo("/memories/");
|
|
||||||
assertThat(lastAuthHeader.get()).isEqualTo("Bearer test-token");
|
|
||||||
|
|
||||||
JsonNode body = mapper.readTree(lastBody.get());
|
|
||||||
assertThat(body.get("user_id").asText()).isEqualTo("user:42");
|
|
||||||
assertThat(body.get("agent_id").asText()).isEqualTo("1");
|
|
||||||
assertThat(body.get("metadata").get("conversation_id").asText()).isEqualTo("conv-abc");
|
|
||||||
assertThat(body.get("messages").size()).isEqualTo(2);
|
|
||||||
assertThat(body.get("messages").get(0).get("role").asText()).isEqualTo("user");
|
|
||||||
assertThat(body.get("messages").get(0).get("content").asText()).isEqualTo("hello");
|
|
||||||
assertThat(body.get("messages").get(1).get("role").asText()).isEqualTo("assistant");
|
|
||||||
assertThat(body.get("messages").get(1).get("content").asText()).isEqualTo("world");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void addMemories_omitsBlankMessages() throws Exception {
|
|
||||||
client.addMemories("user:42", "1", null, " ", "reply");
|
|
||||||
|
|
||||||
JsonNode body = mapper.readTree(lastBody.get());
|
|
||||||
assertThat(body.get("messages").size()).isEqualTo(1);
|
|
||||||
assertThat(body.get("messages").get(0).get("role").asText()).isEqualTo("assistant");
|
|
||||||
// metadata should be absent since conversationId is null
|
|
||||||
assertThat(body.has("metadata")).isFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void searchMemories_returnsParsedMemoryStrings() {
|
|
||||||
List<String> results = client.searchMemories("user:42", "1", "what language");
|
|
||||||
|
|
||||||
assertThat(results).containsExactly("likes Go", "works at Acme");
|
|
||||||
|
|
||||||
assertThat(lastPath.get()).isEqualTo("/memories/search/");
|
|
||||||
assertThat(lastAuthHeader.get()).isEqualTo("Bearer test-token");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void searchMemories_includesQueryUserIdAndLimitInBody() throws Exception {
|
|
||||||
client.searchMemories("user:42", "1", "query text");
|
|
||||||
|
|
||||||
JsonNode body = mapper.readTree(lastBody.get());
|
|
||||||
assertThat(body.get("query").asText()).isEqualTo("query text");
|
|
||||||
assertThat(body.get("user_id").asText()).isEqualTo("user:42");
|
|
||||||
assertThat(body.get("agent_id").asText()).isEqualTo("1");
|
|
||||||
assertThat(body.get("limit").asInt()).isEqualTo(5); // from Mem0Config in setUp
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void non2xxResponseThrowsMem0Exception() {
|
|
||||||
// Use a client pointed at a non-existent path on the running server.
|
|
||||||
// Reconfigure handler to return 500 for the next call.
|
|
||||||
server.removeContext("/");
|
|
||||||
server.createContext("/", ex -> {
|
|
||||||
ex.sendResponseHeaders(500, 0);
|
|
||||||
ex.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> client.searchMemories("user:42", "1", "q"))
|
|
||||||
.isInstanceOf(Mem0Exception.class)
|
|
||||||
.hasMessageContaining("HTTP 500");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void connectionFailureThrowsMem0Exception() {
|
|
||||||
// Stop the server, then call — should fail with connection refused.
|
|
||||||
int port = server.getAddress().getPort();
|
|
||||||
server.stop(0);
|
|
||||||
Mem0Config cfg = new Mem0Config("http://127.0.0.1:" + port, null, true, true, 5, 500);
|
|
||||||
Mem0Client deadClient = new Mem0Client(cfg);
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> deadClient.searchMemories("user:42", "1", "q"))
|
|
||||||
.isInstanceOf(Mem0Exception.class)
|
|
||||||
.hasMessageContaining("request failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void buildAddPayload_isConsistentWithAddMemories() {
|
|
||||||
// buildAddPayload is a test helper used to inspect payload structure
|
|
||||||
// without sending; verify it matches what addMemories would send.
|
|
||||||
Map<String, Object> payload = client.buildAddPayload("user:42", "1", "conv-x", "hi", "there");
|
|
||||||
assertThat(payload).containsEntry("user_id", "user:42");
|
|
||||||
assertThat(payload).containsEntry("agent_id", "1");
|
|
||||||
assertThat(payload).containsKey("messages");
|
|
||||||
assertThat(payload).containsKey("metadata");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
package vip.mate.plugin.mem0;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
class Mem0ConfigTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void isUsable_false_whenBaseUrlNull() {
|
|
||||||
Mem0Config c = new Mem0Config(null, null, true, true, 5, 1000);
|
|
||||||
assertThat(c.isUsable()).isFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void isUsable_false_whenBaseUrlBlank() {
|
|
||||||
Mem0Config c = new Mem0Config(" ", null, true, true, 5, 1000);
|
|
||||||
assertThat(c.isUsable()).isFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void isUsable_true_whenBaseUrlSet() {
|
|
||||||
Mem0Config c = new Mem0Config("http://localhost:8080", null, true, true, 5, 1000);
|
|
||||||
assertThat(c.isUsable()).isTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void normalizedBaseUrl_stripsTrailingSlashes() {
|
|
||||||
Mem0Config c = new Mem0Config("http://localhost:8080///", null, true, true, 5, 1000);
|
|
||||||
assertThat(c.normalizedBaseUrl()).isEqualTo("http://localhost:8080");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void normalizedBaseUrl_keepsUrlWithoutTrailingSlash() {
|
|
||||||
Mem0Config c = new Mem0Config("http://localhost:8080", null, true, true, 5, 1000);
|
|
||||||
assertThat(c.normalizedBaseUrl()).isEqualTo("http://localhost:8080");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void legacyConstructorUsesBoundedQueueDefault() {
|
|
||||||
Mem0Config c = new Mem0Config("http://localhost:8080", null, true, true, 5, 1000);
|
|
||||||
assertThat(c.syncQueueCapacity()).isEqualTo(Mem0Config.DEFAULT_SYNC_QUEUE_CAPACITY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
package vip.mate.plugin.mem0;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.ai.chat.model.ChatModel;
|
|
||||||
import org.springframework.ai.tool.ToolCallback;
|
|
||||||
import vip.mate.plugin.api.PluginContext;
|
|
||||||
import vip.mate.plugin.api.PluginException;
|
|
||||||
import vip.mate.plugin.api.channel.PluginChannelAdapter;
|
|
||||||
import vip.mate.plugin.api.memory.PluginMemoryProvider;
|
|
||||||
import vip.mate.plugin.api.search.PluginSearchProvider;
|
|
||||||
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
|
||||||
import java.util.function.Supplier;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|
||||||
|
|
||||||
class Mem0PluginTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void onLoad_readsConfigAndRegistersProvider() {
|
|
||||||
Map<String, Object> config = new HashMap<>();
|
|
||||||
config.put("baseUrl", "http://localhost:8080");
|
|
||||||
config.put("apiKey", "secret");
|
|
||||||
config.put("searchEnabled", true);
|
|
||||||
config.put("syncEnabled", false);
|
|
||||||
config.put("maxResults", 7);
|
|
||||||
config.put("timeoutMs", 5000);
|
|
||||||
|
|
||||||
AtomicReference<PluginMemoryProvider> registered = new AtomicReference<>();
|
|
||||||
PluginContext ctx = new StubContext(config, registered);
|
|
||||||
|
|
||||||
Mem0Plugin plugin = new Mem0Plugin();
|
|
||||||
plugin.onLoad(ctx);
|
|
||||||
plugin.onEnable();
|
|
||||||
|
|
||||||
PluginMemoryProvider p = registered.get();
|
|
||||||
assertThat(p).isNotNull();
|
|
||||||
assertThat(p.id()).isEqualTo("mem0");
|
|
||||||
assertThat(p.isAvailable()).isTrue(); // baseUrl set + searchEnabled true
|
|
||||||
|
|
||||||
plugin.onDisable();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void onLoad_withMissingBaseUrl_stillRegistersButUnavailable() {
|
|
||||||
// No baseUrl configured — plugin should register but report unavailable
|
|
||||||
// rather than throwing.
|
|
||||||
Map<String, Object> config = new HashMap<>(); // empty
|
|
||||||
AtomicReference<PluginMemoryProvider> registered = new AtomicReference<>();
|
|
||||||
PluginContext ctx = new StubContext(config, registered);
|
|
||||||
|
|
||||||
Mem0Plugin plugin = new Mem0Plugin();
|
|
||||||
plugin.onLoad(ctx);
|
|
||||||
|
|
||||||
PluginMemoryProvider p = registered.get();
|
|
||||||
assertThat(p).isNotNull();
|
|
||||||
assertThat(p.isAvailable()).isFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void onLoad_appliesDefaultsToOptionalConfig() {
|
|
||||||
// Only baseUrl set — searchEnabled/syncEnabled/maxResults/timeoutMs
|
|
||||||
// should default.
|
|
||||||
Map<String, Object> config = new HashMap<>();
|
|
||||||
config.put("baseUrl", "http://localhost:8080");
|
|
||||||
|
|
||||||
AtomicReference<PluginMemoryProvider> registered = new AtomicReference<>();
|
|
||||||
PluginContext ctx = new StubContext(config, registered);
|
|
||||||
|
|
||||||
Mem0Plugin plugin = new Mem0Plugin();
|
|
||||||
plugin.onLoad(ctx);
|
|
||||||
|
|
||||||
// Verify defaults indirectly: searchEnabled and syncEnabled both default
|
|
||||||
// to true → isAvailable() must be true.
|
|
||||||
assertThat(registered.get().isAvailable()).isTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void onLoad_throwsWhenContextRejectsSecondProvider() {
|
|
||||||
// Simulate the platform's single-select constraint by throwing from
|
|
||||||
// registerMemoryProvider.
|
|
||||||
Map<String, Object> config = new HashMap<>();
|
|
||||||
config.put("baseUrl", "http://localhost:8080");
|
|
||||||
AtomicReference<PluginMemoryProvider> registered = new AtomicReference<>();
|
|
||||||
PluginContext ctx = new StubContext(config, registered) {
|
|
||||||
@Override
|
|
||||||
public void registerMemoryProvider(PluginMemoryProvider provider) {
|
|
||||||
registered.set(provider);
|
|
||||||
throw new PluginException("Only one external memory provider allowed");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Mem0Plugin plugin = new Mem0Plugin();
|
|
||||||
assertThatThrownBy(() -> plugin.onLoad(ctx))
|
|
||||||
.isInstanceOf(PluginException.class)
|
|
||||||
.hasMessageContaining("Only one");
|
|
||||||
assertThat(((Mem0Provider) registered.get()).isClosed()).isTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimal PluginContext stub: only getConfig / registerMemoryProvider /
|
|
||||||
* getLogger are exercised by Mem0Plugin; everything else throws.
|
|
||||||
*/
|
|
||||||
static class StubContext implements PluginContext {
|
|
||||||
private final Map<String, Object> config;
|
|
||||||
private final AtomicReference<PluginMemoryProvider> registered;
|
|
||||||
|
|
||||||
StubContext(Map<String, Object> config, AtomicReference<PluginMemoryProvider> registered) {
|
|
||||||
this.config = config;
|
|
||||||
this.registered = registered;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
public <T> T getConfig(String key, Class<T> type) {
|
|
||||||
Object v = config.get(key);
|
|
||||||
if (v == null) return null;
|
|
||||||
if (type.isInstance(v)) return (T) v;
|
|
||||||
// Best-effort scalar coercion for Integer/Boolean from String/Number
|
|
||||||
if (type == Integer.class && v instanceof Number n) return (T) (Integer) n.intValue();
|
|
||||||
if (type == Boolean.class && v instanceof Boolean b) return (T) b;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Logger getLogger() {
|
|
||||||
return LoggerFactory.getLogger("test.Mem0Plugin");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void registerMemoryProvider(PluginMemoryProvider provider) {
|
|
||||||
registered.set(provider);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The remaining methods are not used by Mem0Plugin; stub them out.
|
|
||||||
|
|
||||||
@Override public void registerTool(ToolCallback tool) { throw new UnsupportedOperationException(); }
|
|
||||||
@Override public void registerTool(ToolCallback tool, Supplier<Boolean> availabilityCheck) { throw new UnsupportedOperationException(); }
|
|
||||||
@Override public void registerProvider(String providerId, ChatModel chatModel) { throw new UnsupportedOperationException(); }
|
|
||||||
@Override public void registerChannel(PluginChannelAdapter channel) { throw new UnsupportedOperationException(); }
|
|
||||||
@Override public void registerSearchProvider(PluginSearchProvider provider) { throw new UnsupportedOperationException(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,259 +0,0 @@
|
|||||||
package vip.mate.plugin.mem0;
|
|
||||||
|
|
||||||
import com.sun.net.httpserver.HttpExchange;
|
|
||||||
import com.sun.net.httpserver.HttpHandler;
|
|
||||||
import com.sun.net.httpserver.HttpServer;
|
|
||||||
import org.junit.jupiter.api.AfterEach;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.InputStream;
|
|
||||||
import java.net.InetSocketAddress;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
|
||||||
import java.util.concurrent.CountDownLatch;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|
||||||
|
|
||||||
class Mem0ProviderTest {
|
|
||||||
|
|
||||||
private HttpServer server;
|
|
||||||
private Mem0Provider provider;
|
|
||||||
private final AtomicInteger addCount = new AtomicInteger();
|
|
||||||
private final AtomicInteger searchCount = new AtomicInteger();
|
|
||||||
private final AtomicReference<String> lastAddBody = new AtomicReference<>();
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() throws IOException {
|
|
||||||
addCount.set(0);
|
|
||||||
searchCount.set(0);
|
|
||||||
lastAddBody.set(null);
|
|
||||||
HttpHandler handler = this::handle;
|
|
||||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
|
||||||
server.createContext("/", handler);
|
|
||||||
server.start();
|
|
||||||
|
|
||||||
String baseUrl = "http://127.0.0.1:" + server.getAddress().getPort();
|
|
||||||
Mem0Config config = new Mem0Config(baseUrl, null, true, true, 3, 3000);
|
|
||||||
Mem0Client client = new Mem0Client(config);
|
|
||||||
provider = new Mem0Provider(config, client, LoggerFactory.getLogger("test"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterEach
|
|
||||||
void tearDown() {
|
|
||||||
if (provider != null) provider.close();
|
|
||||||
if (server != null) server.stop(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handle(HttpExchange exchange) throws IOException {
|
|
||||||
String body;
|
|
||||||
try (InputStream in = exchange.getRequestBody()) {
|
|
||||||
body = new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
|
||||||
}
|
|
||||||
String path = exchange.getRequestURI().getPath();
|
|
||||||
byte[] resp;
|
|
||||||
if ("/memories/".equals(path)) {
|
|
||||||
addCount.incrementAndGet();
|
|
||||||
lastAddBody.set(body);
|
|
||||||
resp = "{\"results\":[]}".getBytes(StandardCharsets.UTF_8);
|
|
||||||
} else if ("/memories/search/".equals(path)) {
|
|
||||||
searchCount.incrementAndGet();
|
|
||||||
resp = "{\"results\":[{\"id\":\"m1\",\"memory\":\"likes PostgreSQL\",\"score\":0.9}]}".getBytes(StandardCharsets.UTF_8);
|
|
||||||
} else {
|
|
||||||
resp = "{}".getBytes(StandardCharsets.UTF_8);
|
|
||||||
}
|
|
||||||
exchange.getResponseHeaders().add("Content-Type", "application/json");
|
|
||||||
exchange.sendResponseHeaders(200, resp.length);
|
|
||||||
exchange.getResponseBody().write(resp);
|
|
||||||
exchange.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void id_isMem0() {
|
|
||||||
assertThat(provider.id()).isEqualTo("mem0");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void isAvailable_true_whenConfigUsableAndAtLeastOneFeatureEnabled() {
|
|
||||||
assertThat(provider.isAvailable()).isTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void isAvailable_false_whenBaseUrlMissing() {
|
|
||||||
Mem0Config cfg = new Mem0Config(null, null, true, true, 5, 1000);
|
|
||||||
Mem0Provider p = new Mem0Provider(cfg, new Mem0Client(cfg), LoggerFactory.getLogger("test"));
|
|
||||||
assertThat(p.isAvailable()).isFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void isAvailable_false_whenBothFeaturesDisabled() {
|
|
||||||
Mem0Config cfg = new Mem0Config("http://localhost:8080", null, false, false, 5, 1000);
|
|
||||||
Mem0Provider p = new Mem0Provider(cfg, new Mem0Client(cfg), LoggerFactory.getLogger("test"));
|
|
||||||
assertThat(p.isAvailable()).isFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void systemPromptBlock_isEmpty() {
|
|
||||||
assertThat(provider.systemPromptBlock(1L)).isEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void twoArgPrefetch_returnsEmptyBecauseNoOwnerKey() {
|
|
||||||
// Without ownerKey, Mem0 cannot isolate per-user; provider skips.
|
|
||||||
assertThat(provider.prefetch(1L, "hello")).isEmpty();
|
|
||||||
assertThat(searchCount.get()).isZero();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void threeArgPrefetch_returnsRecallBlock() {
|
|
||||||
String result = provider.prefetch(1L, "what database", "user:42");
|
|
||||||
|
|
||||||
assertThat(result).startsWith("[Mem0 Recall");
|
|
||||||
assertThat(result).contains("likes PostgreSQL");
|
|
||||||
assertThat(searchCount.get()).isEqualTo(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void threeArgPrefetch_returnsEmptyWhenOwnerKeyBlank() {
|
|
||||||
assertThat(provider.prefetch(1L, "query", "")).isEmpty();
|
|
||||||
assertThat(provider.prefetch(1L, "query", null)).isEmpty();
|
|
||||||
assertThat(searchCount.get()).isZero();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void threeArgPrefetch_returnsEmptyWhenQueryBlank() {
|
|
||||||
assertThat(provider.prefetch(1L, "", "user:42")).isEmpty();
|
|
||||||
assertThat(provider.prefetch(1L, null, "user:42")).isEmpty();
|
|
||||||
assertThat(searchCount.get()).isZero();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void threeArgPrefetch_propagatesServerErrorToPlatformCircuitBreaker() {
|
|
||||||
server.removeContext("/");
|
|
||||||
server.createContext("/", ex -> {
|
|
||||||
ex.sendResponseHeaders(500, 0);
|
|
||||||
ex.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> provider.prefetch(1L, "q", "user:42"))
|
|
||||||
.isInstanceOf(Mem0Exception.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void syncTurn_pushesAsynchronouslyWithOwnerKeyAsUserId() throws Exception {
|
|
||||||
provider.syncTurn(1L, "conv-1", "hello", "world", "user:42");
|
|
||||||
|
|
||||||
// Wait briefly for the async executor to fire the POST.
|
|
||||||
long deadline = System.currentTimeMillis() + 2000;
|
|
||||||
while (addCount.get() == 0 && System.currentTimeMillis() < deadline) {
|
|
||||||
Thread.sleep(20);
|
|
||||||
}
|
|
||||||
assertThat(addCount.get()).isEqualTo(1);
|
|
||||||
// The write must land under the same user_id that prefetch recalls by.
|
|
||||||
assertThat(lastAddBody.get()).contains("\"user_id\":\"user:42\"");
|
|
||||||
assertThat(lastAddBody.get()).contains("\"agent_id\":\"1\"");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void fourArgSyncTurn_skipsBecauseNoOwnerKey() throws Exception {
|
|
||||||
// Without ownerKey, a write would be keyed by an identifier that
|
|
||||||
// owner-scoped prefetch never queries; the provider must skip.
|
|
||||||
provider.syncTurn(1L, "conv-1", "hello", "world");
|
|
||||||
Thread.sleep(200); // give async a chance to (not) fire
|
|
||||||
assertThat(addCount.get()).isZero();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void syncTurn_skipsWhenOwnerKeyBlank() throws Exception {
|
|
||||||
provider.syncTurn(1L, "conv-1", "hello", "world", "");
|
|
||||||
provider.syncTurn(1L, "conv-1", "hello", "world", null);
|
|
||||||
Thread.sleep(200);
|
|
||||||
assertThat(addCount.get()).isZero();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void syncTurn_skipsWhenBothMessagesBlank() throws Exception {
|
|
||||||
provider.syncTurn(1L, "conv-1", " ", "", "user:42");
|
|
||||||
Thread.sleep(200); // give async a chance to (not) fire
|
|
||||||
assertThat(addCount.get()).isZero();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void syncTurn_failureIsSwallowedAndDoesNotThrow() throws Exception {
|
|
||||||
// Stop the server so the async POST fails; provider must not propagate.
|
|
||||||
server.stop(0);
|
|
||||||
// Re-create a stub server just so tearDown doesn't NPE; not listening
|
|
||||||
// on the original port anymore — the client will get connection refused.
|
|
||||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
|
||||||
server.createContext("/", ex -> { ex.sendResponseHeaders(200, 0); ex.close(); });
|
|
||||||
// Note: client still points at the old port → connection refused.
|
|
||||||
|
|
||||||
provider.syncTurn(1L, "conv-1", "hi", "there", "user:42");
|
|
||||||
Thread.sleep(500);
|
|
||||||
// No exception thrown; nothing to assert beyond "test didn't blow up".
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void syncTurn_skippedWhenSyncDisabled() throws Exception {
|
|
||||||
// Build a provider with sync disabled.
|
|
||||||
Mem0Config cfg = new Mem0Config(
|
|
||||||
"http://127.0.0.1:" + server.getAddress().getPort(),
|
|
||||||
null, true, false, 3, 3000);
|
|
||||||
Mem0Provider p = new Mem0Provider(cfg, new Mem0Client(cfg), LoggerFactory.getLogger("test"));
|
|
||||||
p.syncTurn(1L, "conv-1", "hi", "there", "user:42");
|
|
||||||
Thread.sleep(200);
|
|
||||||
assertThat(addCount.get()).isZero();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void prefetch_skippedWhenSearchDisabled() {
|
|
||||||
Mem0Config cfg = new Mem0Config(
|
|
||||||
"http://127.0.0.1:" + server.getAddress().getPort(),
|
|
||||||
null, false, true, 3, 3000);
|
|
||||||
Mem0Provider p = new Mem0Provider(cfg, new Mem0Client(cfg), LoggerFactory.getLogger("test"));
|
|
||||||
assertThat(p.prefetch(1L, "q", "user:42")).isEmpty();
|
|
||||||
assertThat(searchCount.get()).isZero();
|
|
||||||
p.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void syncQueueIsBoundedAndCloseReleasesExecutor() throws Exception {
|
|
||||||
CountDownLatch firstStarted = new CountDownLatch(1);
|
|
||||||
CountDownLatch releaseFirst = new CountDownLatch(1);
|
|
||||||
AtomicInteger writes = new AtomicInteger();
|
|
||||||
Mem0Config cfg = new Mem0Config("http://localhost:8080", null,
|
|
||||||
false, true, 3, 3000, 1);
|
|
||||||
Mem0Client blockingClient = new Mem0Client(cfg) {
|
|
||||||
@Override
|
|
||||||
void addMemories(String userId, String agentId, String conversationId,
|
|
||||||
String userMessage, String assistantReply) {
|
|
||||||
writes.incrementAndGet();
|
|
||||||
firstStarted.countDown();
|
|
||||||
try {
|
|
||||||
releaseFirst.await(2, TimeUnit.SECONDS);
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Mem0Provider bounded = new Mem0Provider(cfg, blockingClient, LoggerFactory.getLogger("test"));
|
|
||||||
try {
|
|
||||||
bounded.syncTurn(1L, "one", "u", "a", "user:1");
|
|
||||||
assertThat(firstStarted.await(1, TimeUnit.SECONDS)).isTrue();
|
|
||||||
bounded.syncTurn(1L, "two", "u", "a", "user:1");
|
|
||||||
bounded.syncTurn(1L, "three", "u", "a", "user:1");
|
|
||||||
|
|
||||||
assertThat(bounded.queuedSyncCount()).isEqualTo(1);
|
|
||||||
assertThat(bounded.droppedSyncCount()).isEqualTo(1);
|
|
||||||
} finally {
|
|
||||||
releaseFirst.countDown();
|
|
||||||
bounded.close();
|
|
||||||
}
|
|
||||||
assertThat(bounded.isClosed()).isTrue();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
<?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-sample</artifactId>
|
|
||||||
<packaging>jar</packaging>
|
|
||||||
|
|
||||||
<name>MateClaw Sample Plugin</name>
|
|
||||||
<description>A sample plugin demonstrating 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) -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.ai</groupId>
|
|
||||||
<artifactId>spring-ai-model</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>
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
package vip.mate.plugin.sample;
|
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.springframework.ai.tool.annotation.Tool;
|
|
||||||
import org.springframework.ai.tool.annotation.ToolParam;
|
|
||||||
import org.springframework.ai.support.ToolCallbacks;
|
|
||||||
import org.springframework.ai.tool.ToolCallback;
|
|
||||||
import vip.mate.plugin.api.MateClawPlugin;
|
|
||||||
import vip.mate.plugin.api.PluginContext;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sample plugin demonstrating the MateClaw Plugin SDK.
|
|
||||||
* <p>
|
|
||||||
* Registers a simple "hello_world" tool that agents can call.
|
|
||||||
*
|
|
||||||
* @author MateClaw Team
|
|
||||||
*/
|
|
||||||
public class HelloPlugin implements MateClawPlugin {
|
|
||||||
|
|
||||||
private Logger log;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onLoad(PluginContext context) {
|
|
||||||
this.log = context.getLogger();
|
|
||||||
log.info("HelloPlugin loading...");
|
|
||||||
|
|
||||||
// Register tool callbacks from this class's @Tool methods
|
|
||||||
ToolCallback[] callbacks = ToolCallbacks.from(this);
|
|
||||||
for (ToolCallback callback : callbacks) {
|
|
||||||
context.registerTool(callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("HelloPlugin loaded, registered {} tools", callbacks.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onEnable() {
|
|
||||||
if (log != null) log.info("HelloPlugin enabled");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onDisable() {
|
|
||||||
if (log != null) log.info("HelloPlugin disabled");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Tool(description = "A greeting tool from the Hello World plugin. Returns a friendly greeting message for the given name.")
|
|
||||||
public String hello_world(
|
|
||||||
@ToolParam(description = "The name to greet") String name) {
|
|
||||||
return "Hello, " + name + "! This message comes from the MateClaw Hello World plugin.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "mateclaw-plugin-hello",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"type": "tool",
|
|
||||||
"displayName": "Hello World Plugin",
|
|
||||||
"description": "A sample plugin that demonstrates the MateClaw Plugin SDK by registering a simple greeting tool.",
|
|
||||||
"entrypoint": "vip.mate.plugin.sample.HelloPlugin",
|
|
||||||
"minPlatformVersion": "1.1.0",
|
|
||||||
"author": "MateClaw Team",
|
|
||||||
"config": {}
|
|
||||||
}
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@ -1,124 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"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,142 +1,13 @@
|
|||||||
# Multi-stage build
|
# 多阶段构建
|
||||||
#
|
|
||||||
# Stage 1 — Frontend (Node / pnpm)
|
|
||||||
# Builds the Vue 3 admin SPA and emits static files to /static inside the
|
|
||||||
# build container. These files are later copied into the JAR's classpath so
|
|
||||||
# Spring Boot serves the SPA at the root URL.
|
|
||||||
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
|
|
||||||
|
|
||||||
# Stage 2 — Backend (Maven)
|
|
||||||
FROM maven:3.9-eclipse-temurin-21 AS builder
|
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.
|
|
||||||
#
|
|
||||||
# This list MUST mirror <modules> in the root pom.xml, even for modules this
|
|
||||||
# image never builds. Maven fails while constructing the reactor if a declared
|
|
||||||
# module directory is missing ("Child module /build/<name> does not exist"),
|
|
||||||
# so `-pl mateclaw-server -am` aborts before it ever gets to dependency
|
|
||||||
# resolution. When a module is added to the root POM, add its pom.xml here too.
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
COPY pom.xml ./pom.xml
|
COPY pom.xml .
|
||||||
COPY mateclaw-plugin-api/pom.xml mateclaw-plugin-api/pom.xml
|
RUN mvn dependency:go-offline -q
|
||||||
COPY mateclaw-server/pom.xml mateclaw-server/pom.xml
|
COPY src ./src
|
||||||
COPY mateclaw-plugin-sample/pom.xml mateclaw-plugin-sample/pom.xml
|
RUN mvn package -DskipTests -q
|
||||||
COPY mateclaw-plugin-search-sample/pom.xml mateclaw-plugin-search-sample/pom.xml
|
|
||||||
COPY mateclaw-plugin-mem0/pom.xml mateclaw-plugin-mem0/pom.xml
|
|
||||||
|
|
||||||
# Pre-fetch backend dependencies through the reactor so the parent POM,
|
FROM eclipse-temurin:21-jre-alpine
|
||||||
# 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.62.0-noble
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
COPY --from=builder /build/target/*.jar app.jar
|
||||||
# JDK 21 is NOT part of the base image (it ships Node for the JS driver).
|
|
||||||
# Install openjdk-21 explicitly and add CJK fonts so Chinese pages render
|
|
||||||
# correctly in screenshots and snapshots.
|
|
||||||
#
|
|
||||||
# PDF extraction toolchain — DocumentExtractTool tries pdftotext first, then
|
|
||||||
# Python pdfplumber/pypdf, then falls through to a naive Java parser that
|
|
||||||
# reads bytes as ISO_8859_1 (mojibake for CJK). Without poppler-utils the
|
|
||||||
# Docker image always hits the naive path and feeds garbled text to the
|
|
||||||
# Wiki pipeline.
|
|
||||||
#
|
|
||||||
# We install poppler-utils (backend 1) and tesseract (backend 4), which
|
|
||||||
# together cover the vast majority of PDFs including scanned docs. The
|
|
||||||
# Python backend is intentionally skipped — pip install against aliyun
|
|
||||||
# mirrors in CN networks hits transient hash-mismatch failures on cffi /
|
|
||||||
# cryptography transitive deps, and RFC-051 PR-1c will replace the Python
|
|
||||||
# hop with JVM-native Tika extraction anyway. Leaving it out keeps the
|
|
||||||
# image ~200 MB smaller and the build reproducible.
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends \
|
|
||||||
openjdk-21-jre-headless \
|
|
||||||
fonts-noto-cjk \
|
|
||||||
fonts-noto-color-emoji \
|
|
||||||
poppler-utils \
|
|
||||||
tesseract-ocr \
|
|
||||||
tesseract-ocr-chi-sim \
|
|
||||||
tzdata \
|
|
||||||
python3-pip \
|
|
||||||
python-is-python3 \
|
|
||||||
python3-dev \
|
|
||||||
build-essential \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Remove PEP 668's EXTERNALLY-MANAGED marker so pip can install packages
|
|
||||||
# system-wide without --break-system-packages. This is a container — there is
|
|
||||||
# no host Python environment to protect. Skill scripts and LLM-generated code
|
|
||||||
# need to `pip install` on the fly; PEP 668 would block every install with
|
|
||||||
# "error: externally-managed-environment".
|
|
||||||
RUN rm -f /usr/lib/python3*/EXTERNALLY-MANAGED
|
|
||||||
|
|
||||||
# Tell Playwright Java where Microsoft's image stored the browsers.
|
|
||||||
# BrowserLauncher's BUNDLED strategy will then succeed without extra config.
|
|
||||||
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
|
|
||||||
TZ=Asia/Shanghai \
|
|
||||||
LANG=C.UTF-8 \
|
|
||||||
LC_ALL=C.UTF-8 \
|
|
||||||
JAVA_TOOL_OPTIONS="-Duser.timezone=Asia/Shanghai -Dsun.jnu.encoding=UTF-8"
|
|
||||||
|
|
||||||
# Default DB profile, overridable by the SPRING_PROFILES_ACTIVE env var
|
|
||||||
# (compose sets it explicitly: mysql / postgres / kingbase). It must be an ENV,
|
|
||||||
# not a -D system property on the ENTRYPOINT: a hardcoded
|
|
||||||
# -Dspring.profiles.active outranks the SPRING_PROFILES_ACTIVE env var and would
|
|
||||||
# silently pin the profile regardless of what compose passes.
|
|
||||||
ENV SPRING_PROFILES_ACTIVE=mysql
|
|
||||||
|
|
||||||
COPY --from=builder /build/mateclaw-server/target/*.jar app.jar
|
|
||||||
|
|
||||||
EXPOSE 18088
|
EXPOSE 18088
|
||||||
EXPOSE 1455
|
ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=mysql", "app.jar"]
|
||||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
|
||||||
|
|||||||
@ -0,0 +1,138 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2023-present the original author or authors.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.springframework.ai.chat.messages;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.springframework.ai.content.Media;
|
||||||
|
import org.springframework.ai.content.MediaContent;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.lang.NonNull;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
|
import org.springframework.util.Assert;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A message of the type 'user' passed as input Messages with the user role are from the
|
||||||
|
* end-user or developer. They represent questions, prompts, or any input that you want
|
||||||
|
* the generative to respond to.
|
||||||
|
*/
|
||||||
|
public class UserMessage extends AbstractMessage implements MediaContent {
|
||||||
|
|
||||||
|
protected final List<Media> media;
|
||||||
|
|
||||||
|
public UserMessage(String textContent) {
|
||||||
|
this(textContent, new ArrayList<>(), Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserMessage(String textContent, Collection<Media> media, Map<String, Object> metadata) {
|
||||||
|
super(MessageType.USER, textContent, metadata);
|
||||||
|
Assert.notNull(media, "media cannot be null");
|
||||||
|
Assert.noNullElements(media, "media cannot have null elements");
|
||||||
|
this.media = new ArrayList<>(media);
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserMessage(Resource resource) {
|
||||||
|
this(MessageUtils.readResource(resource));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "UserMessage{" + "content='" + getText() + '\'' + ", metadata=" + this.metadata + ", messageType="
|
||||||
|
+ this.messageType + '}';
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@NonNull
|
||||||
|
public String getText() {
|
||||||
|
return this.textContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Media> getMedia() {
|
||||||
|
return this.media;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserMessage copy() {
|
||||||
|
return new Builder().text(getText()).media(List.copyOf(getMedia())).metadata(Map.copyOf(getMetadata())).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder mutate() {
|
||||||
|
return new Builder().text(getText()).media(List.copyOf(getMedia())).metadata(Map.copyOf(getMetadata()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Builder builder() {
|
||||||
|
return new Builder();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class Builder {
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private String textContent;
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private Resource resource;
|
||||||
|
|
||||||
|
private List<Media> media = new ArrayList<>();
|
||||||
|
|
||||||
|
private Map<String, Object> metadata = new HashMap<>();
|
||||||
|
|
||||||
|
public Builder text(String textContent) {
|
||||||
|
this.textContent = textContent;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder text(Resource resource) {
|
||||||
|
this.resource = resource;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder media(List<Media> media) {
|
||||||
|
this.media = media;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder media(@Nullable Media... media) {
|
||||||
|
if (media != null) {
|
||||||
|
this.media = Arrays.asList(media);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder metadata(Map<String, Object> metadata) {
|
||||||
|
this.metadata = metadata;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserMessage build() {
|
||||||
|
if (StringUtils.hasText(this.textContent) && this.resource != null) {
|
||||||
|
throw new IllegalArgumentException("textContent and resource cannot be set at the same time");
|
||||||
|
}
|
||||||
|
else if (this.resource != null) {
|
||||||
|
this.textContent = MessageUtils.readResource(this.resource);
|
||||||
|
}
|
||||||
|
return new UserMessage(this.textContent, this.media, this.metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -4,47 +4,72 @@
|
|||||||
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.0.108</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>
|
||||||
|
|
||||||
<dependencies>
|
<parent>
|
||||||
<!-- ===== MateClaw Plugin API ===== -->
|
<groupId>org.springframework.boot</groupId>
|
||||||
<dependency>
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
<groupId>vip.mate</groupId>
|
<version>3.5.13</version>
|
||||||
<artifactId>mateclaw-plugin-api</artifactId>
|
<relativePath/>
|
||||||
</dependency>
|
</parent>
|
||||||
|
|
||||||
<!-- ===== Web MVC, excluding WebFlux to keep servlet mode ===== -->
|
<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>
|
||||||
|
<!-- ===== Web MVC(不引入 WebFlux,避免自动切换为响应式模式) ===== -->
|
||||||
<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 ===== -->
|
||||||
<!--
|
<!--
|
||||||
Version is managed centrally because this artifact is outside the Spring AI BOM.
|
1.1.2.2 需单独指定版本,不在 BOM 中
|
||||||
Provides DashScope ChatModel, EmbeddingModel, and ImageModel support.
|
内置 DashScope ChatModel / EmbeddingModel / ImageModel
|
||||||
-->
|
-->
|
||||||
<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>
|
||||||
<!-- Exclude the transitive WebFlux starter to keep MVC mode. -->
|
<version>${spring-ai-alibaba.version}</version>
|
||||||
|
<!-- 排除 webflux 传递依赖,保持 MVC 模式 -->
|
||||||
<exclusions>
|
<exclusions>
|
||||||
<exclusion>
|
<exclusion>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
@ -53,10 +78,11 @@
|
|||||||
</exclusions>
|
</exclusions>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- ===== Spring AI Alibaba Graph Core (StateGraph workflow engine) ===== -->
|
<!-- ===== Spring AI Alibaba Graph Core(StateGraph 工作流引擎) ===== -->
|
||||||
<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 ===== -->
|
||||||
@ -65,15 +91,16 @@
|
|||||||
<artifactId>spring-ai-openai</artifactId>
|
<artifactId>spring-ai-openai</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- ===== Spring AI Anthropic (Claude model support) ===== -->
|
<!-- ===== Spring AI Anthropic(Claude 模型支持) ===== -->
|
||||||
<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 (dynamic MCP server connection management) ===== -->
|
<!-- ===== Spring AI MCP Client(动态 MCP server 连接管理) ===== -->
|
||||||
<!--
|
<!--
|
||||||
Pulls in the MCP core library while application code owns the McpSyncClient lifecycle.
|
使用 spring-ai-mcp-client-spring-boot-starter 引入 MCP 核心库,
|
||||||
|
但禁用自动配置(我们自己管理 McpSyncClient 生命周期)
|
||||||
-->
|
-->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.ai</groupId>
|
<groupId>org.springframework.ai</groupId>
|
||||||
@ -86,29 +113,31 @@
|
|||||||
</exclusions>
|
</exclusions>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- ===== H2 embedded database (development) ===== -->
|
<!-- ===== H2 内嵌数据库(开发环境) ===== -->
|
||||||
<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 driver (production) ===== -->
|
<!-- ===== MySQL 驱动(生产环境) ===== -->
|
||||||
<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, without JPA to avoid dual ORM conflicts ===== -->
|
<!-- ===== MyBatis Plus(不引入 JPA,避免双 ORM 冲突) ===== -->
|
||||||
<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 pagination support is split into a separate module. -->
|
<!-- MyBatis Plus 分页插件(3.5.16 拆分为独立模块) -->
|
||||||
<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 ===== -->
|
||||||
@ -121,49 +150,55 @@
|
|||||||
<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 utilities ===== -->
|
<!-- ===== Hutool 工具库 ===== -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>cn.hutool</groupId>
|
<groupId>cn.hutool</groupId>
|
||||||
<artifactId>hutool-all</artifactId>
|
<artifactId>hutool-all</artifactId>
|
||||||
|
<version>${hutool.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- ===== DingTalk Stream SDK (WebSocket long connection, no public IP required) ===== -->
|
<!-- ===== 钉钉 Stream SDK(WebSocket 长连接,无需公网 IP) ===== -->
|
||||||
<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 long connection and event dispatch) ===== -->
|
<!-- ===== 飞书 / Lark Open API SDK(WebSocket 长连接 + 事件分发) ===== -->
|
||||||
<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 for skill runtime caching ===== -->
|
<!-- ===== Caffeine Cache(用于 skill runtime 缓存) ===== -->
|
||||||
<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 for SKILL.md frontmatter parsing ===== -->
|
<!-- ===== SnakeYAML(用于 SKILL.md frontmatter 解析) ===== -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.yaml</groupId>
|
<groupId>org.yaml</groupId>
|
||||||
<artifactId>snakeyaml</artifactId>
|
<artifactId>snakeyaml</artifactId>
|
||||||
@ -180,24 +215,28 @@
|
|||||||
<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 long connection) ===== -->
|
<!-- ===== JDA(Discord Bot Gateway WebSocket 长连接) ===== -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>net.dv8tion</groupId>
|
<groupId>net.dv8tion</groupId>
|
||||||
<artifactId>JDA</artifactId>
|
<artifactId>JDA</artifactId>
|
||||||
|
<version>5.2.3</version>
|
||||||
<exclusions>
|
<exclusions>
|
||||||
<!-- Exclude audio dependencies because voice features are not used. -->
|
<!-- 排除 audio 相关依赖(MateClaw 不需要语音功能) -->
|
||||||
<exclusion>
|
<exclusion>
|
||||||
<groupId>club.minnced</groupId>
|
<groupId>club.minnced</groupId>
|
||||||
<artifactId>opus-java</artifactId>
|
<artifactId>opus-java</artifactId>
|
||||||
@ -205,233 +244,12 @@
|
|||||||
</exclusions>
|
</exclusions>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- ===== Spring WebSocket (Talk Mode) ===== -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- ===== Slack SDK (Socket Mode and Web API) ===== -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.slack.api</groupId>
|
|
||||||
<artifactId>slack-api-client</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.slack.api</groupId>
|
|
||||||
<artifactId>bolt-socket-mode</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.glassfish.tyrus.bundles</groupId>
|
|
||||||
<artifactId>tyrus-standalone-client</artifactId>
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<!-- ===== WxJava (WeChat Official Account SDK) ===== -->
|
|
||||||
<!--
|
|
||||||
Used by GzhPublishTool to push generated 图文 articles into the
|
|
||||||
Official Account draft box (草稿箱): permanent cover-material upload
|
|
||||||
plus draft creation, and optional free-publish for verified accounts.
|
|
||||||
weixin-java-mp is the Java 17 / Spring Boot 3 compatible MP module.
|
|
||||||
-->
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.github.binarywang</groupId>
|
|
||||||
<artifactId>weixin-java-mp</artifactId>
|
|
||||||
<version>4.6.0</version>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- ===== Apache Tika (Java-side last-resort document extractor) ===== -->
|
|
||||||
<!--
|
|
||||||
Wired as the FINAL fallback in DocumentExtractTool's PDF/DOCX/XLSX/PPTX
|
|
||||||
chains, after every system command + Python + POI-based path has failed.
|
|
||||||
Used in production primarily by Windows users without Python or poppler
|
|
||||||
installed; otherwise idle.
|
|
||||||
|
|
||||||
Pinned to the precise format modules the extractor calls directly. This
|
|
||||||
deliberately avoids `tika-parsers-standard-package`, which pulls in mail,
|
|
||||||
audio, archive, RTF / ODT, scientific, etc. (~80MB). Current footprint:
|
|
||||||
tika-core (~700KB) + tika-parser-pdf-module (PDFBox ~5MB) +
|
|
||||||
tika-parser-microsoft-module (POI-scratchpad ~10MB) is about 16MB.
|
|
||||||
-->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.apache.tika</groupId>
|
|
||||||
<artifactId>tika-core</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.apache.tika</groupId>
|
|
||||||
<artifactId>tika-parser-pdf-module</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.apache.tika</groupId>
|
|
||||||
<artifactId>tika-parser-microsoft-module</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- ===== Markdown -> PDF rendering =====
|
|
||||||
Flying Saucer ships a single `flying-saucer-pdf` artifact that
|
|
||||||
writes PDF via OpenPDF (LGPL fork of iText). It does NOT depend on
|
|
||||||
PDFBox, so it sidesteps a version conflict with the existing
|
|
||||||
pdfbox dependency. CSS3 paged-media features (@page,
|
|
||||||
counter(page), counter(pages), @top-center / @bottom-center) are
|
|
||||||
supported, which the cover / header / footer rendering relies on.
|
|
||||||
|
|
||||||
commonmark-java is the reference CommonMark implementation,
|
|
||||||
actively maintained on a monthly cadence (vs. flexmark, whose
|
|
||||||
upstream stalled at 0.64.8 in 2023). It parses markdown into the
|
|
||||||
XHTML Flying Saucer consumes. The alternative LibreOffice path in
|
|
||||||
PdfRenderTool reuses MarkdownDocxRenderer + a soffice subprocess
|
|
||||||
and adds no dependencies of its own. -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.xhtmlrenderer</groupId>
|
|
||||||
<artifactId>flying-saucer-pdf</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.commonmark</groupId>
|
|
||||||
<artifactId>commonmark</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.commonmark</groupId>
|
|
||||||
<artifactId>commonmark-ext-gfm-tables</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.commonmark</groupId>
|
|
||||||
<artifactId>commonmark-ext-yaml-front-matter</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.commonmark</groupId>
|
|
||||||
<artifactId>commonmark-ext-gfm-strikethrough</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.commonmark</groupId>
|
|
||||||
<artifactId>commonmark-ext-autolink</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- ===== Database Migration (Flyway) ===== -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.flywaydb</groupId>
|
|
||||||
<artifactId>flyway-core</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.flywaydb</groupId>
|
|
||||||
<artifactId>flyway-mysql</artifactId>
|
|
||||||
</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>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<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>
|
||||||
@ -439,13 +257,6 @@
|
|||||||
<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>
|
||||||
@ -455,76 +266,6 @@
|
|||||||
</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>
|
||||||
|
|||||||
@ -1,22 +0,0 @@
|
|||||||
<?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,156 +3,42 @@ 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 = {
|
||||||
// Disable Spring AI MCP Client auto-configuration (lifecycle owned by McpClientManager).
|
// 禁用 Spring AI MCP Client 自动配置(由 McpClientManager 自行管理生命周期)
|
||||||
org.springframework.ai.mcp.client.common.autoconfigure.McpClientAutoConfiguration.class,
|
org.springframework.ai.mcp.client.common.autoconfigure.McpClientAutoConfiguration.class,
|
||||||
org.springframework.ai.mcp.client.common.autoconfigure.McpToolCallbackAutoConfiguration.class,
|
org.springframework.ai.mcp.client.common.autoconfigure.McpToolCallbackAutoConfiguration.class,
|
||||||
org.springframework.ai.mcp.client.common.autoconfigure.StdioTransportAutoConfiguration.class,
|
org.springframework.ai.mcp.client.common.autoconfigure.StdioTransportAutoConfiguration.class,
|
||||||
org.springframework.ai.mcp.client.common.autoconfigure.annotations.McpClientAnnotationScannerAutoConfiguration.class,
|
org.springframework.ai.mcp.client.common.autoconfigure.annotations.McpClientAnnotationScannerAutoConfiguration.class,
|
||||||
org.springframework.ai.mcp.client.httpclient.autoconfigure.SseHttpClientTransportAutoConfiguration.class,
|
org.springframework.ai.mcp.client.httpclient.autoconfigure.SseHttpClientTransportAutoConfiguration.class,
|
||||||
org.springframework.ai.mcp.client.httpclient.autoconfigure.StreamableHttpHttpClientTransportAutoConfiguration.class,
|
org.springframework.ai.mcp.client.httpclient.autoconfigure.StreamableHttpHttpClientTransportAutoConfiguration.class,
|
||||||
// DashScopeAgent is the Bailian "Application Agent" (Bailian-hosted prompt+tool app),
|
|
||||||
// not the chat model. We don't use it — model configuration is admin-UI driven and
|
|
||||||
// built by DashScopeChatModelBuilder. Its auto-config strictly requires
|
|
||||||
// spring.ai.dashscope.api-key to be non-empty at startup, which makes the whole
|
|
||||||
// ApplicationContext fail when users deploy via Docker without setting the key.
|
|
||||||
com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeAgentAutoConfiguration.class,
|
|
||||||
})
|
})
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
@MapperScan("vip.mate.**.repository")
|
@MapperScan("vip.mate.**.repository")
|
||||||
public class MateClawApplication {
|
public class MateClawApplication {
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private DataSource dataSource;
|
|
||||||
|
|
||||||
/** Cached DbType for the PaginationInnerInterceptor. */
|
|
||||||
private volatile DbType resolvedDbType;
|
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
configureHttpClientDefaults();
|
|
||||||
SpringApplication.run(MateClawApplication.class, args);
|
SpringApplication.run(MateClawApplication.class, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Harden the JDK {@link java.net.http.HttpClient} defaults before any client
|
* MyBatis Plus 分页插件
|
||||||
* (or the JDK's internal header-allowlist) is initialized.
|
|
||||||
*
|
|
||||||
* <ul>
|
|
||||||
* <li><b>keep-alive timeout</b> — the JDK default is 1200s, far longer than a
|
|
||||||
* typical reverse proxy / API gateway idle window (often 15–75s). A pooled
|
|
||||||
* HTTP/1.1 connection therefore outlives the peer's socket, and the next
|
|
||||||
* request onto that now-closed socket is reset by the peer before any
|
|
||||||
* response byte arrives, surfacing as
|
|
||||||
* {@code "HTTP/1.1 header parser received no bytes"} / {@code Connection reset}.
|
|
||||||
* Capping it to 15s makes the client evict idle connections before most
|
|
||||||
* gateways do, eliminating stale reuse. (curl never hits this because it
|
|
||||||
* opens a fresh connection per invocation.)</li>
|
|
||||||
* <li><b>allow the {@code Connection} request header</b> — {@code Connection}
|
|
||||||
* is a restricted header the JDK client strips by default; allowing it lets
|
|
||||||
* the OpenAI-compatible path send {@code Connection: close} to force a fresh
|
|
||||||
* connection per request against flaky self-hosted gateways.</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <p>Both are only set when the operator has not already provided an explicit
|
|
||||||
* {@code -D} override, so deliberate tuning is respected.
|
|
||||||
*/
|
|
||||||
private static void configureHttpClientDefaults() {
|
|
||||||
if (System.getProperty("jdk.httpclient.keepalive.timeout") == null) {
|
|
||||||
System.setProperty("jdk.httpclient.keepalive.timeout", "15");
|
|
||||||
}
|
|
||||||
String allowRestricted = System.getProperty("jdk.httpclient.allowRestrictedHeaders");
|
|
||||||
if (allowRestricted == null) {
|
|
||||||
System.setProperty("jdk.httpclient.allowRestrictedHeaders", "connection");
|
|
||||||
} else if (!allowRestricted.toLowerCase().contains("connection")) {
|
|
||||||
System.setProperty("jdk.httpclient.allowRestrictedHeaders", allowRestricted + ",connection");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect the actual database type from the live DataSource so the
|
|
||||||
* {@link PaginationInnerInterceptor} always uses the correct dialect,
|
|
||||||
* even when the JDBC URL is wrapped by a proxy (HikariCP, P6Spy, etc.).
|
|
||||||
*
|
|
||||||
* <p>DbType is cached after the first successful detection; a failure
|
|
||||||
* falls back to the value set in {@code mybatis-plus.global-config.db-config.db-type},
|
|
||||||
* or eventually to {@link DbType#MYSQL} — but by then the connection
|
|
||||||
* pool would already have failed.
|
|
||||||
*/
|
|
||||||
@PostConstruct
|
|
||||||
void detectDbType() {
|
|
||||||
try (Connection conn = dataSource.getConnection()) {
|
|
||||||
String productName = conn.getMetaData().getDatabaseProductName().toLowerCase();
|
|
||||||
if (productName.contains("kingbase")) {
|
|
||||||
resolvedDbType = DbType.KINGBASE_ES;
|
|
||||||
} else if (productName.contains("postgresql")) {
|
|
||||||
resolvedDbType = DbType.POSTGRE_SQL;
|
|
||||||
} else if (productName.contains("mysql") || productName.contains("mariadb")) {
|
|
||||||
resolvedDbType = DbType.MYSQL;
|
|
||||||
} else if (productName.contains("h2")) {
|
|
||||||
resolvedDbType = DbType.H2;
|
|
||||||
} else {
|
|
||||||
// Let the PaginationInnerInterceptor auto-detect at query time
|
|
||||||
resolvedDbType = null;
|
|
||||||
}
|
|
||||||
if (resolvedDbType != null) {
|
|
||||||
log.info("Detected database type: {} (product={})", resolvedDbType, productName);
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Could not detect database type — PaginationInnerInterceptor will auto-detect on first query: {}",
|
|
||||||
e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MyBatis Plus pagination plugin.
|
|
||||||
*
|
|
||||||
* <p>When {@code resolvedDbType} is available the interceptor uses it directly;
|
|
||||||
* otherwise it falls back to JDBC-URL auto-detection, which works for
|
|
||||||
* {@code jdbc:kingbase8://} but not for proxied DataSources (RFC-042 P0).
|
|
||||||
*/
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||||||
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||||
PaginationInnerInterceptor pagination = resolvedDbType != null
|
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.H2));
|
||||||
? 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("");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,368 +0,0 @@
|
|||||||
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<>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,82 +0,0 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
/** Max wait for session/prompt, in seconds. Defaults to 300, capped at 3600. */
|
|
||||||
private Integer promptTimeoutSeconds;
|
|
||||||
|
|
||||||
/** 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;
|
|
||||||
}
|
|
||||||