Compare commits

..

6 Commits
dev ... v1.3.0

Author SHA1 Message Date
matevip
493910bf5a release: v1.3.0 (hotfix bundle — #120 + UI/build fixes) 2026-05-14 09:30:43 +08:00
matevip
da8005a8cb release: v1.3.0 (pnpm build fix) 2026-05-13 11:30:28 +08:00
matevip
1d64194e15 release: v1.3.0 2026-05-13 10:14:27 +08:00
matevip
f47cf8c6be release: v1.3.0 2026-05-13 10:05:53 +08:00
matevip
d994be3d04 release: v1.2.0 2026-05-05 20:09:58 +08:00
matevip
9ada305b8a release: v1.1.137 2026-04-29 16:40:05 +08:00
2606 changed files with 21649 additions and 360850 deletions

View File

@ -6,33 +6,18 @@
# ⚠️ 所有标注「必填」的项若没配置,`docker compose up` 会直接失败退出,避免把默认/示例值带到生产环境。
# ==================== 数据库Docker 模式必填) ====================
#
# ⚠️ Docker 栈已切换到 PostgreSQL 16此前为 MySQL。老部署升级前请先读
# docker-compose.yml 顶部的迁移说明:旧 mysql_data 卷不会被读取,需要先
# mysqldump 再用 pgloader 等工具导入,或钉在切换前的 tag 上继续用 MySQL。
DB_HOST=localhost
DB_PORT=5432
DB_PORT=3306
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=
TAVILY_API_KEY=
# ⚠️ MySQL root 账号密码,仅用于容器内初始化。请改成与 DB_PASSWORD 不同的强密码。
DB_ROOT_PASSWORD=change-me-strong-root-password
# ==================== 安全(强烈建议覆盖) ====================
@ -44,16 +29,6 @@ JWT_SECRET=
# 若留空,服务器会允许所有 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
# 生产数据库 profilemysql/kingbase/postgres默认 false —— 匿名无法浏览全部
# 端点结构需全局管理员ROLE_ADMIN。仅在内网/预发临时调试时设为 true。
MATECLAW_OPENAPI_EXPOSE_UI=
# SearXNG 会话密钥(容器内部用,留空会用开发默认值)。生产部署请设成 32+ 位随机串。
# openssl rand -hex 32
SEARXNG_SECRET=
@ -75,20 +50,6 @@ MATECLAW_BROWSER_CDP_URL=
MATECLAW_BROWSER_CHROME_PATH=
MATECLAW_BROWSER_CHANNEL=
# ==================== 局域网 部署放开(可选,默认 false 严格模式) ====================
# 浏览器 SSRF 防护:放行本地回环和私有 IP127.0.0.1 / 10.x / 192.168.x /
# 172.16-31.x / IPv6 fc00::/7 等),公网部署务必保持 false否则 SSRF 防护失效
PLAYWRIGHT_ALLOW_PRIVATE_NETWORK=false
# 浏览器忽略 HTTPS 证书错误(自签证书 / IP 直连 HTTPS 场景)
# 公网部署务必保持 false否则中间人攻击可绕过证书校验
PLAYWRIGHT_IGNORE_HTTPS_ERRORS=false
# Playwright 单次操作超时(秒),慢链路 / 大页面可调高
PLAYWRIGHT_DEFAULT_TIMEOUT_SECONDS=30
# Playwright 导航超时(秒),慢网络可调高
PLAYWRIGHT_NAVIGATION_TIMEOUT_SECONDS=30
# snapshot 文本截断长度,超出会返回 truncated:true 提示 LLM 用 selector 缩小范围
PLAYWRIGHT_SNAPSHOT_MAX_LENGTH=20000
# ==================== OpenAI OAuthDocker可选 ====================
#
# OpenAI ChatGPT OAuth 使用 Codex CLI 的 public client + PKCE / device code
@ -108,57 +69,6 @@ PLAYWRIGHT_SNAPSHOT_MAX_LENGTH=20000
MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE=
MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST=
# ==================== Wiki 知识库目录白名单Docker 模式,可选)====================
#
# Docker 生产部署开启了路径安全校验fail-closed
# 知识库使用「目录扫描」功能时,扫描路径必须在此白名单内,否则返回 400 错误。
# 多个路径用英文逗号分隔;留空则禁止所有目录扫描。
#
# 示例MATE_WIKI_ALLOWED_SOURCE_ROOTS=/data/wiki,/opt/docs
#
# 同时在 docker-compose.yml 的 volumes 里把宿主机目录挂进容器,例如:
# volumes:
# - /your/host/path:/data/wiki
MATE_WIKI_ALLOWED_SOURCE_ROOTS=
# ── Wiki 知识源自动同步(变更监测)总开关 ────────────────────────
# 定时扫描各知识库的源目录、自动消化新文件。默认关闭,运维主动开启。
# AND 语义:全局这个开关开 *且* 某知识库自己的「自动同步」开关也开,
# 该库才会被定时扫描;手动「立即扫描」不受此开关影响。
# 间隔单位毫秒,默认 5 分钟(目前为全局,暂不支持按库配置)。
MATE_WIKI_WATCHER_ENABLED=false
MATE_WIKI_WATCHER_INTERVAL_MS=300000
# ── Skill 工作区目录 ─────────────────────────────────────────────
# 已安装的 skill、运行时积累的 LESSONS.md、skill 运行产物都落在这个目录。
# 默认(容器内)已指向 /app/data/skills由 docker-compose 的 server_data 卷
# 持久化,容器重启不丢,无需额外挂卷。一般无需修改。
# 内置 skill 由 JAR classpath 每次启动现场释放,挂空卷也不会丢内置文件。
# 仅当你想把 skill 目录放到别处(如独立的 bind mount时才覆盖此项
# 并记得在 docker-compose.yml 的 volumes 里把对应宿主机目录挂进容器。
MATECLAW_SKILL_WORKSPACE_ROOT=
# ── Skill ZIP 上传大小上限MB可选────────────────────────────
# 技能包上传/市场安装的应用层上限,默认单文件 1MB、整包 50MB。
# 解包时整包缓存在内存里,整包上限调多大,单次安装峰值内存就可能吃多大。
# 同时注意 Spring 层 spring.servlet.multipart 的上限(默认 100MB/200MB
MATECLAW_SKILL_UPLOAD_MAX_ENTRY_SIZE_MB=
MATECLAW_SKILL_UPLOAD_MAX_TOTAL_SIZE_MB=
# ── Python pip 镜像源(可选)──────────────────────────────────────
# skill 里 Python 脚本缺包时 pip install 走的源。默认用 PyPI 官方源。
# pip 原生读 PIP_INDEX_URL / PIP_TRUSTED_HOST 环境变量,容器内自动继承。
# HTTP 源会自动从 URL 推导 PIP_TRUSTED_HOST自签 HTTPS 需手动填。
# 互联网加速: https://pypi.tuna.tsinghua.edu.cn/simple
# 局域网私有源: http://192.168.1.100:8080/simpletrusted-host 自动推导)
#PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
#PIP_TRUSTED_HOST=
# ── 桌面版补充(非 Docker宿主机直接跑 Java─────────────────────
# 桌面版不继承上面的 Docker 变量。用 Spring 配置注入 Python 子进程;
# 也可直接设系统环境变量 PIP_INDEX_URL / PIP_TRUSTED_HOST覆盖更全
#MATECLAW_PIP_INDEX_URL=
#MATECLAW_PIP_TRUSTED_HOST=
# ── Maven 镜像(国内加速)─────────────────────────────────────────
# 在中国大陆构建时取消注释,将 Aliyun 仓库优先级提前,大幅提速 mvn 拉包。
# 空值(默认)使用 US Maven Central → Google CDN → Aliyun 的顺序。

19
.gitattributes vendored
View File

@ -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

26
.gitignore vendored
View File

@ -78,12 +78,8 @@ pom.xml.versionsBackup
# mateclaw static build output (do not commit)
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-server/data/
.sessions/
/data/
# VitePress build output and cache (do not commit)
@ -98,13 +94,8 @@ deploy/nginx/ssl/*.crt
deploy/nginx/ssl/*.key
deploy/nginx/ssl/*.pem
# Env files (real secrets - do not commit)
# .env matches any level; .env.example / *.env.example are templates and stay tracked.
.env
.env.local
.env.*.local
!.env.example
!**/.env.example
# Deploy env
deploy/.env
# Claude Code local settings
CLAUDE.md
@ -114,22 +105,9 @@ CLAUDE.md
# Codex CLI local artifacts
.codex/
# Codebase memory (local agent index / graph artifact; do not commit)
.codebase-memory/
# Sync tooling local state (generated each run; report is intentionally tracked)
scripts/.*-sync-state.json
# 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

134
README.md
View File

@ -8,16 +8,14 @@
<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>
[![GitHub Repo](https://img.shields.io/badge/GitHub-Repo-black.svg?logo=github)](https://github.com/mateaix/mateclaw)
[![GitHub Repo](https://img.shields.io/badge/GitHub-Repo-black.svg?logo=github)](https://github.com/matevip/mateclaw)
[![Documentation](https://img.shields.io/badge/Docs-Website-green.svg?logo=readthedocs&label=Docs)](https://claw.mate.vip/docs)
[![Live Demo](https://img.shields.io/badge/Demo-Online-orange.svg?logo=vercel&label=Demo)](https://claw-demo.mate.vip)
[![Website](https://img.shields.io/badge/Website-claw.mate.vip-blue.svg?logo=googlechrome&label=Site)](https://claw.mate.vip)
[![Java Version](https://img.shields.io/badge/Java-21+-blue.svg?logo=openjdk&label=Java)](https://adoptium.net/)
[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.5-brightgreen.svg?logo=springboot)](https://spring.io/projects/spring-boot)
[![Vue](https://img.shields.io/badge/Vue-3-4FC08D.svg?logo=vuedotjs)](https://vuejs.org/)
[![Last Commit](https://img.shields.io/github/last-commit/mateaix/mateclaw)](https://github.com/mateaix/mateclaw)
[![Last Commit](https://img.shields.io/github/last-commit/matevip/mateclaw)](https://github.com/matevip/mateclaw)
[![License](https://img.shields.io/badge/license-Apache--2.0-red.svg?logo=opensourceinitiative&label=License)](LICENSE)
[[Website](https://claw.mate.vip)] [[Live Demo](https://claw-demo.mate.vip)] [[Documentation](https://claw.mate.vip/docs)] [[中文](README_zh.md)]
@ -30,19 +28,13 @@
---
> **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.
>
> **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.
> Multi-user workspaces. Approval-gated sensitive actions. Full audit trail. Spring Boot Actuator health monitoring. Per-channel error isolation so one chat platform's outage doesn't take down the rest. One JAR on your own machine, zero data egress.
Most AI tools die when their vendor has a bad day. Most forget you the moment the tab closes. Most give you a chatbox and call it a product.
**MateClaw is 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.
**MateClaw is the whole widget.** One deployment. Reasoning, knowledge, memory, tools, channels — built together, not bolted on. And when your primary model goes down, the next one picks up mid-sentence.
---
@ -52,7 +44,7 @@ Most AI tools die when their vendor has a bad day. Most forget you the moment th
Primary key expired. Vendor returns 401. Network blip. Quota drained.
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.
Other tools hand you a red error card. MateClaw routes to the next healthy provider — DashScope, OpenAI, Anthropic, Gemini, DeepSeek, Kimi, Ollama, LM Studio, MLX, 14+ in total — and the user sees the reply finish. A provider health tracker parks bad vendors in a cooldown window so they don't waste seconds on every turn.
You don't write a retry script. You drag providers into priority order in **Settings → Models** and watch the health dashboard fill with green dots as requests route around failures in real time.
@ -60,7 +52,7 @@ You don't write a retry script. You drag providers into priority order in **Sett
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.
MateClaw's **LLM Wiki** digests it into structured pages, builds `[[links]]` between them, and remembers where every sentence came from. Click a citation, see the exact source chunk. Ask a question, the page you get is stitched from the right chunks — with references you can verify.
This is the difference between a warehouse and a library.
@ -83,20 +75,7 @@ Same brain. Same memory. Same tools. Different doors.
## What's in the box
### Digital employees, not chatbots
You hire coworkers, not chat boxes. Each one has a **Role**, a **Goal**, a **Backstory**, a 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+)
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)
### Durable long tasks: checkpoint, restart, continue (2.2.0+)
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)
> 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.
You hire coworkers, not chat boxes. Each one has a **Role**, a **Goal**, a **Backstory**, a pixel-art avatar, and a color of their own — five career templates ship ready (Product Researcher · Customer Support · Knowledge Curator · Data Analyst · Executive Assistant). **ReAct** drives iterative reasoning, **Plan-and-Execute** decomposes complex multi-step work, employees can delegate to one another in parallel. Dynamic context pruning, smart truncation, stale-stream cleanup — the boring stuff that makes long conversations actually work.
### Knowledge & memory
- **LLM Wiki** — raw materials digest into linked pages with citations; the **hot cache** auto-injects into every employee's system prompt. **Transformations engine** (1.3.0+) turns the Wiki from a search index into a processing pipeline
@ -104,7 +83,7 @@ One request, one durable **Team Run**. A stable `runId` links the user's objecti
- **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
- **SKILL.md packages** — manifest + prompt + tool list + **LESSONS.md (gets smarter the more you use it)**. Eight starter templates plus a five-step creation wizard, with **Pre-flight checks** that tell you what's missing before install
- **MCP** — stdio / SSE / Streamable HTTP, plug into any external tool server. **Per-employee binding** (1.3.0+) means a tool you install for one employee doesn't bleed into another's toolbox
- **ACP** — bring top-tier coding agents like Claude Code and Codex in as employees, auto-bridged to skill cards with wrapper tools
- **Tool Guard** — RBAC + approval flow + path protection. Capability needs boundaries
@ -115,24 +94,21 @@ One request, one durable **Team Run**. A stable `runId` links the user's objecti
- **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.
**Admin Runtime Console** (`Settings → System → Runtime`) — who's running, what step they're on, how many tokens, one-click force-recycle when stuck. Streaming is staged honestly (thinking / tool / answer), per-event SSE IDs make reconnects safe, multi-employee delegation no longer fights itself, long tasks demand evidence-grounded answers.
### Multimodal creation
Text-to-speech · Speech-to-text · Image · Music · Video · 3D. First-class, not add-ons. **Sidecar routing** (1.3.0+) means a text-only main model + an image attachment no longer dead-ends — a configured vision model describes the image, and the main model answers. **Image edit** lands too: refer to an earlier conversation attachment by `msg:<id>:<idx>` and ask the model to recolor or restyle it. Four **document-generation tools** (`DocxRenderTool` / `XlsxRenderTool` / `PptxRenderTool` / `PdfRenderTool`) render Markdown straight to Office files inside the JVM — no subprocess, no Office install.
### Content Studio (1.8.0+)
A flagship *scene*, not a tool — a seeded "Content Studio" employee turns one sentence into a publishable post: pick-topic → research → draft → illustrate → **de-AI** → lay out → deliver. **WeChat Official Account (公众号)** articles land in your draft box as inline-style HTML with body images uploaded into WeChat; **Xiaohongshu (小红书)** notes package as ≥3 vertical 3:4 cards with an online preview. De-AI-ification runs against a **measurable AI-trace score**; every delivery is compliance-scanned and logged to a **content calendar** that dedups by topic fingerprint.
### Enterprise-ready
RBAC + JWT. **Personal Access Tokens** for headless scripts and CI. **HMAC-SHA-256 outbound webhook signing**. **Distributed Cron lock** so multi-instance deployments don't double-fire. Full audit trail. Flyway-managed schema. 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.
RBAC + JWT. **Personal Access Tokens** for headless scripts and CI. **HMAC-SHA-256 outbound webhook signing**. **Distributed Cron lock** so multi-instance deployments don't double-fire. Full audit trail. Flyway-managed schema that auto-heals on upgrade. One JAR to ship. MySQL in production, H2 for dev — nothing to change in your code.
---
## AI is becoming infrastructure
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.
On March 2, 2026, Claude went dark for 4 hours across API, web, and mobile. Three weeks later, another 5 hours. Every company that bet their AI strategy on a single vendor spent those outages staring at red error cards.
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.
This is the same shift databases went through around 2010 and cloud went through around 2018: the winning layer stops being tied to one supplier. **57% of companies now run AI agents in production.** None of them want one vendor's bad day to become their bad day.
**MateClaw is that layer — built the Spring Boot way.**
@ -152,7 +128,7 @@ Once AI enters production, the stable layer should not be tied to one model supp
**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.
**MateClaw is the version built for teams.** RBAC per digital employee, per model, per tool. An approval flow that pauses risky actions for review. Full audit trail. The Admin Runtime Console gives one operator real-time visibility into 50 employees running across 14 vendors — stuck? force-recycle in one click. Spring Boot inside — drop-in for any Java shop already running production services.
Same "whole widget" philosophy. Different center of gravity.
@ -167,7 +143,7 @@ mvn spring-boot:run # http://localhost:18088
# Frontend
cd mateclaw-ui
npm install && npm run dev # http://localhost:5173
pnpm install && pnpm dev # http://localhost:5173
```
Login: `admin` / `admin123`
@ -181,7 +157,7 @@ docker compose up -d # http://localhost:18080
### Desktop
Download from [GitHub Releases](https://github.com/mateaix/mateclaw/releases). Bundles JRE 21. No Java install needed.
Download from [GitHub Releases](https://github.com/matevip/mateclaw/releases). Bundles JRE 21. No Java install needed.
---
@ -204,29 +180,26 @@ Download from [GitHub Releases](https://github.com/mateaix/mateclaw/releases). B
```
mateclaw/
├── mateclaw-server/ Spring Boot 3.5 backend (Agent Runtime contract, native StateGraph + DSH)
├── mateclaw-server/ Spring Boot 3.5 backend (Spring AI Alibaba, StateGraph runtime)
├── mateclaw-ui/ Vue 3 + TypeScript admin SPA (built into the server JAR)
├── mateclaw-desktop/ Electron desktop app (local-embedded / remote-centralized)
├── mateclaw-webchat/ Embeddable chat widget (UMD / ES bundles)
├── mateclaw-plugin-api/ Java SDK for third-party capability plugins
├── mateclaw-plugin-sample/ Reference plugin implementation
├── mateclaw-plugin-mem0/ Optional Mem0 memory-provider plugin
├── mateclaw-plugin-search-sample/ Search Provider SPI example
├── docker-compose.yml
└── .env.example
```
Desktop binaries ship via [GitHub Releases](https://github.com/mateaix/mateclaw/releases) with a bundled JRE 21 — no Java install needed.
Desktop binaries ship via [GitHub Releases](https://github.com/matevip/mateclaw/releases) with a bundled JRE 21 — no Java install needed.
## Tech stack
| Layer | Technology |
|---|---|
| Backend | Spring Boot 3.5 · Spring AI Alibaba 1.1 · MyBatis Plus · Flyway |
| Agent Runtime | `AgentRuntimeProvider` contract · Native StateGraph (ReAct + Plan-Execute) · managed DSH JSON-RPC runtime · normalized events / lifecycle / usage · Tool Guard |
| Digital Employee Runtime | StateGraph · ReAct + Plan-Execute · Role / Goal / Backstory · LESSONS self-evolution |
| Orchestration | Workflow (7 step modes · Pebble DSL) · Triggers (6 pattern types · event governance) · Wiki Transformations (1.3.0+) |
| Capability Extension | SKILL.md packages · MCP (stdio / SSE / HTTP · per-agent binding) · ACP bridge (Claude Code / Codex) |
| Database | H2 (dev) · PostgreSQL 16 (Docker default) · MySQL 8.0+ (supported) · Kingbase (opt-in driver) |
| Database | H2 (dev) · MySQL 8.0+ (prod) |
| Auth | Spring Security + JWT |
| Frontend | Vue 3 · TypeScript · Vite · Element Plus · TailwindCSS 4 |
| Desktop | Electron · electron-updater · JRE 21 (bundled) |
@ -240,78 +213,17 @@ Full docs at **[claw.mate.vip/docs](https://claw.mate.vip/docs)** — setup, arc
## Roadmap
**v2.2.0 (shipped 2026-08-29)** — from one built-in reasoning loop to **a pluggable and recoverable Agent Runtime**:
**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) for the full story.
- **Runtime contract** — provider registry, session factory, capability validation, normalized event stream, lifecycle, usage, and UI projection decouple employees from execution engines
- **DeepSeek Harness runtime** — managed installation and configuration, authenticated JSON-RPC process bridge, Cordis composition, cancellable streaming, isolated child environment, and host-governed tool dispatch
- **Durable long work** — bounded Goal segments, persisted continuation and input queues, attempts, cooldown, retry, leases, restart recovery, and explicit pause / resume semantics
- **Agent interoperability** — inbound and outbound A2A with Agent Cards, JSON-RPC / SSE tasks, authentication, idempotency, and guarded network boundaries
- **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
Full story in the [v2.2.0 release notes](https://claw.mate.vip/docs/en/releases/2.2.0).
**v2.1.0 (shipped 2026-08-15)** — from “a board full of tasks” to **one governable team run**:
- **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
- **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).
**Next** — Drag-to-edit workflow canvas · run replay timeline · `loop` and `invoke_skill` step modes · trigger priorities and event replay · industry scenario marketplace · more ACP upstream integrations.
## Contributing
```bash
git clone https://github.com/mateaix/mateclaw.git
git clone https://github.com/matevip/mateclaw.git
cd mateclaw
cd mateclaw-server && mvn clean compile
cd ../mateclaw-ui && npm install && npm run dev
cd ../mateclaw-ui && pnpm install && pnpm dev
```
---

View File

@ -8,16 +8,14 @@
<p align="center"><b>你的超级大脑</b></p>
<p align="center"><sub><b>可插拔 Agent Runtime · Native + DSH · Spring Boot 内核</b></sub></p>
[![GitHub 仓库](https://img.shields.io/badge/GitHub-仓库-black.svg?logo=github)](https://github.com/mateaix/mateclaw)
[![GitHub 仓库](https://img.shields.io/badge/GitHub-仓库-black.svg?logo=github)](https://github.com/matevip/mateclaw)
[![文档](https://img.shields.io/badge/文档-在线-green.svg?logo=readthedocs&label=Docs)](https://claw.mate.vip/docs)
[![在线演示](https://img.shields.io/badge/演示-在线-orange.svg?logo=vercel&label=Demo)](https://claw-demo.mate.vip)
[![官网](https://img.shields.io/badge/官网-claw.mate.vip-blue.svg?logo=googlechrome&label=Site)](https://claw.mate.vip)
[![Java 版本](https://img.shields.io/badge/Java-21+-blue.svg?logo=openjdk&label=Java)](https://adoptium.net/)
[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.5-brightgreen.svg?logo=springboot)](https://spring.io/projects/spring-boot)
[![Vue](https://img.shields.io/badge/Vue-3-4FC08D.svg?logo=vuedotjs)](https://vuejs.org/)
[![最后提交](https://img.shields.io/github/last-commit/mateaix/mateclaw)](https://github.com/mateaix/mateclaw)
[![最后提交](https://img.shields.io/github/last-commit/matevip/mateclaw)](https://github.com/matevip/mateclaw)
[![许可证](https://img.shields.io/badge/license-Apache--2.0-red.svg?logo=opensourceinitiative&label=License)](LICENSE)
[[官网](https://claw.mate.vip)] [[在线演示](https://claw-demo.mate.vip)] [[文档](https://claw.mate.vip/docs)] [[English](README.md)]
@ -30,19 +28,13 @@
---
> **最新稳定版v2.2.0 —— 可插拔、可恢复的 Agent Runtime。** 数字员工现在可以选择 MateClaw 原生 StateGraph 引擎或受管理的 DeepSeek HarnessDSH运行时同时复用同一套会话、策略、工具、持久化与可观测面Persistent Goal 可跨有界回合和后端重启继续A2A 则让受治理的员工跨系统互联。详见 [v2.2.0 更新记录](https://claw.mate.vip/docs/zh/releases/2.2.0)。
---
> **别的 AI 助手是给一个人用的。MateClaw 是公司允许部署的那一个。**
>
> 多用户工作空间。敏感操作走审批。完整审计日志。Spring Boot Actuator 健康监控。单个渠道挂掉不影响其他渠道的错误隔离。一个 JAR 包跑在自己的环境里;持久化数据由你掌控,任务所需内容只会发送到你主动配置的模型、渠道或工具服务。
>
> **底下是一套真正的 Agent Runtime。** 员工不再焊死在一套推理循环上:可以用原生 StateGraph 运行 ReAct、Plan-and-Execute、Goal 与 Team Run也可以通过认证 JSON-RPC 把 DeepSeek Harness 作为受管理的外部循环。两条路径最终进入同一套会话、工作空间边界、Tool Guard、事件投影与生命周期控制。
> 多用户工作空间。敏感操作走审批。完整审计日志。Spring Boot Actuator 健康监控。单个渠道挂掉不影响其他渠道的错误隔离。一个 JAR 包跑在自己机器上,数据不出门。
大多数 AI 工具一到厂商抽风那天就两手一摊。关一次标签页就忘了你是谁。给你一个聊天框,就敢叫产品。
**MateClaw 是完整的一整套。** 一次部署——推理、知识、记忆、工具、多渠道入口,从第一天就一起设计,不是事后拼接。主模型不可用时,系统会按优先级改由下一家健康供应商重新完成当前请求
**MateClaw 是完整的一整套。** 一次部署——推理、知识、记忆、工具、多渠道入口,从第一天就一起设计,不是事后拼接。主模型挂了,下一家接着把这句话说完
---
@ -52,7 +44,7 @@
Key 过期。厂商返回 401。网络抖动。配额耗尽。
别的工具丢你一张红色错误卡。MateClaw 会按配置顺序尝试下一家健康供应商——DashScope、OpenAI、Anthropic、Gemini、DeepSeek、Kimi、Ollama、LM Studio、MLX 等内置或 OpenAI 兼容供应商——尽可能恢复当前请求;仅当可用链路全部失败时才返回错误。内置的 **Provider Health Tracker** 会把连续失败的供应商放进冷却窗口,避免每一轮对话都白白撞壁。
别的工具丢你一张红色错误卡。MateClaw 自动切到下一家健康的供应商——DashScope、OpenAI、Anthropic、Gemini、DeepSeek、Kimi、Ollama、LM Studio、MLX共 14+ 家——用户只会看到回答正常完成。内置的 **Provider Health Tracker** 会把连续失败的供应商放进冷却窗口,避免每一轮对话都白白撞壁。
你不用写重试脚本。在 **设置 → 模型** 里把供应商拖成你想要的优先顺序,健康面板实时亮起一排绿点——请求绕着故障流过去。
@ -60,7 +52,7 @@ Key 过期。厂商返回 401。网络抖动。配额耗尽。
上传 PDF、一批 markdown、抓下来的网页——原始材料进去。
MateClaw 的 **LLM Wiki** 把它消化成结构化页面,页面之间自己长出 `[[链接]]`生成内容保留可追踪引用。点开引用抽屉,就能看到对应的原始 chunk页面与回答中的引用可以回到来源核对
MateClaw 的 **LLM Wiki** 把它消化成结构化页面,页面之间自己长出 `[[链接]]`每一句话都记得来自哪里。点开引用抽屉,就能看到原始 chunk。问一个问题得到的页面是从对应片段拼出来的——带可核对的出处
这是**仓库**和**图书馆**的区别。
@ -83,20 +75,7 @@ MateClaw 的 **LLM Wiki** 把它消化成结构化页面,页面之间自己长
## 盒子里有什么
### 数字员工,不是聊天机器人
你雇佣员工,不是开聊天框。每位有**角色**、**目标**、**背景故事**、运行时、像素艺术头像与专属配色——6 个内置模板(通用助手 · 产品助理 · 研究分析师 · 客服助理 · 数据分析师 · 代码审查员)开箱可用。即使更换执行引擎,员工身份和治理边界仍保持不变。
### Agent RuntimeNative 或 DSH2.2.0+
`AgentRuntimeProvider` contract 把员工与实际执行回合的引擎分开。**Native Runtime** 在 MateClaw 内运行 ReAct、Plan-and-Execute、Persistent Goal 与 Team Run**DSH Runtime** 把 `dsh-jsonrpc-agent` 作为认证子进程管理,并将思考、文本、工具调用、用量、完成与取消统一映射为 runtime event。DSH 掌管外部 Agent loopMateClaw 继续掌管 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 Run2.1.0+
一次请求对应一个持久化的 **Team Run**。稳定的 `runId` 串起用户目标、任务 DAG、成员执行、最终汇总与交付物。Chat 是成果交付面Agents Live 按运行聚合成员并展示实时状态Teams 管理历史与治理;三处读取同一份服务端投影。成员子会话不再挤进普通会话列表,摘要和文件优先展示,任务、证据、审批与只读成员记录按需下钻。底层继续使用 2.0 的共享任务板,保留依赖编排、并行派发、前置结果传递、执行租约、取消中断和人工审批卡点。
你雇佣员工,不是开聊天框。每位有**角色**、**目标**、**背景故事**像素艺术头像、专属配色——5 个职业模板(产品研究员 · 客户支持 · 知识管理员 · 数据分析师 · 行政助理)开箱可用。**ReAct** 做迭代推理,**Plan-and-Execute** 做复杂多步任务,员工之间可以并行委派。动态上下文裁剪、智能截断、僵死流清理——让长对话真正能用的那些"不起眼"的基础设施。
### 知识与记忆
- **LLM Wiki** — 原始材料消化成有链接、带引用的结构化页面;**热点缓存**自动注入到员工的 system prompt。**加工器引擎**1.3.0+)把 Wiki 从"搜索索引"升级为"处理流水线"
@ -104,7 +83,7 @@ Persistent Goal 把需要数小时的工作拆成有界、可恢复的执行段
- **记忆生命周期** — 对话后自动提取 · 定时整理 · Dreaming 工作流。工作流也可以通过 `write_memory` step 直接写进员工的 `MEMORY.md`
### 技能 · MCP · ACP — 三种"接外部能力"的方式
- **SKILL.md 技能包** — 一份 manifest + prompt + 工具列表 + **LESSONS.md**。2.1 可通过对话反思与跨会话重复请求挖掘形成可复用改进并以候选晋升、受约束自动绑定、curator 治理、来源策略、快照和恢复点保证过程可观察、按工作空间隔离且可回滚;所有自动能力均由独立开关控制。另有 8 个起步模板、5 步创作向导和安装前 **Pre-flight 检查**
- **SKILL.md 技能包** — 一份 manifest + prompt + 工具列表 + **LESSONS.md(用得越多越聪明)**。8 个起步模板 + 5 步创作向导,安装前自动跑 **Pre-flight 检查**告诉你缺什么
- **MCP** — stdio / SSE / Streamable HTTP 三种传输,接入任意外部工具服务器。**每位员工独立绑定**1.3.0+)——一位员工装的工具不会渗到其他人的工具栏里
- **ACP** — 把 Claude Code、Codex 这种顶级编码 Agent 以"员工"身份接入,桥接成技能卡 + 包装工具
- **Tool Guard** — RBAC + 审批流 + 文件路径保护。能力必须有边界
@ -115,24 +94,21 @@ Persistent Goal 把需要数小时的工作拆成有界、可恢复的执行段
- **Wiki 加工器** — Wiki 不再只是被动检索。用户自定义模板对原料或现有页面跑模板,跨原料 map-reduce 聚合reverse-citation 绑定到源 chunkJSON 输出 + 可选 JSON Schema每个模板独立选模型
### 你看得见每位员工正在干什么
**Admin 运行时控制台**`后台 → 系统 → 运行时`)——谁在跑、当前回合由哪个 runtime provider 承载、跑到哪一步、占多少 token卡住可一键回收。Native 与 DSH 事件进入同一套思考 / 工具 / 回答投影完成、失败、用量和取消保持一致的生命周期语义。SSE 每事件 ID 支持安全重连Team Run 将成员工作聚合到同一次运行下
**Admin 运行时控制台**`后台 → 系统 → 运行时`)——谁在跑、跑到哪一步、占多少 token、卡住了一键回收。流式分阶段显示思考 / 工具 / 回答SSE 每事件 ID 支持安全重连,多员工协作不打架,长任务必须有真实证据才回答
### 多模态创作
语音合成 · 语音识别 · 图片 · 音乐 · 视频 · 3D。一等公民不是附加插件。**多模态旁路**1.3.0+)让纯文本主模型遇到图片附件时自动调用配置好的视觉模型转描述,主对话保持便宜。**图像编辑**也到位:用 `msg:<id>:<idx>` 引用会话里更早的某张图,让模型改色、改风格。**4 个文档生成工具**`DocxRenderTool` / `XlsxRenderTool` / `PptxRenderTool` / `PdfRenderTool`)在 JVM 内把 Markdown 直接渲染成 Office 文件——不 fork 子进程、不依赖 npm、不需要装 Office。
### 内容工作室1.8.0+
一个招牌*场景*,不是工具——预置的「内容工作室」员工把一句话变成可发布成品:选题 → 搜集 → 成文 → 配图 → **去 AI 化** → 排版 → 交付。**微信公众号(公众号)**文章以内联样式 HTML 进入草稿箱,正文图自动上传到微信;**小红书**笔记打包成 ≥3 张竖版 3:4 卡片并在线预览。去 AI 化围绕一个**可度量的 AI 痕迹评分**运行;每次交付都经过合规扫描,并记入按选题指纹去重的**内容日历**。
### 企业就绪
RBAC + JWT。**Personal Access Token** 给无人值守脚本和 CI 使用。**Webhook 出站 HMAC-SHA-256 签名**。**Cron 分布式锁**避免多实例重复执行。完整审计事件流。Flyway 管理数据库 schema。一个 JAR 交付。开发环境可用 H2公开 Docker 栈默认使用 PostgreSQL 16同时保留 MySQL profileKingbase 驱动为按需启用
RBAC + JWT。**Personal Access Token** 给无人值守脚本和 CI 用。**Webhook 出站 HMAC-SHA-256 签名**。**Cron 分布式锁**多实例不双发。完整审计事件流。Flyway 管理数据库 schema升级时自愈。一个 JAR 交付。生产用 MySQL开发用 H2代码零改动。
---
## AI 正在变成基础设施
模型供应商会限流网络会抖动Key 会过期,服务也可能临时不可用。把所有 AI 能力押在单一供应商上,会让上游故障直接变成自己的业务故障
2026 年 3 月 2 日Claude 全球宕机 **4 小时**——API、Web、移动端同时黑屏。三周后又来一次**5 小时**。每一家把 AI 战略押在单一厂商身上的公司,那几个小时只能盯着红色错误卡
当 AI 进入生产环境,稳定的一层既不应绑定一家模型供应商,也不应绑定一套 Agent loop。MateClaw 用供应商优先级、健康追踪、冷却与故障转移吸收模型侧不确定性,再把 Native 与外部执行引擎收进同一份受治理的 Agent Runtime contract
这和 2010 年数据库走过的路、2018 年云走过的路**是同一个转弯**:赢的那一层,不再绑在一家供应商身上。**57% 的公司已经把 AI agent 推进生产**——没有一家希望某个厂商的坏日子变成自己的坏日子
**MateClaw 就是那一层——用 Spring Boot 方式盖的。**
@ -152,7 +128,7 @@ RBAC + JWT。**Personal Access Token** 给无人值守脚本和 CI 使用。**We
**OpenClaw 和 Hermes Agent 是优秀的个人 AI 平台**——如果你是一个人、一台笔记本、习惯从 CLI 搭自己的 agent、所有东西都靠手工配置文件调优选它们没问题。两家的社区规模今天都大于 MateClaw。
**MateClaw 是那个给团队用的版本。** 数字员工、模型与工具都纳入权限和工作空间边界。危险动作可暂停等待审批关键操作进入审计事件流。Admin 运行时控制台集中展示正在执行的员工与供应商状态,卡住时可回收。底座是 Spring Boot适合并入已有 Java 服务体系
**MateClaw 是那个给团队用的版本。** 每位数字员工、每个模型、每个工具都有 RBAC。危险动作自动暂停等审批。完整审计事件流。Admin 运行时控制台让一个运维能实时看到 50 位员工跑在 14 家供应商上的状态——卡住了一键回收。底座是 Spring Boot——任何一家已经在生产跑 Java 服务的公司可以直接并入
**同一套"完整一整套"哲学,不同的重心。**
@ -167,7 +143,7 @@ mvn spring-boot:run # http://localhost:18088
# 前端
cd mateclaw-ui
npm install && npm run dev # http://localhost:5173
pnpm install && pnpm dev # http://localhost:5173
```
默认登录:`admin` / `admin123`
@ -181,7 +157,7 @@ docker compose up -d # http://localhost:18080
### 桌面端
从 [GitHub Releases](https://github.com/mateaix/mateclaw/releases) 下载安装包。内嵌 JRE 21无需额外装 Java。
从 [GitHub Releases](https://github.com/matevip/mateclaw/releases) 下载安装包。内嵌 JRE 21无需额外装 Java。
---
@ -204,29 +180,26 @@ docker compose up -d # http://localhost:18080
```
mateclaw/
├── mateclaw-server/ Spring Boot 3.5 后端(Agent Runtime contract · Native StateGraph + DSH
├── mateclaw-server/ Spring Boot 3.5 后端(Spring AI Alibaba · StateGraph 运行时
├── mateclaw-ui/ Vue 3 + TypeScript 管理 SPA构建产物打进后端 JAR
├── mateclaw-desktop/ Electron 桌面端(本地内嵌 / 远程集中双模式)
├── mateclaw-webchat/ 网页嵌入式聊天组件UMD / ES bundle
├── mateclaw-plugin-api/ 第三方能力插件的 Java SDK
├── mateclaw-plugin-sample/ 参考插件实现
├── mateclaw-plugin-mem0/ 可选 Mem0 记忆 Provider 插件
├── mateclaw-plugin-search-sample/ 搜索 Provider SPI 示例
├── docker-compose.yml
└── .env.example
```
桌面端安装包通过 [GitHub Releases](https://github.com/mateaix/mateclaw/releases) 分发,内嵌 JRE 21——无需安装 Java。
桌面端安装包通过 [GitHub Releases](https://github.com/matevip/mateclaw/releases) 分发,内嵌 JRE 21——无需安装 Java。
## 技术栈
| 层次 | 技术 |
|---|---|
| 后端 | Spring Boot 3.5 · Spring AI Alibaba 1.1 · MyBatis Plus · Flyway |
| Agent Runtime | `AgentRuntimeProvider` contract · Native StateGraphReAct + Plan-Execute· 受管理的 DSH JSON-RPC runtime · 统一事件 / 生命周期 / 用量 · Tool Guard |
| 数字员工运行时 | StateGraph · ReAct + Plan-Execute · 角色 / 目标 / 背景故事 · LESSONS 自我进化 |
| 业务编排 | 工作流7 step mode · Pebble DSL· 触发器6 pattern type · 事件治理)· Wiki 加工器1.3.0+|
| 能力扩展 | SKILL.md 包 · MCPstdio / SSE / HTTP · per-agent 绑定)· ACP 桥接Claude Code / Codex |
| 数据库 | H2开发· PostgreSQL 16Docker 默认)· MySQL 8.0+(支持)· Kingbase按需驱动|
| 数据库 | H2开发· MySQL 8.0+(生产|
| 认证 | Spring Security + JWT |
| 前端 | Vue 3 · TypeScript · Vite · Element Plus · TailwindCSS 4 |
| 桌面端 | Electron · electron-updater · 内嵌 JRE 21 |
@ -240,78 +213,17 @@ mateclaw/
## 路线图
**v2.2.02026-08-29 发布)** —— 从一套内置推理循环走向**可插拔、可恢复的 Agent Runtime**
**v1.3.02026-05-13 发布)** — 工作流引擎 · 6 种 pattern 触发器 · Wiki 加工器 · 每员工独立 MCP 绑定 · 多模态旁路路由 · 4 个 JVM 原生文档生成工具 · 图像编辑。完整故事见 [v1.3.0 release notes](https://claw.mate.vip/docs/zh/releases/1.3.0)。
- **Runtime contract** —— provider registry、session factory、能力校验、统一事件流、生命周期、用量与 UI 投影,让员工身份与执行引擎解耦
- **DeepSeek Harness runtime** —— 受管理的安装与配置、认证 JSON-RPC 进程桥、Cordis composition、可取消流、子进程环境隔离以及由宿主治理的工具派发
- **持久长任务** —— 有界 Goal segment、持久化 continuation / 输入队列、attempt、冷却、重试、租约、重启恢复和显式暂停 / 恢复语义
- **Agent 互操作** —— A2A 入站与出站、Agent Card、JSON-RPC / SSE task、认证、幂等与受控网络边界
- **Runtime 加固** —— 工作空间归属进一步收口Team Run 恢复和交付门更可靠,长文本及审批、停止、恢复期间的输入处理更一致
完整内容见 [v2.2.0 更新记录](https://claw.mate.vip/docs/zh/releases/2.2.0)。
**v2.1.02026-08-15 发布)** —— 从“一块摆满任务的看板”到**一次可治理的团队运行**
- **统一 Team Run** —— 一个 `runId` 串起请求、任务 DAG、成员会话、事件、最终汇总与交付物Chat 交付成果Agents 观察实时执行Teams 管理历史与治理
- **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.02026-07-31 发布)** —— 从“一个能干活的人”到“一支能协作的队伍”:**Agent 团队**成为常设编制,围绕共享任务板工作:
- **Agent 团队与共享任务板** — 团队 / 角色lead · member · reviewer、八状态看板、`blockedBy` 依赖编排、成员级并行派发、前置结果自动传递、结果通报唤醒 LeadTeams 页事件驱动实时看板 + 活动横幅 + 任务时间线 + 交付物下载 + 手动投任务
- **为长任务加固的执行链** — 执行租约 + 运行期心跳防双重执行、取消即真实中断、`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.02026-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.02026-07-04 发布)** — 一次*生产化加固*:把它放进真正的协作里之后,那些看不见、收不拢、够不着、装不下、连不通的地方全补上:
- **审批三条链路彻底闭环** — 工作流 `await_approval` 真的推到渠道并 resolve→恢复执行、WebChatAPI-Key渠道能批准/拒绝并重放、飞书/企微点卡片直接 resolve 工作流审批
- **长任务看得见** — 常驻「运行总览」侧栏 + 本轮 Token 明细(缓存命中/未命中/写入 + 推理拆分)+ 子 Agent 成本向上滚加 + 生成文件一键下载
- **装得下真实模型窗口** — 本地模型上下文窗口探测、prefix 注入统一 Token 预算、小上下文降级、工具 schema 预算门——不再被"猜个 32K"坑到预检拒绝或悄悄截断
- **开放出去** — 知识库 / Deep Research 开放 APIAPI-Key + 限流 + SSE、插件化搜索 Provider SPI、MCP 身份透传(把认证用户身份带给 STDIO MCP
- **够得着更远** — 桌面端本地内嵌 / 远程集中部署双模式(`mateclaw-desktop` 源码开放)+ 局域网部署模式放开受控内网访问
- **运营数据一键导出** — Dashboard 9 表 Excel + CLI 命令行离线导出
完整故事见 [v1.7.0 release notes](https://claw.mate.vip/docs/zh/releases/1.7.0)。
**v1.6.02026-06-22 发布)** — 让自驱的数字员工*更快、更会看、更易嵌入*:技能两段式载入 + prefix 压缩(首字节更快)· `execute_code` 原生沙箱代码执行 · 图片跨轮次留存 + `image_analyze` · 可嵌入/无头 webchat 按 `endUserId` 隔离记忆 · 真正可读的 Wiki阅读与管理分离 · 统一 Sources 标签 · 可点击 `[[wikilinks]]`)· 高负载更稳MCP 自愈 · 工具调用恢复 · 计划证据闸门)。完整故事见 [v1.6.0 release notes](https://claw.mate.vip/docs/zh/releases/1.6.0)。
**v1.5.02026-06-04 发布)** — Goal 可勾选清单(模糊评分 → 逐项打勾)· Wiki 自维护(`[[wikilinks]]` · 事实层/经验层 · pageType 模板与权限 · 知识库流水线 · 本地目录接入)· 按拥有者隔离记忆(`owner_key` + 可见域 + `endUserId` 透传)· 每员工绑定主知识库 · 偏好 provider 驱动选型。完整故事见 [v1.5.0 release notes](https://claw.mate.vip/docs/zh/releases/1.5.0)。
**v1.4.02026-05-23 发布)** — 持续目标(锁定目标,每轮自评)· 子员工委派树(最深 3 层 · 同步 / 并行 / 异步 · 一句话组队)· 工具/技能渐进式披露 · 工作空间 RBACOwner / Admin / Member / Viewer· 飞书一等公民(交互卡 / 审批卡 / 流式卡 · 渠道原生工具)。详见 [v1.4.0 release notes](https://claw.mate.vip/docs/zh/releases/1.4.0)。
**v1.3.02026-05-13 发布)** — 工作流引擎 · 6 种 pattern 触发器 · Wiki 加工器 · 每员工独立 MCP 绑定 · 多模态旁路路由 · 4 个 JVM 原生文档生成工具 · 图像编辑。详见 [v1.3.0 release notes](https://claw.mate.vip/docs/zh/releases/1.3.0)。
**下一步** — 工作流画布可拖拉编辑 · 运行回放时间线 · `loop` / `invoke_skill` step mode · 触发器优先级 + 事件回放 · 行业场景应用市场 · 更多 ACP 上游集成。
## 参与贡献
```bash
git clone https://github.com/mateaix/mateclaw.git
git clone https://github.com/matevip/mateclaw.git
cd mateclaw
cd mateclaw-server && mvn clean compile
cd ../mateclaw-ui && npm install && npm run dev
cd ../mateclaw-ui && pnpm install && pnpm dev
```
---

159
UPGRADING.md Normal file
View File

@ -0,0 +1,159 @@
# Upgrading MateClaw
## 1.0.x → 1.1.0
**TL;DR** — Most users have nothing to do. Restart with 1.1.0, Flyway's built-in repair heals known checksum drift, Ollama auto-discovery rewrites the bad `:latest` defaults, and everything else self-converges. Docker Compose deployments need a one-time `.env` update.
See `docs/en/releases/1.1.0.md` for the feature changelog.
---
## For everyone
### ⚠️ What happens automatically (no action)
- **Flyway migration self-heal** — 1.1.0 rewrote all MySQL migrations V2V14 to replace unsupported `ADD COLUMN IF NOT EXISTS` syntax (Gitee #IIYHLJ). `FlywayRepairConfig` runs `flyway.repair()` on every boot, so the new checksums auto-accept and migration resumes from wherever your schema is.
- **Ollama default model** — if your 1.0.x run auto-picked a model tag Ollama no longer has (commonly `deepseek-r1:latest`), on 1.1.0 restart `OllamaAutoDiscoveryRunner` detects the broken default and re-picks a tag-capable model (e.g. `deepseek-r1:7b`, `qwen3:latest`), preferring one that supports function calling.
- **Stale `mate_model_config` rows** — idempotent seed data reconciles on each startup.
### 📋 Recommended pre-upgrade steps
1. Back up your database — `mateclaw` schema on MySQL, or `data/mateclaw.mv.db` on H2.
2. Back up `data/` directory (skill workspaces, uploaded files, memory files).
3. Note your current default model in Settings → Models in case you want to switch back.
### 🚀 Upgrade
```bash
git pull
cd mateclaw-server
mvn clean package -DskipTests
# then restart your service per your deployment method
```
Or for Desktop app users: just update to 1.1.0 via the in-app updater or re-download.
---
## For Docker Compose deployments
**One-time migration step required** — 1.1.0 refuses to start with default hardcoded passwords.
### 1. Copy-paste merge the new `.env.example` keys
```bash
cp .env .env.backup
# open .env.example — it has new required keys:
# DB_PASSWORD= (was default 'mateclaw123', now MUST be overridden)
# DB_ROOT_PASSWORD= (new, required for MySQL root)
# JWT_SECRET= (new, strongly recommended)
# MATECLAW_CORS_ALLOWED_ORIGINS= (new, strongly recommended for prod)
```
### 2. Set strong values in your `.env`
```env
# STRONG passwords — at least 16 chars, mixed case + digits + symbols
DB_PASSWORD=<your-strong-db-user-password>
DB_ROOT_PASSWORD=<different-strong-root-password>
# 32+ char random string — generate with: openssl rand -base64 48
JWT_SECRET=<your-jwt-secret>
# Production CORS allowlist — comma-separated, no wildcards
MATECLAW_CORS_ALLOWED_ORIGINS=https://mateclaw.example.com
```
If any of `DB_PASSWORD` / `DB_ROOT_PASSWORD` / `DASHSCOPE_API_KEY` is missing, `docker compose up` will fail fast with a clear error — this is intentional.
### 3. Existing MySQL volume compatibility
If you already ran 1.0.x with the old default password (`mateclaw123`), **your existing MySQL volume still has the old root password inside**. You have two options:
**Option A — keep existing password** (fastest, least secure):
Set `DB_ROOT_PASSWORD=mateclaw123` and `DB_PASSWORD=mateclaw123` in `.env` to match. Upgrade works. Then rotate after upgrade using `ALTER USER ... IDENTIFIED BY ...` inside the MySQL container.
**Option B — fresh volume with new password** (cleanest, loses DB if not backed up):
```bash
docker compose down -v # ⚠️ deletes mysql_data volume; back up first
# edit .env with new strong password
docker compose up -d
```
Then re-import your backup if you kept one.
### 4. Restart
```bash
docker compose up -d
docker compose logs -f mateclaw-server # watch for "Flyway Successfully applied N migrations"
```
Expected log lines during boot:
- `Flyway Successfully applied N migrations to schema mateclaw`
- `Ollama: auto-activated default model '<actual-tag>'` (if you use Ollama — should NOT say `:latest` any more)
- `[Security] Using default JWT secret!` → means you forgot to set `JWT_SECRET` — fix and restart
---
## For local dev / H2 deployments
No action required. `mvn spring-boot:run` picks up the latest migrations on next start, Flyway repair handles checksum drift, H2 file at `data/mateclaw.mv.db` is preserved.
---
## Known migration quirks
### 1. If you manually fiddled with `flyway_schema_history`
In 1.0.x some users hit Flyway version collisions (V8/V9 and V9/V10) which 1.1.0 fixes by renumbering. If you manually deleted rows from `flyway_schema_history` you may see `Validate failed` on 1.1.0 startup — run:
```sql
-- MySQL
DELETE FROM flyway_schema_history WHERE success = 0;
```
Then restart. `FlywayRepairConfig` will rebuild history from current schema state.
### 2. If your Ollama models are all in the no-tools family
After upgrade, agents that require tool calling will log a warning on first invocation:
```
Ollama: auto-activated default model '...' but its family does not support tool calling
```
Fix — pull a tool-capable model, or switch default in Settings → Models:
```bash
ollama pull qwen3
# or
ollama pull llama3.1:8b
# or
ollama pull mistral-nemo
```
### 3. If you had custom tools using `extract_document_text` / wiki tools
Wiki chunk schema changed (new `embedding` + `embedding_model` columns on `mate_wiki_chunk`). Your existing wiki pages work unchanged; only semantic search is new and requires an embedding model to be configured in Settings → Models (a default DashScope embedding is seeded).
---
## Rolling back to 1.0.x
Not recommended (some new tables / columns don't exist in 1.0.x), but possible if you backed up the DB before upgrade:
```bash
git checkout v1.0.418
# restore DB backup
docker compose up -d # or mvn spring-boot:run
```
If you need to keep the new data but downgrade the app, you're in unsupported territory — open a Gitee issue.
---
## Getting help
- **Logs first**: `mateclaw-server/logs/mateclaw.log` + `mateclaw-error.log` have everything. Flyway decisions are at INFO level in main log.
- **Doctor tab**: in-app Settings → Doctor runs basic health checks
- **Gitee**: https://gitee.com/matevip_admin/mateclaw/issues — include your upgrade path (1.0.?? → 1.1.0), profile (H2 / MySQL), and the last 100 lines of startup log

View File

@ -32,10 +32,9 @@
<!-- ===== 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>
<text x="480" y="268" text-anchor="middle" font-size="16" font-weight="800" fill="#d96d46">Digital Employee</text>
<text x="480" y="288" text-anchor="middle" font-size="11" font-weight="500" fill="#665245">Role · Goal · Backstory</text>
<text x="480" y="304" text-anchor="middle" font-size="9" fill="#9b7d6c">ReAct + Plan-Execute</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)"/>
@ -99,10 +98,10 @@
<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>
<!-- ===== Orchestration tier (NEW 1.3.0): Workflow + Trigger ===== -->
<rect x="350" y="358" width="260" height="36" rx="10" fill="url(#primary)" filter="url(#shadow)"/>
<text x="480" y="376" text-anchor="middle" font-size="11" font-weight="700" fill="#ffffff">Orchestration · Workflow + Trigger</text>
<text x="480" y="389" text-anchor="middle" font-size="9" fill="#fde7dd">Events → multi-employee → approval / dispatch / memory</text>
<!-- ===== Bottom Center: Provider Pool + Failover ===== -->
<rect x="370" y="420" width="220" height="64" rx="12" fill="url(#accent)" filter="url(#shadow)"/>

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

@ -33,10 +33,9 @@
<!-- ===== 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>
<text x="480" y="268" text-anchor="middle" font-size="16" font-weight="800" fill="#d96d46">数字员工</text>
<text x="480" y="288" text-anchor="middle" font-size="11" font-weight="500" fill="#665245">角色 · 目标 · 背景故事</text>
<text x="480" y="304" text-anchor="middle" font-size="9" fill="#9b7d6c">ReAct + Plan-Execute</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)"/>
@ -106,10 +105,10 @@
<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>
<!-- ===== Orchestration tier (NEW 1.3.0): Workflow + Trigger ===== -->
<rect x="350" y="358" width="260" height="36" rx="10" fill="url(#primary)" filter="url(#shadow)"/>
<text x="480" y="376" text-anchor="middle" font-size="11" font-weight="700" fill="#ffffff">业务编排 · 工作流 + 触发器</text>
<text x="480" y="389" text-anchor="middle" font-size="9" fill="#fde7dd">事件触发 → 多员工协作 → 审批 / 分发 / 写记忆</text>
<!-- ===== Bottom Center: Models + Failover ===== -->
<rect x="370" y="420" width="220" height="64" rx="12" fill="url(#accent)" filter="url(#shadow)"/>

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@ -67,40 +67,40 @@
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">Slack</text>
</g>
<!-- ===== Layer 2: Agent Runtime ===== -->
<!-- ===== Layer 2: Agent Engine ===== -->
<rect x="30" y="186" width="900" height="118" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
<rect x="30" y="186" width="900" height="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 &amp; GOVERNANCE</text>
<text x="56" y="210" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">DIGITAL EMPLOYEE RUNTIME</text>
<g transform="translate(56, 222)">
<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>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">Reasoning Engines</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">ReAct · Think→Act→Observe</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Plan-Execute · Decompose</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>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Workflow + Trigger</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">7 step modes · 6 patterns</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#9b7d6c">Business orchestration (1.3.0+)</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>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Skills · Tools</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Built-in · MCP · ACP · Skills</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">SKILL.md + LESSONS + Approval</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>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Memory System</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">Short-term + Extraction</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Consolidation + Dreaming</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>
<text x="54" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Wiki KB</text>
<text x="54" y="36" text-anchor="middle" font-size="9" fill="#665245">Knowledge digest</text>
<text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">+ Transforms (1.3)</text>
</g>
<!-- ===== Layer 3: Core Services ===== -->

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -70,40 +70,40 @@
<text x="65" y="37" text-anchor="middle" font-size="9" fill="#665245">Slack</text>
</g>
<!-- ===== Layer 2: Agent Runtime ===== -->
<!-- ===== Layer 2: Agent Engine ===== -->
<rect x="30" y="186" width="900" height="118" rx="14" fill="url(#warm)" stroke="#d9cec2" stroke-width="1" filter="url(#shadow)"/>
<rect x="30" y="186" width="900" height="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>
<text x="56" y="210" font-size="11" font-weight="700" fill="#184a45" letter-spacing="1">数字员工运行时</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>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#184a45">推理双引擎</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">ReAct · 思考→行动→观察</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">Plan-Execute · 计划分解</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>
<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">7 step mode · 6 pattern</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#9b7d6c">业务流程编排1.3.0+</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>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">技能 · 工具</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">内置 · MCP · ACP · 技能</text>
<text x="86" y="50" text-anchor="middle" font-size="9" fill="#665245">SKILL.md + LESSONS + 审批</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>
<text x="86" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">记忆 · Dreaming</text>
<text x="86" y="36" text-anchor="middle" font-size="9" fill="#665245">短期上下文 + 长期提取</text>
<text x="86" y="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>
<text x="54" y="20" text-anchor="middle" font-size="12" font-weight="700" fill="#d96d46">Wiki KB</text>
<text x="54" y="36" text-anchor="middle" font-size="9" fill="#665245">知识消化</text>
<text x="54" y="50" text-anchor="middle" font-size="9" fill="#665245">+ 加工器1.3.0</text>
</g>
<!-- ===== Layer 3: Core Services ===== -->

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -1,57 +1,35 @@
# ============================================================================
# ⚠️ 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).
# ============================================================================
version: '3.8'
services:
# PostgreSQL 数据库
# MySQL 数据库
#
# ⚠️ 密码通过环境变量传入,必须从 .env 文件提供。首次部署前:
# 1. cp .env.example .env
# 2. 编辑 .env 把 DB_ADMIN_PASSWORD / DB_PASSWORD 改成强密码
# 2. 编辑 .env 把 DB_ROOT_PASSWORD / DB_PASSWORD 改成强密码
# 未设置会直接在 `docker compose up` 时报错,避免把默认密码带到生产环境。
postgres:
image: postgres:16
container_name: mateclaw-postgres
mysql:
image: mysql:8.0
container_name: mateclaw-mysql
restart: unless-stopped
environment:
# Bootstrap/superuser — owns the cluster, used only for init + admin tasks.
POSTGRES_DB: ${DB_NAME:-mateclaw}
POSTGRES_USER: ${DB_ADMIN_USERNAME:-mateclaw_admin}
POSTGRES_PASSWORD: ${DB_ADMIN_PASSWORD:?DB_ADMIN_PASSWORD is required in .env}
# Least-privilege application role created by the init script below; this
# is the account the server connects with (NOT a superuser).
APP_DB_USERNAME: ${DB_USERNAME:-mateclaw}
APP_DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required in .env}
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:?DB_ROOT_PASSWORD is required in .env}
MYSQL_DATABASE: ${DB_NAME:-mateclaw}
MYSQL_USER: ${DB_USERNAME:-mateclaw}
MYSQL_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required in .env}
TZ: Asia/Shanghai
# No host port on purpose — the DB is only reachable from the compose
# network. For ad-hoc inspection use `docker compose exec postgres psql`,
# or temporarily add: ports: ["127.0.0.1:5432:5432"]
ports:
- "3306:3306"
volumes:
- postgres_data:/var/lib/postgresql/data
# Runs once on first init (empty data dir): creates the restricted app
# role and the mateclaw schema it owns. See docker/postgres/init/.
- ./docker/postgres/init:/docker-entrypoint-initdb.d:ro
- mysql_data:/var/lib/mysql
# Schema and seed data are managed by Flyway on application startup.
# Do NOT mount legacy schema.sql / data.sql here — Flyway creates all
# tables from V1 baseline and applies incremental migrations automatically.
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_ADMIN_USERNAME:-mateclaw_admin} -d ${DB_NAME:-mateclaw}"]
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
networks:
- mateclaw-net
# SearXNG 搜索引擎keyless 搜索 provider
#
@ -59,8 +37,6 @@ services:
# 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
@ -71,14 +47,14 @@ services:
- SEARXNG_SECRET=${SEARXNG_SECRET:-mateclaw-dev-searxng-secret-change-me}
- UWSGI_WORKERS=2
- UWSGI_THREADS=4
ports:
- "8088:8080"
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-server:
@ -90,14 +66,14 @@ services:
container_name: mateclaw-server
restart: unless-stopped
depends_on:
postgres:
mysql:
condition: service_healthy
searxng:
condition: service_healthy
environment:
SPRING_PROFILES_ACTIVE: postgres
DB_HOST: postgres
DB_PORT: 5432
SPRING_PROFILES_ACTIVE: mysql
DB_HOST: mysql
DB_PORT: 3306
DB_NAME: ${DB_NAME:-mateclaw}
DB_USERNAME: ${DB_USERNAME:-mateclaw}
DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required in .env}
@ -106,7 +82,6 @@ services:
# Settings → Models → Add Provider
# Keys are stored in mate_model_provider and hot-reloaded.
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
@ -117,59 +92,10 @@ services:
MATECLAW_BROWSER_CDP_URL: ${MATECLAW_BROWSER_CDP_URL:-}
MATECLAW_BROWSER_CHROME_PATH: ${MATECLAW_BROWSER_CHROME_PATH:-}
MATECLAW_BROWSER_CHANNEL: ${MATECLAW_BROWSER_CHANNEL:-}
# SSRF / TLS relaxations for isolated LAN / on-prem deployments.
# Both default to false (strict mode, public-internet safe).
# The .env file uses the PLAYWRIGHT_* prefix (component-oriented naming,
# not product-oriented) — here we translate to the MATECLAW_BROWSER_*
# container env that Spring Boot relaxed-binding maps to BrowserProperties.
# - PLAYWRIGHT_ALLOW_PRIVATE_NETWORK=true: allow loopback / private / link-local
# addresses through the browser SSRF guard. Cloud-metadata endpoints stay
# blocked. Turn on when the agent must drive http://192.168.x.x:port style
# internal services and has no path to the public internet.
# - PLAYWRIGHT_IGNORE_HTTPS_ERRORS=true: ignore HTTPS certificate errors.
# Auto-enables --ignore-certificate-errors at the Chromium command line
# when ALLOW_PRIVATE_NETWORK is also true (so CDP-attached external
# browsers benefit too). Leave false on internet-facing deployments.
MATECLAW_BROWSER_ALLOW_PRIVATE_NETWORK: ${PLAYWRIGHT_ALLOW_PRIVATE_NETWORK:-false}
MATECLAW_BROWSER_IGNORE_HTTPS_ERRORS: ${PLAYWRIGHT_IGNORE_HTTPS_ERRORS:-false}
# Playwright action / navigation timeouts (seconds). Increase for slow
# LAN or large-page scenarios. Defaults match Playwright's own (30s).
MATECLAW_BROWSER_DEFAULT_TIMEOUT_SECONDS: ${PLAYWRIGHT_DEFAULT_TIMEOUT_SECONDS:-30}
MATECLAW_BROWSER_DEFAULT_NAVIGATION_TIMEOUT_SECONDS: ${PLAYWRIGHT_NAVIGATION_TIMEOUT_SECONDS:-30}
# Hard cap on the textual snapshot returned by action=snapshot. Content
# beyond this length is dropped with a truncated:true flag and a hint to
# retry with selector. Results > framework spill threshold (~8000 chars)
# are further spilt to disk by ToolResultStorage.
MATECLAW_BROWSER_SNAPSHOT_MAX_LENGTH: ${PLAYWRIGHT_SNAPSHOT_MAX_LENGTH:-20000}
# OAuth 模式默认保持 autolocalhost 访问走 LOCALIP/域名访问走 DEVICE_CODE。
# 本机 Docker 若要强制使用 localhost:1455 回调,可在 .env 显式设为 local。
MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE: ${MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE:-}
MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST: ${MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST:-0.0.0.0}
# Wiki 知识库目录扫描白名单(逗号分隔,留空则禁止所有目录扫描)。
# 示例MATE_WIKI_ALLOWED_SOURCE_ROOTS=/data/wiki,/opt/docs
# 记得同步在 volumes 里把宿主机路径挂进容器。
MATE_WIKI_ALLOWED_SOURCE_ROOTS: ${MATE_WIKI_ALLOWED_SOURCE_ROOTS:-}
# Wiki 知识源自动同步总开关运维总闸默认关。AND 语义:全局开关与
# 每个知识库自己的「自动同步」开关都开,该库才会被定时扫描。
# 间隔单位毫秒,默认 5 分钟。
MATE_WIKI_WATCHER_ENABLED: ${MATE_WIKI_WATCHER_ENABLED:-false}
MATE_WIKI_WATCHER_INTERVAL_MS: ${MATE_WIKI_WATCHER_INTERVAL_MS:-300000}
# Skill 工作区根目录。放在 /app/data 下,让现有的 server_data 卷一并持久化
# 已安装的 skill、运行时积累的 LESSONS.md 以及 skill 运行产物,容器重启不丢。
# 内置 skill 仍由 JAR classpath 每次启动现场释放,空卷不会丢内置文件。
MATECLAW_SKILL_WORKSPACE_ROOT: ${MATECLAW_SKILL_WORKSPACE_ROOT:-/app/data/skills}
# Skill ZIP 上传/安装大小上限MB。解包过程整包缓存在内存里
# max-total 调多大,单次安装的峰值内存就可能吃多大。
MATECLAW_SKILL_UPLOAD_MAX_ENTRY_SIZE_MB: ${MATECLAW_SKILL_UPLOAD_MAX_ENTRY_SIZE_MB:-1}
MATECLAW_SKILL_UPLOAD_MAX_TOTAL_SIZE_MB: ${MATECLAW_SKILL_UPLOAD_MAX_TOTAL_SIZE_MB:-50}
# pip 镜像源配置可选。skill 里的 Python 脚本缺包时 pip install 会走这个源。
# 留空则用 PyPI 默认源pypi.org。pip 原生读 PIP_INDEX_URL / PIP_TRUSTED_HOST
# 环境变量容器内所有进程JVM、Python 子进程、bash自动继承无需额外配置。
# HTTP 源会自动从 URL 推导 PIP_TRUSTED_HOST自签 HTTPS 需手动填。
# - 互联网加速PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
# - 局域网私有源PIP_INDEX_URL=http://192.168.1.100:8080/simpletrusted-host 自动推导)
PIP_INDEX_URL: ${PIP_INDEX_URL:-}
PIP_TRUSTED_HOST: ${PIP_TRUSTED_HOST:-}
# Chromium needs a real /dev/shm. Docker defaults to 64MB which causes
# SIGBUS / "Target page closed" errors under load. 2GB is the usual
# recommendation for Playwright / headless chrome.
@ -178,17 +104,8 @@ services:
- "18080:18088" # host:container — app listens on 18088 inside the container
- "1455:1455"
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
networks:
- mateclaw-net
volumes:
postgres_data:
mysql_data:
server_data:
networks:
mateclaw-net:
driver: bridge

View File

@ -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"

View File

@ -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 派生的 pagesource_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;

File diff suppressed because it is too large Load Diff

View File

@ -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; } // 默认排在内置 provider50~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 搜索 APImanifest 声明 `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 可见。

View File

@ -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=

View File

@ -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

View File

@ -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 安装。

View File

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

View File

@ -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

View File

@ -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"
}

View File

@ -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>

View File

@ -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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

View File

@ -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

View File

@ -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 })
}

File diff suppressed because it is too large Load Diff

View File

@ -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
}

View File

@ -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()
}

View File

@ -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()
}

View File

@ -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))
})
}

View File

@ -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)
},
})

View File

@ -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>

View File

@ -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"
]
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

View File

@ -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
```

View File

@ -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),
])
}

View File

@ -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 }

View File

@ -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."

View File

@ -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)"

View File

@ -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."

View File

@ -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'

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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
}

View File

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

View File

@ -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" }]
}

View File

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

File diff suppressed because one or more lines are too long

View File

@ -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,
},
}
})

View File

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

View File

@ -5,7 +5,6 @@ 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;
@ -61,19 +60,6 @@ public interface PluginContext {
*/
void registerMemoryProvider(PluginMemoryProvider provider);
/**
* Register a web-search provider that joins the platform's search provider
* chain used by the {@code web_search} tool.
* <p>
* The provider id must be globally unique registration fails with a
* {@link PluginException} if it clashes with a built-in provider
* (serper / tavily / searxng / duckduckgo) or another plugin's provider.
*
* @param provider the search provider
* @throws PluginException if the id is blank or already taken
*/
void registerSearchProvider(PluginSearchProvider provider);
/**
* Read a configuration value from the plugin's config.
*

View File

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

View File

@ -11,7 +11,7 @@ import java.util.List;
*
* @author MateClaw Team
*/
public interface PluginMemoryProvider extends AutoCloseable {
public interface PluginMemoryProvider {
/**
* Unique provider identifier, e.g. "vector_memory", "graph_memory".
@ -54,24 +54,6 @@ public interface PluginMemoryProvider extends AutoCloseable {
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).
@ -80,28 +62,6 @@ public interface PluginMemoryProvider extends AutoCloseable {
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.
*/
@ -114,9 +74,4 @@ public interface PluginMemoryProvider extends AutoCloseable {
*/
default void onSessionEnd(Long agentId, String conversationId) {
}
/** Release provider-owned resources when the plugin is unloaded. */
@Override
default void close() {
}
}

View File

@ -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);
}

View File

@ -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
) {
}

View File

@ -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
) {
}

View File

@ -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>

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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)";
}
}
}

View File

@ -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();
}
}

View File

@ -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."
}
}
}

View File

@ -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");
}
}

View File

@ -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);
}
}

View File

@ -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(); }
}
}

View File

@ -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();
}
}

View File

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

View File

@ -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>

View File

@ -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;
}
}
}

View File

@ -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"
}
}
}

View File

@ -35,37 +35,28 @@ 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.
# repos first — speeds up builds dramatically 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
COPY pom.xml ./pom.xml
COPY mateclaw-plugin-api/pom.xml mateclaw-plugin-api/pom.xml
COPY mateclaw-server/pom.xml mateclaw-server/pom.xml
COPY mateclaw-plugin-sample/pom.xml mateclaw-plugin-sample/pom.xml
COPY mateclaw-plugin-search-sample/pom.xml mateclaw-plugin-search-sample/pom.xml
COPY mateclaw-plugin-mem0/pom.xml mateclaw-plugin-mem0/pom.xml
# Build and install plugin-api into the local Maven cache first
WORKDIR /plugin-api
COPY mateclaw-plugin-api/pom.xml ./pom.xml
COPY mateclaw-plugin-api/src ./src
RUN mvn install -Dmaven.test.skip=true -q ${MAVEN_FLAGS}
# Pre-fetch backend dependencies through the reactor so the parent POM,
# dependencyManagement, and internal module versions all resolve consistently.
RUN mvn -pl mateclaw-server -am dependency:go-offline -q ${MAVEN_FLAGS}
# Pre-fetch mateclaw-server dependencies (uses mirror, so this won't hang)
WORKDIR /build
COPY mateclaw-server/pom.xml .
RUN mvn 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
COPY mateclaw-server/src ./src
COPY --from=frontend-builder /static ./src/main/resources/static
RUN mvn -pl mateclaw-server -am package -Dmaven.test.skip=true -q ${MAVEN_FLAGS}
RUN mvn package -Dmaven.test.skip=true -q ${MAVEN_FLAGS}
# Stage 3 — Runtime
#
@ -74,11 +65,11 @@ RUN mvn -pl mateclaw-server -am package -Dmaven.test.skip=true -q ${MAVEN_FLAGS}
# 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
# We pin to the exact Playwright version declared in pom.xml (1.52.0). 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
FROM mcr.microsoft.com/playwright:v1.52.0-noble
WORKDIR /app
# JDK 21 is NOT part of the base image (it ships Node for the JS driver).
@ -107,36 +98,15 @@ RUN apt-get update \
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
JAVA_TOOL_OPTIONS="-Duser.timezone=Asia/Shanghai"
COPY --from=builder /build/target/*.jar app.jar
EXPOSE 18088
EXPOSE 1455
ENTRYPOINT ["java", "-jar", "app.jar"]
ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=mysql", "app.jar"]

View File

@ -4,33 +4,70 @@
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-server</artifactId>
<version>1.3.0</version>
<packaging>jar</packaging>
<name>MateClaw Server</name>
<description>MateClaw - Java+Vue Personal AI Assistant powered by Spring AI Alibaba</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.14</version>
<relativePath/>
</parent>
<properties>
<java.version>21</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- Spring AI 1.1.6 正式版patch upgrade from 1.1.5 -->
<spring-ai.version>1.1.6</spring-ai.version>
<!-- Spring AI Alibaba 1.1.2.3(对应 Spring AI 1.1.x -->
<spring-ai-alibaba.version>1.1.2.3</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>
<!-- ===== MateClaw Plugin API ===== -->
<dependency>
<groupId>vip.mate</groupId>
<artifactId>mateclaw-plugin-api</artifactId>
<version>1.1.0-SNAPSHOT</version>
</dependency>
<!-- ===== Web MVC, excluding WebFlux to keep servlet mode ===== -->
<!-- ===== Web MVC(不引入 WebFlux避免自动切换为响应式模式 ===== -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- ===== Actuator - exposes Spring AI observation metrics (gen_ai.*) ===== -->
<!-- ===== Actuator exposes Spring AI observation metrics (gen_ai.*) ===== -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
@ -38,13 +75,14 @@
<!-- ===== Spring AI Alibaba DashScope ===== -->
<!--
Version is managed centrally because this artifact is outside the Spring AI BOM.
Provides DashScope ChatModel, EmbeddingModel, and ImageModel support.
1.1.2.2 需单独指定版本,不在 BOM 中
内置 DashScope ChatModel / EmbeddingModel / ImageModel
-->
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<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>
<exclusion>
<groupId>org.springframework.boot</groupId>
@ -53,10 +91,11 @@
</exclusions>
</dependency>
<!-- ===== Spring AI Alibaba Graph Core (StateGraph workflow engine) ===== -->
<!-- ===== Spring AI Alibaba Graph CoreStateGraph 工作流引擎) ===== -->
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-graph-core</artifactId>
<version>${spring-ai-alibaba.version}</version>
</dependency>
<!-- ===== Spring AI OpenAI Compatible ===== -->
@ -65,15 +104,16 @@
<artifactId>spring-ai-openai</artifactId>
</dependency>
<!-- ===== Spring AI Anthropic (Claude model support) ===== -->
<!-- ===== Spring AI AnthropicClaude 模型支持) ===== -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-anthropic</artifactId>
</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>
<groupId>org.springframework.ai</groupId>
@ -86,29 +126,31 @@
</exclusions>
</dependency>
<!-- ===== H2 embedded database (development) ===== -->
<!-- ===== H2 内嵌数据库(开发环境) ===== -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- ===== MySQL driver (production) ===== -->
<!-- ===== MySQL 驱动(生产环境) ===== -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- ===== MyBatis Plus, without JPA to avoid dual ORM conflicts ===== -->
<!-- ===== MyBatis Plus(不引入 JPA避免双 ORM 冲突) ===== -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- MyBatis Plus pagination support is split into a separate module. -->
<!-- MyBatis Plus 分页插件3.5.16 拆分为独立模块) -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- ===== Spring Security ===== -->
@ -121,49 +163,55 @@
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<!-- ===== SpringDoc OpenAPI (Swagger UI for Spring MVC) ===== -->
<!-- ===== SpringDoc OpenAPISwagger UI for Spring MVC ===== -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
</dependency>
<!-- ===== Hutool utilities ===== -->
<!-- ===== Hutool 工具库 ===== -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>${hutool.version}</version>
</dependency>
<!-- ===== DingTalk Stream SDK (WebSocket long connection, no public IP required) ===== -->
<!-- ===== 钉钉 Stream SDKWebSocket 长连接,无需公网 IP ===== -->
<dependency>
<groupId>com.dingtalk.open</groupId>
<artifactId>dingtalk-stream</artifactId>
<version>1.3.12</version>
</dependency>
<!-- ===== Lark Open API SDK (WebSocket long connection and event dispatch) ===== -->
<!-- ===== 飞书 / Lark Open API SDKWebSocket 长连接 + 事件分发) ===== -->
<dependency>
<groupId>com.larksuite.oapi</groupId>
<artifactId>oapi-sdk</artifactId>
<version>2.6.1</version>
</dependency>
<!-- ===== Caffeine cache for skill runtime caching ===== -->
<!-- ===== Caffeine Cache用于 skill runtime 缓存) ===== -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<!-- ===== SnakeYAML for SKILL.md frontmatter parsing ===== -->
<!-- ===== SnakeYAML(用于 SKILL.md frontmatter 解析) ===== -->
<dependency>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
@ -180,24 +228,28 @@
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
<version>3.5.3</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId>
<version>3.5.3</version>
</dependency>
<!-- ===== Playwright (Browser Automation) ===== -->
<dependency>
<groupId>com.microsoft.playwright</groupId>
<artifactId>playwright</artifactId>
<version>1.52.0</version>
</dependency>
<!-- ===== JDA (Discord Bot Gateway WebSocket long connection) ===== -->
<!-- ===== JDADiscord Bot Gateway WebSocket 长连接) ===== -->
<dependency>
<groupId>net.dv8tion</groupId>
<artifactId>JDA</artifactId>
<version>5.2.3</version>
<exclusions>
<!-- Exclude audio dependencies because voice features are not used. -->
<!-- 排除 audio 相关依赖MateClaw 不需要语音功能) -->
<exclusion>
<groupId>club.minnced</groupId>
<artifactId>opus-java</artifactId>
@ -205,24 +257,27 @@
</exclusions>
</dependency>
<!-- ===== Spring WebSocket (Talk Mode) ===== -->
<!-- ===== Spring WebSocketTalk Mode ===== -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- ===== Slack SDK (Socket Mode and Web API) ===== -->
<!-- ===== Slack SDKSocket Mode + Web API ===== -->
<dependency>
<groupId>com.slack.api</groupId>
<artifactId>slack-api-client</artifactId>
<version>1.44.2</version>
</dependency>
<dependency>
<groupId>com.slack.api</groupId>
<artifactId>bolt-socket-mode</artifactId>
<version>1.44.2</version>
</dependency>
<dependency>
<groupId>org.glassfish.tyrus.bundles</groupId>
<artifactId>tyrus-standalone-client</artifactId>
<version>2.2.0</version>
</dependency>
<!-- ===== Apache POI (in-process .docx generation) ===== -->
@ -233,6 +288,7 @@
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.4.1</version>
</dependency>
<!-- ===== Apache Batik (SVG rasterization for docx image embedding) ===== -->
@ -246,67 +302,60 @@
<dependency>
<groupId>org.apache.xmlgraphics</groupId>
<artifactId>batik-transcoder</artifactId>
<version>1.18</version>
</dependency>
<dependency>
<groupId>org.apache.xmlgraphics</groupId>
<artifactId>batik-codec</artifactId>
<version>1.18</version>
</dependency>
<!-- ===== jsoup (HTML cleanup for Wiki ingest) ===== -->
<!-- ===== jsoup (HTML cleanup for Wiki ingest, RFC-051 PR-1c) ===== -->
<!--
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.
(~430KB), no transitive deps, JVM-only safe for the desktop bundle.
-->
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.18.3</version>
</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) ===== -->
<!-- ===== Apache Tika (RFC-051 PR-?: Java-side last-resort 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,
Pinned to the precise format modules called out in RFC-051 §5.2 — we
deliberately avoid `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.
tika-parser-microsoft-module (POI-scratchpad ~10MB) ≈ 16MB.
-->
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-core</artifactId>
<version>3.0.0</version>
</dependency>
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-parser-pdf-module</artifactId>
<version>3.0.0</version>
</dependency>
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-parser-microsoft-module</artifactId>
<version>3.0.0</version>
</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
Flying Saucer 9.13 ships a single `flying-saucer-pdf` artifact that
writes PDF via OpenPDF (LGPL fork of iText 5). It does NOT depend on
PDFBox, so it sidesteps a version conflict with the existing
pdfbox dependency. CSS3 paged-media features (@page,
pdfbox:3.0.3 dependency. CSS3 paged-media features (@page,
counter(page), counter(pages), @top-center / @bottom-center) are
supported, which the cover / header / footer rendering relies on.
@ -319,26 +368,32 @@
<dependency>
<groupId>org.xhtmlrenderer</groupId>
<artifactId>flying-saucer-pdf</artifactId>
<version>9.13.0</version>
</dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark</artifactId>
<version>0.28.0</version>
</dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark-ext-gfm-tables</artifactId>
<version>0.28.0</version>
</dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark-ext-yaml-front-matter</artifactId>
<version>0.28.0</version>
</dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark-ext-gfm-strikethrough</artifactId>
<version>0.28.0</version>
</dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark-ext-autolink</artifactId>
<version>0.28.0</version>
</dependency>
<!-- ===== Database Migration (Flyway) ===== -->
@ -350,30 +405,6 @@
<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 ===== -->
<dependency>
@ -382,30 +413,33 @@
<scope>test</scope>
</dependency>
<!-- ===== ArchUnit architecture invariants =====
test-scope only, guards:
<!-- ===== ArchUnit (RFC-063r §2.3 / §5.2 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
- CronJobRunner (introduced in PR-3) must not carry @Transactional
(would silently fail under self-invocation; see RFC §5.2)
-->
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>1.3.0</version>
<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
JDBC mode reuses the existing DataSource no Redis dependency
on the desktop / single-node footprint. -->
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-spring</artifactId>
<version>5.16.0</version>
</dependency>
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-jdbc-template</artifactId>
<version>5.16.0</version>
</dependency>
<!-- Graph algorithms (community detection, shortest path, centrality)
@ -413,6 +447,7 @@
<dependency>
<groupId>org.jgrapht</groupId>
<artifactId>jgrapht-core</artifactId>
<version>1.5.2</version>
</dependency>
<!-- PDF parsing for inline image extraction (wiki vision-in pipeline).
@ -421,6 +456,7 @@
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>3.0.3</version>
</dependency>
<!-- Expression language used by the workflow compiler to evaluate
@ -431,6 +467,7 @@
<dependency>
<groupId>io.pebbletemplates</groupId>
<artifactId>pebble</artifactId>
<version>3.2.2</version>
</dependency>
</dependencies>
@ -439,13 +476,6 @@
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
<configuration>
<excludes>
<exclude>
@ -485,7 +515,115 @@
</plugins>
</build>
<!--
Dependency repositories, with US + CN mirrors listed side by side so builds
are reasonable on either continent. Maven tries repositories in the order
they are declared — the first one that resolves an artifact wins.
IDs are deliberately distinct from the super-POM's `central` id so that
mirror rules in settings.xml (if any) don't silently redirect them. Keep
the fastest-by-default first; switch order via a local ~/.m2/settings.xml
or pass `-Paliyun-first` when building from inside China.
-->
<repositories>
<!-- Primary: Maven Central direct — fast from US/EU backbones. -->
<repository>
<id>maven-central</id>
<name>Maven Central</name>
<url>https://repo.maven.apache.org/maven2</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</repository>
<!-- Fallback 1: Google Cloud's Maven Central mirror (global CDN edge). -->
<repository>
<id>google-maven-central</id>
<name>Google Maven Central Mirror</name>
<url>https://maven-central.storage-download.googleapis.com/maven2</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</repository>
<!-- Fallback 2: Aliyun public — fast from China, full Central mirror. -->
<repository>
<id>aliyun-public</id>
<name>Aliyun Public</name>
<url>https://maven.aliyun.com/repository/public</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</repository>
<!-- Spring milestones / snapshots — direct from Spring (US). -->
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</repository>
<!-- Aliyun Spring mirror — fallback for CN builds. -->
<repository>
<id>aliyun-spring</id>
<name>Aliyun Spring Mirror</name>
<url>https://maven.aliyun.com/repository/spring</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</repository>
</repositories>
<!-- Plugin lookups follow the same multi-region fallback. -->
<pluginRepositories>
<pluginRepository>
<id>maven-central</id>
<name>Maven Central</name>
<url>https://repo.maven.apache.org/maven2</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</pluginRepository>
<pluginRepository>
<id>google-maven-central</id>
<name>Google Maven Central Mirror</name>
<url>https://maven-central.storage-download.googleapis.com/maven2</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</pluginRepository>
<pluginRepository>
<id>aliyun-public</id>
<name>Aliyun Public</name>
<url>https://maven.aliyun.com/repository/public</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</pluginRepository>
</pluginRepositories>
<!--
Profile: swap the primary repo order when building from China so Aliyun
is tried first. Activate with `mvn -Paliyun-first ...`.
-->
<profiles>
<profile>
<id>aliyun-first</id>
<repositories>
<repository>
<id>aliyun-public-first</id>
<url>https://maven.aliyun.com/repository/public</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</repository>
<repository>
<id>aliyun-spring-first</id>
<url>https://maven.aliyun.com/repository/spring</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>aliyun-public-first</id>
<url>https://maven.aliyun.com/repository/public</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</pluginRepository>
</pluginRepositories>
</profile>
<!--
Profile: focused test run for image / video generation features.
Activate with `mvn test -P media-gen` (or `mvn verify -P media-gen`).
@ -507,24 +645,5 @@
</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>

View File

@ -1,29 +1,19 @@
package vip.mate;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
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.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.annotation.Bean;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.EnableScheduling;
import javax.sql.DataSource;
import java.sql.Connection;
/**
* MateClaw - Personal AI Assistant
* Powered by Spring AI Alibaba
*
* @author MateClaw Team
*/
@Slf4j
@SpringBootApplication(exclude = {
// Disable Spring AI MCP Client auto-configuration (lifecycle owned by McpClientManager).
org.springframework.ai.mcp.client.common.autoconfigure.McpClientAutoConfiguration.class,
@ -34,7 +24,7 @@ import java.sql.Connection;
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
// built by AgentDashScopeChatModelBuilder. 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,
@ -43,116 +33,22 @@ import java.sql.Connection;
@MapperScan("vip.mate.**.repository")
public class MateClawApplication {
@Autowired
private DataSource dataSource;
/** Cached DbType for the PaginationInnerInterceptor. */
private volatile DbType resolvedDbType;
public static void main(String[] args) {
configureHttpClientDefaults();
SpringApplication.run(MateClawApplication.class, args);
}
/**
* Harden the JDK {@link java.net.http.HttpClient} defaults before any client
* (or the JDK's internal header-allowlist) is initialized.
*
* <ul>
* <li><b>keep-alive timeout</b> the JDK default is 1200s, far longer than a
* typical reverse proxy / API gateway idle window (often 1575s). A pooled
* HTTP/1.1 connection therefore outlives the peer's socket, and the next
* request onto that now-closed socket is reset by the peer before any
* response byte arrives, surfacing as
* {@code "HTTP/1.1 header parser received no bytes"} / {@code Connection reset}.
* Capping it to 15s makes the client evict idle connections before most
* gateways do, eliminating stale reuse. (curl never hits this because it
* opens a fresh connection per invocation.)</li>
* <li><b>allow the {@code Connection} request header</b> {@code Connection}
* is a restricted header the JDK client strips by default; allowing it lets
* the OpenAI-compatible path send {@code Connection: close} to force a fresh
* connection per request against flaky self-hosted gateways.</li>
* </ul>
*
* <p>Both are only set when the operator has not already provided an explicit
* {@code -D} override, so deliberate tuning is respected.
*/
private static void configureHttpClientDefaults() {
if (System.getProperty("jdk.httpclient.keepalive.timeout") == null) {
System.setProperty("jdk.httpclient.keepalive.timeout", "15");
}
String allowRestricted = System.getProperty("jdk.httpclient.allowRestrictedHeaders");
if (allowRestricted == null) {
System.setProperty("jdk.httpclient.allowRestrictedHeaders", "connection");
} else if (!allowRestricted.toLowerCase().contains("connection")) {
System.setProperty("jdk.httpclient.allowRestrictedHeaders", allowRestricted + ",connection");
}
}
/**
* Detect the actual database type from the live DataSource so the
* {@link PaginationInnerInterceptor} always uses the correct dialect,
* even when the JDBC URL is wrapped by a proxy (HikariCP, P6Spy, etc.).
*
* <p>DbType is cached after the first successful detection; a failure
* falls back to the value set in {@code mybatis-plus.global-config.db-config.db-type},
* or eventually to {@link DbType#MYSQL} but by then the connection
* pool would already have failed.
*/
@PostConstruct
void detectDbType() {
try (Connection conn = dataSource.getConnection()) {
String productName = conn.getMetaData().getDatabaseProductName().toLowerCase();
if (productName.contains("kingbase")) {
resolvedDbType = DbType.KINGBASE_ES;
} else if (productName.contains("postgresql")) {
resolvedDbType = DbType.POSTGRE_SQL;
} else if (productName.contains("mysql") || productName.contains("mariadb")) {
resolvedDbType = DbType.MYSQL;
} else if (productName.contains("h2")) {
resolvedDbType = DbType.H2;
} else {
// Let the PaginationInnerInterceptor auto-detect at query time
resolvedDbType = null;
}
if (resolvedDbType != null) {
log.info("Detected database type: {} (product={})", resolvedDbType, productName);
}
} catch (Exception e) {
log.warn("Could not detect database type — PaginationInnerInterceptor will auto-detect on first query: {}",
e.getMessage());
}
}
/**
* MyBatis Plus pagination plugin.
*
* <p>When {@code resolvedDbType} is available the interceptor uses it directly;
* otherwise it falls back to JDBC-URL auto-detection, which works for
* {@code jdbc:kingbase8://} but not for proxied DataSources (RFC-042 P0).
* <p>DbType is auto-detected from the JDBC connection at runtime rather
* than hardcoded. Hardcoding H2 here meant the MySQL deployment used
* the H2 dialect for the count query, which silently returned 0
* frontends saw records but total=0 and couldn't paginate (RFC-042 P0).
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
PaginationInnerInterceptor pagination = resolvedDbType != null
? new PaginationInnerInterceptor(resolvedDbType)
: new PaginationInnerInterceptor();
interceptor.addInnerInterceptor(pagination);
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
return interceptor;
}
/**
* Print a clear "READY" banner after all post-startup initialization,
* so operators can tell at a glance when the application is ready to serve.
*/
@EventListener(ApplicationReadyEvent.class)
public void onReady() {
log.info("");
log.info("╔══════════════════════════════════════════════════════════════════════╗");
log.info("║ MateClaw is READY ✓ ║");
log.info("║ Web UI → http://localhost:18088 ║");
log.info("║ Swagger → http://localhost:18088/swagger-ui.html ║");
log.info("╚══════════════════════════════════════════════════════════════════════╝");
log.info("");
}
}

View File

@ -11,7 +11,6 @@ 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.
@ -30,28 +29,24 @@ public class AcpEndpointController {
@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));
@ -59,7 +54,6 @@ public class AcpEndpointController {
@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();
@ -67,7 +61,6 @@ public class AcpEndpointController {
@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));
@ -79,7 +72,6 @@ public class AcpEndpointController {
*/
@Operation(summary = "Test ACP endpoint connection (initialize handshake)")
@PostMapping("/{id}/test")
@RequireWorkspaceRole("admin")
public R<Map<String, Object>> test(@PathVariable Long id) {
AcpEndpointEntity endpoint = service.get(id);
return R.ok(tester.testEndpoint(endpoint));

View File

@ -60,9 +60,6 @@ public class AcpEndpointEntity {
/** 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;

View File

@ -11,6 +11,7 @@ import vip.mate.acp.model.AcpEndpointEntity;
import vip.mate.exception.MateClawException;
import java.io.IOException;
import java.time.Duration;
import java.util.List;
import java.util.Map;
@ -46,6 +47,11 @@ import java.util.Map;
@RequiredArgsConstructor
public class AcpDelegationService {
/** Hard ceiling on a single ACP delegation. Long enough for a
* multi-turn coding session, short enough that a hung agent can't
* permanently block an LLM tool call. */
private static final Duration PROMPT_TIMEOUT = Duration.ofMinutes(5);
private static final long INITIALIZE_TIMEOUT_MS = 15_000L;
private static final long SESSION_NEW_TIMEOUT_MS = 10_000L;
@ -83,7 +89,6 @@ public class AcpDelegationService {
List<String> args = endpointService.parseArgs(endpoint);
Map<String, String> env = endpointService.parseEnv(endpoint);
boolean trusted = !Boolean.FALSE.equals(endpoint.getTrusted());
long promptTimeoutMillis = resolvePromptTimeoutMillis(endpoint);
// Always resolve cwd to a real directory: Zed's ACP Zod schema
// marks cwd as a required string and rejects {@code undefined}
// with -32602. See {@link AcpRuntimeSupport#resolveCwd}.
@ -119,7 +124,7 @@ public class AcpDelegationService {
ObjectNode promptParams = objectMapper.createObjectNode();
promptParams.put("sessionId", sessionId);
promptParams.set("prompt", buildPromptArray(userPrompt));
autoClose.sendRequest("session/prompt", promptParams, promptTimeoutMillis);
autoClose.sendRequest("session/prompt", promptParams, PROMPT_TIMEOUT.toMillis());
} catch (IOException | InterruptedException e) {
if (e instanceof InterruptedException) Thread.currentThread().interrupt();
log.warn("ACP delegation failed for endpoint '{}': {}", endpointName, e.getMessage());
@ -139,12 +144,6 @@ public class AcpDelegationService {
return accumulator.toString().trim();
}
static long resolvePromptTimeoutMillis(AcpEndpointEntity endpoint) {
int seconds = AcpEndpointService.normalizePromptTimeoutSeconds(
endpoint != null ? endpoint.getPromptTimeoutSeconds() : null);
return seconds * 1000L;
}
private void wireHandlers(AcpStdioClient client, StringBuilder buf,
boolean trusted, String endpointName) {
// Notifications carry session/update messages; agent_message_chunk

View File

@ -36,9 +36,6 @@ import java.util.Map;
@RequiredArgsConstructor
public class AcpEndpointService {
public static final int DEFAULT_PROMPT_TIMEOUT_SECONDS = 300;
public static final int MAX_PROMPT_TIMEOUT_SECONDS = 3600;
private final AcpEndpointMapper mapper;
private final ObjectMapper objectMapper;
private final ApplicationEventPublisher eventPublisher;
@ -94,7 +91,6 @@ public class AcpEndpointService {
if (input.getStdioBufferLimitBytes() == null || input.getStdioBufferLimitBytes() <= 0) {
input.setStdioBufferLimitBytes(50L * 1024L * 1024L);
}
input.setPromptTimeoutSeconds(normalizePromptTimeoutSeconds(input.getPromptTimeoutSeconds()));
if (input.getWorkspaceId() == null) input.setWorkspaceId(1L);
mapper.insert(input);
log.info("Created ACP endpoint: {}", input.getName());
@ -122,9 +118,6 @@ public class AcpEndpointService {
if (patch.getStdioBufferLimitBytes() != null && patch.getStdioBufferLimitBytes() > 0) {
existing.setStdioBufferLimitBytes(patch.getStdioBufferLimitBytes());
}
if (patch.getPromptTimeoutSeconds() != null) {
existing.setPromptTimeoutSeconds(normalizePromptTimeoutSeconds(patch.getPromptTimeoutSeconds()));
}
mapper.updateById(existing);
publish(existing, AcpEndpointChangedEvent.Type.UPDATED);
return existing;
@ -187,13 +180,6 @@ public class AcpEndpointService {
}
}
public static int normalizePromptTimeoutSeconds(Integer seconds) {
if (seconds == null || seconds <= 0) {
return DEFAULT_PROMPT_TIMEOUT_SECONDS;
}
return Math.min(seconds, MAX_PROMPT_TIMEOUT_SECONDS);
}
private List<String> parseStringList(String json) {
if (json == null || json.isBlank()) return Collections.emptyList();
try {

View File

@ -20,7 +20,6 @@ import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
/**
* RFC-090 §4.5 / §7 unified Activity feed.
@ -75,7 +74,6 @@ public class ActivityFeedController {
*/
@Operation(summary = "Unified activity feed (audit + approval + tool calls)")
@GetMapping("/feed")
@RequireWorkspaceRole("admin")
public R<Map<String, Object>> feed(
@RequestParam(required = false) Long workspaceId,
@RequestParam(required = false) String source,

View File

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

View File

@ -13,23 +13,16 @@ import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.context.ChatOriginHolder;
import vip.mate.agent.event.AgentLifecycleEvent;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.progress.ProgressLedgerService;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.exception.MateClawException;
import vip.mate.llm.chatmodel.ThinkingLevelHolder;
import vip.mate.llm.event.ModelConfigChangedEvent;
import vip.mate.memory.MemoryProperties;
import vip.mate.memory.lifecycle.MemoryLifecycleMediator;
import vip.mate.memory.lifecycle.TurnContext;
import vip.mate.memory.service.MemoryRecallTracker;
import vip.mate.team.event.TeamChangedEvent;
import vip.mate.workspace.conversation.model.ConversationEntity;
import vip.mate.workspace.conversation.repository.ConversationMapper;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.nio.file.Path;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import java.util.function.Supplier;
@ -52,50 +45,14 @@ public class AgentService {
private final MemoryRecallTracker memoryRecallTracker;
private final MemoryLifecycleMediator lifecycleMediator;
private final MemoryProperties memoryProperties;
private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver;
/** Read-only lookup of a conversation's pinned model. Mapper (not service)
* to keep this a leaf dependency with no risk of a bean cycle. */
private final ConversationMapper conversationMapper;
/** Field-injected publisher for agent_lifecycle trigger events; the
* trigger module's bridge listens and forwards into ingest. */
@Autowired(required = false)
private ApplicationEventPublisher events;
/**
* C5: tracks in-flight conversations so {@link vip.mate.agent.runtime.EnvironmentEventRouter}
* can push environment-change notifications into the agent's next reasoning
* turn. Field-injected (optional) so existing test constructors of
* {@code AgentService} don't need to supply it.
*/
@Autowired(required = false)
private vip.mate.agent.runtime.RunningConversationRegistry runningConversationRegistry;
@Autowired
private vip.mate.agent.runtime.ConversationTurnGate turnGate = new vip.mate.agent.runtime.ConversationTurnGate();
/**
* Optional clears leftover auto-recorded ledger entries when a new
* user turn starts. Field-injected so existing test constructors of
* {@code AgentService} don't need to supply it.
*/
@Autowired(required = false)
private ProgressLedgerService progressLedgerService;
/** Runtime SPI coordinator. Native agents remain the default. */
@Autowired(required = false)
private vip.mate.agent.runtime.contract.AgentRuntimeCoordinator runtimeCoordinator;
@Autowired(required = false)
private vip.mate.agent.runtime.dsh.DshRuntimeService dshRuntimeService;
/**
* Runtime Agent instance cache. Keyed first by agentId, then by a model
* key, so a conversation that pins a non-default model gets its own graph
* variant instead of mutating the one every other conversation shares.
* The model key is {@code ""} for the Agent / global-default model.
*/
private final Map<Long, Map<String, BaseAgent>> agentInstances = new ConcurrentHashMap<>();
/** 运行时 Agent 实例缓存agentId -> BaseAgent */
private final Map<Long, BaseAgent> agentInstances = new ConcurrentHashMap<>();
// ==================== CRUD ====================
@ -143,16 +100,6 @@ public class AgentService {
if (agent.getAgentType() == null) {
agent.setAgentType("react");
}
if (!StringUtils.hasText(agent.getRuntimeType())) {
agent.setRuntimeType("native");
} else {
agent.setRuntimeType(agent.getRuntimeType().trim().toLowerCase(Locale.ROOT));
}
if (!"native".equals(agent.getRuntimeType()) && !"dsh".equals(agent.getRuntimeType())) {
throw new MateClawException("err.agent.runtime_unsupported", 400,
"Unsupported runtime provider: " + agent.getRuntimeType());
}
validateDshConfiguration(agent);
requireUniqueName(agent, null);
agentMapper.insert(agent);
publishLifecycle(agent, "spawned");
@ -177,9 +124,6 @@ public class AgentService {
}
requireUniqueName(agent, agent.getId());
}
if ("dsh".equalsIgnoreCase(agent.getRuntimeType())) {
validateDshConfiguration(agent);
}
agentMapper.updateById(agent);
agentInstances.remove(agent.getId());
if (prior != null && prior.getEnabled() != null
@ -259,60 +203,8 @@ public class AgentService {
agentInstances.remove(agentId);
}
/**
* Invalidate the cached agent instance only for shared workspace files that
* are baked into the system prompt. Owner-scoped PERSONAL memory rows are
* injected per turn, so updating them must not force a cold agent rebuild.
*/
@org.springframework.context.event.EventListener
public void onWorkspaceFileChanged(vip.mate.workspace.document.event.WorkspaceFileChangedEvent event) {
if (event.agentId() != null && event.affectsSystemPrompt()) {
agentInstances.remove(event.agentId());
}
}
/**
* Invalidate cached agents whenever their team's composition or settings
* change. The team context block is baked into the system prompt at build
* time, so membership edits would otherwise stay invisible until restart.
*/
@EventListener
public void onTeamChanged(TeamChangedEvent event) {
if (event.agentIds() != null) {
event.agentIds().forEach(agentInstances::remove);
}
}
// ==================== 运行时入口 ====================
/**
* New-user-turn housekeeping: drop auto-recorded ledger entries left
* over from the previous turn. They mark past tool calls as DONE, and
* the ledger snapshot's "已完成的步骤不要重复执行" instruction would
* otherwise stop the agent from re-running status-query tools when the
* user repeats a question that needs fresh data.
*
* <p>Only the fresh-turn entries ({@code chat} / {@code chatStream} /
* {@code chatStructuredStream} / {@code execute}) call this. The
* approval-replay entries ({@code chatWithReplay*}) resume the SAME
* logical turn after a tool approval and must keep the safety net for
* work already done before the pause.
*/
private void clearAutoRecordedForNewTurn(String conversationId) {
// Autonomous segments resume the same objective; retain authoritative tool progress.
if (vip.mate.agent.context.GoalContinuationContext.active()) return;
if (progressLedgerService == null || conversationId == null || conversationId.isBlank()) {
return;
}
try {
progressLedgerService.clearAutoRecorded(conversationId);
} catch (Exception e) {
// Ledger housekeeping must never block the chat itself.
log.warn("Failed to clear auto-recorded ledger entries for {}: {}",
conversationId, e.getMessage());
}
}
public String chat(Long agentId, String message, String conversationId) {
return chat(agentId, message, conversationId, ChatOrigin.EMPTY);
}
@ -323,13 +215,8 @@ public class AgentService {
* down to {@code @Tool} methods via Spring AI {@link org.springframework.ai.chat.model.ToolContext}.
*/
public String chat(Long agentId, String message, String conversationId, ChatOrigin origin) {
clearAutoRecordedForNewTurn(conversationId);
if (isDshAgent(agentId)) {
return collectChatResult(chatStructuredStream(agentId, message, conversationId,
"", null, origin != null ? origin : ChatOrigin.EMPTY)).content();
}
trackMemoryRecalls(agentId, message, origin);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
memoryRecallTracker.trackRecalls(agentId, message);
BaseAgent agent = getOrBuildAgent(agentId);
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
try {
return withLifecycleSync(agentId, message, conversationId,
@ -339,40 +226,13 @@ public class AgentService {
}
}
/**
* Sync chat that also captures token usage and runtime model attribution
* from the agent graph's {@code _usage_final} event. Equivalent to
* subscribing to {@link #chatStructuredStream} and joining all content
* deltas produces the same assistant text as {@link #chat} but exposes
* the usage figures so callers can persist them on the assistant message.
*
* <p>Prefer this entry over {@link #chat} for any path that writes the
* reply to {@code mate_message} (sync HTTP endpoint, voice WebSocket,
* cron task, post-approval replay); the plain {@link #chat} stays as the
* thin wrapper for fire-and-forget invocations where usage is not needed.
*/
public ChatResult chatWithUsage(Long agentId, String message, String conversationId) {
return chatWithUsage(agentId, message, conversationId, ChatOrigin.EMPTY);
}
public ChatResult chatWithUsage(Long agentId, String message, String conversationId, ChatOrigin origin) {
return collectChatResult(chatStructuredStream(agentId, message, conversationId, "", null, origin));
}
public Flux<String> chatStream(Long agentId, String message, String conversationId) {
return chatStream(agentId, message, conversationId, ChatOrigin.EMPTY);
}
public Flux<String> chatStream(Long agentId, String message, String conversationId, ChatOrigin origin) {
clearAutoRecordedForNewTurn(conversationId);
if (isDshAgent(agentId)) {
return chatStructuredStream(agentId, message, conversationId, "", null,
origin != null ? origin : ChatOrigin.EMPTY)
.filter(delta -> delta.content() != null)
.map(StreamDelta::content);
}
trackMemoryRecalls(agentId, message, origin);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
memoryRecallTracker.trackRecalls(agentId, message);
BaseAgent agent = getOrBuildAgent(agentId);
// Capture the origin into a request-scoped holder; cleared on Flux
// termination so the next reactive subscriber doesn't inherit stale state.
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
@ -407,22 +267,8 @@ public class AgentService {
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId,
String requesterId, String thinkingLevel,
ChatOrigin origin) {
clearAutoRecordedForNewTurn(conversationId);
trackMemoryRecalls(agentId, message, origin);
if (isDshAgent(agentId)) {
AgentEntity dshAgent = getAgent(agentId);
return withLifecycleFlux(agentId, message, conversationId,
(msg, convId) -> Flux.using(
() -> runtimeCoordinator.start(dshAgent, convId, convId,
dshAgent.getModelName(), dshWorkingDirectory(dshAgent),
dshWorkingDirectory(dshAgent)),
connection -> vip.mate.agent.runtime.RuntimeEventStreamAdapter.adapt(
connection.prompt(msg)),
connection -> connection.close()),
StreamDelta::content)
.doFinally(signal -> ThinkingLevelHolder.clear());
}
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
memoryRecallTracker.trackRecalls(agentId, message);
BaseAgent agent = getOrBuildAgent(agentId);
// 设置请求级思考深度通过 ThreadLocal 传递到 StateGraph 执行
if (thinkingLevel != null && !thinkingLevel.isBlank()) {
@ -467,9 +313,8 @@ public class AgentService {
}
public String execute(Long agentId, String goal, String conversationId, ChatOrigin origin) {
clearAutoRecordedForNewTurn(conversationId);
trackMemoryRecalls(agentId, goal, origin);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
memoryRecallTracker.trackRecalls(agentId, goal);
BaseAgent agent = getOrBuildAgent(agentId);
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
try {
return withLifecycleSync(agentId, goal, conversationId,
@ -495,8 +340,8 @@ public class AgentService {
public String chatWithReplay(Long agentId, String userMessage, String conversationId,
String toolCallPayload, ChatOrigin origin) {
trackMemoryRecalls(agentId, userMessage, origin);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
memoryRecallTracker.trackRecalls(agentId, userMessage);
BaseAgent agent = getOrBuildAgent(agentId);
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
try {
return withLifecycleSync(agentId, userMessage, conversationId,
@ -506,26 +351,6 @@ public class AgentService {
}
}
/**
* Replay-after-approval that also captures token usage and runtime model
* attribution. Mirrors {@link #chatWithUsage} for the
* approval-resumption path used by {@code ChannelMessageRouter}.
*/
public ChatResult chatWithReplayWithUsage(Long agentId, String userMessage, String conversationId,
String toolCallPayload, ChatOrigin origin) {
return collectChatResult(chatWithReplayStream(agentId, userMessage, conversationId,
toolCallPayload, "", origin != null ? origin : ChatOrigin.EMPTY));
}
/**
* Subscribe to a structured stream and collapse it into a single
* {@link ChatResult}: append all content deltas, capture the trailing
* {@code _usage_final} event for token and model attribution.
*/
private ChatResult collectChatResult(Flux<StreamDelta> stream) {
return ChatResultCollector.collect(stream);
}
/**
* 带工具重放的流式调用Web 端审批通过后使用通过 SSE 推送结果
*/
@ -543,8 +368,8 @@ public class AgentService {
public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId,
String toolCallPayload, String requesterId,
ChatOrigin origin) {
trackMemoryRecalls(agentId, userMessage, origin);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
memoryRecallTracker.trackRecalls(agentId, userMessage);
BaseAgent agent = getOrBuildAgent(agentId);
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
return Flux.defer(() -> {
ChatOriginHolder.set(captured);
@ -557,20 +382,8 @@ public class AgentService {
}
public AgentState getAgentState(Long agentId) {
Map<String, BaseAgent> variants = agentInstances.get(agentId);
if (variants == null || variants.isEmpty()) {
return AgentState.IDLE;
}
// An Agent may have several cached graph variants (one per pinned
// model). Report the first non-IDLE state so a turn running on any
// variant stays visible.
for (BaseAgent agent : variants.values()) {
AgentState state = agent.getState();
if (state != AgentState.IDLE) {
return state;
}
}
return AgentState.IDLE;
BaseAgent agent = agentInstances.get(agentId);
return agent != null ? agent.getState() : AgentState.IDLE;
}
// ==================== 缓存管理 ====================
@ -597,49 +410,6 @@ public class AgentService {
log.info("Agent caches refreshed after tool guard config change (denied tools may have changed)");
}
/**
* Issue #289: an MCP server connecting / disconnecting / reconnecting
* changes the live tool set, but cached agents snapshot their tools at
* build time. Clear the cache so the next turn rebuilds against the
* current MCP tools instead of replying "from memory" with a stale,
* tool-less graph.
*/
@EventListener
public void onMcpServerChanged(vip.mate.tool.mcp.event.McpServerChangedEvent event) {
refreshAllAgents();
log.info("Agent caches refreshed after MCP server change: {}", event.reason());
}
/**
* Listen for MCP connection-loss events and clear the agent cache.
*
* <p>Previously this listener was intentionally omitted (the design
* doc said "only listen to McpServerChangedEvent, not
* McpConnectionLostEvent") because {@link McpServerService} auto-heals
* and publishes McpServerChangedEvent on reconnect. However, between
* disconnect and reconnect, cached agents still hold the old
* {@code AgentToolSet} snapshot whose MCP tool callbacks point at a
* dead client calls either time out (5 min default) or throw.
*
* <p>Clearing the cache on disconnect ensures the next agent build
* sees the live connection state: {@link McpClientManager} will
* either skip the dead server or fall back to {@code lastGoodCallbacks}
* with proper error handling, rather than letting the LLM discover
* the breakage by timing out.
*
* <p>Cost is low: {@code McpServerService} already debounces reconnect
* attempts by 10s, and {@code refreshAllAgents} is a Map.clear().
* The subsequent reconnect will fire another McpServerChangedEvent,
* which clears the cache again at most two clears per disconnect
* cycle, which is acceptable.
*/
@EventListener
public void onMcpConnectionLost(vip.mate.tool.mcp.event.McpConnectionLostEvent event) {
refreshAllAgents();
log.warn("Agent caches refreshed after MCP connection lost: serverId={}, reason={}",
event.serverId(), event.reason());
}
// ==================== Lifecycle helpers ====================
/**
@ -651,29 +421,16 @@ public class AgentService {
*/
private String withLifecycleSync(Long agentId, String message, String conversationId,
java.util.function.BiFunction<String, String, String> invoke) {
try (var permit = acquireTurn(conversationId)) {
return invokeWithLifecycleSync(agentId,message,conversationId,invoke);
}
}
private String invokeWithLifecycleSync(Long agentId, String message, String conversationId,
java.util.function.BiFunction<String, String, String> invoke) {
safeRegister(conversationId, agentId);
try {
if (!memoryProperties.isLifecycleMediatorEnabled()) {
return invoke.apply(message, conversationId);
}
String ownerKey = memoryOwnerResolver.resolve(ChatOriginHolder.get());
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message, ownerKey);
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message);
String memoryContext = lifecycleMediator.beforeLlmCall(ctx);
// Inject memory context into the user message (RFC-037 §3.3)
String enrichedMessage = injectMemoryContext(message, memoryContext);
String result = invoke.apply(enrichedMessage, conversationId);
lifecycleMediator.afterLlmCall(ctx, result != null ? result : "");
return result;
} finally {
safeUnregister(conversationId);
}
}
/**
@ -686,29 +443,10 @@ public class AgentService {
private <T> Flux<T> withLifecycleFlux(Long agentId, String message, String conversationId,
java.util.function.BiFunction<String, String, Flux<T>> invoke,
Function<T, String> contentExtractor) {
return Flux.using(() -> acquireTurn(conversationId),
permit -> invokeWithLifecycleFlux(agentId,message,conversationId,invoke,contentExtractor),
vip.mate.agent.runtime.ConversationTurnGate.Permit::close);
}
private vip.mate.agent.runtime.ConversationTurnGate.Permit acquireTurn(String conversationId) {
var permit = turnGate.tryAcquire(conversationId);
if (permit == null) throw new MateClawException("err.agent.conversation_busy",409,"Conversation is already running");
return permit;
}
private <T> Flux<T> invokeWithLifecycleFlux(Long agentId, String message, String conversationId,
java.util.function.BiFunction<String, String, Flux<T>> invoke,
Function<T, String> contentExtractor) {
boolean goalContinuation = vip.mate.agent.context.GoalContinuationContext.active();
safeRegister(conversationId, agentId);
try {
if (!memoryProperties.isLifecycleMediatorEnabled()) {
return invoke.apply(message, conversationId)
.doFinally(s -> safeUnregister(conversationId, goalContinuation));
return invoke.apply(message, conversationId);
}
String ownerKey = memoryOwnerResolver.resolve(ChatOriginHolder.get());
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message, ownerKey);
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message);
String memoryContext = lifecycleMediator.beforeLlmCall(ctx);
String enrichedMessage = injectMemoryContext(message, memoryContext);
StringBuilder reply = new StringBuilder();
@ -720,67 +458,7 @@ public class AgentService {
}
})
.doOnComplete(() -> lifecycleMediator.afterLlmCall(ctx, reply.toString()))
.doOnError(e -> log.debug("[Memory] Stream error, skipping afterLlmCall: {}", e.getMessage()))
.doFinally(s -> safeUnregister(conversationId, goalContinuation));
} catch (Exception e) {
// If invoke.apply() throws before the Flux is constructed, the
// doFinally above never runs clean up here.
safeUnregister(conversationId, goalContinuation);
throw e;
}
}
/** C5 helper — null-safe register so tests without the registry don't NPE. */
private void safeRegister(String conversationId, Long agentId) {
if (runningConversationRegistry != null) {
runningConversationRegistry.register(conversationId, agentId);
}
}
/** C5 helper — null-safe unregister so tests without the registry don't NPE. */
private void safeUnregister(String conversationId) {
safeUnregister(conversationId, vip.mate.agent.context.GoalContinuationContext.active());
}
private void safeUnregister(String conversationId, boolean goalContinuation) {
if (runningConversationRegistry != null) {
runningConversationRegistry.unregister(conversationId);
}
if (events != null && !goalContinuation) events.publishEvent(new vip.mate.goal.service.GoalExecutionSignal.TurnFinished(conversationId));
}
private boolean isDshAgent(Long agentId) {
if (runtimeCoordinator == null || agentId == null) return false;
AgentEntity entity = getAgent(agentId);
return "dsh".equalsIgnoreCase(entity.getRuntimeType());
}
private void trackMemoryRecalls(Long agentId, String message, ChatOrigin origin) {
String ownerKey = memoryProperties.isLifecycleMediatorEnabled()
? memoryOwnerResolver.resolve(origin != null ? origin : ChatOrigin.EMPTY)
: null;
memoryRecallTracker.trackRecalls(agentId, message, ownerKey);
}
private void validateDshConfiguration(AgentEntity agent) {
if (!"dsh".equalsIgnoreCase(agent.getRuntimeType())) return;
if (dshRuntimeService == null) {
throw new MateClawException("err.agent.runtime_unavailable", 503,
"DSH runtime provider is unavailable");
}
try {
dshRuntimeService.validateAgentConfiguration(agent);
} catch (IllegalArgumentException error) {
throw new MateClawException("err.agent.runtime_invalid", 400, error.getMessage());
}
}
private Path dshWorkingDirectory(AgentEntity agent) {
String configured = System.getenv().getOrDefault("DSH_CWD", System.getProperty("user.dir"));
if (agent.getWorkspaceBasePath() != null && !agent.getWorkspaceBasePath().isBlank()) {
configured = agent.getWorkspaceBasePath().trim();
}
return Path.of(configured).toAbsolutePath().normalize();
.doOnError(e -> log.debug("[Memory] Stream error, skipping afterLlmCall: {}", e.getMessage()));
}
/**
@ -794,104 +472,35 @@ public class AgentService {
// ==================== 内部方法 ====================
/**
* Resolve (and cache) the Agent graph for a conversation, honouring the
* conversation's pinned model. Conversations with no pin IM channels
* before issue #183 fix, cron, sub-tasks, or rows not yet created
* resolve to the shared Agent / global-default graph.
*
* <p>Defensive normalisation: a half-populated pair (provider but no
* model, or vice versa) is treated as unpinned. Without this guard, a
* partially-cleared admin UI write could end up cached as a key like
* {@code "volcano::"} which {@link #getOrBuildAgent} would then try to
* build, only to fail at provider-resolution time on every turn.
*/
private BaseAgent getOrBuildAgentForConversation(Long agentId, String conversationId) {
String provider = null;
String modelName = null;
if (conversationId != null && !conversationId.isBlank()) {
ConversationEntity conv = conversationMapper.selectOne(
new LambdaQueryWrapper<ConversationEntity>()
.eq(ConversationEntity::getConversationId, conversationId));
if (conv != null) {
provider = blankToNull(conv.getModelProvider());
modelName = blankToNull(conv.getModelName());
// Half-populated pair treat as unpinned. Pinning requires
// a complete (provider, model) tuple see #183 follow-up
// hardening so a stale row written by an earlier broken
// admin UI release doesn't loop the cache on an invalid key.
if (provider == null || modelName == null) {
provider = null;
modelName = null;
}
}
}
return getOrBuildAgent(agentId, provider, modelName);
}
/** Map empty / whitespace strings to null so the pinned-check is one branch. */
private static String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s;
}
private BaseAgent getOrBuildAgent(Long agentId) {
return getOrBuildAgent(agentId, null, null);
}
private BaseAgent getOrBuildAgent(Long agentId, String modelProvider, String modelName) {
boolean pinned = modelProvider != null && !modelProvider.isBlank()
&& modelName != null && !modelName.isBlank();
String modelKey = pinned ? modelProvider + "::" + modelName : "";
return agentInstances
.computeIfAbsent(agentId, id -> new ConcurrentHashMap<>())
.computeIfAbsent(modelKey, key -> {
AgentEntity entity = getAgent(agentId);
return agentInstances.computeIfAbsent(agentId, id -> {
AgentEntity entity = getAgent(id);
if (!Boolean.TRUE.equals(entity.getEnabled())) {
throw new MateClawException("err.agent.disabled", "Agent 已禁用: " + entity.getName());
}
return agentGraphBuilder.build(entity, modelProvider, modelName);
return agentGraphBuilder.build(entity);
});
}
// ==================== StreamDelta ====================
public record StreamDelta(String content, String thinking, String eventType, Map<String, Object> eventData,
boolean persistenceOnly, boolean segmentOnly, ContentKind kind) {
boolean persistenceOnly, boolean segmentOnly) {
// 兼容构造器广播+持久化
public StreamDelta(String content, String thinking) {
this(content, thinking, null, null, false, false, null);
this(content, thinking, null, null, false, false);
}
// 显式 5-参构造器保留旧调用点对 (content, thinking, eventType, eventData, persistenceOnly) 的兼容
public StreamDelta(String content, String thinking, String eventType,
Map<String, Object> eventData, boolean persistenceOnly) {
this(content, thinking, eventType, eventData, persistenceOnly, false, null);
}
// 兼容构造器kind 出现之前的 6 canonical 形态
public StreamDelta(String content, String thinking, String eventType,
Map<String, Object> eventData, boolean persistenceOnly, boolean segmentOnly) {
this(content, thinking, eventType, eventData, persistenceOnly, segmentOnly, null);
this(content, thinking, eventType, eventData, persistenceOnly, false);
}
/** 仅用于持久化,不再广播(内容已由 NodeStreamingChatHelper 实时广播过) */
public static StreamDelta persistOnly(String content, String thinking) {
return new StreamDelta(content, thinking, null, null, true, false, null);
}
/** {@link #persistOnly(String, String)} 带内容语义标注的变体。 */
public static StreamDelta persistOnly(String content, String thinking, ContentKind kind) {
return new StreamDelta(content, thinking, null, null, true, false, kind);
}
/**
* Final-answer content of the terminal turn. {@code alreadyStreamed}
* decides broadcast suppression exactly like the persistOnly/plain
* split at the emission sites did before the kind tag existed.
*/
public static StreamDelta finalAnswer(String content, boolean alreadyStreamed) {
return new StreamDelta(content, null, null, null, alreadyStreamed, false, ContentKind.FINAL_ANSWER);
return new StreamDelta(content, thinking, null, null, true, false);
}
/**
@ -915,20 +524,15 @@ public class AgentService {
* persisted content field via this flavor.
*/
public static StreamDelta segmentOnly(String content, String thinking) {
return new StreamDelta(content, thinking, null, null, true, true, null);
}
/** {@link #segmentOnly(String, String)} 带内容语义标注的变体。 */
public static StreamDelta segmentOnly(String content, String thinking, ContentKind kind) {
return new StreamDelta(content, thinking, null, null, true, true, kind);
return new StreamDelta(content, thinking, null, null, true, true);
}
public static StreamDelta empty() {
return new StreamDelta(null, null, null, null, false, false, null);
return new StreamDelta(null, null, null, null, false, false);
}
public static StreamDelta event(String type, Map<String, Object> data) {
return new StreamDelta(null, null, type, data, false, false, null);
return new StreamDelta(null, null, type, data, false, false);
}
public boolean isEvent() {
@ -947,28 +551,4 @@ public class AgentService {
return thinking != null ? thinking.length() : 0;
}
}
// ==================== ChatResult ====================
/**
* Sync chat result carrying the assistant reply alongside the usage
* attribution that the streaming path exposes via the {@code _usage_final}
* event. Use this when callers need to persist {@code promptTokens} /
* {@code completionTokens} / {@code runtimeModel} / {@code runtimeProvider}
* on the assistant message row but cannot subscribe to the structured
* stream directly (cron tasks, sync HTTP endpoints, voice WebSocket,
* post-approval replays).
*/
public record ChatResult(String content, int promptTokens, int completionTokens,
String runtimeModel, String runtimeProvider, String finishReason) {
public ChatResult(String content, int promptTokens, int completionTokens,
String runtimeModel, String runtimeProvider) {
this(content, promptTokens, completionTokens, runtimeModel, runtimeProvider, null);
}
public static ChatResult contentOnly(String content) {
return new ChatResult(content != null ? content : "", 0, 0, null, null, null);
}
}
}

View File

@ -179,14 +179,6 @@ public class AgentToolSet {
return callbackByName;
}
/**
* Every runtime identifier this set can resolve: function names plus any
* Spring bean / Java class aliases captured when the set was built.
*/
public Set<String> allNames() {
return aliasIndex.keySet();
}
/**
* 获取原始的 @Tool Bean 列表
*/
@ -227,22 +219,6 @@ public class AgentToolSet {
return callbacks.size();
}
/**
* Resolve a mix of aliases (function name / Spring bean name / Java class simple name)
* to the {@code @Tool} function names they map to. Used to bridge persistence layers
* that key a tool by its class or bean name (e.g. {@code mate_tool.name}) onto the
* runtime callback name ({@code cb.getToolDefinition().name()}). Unknown aliases yield
* nothing.
*/
public Set<String> functionNamesFor(Set<String> aliases) {
if (aliases == null || aliases.isEmpty()) {
return Set.of();
}
return resolveAliases(aliases).stream()
.map(cb -> cb.getToolDefinition().name())
.collect(Collectors.toCollection(LinkedHashSet::new));
}
// ==================== Internals ====================
/**

View File

@ -1,4 +1,4 @@
package vip.mate.llm.chatmodel;
package vip.mate.agent;
import java.util.List;
import java.util.UUID;
@ -7,8 +7,8 @@ import java.util.concurrent.ConcurrentHashMap;
/**
* Relays per-request assistant {@code reasoning_content} from the producer
* ({@code NodeStreamingChatHelper}, which sees {@code AssistantMessage.metadata})
* to the consumer ({@link OpenAiRequestRewriter#patchReasoningContent}, which
* rebuilds the outbound {@code ChatCompletionRequest}).
* to the consumer ({@code AgentGraphBuilder.patchReasoningContent}, which rebuilds
* the outbound {@code ChatCompletionRequest}).
*
* <p>Why not {@link ThreadLocal}: {@code OpenAiChatModel.stream()} hops to
* {@code boundedElastic} via {@code subscribeOn}, so a {@code ThreadLocal} on the

View File

@ -11,9 +11,6 @@ import org.springframework.ai.content.Media;
import org.springframework.core.io.FileSystemResource;
import org.springframework.util.MimeType;
import reactor.core.publisher.Flux;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.context.ChatOriginHolder;
import vip.mate.agent.context.GoalContinuationContext;
import vip.mate.approval.ApprovalPlaceholderUtil;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.routing.MediaCaptionService;
@ -22,7 +19,6 @@ import vip.mate.llm.routing.model.MultimodalRoutingDecision;
import vip.mate.llm.service.ModelCapabilityService;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.MessageMetadataJson;
import vip.mate.workspace.conversation.model.MessageContentPart;
import vip.mate.workspace.conversation.model.MessageEntity;
@ -61,17 +57,11 @@ public abstract class BaseAgent {
/**
* Max ReAct iterations (one reasoning + action + observation step counts as one).
* Default 150, hard ceiling 150 (enforced in AgentGraphBuilder so per-agent DB
* Default 100, hard ceiling 100 (enforced in AgentGraphBuilder so per-agent DB
* overrides cannot exceed it).
*
* <p>Raised from 100 150 after the round-4 LLM-review smoke test, where a
* 10-model research task with browser_use + per-step verification hit the
* 100-iter cap with only 4/10 models completed. 150 gives roughly 50 %
* headroom for similar multi-step research workflows while still bounding
* a runaway agent.
*/
public static final int MAX_ITERATIONS_HARD_CEILING = 150;
protected int maxIterations = 150;
public static final int MAX_ITERATIONS_HARD_CEILING = 100;
protected int maxIterations = 100;
/** 工作区活动目录(限制文件工具访问范围,为空不限制) */
protected String workspaceBasePath;
@ -126,24 +116,9 @@ public abstract class BaseAgent {
protected MultimodalRouter multimodalRouter;
protected MediaCaptionService mediaCaptionService;
/**
* RFC 48 wired by {@link AgentGraphBuilder#build} so the agent's
* {@code buildInitialState} can inject {@code ACTIVE_GOAL} from the
* conversation's active goal row. Nullable when the goal subsystem
* is off / not wired (legacy tests with minimal builders).
*/
protected vip.mate.goal.service.GoalService goalService;
/** Locale used when prompting the vision sidecar. Defaults to zh-CN when unset. */
protected java.util.Locale userLocale = java.util.Locale.SIMPLIFIED_CHINESE;
/**
* Prefix of the system-role divider row a scheduled-job run writes into
* its conversation immediately before the run's user message. Used to
* (a) drop the divider when replaying history to the LLM and (b) locate
* the current run's start when isolating scheduled-job history.
*/
private static final String CRON_HEADER_PREFIX = "📋 ";
protected BaseAgent(ChatClient chatClient, ConversationService conversationService) {
this.chatClient = chatClient;
@ -246,24 +221,6 @@ public abstract class BaseAgent {
}
protected List<Message> buildConversationHistory(String conversationId, String currentUserMessage) {
// ===== Scheduled-job run isolation (issue #142) =====
// A scheduled-job run is a one-shot task whose full instruction is
// passed explicitly via currentUserMessage. Its conversation the
// shared per-workspace tasks_<wsId> log, or a per-job cron_<id>
// conversation concatenates many independent runs; under concurrent
// runs their rows are not even adjacent (each startRun writes a header
// then a user row in its own transaction, and the inserts interleave).
// No positional reconstruction from that conversation is therefore
// safe. The LLM history is simply empty: the prompt is [system, task].
// The gate is an explicit ChatOrigin signal, so a normal Web or
// channel turn can never take this path.
ChatOrigin chatOrigin = ChatOriginHolder.get();
if (chatOrigin != null && chatOrigin.cronOrigin()) {
log.info("[{}] Scheduled-job run: LLM context isolated (no conversation history replayed)",
agentName);
return List.of();
}
// ===== 两阶段加载短对话全量长对话分页递进式 =====
long totalCount = conversationService.countMessages(conversationId);
if (totalCount <= 0) {
@ -533,7 +490,7 @@ public abstract class BaseAgent {
// and bloat the prompt with scheduler metadata.
if ("system".equals(entity.getRole())
&& entity.getContent() != null
&& entity.getContent().startsWith(CRON_HEADER_PREFIX)) {
&& entity.getContent().startsWith("📋 ")) {
return null;
}
@ -659,7 +616,7 @@ public abstract class BaseAgent {
String role = entity.getRole();
if ("system".equals(role)
&& entity.getContent() != null
&& entity.getContent().startsWith(CRON_HEADER_PREFIX)) return true;
&& entity.getContent().startsWith("📋 ")) return true;
if ("assistant".equals(role)
&& isApprovalPlaceholder(entity.getContent())) return true;
if ("assistant".equals(role)
@ -813,15 +770,8 @@ public abstract class BaseAgent {
if (msg == null) return List.of();
String metadata = msg.getMetadata();
if (metadata == null || metadata.isEmpty()) return List.of();
// Guard on the bare key, not on `"directToolNames"`: the escaped form
// reads \"directToolNames\", where the quotes are no longer adjacent to
// the name, so a quoted guard exits early on every H2-backed row and the
// badge silently disappears. Bare-key matching holds for both forms and
// keeps the common case (no such key) allocation-free; the exact match
// then runs against normalized JSON.
if (!metadata.contains("directToolNames")) return List.of();
java.util.regex.Matcher arrayMatcher =
DIRECT_TOOL_NAMES_ARRAY.matcher(MessageMetadataJson.normalize(metadata));
if (!metadata.contains("\"directToolNames\"")) return List.of();
java.util.regex.Matcher arrayMatcher = DIRECT_TOOL_NAMES_ARRAY.matcher(metadata);
if (!arrayMatcher.find()) return List.of();
String inner = arrayMatcher.group(1);
java.util.regex.Matcher nameMatcher = DIRECT_TOOL_NAMES_INNER.matcher(inner);
@ -896,13 +846,7 @@ public abstract class BaseAgent {
}
return switch (message.getRole()) {
case "assistant" -> new AssistantMessage(renderedContent);
case "system" -> isCompressionSummary(message)
// Compression boundaries are persisted as system rows so
// the loader can find the latest boundary cheaply. They
// are still model-generated history context, not durable
// instructions, so replay them at user priority.
? new UserMessage(renderedContent)
: new SystemMessage(renderedContent);
case "system" -> new SystemMessage(renderedContent);
// History user messages: text only. Re-injecting Media on every replay
// accumulates attachments across turns many providers cap at 1 video
// per request (e.g. Zhipu GLM-5V returns code 1210). The current turn
@ -970,11 +914,6 @@ public abstract class BaseAgent {
if (decision.strategy() == MultimodalRoutingDecision.Strategy.SIDECAR
&& mediaCaptionService != null
&& decision.sidecarModel() != null) {
// The user's actual question (text parts only, excluding media markers)
// so the vision model tailors its description to what was asked rather
// than emitting a generic caption.
String userQuestion = extractUserQuestion(parts);
boolean captionPersisted = false;
for (MessageContentPart part : parts) {
if (part == null) continue;
String contentType = part.getContentType();
@ -983,23 +922,13 @@ public abstract class BaseAgent {
&& !contentType.contains("svg");
if (!isImage) continue;
MediaCaptionService.CaptionResult result = mediaCaptionService.caption(
decision.sidecarModel(), part, userLocale, userQuestion);
decision.sidecarModel(), part, userLocale);
if (result.isFailure()) {
log.warn("[{}] Sidecar caption failed for {}: {}",
agentName, part.getFileName(), result.failure().getMessage());
if (isRemoteOnlyAttachment(part)) {
// The image was never downloaded locally (only a remote
// channel URL survives) for WeCom/aibot that URL points
// at short-lived AES-encrypted bytes, so captioning can
// never succeed until media download is enabled.
textBuilder.append("\n\n[系统提示] 图片 ")
.append(part.getFileName())
.append(" 未下载到本地,无法识别;请在「设置 → 渠道」开启该渠道的媒体下载。");
} else {
textBuilder.append("\n\n[系统提示] 视觉模型未能解析附件 ")
.append(part.getFileName())
.append(",请稍后重试或在「设置 → 模型」检查视觉模型配置。");
}
continue;
}
textBuilder.append("\n\n[图片附件描述: ")
@ -1007,17 +936,9 @@ public abstract class BaseAgent {
.append("]\n")
.append(result.description())
.append("\n[/图片附件描述]");
// Persist the caption onto the part so later turns retain the image
// content: history user messages replay as text only, and without a
// stored caption every follow-up question loses the attachment.
part.setCaption(result.description());
captionPersisted = true;
String identifier = identifyPart(part);
if (identifier != null) sidecarHandledIdentifiers.add(identifier);
}
if (captionPersisted && conversationService != null) {
conversationService.updateMessageParts(message, parts);
}
}
List<Media> mediaList = new ArrayList<>();
@ -1086,7 +1007,7 @@ public abstract class BaseAgent {
if (mediaPath == null) {
log.warn("[{}] {} file not found for attachment: {}, path: {}, mediaId: {}",
agentName, isVideo ? "Video" : "Image", part.getFileName(), part.getPath(), part.getMediaId());
skippedAttachments.add(part.getFileName() + unresolvedAttachmentReason(part));
skippedAttachments.add(part.getFileName() + "(文件未找到)");
continue;
}
try {
@ -1121,52 +1042,6 @@ public abstract class BaseAgent {
return new CurrentTurnUserMessage(built, decision);
}
/**
* Concatenate the text parts of a message into the user's question, dropping
* image/file/media parts. Returns {@code null} when there is no usable text
* (e.g. an image-only IM message), which makes the caption fall back to the
* generic full-description prompt.
*/
private static String extractUserQuestion(List<MessageContentPart> parts) {
if (parts == null || parts.isEmpty()) return null;
StringBuilder sb = new StringBuilder();
for (MessageContentPart part : parts) {
if (part == null || !"text".equals(part.getType())) continue;
String text = part.getText();
if (text == null || text.isBlank()) continue;
if (sb.length() > 0) sb.append('\n');
sb.append(text.trim());
}
String question = sb.toString().trim();
return question.isEmpty() ? null : question;
}
/**
* True when an attachment has no resolvable local file and its only locator
* is a remote http(s) URL i.e. the IM channel never downloaded it locally.
* For WeCom/aibot images that URL points at short-lived AES-encrypted bytes,
* so it is unusable as-is. Lets callers turn a generic "file not found" into
* an actionable hint instead of a dead end.
*/
private static boolean isRemoteOnlyAttachment(MessageContentPart part) {
if (part == null) return false;
if (part.getPath() != null && !part.getPath().isBlank()) return false;
String locator = part.getMediaId();
if (locator == null || locator.isBlank()) locator = part.getFileUrl();
return locator != null && (locator.startsWith("http://") || locator.startsWith("https://"));
}
/**
* Reason string appended to a skipped attachment whose local file could not
* be resolved distinguishes "never downloaded" (channel media download
* off) from a genuine missing-file so the user gets an actionable message.
*/
private static String unresolvedAttachmentReason(MessageContentPart part) {
return isRemoteOnlyAttachment(part)
? "(图片未下载到本地,无法识别;请在「设置 → 渠道」开启该渠道的媒体下载)"
: "(文件未找到)";
}
/**
* Stable identifier for de-duplicating parts already handled by the sidecar
* pass. Falls back across {@code path mediaId fileName} since not every
@ -1233,14 +1108,10 @@ public abstract class BaseAgent {
}
/**
* Resolve the absolute path of an image file.
* 解析图片文件的绝对路径
* <p>
* The storage location of uploaded files is resolved by
* {@code ChatUploadLocationResolver} in priority order: the Agent's
* workspaceBasePath the Workspace's basePath a configurable default
* directory ({@code mateclaw.chat.upload.base-dir}, default
* {@code data/chat-uploads}). An MCP tool's working directory may differ,
* so this resolves directly to an absolute path.
* 上传文件存储在 data/chat-uploads/ 是相对于 Spring Boot 工作目录的路径
* MCP 工具的工作目录可能不同所以这里直接解析为绝对路径
*/
/**
* 构建当前用户消息的 UserMessage multimodal 图片注入
@ -1265,21 +1136,6 @@ public abstract class BaseAgent {
* the primary model can't already handle.
*/
protected CurrentTurnUserMessage buildCurrentUserMessageWithRouting(String conversationId, String userMessageText) {
// Autonomous segments have no new persisted user row. Reconstructing
// from the last user would replace the continuation/recovery instruction.
// History is still loaded normally; queued user turns retain attachment routing.
if (GoalContinuationContext.explicitPrompt()) {
return new CurrentTurnUserMessage(new UserMessage(userMessageText), null);
}
// Scheduled-job run (issue #142): the task text is the explicit
// userMessageText argument. Never reconstruct it from the conversation
// a shared cron conversation under concurrent runs has no reliable
// "last user message" (another run's row may be last). Scheduled jobs
// carry no attachments, so a plain text UserMessage is exact.
ChatOrigin chatOrigin = ChatOriginHolder.get();
if (chatOrigin != null && chatOrigin.cronOrigin()) {
return new CurrentTurnUserMessage(new UserMessage(userMessageText), null);
}
try {
List<MessageEntity> history = conversationService.listMessages(conversationId);
// 倒序取最后一条 user 消息buildInitialState saveMessage 后调用所以最后一条就是当前消息
@ -1288,14 +1144,7 @@ public abstract class BaseAgent {
if ("user".equals(msg.getRole())) {
// DB 中的实际内容可能包含 contentParts不用传入的 text
String content = conversationService.renderMessageContent(msg);
CurrentTurnUserMessage built = buildUserMessageForCurrentTurn(
msg, content != null && !content.isBlank() ? content : userMessageText);
// Vision-capable models replay history as text only, so a
// follow-up question about an earlier image would otherwise be
// answered blind. Re-attach the most recent image to this turn
// so the model actually re-sees it. (Text-only models instead
// rely on the persisted sidecar caption see buildUserMessageInternal.)
return maybeCarryRecentImage(history, i, msg, built);
return buildUserMessageForCurrentTurn(msg, content != null && !content.isBlank() ? content : userMessageText);
}
}
} catch (Exception e) {
@ -1305,85 +1154,6 @@ public abstract class BaseAgent {
return new CurrentTurnUserMessage(new UserMessage(userMessageText), null);
}
/** How far back (in messages) to look for an image to carry into a follow-up turn. */
private static final int CARRY_IMAGE_LOOKBACK = 8;
/**
* For a vision-capable model, re-attach the most recent image from a recent
* earlier turn to the current user message when the current turn carries no
* image of its own. History is replayed as text only (see {@link #toSpringMessage}),
* so without this a follow-up like "what's in the top-left of that photo?" is
* answered blind. Bounded to a single image within {@link #CARRY_IMAGE_LOOKBACK}
* messages so a long conversation doesn't re-send pixels on every turn.
*
* <p>No-op (returns {@code built} unchanged) when: the model can't see images,
* the current turn already has an image, no recent image exists, or the recent
* image has no resolvable local file (e.g. an undownloaded channel URL).
*/
private CurrentTurnUserMessage maybeCarryRecentImage(List<MessageEntity> history, int currentIdx,
MessageEntity currentMsg, CurrentTurnUserMessage built) {
try {
if (built == null || !modelSupportsVision()) return built;
if (messageHasImagePart(currentMsg)) return built; // current turn already carries an image
int from = Math.max(0, currentIdx - CARRY_IMAGE_LOOKBACK);
for (int j = currentIdx - 1; j >= from; j--) {
MessageEntity m = history.get(j);
if (m == null || !"user".equals(m.getRole())) continue;
List<MessageContentPart> parts = conversationService.parseMessageParts(m);
for (int k = parts.size() - 1; k >= 0; k--) {
MessageContentPart part = parts.get(k);
if (!isResolvableImagePart(part)) continue;
Path imgPath = resolveImagePath(part.getPath());
if (imgPath == null && part.getMediaId() != null) imgPath = resolveImagePath(part.getMediaId());
if (imgPath == null) continue;
String contentType = part.getContentType();
if (contentType == null || "image/*".equals(contentType)) contentType = "image/jpeg";
try {
Media carried = new Media(MimeType.valueOf(contentType), new FileSystemResource(imgPath));
UserMessage orig = built.userMessage();
String name = part.getFileName() == null ? "image" : part.getFileName();
String text = (orig.getText() == null ? "" : orig.getText())
+ "\n\n[系统提示] 以下图片是用户本次对话中较早发送的「" + name
+ "」,当前问题很可能与它相关。请直接查看该图片作答,不要凭记忆猜测。";
List<Media> media = new ArrayList<>();
if (orig.getMedia() != null) media.addAll(orig.getMedia());
media.add(carried);
log.debug("[{}] Carried recent image {} into follow-up turn for vision model",
agentName, name);
return new CurrentTurnUserMessage(
UserMessage.builder().text(text).media(media).build(),
built.routingDecision());
} catch (Exception e) {
log.debug("[{}] Failed to carry recent image {}: {}",
agentName, part.getFileName(), e.getMessage());
return built;
}
}
}
} catch (Exception e) {
log.debug("[{}] maybeCarryRecentImage failed: {}", agentName, e.getMessage());
}
return built;
}
private boolean messageHasImagePart(MessageEntity message) {
for (MessageContentPart part : conversationService.parseMessageParts(message)) {
if (isResolvableImagePart(part)) return true;
}
return false;
}
/** An image part (not SVG) — the raster kind a multimodal API can ingest. */
private static boolean isResolvableImagePart(MessageContentPart part) {
if (part == null) return false;
String type = part.getType();
String contentType = part.getContentType();
boolean isImage = ("image".equals(type) || "file".equals(type))
&& contentType != null && contentType.startsWith("image/");
return isImage && !contentType.contains("svg");
}
protected Path resolveImagePath(String relativePath) {
if (relativePath == null || relativePath.isBlank()) {
return null;

View File

@ -1,39 +0,0 @@
package vip.mate.agent;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.Map;
/** Collapses a structured agent stream without discarding terminal metadata. */
final class ChatResultCollector {
private ChatResultCollector() {
}
static AgentService.ChatResult collect(Flux<AgentService.StreamDelta> stream) {
StringBuilder content = new StringBuilder();
final int[] usage = {0, 0};
final String[] modelInfo = {null, null};
final String[] finishReason = {null};
stream.doOnNext(delta -> {
if (delta.isEvent() && "_usage_final".equals(delta.eventType())) {
Map<String, Object> data = delta.eventData() != null ? delta.eventData() : Map.of();
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
Object model = data.get("runtimeModelName");
Object provider = data.get("runtimeProviderId");
if (model != null) modelInfo[0] = model.toString();
if (provider != null) modelInfo[1] = provider.toString();
} else if (delta.isEvent() && "finish_reason".equals(delta.eventType())) {
Map<String, Object> data = delta.eventData();
Object reason = data != null ? data.get("reason") : null;
if (reason != null) finishReason[0] = reason.toString();
} else if (delta.content() != null) {
content.append(delta.content());
}
}).blockLast(Duration.ofMinutes(10));
return new AgentService.ChatResult(content.toString(), usage[0], usage[1],
modelInfo[0], modelInfo[1], finishReason[0]);
}
}

View File

@ -1,42 +0,0 @@
package vip.mate.agent;
import java.util.Locale;
/**
* Semantic category of a content-bearing stream delta, assigned once at the
* producer (the agent graph) where the classification inputs whether the
* completion carried tool calls and whether any tool observation preceded the
* text this turn are definitively known.
*
* <p>Downstream consumers (web segment persistence, IM channel adapters, the
* SSE client) MUST read this tag instead of re-deriving the category from
* stream structure. Deltas from producers that predate this tag carry
* {@code null}; consumers fall back to their legacy structural handling in
* that case.
*/
public enum ContentKind {
/**
* Text emitted in a completion that also carries tool calls, before any
* tool observation this turn. Not grounded in this turn's results it may
* be process narration or a fully fabricated "rehearsal" of the outcome.
* Provisional: replaced by the next content of the same turn if one
* arrives, kept only when the turn produces no later content at all.
*/
PRE_TOOL_NARRATION,
/**
* Intermediate narration emitted after at least one tool observation this
* turn (even when the same completion issues further tool calls). Grounded
* in real results; never replaced.
*/
GROUNDED_NARRATION,
/** Final-answer text of the terminal turn. */
FINAL_ANSWER;
/** Stable lower-case token used in persisted segment metadata and SSE payloads. */
public String wireName() {
return name().toLowerCase(Locale.ROOT);
}
}

View File

@ -366,25 +366,4 @@ public final class GraphEventPublisher {
data.put("timestamp", ts);
return new GraphEvent(EVENT_ITERATION_END, Map.copyOf(data), ts);
}
// ===== Warning events =====
public static final String EVENT_WARNING = "warning";
/**
* A user-visible runtime warning. The stream accumulator folds
* {@code message} into the assistant message's {@code metadata.warnings}
* (persisted) and rebroadcasts the event live on SSE, so the chat UI can
* render a warning chip both during streaming and on history reload.
* {@code source} lets consumers group or filter warnings by origin
* (e.g. {@code "loop_guard"}).
*/
public static GraphEvent warning(String message, String source) {
long ts = System.currentTimeMillis();
return new GraphEvent(EVENT_WARNING, Map.of(
"message", message != null ? message : "",
"source", source != null ? source : "",
"timestamp", ts
), ts);
}
}

View File

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

View File

@ -1,92 +0,0 @@
package vip.mate.agent.binding;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import vip.mate.agent.binding.service.AgentBindingService;
import vip.mate.skill.event.SkillAuthoredEvent;
import java.util.Set;
/**
* Makes a self-authored skill reachable from the catalog of the agent that
* authored it.
*
* <h2>Why this exists</h2>
* An agent's visible skill catalog is filtered by
* {@link AgentBindingService#getBoundSkillIds(Long)}. That method has a
* three-state contract:
*
* <ul>
* <li>{@code null} no binding rows: the agent inherits every globally
* enabled skill, so a newly created skill is visible automatically.</li>
* <li>{@code Set.of()} the agent is explicitly scoped to zero skills
* (opt-out flag, or every binding row disabled).</li>
* <li>non-empty an explicit allowlist; anything not in it is invisible.</li>
* </ul>
*
* Without this listener, an agent in the third state can author a skill,
* persist it, and then never see it again the catalog renderer filters the
* new row straight out. Self-improvement writes into a hole: the skill exists
* in the registry but the agent that learned it cannot reach it on the next
* turn.
*
* <h2>Binding policy</h2>
* Bind only when the agent is already in explicit-allowlist mode
* (non-null, non-empty). The other two states are deliberately left alone:
*
* <ul>
* <li>{@code null} writing a row here would flip the agent from "inherit
* everything" into allowlist mode containing exactly one skill, which
* would silently revoke every other skill it had. Strictly worse than
* doing nothing.</li>
* <li>{@code Set.of()} the operator asked for an agent with no skills.
* Binding would also clear the {@code skills_disabled} flag as a side
* effect of {@link AgentBindingService#bindSkill}, overriding an
* explicit human decision from a background code path.</li>
* </ul>
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AgentSkillAutoBindListener {
private final AgentBindingService agentBindingService;
@EventListener
public void onSkillAuthored(SkillAuthoredEvent event) {
if (event == null || event.agentId() == null || event.skillId() == null) {
return;
}
Set<Long> bound;
try {
bound = agentBindingService.getBoundSkillIds(event.agentId());
} catch (Exception e) {
log.warn("[SkillAutoBind] Could not resolve bindings for agent={}: {}",
event.agentId(), e.getMessage());
return;
}
// null = inherits every enabled skill; empty = explicitly scoped to
// none. Neither state should be rewritten by a background author.
if (bound == null || bound.isEmpty()) {
return;
}
if (bound.contains(event.skillId())) {
return;
}
try {
agentBindingService.bindSkill(event.agentId(), event.skillId());
log.info("[SkillAutoBind] Bound self-authored skill '{}' (id={}) to agent={}",
event.skillName(), event.skillId(), event.agentId());
} catch (Exception e) {
// A cross-workspace skill, a deleted agent, or a concurrent unbind
// all land here. The skill itself is already persisted and remains
// usable through the global catalog, so this stays a warning.
log.warn("[SkillAutoBind] Failed to bind skill '{}' (id={}) to agent={}: {}",
event.skillName(), event.skillId(), event.agentId(), e.getMessage());
}
}
}

View File

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

View File

@ -29,16 +29,6 @@ public class AgentProviderPreference {
/** Provider id (matches {@code mate_model_provider.provider_id}). */
private String providerId;
/**
* Specific chat model to pin for this entry (matches
* {@code mate_model_config.id}). {@code null} means "use the provider's
* default chat model" — backward compatible with provider-only
* preferences. With this column the same {@code providerId} may appear
* in multiple rows, each pinning a different model, forming a per-agent
* preferred-model chain.
*/
private Long modelId;
/** Lower wins. Two rows with the same value tie-break on provider_id alphabetically. */
private Integer sortOrder;

View File

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

View File

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

View File

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

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