release: v1.8.0

This commit is contained in:
mateaix 2026-07-12 17:30:23 +08:00
parent c8bf4e0f89
commit c6f6f10fd0
407 changed files with 88731 additions and 658 deletions

View File

@ -6,18 +6,33 @@
# ⚠️ 所有标注「必填」的项若没配置,`docker compose up` 会直接失败退出,避免把默认/示例值带到生产环境。
# ==================== 数据库Docker 模式必填) ====================
#
# ⚠️ Docker 栈已切换到 PostgreSQL 16此前为 MySQL。老部署升级前请先读
# docker-compose.yml 顶部的迁移说明:旧 mysql_data 卷不会被读取,需要先
# mysqldump 再用 pgloader 等工具导入,或钉在切换前的 tag 上继续用 MySQL。
DB_HOST=localhost
DB_PORT=3306
DB_PORT=5432
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
# ⚠️ MySQL root 账号密码,仅用于容器内初始化。请改成与 DB_PASSWORD 不同的强密码。
DB_ROOT_PASSWORD=change-me-strong-root-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=
# ==================== 安全(强烈建议覆盖) ====================
@ -123,6 +138,27 @@ MATE_WIKI_WATCHER_INTERVAL_MS=300000
# 并记得在 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 的顺序。

5
.gitignore vendored
View File

@ -119,3 +119,8 @@ outputs/
# 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

View File

@ -103,6 +103,9 @@ You hire coworkers, not chat boxes. Each one has a **Role**, a **Goal**, a **Bac
### Multimodal creation
Text-to-speech · Speech-to-text · Image · Music · Video · 3D. First-class, not add-ons. **Sidecar routing** (1.3.0+) means a text-only main model + an image attachment no longer dead-ends — a configured vision model describes the image, and the main model answers. **Image edit** lands too: refer to an earlier conversation attachment by `msg:<id>:<idx>` and ask the model to recolor or restyle it. Four **document-generation tools** (`DocxRenderTool` / `XlsxRenderTool` / `PptxRenderTool` / `PdfRenderTool`) render Markdown straight to Office files inside the JVM — no subprocess, no Office install.
### Content Studio (1.8.0+)
A flagship *scene*, not a tool — a seeded "Content Studio" employee turns one sentence into a publishable post: pick-topic → research → draft → illustrate → **de-AI** → lay out → deliver. **WeChat Official Account (公众号)** articles land in your draft box as inline-style HTML with body images uploaded into WeChat; **Xiaohongshu (小红书)** notes package as ≥3 vertical 3:4 cards with an online preview. De-AI-ification runs against a **measurable AI-trace score**; every delivery is compliance-scanned and logged to a **content calendar** that dedups by topic fingerprint.
### Enterprise-ready
RBAC + JWT. **Personal Access Tokens** for headless scripts and CI. **HMAC-SHA-256 outbound webhook signing**. **Distributed Cron lock** so multi-instance deployments don't double-fire. Full audit trail. Flyway-managed schema that auto-heals on upgrade. One JAR to ship. MySQL in production, H2 for dev — nothing to change in your code.
@ -217,6 +220,19 @@ Full docs at **[claw.mate.vip/docs](https://claw.mate.vip/docs)** — setup, arc
## Roadmap
**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

View File

@ -103,6 +103,9 @@ MateClaw 的 **LLM Wiki** 把它消化成结构化页面,页面之间自己长
### 多模态创作
语音合成 · 语音识别 · 图片 · 音乐 · 视频 · 3D。一等公民不是附加插件。**多模态旁路**1.3.0+)让纯文本主模型遇到图片附件时自动调用配置好的视觉模型转描述,主对话保持便宜。**图像编辑**也到位:用 `msg:<id>:<idx>` 引用会话里更早的某张图,让模型改色、改风格。**4 个文档生成工具**`DocxRenderTool` / `XlsxRenderTool` / `PptxRenderTool` / `PdfRenderTool`)在 JVM 内把 Markdown 直接渲染成 Office 文件——不 fork 子进程、不依赖 npm、不需要装 Office。
### 内容工作室1.8.0+
一个招牌*场景*,不是工具——预置的「内容工作室」员工把一句话变成可发布成品:选题 → 搜集 → 成文 → 配图 → **去 AI 化** → 排版 → 交付。**微信公众号(公众号)** 文章以内联样式 HTML 躺进你的草稿箱,正文图自动上传进微信;**小红书** 笔记打包成 ≥3 张竖版 3:4 卡片并在线预览。去 AI 化对着一个**可度量的 AI 痕迹评分**跑;每次交付都被合规扫描并记进一个按选题指纹去重的**内容日历**。
### 企业就绪
RBAC + JWT。**Personal Access Token** 给无人值守脚本和 CI 用。**Webhook 出站 HMAC-SHA-256 签名**。**Cron 分布式锁**多实例不双发。完整审计事件流。Flyway 管理数据库 schema升级时自愈。一个 JAR 交付。生产用 MySQL开发用 H2代码零改动。
@ -217,6 +220,19 @@ mateclaw/
## 路线图
**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 工作流审批

View File

@ -1,35 +1,57 @@
version: '3.8'
# ============================================================================
# ⚠️ DATABASE ENGINE: PostgreSQL (was MySQL before)
#
# This stack now runs on PostgreSQL 16. Switching the DB engine is a BREAKING
# change for existing deployments: `docker compose up -d` on a host that
# previously ran the MySQL stack starts a FRESH, EMPTY PostgreSQL volume
# (postgres_data). The old `mysql_data` volume is NOT read and the app
# re-seeds default data — existing data is not lost, but it is also NOT
# visible to the new stack.
#
# Treat this as FRESH-INSTALL-ONLY. To carry data across from a MySQL
# deployment, dump the OLD stack BEFORE pulling this change, e.g.:
# docker compose exec mysql mysqldump -u"$DB_USERNAME" -p"$DB_PASSWORD" "$DB_NAME" > mateclaw-mysql.sql
# then load into PostgreSQL with a cross-engine tool such as pgloader —
# there is no automatic MySQL → PostgreSQL migration here. Alternatively,
# keep running MySQL by pinning your checkout to a pre-switch tag and setting
# SPRING_PROFILES_ACTIVE=mysql (the mysql Spring profile remains supported).
# ============================================================================
services:
# MySQL 数据库
# PostgreSQL 数据库
#
# ⚠️ 密码通过环境变量传入,必须从 .env 文件提供。首次部署前:
# 1. cp .env.example .env
# 2. 编辑 .env 把 DB_ROOT_PASSWORD / DB_PASSWORD 改成强密码
# 2. 编辑 .env 把 DB_ADMIN_PASSWORD / DB_PASSWORD 改成强密码
# 未设置会直接在 `docker compose up` 时报错,避免把默认密码带到生产环境。
mysql:
image: mysql:8.0
container_name: mateclaw-mysql
postgres:
image: postgres:16
container_name: mateclaw-postgres
restart: unless-stopped
environment:
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}
# 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}
TZ: Asia/Shanghai
ports:
- "3306:3306"
# 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"]
volumes:
- 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
- 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
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
test: ["CMD-SHELL", "pg_isready -U ${DB_ADMIN_USERNAME:-mateclaw_admin} -d ${DB_NAME:-mateclaw}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- mateclaw-net
# SearXNG 搜索引擎keyless 搜索 provider
#
@ -37,6 +59,8 @@ 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
@ -47,14 +71,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:
@ -66,14 +90,14 @@ services:
container_name: mateclaw-server
restart: unless-stopped
depends_on:
mysql:
postgres:
condition: service_healthy
searxng:
condition: service_healthy
environment:
SPRING_PROFILES_ACTIVE: mysql
DB_HOST: mysql
DB_PORT: 3306
SPRING_PROFILES_ACTIVE: postgres
DB_HOST: postgres
DB_PORT: 5432
DB_NAME: ${DB_NAME:-mateclaw}
DB_USERNAME: ${DB_USERNAME:-mateclaw}
DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required in .env}
@ -82,6 +106,7 @@ 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
@ -133,6 +158,18 @@ services:
# 已安装的 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.
@ -141,11 +178,17 @@ services:
- "18080:18088" # host:container — app listens on 18088 inside the container
- "1455:1455"
volumes:
# server_data covers /app/data — H2 DB, wiki-uploads, AND the skill
# workspace (MATECLAW_SKILL_WORKSPACE_ROOT=/app/data/skills above), so a
# single volume persists everything. No separate skills volume needed.
# server_data 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:
mysql_data:
postgres_data:
server_data:
networks:
mateclaw-net:
driver: bridge

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "mateclaw-desktop",
"version": "1.7.0",
"version": "1.8.0",
"description": "MateClaw Desktop - AI Assistant powered by Spring AI Alibaba",
"author": "MateClaw Team",
"license": "Apache-2.0",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -47,6 +47,7 @@ COPY pom.xml ./pom.xml
COPY mateclaw-plugin-api/pom.xml mateclaw-plugin-api/pom.xml
COPY mateclaw-server/pom.xml mateclaw-server/pom.xml
COPY mateclaw-plugin-sample/pom.xml mateclaw-plugin-sample/pom.xml
COPY mateclaw-plugin-search-sample/pom.xml mateclaw-plugin-search-sample/pom.xml
# Pre-fetch backend dependencies through the reactor so the parent POM,
# dependencyManagement, and internal module versions all resolve consistently.
@ -99,8 +100,19 @@ 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 \
@ -117,6 +129,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
ENV SPRING_PROFILES_ACTIVE=mysql
COPY --from=builder /build/mateclaw-server/target/*.jar app.jar
EXPOSE 18088
EXPOSE 1455
ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@ -263,6 +263,19 @@
<artifactId>jsoup</artifactId>
</dependency>
<!-- ===== WxJava (WeChat Official Account SDK) ===== -->
<!--
Used by GzhPublishTool to push generated 图文 articles into the
Official Account draft box (草稿箱): permanent cover-material upload
plus draft creation, and optional free-publish for verified accounts.
weixin-java-mp is the Java 17 / Spring Boot 3 compatible MP module.
-->
<dependency>
<groupId>com.github.binarywang</groupId>
<artifactId>weixin-java-mp</artifactId>
<version>4.6.0</version>
</dependency>
<!-- ===== Apache Tika (Java-side last-resort document extractor) ===== -->
<!--
Wired as the FINAL fallback in DocumentExtractTool's PDF/DOCX/XLSX/PPTX

View File

@ -50,9 +50,45 @@ public class MateClawApplication {
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,

View File

@ -53,6 +53,7 @@ import vip.mate.skill.service.SkillService;
import vip.mate.system.service.SystemSettingService;
import vip.mate.tool.ToolRegistry;
import vip.mate.tool.disclosure.ToolUsageRecencyTracker;
import vip.mate.tool.mcp.runtime.McpProgressContext;
import vip.mate.memory.spi.MemoryManager;
import vip.mate.workspace.document.WorkspaceFileService;
import vip.mate.tool.guard.service.ToolGuardService;
@ -105,6 +106,7 @@ public class AgentGraphBuilder {
private final ModelContextWindowResolver contextWindowResolver;
private final PrefixBudgetPlanner prefixBudgetPlanner;
private final ToolUsageRecencyTracker toolUsageRecencyTracker;
private final McpProgressContext progressContext;
private final vip.mate.llm.service.ModelCapabilityService modelCapabilityService;
private final ProviderRouter providerRouter;
private final PlanningService planningService;
@ -140,6 +142,8 @@ public class AgentGraphBuilder {
private final vip.mate.goal.service.GoalEvaluationService goalEvaluationService;
private final vip.mate.goal.service.GoalFollowupService goalFollowupService;
private final vip.mate.goal.config.GoalProperties goalProperties;
/** C4: per-conversation environment notification registry, injected into ReasoningNode. */
private final vip.mate.agent.runtime.RunningConversationRegistry runningConversationRegistry;
/**
* Auto-grant resolver wired into the executor so an active
@ -256,8 +260,30 @@ public class AgentGraphBuilder {
public BaseAgent build(AgentEntity entity, String modelProvider, String modelName) {
AgentToolSet toolSet = toolRegistry.getEnabledToolSet();
// 过滤掉 denied 工具使模型完全看不到它们防止 prompt injection 利用 schema
toolSet = toolSet.withDeniedToolsFiltered(toolGuardConfigService.getDeniedTools());
// Move 6 Permission flattening at build time.
//
// Two layers of tool filtering exist in MateClaw:
// (1) Build-time filter (HERE) decides which tools the model SEES
// in the tool list. Computed once per agent build; stable for
// the agent's lifecycle unless bindings change.
// (2) Runtime guard (ToolGuardService.evaluate) decides which
// tools the model can CALL. Runs on every invocation; checks
// workspace boundaries, sensitive paths, credential exposure,
// shell command patterns, and approval workflows. All dynamic
// (depends on tool arguments, not just tool name).
//
// The build-time filter previously ran as 4 separate passes
// (deny allow deny exclude). Move 6 consolidates them into
// a single deny-set + a single allow-set, applied in two passes:
// denied = global denied skill-discovery denied {load_skill if disabled}
// allowed = agent's bound tools (null = global default)
Set<String> deniedTools = new java.util.LinkedHashSet<>(
toolGuardConfigService.getDeniedTools());
deniedTools.addAll(agentBindingService.getSkillDiscoveryDeniedTools(entity.getId()));
if (!loadSkillToolEnabled) {
deniedTools.add("load_skill");
}
toolSet = toolSet.withDeniedToolsFiltered(deniedTools);
// RFC-090 §14.2 single entry point that merges:
// (a) tools expanded from bound skills' active features, and
@ -267,24 +293,6 @@ public class AgentGraphBuilder {
Set<String> boundTools = agentBindingService.getEffectiveToolNames(entity.getId());
toolSet = toolSet.withAllowedToolsOnly(boundTools); // null = 全局默认
// Issue #184 follow-up: an agent that opted out of skills must not be
// able to circle back and discover/load them via the meta tools. Strip
// the skill-discovery surface (listAvailableSkills / load_skill /
// readSkillFile / runSkillScript / listSkillFiles) here. This runs as a
// separate deny layer so the allowlist matrix in getEffectiveToolNames
// stays untouched in particular, the (skillsDisabled, !toolsDisabled,
// no tool bindings) cell still returns null so non-skill global tools
// continue to flow through.
toolSet = toolSet.withDeniedToolsFiltered(
agentBindingService.getSkillDiscoveryDeniedTools(entity.getId()));
// Escape hatch: drop the load_skill meta tool entirely when disabled, so
// it isn't advertised regardless of binding (the catalog guidance falls
// back to readSkillFile see SkillRuntimeService).
if (!loadSkillToolEnabled) {
toolSet = toolSet.excluding(java.util.Set.of("load_skill"));
}
// Resolve the base model with the precedence: per-conversation pin >
// per-Agent model override > global default. resolveRuntimeBaseModel
// looks up enabled-only models and silently degrades an unmatched pin /
@ -626,6 +634,7 @@ public class AgentGraphBuilder {
// the right invocation pattern instead of a dead-end error.
executor.setSkillRuntimeService(skillRuntimeService);
executor.setUsageRecencyTracker(toolUsageRecencyTracker);
executor.setProgressContext(progressContext);
// Optional: route child-agent denied-tool audit events through
// the audit pipeline. Null when audit is not wired (legacy / test).
if (auditEventService != null) {
@ -735,6 +744,12 @@ public class AgentGraphBuilder {
// Tool progressive disclosure extensions enabled this run.
// Registered in BOTH graphs for the same merge-safety reason.
.addStrategy(MateClawStateKeys.ENABLED_EXTENSION_TOOLS, KeyStrategy.REPLACE)
// Tool-call loop guard counters + one-shot post-mutation
// verification reminder flag. Read-merge-write by
// ObservationNode; registered in BOTH graphs so the
// counters survive multi-node merges.
.addStrategy(MateClawStateKeys.TOOL_LOOP_STATS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.MUTATION_REMINDER_INJECTED, KeyStrategy.REPLACE)
.build();
// Graph 拓扑
@ -916,6 +931,7 @@ public class AgentGraphBuilder {
// the right invocation pattern instead of a dead-end error.
executor.setSkillRuntimeService(skillRuntimeService);
executor.setUsageRecencyTracker(toolUsageRecencyTracker);
executor.setProgressContext(progressContext);
// Optional: route child-agent denied-tool audit events through
// the audit pipeline. Null when audit is not wired (legacy / test).
if (auditEventService != null) {
@ -941,7 +957,16 @@ public class AgentGraphBuilder {
skillCatalogRenderer, toolDisclosureService, progressLedgerService);
reasoningNode.setPrefixBudgetPlan(prefixBudgetPlan);
reasoningNode.setAutoDemotedTools(autoDemotedTools);
// C4: wire the environment-notification registry so ReasoningNode
// can drain pending MCP/skill events and inject them as a SystemMessage.
reasoningNode.setRunningConversationRegistry(runningConversationRegistry);
ActionNode actionNode = new ActionNode(executor, streamTracker);
// B2/B5: wire optional collaborators so ActionNode can pin skill
// constraints and auto-record tool completions into ProgressLedger.
// Setter injection keeps the existing constructor signature stable
// for tests that build ActionNode directly.
actionNode.setSkillRuntimeService(skillRuntimeService);
actionNode.setProgressLedgerService(progressLedgerService);
ObservationProcessor observationProcessor = new ObservationProcessor(graphObservationProperties);
ObservationNode observationNode = new ObservationNode(observationProcessor, streamTracker);
SummarizingNode summarizingNode = new SummarizingNode(chatModel, streamingHelper, streamTracker);
@ -1052,6 +1077,12 @@ public class AgentGraphBuilder {
// Tool progressive disclosure extensions enabled this run.
// Registered in BOTH graphs for the same merge-safety reason.
.addStrategy(MateClawStateKeys.ENABLED_EXTENSION_TOOLS, KeyStrategy.REPLACE)
// Tool-call loop guard counters + one-shot post-mutation
// verification reminder flag. Read-merge-write by
// ObservationNode; registered in BOTH graphs so the
// counters survive multi-node merges.
.addStrategy(MateClawStateKeys.TOOL_LOOP_STATS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.MUTATION_REMINDER_INJECTED, KeyStrategy.REPLACE)
.build();
GoalEvaluationNode goalEvalNode = new GoalEvaluationNode(
@ -1633,6 +1664,15 @@ public class AgentGraphBuilder {
Only state you cannot access something if no relevant tool is available.
Do not claim a tool-generated file, URL, UUID, path, task id, or success result before the corresponding tool call has completed. If a tool is needed, call the tool first, then report only the actual returned result.
## MCP Tool Naming
Tools from MCP servers have names shaped like `mcp_<serverId>_<slug>_<hash6>`:
- `<serverId>` is a numeric ID identifying which MCP server the tool belongs to.
- Tools from DIFFERENT servers have DIFFERENT serverId prefixes, even if they have the same raw name (e.g. `search` on server A vs server B) they are DIFFERENT tools and are NOT interchangeable.
- Each MCP tool's description starts with `[MCP server: <name>]` so you can identify the source server by its human-readable name.
- MCP tools are listed in the Extension Tools catalog by default. Use `enable_tool(toolName="<exact-name>")` to activate the one you need before calling it.
- Always call tools by the EXACT name shown in the tool list. Do NOT reconstruct a tool name by swapping the slug into a serverId you remember from a previous successful call that produces a non-existent tool name and the call will fail.
- If a tool call returns "Tool not found" with candidate suggestions, pick the correct one from the candidates verbatim.
## Multi-Part Question Guidelines
When the user asks multiple questions or requests multiple tasks in a single message:
1. Structure your final answer with numbered sections, one per sub-task
@ -1660,6 +1700,24 @@ public class AgentGraphBuilder {
3. Process the extracted text content
If you try to read a PDF/Office file with read_file, you will get binary garbage or an error.
## ProgressLedger Discipline (mandatory)
The `## 当前任务进度` block injected near the top of every turn is the **authoritative record** of what you have done and what remains. Treat it as ground truth, not as a scratchpad you may ignore.
- **On starting any multi-step task** (3 tool calls expected), call `progress_update` in a parallel tool_calls batch to register every pending step BEFORE doing the work. Do not wait until "later" context compression can trim earlier turns and you will lose track.
- **After each completed sub-step**, immediately call `progress_update` to flip its status to `done`. "Immediately" means in the same tool_calls batch that returns the result, not after the next reasoning turn.
- **Never re-execute a step the ledger shows as `done`** unless you can articulate why the prior result is stale.
- **🔒 固定约束 entries** (pinned from skill manifests) are non-negotiable. They survive context compression for a reason re-read them every turn and make sure your planned action still satisfies them.
- **🔧 自动记录 entries** are auto-filled by Java after each tool call. They are a safety net, not a substitute for your own `progress_update` if you only rely on them, you will lose the pending/blocked view that drives planning.
- If you see a ` 进度账本已 N 秒未更新` reminder, **stop whatever you are doing and update the ledger first**. Continuing to call tools without updating the ledger is the #1 cause of duplicate work and missed steps.
- The ledger is per-conversation and persists across context trims; treating it as ephemeral will cause you to repeat work after every compaction.
## Environment Change Notifications
Occasionally you will see a `## 📢 环境变更通知` block injected near the top of a turn. It is generated by Java when an external event affects your runtime an MCP server disconnecting, a skill being updated/removed, or a tool binding change.
- These notifications are **authoritative** Java detected the change; do not second-guess them by re-probing the tool.
- If a notification says an MCP server is unavailable, **immediately stop calling tools prefixed with that server's id** and either switch to an alternative or report the gap to the user.
- If a notification says a skill was updated, **re-load it via `load_skill`** to refresh its constraints in your pinned ledger; the old constraints you remember may no longer apply.
- If a notification says a tool was removed, **do not attempt to call it**; pick a different approach or ask the user.
- These notifications appear at most once per event; if you miss one, it will not be repeated, so act on it in the turn you see it.
""".formatted(entity.getId());
// Web-search vs browser_use priority guidance emitted unconditionally so the rule
@ -1696,14 +1754,48 @@ public class AgentGraphBuilder {
* bound skills, effective tool allowlist, model window and workspace once;
* the returned renderer is invoked each turn with the skills loaded so far
* this run so {@code load_skill} pins float to the top of the catalog.
*
* <p>agent-4: when any loaded skill declares structured {@code constraints}
* in its manifest, the rendered catalog gets a trailing anchor note
* {@code "🔒 = 含固定约束的 skill详见 ProgressLedger"} so the LLM has
* a visible cue that some skills carry non-negotiable rules pinned into
* the ledger. The cue is appended (not interleaved) to keep the
* catalog's prompt-cache hash stable for the unchanged prefix.
*/
private SkillCatalogRenderer buildSkillCatalogRenderer(AgentEntity entity, Set<String> boundTools,
Integer maxInputTokens) {
Set<Long> boundSkillIds = agentBindingService.getBoundSkillIds(entity.getId());
Long agentId = entity.getId();
Long workspaceId = entity.getWorkspaceId();
return loaded -> skillRuntimeService.buildSkillPromptEnhancement(
boundSkillIds, boundTools, maxInputTokens, agentId, workspaceId, loaded);
return loaded -> {
String catalog = skillRuntimeService.buildSkillPromptEnhancement(
boundSkillIds, boundTools, maxInputTokens, agentId, workspaceId, loaded);
if (catalog == null || catalog.isBlank() || loaded == null || loaded.isEmpty()) {
return catalog;
}
// Scan loaded skills for any with structured constraints. We only
// surface the anchor when at least one matches, to avoid noisy
// output on constraint-free skills.
boolean anyHasConstraints = false;
for (String skillName : loaded) {
try {
vip.mate.skill.runtime.model.ResolvedSkill skill = skillRuntimeService.findActiveSkill(skillName);
if (skill != null && skill.getManifest() != null) {
List<String> constraints = skill.getManifest().getConstraints();
if (constraints != null && !constraints.isEmpty()) {
anyHasConstraints = true;
break;
}
}
} catch (Exception ignored) {
// best-effort lookup; do not fail the catalog render
}
}
if (!anyHasConstraints) {
return catalog;
}
return catalog + "\n\n🔒 = 含固定约束的 skill已写入 ProgressLedger详见 🔒 固定约束 段落,全程不可忽略)";
};
}
/**

View File

@ -59,6 +59,15 @@ public class AgentService {
@Autowired(required = false)
private ApplicationEventPublisher events;
/**
* C5: tracks in-flight conversations so {@link vip.mate.agent.runtime.EnvironmentEventRouter}
* can push environment-change notifications into the agent's next reasoning
* turn. Field-injected (optional) so existing test constructors of
* {@code AgentService} don't need to supply it.
*/
@Autowired(required = false)
private vip.mate.agent.runtime.RunningConversationRegistry runningConversationRegistry;
/**
* Runtime Agent instance cache. Keyed first by agentId, then by a model
* key, so a conversation that pins a non-default model gets its own graph
@ -518,6 +527,36 @@ public class AgentService {
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 ====================
/**
@ -529,17 +568,22 @@ public class AgentService {
*/
private String withLifecycleSync(Long agentId, String message, String conversationId,
java.util.function.BiFunction<String, String, String> invoke) {
if (!memoryProperties.isLifecycleMediatorEnabled()) {
return invoke.apply(message, conversationId);
safeRegister(conversationId, agentId);
try {
if (!memoryProperties.isLifecycleMediatorEnabled()) {
return invoke.apply(message, conversationId);
}
String ownerKey = memoryOwnerResolver.resolve(ChatOriginHolder.get());
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message, ownerKey);
String memoryContext = lifecycleMediator.beforeLlmCall(ctx);
// Inject memory context into the user message (RFC-037 §3.3)
String enrichedMessage = injectMemoryContext(message, memoryContext);
String result = invoke.apply(enrichedMessage, conversationId);
lifecycleMediator.afterLlmCall(ctx, result != null ? result : "");
return result;
} finally {
safeUnregister(conversationId);
}
String ownerKey = memoryOwnerResolver.resolve(ChatOriginHolder.get());
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message, ownerKey);
String memoryContext = lifecycleMediator.beforeLlmCall(ctx);
// Inject memory context into the user message (RFC-037 §3.3)
String enrichedMessage = injectMemoryContext(message, memoryContext);
String result = invoke.apply(enrichedMessage, conversationId);
lifecycleMediator.afterLlmCall(ctx, result != null ? result : "");
return result;
}
/**
@ -552,23 +596,47 @@ 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) {
if (!memoryProperties.isLifecycleMediatorEnabled()) {
return invoke.apply(message, conversationId);
safeRegister(conversationId, agentId);
try {
if (!memoryProperties.isLifecycleMediatorEnabled()) {
return invoke.apply(message, conversationId)
.doFinally(s -> safeUnregister(conversationId));
}
String ownerKey = memoryOwnerResolver.resolve(ChatOriginHolder.get());
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message, ownerKey);
String memoryContext = lifecycleMediator.beforeLlmCall(ctx);
String enrichedMessage = injectMemoryContext(message, memoryContext);
StringBuilder reply = new StringBuilder();
return invoke.apply(enrichedMessage, conversationId)
.doOnNext(item -> {
String text = contentExtractor.apply(item);
if (text != null) {
reply.append(text);
}
})
.doOnComplete(() -> lifecycleMediator.afterLlmCall(ctx, reply.toString()))
.doOnError(e -> log.debug("[Memory] Stream error, skipping afterLlmCall: {}", e.getMessage()))
.doFinally(s -> safeUnregister(conversationId));
} catch (Exception e) {
// If invoke.apply() throws before the Flux is constructed, the
// doFinally above never runs clean up here.
safeUnregister(conversationId);
throw e;
}
}
/** C5 helper — null-safe register so tests without the registry don't NPE. */
private void safeRegister(String conversationId, Long agentId) {
if (runningConversationRegistry != null) {
runningConversationRegistry.register(conversationId, agentId);
}
}
/** C5 helper — null-safe unregister so tests without the registry don't NPE. */
private void safeUnregister(String conversationId) {
if (runningConversationRegistry != null) {
runningConversationRegistry.unregister(conversationId);
}
String ownerKey = memoryOwnerResolver.resolve(ChatOriginHolder.get());
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message, ownerKey);
String memoryContext = lifecycleMediator.beforeLlmCall(ctx);
String enrichedMessage = injectMemoryContext(message, memoryContext);
StringBuilder reply = new StringBuilder();
return invoke.apply(enrichedMessage, conversationId)
.doOnNext(item -> {
String text = contentExtractor.apply(item);
if (text != null) {
reply.append(text);
}
})
.doOnComplete(() -> lifecycleMediator.afterLlmCall(ctx, reply.toString()))
.doOnError(e -> log.debug("[Memory] Stream error, skipping afterLlmCall: {}", e.getMessage()));
}
/**

View File

@ -366,4 +366,25 @@ 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

@ -96,16 +96,31 @@ public class ConversationWindowManager {
/**
* Tool names whose results must never be compacted into a one-line
* summary. Sub-agent delegations are irreplaceable: the child runs an
* summary.
*
* <p>Sub-agent delegations are irreplaceable: the child runs an
* independent LLM session that the parent cannot reproduce, so dropping
* earlier batches forces the parent to re-dispatch the same children to
* recover what was lost. Every other tool (read_file, shell, search,
* memory) can be re-invoked cheaply if the parent decides it needs
* the data again.
* recover what was lost.
*
* <p>{@code load_skill} returns the SKILL.md content at load time
* a snapshot of the skill's constraints, flow, and script entrypoints.
* The skill author may update SKILL.md between the original load and a
* hypothetical re-load, so re-invoking {@code load_skill} does NOT
* guarantee recovering the same instructions the agent started with.
* Dropping the original also forces the agent to either re-load (token
* expensive for 50KB+ skills) or operate without constraints the
* root cause of the "skill constraint forgetting" bug. Pinning the
* original ToolResponseMessage keeps the agent's understanding of the
* task's rules stable across context-window trims.
*
* <p>Every other tool (read_file, shell, search, memory) can be
* re-invoked cheaply if the parent decides it needs the data again.
*/
private static final java.util.Set<String> PRUNE_EXEMPT_TOOLS = java.util.Set.of(
"delegateToAgent",
"delegateParallel"
"delegateParallel",
"load_skill"
);
// ==================== 冷却机制 ====================
@ -256,6 +271,16 @@ public class ConversationWindowManager {
java.util.Collection<ToolCallback> toolCallbacks,
String workspaceBasePath) {
if (messages == null || messages.isEmpty()) {
// Still surface an occupancy snapshot for the very first turn so
// the chat panel shows context usage from message one on.
int firstTurnMax = (maxInputTokens != null && maxInputTokens > 0)
? maxInputTokens : properties.getDefaultMaxInputTokens();
broadcastContextUsage(conversationId, firstTurnMax,
TokenEstimator.estimateTokens(systemPrompt),
TokenEstimator.estimateTokens(currentUserMessage) + TokenEstimator.PER_MESSAGE_OVERHEAD,
0,
TokenEstimator.estimateToolsTokens(toolCallbacks),
false);
return messages;
}
long spillsAtEntry = (toolResultStorage != null) ? toolResultStorage.getSpillCount() : 0L;
@ -277,6 +302,9 @@ public class ConversationWindowManager {
int toolsTokens = TokenEstimator.estimateToolsTokens(toolCallbacks);
int totalTokens = systemTokens + currentMsgTokens + historyTokens + toolsTokens;
broadcastContextUsage(conversationId, effectiveMax, systemTokens, currentMsgTokens,
historyTokens, toolsTokens, totalTokens > triggerThreshold);
if (totalTokens <= triggerThreshold) {
return messages;
}
@ -304,8 +332,14 @@ public class ConversationWindowManager {
// 尾部保护 token 预算阈值的 20%
int tailTokenBudget = (int) (triggerThreshold * 0.20);
return compactMessages(messages, historyBudget, tailTokenBudget, chatModel,
conversationId, agentId, totalTokens, spillsAtEntry, "token_threshold");
List<Message> compacted = compactMessages(messages, historyBudget, tailTokenBudget, chatModel,
conversationId, agentId, totalTokens, spillsAtEntry, "token_threshold",
workspaceBasePath);
// Re-broadcast so the occupancy gauge falls immediately after
// compaction instead of waiting for the next window-fit pass.
broadcastContextUsage(conversationId, effectiveMax, systemTokens, currentMsgTokens,
TokenEstimator.estimateTokens(compacted), toolsTokens, false);
return compacted;
}
/**
@ -321,6 +355,42 @@ public class ConversationWindowManager {
// ==================== 核心压缩逻辑 ====================
/**
* Broadcast a {@code context_usage} snapshot: how much of the effective
* input window the next LLM call will occupy, split by source (system
* prompt / tool schemas / history / current input). Values come from
* {@link TokenEstimator}, so they are heuristic estimates for a gauge,
* not billing-grade counts the UI labels them as such. Fired once per
* window-fit pass (i.e. per reasoning step) and again after compaction
* completes so the gauge falls back. Silent no-op when no tracker is
* wired or the window size is unknown.
*/
private void broadcastContextUsage(String conversationId, int windowTokens,
int systemTokens, int currentTokens,
int historyTokens, int toolsTokens,
boolean willCompact) {
if (streamTracker == null || conversationId == null || conversationId.isEmpty()
|| windowTokens <= 0) {
return;
}
try {
int usedTokens = systemTokens + currentTokens + historyTokens + toolsTokens;
Map<String, Object> payload = new java.util.LinkedHashMap<>();
payload.put("windowTokens", windowTokens);
payload.put("usedTokens", usedTokens);
payload.put("systemTokens", systemTokens);
payload.put("currentTokens", currentTokens);
payload.put("historyTokens", historyTokens);
payload.put("toolsTokens", toolsTokens);
payload.put("ratio", windowTokens > 0 ? (double) usedTokens / windowTokens : 0d);
payload.put("willCompact", willCompact);
payload.put("timestamp", System.currentTimeMillis());
streamTracker.broadcastObject(conversationId, "context_usage", payload);
} catch (Exception e) {
log.debug("[ConversationWindow] broadcast context_usage failed: {}", e.getMessage());
}
}
/** Broadcast a single compact_status event; silent no-op when no tracker is wired. */
private void broadcastCompactStatus(String conversationId, String status, Map<String, Object> extra) {
if (streamTracker == null || conversationId == null || conversationId.isEmpty()) {
@ -341,7 +411,7 @@ public class ConversationWindowManager {
int tailTokenBudget, ChatModel chatModel,
String conversationId, Long agentId,
int preTokens, long spillsAtEntry,
String trigger) {
String trigger, String workspaceBasePath) {
broadcastCompactStatus(conversationId, "start", Map.of(
"preTokens", preTokens,
"messagesIn", messages.size(),
@ -421,6 +491,36 @@ public class ConversationWindowManager {
}
}
// Phase 2.7: Move 4 Lossless spill evict
// Before paying the LLM-summary cost (lossy + tokens + latency),
// try to bring oldMessages under budget by spilling remaining
// oversized tool results to disk. Each spilled result becomes a
// compact spill-marker (preview + on-disk path), recoverable via
// read_file. Exempt tools and already-spilled markers are skipped.
// If this phase lands the token count under historyBudget, the
// LLM summary is skipped entirely the eviction is lossless.
if (toolResultStorage != null && workspaceBasePath != null) {
int spilled = spillEvictToolResults(oldMessages, conversationId, workspaceBasePath);
if (spilled > 0) {
int afterSpillTokens = TokenEstimator.estimateTokens(oldMessages)
+ TokenEstimator.estimateTokens(recentMessages);
log.info("[ConversationWindow] Phase 2.7 Spill evict: {} results spilled, tokens={}, budget={}",
spilled, afterSpillTokens, historyBudget);
if (afterSpillTokens <= historyBudget) {
List<Message> result = new ArrayList<>(oldMessages);
result.addAll(recentMessages);
broadcastCompactStatus(conversationId, "done", Map.of(
"trigger", trigger,
"preTokens", preTokens,
"postTokens", afterSpillTokens,
"strategy", "lossless_spill_evict",
"resultsSpilled", spilled
));
return result;
}
}
}
// Phase 3: Pre-Prune + LLM 结构化摘要
// Pre-prune在喂给摘要 LLM 前清理旧消息中的工具输出
@ -931,6 +1031,22 @@ public class ConversationWindowManager {
&& r.responseData().startsWith(ToolResultStorage.SPILL_MARKER_PREFIX);
}
/**
* Move 4 exempt tools ({@link #PRUNE_EXEMPT_TOOLS}) must bypass every
* compaction phase, not just the size-based and age-based passes. Their
* outputs are not replayable (sub-agent delegations run independent LLM
* sessions) or not safely recoverable (load_skill returns the SKILL.md
* snapshot at load time, which the author may have edited since). Trimming
* or clearing them in Phase 1/2/3 silently drops the skill's constraints
* and the sub-agent's transcript the root cause of the
* "compression causes attention failure" symptom.
*/
static boolean isExemptTool(ToolResponseMessage.ToolResponse r) {
return r != null
&& r.name() != null
&& PRUNE_EXEMPT_TOOLS.contains(r.name());
}
/**
* Age-based compaction. Replace bodies of all tool responses older than
* the {@code keepRecentN} most recent with a one-line placeholder, while
@ -1071,6 +1187,12 @@ public class ConversationWindowManager {
newResponses.add(r);
continue;
}
if (isExemptTool(r)) {
// Move 4: load_skill / delegateToAgent outputs are not
// safely recoverable pass through verbatim.
newResponses.add(r);
continue;
}
String data = r.responseData();
if (data != null && data.length() > 500) {
String marker = "\n...[trimmed " + data.length() + " chars; "
@ -1108,6 +1230,11 @@ public class ConversationWindowManager {
replaced.add(r);
continue;
}
if (isExemptTool(r)) {
// Move 4: exempt tools survive Phase 2 unchanged.
replaced.add(r);
continue;
}
replaced.add(new ToolResponseMessage.ToolResponse(r.id(), r.name(),
buildInformativeCleared(r.name(), r.responseData())));
changed = true;
@ -1130,8 +1257,12 @@ public class ConversationWindowManager {
int pruned = 0;
for (int i = 0; i < messages.size(); i++) {
if (messages.get(i) instanceof ToolResponseMessage trm) {
// Move 4: skip the entire ToolResponseMessage if every
// response is either a spill marker or an exempt tool
// there's nothing to prune.
boolean hasSubstantial = trm.getResponses().stream()
.anyMatch(r -> !isSpillMarker(r)
&& !isExemptTool(r)
&& r.responseData() != null
&& r.responseData().length() > 200);
if (hasSubstantial) {
@ -1141,6 +1272,11 @@ public class ConversationWindowManager {
placeholders.add(r);
continue;
}
if (isExemptTool(r)) {
// Move 4: exempt tools survive Phase 3 pre-prune.
placeholders.add(r);
continue;
}
placeholders.add(new ToolResponseMessage.ToolResponse(r.id(), r.name(),
"[旧工具输出已清理以节省上下文空间]"));
}
@ -1152,6 +1288,71 @@ public class ConversationWindowManager {
return pruned;
}
/**
* Move 4 Phase 2.7 lossless spill evict. Walks {@code messages} and
* spills each non-exempt, non-spilled, oversized tool result to disk via
* {@link ToolResultStorage#persistIfOversized}, replacing the body with
* a compact spill marker (preview + on-disk path). The model can recover
* the original via {@code read_file} on the path.
*
* <p>Unlike Phase 1/2 (which trim/clear in place), this phase is
* <em>lossless</em>: the full output is preserved on disk. The token
* savings come from replacing a multi-KB body with a ~200-char preview.
* When the savings are enough to land under {@code historyBudget}, the
* caller skips the LLM summary entirely avoiding its token cost,
* latency, and lossy compression.
*
* <p>Skips:
* <ul>
* <li>Exempt tools ({@link #PRUNE_EXEMPT_TOOLS}) not safely
* recoverable.</li>
* <li>Already-spilled markers re-spilling would just overwrite the
* same file.</li>
* <li>Bodies under {@code perResultThresholdChars} too small to
* benefit from spilling (the marker preview is comparable in size).</li>
* </ul>
*
* @return number of tool responses actually spilled to disk
*/
int spillEvictToolResults(List<Message> messages, String conversationId,
String workspaceBasePath) {
if (toolResultStorage == null || conversationId == null || conversationId.isBlank()) {
return 0;
}
int spilled = 0;
for (int i = 0; i < messages.size(); i++) {
if (!(messages.get(i) instanceof ToolResponseMessage trm)) continue;
List<ToolResponseMessage.ToolResponse> newResponses = new ArrayList<>();
boolean changed = false;
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) {
if (isSpillMarker(r) || isExemptTool(r)) {
newResponses.add(r);
continue;
}
String data = r.responseData();
if (data == null || data.isEmpty()) {
newResponses.add(r);
continue;
}
String spilledBody = toolResultStorage.persistIfOversized(
data, r.name(), r.id(), conversationId, workspaceBasePath);
if (spilledBody != data) {
// persistIfOversized replaced the body with a spill marker
newResponses.add(new ToolResponseMessage.ToolResponse(
r.id(), r.name(), spilledBody));
changed = true;
spilled++;
} else {
newResponses.add(r);
}
}
if (changed) {
messages.set(i, ToolResponseMessage.builder().responses(newResponses).build());
}
}
return spilled;
}
// ==================== LLM 摘要生成结构化 + 迭代更新 ====================
/**
@ -1383,7 +1584,7 @@ public class ConversationWindowManager {
List<Message> compacted = compactMessages(messages, forcedBudget, forcedTailBudget,
chatModel, conversationId, agentId, currentTokens, spillsAtEntry,
"prompt_too_long");
"prompt_too_long", null);
if (compacted == messages || TokenEstimator.estimateTokens(compacted) >= currentTokens) {
log.warn("[ConversationWindow] PTL structured compaction had no effect for conv={}, falling back to tail-only",

View File

@ -8,6 +8,9 @@ import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import vip.mate.tool.builtin.ToolExecutionContext;
import vip.mate.tool.disclosure.ToolUsageRecencyTracker;
import vip.mate.tool.mcp.runtime.McpProgressContext;
import vip.mate.tool.mcp.runtime.McpToolNameResolver;
import vip.mate.tool.mcp.runtime.ProgressAwareMcpToolCallback;
import vip.mate.agent.AgentToolSet;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.context.ChatOrigin;
@ -255,10 +258,17 @@ public class ToolExecutionExecutor {
/** Optional recency feed for budget-driven tool-disclosure demotion. */
private ToolUsageRecencyTracker usageRecencyTracker;
/** Optional MCP progress context for long-running tool progress relay. */
private McpProgressContext progressContext;
public void setUsageRecencyTracker(ToolUsageRecencyTracker tracker) {
this.usageRecencyTracker = tracker;
}
public void setProgressContext(McpProgressContext ctx) {
this.progressContext = ctx;
}
public void setSkillRuntimeService(vip.mate.skill.runtime.SkillRuntimeService s) {
this.skillRuntimeService = s;
}
@ -882,14 +892,31 @@ public class ToolExecutionExecutor {
// not yet migrated to ToolContext keep working unchanged.
ToolExecutionContext.set(pc.conversationId, pc.requesterId, pc.workspaceBasePath);
String result;
String progressToken = null;
try {
ChatOrigin runtimeOrigin = pc.origin != null ? pc.origin : ChatOrigin.EMPTY;
runtimeOrigin = runtimeOrigin
.withConversationId(pc.conversationId)
.withWorkspace(runtimeOrigin.workspaceId(), pc.workspaceBasePath);
ToolContext toolContext = runtimeOrigin.toToolContext();
// MCP progress: generate progressToken and inject into ToolContext
// so ProgressAwareMcpToolCallback can include it in tools/call _meta.
if (progressContext != null) {
progressToken = UUID.randomUUID().toString();
progressContext.register(progressToken,
new McpProgressContext.ProgressEntry(pc.conversationId, pc.toolCall.id(), toolName));
Map<String, Object> ctxMap = new HashMap<>(toolContext.getContext());
ctxMap.put(ProgressAwareMcpToolCallback.MCP_PROGRESS_TOKEN_KEY, progressToken);
toolContext = new ToolContext(ctxMap);
}
result = pc.callback.call(pc.arguments, toolContext);
} finally {
if (progressToken != null) {
progressContext.remove(progressToken);
progressContext.removeSnapshot(pc.conversationId, pc.toolCall.id());
}
ToolExecutionContext.clear();
}
@ -1264,7 +1291,79 @@ public class ToolExecutionExecutor {
log.debug("[ToolExecutor] skill-aware hint check failed: {}", e.getMessage());
}
}
return "Tool not found: " + toolName;
return buildMcpAwareNotFoundMessage(toolName);
}
/**
* Build a "Tool not found" message that surfaces candidate MCP tools
* when the LLM appears to have confused two servers' tools.
*
* <p>Background: MCP tool names are {@code mcp_<serverId>_<slug>_<hash6>}.
* The {@code serverId} is an opaque 19-digit Snowflake ID. When a task
* mixes tools from multiple MCP servers, the LLM often reconstructs a
* tool name by taking a remembered slug and swapping it onto the
* serverId of a previously-successful call producing a non-existent
* name like {@code mcp_<serverA>_fetch_xxx} when {@code fetch} actually
* lives under serverB. Without candidate suggestions, the LLM gets
* "Tool not found: ..." with no recovery signal and keeps retrying the
* same wrong name until it hits max iterations.
*
* <p>This method parses the requested name, and if it looks like an
* MCP tool, searches {@link #toolCallbackMap} for registered tools
* whose slug OR hash6 matches (cross-server). Matching by slug catches
* "same raw tool name on a different server"; matching by hash6
* catches "same raw tool name with the LLM remembering the hash but
* not the serverId". Returns at most 5 candidates to keep the message
* bounded.
*
* <p>If no candidates are found, falls back to the plain
* "Tool not found: ..." message.
*/
private String buildMcpAwareNotFoundMessage(String toolName) {
if (toolName == null || toolName.isBlank()) {
return "Tool not found: " + toolName;
}
McpToolNameResolver.ParsedRef ref = McpToolNameResolver.parse(toolName);
if (ref == null) {
// Not an MCP-prefixed name no candidate heuristic applies.
return "Tool not found: " + toolName;
}
// Search for registered tools with matching slug or hash6 on OTHER servers.
java.util.List<String> candidates = new java.util.ArrayList<>();
for (String registered : toolCallbackMap.keySet()) {
McpToolNameResolver.ParsedRef r = McpToolNameResolver.parse(registered);
if (r == null || r.serverId() == ref.serverId()) {
continue; // same server, or not an MCP tool skip
}
boolean slugMatch = r.slug().equals(ref.slug());
boolean hashMatch = r.hash6().equals(ref.hash6());
if (slugMatch || hashMatch) {
candidates.add(registered);
if (candidates.size() >= 5) {
break; // bound the list
}
}
}
if (candidates.isEmpty()) {
return "Tool not found: " + toolName
+ "\n(This name looks like an MCP tool on server " + ref.serverId()
+ ", but no tool with slug '" + ref.slug() + "' is registered on any server."
+ " Check the tool list for the correct name.)";
}
StringBuilder sb = new StringBuilder("Tool not found: ").append(toolName).append('\n');
sb.append("The name looks like an MCP tool on server ").append(ref.serverId())
.append(", but no tool with that slug/hash is registered there.\n");
sb.append("Did you mean one of these (same slug or hash on other servers)?\n");
for (String c : candidates) {
sb.append(" - ").append(c).append('\n');
}
sb.append("\nUse the exact name from above. Do NOT reconstruct tool names from memory — ")
.append("always copy verbatim from the tool list or from this suggestion.");
return sb.toString();
}
/**

View File

@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import vip.mate.agent.context.StructuredTruncator;
import vip.mate.tool.guard.WorkspacePathGuard;
import vip.mate.workspace.core.service.ChatUploadLocationResolver;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;
@ -478,10 +479,14 @@ public class ToolResultStorage {
return deleted;
}
/** Strip path separators and reserved characters so user-supplied IDs cannot escape the directory. */
/**
* Strip path separators and reserved characters so user-supplied IDs cannot
* escape the directory. Delegates to the canonical conversation-id sanitizer
* so every id path-segment mapping across the codebase stays byte-for-byte
* identical (see issue #507).
*/
private static String sanitize(String s) {
if (s == null) return "";
return s.replaceAll("[^A-Za-z0-9_.-]", "_");
return ChatUploadLocationResolver.sanitizeSegment(s);
}
/** Test/admin helper: lexicographic ordering by length, descending. Not used at runtime. */

View File

@ -0,0 +1,252 @@
package vip.mate.agent.graph.guard;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/**
* Tool-call loop guard pure, side-effect-free decision logic that detects
* a ReAct loop stuck on repetitive tool calls.
* <p>
* Three independent detectors, each keyed on a tool-call signature
* ({@code toolName + ":" + sha256(canonicalized args JSON)}):
* <ol>
* <li><b>Identical-argument repeated failure</b> the model retries the
* exact same failing call without reading the error. Warn early, halt
* when clearly stuck.</li>
* <li><b>Per-tool repeated failure</b> (arguments ignored) the model
* keeps guessing slightly different arguments against the same broken
* tool ("path-guessing" loops).</li>
* <li><b>Idempotent no-progress</b> a read-only tool keeps returning the
* byte-identical result; the model should use what it already has.
* Restricted to a known read-only tool set so legitimate repeated
* writes are never flagged.</li>
* </ol>
* Warnings are meant to be appended to the observation text so the model can
* self-correct on the next reasoning turn; a halt is meant to be routed to
* the graceful wrap-up node. Executing those side effects is the caller's
* (ObservationNode's) job this class only counts and decides, which keeps
* it trivially unit-testable.
* <p>
* Counters live in graph state under
* {@link vip.mate.agent.graph.state.MateClawStateKeys#TOOL_LOOP_STATS} and are
* scoped to a single graph run.
*
* @author MateClaw Team
*/
public final class ToolLoopGuard {
/** Identical tool + identical args failing: warn from the 2nd failure, halt at the 5th. */
static final int EXACT_FAILURE_WARN_AFTER = 2;
static final int EXACT_FAILURE_HALT_AFTER = 5;
/** Same tool failing regardless of args: warn from the 3rd failure, halt at the 8th. */
static final int SAME_TOOL_FAILURE_WARN_AFTER = 3;
static final int SAME_TOOL_FAILURE_HALT_AFTER = 8;
/** Idempotent tool returning the identical result: warn from the 2nd repeat, halt at the 5th. */
static final int NO_PROGRESS_WARN_AFTER = 2;
static final int NO_PROGRESS_HALT_AFTER = 5;
/**
* Read-only tools eligible for no-progress detection. Mutating tools are
* deliberately excluded calling {@code write_file} twice with the same
* content is legitimate (e.g. after an external revert).
*/
static final Set<String> IDEMPOTENT_TOOLS = Set.of(
"read_file", "web_search", "extract_document_text", "extract_pdf_text");
/** Counter-key prefixes inside the stats map. */
private static final String KEY_EXACT_FAILURE = "ef:";
private static final String KEY_TOOL_FAILURE = "tf:";
private static final String KEY_NO_PROGRESS_HASH = "nph:";
private static final String KEY_NO_PROGRESS_COUNT = "npc:";
/**
* Failure heuristic, aligned with how tool errors actually surface:
* the executor's exception path ({@code "Tool execution failed: …"}), the
* guard-block path ({@code "[安全拦截] …"}), tools' own error prefixes, and
* structured JSON errors ({@code "error": <non-empty>} / {@code "success": false}).
*/
// The possessive \s*+ prevents backtracking from letting the lookahead
// land on a whitespace char and misclassify {"error": null} as a failure.
private static final Pattern JSON_ERROR_PATTERN = Pattern.compile(
"\"error\"\\s*:\\s*+(?!null|\"\")|\"success\"\\s*:\\s*false");
private static final ObjectMapper CANONICAL_MAPPER = new ObjectMapper()
.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);
private ToolLoopGuard() {
}
/**
* Outcome of one observation round.
*
* @param stats updated counter map to write back to graph state
* @param warnings guidance lines to append to the observation text (may be empty)
* @param haltReason non-null when a detector crossed its halt threshold;
* the text is suitable for the graph ERROR slot
*/
public record Evaluation(Map<String, Object> stats, List<String> warnings, String haltReason) {
public boolean shouldHalt() {
return haltReason != null;
}
}
/**
* Evaluate one tool batch. Pairs calls with results by tool-call id
* (falling back to list order), updates the counters, and returns the
* warnings / halt decision for this round.
*
* @param previousStats counter map from the previous round (never mutated)
* @param toolCalls the batch the model requested this round
* @param toolResults the corresponding execution results
*/
public static Evaluation evaluate(Map<String, Object> previousStats,
List<AssistantMessage.ToolCall> toolCalls,
List<ToolResponseMessage.ToolResponse> toolResults) {
Map<String, Object> stats = new HashMap<>(previousStats == null ? Map.of() : previousStats);
List<String> warnings = new ArrayList<>();
String haltReason = null;
if (toolResults == null || toolResults.isEmpty()) {
return new Evaluation(stats, warnings, null);
}
for (ToolResponseMessage.ToolResponse result : toolResults) {
String toolName = result.name();
if (toolName == null || toolName.isBlank()) {
continue;
}
String arguments = findArguments(toolCalls, result);
String signature = toolName + ":" + hash(canonicalizeArguments(arguments));
boolean failed = isFailure(result.responseData());
if (failed) {
int exactCount = increment(stats, KEY_EXACT_FAILURE + signature);
int toolCount = increment(stats, KEY_TOOL_FAILURE + toolName);
if (exactCount >= EXACT_FAILURE_HALT_AFTER) {
haltReason = "工具调用陷入循环:" + toolName + " 已连续 " + exactCount
+ " 次以相同参数失败,已强制收尾";
} else if (toolCount >= SAME_TOOL_FAILURE_HALT_AFTER) {
haltReason = "工具调用陷入循环:" + toolName + " 本次运行累计失败 " + toolCount
+ " 次,已强制收尾";
} else if (exactCount >= EXACT_FAILURE_WARN_AFTER) {
warnings.add("[🔁 循环警告] 工具 " + toolName + " 已连续 " + exactCount
+ " 次以相同参数失败。请勿原样重试:分析上面的错误信息并改变策略"
+ "(调整参数或改用其他工具),或向用户说明具体阻塞点。");
} else if (toolCount >= SAME_TOOL_FAILURE_WARN_AFTER) {
warnings.add("[🔁 循环警告] 工具 " + toolName + " 本次运行已失败 " + toolCount
+ " 次。请先诊断根因(检查路径、参数、前置条件)再继续,不要盲目换参数重试。");
}
} else {
// A success clears the failure streaks for this call shape / tool.
stats.remove(KEY_EXACT_FAILURE + signature);
stats.remove(KEY_TOOL_FAILURE + toolName);
if (IDEMPOTENT_TOOLS.contains(toolName)) {
String resultHash = hash(result.responseData());
String lastHash = (String) stats.get(KEY_NO_PROGRESS_HASH + signature);
int repeatCount = resultHash.equals(lastHash)
? increment(stats, KEY_NO_PROGRESS_COUNT + signature)
: resetNoProgress(stats, signature);
stats.put(KEY_NO_PROGRESS_HASH + signature, resultHash);
if (repeatCount >= NO_PROGRESS_HALT_AFTER) {
haltReason = "工具调用陷入循环:" + toolName + " 已连续 " + repeatCount
+ " 次返回完全相同的结果,已强制收尾";
} else if (repeatCount >= NO_PROGRESS_WARN_AFTER) {
warnings.add("[🔁 循环提示] 工具 " + toolName + " 已连续 " + repeatCount
+ " 次返回完全相同的结果。请直接使用已获得的结果继续任务,不要重复调用。");
}
}
}
}
return new Evaluation(Map.copyOf(stats), List.copyOf(warnings), haltReason);
}
/** Heuristic: does this tool response text represent a failure? */
public static boolean isFailure(String responseData) {
if (responseData == null || responseData.isBlank()) {
return false;
}
String head = responseData.substring(0, Math.min(responseData.length(), 300)).strip();
String lower = head.toLowerCase(Locale.ROOT);
if (lower.startsWith("tool execution failed")
|| head.startsWith("[安全拦截]")
|| lower.startsWith("error:")
|| head.startsWith("错误:")
|| head.startsWith("错误:")) {
return true;
}
return head.startsWith("{") && JSON_ERROR_PATTERN.matcher(head).find();
}
/**
* Canonicalize an arguments JSON string so key order and whitespace do not
* change the signature. Falls back to the trimmed raw string when the
* arguments are not parseable JSON.
*/
static String canonicalizeArguments(String argumentsJson) {
if (argumentsJson == null || argumentsJson.isBlank()) {
return "";
}
try {
Object parsed = CANONICAL_MAPPER.readValue(argumentsJson, Object.class);
return CANONICAL_MAPPER.writeValueAsString(parsed);
} catch (Exception e) {
return argumentsJson.trim();
}
}
private static String findArguments(List<AssistantMessage.ToolCall> toolCalls,
ToolResponseMessage.ToolResponse result) {
if (toolCalls == null) {
return "";
}
for (AssistantMessage.ToolCall call : toolCalls) {
if (call != null && call.id() != null && call.id().equals(result.id())) {
return call.arguments();
}
}
return "";
}
private static int increment(Map<String, Object> stats, String key) {
int next = ((Number) stats.getOrDefault(key, 0)).intValue() + 1;
stats.put(key, next);
return next;
}
private static int resetNoProgress(Map<String, Object> stats, String signature) {
stats.put(KEY_NO_PROGRESS_COUNT + signature, 1);
return 1;
}
private static String hash(String text) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] bytes = digest.digest((text == null ? "" : text).getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(24);
for (int i = 0; i < 12; i++) {
sb.append(String.format("%02x", bytes[i]));
}
return sb.toString();
} catch (Exception e) {
// SHA-256 is mandatory on every JVM; fall back to hashCode just in case.
return Integer.toHexString(text == null ? 0 : text.hashCode());
}
}
}

View File

@ -19,11 +19,24 @@ import java.util.concurrent.CancellationException;
import static vip.mate.agent.graph.state.MateClawStateKeys.*;
/**
* 工具执行节点ReAct Action 阶段
* Tool-execution node (the ReAct Action phase).
* <p>
* 委托 {@link ToolExecutionExecutor} 执行工具调用支持并发执行和审批 barrier
* Delegates tool-call execution to {@link ToolExecutionExecutor}, supporting
* concurrent execution and the approval barrier.
* <p>
* 支持 forced_replay 阶段当审批通过后的重放调用到达时跳过 ToolGuard 检查直接执行
* Supports the forced_replay phase: when an approved replay call arrives, it
* skips the ToolGuard check and executes directly.
*
* <p>B2: when a {@code load_skill} call is detected, the skill manifest's
* {@code constraints} are extracted and written to the ProgressLedger's pinned
* entries, so the constraints stay visible for the whole conversation never
* overwritten by the LLM's progress_update, never trimmed by context compression.
*
* <p>B5: after a tool call succeeds, the ProgressLedger is auto-backfilled so the
* LLM sees the completed tool-call record on the next turn even without calling
* progress_update. Auto-recorded entries are capped
* ({@link ProgressLedgerService#MAX_AUTO_RECORDED}) and never overwrite entries
* the LLM already wrote.
*
* @author MateClaw Team
*/
@ -38,9 +51,27 @@ public class ActionNode implements NodeAction {
/** Function name of the extension-tool activator, mirrored from EnableExtensionTool. */
private static final String ENABLE_TOOL = "enable_tool";
/** Function name of the progress-update tool — skip auto-recording it. */
private static final String PROGRESS_UPDATE_TOOL = "progress_update";
/**
* Tools whose results should NOT be auto-recorded into the ledger.
* Meta-tools (load_skill, enable_tool, progress_update) either have
* their own ledger side-effects or are the ledger itself.
*/
private static final Set<String> AUTO_RECORD_SKIP = Set.of(
LOAD_SKILL_TOOL, ENABLE_TOOL, PROGRESS_UPDATE_TOOL,
"listAvailableSkills", "readSkillFile", "runSkillScript"
);
private final ToolExecutionExecutor executor;
private final vip.mate.channel.web.ChatStreamTracker streamTracker;
/** Optional — B2: extract constraints from skill manifest on load_skill. */
private vip.mate.skill.runtime.SkillRuntimeService skillRuntimeService;
/** Optional — B2/B5: write pinned + auto-recorded entries. */
private vip.mate.agent.progress.ProgressLedgerService progressLedgerService;
public ActionNode(ToolExecutionExecutor executor) {
this(executor, null);
}
@ -50,6 +81,16 @@ public class ActionNode implements NodeAction {
this.streamTracker = streamTracker;
}
/** Setter injection so existing constructors stay source-compatible. */
public void setSkillRuntimeService(vip.mate.skill.runtime.SkillRuntimeService s) {
this.skillRuntimeService = s;
}
/** Setter injection so existing constructors stay source-compatible. */
public void setProgressLedgerService(vip.mate.agent.progress.ProgressLedgerService s) {
this.progressLedgerService = s;
}
@Override
@SuppressWarnings("unchecked")
public Map<String, Object> apply(OverAllState state) throws Exception {
@ -88,14 +129,6 @@ public class ActionNode implements NodeAction {
.responses(result.responses())
.build();
// Use the executor's raw-stage ledger instead of re-parsing the
// spill-compacted responses. ToolExecutionExecutor builds this
// ledger from the full pre-truncate text, so a 30 KB grep result
// whose head/tail-cut version no longer mentions a path will still
// contribute that path to the evidence pool. Falls back to empty
// for legacy executor stubs (tests, mocks) that didn't populate
// the new field fine, the merge with `accessor.sourceEvidenceLedger`
// is no-op in that case.
SourceEvidenceLedger rawLedger = result.rawEvidenceLedger() != null
? result.rawEvidenceLedger()
: SourceEvidenceLedger.empty();
@ -112,19 +145,6 @@ public class ActionNode implements NodeAction {
}
// RFC-052: any returnDirect tool in this batch short-circuit the graph.
// ObservationDispatcher will route to FinalAnswerNode (skipping the next
// LLM call). Direct outputs and the trigger flag both live in state so
// FinalAnswerNode can assemble the final answer verbatim.
//
// Priority guard: when an approval barrier ALSO fires in the same batch
// (a direct tool ran successfully BEFORE a sibling tool that needed
// approval), let the approval flow win. Otherwise the user would see a
// "RETURN_DIRECT" final answer while an approval modal is still open
// for the unresolved sibling a confusing dual-track state. After the
// user resolves the approval, the replay path will re-execute and the
// direct tool's content reaches the user via the streamedContent path
// instead. Same-batch direct+approval is rare; we explicitly defer to
// approval for safety.
if (result.hasDirectOutputs() && !result.awaitingApproval()) {
output.returnDirectTriggered(true);
output.directToolOutputs(result.directOutputs());
@ -144,20 +164,20 @@ public class ActionNode implements NodeAction {
}
// Pin skills the model loaded this run so the next reasoning turn's
// catalog ranks them first and the model stops re-loading the same
// skill it already pulled into message history. Tools cannot mutate
// graph state directly, so the load is detected here from the tool
// calls and merged into LOADED_SKILLS (read-merge-write, REPLACE key).
// catalog ranks them first.
Set<String> requestedSkills = extractLoadedSkillNames(toolCalls);
if (!requestedSkills.isEmpty()) {
Set<String> merged = new LinkedHashSet<>(accessor.loadedSkills());
if (merged.addAll(requestedSkills)) {
output.loadedSkills(Set.copyOf(merged));
}
// B2: extract structured constraints from loaded skills' manifests
// and pin them into the ProgressLedger so they survive context
// compression and stay visible on every turn.
pinSkillConstraints(conversationId, requestedSkills);
}
// Same mechanism for enable_tool: record the activated extension tools so
// ReasoningNode's next turn adds them back to the advertised callbacks.
// Same mechanism for enable_tool
Set<String> enabledTools = extractEnabledToolNames(toolCalls);
if (!enabledTools.isEmpty()) {
Set<String> merged = new LinkedHashSet<>(accessor.enabledExtensionTools());
@ -166,15 +186,134 @@ public class ActionNode implements NodeAction {
}
}
// B5: auto-record successful tool calls into the ledger so the LLM
// sees what it already did even if it forgot to call progress_update.
// Skips meta-tools (load_skill, enable_tool, progress_update) and
// doesn't overwrite LLM-authored entries.
autoRecordToolCalls(conversationId, result.responses());
return output.build();
}
// ==================== B2: Pin skill constraints ====================
/**
* Extract the {@code toolName} argument of every {@code enable_tool} call in
* this batch. Like {@link #extractLoadedSkillNames}, an unknown name is
* harmless: the reasoning-node split only activates names that resolve to an
* extension-tier tool actually in the agent's set.
* For each loaded skill, extract the manifest's {@code constraints} list
* and write them as pinned entries in the ProgressLedger. Pinned entries
* live in {@code nonHistoryPrefix} (never trimmed) and are never
* overwritten by the LLM's {@code progress_update} tool.
*
* <p>Failures are swallowed a missing manifest or a ledger write error
* must never abort the tool execution batch.
*/
private void pinSkillConstraints(String conversationId, Set<String> skillNames) {
if (progressLedgerService == null || skillRuntimeService == null
|| conversationId == null || conversationId.isBlank()) {
return;
}
for (String skillName : skillNames) {
try {
vip.mate.skill.runtime.model.ResolvedSkill skill = skillRuntimeService.findActiveSkill(skillName);
if (skill == null || skill.getManifest() == null) {
continue;
}
List<String> constraints = skill.getManifest().getConstraints();
if (constraints == null || constraints.isEmpty()) {
continue;
}
// Clear old pinned entries for this skill first (handles re-load after update).
String keyPrefix = "pin_" + skillName + "_";
progressLedgerService.clearPinnedByPrefix(conversationId, keyPrefix);
// Write each constraint as a pinned entry.
for (int i = 0; i < constraints.size(); i++) {
String constraint = constraints.get(i);
if (constraint == null || constraint.isBlank()) {
continue;
}
String key = keyPrefix + i;
progressLedgerService.upsertPinned(conversationId, key,
"🔒 " + skillName + ": " + truncate(constraint, 100), constraint);
}
log.info("[ActionNode] Pinned {} constraint(s) from skill '{}' for conv {}",
constraints.size(), skillName, conversationId);
} catch (Exception e) {
log.warn("[ActionNode] Failed to pin constraints for skill '{}': {}",
skillName, e.getMessage());
}
}
}
// ==================== B5: Auto-record tool calls ====================
/**
* Auto-record each successful tool call as a ledger entry with key
* {@code auto_<toolName>}. Bounded to {@link ProgressLedgerService#MAX_AUTO_RECORDED}
* most recent entries. Skips meta-tools and doesn't overwrite LLM entries.
*
* <p>Key uniqueness: for MCP tools the FULL prefixed name
* ({@code mcp_<serverId>_<slug>_<hash6>}) is used as the key suffix to
* avoid collisions between servers that expose tools with the same slug.
* The display label uses the simplified slug for readability.
*/
private void autoRecordToolCalls(String conversationId,
List<ToolResponseMessage.ToolResponse> responses) {
if (progressLedgerService == null || conversationId == null
|| conversationId.isBlank() || responses == null || responses.isEmpty()) {
return;
}
// Collect valid entries first, then persist in a single batch to avoid
// N separate lock+load+save cycles when the LLM calls tools in parallel.
List<vip.mate.agent.progress.ProgressLedgerService.AutoRecordEntry> batch = new java.util.ArrayList<>();
for (ToolResponseMessage.ToolResponse resp : responses) {
String toolName = resp.name();
if (toolName == null || toolName.isBlank() || AUTO_RECORD_SKIP.contains(toolName)) {
continue;
}
// Use the full tool name as the key (unique across MCP servers),
// but the simplified slug as the display label (readable).
String displayName = simplifyToolName(toolName);
String summary = resp.responseData();
if (summary != null && summary.length() > 120) {
summary = summary.substring(0, 120) + "";
}
batch.add(new vip.mate.agent.progress.ProgressLedgerService.AutoRecordEntry(
toolName, displayName, summary));
}
if (batch.isEmpty()) {
return;
}
try {
progressLedgerService.upsertAutoRecordedBatch(conversationId, batch);
} catch (Exception e) {
log.debug("[ActionNode] Batch auto-record failed for {} tools: {}",
batch.size(), e.getMessage());
}
}
/**
* Simplify an MCP tool name ({@code mcp_<serverId>_<slug>_<hash6>})
* to just the slug for ledger readability. Non-MCP names pass through.
*/
private static String simplifyToolName(String name) {
if (name == null) return "unknown";
if (!name.startsWith("mcp_")) return name;
// mcp_<serverId>_<slug>_<hash6> <slug>
int firstSep = name.indexOf('_', 4);
int lastSep = name.lastIndexOf('_');
if (firstSep > 0 && lastSep > firstSep) {
String slug = name.substring(firstSep + 1, lastSep);
return slug.isEmpty() ? name : slug;
}
return name;
}
private static String truncate(String s, int max) {
if (s == null) return "";
return s.length() > max ? s.substring(0, max) + "" : s;
}
// ==================== Existing helpers ====================
static Set<String> extractEnabledToolNames(List<AssistantMessage.ToolCall> toolCalls) {
if (toolCalls == null || toolCalls.isEmpty()) {
return Set.of();
@ -192,12 +331,6 @@ public class ActionNode implements NodeAction {
return names;
}
/**
* Extract the {@code skillName} argument of every {@code load_skill} call in
* this batch. The names are used only to bias catalog ordering, so an
* unparseable or unknown name is harmless (it simply never matches a
* visible skill) failures are swallowed rather than aborting the batch.
*/
static Set<String> extractLoadedSkillNames(List<AssistantMessage.ToolCall> toolCalls) {
if (toolCalls == null || toolCalls.isEmpty()) {
return Set.of();
@ -215,11 +348,6 @@ public class ActionNode implements NodeAction {
return names;
}
/**
* Read the first present, non-null string value among {@code keys} from a
* tool-call arguments JSON object. Returns null on malformed JSON or when
* none of the keys are present.
*/
private static String parseStringArg(String argumentsJson, String... keys) {
if (argumentsJson == null || argumentsJson.isBlank()) {
return null;

View File

@ -3,8 +3,10 @@ package vip.mate.agent.graph.node;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.graph.guard.ToolLoopGuard;
import vip.mate.agent.graph.observation.ObservationProcessor;
import vip.mate.agent.graph.state.MateClawStateAccessor;
@ -44,6 +46,21 @@ public class ObservationNode implements NodeAction {
/** Per-run cap on iteration refunds — keeps a load-skill-only model from looping forever. */
private static final int MAX_ITERATION_REFUNDS_PER_RUN = 3;
/**
* File-mutation tools whose first successful call this run triggers the
* one-shot verification reminder nudging the model to verify the change
* (run tests / re-read the file) before declaring the task complete.
* Shell/code/SQL tools are excluded: their mutating nature can't be
* determined statically, and a false reminder is worse than none.
*/
private static final java.util.Set<String> FILE_MUTATION_TOOLS =
java.util.Set.of("write_file", "edit_file");
private static final String VERIFICATION_REMINDER =
"\n\n[✅ 验证提醒] 本轮修改了文件。在给出最终回答前,请先验证改动是否生效" +
"(运行相关测试 / 构建命令,或重读文件确认关键内容);" +
"若无法验证,请在回答中明确说明「未经验证」及原因,不要声称已确认。";
public ObservationNode(ObservationProcessor observationProcessor) {
this(observationProcessor, null);
}
@ -99,6 +116,30 @@ public class ObservationNode implements NodeAction {
// 合并为单条观察记录
String combinedObservation = String.join("\n---\n", processedObservations);
// Tool-call loop guard: signature-level repetition detection across the
// run (identical-arg failures / per-tool failures / idempotent
// no-progress). Warnings are appended so the model can self-correct on
// the next reasoning turn; crossing a halt threshold routes to the
// graceful wrap-up via the existing ERROR path.
List<AssistantMessage.ToolCall> toolCalls =
state.<List<AssistantMessage.ToolCall>>value(TOOL_CALLS).orElse(List.of());
ToolLoopGuard.Evaluation loopGuard = ToolLoopGuard.evaluate(
accessor.toolLoopStats(), toolCalls, toolResults);
for (String warning : loopGuard.warnings()) {
combinedObservation += "\n\n" + warning;
log.info("[ObservationNode] Loop-guard warning injected: {}", warning);
}
// One-shot post-mutation verification reminder: the first successful
// file mutation this run asks the model to verify before wrapping up.
boolean injectVerificationReminder = !accessor.mutationReminderInjected()
&& toolResults.stream().anyMatch(tr -> FILE_MUTATION_TOOLS.contains(tr.name())
&& !ToolLoopGuard.isFailure(tr.responseData()));
if (injectVerificationReminder) {
combinedObservation += VERIFICATION_REMINDER;
log.info("[ObservationNode] Post-mutation verification reminder injected");
}
// Budget Pressure Warning接近上限时注入警告到工具结果中
// LLM 下一轮 reasoning 时能看到从而主动收束而非被硬性截断
if (maxIterations > 0) {
@ -146,26 +187,48 @@ public class ObservationNode implements NodeAction {
.iterationCount(nextIteration)
.put(OBSERVATION_HISTORY, updatedHistory)
.shouldSummarize(shouldSummarize)
.toolCallCount(newToolCallCount);
.toolCallCount(newToolCallCount)
.toolLoopStats(loopGuard.stats());
if (refundIteration) {
builder.iterationRefundCount(refundCount + 1);
}
if (injectVerificationReminder) {
builder.mutationReminderInjected(true);
}
// Close out the iteration we just observed. We use currentIteration
// (not nextIteration) so the index pairs with whatever
// iteration_start the ReasoningNode emitted at the top of this turn.
// Char totals are best-effort: ObservationNode doesn't see the LLM
// delta stream directly, so 0/0 is acceptable for now consumers
// that care fall back to summing the deltas themselves.
List<GraphEventPublisher.GraphEvent> events = new ArrayList<>();
if (streamTracker == null || streamTracker.isIterationEventsEnabled()) {
builder.events(List.of(
GraphEventPublisher.iterationEnd(currentIteration, "parent", null, 0, 0)));
events.add(GraphEventPublisher.iterationEnd(currentIteration, "parent", null, 0, 0));
}
// Surface loop-guard interventions to the user: the observation-text
// injection above is LLM-only, so mirror each warning (and a halt) as
// a "warning" graph event the accumulator persists it under
// metadata.warnings and rebroadcasts it live on SSE.
for (String warning : loopGuard.warnings()) {
events.add(GraphEventPublisher.warning(warning, "loop_guard"));
}
if (loopGuard.shouldHalt() && !duplicateObservation) {
events.add(GraphEventPublisher.warning(
"[⛔ 循环熔断] " + loopGuard.haltReason() + ",已提前收尾。", "loop_guard"));
}
if (!events.isEmpty()) {
builder.events(events);
}
// 重复观察时标记错误 ObservationDispatcher 路由到 limitExceededNode
if (duplicateObservation) {
builder.put(ERROR, "连续 3 次工具调用返回相同结果,已强制终止循环");
} else if (loopGuard.shouldHalt()) {
log.warn("[ObservationNode] Loop-guard halt: {}", loopGuard.haltReason());
builder.put(ERROR, loopGuard.haltReason());
}
return builder.build();

View File

@ -376,6 +376,20 @@ public class ReasoningNode implements NodeAction {
this.autoDemotedTools = autoDemotedTools == null ? Set.of() : autoDemotedTools;
}
/**
* C4: per-conversation registry of environment-change notifications.
* When non-null, each reasoning turn drains pending notifications and
* injects them as a single {@code SystemMessage} so the LLM sees that
* an MCP server disconnected or a skill was updated mid-turn. Null in
* tests / legacy paths injection is simply skipped.
*/
private vip.mate.agent.runtime.RunningConversationRegistry runningConversationRegistry;
public void setRunningConversationRegistry(
vip.mate.agent.runtime.RunningConversationRegistry runningConversationRegistry) {
this.runningConversationRegistry = runningConversationRegistry;
}
/** Floor for the window-aware output clamp — an answer needs at least this much room. */
private static final int MIN_CLAMPED_OUTPUT_TOKENS = 512;
@ -684,15 +698,18 @@ public class ReasoningNode implements NodeAction {
List<Message> nonHistoryPrefix = buildNonHistoryPrefix(systemPrompt, workspaceBasePath, agentIdStr, userMsg,
accessor.chatOrigin(), runtimeModelName, runtimeProviderId);
// Append the runtime-rendered skill catalog as a SEPARATE SystemMessage
// right after the skeleton system prompt. Keeping it out of the baked
// prompt keeps the stable prefix's prompt-cache hash intact, while
// re-rendering each turn lets skills loaded this run (load_skill) pin
// to the top of the catalog. Reused verbatim by the PTL retry branch.
// Append the skill catalog as a SEPARATE SystemMessage right after the
// skeleton system prompt. Rendered with an empty loadedThisRun set so
// the catalog content stays stable across turns this lets the
// system+catalog SystemMessage pair be served from the prompt cache
// (Anthropic SYSTEM_AND_TOOLS / OpenAI prefix cache). The per-turn
// "skills loaded this run" hint is injected separately as a volatile
// suffix (after RuntimeContext) so it never invalidates the cached
// prefix. Reused verbatim by the PTL retry branch.
if (skillCatalogRenderer != null) {
String skillCatalog = skillCatalogRenderer.render(accessor.loadedSkills());
if (skillCatalog != null && !skillCatalog.isBlank()) {
nonHistoryPrefix.add(1, new SystemMessage(skillCatalog));
String staticCatalog = skillCatalogRenderer.render(java.util.Set.of());
if (staticCatalog != null && !staticCatalog.isBlank()) {
nonHistoryPrefix.add(1, new SystemMessage(staticCatalog));
}
}
@ -732,6 +749,44 @@ public class ReasoningNode implements NodeAction {
}
}
// C4: drain any environment-change notifications that landed while
// this conversation was running (MCP disconnect, skill update, etc.)
// and inject them as a one-shot SystemMessage. Each notification is
// delivered at most once drain() empties the queue. Skipped when
// the registry is absent (tests / legacy paths) or the conversation
// has no pending notifications.
if (runningConversationRegistry != null && conversationId != null && !conversationId.isBlank()) {
try {
List<vip.mate.agent.runtime.EnvironmentNotification> notes =
runningConversationRegistry.drain(conversationId);
if (!notes.isEmpty()) {
String block = renderEnvironmentNotifications(notes);
if (block != null) {
nonHistoryPrefix.add(new SystemMessage(block));
log.info("[ReasoningNode] Injected {} environment notification(s) for conv {}",
notes.size(), conversationId);
}
}
} catch (Exception e) {
log.warn("[ReasoningNode] Failed to drain environment notifications for {}: {}",
conversationId, e.getMessage());
}
}
// Per-turn "skills loaded this run" hint kept out of the stable
// catalog segment (which is rendered with an empty loadedThisRun set
// so it stays prompt-cache-friendly). Injected here as a volatile
// suffix so the model still sees which skills it already pulled in
// via load_skill this run, without invalidating the cached
// system+catalog SystemMessage prefix.
java.util.Set<String> loadedThisRun = accessor.loadedSkills();
if (loadedThisRun != null && !loadedThisRun.isEmpty()) {
String hint = renderLoadedSkillsHint(loadedThisRun);
if (hint != null) {
nonHistoryPrefix.add(new SystemMessage(hint));
}
}
if (conversationWindowManager != null) {
// Age-based compaction first: drop the body of tool responses
// older than the K most recent into a one-line placeholder that
@ -1154,6 +1209,53 @@ public class ReasoningNode implements NodeAction {
return out;
}
/**
* C4: render drained environment notifications into a single markdown
* block suitable for injection as a {@code SystemMessage} in
* {@code nonHistoryPrefix}. Returns {@code null} for an empty list so the
* caller can skip injection entirely (no "(empty)" noise).
*/
// Package-private so black-box tests in vip.mate.agent.graph.node can
// exercise the real production rendering without duplicating the format.
static String renderEnvironmentNotifications(
List<vip.mate.agent.runtime.EnvironmentNotification> notes) {
if (notes == null || notes.isEmpty()) {
return null;
}
StringBuilder sb = new StringBuilder(128);
sb.append("## 📢 环境变更通知(本轮新增,请立即据此调整计划)\n\n");
for (vip.mate.agent.runtime.EnvironmentNotification n : notes) {
sb.append("- ").append(n.message()).append('\n');
}
sb.append("\n以上通知由 Java 运行时检测并注入,权威可信。")
.append("如果通知涉及你正在使用的工具/skill请立即调整后续步骤")
.append("如果与当前任务无关,可忽略。");
return sb.toString();
}
/**
* Render the per-turn "skills loaded this run" hint as a short
* SystemMessage. Kept out of the stable skill-catalog segment (which is
* rendered with an empty loadedThisRun set for prompt-cache stability)
* so the model still knows which skills it already pulled in via
* load_skill without invalidating the cached system+catalog prefix.
* Returns {@code null} for an empty set so the caller can skip
* injection entirely.
*/
// Package-private so tests can exercise the format without duplicating it.
static String renderLoadedSkillsHint(java.util.Set<String> loadedThisRun) {
if (loadedThisRun == null || loadedThisRun.isEmpty()) {
return null;
}
StringBuilder sb = new StringBuilder(96);
sb.append("Skills already loaded this run (available in context, do not re-load): ");
sb.append(String.join(", ", loadedThisRun.stream()
.map(n -> "`" + n + "`")
.toList()));
sb.append('.');
return sb.toString();
}
/**
* Build the part of the Prompt that does not depend on history messages:
* system prompt, workspace runtime context, and (when wiring permits) the

View File

@ -253,6 +253,21 @@ public final class MateClawStateAccessor {
return state.<Set<String>>value(ENABLED_EXTENSION_TOOLS).orElse(Set.of());
}
// ===== Tool-call loop guard =====
/**
* Loop-guard counters accumulated so far this run. Empty at run start.
*/
@SuppressWarnings("unchecked")
public java.util.Map<String, Object> toolLoopStats() {
return state.<java.util.Map<String, Object>>value(TOOL_LOOP_STATS).orElse(java.util.Map.of());
}
/** Whether the one-shot post-mutation verification reminder was already injected this run. */
public boolean mutationReminderInjected() {
return state.value(MUTATION_REMINDER_INJECTED, false);
}
// ===== Token Usage =====
public int promptTokens() {
@ -528,6 +543,15 @@ public final class MateClawStateAccessor {
return put(ENABLED_EXTENSION_TOOLS, names);
}
// ---- Tool-call loop guard ----
public OutputBuilder toolLoopStats(java.util.Map<String, Object> stats) {
return put(TOOL_LOOP_STATS, stats);
}
public OutputBuilder mutationReminderInjected(boolean injected) {
return put(MUTATION_REMINDER_INJECTED, injected);
}
// ---- Token Usage ----
/** 将本次 LLM 调用的 usage 累加到 state 已有值上 */

View File

@ -302,4 +302,29 @@ public final class MateClawStateKeys {
* {@link #LOADED_SKILLS}).
*/
public static final String ENABLED_EXTENSION_TOOLS = "enabled_extension_tools";
// ===== Tool-call loop guard (REPLACE strategy) =====
/**
* Per-run counters for the tool-call loop guard: repeated identical-argument
* failures, per-tool failure totals, and consecutive no-progress results
* from idempotent read-only tools. Stored as a {@code Map<String, Object>}
* keyed by detector-prefixed signatures; ObservationNode reads the prior
* map and writes back the updated one each observation round
* (read-merge-write under REPLACE). Implicitly empty at run start, so the
* counters reset naturally between graph runs.
* <p>
* MUST be registered in both KeyStrategyFactory blocks (see
* {@link #LOADED_SKILLS}).
*/
public static final String TOOL_LOOP_STATS = "tool_loop_stats";
/**
* True once the one-shot post-mutation verification reminder has been
* injected into an observation this run. The reminder asks the model to
* verify a successful file mutation (run tests / re-read the file) before
* declaring the task complete; injecting it at most once per run keeps
* multi-file tasks from being spammed. REPLACE strategy.
*/
public static final String MUTATION_REMINDER_INJECTED = "mutation_reminder_injected";
}

View File

@ -16,41 +16,82 @@ import java.util.Optional;
* blocked) and stays short on purpose: the agent reads it on every turn, so
* spending more than ~200 tokens on it would defeat the very context
* pressure this ledger exists to relieve.
*
* <p>Three entry classes coexist:
* <ul>
* <li><b>Regular entries</b> LLM-controlled via the {@code progress_update}
* tool. The model registers steps, advances their status, and adds
* notes. These are what {@link #mostRecentUpdate()} and the stale
* reminder consider when judging whether the ledger is maintained.</li>
* <li><b>Pinned entries</b> Java-controlled, written by ActionNode when
* {@code load_skill} is called (B2). They carry the skill's structured
* constraints (from {@code SkillManifest.constraints}) and survive
* context compression because they live in {@code nonHistoryPrefix}.
* The LLM's {@code progress_update} tool never touches them.</li>
* <li><b>Auto-recorded entries</b> Java-controlled, written by ActionNode
* after a successful tool call (B5). They use the
* {@link #AUTO_RECORDED_PREFIX} on their key so the renderer can group
* them separately. They don't affect staleness calculation.</li>
* </ul>
*/
public final class ProgressLedger {
/** Hard cap on the snapshot's note suffix so a rambling note can't bloat every turn. */
private static final int NOTE_PREVIEW_CHARS = 120;
/**
* Key prefix for entries auto-recorded by ActionNode after successful
* tool calls (B5). Lets the renderer group them into a separate
* "auto-recorded" section and exclude them from staleness calculation.
*/
public static final String AUTO_RECORDED_PREFIX = "auto_";
private final Map<String, ProgressEntry> entries;
private final Map<String, ProgressEntry> pinned;
public ProgressLedger(Map<String, ProgressEntry> entries) {
this(entries, null);
}
public ProgressLedger(Map<String, ProgressEntry> entries, Map<String, ProgressEntry> pinned) {
this.entries = entries != null ? entries : new LinkedHashMap<>();
this.pinned = pinned != null ? pinned : new LinkedHashMap<>();
}
public static ProgressLedger empty() {
return new ProgressLedger(new LinkedHashMap<>());
return new ProgressLedger(new LinkedHashMap<>(), new LinkedHashMap<>());
}
public boolean isEmpty() {
return entries.isEmpty();
return entries.isEmpty() && pinned.isEmpty();
}
public int size() {
return entries.size();
return entries.size() + pinned.size();
}
public Map<String, ProgressEntry> asMap() {
return entries;
}
/** @return the pinned (Java-controlled) entries, never null. */
public Map<String, ProgressEntry> pinnedEntries() {
return pinned;
}
/**
* @return the most recent {@code updatedAt} across all entries, or empty
* when the ledger is empty / all entries lack a timestamp.
* @return the most recent {@code updatedAt} across regular (non-auto,
* non-pinned) entries, or empty when none have a timestamp.
* Only regular entries count because pinned entries are static
* constraints and auto-recorded entries are Java-side neither
* indicates the LLM is maintaining the ledger.
*/
public Optional<Instant> mostRecentUpdate() {
Instant max = null;
for (ProgressEntry e : entries.values()) {
if (e.getKey() != null && e.getKey().startsWith(AUTO_RECORDED_PREFIX)) {
continue;
}
Instant t = e.getUpdatedAt();
if (t != null && (max == null || t.isAfter(max))) {
max = t;
@ -59,45 +100,21 @@ public final class ProgressLedger {
return Optional.ofNullable(max);
}
/** Iteration before which no stale reminder is ever issued — too early to judge. */
private static final int STALE_WARMUP_ITERATIONS = 10;
/** Iteration past which an empty ledger triggers a "you should register steps" reminder. */
private static final int EMPTY_LEDGER_NUDGE_ITERATIONS = 15;
/** Wall-clock gap that flips a non-empty ledger from "fresh" to "stale". */
private static final long STALE_GAP_SECONDS = 90;
/**
* Build a stale-reminder string for injection into the model's context
* when the ledger appears to be falling behind the actual reasoning
* progress. Returns {@code null} when the ledger is being maintained
* normally so the caller can skip the injection.
*
* <p>Trigger heuristics derived from round-4 of the LLM-review smoke
* test, where the model stopped calling {@code progress_update} after
* the first 30s and silently fell out of the ledger discipline:
*
* <ul>
* <li><strong>Warm-up</strong>: {@code currentIteration < 10} never
* remind, the model is still setting up the task.</li>
* <li><strong>Empty ledger</strong>: {@code currentIteration 15} and
* no entries at all likely a multi-step task being executed
* without any ledger discipline.</li>
* <li><strong>Stale updates</strong>: ledger has entries, but the
* most recent {@code updatedAt} is &gt; 90 s ago ledger is no
* longer tracking the real work.</li>
* </ul>
*
* @param currentIteration the agent's current ReAct iteration count
* @param now the reference instant for staleness ("now");
* injected for testability
* Iteration before which no stale reminder is ever issued too early to judge.
*/
private static final int STALE_WARMUP_ITERATIONS = 3;
private static final int EMPTY_LEDGER_NUDGE_ITERATIONS = 5;
private static final long STALE_GAP_SECONDS = 45;
public String renderStaleReminder(int currentIteration, Instant now) {
if (currentIteration < STALE_WARMUP_ITERATIONS) {
return null;
}
if (entries.isEmpty()) {
// Only regular (non-auto) entries indicate LLM engagement with the ledger.
boolean hasRegularEntries = entries.values().stream()
.anyMatch(e -> e.getKey() == null || !e.getKey().startsWith(AUTO_RECORDED_PREFIX));
if (!hasRegularEntries) {
if (currentIteration < EMPTY_LEDGER_NUDGE_ITERATIONS) {
return null;
}
@ -114,15 +131,17 @@ public final class ProgressLedger {
if (gap < STALE_GAP_SECONDS) {
return null;
}
int done = (int) entries.values().stream()
.filter(e -> e.getStatus() == ProgressStatus.DONE).count();
int inProgress = (int) entries.values().stream()
.filter(e -> e.getStatus() == ProgressStatus.IN_PROGRESS).count();
long done = entries.values().stream()
.filter(e -> isRegular(e) && e.getStatus() == ProgressStatus.DONE).count();
long inProgress = entries.values().stream()
.filter(e -> isRegular(e) && e.getStatus() == ProgressStatus.IN_PROGRESS).count();
long pending = entries.values().stream()
.filter(e -> isRegular(e) && e.getStatus() == ProgressStatus.PENDING).count();
return "## ⚠️ 进度账本已 " + gap + " 秒未更新\n\n"
+ "你已运行 " + currentIteration + " 轮,但 progress_update 已经 "
+ gap + " 秒(约 " + (gap / 60) + " 分钟)没被调用过。\n"
+ "当前账本:" + done + " done / " + inProgress + " in_progress / "
+ (entries.size() - done - inProgress) + " pending。\n\n"
+ pending + " pending。\n\n"
+ "**立即做以下一件事**(不要再 read_file 或 browser_use先更新账本\n"
+ "- 把已经完成的子步骤切到 `done`(如果你能看到工作区文件已生成)\n"
+ "- 把正在做的步骤切到 `in_progress`\n"
@ -130,22 +149,65 @@ public final class ProgressLedger {
+ "不维护账本会导致重复工作 / 漏做项目 / 撞迭代上限。";
}
/** True when the entry is regular (not auto-recorded, not pinned). */
private boolean isRegular(ProgressEntry e) {
return e.getKey() == null || !e.getKey().startsWith(AUTO_RECORDED_PREFIX);
}
/**
* @return a compact, model-readable progress snapshot, or {@code null}
* when the ledger is empty so the caller can skip injection
* entirely (no "(empty)" placeholder noise).
*/
public String renderSnapshot() {
if (entries.isEmpty()) {
if (isEmpty()) {
return null;
}
List<ProgressEntry> done = bucket(ProgressStatus.DONE);
List<ProgressEntry> inProgress = bucket(ProgressStatus.IN_PROGRESS);
List<ProgressEntry> pending = bucket(ProgressStatus.PENDING);
List<ProgressEntry> blocked = bucket(ProgressStatus.BLOCKED);
StringBuilder sb = new StringBuilder(256);
sb.append("## 当前任务进度(执行参考,权威记录)\n\n");
// Pinned constraints (highest priority always visible)
if (!pinned.isEmpty()) {
sb.append("🔒 固定约束(来自 skill全程不可忽略:\n");
for (ProgressEntry e : pinned.values()) {
String label = e.getLabel() != null && !e.getLabel().isBlank()
? e.getLabel() : e.getKey();
sb.append("- ").append(label);
String note = e.getNote();
if (note != null && !note.isBlank()) {
String trimmed = note.length() > NOTE_PREVIEW_CHARS
? note.substring(0, NOTE_PREVIEW_CHARS) + ""
: note;
sb.append("").append(trimmed);
}
sb.append('\n');
}
sb.append('\n');
}
// Auto-recorded tool completions (B5)
List<ProgressEntry> autoRecorded = new ArrayList<>();
for (ProgressEntry e : entries.values()) {
if (e.getKey() != null && e.getKey().startsWith(AUTO_RECORDED_PREFIX)) {
autoRecorded.add(e);
}
}
if (!autoRecorded.isEmpty()) {
sb.append("🔧 自动记录(工具调用完成):\n");
for (ProgressEntry e : autoRecorded) {
String label = e.getLabel() != null && !e.getLabel().isBlank()
? e.getLabel() : e.getKey();
sb.append("- ").append(label).append("").append(e.getStatus());
sb.append('\n');
}
sb.append('\n');
}
// Regular entries grouped by status
List<ProgressEntry> done = bucketRegular(ProgressStatus.DONE);
List<ProgressEntry> inProgress = bucketRegular(ProgressStatus.IN_PROGRESS);
List<ProgressEntry> pending = bucketRegular(ProgressStatus.PENDING);
List<ProgressEntry> blocked = bucketRegular(ProgressStatus.BLOCKED);
appendBucket(sb, "✅ 已完成", done);
appendBucket(sb, "🔄 进行中", inProgress);
appendBucket(sb, "⏳ 待办", pending);
@ -155,10 +217,10 @@ public final class ProgressLedger {
return sb.toString();
}
private List<ProgressEntry> bucket(ProgressStatus status) {
private List<ProgressEntry> bucketRegular(ProgressStatus status) {
List<ProgressEntry> out = new ArrayList<>();
for (ProgressEntry e : entries.values()) {
if (e.getStatus() == status) {
if (isRegular(e) && e.getStatus() == status) {
out.add(e);
}
}

View File

@ -12,6 +12,7 @@ import vip.mate.workspace.conversation.repository.ConversationMapper;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;
@ -24,53 +25,65 @@ import java.util.concurrent.locks.ReentrantLock;
* Callers above it work with {@link ProgressLedger} (immutable view) or plain
* {@code Map<String, ProgressEntry>}.
*
* <p>Three entry classes share the JSON column:
* <ul>
* <li><b>Regular entries</b> ({@code entries} map) written by the LLM via
* {@code progress_update} tool through {@link #upsert}.</li>
* <li><b>Pinned entries</b> ({@code pinned} map) written by Java via
* {@link #upsertPinned} when {@code load_skill} extracts structured
* constraints. Never touched by the LLM's {@code progress_update}.</li>
* <li><b>Auto-recorded entries</b> stored in the {@code entries} map with
* a key prefixed by {@link ProgressLedger#AUTO_RECORDED_PREFIX}, written
* by Java via {@link #upsertAutoRecorded} after successful tool calls.
* Bounded to the most recent {@link #MAX_AUTO_RECORDED} entries.</li>
* </ul>
*
* <p><b>JSON format</b> (backward-compatible): the new wrapper shape is
* {@code {"entries": {...}, "pinned": {...}}}. Old conversations stored as
* a flat map {@code {"step1": {...}}} are auto-migrated on first load
* the flat map is treated as {@code entries} with an empty {@code pinned}.
*
* <p>Failure mode: a malformed JSON value never throws back at the caller
* the runtime would rather render no snapshot than crash the reasoning loop
* over a corrupted ledger column. Parse failures are logged at warn level so
* the operator notices on a long-running deployment.
* over a corrupted ledger column.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ProgressLedgerService {
/** Map<stepKey, ProgressEntry> — LinkedHashMap preserves insertion order in the rendered snapshot. */
private static final TypeReference<LinkedHashMap<String, ProgressEntry>> LEDGER_TYPE =
/** Map<stepKey, ProgressEntry> — LinkedHashMap preserves insertion order. */
private static final TypeReference<LinkedHashMap<String, ProgressEntry>> ENTRIES_TYPE =
new TypeReference<>() {};
/** Wrapper type for the new JSON format. */
private static final TypeReference<LedgerWrapper> WRAPPER_TYPE = new TypeReference<>() {};
/** Maximum auto-recorded entries kept per conversation (risk mitigation). */
public static final int MAX_AUTO_RECORDED = 5;
/**
* Per-conversation lock for the load-mutate-save sequence inside
* {@link #upsert}. Without this guard, a single agent turn that issues
* N parallel {@code progress_update} tool calls (observed: 12 calls in
* one batch when the model pre-registered every step at task start)
* collapses to last-writer-wins, losing every entry but one defeating
* the whole point of the ledger. Different conversations stay
* uncontended; only intra-conversation writes serialise.
*
* <p>Must be a {@link ReentrantLock}, not an intrinsic {@code synchronized}
* monitor. Tool calls execute on virtual threads, and the critical section
* spans blocking JDBC I/O (load + persist). A virtual thread that blocks
* whether on the DB call or while waiting to enter the lock pins its
* carrier when the lock is an intrinsic monitor. A turn that fires dozens
* of parallel {@code progress_update} calls on the same conversation then
* pins every carrier in the pool at once: the holder cannot be rescheduled
* to release its connection and exit, JDBC connections are held past the
* leak-detection threshold, and the whole server stops servicing requests.
* {@code ReentrantLock} parks via {@code LockSupport}, which unmounts the
* virtual thread and frees the carrier, so contention costs a park instead
* of a pinned platform thread.
*
* <p>Entries are computed on demand and never explicitly removed; even
* with thousands of long-running conversations the map stays bounded by
* the active conversation set, and any leak is one lock per conversation
* id small enough to ignore relative to the rest of the per-conv state
* already held in memory.
* {@link #upsert}. See class Javadoc in ProgressLedger for the
* virtual-thread pinning rationale.
*/
private final ConcurrentHashMap<String, ReentrantLock> upsertLocks = new ConcurrentHashMap<>();
private final ConversationMapper conversationMapper;
private final ObjectMapper objectMapper;
/**
* Wrapper for the persisted JSON. Both fields default to empty maps
* so a partially-written JSON (e.g. only entries) still parses.
*/
public record LedgerWrapper(
LinkedHashMap<String, ProgressEntry> entries,
LinkedHashMap<String, ProgressEntry> pinned) {
public LedgerWrapper() {
this(new LinkedHashMap<>(), new LinkedHashMap<>());
}
}
/**
* @return the conversation's ledger, never null an empty map when the
* column is NULL or unparseable.
@ -82,12 +95,6 @@ public class ProgressLedgerService {
return parse(loadLedgerJson(conversationId));
}
/**
* Read the raw JSON column for one conversation, or {@code null} when
* the row or column is empty. Protected so concurrency tests can
* subclass and back the service with an in-memory map without having
* to mock the Mybatis-Plus wrapper internals.
*/
protected String loadLedgerJson(String conversationId) {
ConversationEntity row = conversationMapper.selectOne(
new LambdaQueryWrapper<ConversationEntity>()
@ -96,10 +103,6 @@ public class ProgressLedgerService {
return row != null ? row.getProgressLedger() : null;
}
/**
* Write the raw JSON column for one conversation. Protected for the
* same reason as {@link #loadLedgerJson}.
*/
protected void saveLedgerJson(String conversationId, String json) {
conversationMapper.update(null,
new LambdaUpdateWrapper<ConversationEntity>()
@ -108,7 +111,8 @@ public class ProgressLedgerService {
}
/**
* Upsert one entry on the ledger atomically (load mutate save).
* Upsert one regular entry on the ledger atomically (load mutate save).
* Never touches pinned entries.
*
* @return the updated ledger so callers can render a fresh snapshot
* without a second DB roundtrip.
@ -121,52 +125,224 @@ public class ProgressLedgerService {
if (key == null || key.isBlank()) {
throw new IllegalArgumentException("step key is required");
}
// Guard reserved prefixes the LLM must not overwrite Java-managed
// entries (auto-recorded tool calls or pinned skill constraints).
// Reject the write so the caller re-issues progress_update under a
// non-reserved key instead of clobbering a system-managed entry.
if (key.startsWith(ProgressLedger.AUTO_RECORDED_PREFIX) || key.startsWith("pin_")) {
throw new IllegalArgumentException(
"step key prefix '" + ProgressLedger.AUTO_RECORDED_PREFIX
+ "' / 'pin_' is reserved for system-managed entries; "
+ "use a different key like 'step_<name>'");
}
if (status == null) {
throw new IllegalArgumentException("status is required");
}
// Serialise the load-mutate-save sequence per conversation. Without
// this, two parallel @Tool calls on the same conversation race: both
// read the same starting state, each adds its own entry, and the
// last save() drops the other's entry. Observed in production: a
// 12-entry pre-registration collapsed to 8 because four sibling
// tool calls landed in the same window.
ReentrantLock lock = upsertLocks.computeIfAbsent(conversationId, k -> new ReentrantLock());
lock.lock();
try {
ProgressLedger ledger = load(conversationId);
Map<String, ProgressEntry> map = ledger.asMap();
LedgerWrapper wrapper = loadWrapper(conversationId);
Map<String, ProgressEntry> map = wrapper.entries;
ProgressEntry existing = map.get(key);
String effectiveLabel = (label != null && !label.isBlank())
? label
: (existing != null ? existing.getLabel() : key);
map.put(key, new ProgressEntry(key, effectiveLabel, status, note, Instant.now()));
persist(conversationId, map);
return new ProgressLedger(map);
persistWrapper(conversationId, wrapper);
return new ProgressLedger(map, wrapper.pinned);
} finally {
lock.unlock();
}
}
/**
* Upsert a pinned entry (Java-controlled, from skill constraints).
* The LLM's {@code progress_update} tool never touches pinned entries.
*
* @param conversationId target conversation
* @param key stable key, e.g. {@code pin_<skillName>_<index>}
* @param label human-readable constraint text
* @param note optional extra context
*/
public void upsertPinned(String conversationId, String key, String label, String note) {
if (conversationId == null || conversationId.isBlank()) {
throw new IllegalArgumentException("conversationId is required");
}
if (key == null || key.isBlank()) {
throw new IllegalArgumentException("pinned key is required");
}
ReentrantLock lock = upsertLocks.computeIfAbsent(conversationId, k -> new ReentrantLock());
lock.lock();
try {
LedgerWrapper wrapper = loadWrapper(conversationId);
wrapper.pinned.put(key, new ProgressEntry(key, label, ProgressStatus.PENDING, note, Instant.now()));
persistWrapper(conversationId, wrapper);
} finally {
lock.unlock();
}
}
/**
* Remove all pinned entries whose key starts with the given prefix.
* Used when a skill is unloaded or updated the old constraints
* should be cleared before re-injecting the new ones.
*/
public void clearPinnedByPrefix(String conversationId, String keyPrefix) {
if (conversationId == null || conversationId.isBlank() || keyPrefix == null) {
return;
}
ReentrantLock lock = upsertLocks.computeIfAbsent(conversationId, k -> new ReentrantLock());
lock.lock();
try {
LedgerWrapper wrapper = loadWrapper(conversationId);
wrapper.pinned.entrySet().removeIf(e -> e.getKey() != null && e.getKey().startsWith(keyPrefix));
persistWrapper(conversationId, wrapper);
} finally {
lock.unlock();
}
}
/**
* Auto-record a completed tool call as a ledger entry (B5). Uses the
* {@link ProgressLedger#AUTO_RECORDED_PREFIX} on the key so the renderer
* groups it separately. Bounds the total auto-recorded entries to
* {@link #MAX_AUTO_RECORDED} by evicting the oldest.
*
* <p>Does NOT overwrite an existing entry with the same key if the
* LLM already tracked this step via {@code progress_update}, the LLM's
* entry stays. This prevents Java from clobbering a richer LLM-authored
* note.
*
* @param conversationId target conversation
* @param toolName unique tool identifier used as the key suffix
* for MCP tools this should be the FULL prefixed
* name ({@code mcp_<serverId>_<slug>_<hash6>}) to
* avoid collisions between servers that have tools
* with the same slug.
* @param displayName human-readable label shown in the snapshot (e.g.
* the slug portion only). Falls back to
* {@code toolName} when null/blank.
* @param resultSummary short tool-result excerpt; truncated to 120 chars
*/
public void upsertAutoRecorded(String conversationId, String toolName, String displayName,
String resultSummary) {
if (conversationId == null || conversationId.isBlank() || toolName == null || toolName.isBlank()) {
return;
}
upsertAutoRecordedBatch(conversationId,
List.of(new AutoRecordEntry(toolName, displayName, resultSummary)));
}
/**
* Input tuple for batch auto-record: the unique tool name (key suffix),
* the readable display label, and the truncated result summary.
*/
public record AutoRecordEntry(String toolName, String displayName, String resultSummary) {}
/**
* Batch version of {@link #upsertAutoRecorded} processes multiple tool
* results in a single lock + load + mutate + save cycle. Use this when
* ActionNode receives a batch of parallel ToolResponses to avoid
* serializing N lock acquisitions.
*
* <p>Skips entries whose {@code toolName} is null/blank or whose key
* already exists in the ledger (LLM-authored entries are preserved).
* Bounds the total auto-recorded entries to {@link #MAX_AUTO_RECORDED}
* by evicting the oldest in bulk after inserting the new batch.
*/
public void upsertAutoRecordedBatch(String conversationId, List<AutoRecordEntry> entries) {
if (conversationId == null || conversationId.isBlank()
|| entries == null || entries.isEmpty()) {
return;
}
ReentrantLock lock = upsertLocks.computeIfAbsent(conversationId, k -> new ReentrantLock());
lock.lock();
try {
LedgerWrapper wrapper = loadWrapper(conversationId);
Map<String, ProgressEntry> map = wrapper.entries;
Instant now = Instant.now();
for (AutoRecordEntry e : entries) {
if (e == null || e.toolName() == null || e.toolName().isBlank()) {
continue;
}
String key = ProgressLedger.AUTO_RECORDED_PREFIX + e.toolName();
// Don't overwrite an LLM-authored entry (LLM wouldn't use the auto_ prefix).
if (map.containsKey(key)) {
continue;
}
String label = (e.displayName() != null && !e.displayName().isBlank())
? e.displayName() : e.toolName();
String note = e.resultSummary();
if (note != null && note.length() > 120) {
note = note.substring(0, 120) + "";
}
map.put(key, new ProgressEntry(key, label, ProgressStatus.DONE, note, now));
}
// Bound auto-recorded entries: evict oldest in bulk if over limit.
List<String> autoKeys = new java.util.ArrayList<>();
for (String k : map.keySet()) {
if (k != null && k.startsWith(ProgressLedger.AUTO_RECORDED_PREFIX)) {
autoKeys.add(k);
}
}
while (autoKeys.size() > MAX_AUTO_RECORDED && !autoKeys.isEmpty()) {
String oldest = autoKeys.remove(0);
map.remove(oldest);
}
persistWrapper(conversationId, wrapper);
} finally {
lock.unlock();
}
}
// ==================== Internal: load / parse / persist ====================
private ProgressLedger parse(String json) {
if (json == null || json.isBlank() || "{}".equals(json.trim())) {
return ProgressLedger.empty();
}
try {
LinkedHashMap<String, ProgressEntry> map = objectMapper.readValue(json, LEDGER_TYPE);
return new ProgressLedger(map);
LedgerWrapper wrapper = parseWrapper(json);
return new ProgressLedger(wrapper.entries, wrapper.pinned);
} catch (Exception e) {
log.warn("Failed to parse progress ledger JSON, treating as empty: {}", e.getMessage());
return ProgressLedger.empty();
}
}
private void persist(String conversationId, Map<String, ProgressEntry> map) {
/**
* Parse with backward compatibility: new format has {@code "entries"}
* and {@code "pinned"} keys; old format is a flat map treated as entries.
*/
private LedgerWrapper parseWrapper(String json) throws Exception {
// Peek: if the JSON contains '"entries"' it's the new wrapper format.
if (json.contains("\"entries\"")) {
return objectMapper.readValue(json, WRAPPER_TYPE);
}
// Old format: flat map migrate to wrapper with empty pinned.
LinkedHashMap<String, ProgressEntry> entries = objectMapper.readValue(json, ENTRIES_TYPE);
return new LedgerWrapper(entries, new LinkedHashMap<>());
}
private LedgerWrapper loadWrapper(String conversationId) {
String json = loadLedgerJson(conversationId);
if (json == null || json.isBlank() || "{}".equals(json.trim())) {
return new LedgerWrapper();
}
try {
String json = objectMapper.writeValueAsString(map);
return parseWrapper(json);
} catch (Exception e) {
log.warn("Failed to parse progress ledger JSON for {}, treating as empty: {}",
conversationId, e.getMessage());
return new LedgerWrapper();
}
}
private void persistWrapper(String conversationId, LedgerWrapper wrapper) {
try {
String json = objectMapper.writeValueAsString(wrapper);
saveLedgerJson(conversationId, json);
} catch (Exception e) {
// Surface to caller so the tool can return an error message to
// the LLM rather than silently dropping the update.
throw new IllegalStateException(
"Failed to persist progress ledger for " + conversationId + ": " + e.getMessage(), e);
}

View File

@ -0,0 +1,114 @@
package vip.mate.agent.runtime;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import vip.mate.skill.event.SkillRemovedEvent;
import vip.mate.skill.event.SkillUpdatedEvent;
import vip.mate.tool.mcp.event.McpConnectionLostEvent;
import vip.mate.tool.mcp.event.McpServerChangedEvent;
import vip.mate.tool.mcp.event.McpServerRemovedEvent;
import java.time.Instant;
/**
* Bridges MCP / skill environment events into the agent runtime by translating
* each event into an {@link EnvironmentNotification} and broadcasting it to
* every currently-running conversation via {@link RunningConversationRegistry}.
*
* <p>This is the Java-side half of "agent environment awareness": instead of
* expecting the LLM to notice that a tool disappeared (it won't the tool
* list is a static snapshot taken at turn start), Java detects the change
* here and injects a one-shot notification into the agent's next reasoning
* turn. The LLM only has to read and obey the notification; it does not have
* to probe or guess.
*
* <p><b>Broadcast vs. targeted:</b> we broadcast to all active conversations
* rather than filtering by "which agent has tools from this server". The
* filtering would require a DB lookup per event (agent_tool_binding rows),
* and the cost of a stray notification to an unaffected conversation is just
* one extra SystemMessage the LLM is told to ignore notifications about
* tools it isn't using (see the "Environment Change Notifications" section
* in the system prompt).
*
* <p>Coexists with the existing {@code @EventListener} methods in
* {@code AgentService} which call {@code refreshAllAgents()} those handle
* cache invalidation so the NEXT turn sees fresh state; this router handles
* in-flight notification so the CURRENT turn can adapt.
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class EnvironmentEventRouter {
private final RunningConversationRegistry registry;
@EventListener
public void onMcpServerChanged(McpServerChangedEvent event) {
broadcast("mcp-changed",
"🔧 MCP 工具列表已变更(原因: " + event.reason()
+ ")。请重新检查可用工具列表,避免调用已失效的工具名。"
+ "如果之前用过的工具现在不在列表里,说明它已被移除或重命名。");
}
@EventListener
public void onMcpConnectionLost(McpConnectionLostEvent event) {
broadcast("mcp-lost",
"⚠️ MCP 服务器连接丢失serverId=" + event.serverId()
+ ",原因: " + event.reason() + ")。"
+ "该服务器下所有工具(前缀 mcp_" + event.serverId()
+ "_暂不可用。请改用其他工具或向用户报告该能力暂时缺失。"
+ "不要反复重试同一工具名。");
}
@EventListener
public void onMcpServerRemoved(McpServerRemovedEvent event) {
broadcast("mcp-removed",
"❌ MCP 服务器已移除serverName=" + event.serverName()
+ "serverId=" + event.serverId() + ")。"
+ "其下所有工具已永久失效,不要再尝试调用前缀 mcp_" + event.serverId()
+ "_ 的任何工具。请改用其他途径完成任务。");
}
@EventListener
public void onSkillRemoved(SkillRemovedEvent event) {
broadcast("skill-removed",
"❌ Skill 已移除(" + event.skillName() + ")。"
+ "如果之前加载过该 skill其固定约束已失效不要再尝试 load_skill 加载它。"
+ "请基于剩余能力重新规划任务。");
}
@EventListener
public void onSkillUpdated(SkillUpdatedEvent event) {
String verb = switch (event.changeType() == null ? "update" : event.changeType()) {
case "enable" -> "已启用";
case "disable" -> "已禁用";
case "rescan" -> "安全扫描结果已更新";
default -> "已更新";
};
broadcast("skill-updated",
"🔄 Skill " + event.skillName() + " " + verb + ""
+ "如果之前加载过该 skill请重新调用 load_skill 刷新其约束;"
+ "旧约束可能不再适用,继续按旧约束执行可能导致错误。");
}
// ==================== Internal ====================
private void broadcast(String type, String message) {
try {
int active = registry.activeConversations().size();
if (active == 0) {
return; // no-one to notify skip the allocation
}
EnvironmentNotification n = new EnvironmentNotification(type, message, Instant.now());
registry.broadcast(n);
log.info("[EnvironmentEventRouter] Broadcasted {} to {} active conversation(s)", type, active);
} catch (Exception e) {
// Never let an event-routing failure bubble into the Spring event bus.
log.warn("[EnvironmentEventRouter] Failed to broadcast {}: {}", type, e.getMessage());
}
}
}

View File

@ -0,0 +1,27 @@
package vip.mate.agent.runtime;
import java.time.Instant;
/**
* A single environment-change notification destined for a running agent.
*
* <p>Produced by {@link EnvironmentEventRouter} when an MCP / skill event fires
* during an in-flight conversation. Consumed by {@code ReasoningNode} (C4) which
* drains the queue at the start of each reasoning turn and injects the
* accumulated notifications as a single {@code SystemMessage} so the LLM sees
* them alongside the progress ledger snapshot.
*
* <p>Notifications are ephemeral they live only for the duration of a running
* turn. If a conversation is not actively running when the event fires, the
* notification is dropped (the next turn will rebuild the agent with fresh
* state, so the LLM doesn't need a stale notification).
*
* @param type event category one of {@code mcp-changed}, {@code mcp-lost},
* {@code mcp-removed}, {@code skill-removed}, {@code skill-updated}
* @param message human-readable, LLM-facing description of the change and
* what the agent should do about it
* @param timestamp when the event was observed by Java
* @author MateClaw Team
*/
public record EnvironmentNotification(String type, String message, Instant timestamp) {
}

View File

@ -0,0 +1,227 @@
package vip.mate.agent.runtime;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ConcurrentMap;
/**
* Tracks which conversations are currently in-flight (a chat turn is actively
* running) and holds a bounded per-conversation queue of pending environment
* notifications.
*
* <p>This is the agent-side counterpart to {@code ChatStreamTracker} but
* whereas that tracker is SSE-pipeline-specific and lives in {@code channel.web},
* this registry covers ALL chat entry points (sync {@code chat}, streaming
* {@code chatStream}, {@code execute}, {@code chatWithReplay}) because it is
* wired into {@code AgentService.withLifecycleSync/Flux} which every path
* funnels through.
*
* <p><b>Lifecycle:</b>
* <ul>
* <li>{@link #register} is called when a turn starts (inside
* {@code withLifecycleSync} / {@code withLifecycleFlux}).</li>
* <li>{@link #unregister} is called when the turn ends (in {@code finally} /
* {@code doFinally}).</li>
* <li>Between turns the conversation is absent from the registry, so events
* fired between turns are silently dropped this is intentional: the
* agent cache is invalidated by the existing {@code @EventListener}
* methods in {@code AgentService}, so the next turn rebuilds with fresh
* tool/skill state.</li>
* </ul>
*
* <p><b>Queue bounds:</b> each conversation's notification queue is capped at
* {@link #MAX_NOTIFICATIONS_PER_CONVERSATION}. When full, the oldest entry is
* evicted. This prevents unbounded memory growth if events fire faster than
* the agent consumes them (e.g. a tight MCP reconnection loop).
*
* <p>All operations are non-blocking and thread-safe.
*
* @author MateClaw Team
*/
@Slf4j
@Component
public class RunningConversationRegistry {
/** Max pending notifications per conversation before oldest evicts. */
static final int MAX_NOTIFICATIONS_PER_CONVERSATION = 10;
private final ConcurrentMap<String, ConversationHandle> active = new ConcurrentHashMap<>();
/**
* Mark a conversation as actively running. Idempotent if already
* registered (e.g. concurrent sub-agent delegation into the same
* conversation), only refreshes {@code lastActiveAt}.
*/
public void register(String conversationId, Long agentId) {
if (conversationId == null || conversationId.isBlank()) {
return;
}
active.compute(conversationId, (k, existing) -> {
Instant now = Instant.now();
if (existing == null) {
return new ConversationHandle(agentId, now, now, new ConcurrentLinkedQueue<>());
}
existing.lastActiveAt = now;
return existing;
});
}
/**
* Mark a conversation as no longer running. Safe to call multiple times
* and on never-registered ids. Any pending notifications are discarded.
*/
public void unregister(String conversationId) {
if (conversationId == null || conversationId.isBlank()) {
return;
}
active.remove(conversationId);
}
/** @return true iff a turn is currently in-flight for this conversation. */
public boolean isActive(String conversationId) {
return conversationId != null && active.containsKey(conversationId);
}
/** @return a snapshot of all currently-running conversation ids. */
public Set<String> activeConversations() {
return Collections.unmodifiableSet(active.keySet());
}
/**
* Push a notification to a single conversation's queue. No-op if the
* conversation is not active (event fired between turns).
*/
public void enqueue(String conversationId, EnvironmentNotification notification) {
if (conversationId == null || notification == null) {
return;
}
ConversationHandle handle = active.get(conversationId);
if (handle == null) {
return;
}
ConcurrentLinkedQueue<EnvironmentNotification> q = handle.notifications;
while (q.size() >= MAX_NOTIFICATIONS_PER_CONVERSATION) {
q.poll(); // evict oldest
}
q.offer(notification);
}
/**
* Push a notification to ALL currently-active conversations. Used by
* {@link EnvironmentEventRouter} when an event is not conversation-scoped
* (e.g. an MCP server disconnect affects every agent that has tools from
* that server, and we don't have a cheap way to filter).
*/
public void broadcast(EnvironmentNotification notification) {
if (notification == null) {
return;
}
for (String convId : active.keySet()) {
enqueue(convId, notification);
}
}
/**
* Drain and return all pending notifications for a conversation. The
* queue is emptied by this call each notification is delivered at most
* once. Returns an empty list for inactive / unknown conversations.
*/
public List<EnvironmentNotification> drain(String conversationId) {
if (conversationId == null) {
return List.of();
}
ConversationHandle handle = active.get(conversationId);
if (handle == null) {
return List.of();
}
List<EnvironmentNotification> out = new ArrayList<>();
EnvironmentNotification n;
while ((n = handle.notifications.poll()) != null) {
out.add(n);
}
return out;
}
// ==================== Stale-handle cleanup ====================
/**
* Remove conversations whose {@code lastActiveAt} is older than
* {@code maxAge} ago. Defensive cleanup for the case where
* {@code unregister} was skipped due to an exception path that
* bypassed the {@code finally}/{@code doFinally} guards.
*
* <p>Safe to call concurrently with {@link #register} /
* {@link #unregister} uses {@link ConcurrentHashMap#entrySet()}
* iterator's weak consistency.
*
* @return the number of stale handles removed
*/
public int cleanupStale(Duration maxAge) {
if (maxAge == null || maxAge.isNegative() || maxAge.isZero()) {
return 0;
}
Instant cutoff = Instant.now().minus(maxAge);
int removed = 0;
for (var entry : active.entrySet()) {
ConversationHandle handle = entry.getValue();
if (handle != null && handle.lastActiveAt != null
&& handle.lastActiveAt.isBefore(cutoff)) {
// Use remove(key, value) to avoid removing a handle that was
// just refreshed by a concurrent register() call.
if (active.remove(entry.getKey(), handle)) {
removed++;
}
}
}
if (removed > 0) {
log.info("[RunningConversationRegistry] Cleaned up {} stale conversation handle(s) "
+ "(older than {})", removed, maxAge);
}
return removed;
}
/**
* Periodic background sweep runs every 5 minutes (1 minute initial
* delay after startup). Removes handles inactive for more than 30
* minutes, which almost certainly indicates a leaked registration
* (normal turns complete in seconds to minutes).
*
* <p>The 30-minute threshold is intentionally generous: active
* long-running conversations (e.g. a multi-hour research task) refresh
* {@code lastActiveAt} on every iteration via {@link #register}, so
* they won't be swept.
*/
@Scheduled(fixedDelay = 5 * 60 * 1000L, initialDelay = 60 * 1000L)
public void scheduledCleanup() {
try {
cleanupStale(Duration.ofMinutes(30));
} catch (Exception e) {
log.warn("[RunningConversationRegistry] Scheduled cleanup failed: {}", e.getMessage());
}
}
// ==================== Internal handle ====================
private static final class ConversationHandle {
final Long agentId;
volatile Instant lastActiveAt;
final ConcurrentLinkedQueue<EnvironmentNotification> notifications;
ConversationHandle(Long agentId, Instant startedAt, Instant lastActiveAt,
ConcurrentLinkedQueue<EnvironmentNotification> notifications) {
this.agentId = agentId;
this.lastActiveAt = lastActiveAt;
this.notifications = notifications;
}
}
}

View File

@ -21,6 +21,7 @@ import vip.mate.memory.event.ConversationCompletionPublisher;
import vip.mate.tts.TtsService;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.model.MessageContentPart;
import vip.mate.workspace.core.service.ChatUploadLocationResolver;
import vip.mate.workspace.conversation.model.MessageEntity;
import com.fasterxml.jackson.core.type.TypeReference;
@ -1539,16 +1540,18 @@ public class ChannelMessageRouter {
*/
private Path resolveVoiceReplyAudio(String conversationId, String fileName) {
if (chatUploadLocationResolver != null) {
for (Path root : chatUploadLocationResolver.resolveCandidateUploadRoots(conversationId)) {
Path candidate = root.resolve(conversationId).resolve(fileName);
for (Path dir : chatUploadLocationResolver.resolveCandidateConversationDirs(conversationId)) {
Path candidate = dir.resolve(fileName);
if (Files.exists(candidate)) {
return candidate;
}
}
}
// Fallback to the legacy default dir when the resolver is absent
// (e.g. direct-construction unit tests).
Path legacy = Paths.get("data", "chat-uploads", conversationId, fileName);
// (e.g. direct-construction unit tests). Sanitize the id for the path
// segment so it matches the write side.
Path legacy = Paths.get("data", "chat-uploads",
ChatUploadLocationResolver.sanitizeSegment(conversationId), fileName);
return Files.exists(legacy) ? legacy : null;
}

View File

@ -9,6 +9,7 @@ import reactor.core.publisher.Flux;
import vip.mate.agent.AgentService.StreamDelta;
import vip.mate.channel.AbstractChannelAdapter;
import vip.mate.channel.ChannelMessage;
import vip.mate.workspace.core.service.ChatUploadLocationResolver;
import vip.mate.channel.ChannelMessageRouter;
import vip.mate.channel.ExponentialBackoff;
import vip.mate.channel.StreamingChannelAdapter;
@ -271,6 +272,29 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
return roots;
}
/**
* Candidate conversation attachment directories: the sanitized segment under
* each candidate root first, then for backward compatibility with pre-fix
* Linux uploads that used the raw id verbatim the raw-id dir when legal on
* this filesystem. Mirrors {@code ChatUploadLocationResolver
* .resolveCandidateConversationDirs} for the resolver-less fallback path.
*/
private java.util.List<java.nio.file.Path> candidateChatUploadDirs(String conversationId) {
String safe = ChatUploadLocationResolver.sanitizeSegment(conversationId);
java.util.Set<java.nio.file.Path> dirs = new java.util.LinkedHashSet<>();
for (java.nio.file.Path root : candidateChatUploadRoots(conversationId)) {
dirs.add(root.resolve(safe));
if (!safe.equals(conversationId)) {
try {
dirs.add(root.resolve(conversationId));
} catch (java.nio.file.InvalidPathException ignore) {
// Raw id illegal on this filesystem (e.g. ':' on Windows).
}
}
}
return new java.util.ArrayList<>(dirs);
}
public FeishuChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter,
ObjectMapper objectMapper) {
@ -1775,8 +1799,11 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
: maybeDownloadResource(messageId, fileKey, type, fileName);
if (dl == null) return null;
// Save under the workspace/agent-aware upload root ({convId}/ subdir)
Path uploadDir = chatUploadRootFor(conversationId).resolve(conversationId);
// Save under the workspace/agent-aware upload root ({convId}/ subdir).
// Sanitize the id for the path segment IM ids like "feishu:xxx"
// carry a ':' that is illegal in a Windows filename.
Path uploadDir = chatUploadRootFor(conversationId)
.resolve(ChatUploadLocationResolver.sanitizeSegment(conversationId));
Files.createDirectories(uploadDir);
String rawName = (dl.fileName() != null && !dl.fileName().isBlank())
? dl.fileName() : fileKey;
@ -1869,8 +1896,8 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
private List<RecentFileEntry> loadRecentFilesFromDisk(String conversationId) {
long cutoff = System.currentTimeMillis() - RECENT_FILE_TTL_MINUTES * 60_000L;
List<RecentFileEntry> merged = new java.util.ArrayList<>();
for (Path root : candidateChatUploadRoots(conversationId)) {
merged.addAll(loadRecentFilesFromDisk(root.resolve(conversationId), cutoff));
for (Path dir : candidateChatUploadDirs(conversationId)) {
merged.addAll(loadRecentFilesFromDisk(dir, cutoff));
}
return merged;
}

View File

@ -15,6 +15,7 @@ import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import vip.mate.common.result.R;
import vip.mate.workspace.core.service.ChatUploadLocationResolver;
import vip.mate.agent.AgentService;
import vip.mate.agent.model.AgentEntity;
import vip.mate.approval.ApprovalWorkflowService;
@ -1106,7 +1107,10 @@ public class ChatController {
String safeFilename = Path.of(originalFilename).getFileName().toString().replaceAll("[^a-zA-Z0-9._-]", "_");
String storedName = System.currentTimeMillis() + "_" + safeFilename;
Path uploadRoot = uploadLocationResolver.resolveUploadRoot(conversationId);
Path conversationDir = uploadRoot.resolve(conversationId);
// Sanitize the id before using it as a path segment IM-channel ids like
// "wecom:XXXX" carry a ':' that is illegal in a Windows filename and would
// throw InvalidPathException here. Reads use the same sanitization.
Path conversationDir = uploadRoot.resolve(ChatUploadLocationResolver.sanitizeSegment(conversationId));
Files.createDirectories(conversationDir);
Path target = conversationDir.resolve(storedName);
file.transferTo(target);
@ -1143,10 +1147,12 @@ public class ChatController {
// current workspace-scoped ones, are both servable. Each candidate keeps
// its own startsWith traversal guard.
Path filePath = null;
for (Path root : uploadLocationResolver.resolveCandidateUploadRoots(conversationId)) {
Path conversationDir = root.resolve(conversationId).normalize();
Path candidate = conversationDir.resolve(storedName).normalize();
if (Files.exists(candidate) && candidate.startsWith(conversationDir)) {
// Sanitized-then-raw candidate dirs so both new writes (sanitized) and
// legacy Linux uploads (raw ':' dir) resolve.
for (Path conversationDir : uploadLocationResolver.resolveCandidateConversationDirs(conversationId)) {
Path normDir = conversationDir.normalize();
Path candidate = normDir.resolve(storedName).normalize();
if (Files.exists(candidate) && candidate.startsWith(normDir)) {
filePath = candidate;
break;
}
@ -1556,7 +1562,7 @@ public class ChatController {
* separators are normalized to {@code /} so the value is stable across OSes.
*/
static String toRelativeUploadPath(Path uploadRoot, String conversationId, String storedName) {
Path target = uploadRoot.resolve(conversationId).resolve(storedName);
Path target = uploadRoot.resolve(ChatUploadLocationResolver.sanitizeSegment(conversationId)).resolve(storedName);
Path base = uploadRoot.getParent();
Path relative = base != null ? base.relativize(target) : target;
return relative.toString().replace('\\', '/');

View File

@ -3,10 +3,13 @@ package vip.mate.channel.web;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import reactor.core.Disposable;
import vip.mate.tool.mcp.runtime.McpProgressContext;
import vip.mate.workspace.conversation.model.MessageContentPart;
import java.io.IOException;
@ -106,6 +109,9 @@ public class ChatStreamTracker {
@Value("${mateclaw.stream.heartbeat.tool-sec:5}")
private int heartbeatToolSec = 5;
@Autowired
private ApplicationContext applicationContext;
public ChatStreamTracker(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@ -582,6 +588,15 @@ public class ChatStreamTracker {
* early-return remains.
*/
public void broadcast(String conversationId, String eventName, String jsonData) {
broadcast(conversationId, eventName, jsonData, false);
}
/**
* Broadcast an event to all subscribers (optionally skip buffer).
* @param skipBuffer if true, do not write to the ring buffer used for
* high-frequency transient events (e.g. progress).
*/
public void broadcast(String conversationId, String eventName, String jsonData, boolean skipBuffer) {
RunState state = runs.get(conversationId);
boolean isDone = "done".equals(eventName);
@ -652,18 +667,23 @@ public class ChatStreamTracker {
}
synchronized (state.lock) {
long id = ++state.nextEventId;
SseEvent event = new SseEvent(id, eventName, jsonData);
state.buffer.add(event);
// buffer 容量保护超出上限时优先丢弃 thinking_delta占比最大且非关键
if (state.buffer.size() > MAX_BUFFER_SIZE) {
trimBuffer(state.buffer);
if (!skipBuffer) {
long id = ++state.nextEventId;
SseEvent event = new SseEvent(id, eventName, jsonData);
state.buffer.add(event);
if (state.buffer.size() > MAX_BUFFER_SIZE) {
trimBuffer(state.buffer);
}
}
Iterator<SseEmitter> it = state.subscribers.iterator();
while (it.hasNext()) {
SseEmitter emitter = it.next();
try {
emitter.send(SseEmitter.event().id(String.valueOf(id)).name(eventName).data(jsonData));
if (skipBuffer) {
emitter.send(SseEmitter.event().name(eventName).data(jsonData));
} else {
emitter.send(SseEmitter.event().id(String.valueOf(state.nextEventId)).name(eventName).data(jsonData));
}
} catch (IOException | IllegalStateException e) {
log.debug("Removing dead subscriber for {}: {}", conversationId, e.getMessage());
it.remove();
@ -695,6 +715,13 @@ public class ChatStreamTracker {
* @param data 事件载荷将被 Jackson 序列化为 JSON
*/
public void broadcastObject(String conversationId, String eventName, Object data) {
broadcastObject(conversationId, eventName, data, false);
}
/**
* Broadcast an Object directly (auto-serialized to JSON), optionally skipping the buffer.
*/
public void broadcastObject(String conversationId, String eventName, Object data, boolean skipBuffer) {
String json;
try {
json = objectMapper.writeValueAsString(data);
@ -702,7 +729,32 @@ public class ChatStreamTracker {
log.warn("Failed to serialize broadcast data for event {}: {}", eventName, e.getMessage());
json = "{\"error\":\"serialization_failed\"}";
}
broadcast(conversationId, eventName, json);
broadcast(conversationId, eventName, json, skipBuffer);
}
/**
* Deliver MCP progress snapshots on SSE reconnect. Progress events do not
* participate in buffer replay, so the latest snapshot is read from
* {@link McpProgressContext} and delivered separately on attach.
*/
private void sendProgressSnapshots(String conversationId, SseEmitter emitter) {
try {
McpProgressContext progressCtx = applicationContext.getBean(McpProgressContext.class);
Map<String, String> snapshots = progressCtx.getSnapshots(conversationId);
if (snapshots != null && !snapshots.isEmpty()) {
for (Map.Entry<String, String> entry : snapshots.entrySet()) {
try {
emitter.send(SseEmitter.event()
.name("tool_call_progress")
.data(entry.getValue()));
} catch (IOException e) {
log.debug("Failed to send progress snapshot for {}: {}", conversationId, e.getMessage());
}
}
}
} catch (Exception e) {
log.debug("Failed to send progress snapshots for {}: {}", conversationId, e.getMessage());
}
}
/**
@ -921,6 +973,10 @@ public class ChatStreamTracker {
// Without this, async_task_completed fired after `done` would be silently
// dropped, leaving the chat UI stuck on the "正在生成中" placeholder.
state.subscribers.add(emitter);
// Deliver MCP progress snapshots on reconnect (progress events skip buffer replay)
sendProgressSnapshots(conversationId, emitter);
if (state.done) {
log.info("[SSE] Replayed {} buffered events; emitter stays subscribed for late async events: {}",
state.buffer.size(), conversationId);

View File

@ -120,7 +120,9 @@ public class WebChatFileService {
String storedName = UUID.randomUUID() + "_" + safeName;
Path uploadRoot = uploadLocationResolver.resolveUploadRoot(conversationId).normalize();
Path dir = uploadRoot.resolve(conversationId).normalize();
// Sanitize the id for the path segment (IM ids like "wecom:XXXX" carry a
// ':' illegal on Windows); reads use the same sanitization.
Path dir = uploadRoot.resolve(ChatUploadLocationResolver.sanitizeSegment(conversationId)).normalize();
if (!dir.startsWith(uploadRoot)) {
// conversationId is server-derived, so this should never happen; fail closed if it does.
throw new UploadRejectedException("Invalid conversation");
@ -172,10 +174,10 @@ public class WebChatFileService {
}
// Check every candidate root (workspace-scoped dir + legacy default dir)
// so files written before the workspace-aware relocation still resolve.
for (Path root : uploadLocationResolver.resolveCandidateUploadRoots(conversationId)) {
Path base = root.resolve(conversationId).normalize();
Path file = base.resolve(storedName).normalize();
if (file.startsWith(base) && Files.exists(file) && Files.isRegularFile(file)) {
for (Path base : uploadLocationResolver.resolveCandidateConversationDirs(conversationId)) {
Path normBase = base.normalize();
Path file = normBase.resolve(storedName).normalize();
if (file.startsWith(normBase) && Files.exists(file) && Files.isRegularFile(file)) {
return Optional.of(file);
}
}

View File

@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j;
import vip.mate.channel.AbstractChannelAdapter;
import vip.mate.channel.ChannelMessage;
import vip.mate.channel.ChannelMessageRouter;
import vip.mate.workspace.core.service.ChatUploadLocationResolver;
import vip.mate.channel.ExponentialBackoff;
import vip.mate.channel.media.InboundMediaDownloader;
import vip.mate.channel.model.ChannelEntity;
@ -2970,8 +2971,8 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
// dedup-named write; the WeCom-specific AES-256-CBC decrypt stays here
// inside the byte source so a fetch + decrypt is retried as one unit.
Path uploadDir = (chatUploadLocationResolver != null)
? chatUploadLocationResolver.resolveUploadRoot(conversationId).resolve(conversationId)
: Path.of("data", "chat-uploads", conversationId);
? chatUploadLocationResolver.resolveConversationDir(conversationId)
: Path.of("data", "chat-uploads", ChatUploadLocationResolver.sanitizeSegment(conversationId));
String hint = (fileNameHint == null || fileNameHint.isBlank()) ? null : fileNameHint;
return InboundMediaDownloader.download(
() -> {

View File

@ -10,6 +10,7 @@ import vip.mate.channel.media.InboundMediaDownloader;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.channel.weixin.error.TokenExpiredException;
import vip.mate.common.security.SecretEquals;
import vip.mate.workspace.core.service.ChatUploadLocationResolver;
import vip.mate.workspace.conversation.model.MessageContentPart;
import java.io.IOException;
@ -687,8 +688,8 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
}
Path uploadDir = (chatUploadLocationResolver != null)
? chatUploadLocationResolver.resolveUploadRoot(conversationId).resolve(conversationId)
: Path.of("data", "chat-uploads", conversationId);
? chatUploadLocationResolver.resolveConversationDir(conversationId)
: Path.of("data", "chat-uploads", ChatUploadLocationResolver.sanitizeSegment(conversationId));
return InboundMediaDownloader.download(
() -> client.downloadMedia("", aesKey, encryptQueryParam),
filenameHint,

View File

@ -0,0 +1,46 @@
package vip.mate.content.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import vip.mate.common.result.R;
import vip.mate.content.model.ContentItemEntity;
import vip.mate.content.service.ContentItemService;
import java.util.Map;
/**
* Read-only content calendar API lists produced 公众号 / 小红书 pieces and their
* lifecycle status, so operators can see what's drafted / packaged / published /
* pending. Writes happen through the tools ({@code content_item} + auto-record on
* delivery), not here.
*/
@Tag(name = "内容日历")
@RestController
@RequestMapping("/api/v1/content-items")
@RequiredArgsConstructor
public class ContentItemController {
private final ContentItemService contentItemService;
@Operation(summary = "内容日历分页列表")
@GetMapping
public R<IPage<ContentItemEntity>> list(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String platform,
@RequestParam(required = false) String status) {
return R.ok(contentItemService.page(page, size, platform, status));
}
@Operation(summary = "内容日历状态计数")
@GetMapping("/summary")
public R<Map<String, Long>> summary() {
return R.ok(contentItemService.summary());
}
}

View File

@ -0,0 +1,62 @@
package vip.mate.content.model;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* A produced content item (公众号 article / 小红书 note) tracked across its
* lifecycle. Backs the content calendar so the daily scheduler can avoid
* repeating topics and so publishing is idempotent and auditable.
*
* <p>{@code topicFingerprint} is a stable hash of the normalized topic; it is the
* dedup key for "did we already cover this recently". {@code status} moves
* {@code draft/packaged published} (or {@code failed}).
*/
@Data
@TableName("mate_content_item")
public class ContentItemEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** Owning workspace; nullable for single-user setups. */
private Long workspaceId;
/** Target platform: {@code gzh} (公众号) or {@code xhs} (小红书). */
private String platform;
/** The chosen topic, human-readable. */
private String topic;
/** Stable hash of the normalized topic — the recency/dedup key. */
private String topicFingerprint;
/** Final title of the produced piece. */
private String title;
/** Lifecycle: {@code draft} | {@code packaged} | {@code published} | {@code failed}. */
private String status;
/** Platform-side reference: draft media_id / publish_id, when applicable. */
private String externalRef;
/** Online-preview link handed to the user. */
private String previewUrl;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
/** Set when the item is marked published. */
private LocalDateTime publishTime;
private Integer deleted;
}

View File

@ -0,0 +1,13 @@
package vip.mate.content.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import vip.mate.content.model.ContentItemEntity;
/**
* Mapper for {@link ContentItemEntity}. Must live under a {@code repository}
* package so {@code @MapperScan("vip.mate.**.repository")} registers it.
*/
@Mapper
public interface ContentItemMapper extends BaseMapper<ContentItemEntity> {
}

View File

@ -0,0 +1,165 @@
package vip.mate.content.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.content.model.ContentItemEntity;
import vip.mate.content.repository.ContentItemMapper;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Content calendar / dedup ledger service the single home for content-item
* logic, shared by {@code content_item} (the tool), the package tools (which
* auto-record on delivery), and the read-only content-calendar API.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ContentItemService {
/** Statuses that count as "already covered" for dedup — a discarded draft doesn't. */
private static final List<String> COMMITTED_STATUSES = List.of("packaged", "published");
/** Ignore same-topic rows created within this window, so a recordcheck in one
* run doesn't flag itself as a repeat. */
private static final long SELF_MATCH_GUARD_MINUTES = 2;
/** Re-packaging the same topic within this window updates the existing ledger
* row instead of inserting a duplicate (the agent may call a package tool twice). */
private static final long RECORD_DEDUP_MINUTES = 10;
private final ContentItemMapper contentItemMapper;
/**
* Recent committed items with the same topic fingerprint on this platform,
* within {@code days}, excluding just-created rows (self-match guard). Empty
* means "not a repeat".
*/
public List<ContentItemEntity> findRecent(String platform, String topic, int days) {
LocalDateTime now = LocalDateTime.now();
return contentItemMapper.selectList(new LambdaQueryWrapper<ContentItemEntity>()
.eq(ContentItemEntity::getPlatform, platform.trim().toLowerCase())
.eq(ContentItemEntity::getTopicFingerprint, fingerprint(topic))
.in(ContentItemEntity::getStatus, COMMITTED_STATUSES)
.ge(ContentItemEntity::getCreateTime, now.minusDays(days))
.lt(ContentItemEntity::getCreateTime, now.minusMinutes(SELF_MATCH_GUARD_MINUTES))
.orderByDesc(ContentItemEntity::getCreateTime));
}
/**
* Record a produced piece; returns its item id. Idempotent within a short
* window: re-packaging the same topic on the same platform (e.g. the agent
* called a package tool twice) updates the existing row instead of inserting
* a duplicate. A published row is never overwritten.
*/
public Long record(Long workspaceId, String platform, String topic, String title,
String status, String previewUrl, String externalRef) {
String plat = platform.trim().toLowerCase();
String fp = fingerprint(topic != null ? topic : title);
String resolvedStatus = status == null || status.isBlank() ? "packaged" : status.trim().toLowerCase();
ContentItemEntity existing = contentItemMapper.selectOne(new LambdaQueryWrapper<ContentItemEntity>()
.eq(ContentItemEntity::getPlatform, plat)
.eq(ContentItemEntity::getTopicFingerprint, fp)
.ne(ContentItemEntity::getStatus, "published")
.ge(ContentItemEntity::getCreateTime, LocalDateTime.now().minusMinutes(RECORD_DEDUP_MINUTES))
.orderByDesc(ContentItemEntity::getCreateTime)
.last("LIMIT 1"));
if (existing != null) {
if (title != null && !title.isBlank()) {
existing.setTitle(title.trim());
}
if (previewUrl != null) {
existing.setPreviewUrl(previewUrl);
}
if (externalRef != null) {
existing.setExternalRef(externalRef);
}
existing.setStatus(resolvedStatus);
contentItemMapper.updateById(existing);
log.info("[ContentItem] re-package dedup: updated id={} platform={} topic='{}'",
existing.getId(), plat, topic);
return existing.getId();
}
ContentItemEntity e = new ContentItemEntity();
e.setWorkspaceId(workspaceId);
e.setPlatform(plat);
e.setTopic(topic != null ? topic.trim() : null);
e.setTopicFingerprint(fp);
e.setTitle(title != null ? title.trim() : null);
e.setStatus(resolvedStatus);
e.setPreviewUrl(previewUrl);
e.setExternalRef(externalRef);
contentItemMapper.insert(e);
log.info("[ContentItem] recorded id={} ws={} platform={} status={} title='{}'",
e.getId(), workspaceId, e.getPlatform(), e.getStatus(), title);
return e.getId();
}
/** Flip an item to published. Returns false if the id is unknown. */
public boolean markPublished(Long id, String externalRef) {
ContentItemEntity e = contentItemMapper.selectById(id);
if (e == null) {
return false;
}
e.setStatus("published");
e.setPublishTime(LocalDateTime.now());
if (externalRef != null && !externalRef.isBlank()) {
e.setExternalRef(externalRef);
}
contentItemMapper.updateById(e);
log.info("[ContentItem] item {} marked published (ref={})", id, externalRef);
return true;
}
/** Paged content-calendar listing, newest first, optionally filtered by platform / status. */
public IPage<ContentItemEntity> page(int page, int size, String platform, String status) {
LambdaQueryWrapper<ContentItemEntity> w = new LambdaQueryWrapper<>();
if (platform != null && !platform.isBlank()) {
w.eq(ContentItemEntity::getPlatform, platform.trim().toLowerCase());
}
if (status != null && !status.isBlank()) {
w.eq(ContentItemEntity::getStatus, status.trim().toLowerCase());
}
w.orderByDesc(ContentItemEntity::getCreateTime);
int p = Math.max(1, page);
int s = Math.min(Math.max(1, size), 100);
return contentItemMapper.selectPage(new Page<>(p, s), w);
}
/** Counts by status (draft/packaged/published/failed) plus total, for the summary cards. */
public Map<String, Long> summary() {
Map<String, Long> m = new LinkedHashMap<>();
for (String s : List.of("draft", "packaged", "published", "failed")) {
m.put(s, contentItemMapper.selectCount(
new LambdaQueryWrapper<ContentItemEntity>().eq(ContentItemEntity::getStatus, s)));
}
m.put("total", contentItemMapper.selectCount(null));
return m;
}
/** Stable 32-hex fingerprint of the normalized topic (lowercased, alnum/CJK only). */
public static String fingerprint(String topic) {
String normalized = topic == null ? "" : topic.toLowerCase()
.replaceAll("[\\s\\p{Punct}\\u3000-\\u303F\\uFF00-\\uFFEF]+", "");
try {
byte[] hash = MessageDigest.getInstance("SHA-256")
.digest(normalized.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder();
for (int i = 0; i < 16; i++) {
hex.append(String.format("%02x", hash[i]));
}
return hex.toString();
} catch (Exception e) {
return Integer.toHexString(normalized.hashCode());
}
}
}

View File

@ -350,6 +350,14 @@ public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
private MultiValueMap<String, String> buildOpenAiHeaders(Map<String, Object> kwargs) {
LinkedMultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
headers.add("User-Agent", "MateClaw/1.0");
// Force a fresh TCP connection per request (no keep-alive pooling). Self-hosted
// OpenAI-compatible gateways frequently idle-close connections faster than the
// JDK HttpClient evicts them from its pool, so a reused-but-dead socket gets reset
// by the peer before any response byte arrives "header parser received no bytes".
// This mirrors curl's one-connection-per-call behavior. "Connection" is a JDK
// restricted header, enabled at startup via jdk.httpclient.allowRestrictedHeaders.
// A user-supplied Connection header in kwargs.headers overrides this (set() below).
headers.add("Connection", "close");
Object headerObject = kwargs.get("headers");
if (headerObject instanceof Map<?, ?> headerMap) {
headerMap.forEach((key, value) -> {

View File

@ -0,0 +1,27 @@
package vip.mate.skill.event;
/**
* Fires after a skill row has been updated (metadata, SKILL.md content,
* security rescan, or enabled-flag toggle) so downstream listeners can
* react to the new state.
*
* <p>Mirrors {@link SkillRemovedEvent} in shape, with an added
* {@code changeType} hint so listeners can skip no-op refreshes. The
* publisher is {@code SkillService} (which already holds the
* {@code ApplicationEventPublisher}), avoiding a circular dependency on
* {@code SkillRuntimeService} or {@code AgentService}.
*
* <p>Use cases:
* <ul>
* <li>Environment event routing (C3) notifies running conversations
* that a skill's constraints or flow may have changed.</li>
* <li>Agent cache invalidation so the next turn picks up the new
* manifest.</li>
* </ul>
*
* @param skillId DB id of the updated skill row
* @param skillName slug identifier the row carries, useful for log lines
* @param changeType {@code updated} | {@code toggled} | {@code rescanned}
*/
public record SkillUpdatedEvent(Long skillId, String skillName, String changeType) {
}

View File

@ -91,6 +91,28 @@ public class SkillManifest {
/** Set when {@code type=acp}. Resolves to a {@code mate_acp_endpoint} row. */
private AcpBinding acp;
// ==================== Attention-anchoring constraints ====================
/**
* Short, high-priority constraints extracted from SKILL.md that the
* agent must obey throughout the task e.g. "never delete user
* files", "always confirm before writing", "use server B's fetch
* tool, not server A's".
*
* <p>Unlike the full SKILL.md (which is a free-form document loaded
* via {@code load_skill} and subject to context-window trimming),
* these structured constraints are pinned into the ProgressLedger's
* pinned-entries section by ActionNode on {@code load_skill}, so they
* survive context compression and stay visible on every turn.
*
* <p>Empty list when the skill author didn't declare structured
* constraints the agent then falls back to the SKILL.md content
* loaded via {@code load_skill} (protected by
* {@code PRUNE_EXEMPT_TOOLS} in ConversationWindowManager).
*/
@Builder.Default
private List<String> constraints = List.of();
// ==================== type=code script entrypoints ====================
/**

View File

@ -41,6 +41,7 @@ public class SkillManifestParser {
"knowledge",
"acp",
"scripts",
"constraints",
// legacy / housekeeping fields that aren't manifest-relevant
"metadata"
);
@ -104,6 +105,7 @@ public class SkillManifestParser {
.knowledge(parseKnowledge(fm.get("knowledge")))
.acp(parseAcp(fm.get("acp")))
.scripts(parseScripts(fm.get("scripts")))
.constraints(stringList(fm.get("constraints")))
.extras(extractUnknown(fm));
return b.build();

View File

@ -499,8 +499,8 @@ public class SkillRuntimeService {
sb.append("If a skill describes steps but ships no runnable script, write the code ");
sb.append("its instructions describe and run it with ");
sb.append("`execute_code(language=<python|bash|node>, code=..., skillName=<name>)`.\n\n");
sb.append("| Skill | Status | Description |\n");
sb.append("|-------|--------|-------------|\n");
sb.append("| Skill | Status | Description | Constraints |\n");
sb.append("|-------|--------|-------------|-------------|\n");
for (ResolvedSkill skill : selected) {
sb.append("| `").append(skill.getName()).append("`");
if (skill.getIcon() != null && !skill.getIcon().isBlank()) {
@ -516,6 +516,19 @@ public class SkillRuntimeService {
// break the table layout.
sb.append(desc.replace("|", "\\|").replace("\n", " "));
}
sb.append(" | ");
// Only bound skills show constraints they're the ones the agent
// is configured to obey. Constraints are stable (agent lifecycle)
// so they live in the prompt-cache-friendly static catalog segment,
// immune to history compression. Non-bound skills (recommended /
// recent) are "visible but optional" and don't carry constraints
// the agent must follow.
if (skill.getId() != null && boundIds.contains(skill.getId())
&& skill.getManifest() != null
&& skill.getManifest().getConstraints() != null
&& !skill.getManifest().getConstraints().isEmpty()) {
sb.append(renderConstraintsSummary(skill.getManifest().getConstraints()));
}
sb.append(" |\n");
}
if (selected.size() < visibleSkills.size()) {
@ -524,6 +537,29 @@ public class SkillRuntimeService {
.append(" available skills. Use `listAvailableSkills()` for the full catalog.\n");
}
// Append a compact "bound skill tools" block so the model knows which
// tools each bound skill allows this is the other half of the skill
// contract (constraints in the table above, allowedTools here). Kept
// in the static catalog segment (prompt-cache-friendly, immune to
// history compression) so the model always knows the tool boundary
// for bound skills without needing to load_skill.
List<ResolvedSkill> boundWithTools = selected.stream()
.filter(s -> s.getId() != null && boundIds.contains(s.getId()))
.filter(s -> s.getEffectiveAllowedTools() != null
&& !s.getEffectiveAllowedTools().isEmpty())
.toList();
if (!boundWithTools.isEmpty()) {
sb.append("\n### Bound skill allowed tools\n");
for (ResolvedSkill skill : boundWithTools) {
Set<String> tools = skill.getEffectiveAllowedTools();
sb.append("- `").append(skill.getName()).append("`: ");
sb.append(tools.stream()
.map(t -> "`" + t + "`")
.collect(java.util.stream.Collectors.joining(", ")));
sb.append('\n');
}
}
List<ResolvedSkill> lessonSkills = sorted.stream()
.filter(s -> (s.getId() != null && boundIds.contains(s.getId())) || recentNames.contains(s.getName()))
.toList();
@ -603,6 +639,30 @@ public class SkillRuntimeService {
return 160;
}
/**
* Per-constraint char budget for the catalog table's Constraints column.
* Kept short so the table stays compact the full constraints are
* available via load_skill / readSkillFile when the agent needs the
* complete text. Multiple constraints are joined with "; " before
* truncation.
*/
private static final int CONSTRAINTS_SUMMARY_LIMIT = 80;
/**
* Render a compact one-line summary of a skill's constraints for the
* catalog table. Multiple constraints are joined with "; "; the result
* is truncated to {@link #CONSTRAINTS_SUMMARY_LIMIT} chars and
* pipe/newline escaped so it doesn't break the table layout.
*/
static String renderConstraintsSummary(List<String> constraints) {
if (constraints == null || constraints.isEmpty()) return "";
String joined = String.join("; ", constraints);
if (joined.length() > CONSTRAINTS_SUMMARY_LIMIT) {
joined = joined.substring(0, CONSTRAINTS_SUMMARY_LIMIT) + "...";
}
return joined.replace("|", "\\|").replace("\n", " ");
}
private static String statusToken(ResolvedSkill skill) {
if (skill.isSecurityBlocked()) return "blocked";
if (!skill.isEnabled()) return "disabled";

View File

@ -2,10 +2,12 @@ package vip.mate.skill.runtime;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
@ -28,11 +30,24 @@ import java.util.concurrent.TimeUnit;
public class SkillScriptExecutionService {
private static final long DEFAULT_TIMEOUT_SECONDS = 30;
private static final long MAX_TIMEOUT_SECONDS = 300;
private static final long MAX_TIMEOUT_SECONDS = 600;
private static final int MAX_OUTPUT_BYTES = 50_000;
private static final boolean IS_WINDOWS = System.getProperty("os.name", "")
.toLowerCase(Locale.ROOT).contains("win");
/**
* Pip mirror config for desktop (non-Docker) deployments. In Docker these
* arrive as PIP_INDEX_URL / PIP_TRUSTED_HOST env vars (set in
* docker-compose) and are inherited by ProcessBuilder directly. On the
* desktop app Java runs on the host the host may not have those env
* vars set, so we fall back to Spring config and inject them explicitly.
*/
@Value("${mateclaw.pip.index-url:}")
private String pipIndexUrl;
@Value("${mateclaw.pip.trusted-host:}")
private String pipTrustedHost;
/** Supported inline-code languages mapped to the temp-file extension. */
private static final Map<String, String> LANGUAGE_EXTENSIONS = Map.of(
"python", ".py",
@ -225,6 +240,7 @@ public class SkillScriptExecutionService {
processEnv.put(e.getKey(), e.getValue());
}
}
injectPipMirrorEnv(pb);
Process process = pb.start();
@ -272,6 +288,45 @@ public class SkillScriptExecutionService {
}
}
/**
* Inject pip mirror config into the subprocess environment.
*
* <p>Three layers, later ones only fill gaps left by earlier ones:
* <ol>
* <li>Docker / system env {@code PIP_INDEX_URL} / {@code PIP_TRUSTED_HOST}
* already in the ProcessBuilder env (inherited from JVM). Nothing to do.</li>
* <li>Spring config fallback for desktop (non-Docker) deployments where
* the host may not have those env vars. Injected only when absent.</li>
* <li>Auto-derive {@code PIP_TRUSTED_HOST} if the index URL is plain
* HTTP and no trusted-host is set, pip blocks the download. Extract
* the host from the URL so the user only needs to set one variable.</li>
* </ol>
*/
private void injectPipMirrorEnv(ProcessBuilder pb) {
Map<String, String> env = pb.environment();
// Layer 2: Spring config fallback (desktop)
if (pipIndexUrl != null && !pipIndexUrl.isBlank()
&& !env.containsKey("PIP_INDEX_URL")) {
env.put("PIP_INDEX_URL", pipIndexUrl);
}
if (pipTrustedHost != null && !pipTrustedHost.isBlank()
&& !env.containsKey("PIP_TRUSTED_HOST")) {
env.put("PIP_TRUSTED_HOST", pipTrustedHost);
}
// Layer 3: auto-derive trusted-host for HTTP sources
String indexUrl = env.get("PIP_INDEX_URL");
if (indexUrl != null && !indexUrl.isBlank()
&& !env.containsKey("PIP_TRUSTED_HOST")
&& indexUrl.startsWith("http://")) {
String host = URI.create(indexUrl).getHost();
if (host != null && !host.isEmpty()) {
env.put("PIP_TRUSTED_HOST", host);
}
}
}
private static String readFileTruncated(Path file, int maxBytes) {
try {
if (file == null || !Files.exists(file)) return "";

View File

@ -9,6 +9,7 @@ import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import vip.mate.exception.MateClawException;
import vip.mate.skill.event.SkillRemovedEvent;
import vip.mate.skill.event.SkillUpdatedEvent;
import vip.mate.skill.lifecycle.SkillLifecycleService;
import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.repository.SkillFileMapper;
@ -217,7 +218,11 @@ public class SkillService {
"Skill runtime not initialized yet; retry in a moment");
}
runtimeService.rescanSingle(skill);
return skillMapper.selectById(id);
SkillEntity reloaded = skillMapper.selectById(id);
// B6: notify running agents that this skill's manifest/security verdict
// may have changed so they can re-load constraints and refresh tool ads.
eventPublisher.publishEvent(new SkillUpdatedEvent(id, reloaded.getName(), "rescan"));
return reloaded;
}
/**
@ -440,6 +445,8 @@ public class SkillService {
runtimeService.refreshActiveSkills();
}
// B6: notify running agents so they re-load this skill's constraints.
eventPublisher.publishEvent(new SkillUpdatedEvent(existing.getId(), existing.getName(), "update"));
return existing;
}
@ -475,6 +482,8 @@ public class SkillService {
runtimeService.refreshActiveSkills();
}
// B6: notify running agents so they re-load this skill's constraints.
eventPublisher.publishEvent(new SkillUpdatedEvent(existing.getId(), existing.getName(), "update"));
return existing;
}
@ -604,6 +613,9 @@ public class SkillService {
runtimeService.refreshActiveSkills();
}
// B6: notify running agents so they re-evaluate this skill's ads/constraints.
eventPublisher.publishEvent(new SkillUpdatedEvent(skill.getId(), skill.getName(),
enabled ? "enable" : "disable"));
return skill;
}

View File

@ -34,6 +34,14 @@ public class SystemSettingsDTO {
private String serperApiKeyMasked;
private String tavilyApiKeyMasked;
// ===== WeChat Official Account (公众号) publish credentials =====
/** 公众号 AppID (plain — not sensitive). */
private String weixinoaAppId;
/** 公众号 AppSecret — write-only from the client; never echoed in plaintext. */
private String weixinoaAppSecret;
/** Masked AppSecret for display. */
private String weixinoaAppSecretMasked;
// ===== 视频生成配置 =====
/** 是否启用视频生成能力 */
private Boolean videoEnabled;

View File

@ -0,0 +1,127 @@
package vip.mate.system.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
/**
* Transparent at-rest encryption for sensitive system settings (API keys,
* WeChat Official Account app secret, etc.). Values are encrypted with
* AES-256-GCM and stored as {@code enc:v1:<base64(iv||ciphertext||tag)>}.
*
* <p>Backward compatibility: {@link #decrypt} returns any value WITHOUT the
* {@code enc:v1:} prefix verbatim, so legacy plaintext secrets keep working and
* are transparently upgraded to ciphertext the next time they are saved.
*
* <p>Key source, in order:
* <ol>
* <li>{@code MATECLAW_SETTING_KEY} environment variable (any string hashed
* to a 256-bit key). This is the recommended production setup; back it up,
* because rotating or losing it makes existing ciphertext unreadable.</li>
* <li>A built-in default passphrase when the env var is absent. This still
* keeps secrets out of plaintext in the database, but since the passphrase
* ships with the code it is obfuscation rather than strong protection a
* warning is logged at startup urging the operator to set the env var.</li>
* </ol>
*/
@Slf4j
@Component
public class SettingCrypto {
/** Version-tagged prefix so the format can evolve and be detected on read. */
static final String PREFIX = "enc:v1:";
private static final String ENV_KEY = "MATECLAW_SETTING_KEY";
private static final int GCM_IV_BYTES = 12;
private static final int GCM_TAG_BITS = 128;
/** Fallback passphrase used only when the env var is unset (obfuscation-grade). */
private static final String DEFAULT_PASSPHRASE = "mateclaw-default-setting-key-v1";
private final SecretKeySpec key;
private final SecureRandom random = new SecureRandom();
public SettingCrypto(@Value("${mateclaw.setting.key:}") String configuredKey) {
String source = firstNonBlank(configuredKey, System.getenv(ENV_KEY));
if (source == null || source.isBlank()) {
log.warn("[SettingCrypto] No {} set — encrypting sensitive settings with a built-in "
+ "default key (obfuscation only). Set {} to a strong secret in production "
+ "and back it up; losing it makes stored secrets unreadable.", ENV_KEY, ENV_KEY);
source = DEFAULT_PASSPHRASE;
}
this.key = deriveKey(source);
}
/** Encrypt a plaintext value into the {@code enc:v1:} envelope. Blank in → blank out. */
public String encrypt(String plaintext) {
if (plaintext == null || plaintext.isEmpty()) {
return plaintext;
}
try {
byte[] iv = new byte[GCM_IV_BYTES];
random.nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv));
byte[] ct = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
byte[] out = new byte[iv.length + ct.length];
System.arraycopy(iv, 0, out, 0, iv.length);
System.arraycopy(ct, 0, out, iv.length, ct.length);
return PREFIX + Base64.getEncoder().encodeToString(out);
} catch (Exception e) {
// Never persist a half-encrypted value; surface loudly instead.
throw new IllegalStateException("Failed to encrypt sensitive setting", e);
}
}
/**
* Decrypt an {@code enc:v1:} value. Any value without the prefix is returned
* unchanged (legacy plaintext), so reads never break during migration.
*/
public String decrypt(String stored) {
if (stored == null || !stored.startsWith(PREFIX)) {
return stored;
}
try {
byte[] blob = Base64.getDecoder().decode(stored.substring(PREFIX.length()));
byte[] iv = new byte[GCM_IV_BYTES];
System.arraycopy(blob, 0, iv, 0, GCM_IV_BYTES);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv));
byte[] pt = cipher.doFinal(blob, GCM_IV_BYTES, blob.length - GCM_IV_BYTES);
return new String(pt, StandardCharsets.UTF_8);
} catch (Exception e) {
// Wrong key or corrupt data don't hand back ciphertext as if it were the secret.
log.error("[SettingCrypto] Failed to decrypt a sensitive setting (wrong {} or corrupt "
+ "value?). Returning empty.", ENV_KEY);
return "";
}
}
/** True if the value is already in the encrypted envelope. */
public boolean isEncrypted(String value) {
return value != null && value.startsWith(PREFIX);
}
private static SecretKeySpec deriveKey(String source) {
try {
byte[] hash = MessageDigest.getInstance("SHA-256")
.digest(source.getBytes(StandardCharsets.UTF_8));
return new SecretKeySpec(hash, "AES");
} catch (Exception e) {
throw new IllegalStateException("Failed to derive setting encryption key", e);
}
}
private static String firstNonBlank(String a, String b) {
if (a != null && !a.isBlank()) {
return a;
}
return b;
}
}

View File

@ -13,6 +13,7 @@ import vip.mate.tool.search.SearchProvider;
import vip.mate.tool.search.SearchProviderRegistry;
import java.util.List;
import java.util.Set;
@Service
public class SystemSettingService {
@ -30,6 +31,10 @@ public class SystemSettingService {
private static final String SERPER_BASE_URL_KEY = "serperBaseUrl";
private static final String TAVILY_API_KEY_KEY = "tavilyApiKey";
private static final String TAVILY_BASE_URL_KEY = "tavilyBaseUrl";
// WeChat Official Account (公众号) publish credentials read by GzhPublishTool.
private static final String WEIXINOA_APP_ID_KEY = "weixinoa.app_id";
private static final String WEIXINOA_APP_SECRET_KEY = "weixinoa.app_secret";
private static final String DUCKDUCKGO_ENABLED_KEY = "duckduckgoEnabled";
private static final String SEARXNG_BASE_URL_KEY = "searxngBaseUrl";
@ -82,8 +87,19 @@ public class SystemSettingService {
private static final String MINIMAX_API_KEY_KEY = "minimaxApiKey";
private static final String MINIMAX_REGION_KEY = "minimaxRegion";
/**
* Keys whose values are secrets and must be encrypted at rest. Reads decrypt
* transparently and writes encrypt; legacy plaintext is upgraded on next save
* (see {@link SettingCrypto}). Add every credential-bearing key here.
*/
private static final Set<String> SENSITIVE_KEYS = Set.of(
SERPER_API_KEY_KEY, TAVILY_API_KEY_KEY, WEIXINOA_APP_SECRET_KEY,
ZHIPU_API_KEY_KEY, FAL_API_KEY_KEY, KLING_ACCESS_KEY_KEY, KLING_SECRET_KEY_KEY,
RUNWAY_API_KEY_KEY, MINIMAX_API_KEY_KEY);
private final SystemSettingMapper systemSettingMapper;
private final SearchProviderRegistry searchProviderRegistry;
private final SettingCrypto settingCrypto;
/**
* {@code PluginManager} is injected lazily because the bean graph is
@ -101,9 +117,11 @@ public class SystemSettingService {
public SystemSettingService(SystemSettingMapper systemSettingMapper,
SearchProviderRegistry searchProviderRegistry,
SettingCrypto settingCrypto,
@Lazy PluginManager pluginManager) {
this.systemSettingMapper = systemSettingMapper;
this.searchProviderRegistry = searchProviderRegistry;
this.settingCrypto = settingCrypto;
this.pluginManager = pluginManager;
}
@ -139,6 +157,10 @@ public class SystemSettingService {
dto.setSerperApiKeyMasked(maskApiKey(getValue(SERPER_API_KEY_KEY, "")));
dto.setTavilyApiKeyMasked(maskApiKey(getValue(TAVILY_API_KEY_KEY, "")));
// 公众号发布凭证AppSecret 脱敏回显AppID 明文
dto.setWeixinoaAppId(getValue(WEIXINOA_APP_ID_KEY, ""));
dto.setWeixinoaAppSecretMasked(maskApiKey(getValue(WEIXINOA_APP_SECRET_KEY, "")));
// 视频生成配置
dto.setVideoEnabled(Boolean.parseBoolean(getValue(VIDEO_ENABLED_KEY, "false")));
dto.setVideoProvider(getValue(VIDEO_PROVIDER_KEY, "auto"));
@ -295,6 +317,14 @@ public class SystemSettingService {
if (dto.getTavilyBaseUrl() != null) {
saveValue(TAVILY_BASE_URL_KEY, dto.getTavilyBaseUrl(), "Tavily 接口地址");
}
// 公众号发布凭证AppSecret 仅在非空时保存避免脱敏回显覆盖为空
if (dto.getWeixinoaAppId() != null) {
saveValue(WEIXINOA_APP_ID_KEY, dto.getWeixinoaAppId().trim(), "公众号 AppID");
}
if (dto.getWeixinoaAppSecret() != null && !dto.getWeixinoaAppSecret().isBlank()) {
saveValue(WEIXINOA_APP_SECRET_KEY, dto.getWeixinoaAppSecret().trim(), "公众号 AppSecret");
}
// Keyless provider 配置
if (dto.getDuckduckgoEnabled() != null) {
saveValue(DUCKDUCKGO_ENABLED_KEY, String.valueOf(dto.getDuckduckgoEnabled()), "DuckDuckGo 免 Key 搜索(零配置兜底)");
@ -499,7 +529,12 @@ public class SystemSettingService {
SystemSettingEntity entity = systemSettingMapper.selectOne(new LambdaQueryWrapper<SystemSettingEntity>()
.eq(SystemSettingEntity::getSettingKey, key)
.last("LIMIT 1"));
return entity != null && entity.getSettingValue() != null ? entity.getSettingValue() : defaultValue;
if (entity == null || entity.getSettingValue() == null) {
return defaultValue;
}
String stored = entity.getSettingValue();
// Sensitive keys are stored encrypted; decrypt() passes legacy plaintext through.
return SENSITIVE_KEYS.contains(key) ? settingCrypto.decrypt(stored) : stored;
}
private String maskApiKey(String apiKey) {
@ -513,6 +548,10 @@ public class SystemSettingService {
}
private void saveValue(String key, String value, String description) {
// Encrypt secrets at rest; non-blank only (blank passes through to clear).
if (SENSITIVE_KEYS.contains(key) && value != null && !value.isEmpty()) {
value = settingCrypto.encrypt(value);
}
SystemSettingEntity entity = systemSettingMapper.selectOne(new LambdaQueryWrapper<SystemSettingEntity>()
.eq(SystemSettingEntity::getSettingKey, key)
.last("LIMIT 1"));

View File

@ -0,0 +1,142 @@
package vip.mate.tool.browser;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.tool.guard.model.GuardDecision;
import vip.mate.tool.guard.model.GuardSeverity;
import vip.mate.tool.guard.model.ToolGuardAuditLogEntity;
import vip.mate.tool.guard.repository.ToolGuardAuditLogMapper;
import java.net.URI;
import java.util.List;
/**
* Privacy guard for browser sessions attached to a user's own logged-in browser.
*
* <p>When the browser tool connects to a Chrome the user is running themselves
* (via the DevTools Protocol, process not spawned by us), that browser may have
* banking, webmail or internal-admin tabs open with live sessions. Reading such
* a page in full screenshot, arbitrary JS eval, or a text/accessibility dump
* would funnel private content into the model and possibly into persisted
* transcripts. This guard classifies the current page and refuses those
* content-reading actions on pages that look sensitive, while leaving plain
* navigation untouched. It never applies to headless or self-spawned browsers.
*
* <p>Classification precedence: configured trusted hosts (always safe)
* configured sensitive hosts a built-in keyword heuristic over host + path.
* Every block is overridable by adding the host to
* {@code mateclaw.browser.privacy.trusted-hosts}.
*/
@Slf4j
@Component
public class BrowserPrivacyGuard {
/** Keyword signals that a page handles money, identity, or privileged access. */
private static final List<String> SENSITIVE_SIGNALS = List.of(
"bank", "banking", "pay", "payment", "wallet", "checkout", "billing", "invoice",
"mail", "webmail", "signin", "login", "logon", "account", "admin", "console",
"secure", "oauth", "authorize", "password", "passport", "identity");
private final BrowserProperties properties;
private final ToolGuardAuditLogMapper auditMapper;
public BrowserPrivacyGuard(BrowserProperties properties, ToolGuardAuditLogMapper auditMapper) {
this.properties = properties;
this.auditMapper = auditMapper;
}
/**
* Decide whether a content-reading action must be refused. Returns a
* human-readable block reason, or {@code null} when the action may proceed.
*
* @param userManagedBrowser true only when attached to a Chrome the user runs themselves
* @param url the current page URL
* @param action the action being attempted (screenshot / eval / snapshot)
*/
public String blockReason(boolean userManagedBrowser, String url, String action) {
if (!properties.getPrivacy().isEnabled() || !userManagedBrowser) {
return null;
}
if (!isSensitive(url)) {
return null;
}
return "Refusing action=" + action + " on what looks like a sensitive page (" + safeHost(url)
+ ") inside your own logged-in browser, to avoid exposing private content to the"
+ " model. Plain navigation is still allowed. If this page is safe to read, add its"
+ " host to mateclaw.browser.privacy.trusted-hosts.";
}
/** True when the URL matches a configured/heuristic sensitive signal and is not trusted. */
public boolean isSensitive(String url) {
if (url == null || url.isBlank()) {
return false;
}
String host;
String path;
try {
URI uri = URI.create(url);
host = uri.getHost() == null ? "" : uri.getHost().toLowerCase();
path = uri.getPath() == null ? "" : uri.getPath().toLowerCase();
} catch (IllegalArgumentException e) {
return false;
}
if (host.isEmpty()) {
return false;
}
if (hostMatches(host, properties.getPrivacy().getTrustedHosts())) {
return false;
}
if (hostMatches(host, properties.getPrivacy().getSensitiveHosts())) {
return true;
}
String hostAndPath = host + path;
for (String sig : SENSITIVE_SIGNALS) {
if (hostAndPath.contains(sig)) {
return true;
}
}
return false;
}
/** Record a blocked read into the shared tool-guard audit log so it shows up in the audit panel. */
public void audit(String conversationId, String action, String url, String reason) {
try {
ToolGuardAuditLogEntity entity = new ToolGuardAuditLogEntity();
entity.setConversationId(conversationId);
entity.setToolName("browser_use");
entity.setDecision(GuardDecision.BLOCK.name());
entity.setMaxSeverity(GuardSeverity.HIGH.name());
entity.setToolParamsJson("{\"action\":\"" + action + "\",\"host\":\"" + safeHost(url) + "\"}");
entity.setFindingsJson("[{\"type\":\"sensitive-page\",\"reason\":\""
+ reason.replace("\"", "'") + "\"}]");
auditMapper.insert(entity);
} catch (Exception e) {
log.warn("[BrowserPrivacyGuard] Failed to record audit entry: {}", e.getMessage());
}
}
private static boolean hostMatches(String host, List<String> patterns) {
if (patterns == null) {
return false;
}
for (String p : patterns) {
if (p == null || p.isBlank()) {
continue;
}
String pat = p.trim().toLowerCase();
if (host.equals(pat) || host.endsWith("." + pat)) {
return true;
}
}
return false;
}
private static String safeHost(String url) {
try {
String h = URI.create(url).getHost();
return h == null ? "unknown" : h;
} catch (Exception e) {
return "unknown";
}
}
}

View File

@ -116,5 +116,69 @@ public class BrowserProperties {
* to avoid forcing every snapshot through the spill-and-preview path.
*/
private int snapshotMaxLength = 20_000;
/**
* Whether {@code action=snapshot} includes non-interactive structural nodes
* (headings, list items, navigation, images) in the accessibility tree.
* Interactive elements always get a reference handle; structural nodes are
* emitted without one, purely to give the model page context. Turn off to
* produce a terser tree of only actionable elements.
*/
private boolean snapshotIncludeNonInteractive = true;
/** Privacy guard for sessions attached to a user's own logged-in browser (action=connect_cdp). */
private Privacy privacy = new Privacy();
/** Raw DevTools Protocol escape hatch (action=cdp) configuration. */
private Cdp cdp = new Cdp();
/**
* Controls {@code action=cdp}, which forwards a raw Chrome DevTools Protocol
* command. Constrained by a method allowlist; content-reading methods are
* additionally subject to {@link Privacy} on user-managed browsers.
*/
@Data
public static class Cdp {
/** Master switch for action=cdp. */
private boolean enabled = true;
/**
* Allowed CDP methods. An entry is either an exact method
* ({@code "Page.navigate"}) or a domain wildcard ({@code "Input.*"}).
* Defaults to safe actuation methods; extend for advanced automation.
* Content-reading methods stay guarded by {@link Privacy} even if added.
*/
private java.util.List<String> allowedMethods = new java.util.ArrayList<>(java.util.List.of(
"Input.*",
"Page.navigate", "Page.reload", "Page.bringToFront",
"Page.getNavigationHistory", "Page.navigateToHistoryEntry"));
}
/**
* When the browser tool is attached to a user-managed Chrome (connected via
* CDP, process not spawned by us), that Chrome may have banking / email /
* internal-admin tabs open. This guard refuses content-reading actions
* (screenshot / eval / full snapshot) on pages that look sensitive, so
* private content is not funnelled into the model / persisted. It never
* affects headless or self-spawned browsers.
*/
@Data
public static class Privacy {
/** Master switch. When false, no sensitive-page blocking happens. */
private boolean enabled = true;
/**
* Extra hosts to always treat as sensitive (exact host or any subdomain),
* on top of the built-in heuristic. E.g. {@code intranet.corp.example}.
*/
private java.util.List<String> sensitiveHosts = new java.util.ArrayList<>();
/**
* Hosts to always treat as safe (exact host or any subdomain). Overrides
* both the heuristic and {@link #sensitiveHosts}. Use to un-block a page
* the heuristic flagged that you know is fine to read.
*/
private java.util.List<String> trustedHosts = new java.util.ArrayList<>();
}
}

View File

@ -0,0 +1,230 @@
package vip.mate.tool.browser;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import java.util.ArrayList;
import java.util.List;
/**
* Accessibility-tree page snapshot for browser automation.
*
* <p>Replaces the older visible-text dump with a compact accessibility tree in
* which every interactive element is tagged with a stable reference handle
* ({@code @e1}, {@code @e2}, ...). The reference is materialised as a
* {@code data-mate-ref} attribute on the live DOM node, so a follow-up
* click/type action can address the element by {@code [data-mate-ref='eN']}
* instead of forcing the model to guess a brittle CSS selector.
*
* <p>Why an injected attribute rather than a framework-native snapshot: the
* attribute approach is independent of the browser-driver version, survives
* driver upgrades, and produces a selector that plugs straight into the
* existing click/type plumbing. References stay valid only for the snapshot
* that produced them a navigation wipes the attributes, so a stale reference
* naturally resolves to "not found" and the caller is told to re-snapshot.
*/
public final class PageSnapshotScript {
private PageSnapshotScript() {
}
/**
* Injected snapshot function. Runs as {@code root.evaluate(SNAPSHOT_JS, opts)}.
* Playwright's {@code ElementHandle.evaluate} invokes the function as
* {@code fn(element, arg)} the scoped root element is the FIRST positional
* parameter (NOT {@code this}, which Playwright never binds to the element),
* and the caller's {@code opts} object is the second. {@code opts} is
* {@code {maxLen, includeNonInteractive}}.
*
* <p>Returns a JSON string {@code {tree, truncated, refs}} where:
* <ul>
* <li>{@code tree} indented accessibility tree text for the model;</li>
* <li>{@code truncated} true when output was cut at the length budget;</li>
* <li>{@code refs} the list of reference ids assigned this snapshot.</li>
* </ul>
*/
public static final String SNAPSHOT_JS = """
(rootEl, opts) => {
const maxLen = opts.maxLen;
const includeNon = opts.includeNonInteractive;
const budget = { remaining: maxLen, truncated: false };
let counter = 0;
const refs = [];
// Wipe references from a prior snapshot so ids never collide
// across generations and a navigated-away page leaves nothing behind.
document.querySelectorAll('[data-mate-ref]').forEach(function (n) {
n.removeAttribute('data-mate-ref');
});
function isVisible(el) {
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
return el.offsetWidth > 0 || el.offsetHeight > 0 || el.getClientRects().length > 0;
}
function isInteractive(el) {
const tag = el.tagName.toLowerCase();
if (['a', 'button', 'input', 'select', 'textarea', 'summary'].includes(tag)) return true;
const role = el.getAttribute('role');
if (role && ['button', 'link', 'checkbox', 'radio', 'tab', 'menuitem',
'switch', 'textbox', 'combobox', 'option', 'searchbox', 'slider'].includes(role)) return true;
if (el.hasAttribute('onclick')) return true;
if (el.isContentEditable) return true;
const ti = el.getAttribute('tabindex');
if (ti !== null && ti !== '-1') return true;
return false;
}
function roleOf(el) {
const explicit = el.getAttribute('role');
if (explicit) return explicit;
const tag = el.tagName.toLowerCase();
switch (tag) {
case 'a': return el.hasAttribute('href') ? 'link' : 'generic';
case 'button': return 'button';
case 'select': return 'combobox';
case 'textarea': return 'textbox';
case 'summary': return 'button';
case 'input': {
const t = (el.getAttribute('type') || 'text').toLowerCase();
if (t === 'checkbox') return 'checkbox';
if (t === 'radio') return 'radio';
if (t === 'submit' || t === 'button' || t === 'reset') return 'button';
if (t === 'search') return 'searchbox';
if (t === 'hidden') return null;
return 'textbox';
}
case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6': return 'heading';
case 'li': return 'listitem';
case 'ul': case 'ol': return 'list';
case 'nav': return 'navigation';
case 'img': return 'img';
default: return null;
}
}
function nameOf(el) {
const aria = el.getAttribute('aria-label');
if (aria) return aria.trim();
const labelledby = el.getAttribute('aria-labelledby');
if (labelledby) {
const target = document.getElementById(labelledby);
if (target) return (target.textContent || '').trim();
}
const tag = el.tagName.toLowerCase();
if (tag === 'input' || tag === 'textarea') {
const ph = el.getAttribute('placeholder');
if (ph) return ph.trim();
if (el.value) return String(el.value).trim();
if (el.id) {
const lab = document.querySelector('label[for="' + (window.CSS ? CSS.escape(el.id) : el.id) + '"]');
if (lab) return (lab.textContent || '').trim();
}
// Wrapping label: <label>Customer name: <input></label> common
// and has no for= link, so climb to the nearest label ancestor.
const wrap = el.closest('label');
if (wrap) {
const wt = (wrap.textContent || '').trim().replace(/\\s+/g, ' ');
if (wt) return wt;
}
return '';
}
if (tag === 'img') {
const alt = el.getAttribute('alt');
if (alt) return alt.trim();
}
const title = el.getAttribute('title');
if (title) return title.trim();
const txt = el.textContent ? el.textContent.trim().replace(/\\s+/g, ' ') : '';
return txt;
}
function clip(s, n) {
if (!s) return '';
return s.length > n ? s.substring(0, n) + '…' : s;
}
const lines = [];
function emit(text) {
if (budget.remaining <= 0) { budget.truncated = true; return false; }
if (text.length + 1 > budget.remaining) {
budget.truncated = true;
budget.remaining = 0;
return false;
}
lines.push(text);
budget.remaining -= (text.length + 1);
return true;
}
function walk(el, depth) {
if (depth > 20 || budget.remaining <= 0) return;
if (!isVisible(el)) return;
const role = roleOf(el);
const interactive = isInteractive(el);
let line = null;
if (interactive && role !== 'generic' && role !== null) {
counter += 1;
const ref = 'e' + counter;
el.setAttribute('data-mate-ref', ref);
refs.push(ref);
const nm = clip(nameOf(el), 100);
line = role + (nm ? ' "' + nm + '"' : '') + ' @' + ref;
} else if (includeNon && role && role !== 'generic') {
const nm = clip(nameOf(el), 100);
if (nm || role === 'list' || role === 'navigation') {
let extra = '';
if (role === 'heading') {
const lvl = el.getAttribute('aria-level')
|| (el.tagName.length === 2 ? el.tagName.charAt(1) : '');
if (lvl) extra = ' [level=' + lvl + ']';
}
line = role + (nm ? ' "' + nm + '"' : '') + extra;
}
}
if (line !== null) {
if (!emit(' '.repeat(Math.min(depth, 10)) + '- ' + line)) return;
}
const childDepth = line !== null ? depth + 1 : depth;
for (const child of el.children) {
if (budget.remaining <= 0) break;
walk(child, childDepth);
}
}
walk(rootEl, 0);
return JSON.stringify({ tree: lines.join('\\n'), truncated: budget.truncated, refs: refs });
}
""";
/** Parsed result of a snapshot evaluation. */
public record Result(String tree, boolean truncated, List<String> refs) {
public static Result fromJson(String json) {
JSONObject obj = JSONUtil.parseObj(json);
String tree = obj.getStr("tree", "");
boolean truncated = obj.getBool("truncated", false);
List<String> refs = new ArrayList<>();
JSONArray arr = obj.getJSONArray("refs");
if (arr != null) {
for (Object o : arr) {
if (o != null) {
refs.add(o.toString());
}
}
}
return new Result(tree, truncated, refs);
}
}
/** Build the deterministic attribute selector for a reference id. */
public static String selectorForRef(String ref) {
return "[data-mate-ref='" + ref + "']";
}
}

View File

@ -4,8 +4,11 @@ import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.microsoft.playwright.Browser;
import com.microsoft.playwright.BrowserContext;
import com.microsoft.playwright.CDPSession;
import com.microsoft.playwright.ElementHandle;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.Playwright;
@ -20,7 +23,9 @@ import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import vip.mate.tool.browser.BrowserDiagnosticsService;
import vip.mate.tool.browser.BrowserLauncher;
import vip.mate.tool.browser.BrowserPrivacyGuard;
import vip.mate.common.net.SsrfProperties;
import vip.mate.tool.browser.PageSnapshotScript;
import vip.mate.tool.browser.UrlSafetyChecker;
import java.net.Socket;
@ -44,14 +49,16 @@ public class BrowserUseTool {
private static final int CDP_SCAN_PORT_MAX = 10000;
/**
* Snapshot extractor runs as an ElementHandle.evaluate so {@code this}
* is the scoped root (document.body when no selector is passed). Uses a
* budget object so truncation stops at element boundaries rather than
* mid-TEXT_NODE, and surfaces a {@code truncated:true} flag to the caller
* so the LLM can be told to retry with a narrower selector.
* Legacy visible-text extractor kept as a fallback: {@link #doSnapshot}
* prefers the accessibility-tree snapshot ({@link PageSnapshotScript}) and
* only falls back to this plain-text dump if the tree script throws on an
* unusual page. Runs as an ElementHandle.evaluate so {@code this} is the
* scoped root (document.body when no selector is passed). Uses a budget
* object so truncation stops at element boundaries rather than
* mid-TEXT_NODE, and surfaces a {@code truncated:true} flag.
*/
private static final String SNAPSHOT_JS = """
(maxLen) => {
private static final String TEXT_SNAPSHOT_JS_FALLBACK = """
(rootEl, maxLen) => {
const budget = { remaining: maxLen, truncated: false };
function getVisibleText(node, depth) {
if (depth > 10 || budget.remaining <= 0) return '';
@ -104,7 +111,7 @@ public class BrowserUseTool {
}
return results.join('\\n');
}
const text = getVisibleText(this, 0);
const text = getVisibleText(rootEl, 0);
return JSON.stringify({ text: text, truncated: budget.truncated });
}
""";
@ -114,15 +121,35 @@ public class BrowserUseTool {
private final BrowserLauncher launcher;
private final BrowserDiagnosticsService diagnostics;
private final SsrfProperties ssrfProperties;
private final BrowserPrivacyGuard privacyGuard;
public BrowserUseTool(vip.mate.channel.web.ChatStreamTracker streamTracker,
BrowserLauncher launcher,
BrowserDiagnosticsService diagnostics,
SsrfProperties ssrfProperties) {
SsrfProperties ssrfProperties,
BrowserPrivacyGuard privacyGuard) {
this.streamTracker = streamTracker;
this.launcher = launcher;
this.diagnostics = diagnostics;
this.ssrfProperties = ssrfProperties;
this.privacyGuard = privacyGuard;
}
/**
* Enforce the privacy guard for a content-reading action. Returns an error
* JSON string to return to the caller when the action is refused on a
* sensitive page of a user-managed browser, or {@code null} to proceed.
*/
private String guardReadOrNull(BrowserSession session, String action) {
String reason = privacyGuard.blockReason(session.isUserManagedBrowser(),
session.page.url(), action);
if (reason == null) {
return null;
}
String conversationId = ToolExecutionContext.conversationId(currentToolContext);
privacyGuard.audit(conversationId, action, session.page.url(), reason);
log.info("[BrowserUse] Privacy guard blocked action={} on {}", action, session.page.url());
return error(reason);
}
/**
@ -165,25 +192,34 @@ public class BrowserUseTool {
- start: Launch a new browser (tries system Chrome, system Edge, then Playwright bundled). Optional headed=true.
- stop: Close browser. If connected via CDP, only disconnects (Chrome keeps running).
- open: Navigate to a URL. Requires url parameter. Auto-starts browser if not running.
- snapshot: Get page text content, interactive elements, and title. Optional `selector`
scopes to a subtree USE IT when the page is large (big tables, long lists) to avoid
truncation. Without selector, content is capped and a `truncated:true` flag is returned
with a hint to retry using selector.
- snapshot: Get the page as an accessibility tree. Every interactive element (link, button,
input, ...) is tagged with a stable reference like `@e1`, `@e2`. Read the tree, then act on
an element by passing ref=<eN> to action=click/type no CSS selector guessing needed.
References belong to the returned `generation`; re-snapshot if the page changes. Optional
`selector` scopes to a subtree USE IT when the page is large to avoid truncation
(`truncated:true` is flagged with a hint).
- screenshot: Take a screenshot. Optional path to save file; returns base64 if no path.
- click: Click an element. Requires selector (CSS selector).
- type: Type text into an element. Requires selector and text.
- click: Click an element. Pass ref=<eN> from a snapshot (preferred) or a CSS selector.
- type: Type text into an element. Pass ref=<eN> (preferred) or selector, plus text.
- hover: Hover over an element (reveals menus/tooltips). Pass ref=<eN> or selector.
- select: Choose an option in a dropdown. Pass ref=<eN> or selector, plus value.
- eval: Execute JavaScript on the page. Requires code parameter. Top-level await is supported; use `return` to surface a value.
- cdp: Send a raw Chrome DevTools Protocol command. Requires method (e.g. 'Page.navigate'), optional params (JSON). Constrained by an allowlist; use only when the higher-level actions cannot express what you need.
- connect_cdp: Connect to an existing Chrome via CDP. Requires url (e.g. "http://localhost:9222").
- list_cdp_targets: Scan local ports (9000-10000) for CDP endpoints. Optional cdpPort for single port.
- navigate_back: Go back in browser history.
- diagnose: Run a self-check reports which launch strategies are available and what to install if none are.
""")
public String browser_use(
@ToolParam(description = "Action: start|stop|open|snapshot|screenshot|click|type|eval|connect_cdp|list_cdp_targets|navigate_back|diagnose") String action,
@ToolParam(description = "Action: start|stop|open|snapshot|screenshot|click|type|hover|select|eval|connect_cdp|list_cdp_targets|navigate_back|diagnose") String action,
@ToolParam(description = "URL to navigate to (for open), or CDP base URL (for connect_cdp, e.g. http://localhost:9222)", required = false) String url,
@ToolParam(description = "CSS selector. REQUIRED for click/type. OPTIONAL for snapshot: pass to scope to a subtree when previous snapshot returned truncated:true.", required = false) String selector,
@ToolParam(description = "CSS selector. Alternative to ref for click/type/hover/select. OPTIONAL for snapshot: pass to scope to a subtree when previous snapshot returned truncated:true.", required = false) String selector,
@ToolParam(description = "Element reference from a snapshot (e.g. 'e4'). PREFERRED for click/type/hover/select — takes priority over selector. Re-snapshot if it reports stale.", required = false) String ref,
@ToolParam(description = "Text to type (for action=type)", required = false) String text,
@ToolParam(description = "Option value or visible label to choose (for action=select)", required = false) String value,
@ToolParam(description = "JavaScript code to execute (for action=eval). Top-level await is allowed; add `return` to return a value when the snippet uses await.", required = false) String code,
@ToolParam(description = "CDP method for action=cdp (e.g. 'Page.navigate', 'Input.dispatchMouseEvent'). Must be in the allowlist.", required = false) String method,
@ToolParam(description = "JSON object of params for action=cdp (e.g. {\"url\":\"https://example.com\"})", required = false) String params,
@ToolParam(description = "File path to save screenshot (for action=screenshot)", required = false) String path,
@ToolParam(description = "Launch visible browser window (for action=start, default false)", required = false) Boolean headed,
@ToolParam(description = "Single CDP port to scan (for action=list_cdp_targets)", required = false) Integer cdpPort,
@ -199,8 +235,14 @@ public class BrowserUseTool {
return error("action is required");
}
String sessionKey = "default";
log.info("[BrowserUse] action={}, url={}, selector={}, headed={}, cdpPort={}", action, url, selector, headed, cdpPort);
// Isolate browser state per conversation so concurrent chats don't drive
// (and navigate) each other's page. Falls back to a shared key when no
// conversation context is present (e.g. internal/system invocations).
String conversationId = ToolExecutionContext.conversationId(ctx);
String sessionKey = (conversationId != null && !conversationId.isBlank())
? conversationId : "default";
log.info("[BrowserUse] action={}, session={}, url={}, selector={}, headed={}, cdpPort={}",
action, sessionKey, url, selector, headed, cdpPort);
try {
return switch (action.toLowerCase().trim()) {
@ -209,14 +251,17 @@ public class BrowserUseTool {
case "open" -> doOpen(sessionKey, url);
case "snapshot" -> doSnapshot(sessionKey, selector);
case "screenshot" -> doScreenshot(sessionKey, path);
case "click" -> doClick(sessionKey, selector);
case "type" -> doType(sessionKey, selector, text);
case "click" -> doClick(sessionKey, ref, selector);
case "type" -> doType(sessionKey, ref, selector, text);
case "hover" -> doHover(sessionKey, ref, selector);
case "select" -> doSelect(sessionKey, ref, selector, value);
case "eval" -> doEval(sessionKey, code);
case "cdp" -> doCdp(sessionKey, method, params);
case "connect_cdp" -> doConnectCdp(sessionKey, url);
case "list_cdp_targets" -> doListCdpTargets(cdpPort);
case "navigate_back" -> doNavigateBack(sessionKey);
case "diagnose" -> doDiagnose();
default -> error("Unknown action: " + action + ". Supported: start, stop, open, snapshot, screenshot, click, type, eval, connect_cdp, list_cdp_targets, navigate_back, diagnose");
default -> error("Unknown action: " + action + ". Supported: start, stop, open, snapshot, screenshot, click, type, hover, select, eval, cdp, connect_cdp, list_cdp_targets, navigate_back, diagnose");
};
} catch (PlaywrightException e) {
log.error("[BrowserUse] Playwright error: {}", e.getMessage());
@ -520,6 +565,7 @@ public class BrowserUseTool {
}
session.touch();
session.invalidateRefs();
Page page = session.page;
page.navigate(normalizedUrl);
@ -546,6 +592,7 @@ public class BrowserUseTool {
}
session.touch();
session.invalidateRefs();
session.page.goBack();
String title = session.page.title();
@ -567,6 +614,11 @@ public class BrowserUseTool {
return error("No browser running. Use action=start first.");
}
String blocked = guardReadOrNull(session, "snapshot");
if (blocked != null) {
return blocked;
}
session.touch();
Page page = session.page;
@ -590,31 +642,70 @@ public class BrowserUseTool {
}
int maxLen = launcher.properties().getSnapshotMaxLength();
String jsResult = (String) root.evaluate(SNAPSHOT_JS, maxLen);
// JS returns { text: "...", truncated: true/false }
JSONObject parsed = JSONUtil.parseObj(jsResult);
String textContent = parsed.getStr("text");
boolean truncated = parsed.getBool("truncated", false);
boolean includeNon = launcher.properties().isSnapshotIncludeNonInteractive();
JSONObject result = new JSONObject();
result.set("ok", true);
result.set("title", title);
result.set("url", url);
// IMPORTANT: truncated + hint MUST come before content. The framework's
// spill-preview keeps only the head ~800 chars of the JSON, so placing
// these flags first ensures the LLM still sees them after a spill.
result.set("truncated", truncated);
if (truncated) {
result.set("hint", "Content truncated. Re-call browser_use with action=snapshot"
+ " and selector=<CSS> to scope to a subtree (e.g. selector='#main',"
+ " selector='table tbody tr').");
try {
// Accessibility-tree snapshot: assigns stable @eN references to
// interactive elements (materialised as data-mate-ref attributes)
// so click/type can address them precisely instead of guessing a
// CSS selector. Bumps the session generation so a later action on a
// stale reference (page changed / navigated) resolves to not-found.
JSONObject opts = new JSONObject();
opts.set("maxLen", maxLen);
opts.set("includeNonInteractive", includeNon);
String jsResult = (String) root.evaluate(PageSnapshotScript.SNAPSHOT_JS, opts);
PageSnapshotScript.Result snap = PageSnapshotScript.Result.fromJson(jsResult);
int generation = session.nextSnapshotGeneration(snap.refs());
result.set("snapshotMode", "accessibility-tree");
result.set("generation", generation);
// IMPORTANT: flags/hints MUST precede the (large) tree. The framework's
// spill-preview keeps only the head of the JSON, so ordering these
// first ensures the LLM still sees them after a spill.
result.set("truncated", snap.truncated());
result.set("hint", "Interactive elements are tagged @eN. To act on one, call"
+ " action=click or action=type with ref=<eN> (e.g. ref='e4') — no CSS"
+ " selector needed. References are valid only for generation "
+ generation + "; re-snapshot if the page changes."
+ (snap.truncated() ? " Content truncated — pass selector=<CSS> to scope"
+ " to a subtree (e.g. selector='#main')." : ""));
if (selector != null && !selector.isBlank()) {
result.set("scopedTo", selector);
}
result.set("refCount", snap.refs().size());
result.set("content", snap.tree());
return JSONUtil.toJsonPrettyStr(result);
} catch (PlaywrightException e) {
// Rare pages break the tree walk (exotic custom elements, CSP on
// attribute writes). Fall back to the plain visible-text dump so the
// model still gets *something* readable, flagged so it knows refs
// are unavailable and it must use CSS selectors for this page.
log.warn("[BrowserUse] Accessibility snapshot failed, falling back to text dump: {}", e.getMessage());
// The tree script wipes data-mate-ref attributes before it walks, so a
// mid-walk failure leaves the DOM with no refs. Drop currentRefs too,
// otherwise a later ref action would pass the stale-check but resolve
// to nothing (bare timeout) instead of a clean re-snapshot prompt.
session.invalidateRefs();
String jsResult = (String) root.evaluate(TEXT_SNAPSHOT_JS_FALLBACK, maxLen);
JSONObject parsed = JSONUtil.parseObj(jsResult);
boolean truncated = parsed.getBool("truncated", false);
result.set("snapshotMode", "text-fallback");
result.set("truncated", truncated);
result.set("hint", "Accessibility tree unavailable on this page — no @eN refs."
+ " Use action=click/type with selector=<CSS>."
+ (truncated ? " Content truncated — pass selector=<CSS> to scope." : ""));
if (selector != null && !selector.isBlank()) {
result.set("scopedTo", selector);
}
result.set("content", parsed.getStr("text"));
return JSONUtil.toJsonPrettyStr(result);
}
if (selector != null && !selector.isBlank()) {
result.set("scopedTo", selector);
}
result.set("content", textContent);
return JSONUtil.toJsonPrettyStr(result);
}
private String doScreenshot(String sessionKey, String path) {
@ -623,6 +714,11 @@ public class BrowserUseTool {
return error("No browser running. Use action=start first.");
}
String blocked = guardReadOrNull(session, "screenshot");
if (blocked != null) {
return blocked;
}
session.touch();
Page page = session.page;
@ -654,63 +750,159 @@ public class BrowserUseTool {
}
}
private String doClick(String sessionKey, String selector) {
if (selector == null || selector.isBlank()) {
return error("selector is required for action=click");
/** Result of resolving a click/type/hover/select target: a selector, or an error to return. */
private record TargetResolution(String selector, String label, String error) {
static TargetResolution ok(String selector, String label) {
return new TargetResolution(selector, label, null);
}
static TargetResolution fail(String error) {
return new TargetResolution(null, null, error);
}
}
/**
* Resolve an action target to a CSS selector. A snapshot {@code ref} takes
* priority over an explicit CSS {@code selector}; a ref that is not part of
* the current snapshot is reported as stale so the caller re-snapshots
* instead of getting a bare element-not-found timeout.
*/
private TargetResolution resolveTarget(BrowserSession session, String ref, String selector) {
if (ref != null && !ref.isBlank()) {
String r = ref.trim();
if (!session.currentRefs.contains(r)) {
return TargetResolution.fail("ref '" + r + "' is not part of the current snapshot"
+ " (generation " + session.snapshotGeneration + "). The page likely changed,"
+ " or you have not snapshotted since it did. Call action=snapshot first,"
+ " then use a ref from that fresh result.");
}
return TargetResolution.ok(PageSnapshotScript.selectorForRef(r), r);
}
if (selector != null && !selector.isBlank()) {
return TargetResolution.ok(selector, selector);
}
return TargetResolution.fail("Either ref (from a snapshot, e.g. ref='e4') or a CSS selector is required.");
}
private String doClick(String sessionKey, String ref, String selector) {
BrowserSession session = requireSession(sessionKey);
if (session == null) {
return error("No browser running. Use action=start first.");
}
TargetResolution t = resolveTarget(session, ref, selector);
if (t.error() != null) {
return error(t.error());
}
session.touch();
Page page = session.page;
page.click(selector);
String before = page.url();
page.click(t.selector());
page.waitForLoadState(LoadState.DOMCONTENTLOADED);
String title = page.title();
String url = page.url();
// A navigation invalidates the snapshot references; a same-page click
// (toggle, expand) keeps them so the model can act on more refs.
if (!url.equals(before)) {
session.invalidateRefs();
}
log.info("[BrowserUse] Clicked: {} (page now: {})", selector, url);
log.info("[BrowserUse] Clicked: {} (page now: {})", t.label(), url);
broadcastBrowserEvent("click", true, url, title, null, 0);
JSONObject result = new JSONObject();
result.set("ok", true);
result.set("selector", selector);
result.set("target", t.label());
result.set("currentUrl", url);
result.set("currentTitle", title);
result.set("message", "Clicked element: " + selector);
if (!url.equals(before)) {
result.set("navigated", true);
result.set("hint", "The page navigated — previous @eN refs are stale. Re-snapshot before acting.");
}
result.set("message", "Clicked element: " + t.label());
return JSONUtil.toJsonPrettyStr(result);
}
private String doType(String sessionKey, String selector, String text) {
if (selector == null || selector.isBlank()) {
return error("selector is required for action=type");
}
private String doType(String sessionKey, String ref, String selector, String text) {
if (text == null) {
return error("text is required for action=type");
}
BrowserSession session = requireSession(sessionKey);
if (session == null) {
return error("No browser running. Use action=start first.");
}
TargetResolution t = resolveTarget(session, ref, selector);
if (t.error() != null) {
return error(t.error());
}
session.touch();
Page page = session.page;
session.page.fill(t.selector(), text);
page.fill(selector, text);
log.info("[BrowserUse] Typed into: {} ({} chars)", selector, text.length());
log.info("[BrowserUse] Typed into: {} ({} chars)", t.label(), text.length());
broadcastBrowserEvent("type", true, null, null, null, 0);
JSONObject result = new JSONObject();
result.set("ok", true);
result.set("selector", selector);
result.set("target", t.label());
result.set("textLength", text.length());
result.set("message", "Typed " + text.length() + " characters into " + selector);
result.set("message", "Typed " + text.length() + " characters into " + t.label());
return JSONUtil.toJsonPrettyStr(result);
}
private String doHover(String sessionKey, String ref, String selector) {
BrowserSession session = requireSession(sessionKey);
if (session == null) {
return error("No browser running. Use action=start first.");
}
TargetResolution t = resolveTarget(session, ref, selector);
if (t.error() != null) {
return error(t.error());
}
session.touch();
session.page.hover(t.selector());
log.info("[BrowserUse] Hovered: {}", t.label());
broadcastBrowserEvent("hover", true, null, null, null, 0);
JSONObject result = new JSONObject();
result.set("ok", true);
result.set("target", t.label());
result.set("message", "Hovered element: " + t.label()
+ ". Re-snapshot to capture any menu/tooltip it revealed.");
return JSONUtil.toJsonPrettyStr(result);
}
private String doSelect(String sessionKey, String ref, String selector, String value) {
if (value == null || value.isBlank()) {
return error("value is required for action=select");
}
BrowserSession session = requireSession(sessionKey);
if (session == null) {
return error("No browser running. Use action=start first.");
}
TargetResolution t = resolveTarget(session, ref, selector);
if (t.error() != null) {
return error(t.error());
}
session.touch();
// Playwright matches by option value, label, or visible text, so a
// human-readable value from the model works without extra hints.
List<String> chosen = session.page.selectOption(t.selector(), value);
log.info("[BrowserUse] Selected {} in {} -> {}", value, t.label(), chosen);
broadcastBrowserEvent("select", true, null, null, null, 0);
JSONObject result = new JSONObject();
result.set("ok", true);
result.set("target", t.label());
result.set("selected", chosen);
result.set("message", chosen.isEmpty()
? "No option matched '" + value + "'. Re-snapshot and check the option labels."
: "Selected '" + value + "' in " + t.label());
return JSONUtil.toJsonPrettyStr(result);
}
@ -737,6 +929,11 @@ public class BrowserUseTool {
return error("No browser running. Use action=start first.");
}
String blocked = guardReadOrNull(session, "eval");
if (blocked != null) {
return blocked;
}
session.touch();
Page page = session.page;
@ -777,6 +974,110 @@ public class BrowserUseTool {
return JSONUtil.toJsonPrettyStr(result);
}
/**
* CDP methods that read page/network/storage content. Even when allowlisted,
* these are refused by the privacy guard on a sensitive page of a
* user-managed browser. Entries ending in {@code .} match a whole domain.
*/
private static final List<String> CDP_CONTENT_READ = List.of(
"Network.getResponseBody", "Network.getResponseBodyForInterception",
"Network.getRequestPostData", "Network.getAllCookies", "Network.getCookies",
"Storage.", "DOMStorage.", "IndexedDB.", "CacheStorage.",
"Page.captureScreenshot", "Page.captureSnapshot", "Page.printToPDF",
"Page.getResourceContent", "Page.getResourceTree",
"DOM.getOuterHTML", "DOM.getDocument", "Runtime.evaluate", "Runtime.getProperties");
private boolean isCdpMethodAllowed(String method) {
for (String entry : launcher.properties().getCdp().getAllowedMethods()) {
if (entry == null || entry.isBlank()) {
continue;
}
String e = entry.trim();
if (e.endsWith(".*")) {
if (method.startsWith(e.substring(0, e.length() - 1))) { // "Input." prefix
return true;
}
} else if (e.equals(method)) {
return true;
}
}
return false;
}
private static boolean isCdpContentReading(String method) {
for (String p : CDP_CONTENT_READ) {
if (p.endsWith(".") ? method.startsWith(p) : method.equals(p)) {
return true;
}
}
return false;
}
private String doCdp(String sessionKey, String method, String paramsJson) {
if (!launcher.properties().getCdp().isEnabled()) {
return error("action=cdp is disabled (mateclaw.browser.cdp.enabled=false).");
}
if (method == null || method.isBlank()) {
return error("method is required for action=cdp (e.g. 'Page.navigate').");
}
BrowserSession session = requireSession(sessionKey);
if (session == null) {
return error("No browser running. Use action=start first.");
}
String m = method.trim();
if (!isCdpMethodAllowed(m)) {
return error("CDP method '" + m + "' is not allowed. Allowlist: "
+ launcher.properties().getCdp().getAllowedMethods()
+ ". Add it to mateclaw.browser.cdp.allowed-methods if you trust it.");
}
if (isCdpContentReading(m)) {
String reason = privacyGuard.blockReason(session.isUserManagedBrowser(),
session.page.url(), "cdp:" + m);
if (reason != null) {
privacyGuard.audit(ToolExecutionContext.conversationId(currentToolContext),
"cdp:" + m, session.page.url(), reason);
return error(reason);
}
}
JsonObject parsed = null;
if (paramsJson != null && !paramsJson.isBlank()) {
try {
parsed = JsonParser.parseString(paramsJson).getAsJsonObject();
} catch (RuntimeException e) {
return error("params must be a JSON object: " + e.getMessage());
}
}
session.touch();
// A fresh CDP session per call keeps the escape hatch stateless and avoids
// leaking listeners; navigation-triggering methods invalidate references.
CDPSession cdp = session.context.newCDPSession(session.page);
try {
JsonObject res = parsed != null ? cdp.send(m, parsed) : cdp.send(m);
if (m.startsWith("Page.navigate") || m.equals("Page.navigateToHistoryEntry")) {
session.invalidateRefs();
}
String out = res != null ? res.toString() : "{}";
if (out.length() > 10_000) {
out = out.substring(0, 10_000) + "\n... [truncated]";
}
log.info("[BrowserUse] CDP {} -> {} chars", m, out.length());
broadcastBrowserEvent("cdp", true, session.page.url(), null, null, 0);
JSONObject result = new JSONObject();
result.set("ok", true);
result.set("method", m);
result.set("result", out);
return JSONUtil.toJsonPrettyStr(result);
} finally {
try {
cdp.detach();
} catch (Exception ignored) {
// best-effort; the session is discarded either way
}
}
}
// ==================== CDP Helpers ====================
private boolean isPortOpen(int port) {
@ -917,6 +1218,35 @@ public class BrowserUseTool {
/** 空闲看门狗定时任务stop 时取消,避免泄漏) */
volatile ScheduledFuture<?> idleWatchdog;
/**
* Monotonic snapshot generation. Each {@code action=snapshot} bumps it and
* replaces {@link #currentRefs} with the references assigned that pass.
* A click/type by a reference not in {@link #currentRefs} is reported as
* stale, prompting the caller to re-snapshot. Safe as plain volatile
* because tool calls are serialized per executor.
*/
volatile int snapshotGeneration;
volatile java.util.Set<String> currentRefs = java.util.Set.of();
int nextSnapshotGeneration(java.util.List<String> refs) {
this.currentRefs = java.util.Set.copyOf(refs);
return ++this.snapshotGeneration;
}
/** Drop all references — the DOM they pointed at is gone (navigation). */
void invalidateRefs() {
this.currentRefs = java.util.Set.of();
}
/**
* True when this session is attached to a Chrome the user runs themselves:
* connected over CDP and NOT spawned by us. Only these carry the user's
* live logins, so the privacy guard applies only here.
*/
boolean isUserManagedBrowser() {
return connectedViaCdp && ownedProcess == null;
}
BrowserSession(Browser browser, BrowserContext context, Page page,
boolean headed, boolean connectedViaCdp, String cdpUrl,
java.nio.file.Path userDataDir, Process ownedProcess) {

View File

@ -5,6 +5,7 @@ import vip.mate.workspace.core.service.ChatUploadLocationResolver;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
@ -97,13 +98,32 @@ public final class ChatUploadResolver {
Set<Path> dirs = new LinkedHashSet<>();
String basePath = ToolExecutionContext.workspaceBasePath();
if (basePath != null && !basePath.isBlank()) {
dirs.add(Paths.get(basePath).toAbsolutePath().normalize()
.resolve(UPLOAD_SUBDIR).resolve(conversationId));
Path scopedRoot = Paths.get(basePath).toAbsolutePath().normalize().resolve(UPLOAD_SUBDIR);
addConversationDirs(dirs, scopedRoot, conversationId);
}
dirs.add(defaultRoot.resolve(conversationId).toAbsolutePath().normalize());
addConversationDirs(dirs, defaultRoot, conversationId);
return new ArrayList<>(dirs);
}
/**
* Add a conversation's attachment dir under {@code root} to {@code dirs}:
* the sanitized segment first (matching the write path), then for
* backward compatibility with pre-fix Linux uploads that used the raw id
* verbatim the raw-id dir when it differs and is a legal path on this OS.
*/
private static void addConversationDirs(Set<Path> dirs, Path root, String conversationId) {
String safe = ChatUploadLocationResolver.sanitizeSegment(conversationId);
dirs.add(root.resolve(safe).toAbsolutePath().normalize());
if (!safe.equals(conversationId)) {
try {
dirs.add(root.resolve(conversationId).toAbsolutePath().normalize());
} catch (InvalidPathException ignore) {
// Raw id illegal on this filesystem (e.g. ':' on Windows) no
// legacy attachments could exist there.
}
}
}
private static Path resolveIn(String rawPath, Path uploadDir) {
if (!Files.isDirectory(uploadDir)) {
return null;

View File

@ -0,0 +1,39 @@
package vip.mate.tool.builtin;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import java.util.Arrays;
/**
* Built-in tool: server-side compliance scan for 公众号 / 小红书 copy. Deterministic
* backstop for the model's skill-side self-check catches 广告法 极限词, WeChat 诱导
* words, 承诺收益/效果 and 医疗功效 claims. Run it before packaging or publishing.
*/
@Slf4j
@Component
public class ComplianceScanTool {
@Tool(name = "compliance_scan", description = """
Scan 公众号/小红书 copy (title + body) for policy violations before publishing:
广告法 极限词 (/第一/唯一/国家级/100%), WeChat 诱导 words (集赞/助力/分享解锁/
关注才能看), 承诺收益/效果 (保本/稳赚/包过), and 医疗功效 (治愈/抗癌).
Returns a report listing each hit by category and whether it's high-risk.
High-risk hits (极限词 / 诱导 / 承诺收益) should be replaced before publishing
the 公众号 publish path hard-blocks a mass-send on them.
""")
public String compliance_scan(
@ToolParam(description = "Text to scan (title + body)")
String text,
@ToolParam(description = "Extra banned words to enforce, comma-separated (e.g. recalled banned_words)", required = false)
String extraBannedWords) {
ComplianceScanner.Result result = (extraBannedWords == null || extraBannedWords.isBlank())
? ComplianceScanner.scan(text)
: ComplianceScanner.scan(text, Arrays.asList(extraBannedWords.split(",")));
log.info("[ComplianceScan] hits={}, highRisk={}", result.hits().size(), result.hasHighRisk());
return ComplianceScanner.report(result);
}
}

View File

@ -0,0 +1,130 @@
package vip.mate.tool.builtin;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Server-side hard compliance scan for content bound for 公众号 / 小红书.
* Model-side self-checks (skills) can be skipped or hallucinated; this is a
* deterministic backstop that publish paths can enforce.
*
* <p>Four categories, roughly ordered by account risk:
* <ul>
* <li>{@code 广告法极限词} 绝对化用语/第一/唯一/国家级/100%</li>
* <li>{@code 微信诱导} 诱导分享/关注集赞/助力/分享解锁/关注才能看 WeChat's most account-fatal rule</li>
* <li>{@code 承诺收益/效果} 保本/稳赚/包过/根治</li>
* <li>{@code 医疗功效} 治愈/抗癌/包瘦/排毒</li>
* </ul>
* The first three are treated as high-risk (a publish path may block on them).
*/
final class ComplianceScanner {
private ComplianceScanner() {
}
/** Category name → matching pattern. Ordered by severity for stable output. */
private static final Map<String, Pattern> RULES = new LinkedHashMap<>();
/** Categories that a publish path should hard-block on. */
private static final List<String> HIGH_RISK = List.of("广告法极限词", "微信诱导", "承诺收益/效果");
static {
RULES.put("广告法极限词", Pattern.compile(
"最佳|最好|最优|最强|最高级|最便宜|最先进|最顶级|第一品牌|全国第一|全球第一"
+ "|唯一|独家|首个|首选|冠军|领导品牌|国家级|世界级|国际级|顶级|极致"
+ "|100%|百分百|绝对|彻底根治|永久|包治|一劳永逸"));
RULES.put("微信诱导", Pattern.compile(
"集赞|助力|砍一刀|分享到朋友圈|分享后解锁|分享解锁|分享可见|转发抽奖|转发领取"
+ "|不转不是|关注才能看|关注才可见|关注领取|关注解锁|扫码加个人微信|加我微信领"));
RULES.put("承诺收益/效果", Pattern.compile(
"保本|稳赚|稳赚不赔|保收益|保底收益|包赚|躺赚|一夜暴富"
+ "|包过|保过|保分|名校保录|包录取|包就业|包瘦身"));
RULES.put("医疗功效", Pattern.compile(
"治愈|根治|抗癌|防癌|包瘦|排毒|壮阳|丰胸|生发防脱|药到病除|无副作用"));
}
/** One category's hits. */
record CategoryHit(String category, List<String> terms, boolean highRisk) {}
/** Full scan result. */
record Result(List<CategoryHit> hits) {
boolean clean() {
return hits.isEmpty();
}
boolean hasHighRisk() {
return hits.stream().anyMatch(CategoryHit::highRisk);
}
}
/** Scan text for policy violations across all categories. */
static Result scan(String text) {
List<CategoryHit> hits = new ArrayList<>();
if (text == null || text.isBlank()) {
return new Result(hits);
}
for (Map.Entry<String, Pattern> rule : RULES.entrySet()) {
List<String> terms = new ArrayList<>();
Matcher m = rule.getValue().matcher(text);
while (m.find()) {
String term = m.group();
if (!terms.contains(term)) {
terms.add(term);
}
}
if (!terms.isEmpty()) {
hits.add(new CategoryHit(rule.getKey(), terms, HIGH_RISK.contains(rule.getKey())));
}
}
return new Result(hits);
}
/**
* Scan with additional user-supplied banned words merged in as a
* (non-high-risk) {@code 自定义禁用词} category e.g. the user's
* {@code banned_words} memory or brand-forbidden terms.
*/
static Result scan(String text, Collection<String> extraTerms) {
Result base = scan(text);
if (extraTerms == null || extraTerms.isEmpty() || text == null || text.isBlank()) {
return base;
}
List<String> hitTerms = new ArrayList<>();
for (String t : extraTerms) {
if (t == null) {
continue;
}
String term = t.trim();
if (!term.isEmpty() && text.contains(term) && !hitTerms.contains(term)) {
hitTerms.add(term);
}
}
if (hitTerms.isEmpty()) {
return base;
}
List<CategoryHit> all = new ArrayList<>(base.hits());
all.add(new CategoryHit("自定义禁用词", hitTerms, false));
return new Result(all);
}
/** Render a scan result as a short Chinese report. */
static String report(Result result) {
if (result.clean()) {
return "✅ 合规扫描:未命中极限词 / 诱导词 / 承诺收益 / 功效违禁词。";
}
StringBuilder sb = new StringBuilder("⚠️ 合规扫描命中:\n");
for (CategoryHit h : result.hits()) {
sb.append("- [").append(h.category()).append(h.highRisk() ? " · 高危" : "")
.append("] ").append(String.join("", h.terms())).append('\n');
}
sb.append(result.hasHighRisk()
? "含高危词,发布前必须替换(尤其微信诱导词,易限流/封号)。"
: "建议替换后再发布。");
return sb.toString();
}
}

View File

@ -0,0 +1,130 @@
package vip.mate.tool.builtin;
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.context.ChatOrigin;
import vip.mate.content.model.ContentItemEntity;
import vip.mate.content.service.ContentItemService;
import java.util.List;
/**
* Built-in tool: the content calendar / dedup ledger for the content studio.
* Thin wrapper over {@link ContentItemService}; the package tools also call the
* service to auto-record on delivery.
*
* <p>Actions:
* <ul>
* <li>{@code check_recent} has this topic been produced on this platform in
* the last N days? Call BEFORE committing to a topic.</li>
* <li>{@code record} log a produced piece (usually done automatically by the
* package tools; available for manual use).</li>
* <li>{@code mark_published} flip an item to published.</li>
* </ul>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ContentItemTool {
private static final int DEFAULT_RECENT_DAYS = 14;
private final ContentItemService contentItemService;
@Tool(name = "content_item", description = """
Content calendar / dedup ledger for 公众号 & 小红书 pieces.
Actions:
- check_recent: has `topic` already been produced for `platform`
(gzh|xhs) within the last `days` (default 14)? Call this BEFORE picking
a topic in a scheduled run, to avoid repeats. Only counts committed
pieces (packaged/published). Returns whether it's a repeat plus titles.
- record: log a produced piece (platform, topic, title, status). NOTE:
gzh_package / xhs_package already auto-record on delivery, so you rarely
need this by hand.
- mark_published: set item `id` to published with optional externalRef.
""")
public String content_item(
@ToolParam(description = "Action: check_recent | record | mark_published")
String action,
@ToolParam(description = "Platform: gzh (公众号) or xhs (小红书)", required = false)
String platform,
@ToolParam(description = "Topic text (check_recent / record)", required = false)
String topic,
@ToolParam(description = "Title of the produced piece (record)", required = false)
String title,
@ToolParam(description = "Lifecycle status for record: draft|packaged|published", required = false)
String status,
@ToolParam(description = "Online preview link (record)", required = false)
String previewUrl,
@ToolParam(description = "Platform ref — draft media_id / publish id (record / mark_published)", required = false)
String externalRef,
@ToolParam(description = "Recency window in days for check_recent (default 14)", required = false)
Integer days,
@ToolParam(description = "Item id (mark_published)", required = false)
Long id,
@Nullable ToolContext ctx) {
String act = action == null ? "" : action.trim().toLowerCase();
return switch (act) {
case "check_recent" -> checkRecent(platform, topic, days);
case "record" -> record(platform, topic, title, status, previewUrl, externalRef, ctx);
case "mark_published" -> markPublished(id, externalRef);
default -> "Error: unknown action '" + act + "'. Use check_recent | record | mark_published.";
};
}
private String checkRecent(String platform, String topic, Integer days) {
if (isBlank(platform) || isBlank(topic)) {
return "Error: platform and topic are required for check_recent.";
}
int window = (days == null || days <= 0) ? DEFAULT_RECENT_DAYS : days;
List<ContentItemEntity> recent = contentItemService.findRecent(platform, topic, window);
if (recent.isEmpty()) {
return "✅ 未重复:最近 " + window + " 天没有在 " + platform + " 做过「" + topic + "」,可以继续。";
}
StringBuilder sb = new StringBuilder();
sb.append("⚠️ 疑似重复:最近 ").append(window).append(" 天已在 ").append(platform)
.append(" 做过同题「").append(topic).append("").append(recent.size()).append(" 次:\n");
for (ContentItemEntity e : recent) {
sb.append("- ").append(e.getCreateTime() != null ? e.getCreateTime().toLocalDate() : "?")
.append("").append(e.getTitle() != null ? e.getTitle() : "(无标题)")
.append("").append(e.getStatus()).append('\n');
}
sb.append("建议换个角度或另选选题。");
return sb.toString();
}
private String record(String platform, String topic, String title, String status,
String previewUrl, String externalRef, @Nullable ToolContext ctx) {
if (isBlank(platform) || isBlank(topic)) {
return "Error: platform and topic are required for record.";
}
Long id = contentItemService.record(workspaceFromContext(ctx), platform, topic, title,
status, previewUrl, externalRef);
return "✅ 已记入内容日历。item id: " + id + "status=" + (isBlank(status) ? "packaged" : status) + "";
}
private String markPublished(Long id, String externalRef) {
if (id == null) {
return "Error: id is required for mark_published.";
}
return contentItemService.markPublished(id, externalRef)
? "✅ 已标记为已发布。item id: " + id
: "Error: content item " + id + " not found.";
}
private static Long workspaceFromContext(@Nullable ToolContext ctx) {
ChatOrigin origin = ChatOrigin.from(ctx);
return origin != null && origin.workspaceId() != null ? origin.workspaceId() : 1L;
}
private static boolean isBlank(String s) {
return s == null || s.isBlank();
}
}

View File

@ -0,0 +1,393 @@
package vip.mate.tool.builtin;
import cn.hutool.http.HttpUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension;
import org.commonmark.ext.gfm.tables.TablesExtension;
import org.commonmark.parser.Parser;
import org.commonmark.renderer.html.HtmlRenderer;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
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.context.ChatOrigin;
import vip.mate.content.service.ContentItemService;
import vip.mate.tool.browser.UrlSafetyChecker;
import vip.mate.tool.document.GeneratedFileCache;
import javax.imageio.ImageIO;
import java.awt.Color;
import java.awt.GradientPaint;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
/**
* Built-in tool: assemble a finished WeChat Official Account (公众号) image-text
* article from compact Markdown and deliver it as an online preview plus a
* downloadable material bundle.
*
* <p>Why Markdown in, HTML out: emitting a full inline-styled article HTML as a
* single tool-call argument is fragile on streaming providers the large,
* escape-heavy string can be truncated during argument aggregation, yielding
* invalid JSON that gets dropped and sending the agent into a retry loop. The
* caller therefore passes the body as Markdown (compact, few escapes); this tool
* converts it to WeChat-editor-compatible <b>inline-styled</b> HTML server-side
* (公众号 ignores {@code <style>} blocks and classes), so styling never rides on
* the model's token stream.
*
* <p>Outputs, Manus-style:
* <ul>
* <li><b>Online preview</b> the rendered HTML is stored as {@code text/html}
* and served inline (behind a strict CSP), so the link opens the finished
* article in the browser.</li>
* <li><b>Material bundle</b> a {@code .zip} with {@code article.html},
* {@code article.md} and the cover image for one-click download.</li>
* </ul>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class GzhPackageTool {
// Palette mirrors references/gzh_layout.html so packaged articles match the skill's template.
private static final String INK = "#1a1a1a";
private static final String MUTED = "#6b6b6b";
private static final String FAINT = "#9a9a9a";
private static final String ACCENT = "#2f6fed";
private static final String HAIRLINE = "#ececec";
private static final String CODE_BG = "#f6f8fa";
private final GeneratedFileCache cache;
private final ContentItemService contentItemService;
@Tool(name = "gzh_package", description = """
Package a finished WeChat Official Account (公众号) article and return an
online preview link plus a downloadable material bundle.
Pass the article body as **Markdown** (headings, paragraphs, lists, quotes,
fenced code, tables). Do NOT hand-write a big inline-styled HTML string
this tool builds the 公众号-compatible inline-styled HTML server-side, which
avoids the tool-argument truncation that large HTML blobs cause.
Returns:
- 在线预览 link (opens the rendered article in the browser),
- 素材下载 .zip (article.html + article.md + cover image),
- the inline-styled HTML to paste into the 公众号 editor.
Use this as the delivery step of gzh_article instead of write_file +
render_html_image on a hand-built HTML file.
""")
public String gzh_package(
@ToolParam(description = "Article title")
String title,
@ToolParam(description = "Article body in Markdown")
String markdown,
@ToolParam(description = "Cover image reference: a render_html_image / image URL (/api/v1/files/generated/<id>), http(s) URL, or omit", required = false)
String coverImageUrl,
@ToolParam(description = "Author / source name", required = false)
String author,
@ToolParam(description = "Selected topic (for the content ledger; falls back to title)", required = false)
String topic,
@Nullable ToolContext ctx) {
if (title == null || title.isBlank()) {
return "Error: title is required.";
}
if (markdown == null || markdown.isBlank()) {
return "Error: markdown body is required.";
}
// 1. Markdown -> HTML fragment, then inline every style (公众号 drops <style>/class).
String innerHtml = markdownToInlineHtml(markdown);
// Resolve the cover to real image bytes + a servable URL up front, so the
// preview never embeds a broken <img>: a reference that doesn't resolve to
// an actual image is dropped (and flagged) rather than rendered. This also
// self-heals a reference that points at the file's name instead of its id.
ResolvedCover cover = resolveCover(coverImageUrl, ctx);
// Fallback: 公众号 requires a cover to publish, so never ship without one
// synthesize a neutral gradient placeholder when none resolves.
boolean placeholderCover = false;
if (cover == null) {
byte[] ph = placeholderCover();
cover = new ResolvedCover(ph, store(ph, "gzh-cover-placeholder.png", "image/png", ctx));
placeholderCover = true;
}
String coverTag = "<img src=\"" + escapeAttr(cover.url()) + "\" alt=\"cover\" "
+ "style=\"width:100%;border-radius:8px;margin:0 0 20px;display:block;\" />";
String meta = (author != null && !author.isBlank())
? "<p style=\"color:" + FAINT + ";font-size:14px;margin:0 0 20px;\">" + escapeText(author.trim()) + "</p>"
: "";
String container =
"<div style=\"max-width:677px;margin:0 auto;padding:0 4px;"
+ "font-family:-apple-system,BlinkMacSystemFont,'PingFang SC','Microsoft YaHei',sans-serif;"
+ "font-size:16px;line-height:1.8;color:" + INK + ";word-break:break-word;\">"
+ "<h1 style=\"font-size:22px;font-weight:700;line-height:1.4;margin:0 0 12px;color:" + INK + ";\">"
+ escapeText(title.trim()) + "</h1>"
+ meta + coverTag + innerHtml
+ "<p style=\"margin:28px 0 0;padding-top:16px;border-top:1px solid " + HAIRLINE + ";"
+ "color:" + MUTED + ";font-size:14px;\">如果这篇对你有帮助,欢迎点赞、在看、转发,也欢迎关注。</p>"
+ "</div>";
// 2. Online preview a full HTML doc served inline (text/html + CSP).
String previewDoc = "<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">"
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
+ "<title>" + escapeText(title.trim()) + "</title></head>"
+ "<body style=\"margin:0;padding:20px 12px;background:#fff;\">" + container + "</body></html>";
String previewUrl = store(previewDoc.getBytes(StandardCharsets.UTF_8), "公众号预览.html", "text/html", ctx);
// 3. Material bundle zip {article.html, article.md, cover.png?}.
String zipUrl;
String coverNote;
try {
byte[] coverBytes = cover != null ? cover.bytes() : null;
// For the offline bundle, point the cover at the local file.
String bundleContainer = coverBytes != null
? container.replaceFirst("<img src=\"[^\"]*\"", "<img src=\"cover.png\"")
: container;
String bundleHtml = "<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">"
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
+ "<title>" + escapeText(title.trim()) + "</title></head>"
+ "<body style=\"margin:0;padding:20px 12px;background:#fff;\">" + bundleContainer + "</body></html>";
byte[] zip = buildZip(bundleHtml, markdown, coverBytes);
zipUrl = store(zip, "公众号素材.zip", "application/zip", ctx);
coverNote = coverBytes != null ? "含封面图" : "未附封面(未提供或无法下载)";
} catch (Exception e) {
log.warn("[GzhPackage] bundle build failed: {}", e.getMessage());
zipUrl = null;
coverNote = "打包失败:" + e.getMessage();
}
StringBuilder out = new StringBuilder();
out.append("✅ 公众号图文已打包完成。\n\n");
if (placeholderCover) {
// Never a broken image but tell the user we substituted a placeholder.
boolean hadRef = coverImageUrl != null && !coverImageUrl.isBlank();
out.append("⚠️ ")
.append(hadRef ? "提供的封面无法解析为图片" : "未提供封面")
.append("已生成占位封面纯色渐变。建议补一张正式头图2.35:1")
.append("用 image_generate(aspectRatio=landscape) 出图后把完整 URL 传给 coverImageUrl 再打包。\n\n");
}
out.append("🔍 在线预览(浏览器打开即渲染):").append(previewUrl).append('\n');
if (zipUrl != null) {
out.append("📦 素材下载article.html + article.md + 封面,").append(coverNote).append("")
.append(zipUrl).append('\n');
}
// Auto compliance scan on delivery never relies on the model calling it.
ComplianceScanner.Result scan = ComplianceScanner.scan(title + "\n" + markdown);
if (!scan.clean()) {
out.append('\n').append(ComplianceScanner.report(scan)).append('\n');
}
out.append("\n可将下面的内联样式 HTML 直接粘贴进公众号编辑器(如需直接进草稿箱,用 gzh_publish\n");
out.append("```html\n").append(container).append("\n```");
// Auto-record into the content ledger the calendar is always populated.
try {
Long itemId = contentItemService.record(workspaceFromContext(ctx), "gzh",
topic != null && !topic.isBlank() ? topic : title.trim(),
title.trim(), "packaged", previewUrl, null);
out.append("\n🗓 已记入内容日历item id: ").append(itemId).append(")。");
} catch (Exception e) {
log.warn("[GzhPackage] auto-record failed: {}", e.getMessage());
}
log.info("[GzhPackage] packaged '{}' ({} md chars, coverResolved={}, complianceHits={})",
title, markdown.length(), cover != null, scan.hits().size());
return out.toString();
}
/** Convert Markdown to an inline-styled HTML fragment (no &lt;style&gt;/class). */
private String markdownToInlineHtml(String markdown) {
List<org.commonmark.Extension> ext = List.of(TablesExtension.create(), StrikethroughExtension.create());
Parser parser = Parser.builder().extensions(ext).build();
HtmlRenderer renderer = HtmlRenderer.builder().extensions(ext).build();
String rawHtml = renderer.render(parser.parse(markdown));
Document doc = Jsoup.parseBodyFragment(rawHtml);
for (Element el : doc.body().getAllElements()) {
switch (el.tagName()) {
case "h1", "h2" -> el.attr("style", "font-size:19px;font-weight:700;line-height:1.5;margin:28px 0 12px;color:" + INK + ";");
case "h3" -> el.attr("style", "font-size:17px;font-weight:600;margin:22px 0 10px;color:" + INK + ";");
case "h4", "h5", "h6" -> el.attr("style", "font-size:16px;font-weight:600;margin:18px 0 8px;color:" + INK + ";");
case "p" -> el.attr("style", "margin:0 0 18px;color:" + INK + ";");
case "a" -> el.attr("style", "color:" + ACCENT + ";text-decoration:none;");
case "strong", "b" -> el.attr("style", "font-weight:700;color:" + INK + ";");
case "em", "i" -> el.attr("style", "font-style:italic;");
case "ul", "ol" -> el.attr("style", "margin:0 0 18px;padding-left:22px;");
case "li" -> el.attr("style", "margin:0 0 8px;");
case "blockquote" -> el.attr("style", "margin:0 0 18px;padding:10px 16px;border-left:3px solid " + ACCENT
+ ";background:#f5f7ff;color:" + MUTED + ";");
case "pre" -> el.attr("style", "margin:0 0 18px;padding:14px 16px;background:" + CODE_BG
+ ";border-radius:8px;overflow-x:auto;font-size:14px;line-height:1.6;"
+ "font-family:Consolas,Menlo,Monaco,monospace;color:#24292f;white-space:pre;");
case "code" -> {
// Inline code only; code inside <pre> inherits the block style.
if (el.parent() == null || !"pre".equals(el.parent().tagName())) {
el.attr("style", "background:" + CODE_BG + ";border-radius:4px;padding:1px 5px;"
+ "font-family:Consolas,Menlo,Monaco,monospace;font-size:14px;color:#d6336c;");
}
}
case "img" -> el.attr("style", "max-width:100%;border-radius:8px;display:block;margin:8px 0;");
case "hr" -> el.attr("style", "border:none;border-top:1px solid " + HAIRLINE + ";margin:24px 0;");
case "table" -> el.attr("style", "border-collapse:collapse;width:100%;margin:0 0 18px;font-size:14px;");
case "th" -> el.attr("style", "border:1px solid " + HAIRLINE + ";padding:8px 10px;background:" + CODE_BG
+ ";text-align:left;font-weight:600;");
case "td" -> el.attr("style", "border:1px solid " + HAIRLINE + ";padding:8px 10px;");
default -> { /* leave other elements unstyled */ }
}
}
return doc.body().html();
}
private byte[] buildZip(String html, String markdown, @Nullable byte[] cover) throws Exception {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(bos)) {
zos.putNextEntry(new ZipEntry("article.html"));
zos.write(html.getBytes(StandardCharsets.UTF_8));
zos.closeEntry();
zos.putNextEntry(new ZipEntry("article.md"));
zos.write(markdown.getBytes(StandardCharsets.UTF_8));
zos.closeEntry();
if (cover != null) {
zos.putNextEntry(new ZipEntry("cover.png"));
zos.write(cover);
zos.closeEntry();
}
}
return bos.toByteArray();
}
/** A cover resolved to real image bytes (for the bundle) and a servable URL (for the preview). */
private record ResolvedCover(byte[] bytes, String url) { }
/**
* Resolve the cover reference to real image bytes plus a servable URL, or null
* if it can't be made into an image. Three paths, in order:
* <ol>
* <li>an explicit generated-file id in the URL ({@code .../generated/<uuid>}),
* accepted only if it maps to a live {@code image/*} entry;</li>
* <li><b>self-heal</b>: the ref points at the file's logical <em>name</em>
* ({@code cover_xyz.png}) rather than its id recover the real id by
* filename so a name-based reference still yields a working cover;</li>
* <li>an external {@code http(s)} image downloaded for the bundle, its URL
* kept for the preview.</li>
* </ol>
*/
@Nullable
private ResolvedCover resolveCover(@Nullable String ref, @Nullable ToolContext ctx) {
if (ref == null || ref.isBlank()) {
return null;
}
String r = ref.trim();
try {
// 1. Explicit generated-file id trust only a live image entry.
Matcher m = GeneratedFileCache.GENERATED_URL_PATTERN.matcher(r);
if (m.find()) {
Optional<GeneratedFileCache.Entry> e = cache.get(m.group(1));
if (e.isPresent() && isImage(e.get()) && hasBytes(e.get())) {
return new ResolvedCover(e.get().bytes(), cache.downloadUrl(m.group(1), ctx));
}
}
// 2. Self-heal a name-based reference (the id pattern can't parse
// underscores/dots, so `cover_xyz.png` never matches step 1).
String name = lastSegment(r);
if (name != null && !name.isBlank()) {
Optional<String> healed = cache.findIdByFilename(name, "image/");
if (healed.isPresent()) {
Optional<GeneratedFileCache.Entry> e = cache.get(healed.get());
if (e.isPresent() && hasBytes(e.get())) {
log.info("[GzhPackage] cover ref '{}' healed to generated id {} by filename", r, healed.get());
return new ResolvedCover(e.get().bytes(), cache.downloadUrl(healed.get(), ctx));
}
}
}
// 3. External http(s) image download for the bundle, keep the URL.
if (r.startsWith("http://") || r.startsWith("https://")) {
UrlSafetyChecker.check(r);
byte[] b = HttpUtil.downloadBytes(r);
if (b != null && b.length > 0) {
return new ResolvedCover(b, r);
}
}
} catch (Exception e) {
log.warn("[GzhPackage] cover resolve failed for '{}': {}", r, e.getMessage());
}
return null;
}
/**
* A neutral 2.35:1 gradient placeholder cover. Deliberately text-free server
* JVMs often lack CJK fonts, so drawing the title risks tofu boxes; a clean
* gradient is a always-valid cover the user can replace with a real one.
*/
private static byte[] placeholderCover() {
int w = 900, h = 383;
BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
Graphics2D g = img.createGraphics();
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setPaint(new GradientPaint(0, 0, new Color(0x2f6fed), w, h, new Color(0x1a3a8f)));
g.fillRect(0, 0, w, h);
// A couple of soft translucent circles for a bit of depth.
g.setColor(new Color(255, 255, 255, 26));
g.fillOval(w - 220, -120, 340, 340);
g.fillOval(-80, h - 160, 260, 260);
g.dispose();
try {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ImageIO.write(img, "png", bos);
return bos.toByteArray();
} catch (Exception e) {
throw new IllegalStateException("Failed to render placeholder cover", e);
}
}
private static Long workspaceFromContext(@Nullable ToolContext ctx) {
ChatOrigin origin = ChatOrigin.from(ctx);
return origin != null && origin.workspaceId() != null ? origin.workspaceId() : 1L;
}
private static boolean isImage(GeneratedFileCache.Entry e) {
return e.mimeType() != null && e.mimeType().startsWith("image/");
}
private static boolean hasBytes(GeneratedFileCache.Entry e) {
return e.bytes() != null && e.bytes().length > 0;
}
/** Last path segment of a URL, minus any {@code ?query} / {@code #fragment}. */
@Nullable
private static String lastSegment(String url) {
String s = url;
int cut = s.indexOf('?');
if (cut >= 0) s = s.substring(0, cut);
cut = s.indexOf('#');
if (cut >= 0) s = s.substring(0, cut);
int slash = s.lastIndexOf('/');
return slash >= 0 ? s.substring(slash + 1) : s;
}
private String store(byte[] bytes, String name, String mime, @Nullable ToolContext ctx) {
String id = cache.put(bytes, name, mime);
return cache.downloadUrl(id, ctx);
}
private static String escapeText(String s) {
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
}
private static String escapeAttr(String s) {
return escapeText(s).replace("\"", "&quot;");
}
}

View File

@ -0,0 +1,372 @@
package vip.mate.tool.builtin;
import cn.hutool.http.HttpUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import me.chanjar.weixin.common.api.WxConsts;
import me.chanjar.weixin.common.error.WxErrorException;
import me.chanjar.weixin.mp.api.WxMpService;
import me.chanjar.weixin.mp.bean.draft.WxMpAddDraft;
import me.chanjar.weixin.mp.bean.draft.WxMpDraftArticles;
import me.chanjar.weixin.mp.bean.material.WxMpMaterial;
import me.chanjar.weixin.mp.bean.material.WxMpMaterialUploadResult;
import me.chanjar.weixin.mp.bean.material.WxMediaImgUploadResult;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import vip.mate.system.service.SystemSettingService;
import vip.mate.tool.browser.UrlSafetyChecker;
import vip.mate.tool.document.GeneratedFileCache;
import java.io.File;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.regex.Matcher;
/**
* Built-in tool: publish a generated 图文 article to a WeChat Official Account.
*
* <p>The realistic, compliant endpoint is the <b>draft box</b> (草稿箱): the tool
* uploads the cover image as a permanent material and creates a draft article via
* the Official Account draft API. The account owner then reviews and taps
* "publish" in the WeChat backend. Mass-send / one-click publish to all followers
* is deliberately gated: it is an outward, irreversible action restricted by
* platform verification and rate limits, so {@code publish} requires an explicit
* confirmation flag and is only meaningful for verified accounts.
*
* <p>Credentials are read from system settings ({@code weixinoa.app_id} /
* {@code weixinoa.app_secret}); nothing runs until they are configured.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class GzhPublishTool {
private static final String SETTING_APP_ID = "weixinoa.app_id";
private static final String SETTING_APP_SECRET = "weixinoa.app_secret";
private final SystemSettingService systemSettingService;
private final WxMpServiceProvider wxMpServiceProvider;
private final GeneratedFileCache generatedFileCache;
@Tool(name = "gzh_publish", description = """
Publish a generated image-text article to a WeChat Official Account (微信公众号).
Actions:
- draft (default): upload the cover image and create a draft in the
Official Account 草稿箱. The user then taps "publish" in the WeChat
backend. This is the recommended, compliant path.
- publish: submit an already-drafted article for free-publish. Only works
for verified accounts and is an irreversible outward action, so it
requires confirmPublish=true AND you MUST get explicit user confirmation
of the final content before calling it.
`content` must be WeChat-editor-compatible HTML with INLINE styles only
(公众号 ignores <style> blocks). `coverImageUrl` is required for a draft
(WeChat requires a cover / thumb). Requires weixinoa.app_id and
weixinoa.app_secret to be configured in system settings.
""")
public String gzh_publish(
@ToolParam(description = "Action: draft (default) or publish", required = false)
String action,
@ToolParam(description = "Article title (required for draft)", required = false)
String title,
@ToolParam(description = "Article body as inline-styled HTML (required for draft)", required = false)
String content,
@ToolParam(description = "Cover image URL — uploaded as the article thumb (required for draft)", required = false)
String coverImageUrl,
@ToolParam(description = "Author / source name", required = false)
String author,
@ToolParam(description = "Short summary shown in the article list (<=120 chars); auto-derived if omitted", required = false)
String digest,
@ToolParam(description = "Draft media_id to free-publish (required for publish action)", required = false)
String draftMediaId,
@ToolParam(description = "Must be true to actually free-publish; forces explicit user confirmation", required = false)
Boolean confirmPublish) {
String appId = systemSettingService.getString(SETTING_APP_ID, "");
String appSecret = systemSettingService.getString(SETTING_APP_SECRET, "");
if (appId.isBlank() || appSecret.isBlank()) {
return "Error: WeChat Official Account is not configured. Set '" + SETTING_APP_ID
+ "' and '" + SETTING_APP_SECRET + "' in system settings first.";
}
WxMpService wxMpService = wxMpServiceProvider.getService(appId, appSecret);
String act = (action == null || action.isBlank()) ? "draft" : action.trim().toLowerCase();
return switch (act) {
case "draft" -> createDraft(wxMpService, title, content, coverImageUrl, author, digest);
case "publish" -> freePublish(wxMpService, draftMediaId, confirmPublish);
default -> "Error: unknown action '" + act + "'. Use 'draft' or 'publish'.";
};
}
private String createDraft(WxMpService wxMpService, String title, String content,
String coverImageUrl, String author, String digest) {
if (title == null || title.isBlank()) {
return "Error: title is required for a draft.";
}
if (content == null || content.isBlank()) {
return "Error: content (inline-styled HTML) is required for a draft.";
}
if (coverImageUrl == null || coverImageUrl.isBlank()) {
return "Error: coverImageUrl is required — WeChat needs a cover/thumb for the article.";
}
// Hard compliance gate (fail fast, before any upload): refuse to draft
// account-fatal copy 广告法 极限词 / 微信诱导 / 承诺收益. Lower-risk hits
// (医疗功效) are surfaced as a warning on success instead.
ComplianceScanner.Result scan = ComplianceScanner.scan(
title + "\n" + content.replaceAll("<[^>]+>", " "));
if (scan.hasHighRisk()) {
return "⛔ 合规拦截:命中高危违规词,已阻止进入草稿箱,请替换后再发。\n"
+ ComplianceScanner.report(scan);
}
// 1. Download the cover and upload it as a permanent image material -> thumb media_id.
String thumbMediaId;
File tmpCover = null;
try {
tmpCover = Files.createTempFile("gzh_cover_", ".jpg").toFile();
HttpUtil.downloadFile(coverImageUrl, tmpCover);
WxMpMaterial material = new WxMpMaterial();
material.setName(tmpCover.getName());
material.setFile(tmpCover);
WxMpMaterialUploadResult uploaded = withRetry(() -> wxMpService.getMaterialService()
.materialFileUpload(WxConsts.MediaFileType.IMAGE, material));
thumbMediaId = uploaded.getMediaId();
if (thumbMediaId == null || thumbMediaId.isBlank()) {
return "Error: cover upload returned no media_id.";
}
} catch (WxErrorException e) {
log.warn("[GzhPublish] cover upload failed: {}", e.getMessage());
return "Error: 封面上传失败 — " + translateWxError(e);
} catch (Exception e) {
log.warn("[GzhPublish] cover download/upload failed: {}", e.getMessage());
return "Error: cover download/upload failed — " + e.getMessage();
} finally {
if (tmpCover != null) {
//noinspection ResultOfMethodCallIgnored
tmpCover.delete();
}
}
// 2. Inline body images into WeChat: article HTML with external <img src>
// (our generated-file URLs, localhost, any non-mp host) renders broken in
// the published article WeChat only displays images it hosts. Upload each
// and rewrite src to the returned mp.weixin.qq.com URL. Failures don't block
// the draft; they're reported so the user can fix those images by hand.
ImageInlineResult inlined = inlineContentImages(wxMpService, content);
String bodyHtml = inlined.html();
// 3. Build the draft article and submit it.
try {
WxMpDraftArticles article = new WxMpDraftArticles();
article.setTitle(trimTo(title, 64));
article.setContent(bodyHtml);
article.setThumbMediaId(thumbMediaId);
if (author != null && !author.isBlank()) {
article.setAuthor(trimTo(author, 8));
}
article.setDigest(digest != null && !digest.isBlank()
? trimTo(digest, 120)
: deriveDigest(content));
String draftMediaId = withRetry(() -> wxMpService.getDraftService()
.addDraft(new WxMpAddDraft(List.of(article))));
log.info("[GzhPublish] draft created, media_id={}, title='{}', imagesInlined={}, imagesFailed={}",
draftMediaId, title, inlined.uploaded(), inlined.failed().size());
StringBuilder ok = new StringBuilder();
ok.append("✅ 已存入公众号草稿箱。\n");
ok.append("draft media_id: ").append(draftMediaId).append('\n');
if (inlined.uploaded() > 0) {
ok.append("正文图已上传微信并改写链接:").append(inlined.uploaded()).append(" 张。\n");
}
if (!inlined.failed().isEmpty()) {
ok.append("⚠️ 有 ").append(inlined.failed().size())
.append(" 张正文图未能上传(发布后会裂图,请在后台手动替换):")
.append(String.join("", inlined.failed())).append('\n');
}
if (!scan.clean()) {
ok.append("⚠️ 合规提示(非高危,建议核对):").append(ComplianceScanner.report(scan)).append('\n');
}
ok.append("请到公众号后台「草稿箱」核对排版后点击「发表」。\n");
ok.append("如需直接群发(仅认证号),可用 gzh_publish action=publish draftMediaId=").append(draftMediaId)
.append(" confirmPublish=true并在发布前与用户再次确认内容。");
return ok.toString();
} catch (WxErrorException e) {
log.warn("[GzhPublish] addDraft failed: {}", e.getMessage());
return "Error: 创建草稿失败 — " + translateWxError(e);
}
}
private String freePublish(WxMpService wxMpService, String draftMediaId, Boolean confirmPublish) {
if (draftMediaId == null || draftMediaId.isBlank()) {
return "Error: draftMediaId is required for publish. Create a draft first.";
}
if (confirmPublish == null || !confirmPublish) {
return "Publish is an irreversible outward action. Confirm the final content with the user, "
+ "then call again with confirmPublish=true.";
}
try {
String publishId = withRetry(() -> wxMpService.getFreePublishService().submit(draftMediaId));
log.info("[GzhPublish] free-publish submitted, publish_id={}, draft={}", publishId, draftMediaId);
return "✅ 已提交群发free-publish。publish_id: " + publishId
+ "\n注意发布结果由微信异步审核请在公众号后台确认最终状态。";
} catch (WxErrorException e) {
log.warn("[GzhPublish] free-publish failed: {}", e.getMessage());
return "Error: 群发失败(仅认证号可用)— " + translateWxError(e);
}
}
@FunctionalInterface
private interface WxCall<T> {
T get() throws WxErrorException;
}
/**
* Retry a WeChat call on transient error codes (system busy / rate limit) with
* a short backoff, up to 2 extra attempts. Non-transient errors (bad token,
* IP whitelist, unauthorized) throw immediately retrying them is pointless.
*/
private static <T> T withRetry(WxCall<T> call) throws WxErrorException {
int attempt = 0;
while (true) {
try {
return call.get();
} catch (WxErrorException e) {
int code = e.getError() != null ? e.getError().getErrorCode() : 0;
boolean transient_ = (code == -1 || code == 45009);
if (transient_ && attempt < 2) {
attempt++;
try {
Thread.sleep(500L * attempt);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw e;
}
continue;
}
throw e;
}
}
}
/** Translate a WeChat error into an actionable Chinese hint (falls back to the raw message). */
static String translateWxError(WxErrorException e) {
int code = e.getError() != null ? e.getError().getErrorCode() : 0;
String hint = switch (code) {
case 40164 -> "服务器公网 IP 不在公众号后台白名单。到「设置与开发 → 安全中心 / IP 白名单」把本机公网 IP 加进去后重试。";
case 48001 -> "接口未授权:该能力通常仅认证服务号可用。";
case 40001, 42001 -> "access_token 无效或已过期:请核对 AppID/AppSecret或稍后重试。";
case 45009 -> "接口调用频率超限:请稍后再试。";
case -1 -> "微信系统繁忙:请稍后再试。";
default -> "";
};
return hint.isEmpty()
? (e.getMessage() == null ? "微信接口错误" : e.getMessage())
: hint + "errcode=" + code + "";
}
/** Result of rewriting article body images to WeChat-hosted URLs. */
record ImageInlineResult(String html, int uploaded, List<String> failed) {}
/**
* Upload every non-WeChat body image to the Official Account and rewrite its
* {@code src} to the returned {@code mp.weixin.qq.com} URL, so images actually
* render in the published article. Images already on {@code mp.weixin.qq.com}
* (or {@code data:} URIs) are left as-is. A single image failing to upload is
* recorded and skipped it never blocks the whole draft.
*/
ImageInlineResult inlineContentImages(WxMpService wxMpService, String html) {
if (html == null || html.isBlank()) {
return new ImageInlineResult(html, 0, List.of());
}
Document doc = Jsoup.parseBodyFragment(html);
doc.outputSettings().prettyPrint(false);
List<String> failed = new ArrayList<>();
int uploaded = 0;
for (Element img : doc.select("img[src]")) {
String src = img.attr("src").trim();
if (src.isEmpty() || src.contains("mp.weixin.qq.com") || src.startsWith("data:")) {
continue;
}
File tmp = null;
try {
byte[] bytes = resolveImageBytes(src);
if (bytes == null || bytes.length == 0) {
failed.add(src);
continue;
}
tmp = Files.createTempFile("gzh_img_", "." + extOf(src)).toFile();
Files.write(tmp.toPath(), bytes);
WxMediaImgUploadResult result = wxMpService.getMaterialService().mediaImgUpload(tmp);
if (result != null && result.getUrl() != null && !result.getUrl().isBlank()) {
img.attr("src", result.getUrl());
uploaded++;
} else {
failed.add(src);
}
} catch (Exception e) {
log.warn("[GzhPublish] content image upload failed for {}: {}", src, e.getMessage());
failed.add(src);
} finally {
if (tmp != null) {
//noinspection ResultOfMethodCallIgnored
tmp.delete();
}
}
}
return new ImageInlineResult(doc.body().html(), uploaded, failed);
}
/** Resolve an article-body image ref to bytes: our generated files, or http(s). */
private byte[] resolveImageBytes(String src) {
try {
Matcher m = GeneratedFileCache.GENERATED_URL_PATTERN.matcher(src);
if (m.find()) {
Optional<GeneratedFileCache.Entry> e = generatedFileCache.get(m.group(1));
return e.map(GeneratedFileCache.Entry::bytes).orElse(null);
}
if (src.startsWith("http://") || src.startsWith("https://")) {
UrlSafetyChecker.check(src);
byte[] b = HttpUtil.downloadBytes(src);
return (b != null && b.length > 0) ? b : null;
}
} catch (Exception e) {
log.debug("[GzhPublish] could not resolve body image {}: {}", src, e.toString());
}
return null;
}
private static String extOf(String url) {
String clean = url.split("[?#]")[0];
int dot = clean.lastIndexOf('.');
if (dot >= 0 && dot < clean.length() - 1) {
String ext = clean.substring(dot + 1).toLowerCase();
if (ext.matches("(png|jpg|jpeg|gif|webp)")) {
return ext.equals("jpeg") ? "jpg" : ext;
}
}
return "jpg";
}
/** Strip tags and clamp to a length for the article digest. */
private static String deriveDigest(String htmlContent) {
String plain = htmlContent.replaceAll("<[^>]+>", " ").replaceAll("\\s+", " ").trim();
return trimTo(plain, 120);
}
private static String trimTo(String v, int max) {
if (v == null) {
return "";
}
String t = v.trim();
return t.length() <= max ? t : t.substring(0, max);
}
}

View File

@ -31,16 +31,20 @@ public class ProgressLedgerTool {
private final ProgressLedgerService service;
@Tool(description = "Record or update a single step in the current conversation's progress "
+ "ledger. Use this to track multi-step tasks (research workflows, document drafting "
+ "split by section, etc.) — the runtime injects a rendered snapshot of the ledger "
+ "into your context before every reasoning step so you never lose track of what is "
+ "already done after a context trim. Call once per step transition: "
+ "register pending entries up front when you decompose a task, mark in_progress "
+ "before starting each one, then done as soon as it lands. Re-using the same stepKey "
+ "overwrites the entry in place (no duplicates).")
+ "ledger. Use this to track multi-step tasks (research workflows, document drafting "
+ "split by section, etc.) — the runtime injects a rendered snapshot of the ledger "
+ "into your context before every reasoning step so you never lose track of what is "
+ "already done after a context trim. Call once per step transition: "
+ "register pending entries up front when you decompose a task, mark in_progress "
+ "before starting each one, then done as soon as it lands. Re-using the same stepKey "
+ "overwrites the entry in place (no duplicates). "
+ "IMPORTANT: do NOT use the `auto_` or `pin_` prefix in stepKey — those are reserved "
+ "for system-managed entries (auto-recorded tool completions and pinned skill "
+ "constraints) and will be rejected.")
public String progress_update(
@ToolParam(description = "Stable identifier for this step (e.g. 'model_gpt55', "
+ "'section_intro', 'step_pptx'). Reuse exactly to update an existing entry.")
+ "'section_intro', 'step_pptx'). Reuse exactly to update an existing entry. "
+ "Do NOT prefix with 'auto_' or 'pin_' — those are system-reserved.")
String stepKey,
@ToolParam(description = "Human-readable label shown in the snapshot (e.g. "
+ "'GPT-5.5 调研'). Pass empty to keep the existing label when updating.",

View File

@ -0,0 +1,180 @@
package vip.mate.tool.builtin;
import com.microsoft.playwright.Browser;
import com.microsoft.playwright.BrowserContext;
import com.microsoft.playwright.BrowserType;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.Playwright;
import com.microsoft.playwright.options.ScreenshotType;
import com.microsoft.playwright.options.WaitUntilState;
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.beans.factory.annotation.Value;
import org.springframework.lang.Nullable;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import vip.mate.auth.service.AuthService;
import vip.mate.tool.browser.BrowserLauncher;
import vip.mate.tool.document.FilenameSanitizer;
import vip.mate.tool.document.GeneratedFileCache;
import vip.mate.tool.document.GeneratedFileLink;
/**
* Built-in tool: capture a screenshot of a MateClaw admin-console page and
* return an embeddable image URL.
*
* <p>Purpose: let content skills illustrate a how-to article with <b>real</b>
* product screenshots. The console lives behind JWT auth, so the tool mints a
* short-lived token for the calling user, injects it into {@code localStorage}
* before the SPA boots (Playwright init script), navigates to the requested
* <b>same-origin relative path</b>, and screenshots the rendered page. The PNG
* is stashed in {@link GeneratedFileCache}; the returned {@code /api/v1/files/
* generated/<id>} URL can be embedded directly as {@code ![](url)} in a
* gzh_package Markdown body.
*
* <p>Security: only relative in-app paths ({@code /chat}, {@code /channels}, )
* are allowed no scheme/host, so the tool cannot be aimed at arbitrary hosts
* (no SSRF). The injected token is a normal user token scoped to whoever is
* driving the conversation.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ScreenshotTool {
private static final String PNG_MIME = "image/png";
private static final int DEFAULT_WIDTH = 1440;
private static final int DEFAULT_HEIGHT = 900;
private static final int NAV_TIMEOUT_MS = 20_000;
private static final int DEFAULT_SETTLE_MS = 2500;
private static final int MAX_SETTLE_MS = 8000;
private final GeneratedFileCache cache;
private final AuthService authService;
@Value("${server.port:18088}")
private int serverPort;
@Tool(name = "capture_screenshot", description = """
Capture a screenshot of a MateClaw admin-console page and return an
embeddable image URL. Use this to put REAL product screenshots into a
how-to / tutorial article (e.g. steps of using 内容工作室).
`path` must be a relative in-app path starting with '/', e.g. '/chat',
'/channels', '/agents', '/skills'. External URLs are rejected.
The returned URL is `/api/v1/files/generated/<id>` (image/png). Embed it
in a gzh_package Markdown body as `![说明](URL)` so the packaged article
shows the real screenshot instead of a 截图placeholder.
""")
public String capture_screenshot(
@ToolParam(description = "Relative in-app path, e.g. /chat, /channels, /agents, /skills")
String path,
@ToolParam(description = "Capture the full scrollable page (default false = just the viewport)", required = false)
Boolean fullPage,
@ToolParam(description = "Output filename without extension, e.g. 'step1-console'", required = false)
String filename,
@ToolParam(description = "Extra settle wait in ms after load for the SPA to render (default 2500, max 8000)", required = false)
Integer waitMs,
@Nullable ToolContext ctx) {
if (path == null || path.isBlank()) {
return "Error: path is required (a relative in-app path like /chat).";
}
String p = path.trim();
if (p.contains("://") || p.startsWith("//") || !p.startsWith("/")) {
return "Error: only relative in-app paths are allowed (must start with '/', no scheme/host). Got: " + path;
}
String token = mintToken();
if (token == null || token.isBlank()) {
return "Error: could not mint an auth token to render the console (no resolvable user).";
}
String url = "http://127.0.0.1:" + serverPort + p;
int settle = waitMs == null ? DEFAULT_SETTLE_MS : Math.min(Math.max(waitMs, 0), MAX_SETTLE_MS);
boolean full = fullPage != null && fullPage;
String displayName = FilenameSanitizer.sanitize(filename, "screenshot", ".png") + ".png";
byte[] png;
try {
png = render(url, token, full, settle);
} catch (Exception e) {
log.warn("[Screenshot] capture failed for {}: {}", p, e.getMessage());
String hint = e.getMessage() != null && e.getMessage().contains("Executable doesn't exist")
? " Hint: install the bundled browser (Playwright chromium)."
: "";
return "Error: screenshot failed — " + e.getMessage() + hint;
}
log.info("[Screenshot] captured {} ({} bytes, fullPage={})", p, png.length, full);
return GeneratedFileLink.resultZh(png, displayName, PNG_MIME, cache, "截图", ctx);
}
private byte[] render(String url, String token, boolean fullPage, int settleMs) {
try (Playwright pw = Playwright.create()) {
BrowserType.LaunchOptions opts = new BrowserType.LaunchOptions()
.setHeadless(true)
.setArgs(BrowserLauncher.chromiumLaunchArgs());
Browser browser = pw.chromium().launch(opts);
try {
BrowserContext context = browser.newContext(new Browser.NewContextOptions()
.setViewportSize(DEFAULT_WIDTH, DEFAULT_HEIGHT)
.setDeviceScaleFactor(2.0)
.setLocale("zh-CN"));
// Seed the JWT before any app script runs so the SPA boots
// authenticated. Playwright evaluates the init script as-is, so it
// must be raw statements a "() => {...}" arrow would only be
// defined, never called, leaving localStorage untouched (and the
// SPA would render the login page instead).
context.addInitScript("try { window.localStorage.setItem('token', '"
+ token + "'); } catch (e) {}");
try {
Page page = context.newPage();
page.navigate(url, new Page.NavigateOptions()
.setWaitUntil(WaitUntilState.DOMCONTENTLOADED)
.setTimeout(NAV_TIMEOUT_MS));
// The console holds an SSE stream, so 'networkidle' can never
// settle use a fixed render delay instead.
page.waitForTimeout(settleMs);
return page.screenshot(new Page.ScreenshotOptions()
.setFullPage(fullPage)
.setType(ScreenshotType.PNG));
} finally {
try { context.close(); } catch (Exception ignored) {}
}
} finally {
try { browser.close(); } catch (Exception ignored) {}
}
}
}
/** Mint a token for the current user, falling back to the default admin. */
@Nullable
private String mintToken() {
String username = currentUsername();
String token = username != null ? authService.renewToken(username) : null;
if (token == null) {
token = authService.renewToken("admin");
}
return token;
}
@Nullable
private String currentUsername() {
try {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated() && auth.getName() != null
&& !"anonymousUser".equals(auth.getName())) {
return auth.getName();
}
} catch (Exception ignored) {
// Async agent thread may have no security context fall back to admin.
}
return null;
}
}

View File

@ -0,0 +1,210 @@
package vip.mate.tool.builtin;
import cn.hutool.http.HttpRequest;
import lombok.extern.slf4j.Slf4j;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import vip.mate.tool.browser.UrlSafetyChecker;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* Built-in tool: fetch a WeChat Official Account article and return its cleaned,
* structured body (title / author / publish time / markdown text / image URLs).
*
* <p>WeChat article pages ({@code mp.weixin.qq.com/s/...}) are largely static
* HTML: the body lives in {@code #js_content} and images lazy-load through a
* {@code data-src} attribute. A plain HTTP GET plus a jsoup cleanup is therefore
* enough for the common case, which is far cheaper and more reliable than
* driving a headless browser. Callers that need to summarise several reference
* articles ("参考公众号信息抓取汇总") get clean text instead of a raw page
* snapshot.
*
* <p>The URL is constrained to the {@code mp.weixin.qq.com} host and validated
* through {@link UrlSafetyChecker} so the tool cannot be used for SSRF.
*/
@Slf4j
@Component
public class WechatArticleExtractTool {
private static final String ALLOWED_HOST_SUFFIX = "mp.weixin.qq.com";
private static final int FETCH_TIMEOUT_MS = 15_000;
private static final int MAX_BODY_CHARS = 20_000;
private static final int MAX_IMAGES = 30;
private static final String USER_AGENT =
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) "
+ "AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 MicroMessenger/8.0.0";
@Tool(name = "wechat_article_extract", description = """
Fetch a WeChat Official Account (微信公众号) article by URL and return its
cleaned content: title, author, publish time, body as Markdown, and the
list of image URLs.
Use this to gather and summarise reference 公众号 articles before writing
(参考公众号信息抓取汇总). It returns clean readable text rather than a raw
page snapshot, so it is preferred over browser_use for mp.weixin.qq.com
article pages.
Only https://mp.weixin.qq.com/... URLs are accepted. Content is meant for
reference and summarisation produce original, differentiated writing and
cite the source; do not copy verbatim.
""")
public String wechat_article_extract(
@ToolParam(description = "Full WeChat article URL, e.g. https://mp.weixin.qq.com/s/xxxxxxxx")
String url) {
if (url == null || url.isBlank()) {
return "Error: url is required.";
}
String trimmed = url.trim();
// SSRF guard + host allowlisting: only public mp.weixin.qq.com pages.
try {
UrlSafetyChecker.check(trimmed);
} catch (SecurityException e) {
return "Error: unsafe URL — " + e.getMessage();
}
String host = java.net.URI.create(trimmed).getHost();
if (host == null || !(host.equals(ALLOWED_HOST_SUFFIX) || host.endsWith("." + ALLOWED_HOST_SUFFIX))) {
return "Error: only mp.weixin.qq.com article URLs are supported (got host: " + host + ").";
}
String html;
try {
html = HttpRequest.get(trimmed)
.header("User-Agent", USER_AGENT)
.timeout(FETCH_TIMEOUT_MS)
.execute()
.body();
} catch (Exception e) {
log.warn("[WechatExtract] fetch failed for {}: {}", trimmed, e.getMessage());
return "Error: failed to fetch the article — " + e.getMessage();
}
if (html == null || html.isBlank()) {
return "Error: empty response from the article URL.";
}
Document doc = Jsoup.parse(html, trimmed);
String title = firstNonBlank(
text(doc, "#activity-name"),
text(doc, "h1.rich_media_title"),
text(doc, "meta[property=og:title]", "content"),
doc.title());
String author = firstNonBlank(
text(doc, "#js_author_name"),
text(doc, "#js_name"),
text(doc, "a#js_name"),
text(doc, "meta[name=author]", "content"));
String publishTime = firstNonBlank(
text(doc, "#publish_time"),
text(doc, "em#publish_time"));
Element content = doc.selectFirst("#js_content");
if (content == null) {
// The article may be intercepted (verification / deleted / anti-scrape).
return "Error: could not locate the article body (#js_content). "
+ "The page may require verification or has been removed. "
+ "Try browser_use as a fallback.\nTitle: " + safe(title);
}
// Drop non-content noise before walking the tree.
content.select("script, style, noscript").remove();
List<String> imageUrls = collectImages(content);
String bodyMarkdown = toMarkdown(content);
if (bodyMarkdown.length() > MAX_BODY_CHARS) {
bodyMarkdown = bodyMarkdown.substring(0, MAX_BODY_CHARS)
+ "\n\n…正文超长已截断 / body truncated";
}
StringBuilder out = new StringBuilder();
out.append("# ").append(safe(title)).append('\n');
if (!author.isBlank()) {
out.append("**作者/来源**").append(author).append('\n');
}
if (!publishTime.isBlank()) {
out.append("**发布时间**").append(publishTime).append('\n');
}
out.append("**原文链接**").append(trimmed).append("\n\n");
out.append("---\n\n");
out.append(bodyMarkdown.isBlank() ? "(未提取到正文文本)" : bodyMarkdown);
if (!imageUrls.isEmpty()) {
out.append("\n\n---\n**图片素材(").append(imageUrls.size()).append("**\n");
for (String img : imageUrls) {
out.append("- ").append(img).append('\n');
}
}
log.info("[WechatExtract] extracted '{}' ({} chars, {} images) from {}",
safe(title), bodyMarkdown.length(), imageUrls.size(), trimmed);
return out.toString();
}
/** Walk the article body, emitting headings as ATX Markdown and keeping paragraph breaks. */
private String toMarkdown(Element content) {
StringBuilder sb = new StringBuilder();
for (Element el : content.getAllElements()) {
String tag = el.tagName();
String own = el.ownText();
if (own.isBlank()) {
continue;
}
if (tag.length() == 2 && tag.charAt(0) == 'h'
&& tag.charAt(1) >= '1' && tag.charAt(1) <= '6') {
int level = tag.charAt(1) - '0';
sb.append('\n').append("#".repeat(level)).append(' ').append(own.trim()).append('\n');
} else {
sb.append(own.trim()).append('\n');
}
}
// Collapse runs of blank lines.
return sb.toString().replaceAll("\n{3,}", "\n\n").strip();
}
/** WeChat lazy-loads images via data-src; fall back to src. */
private List<String> collectImages(Element content) {
Set<String> urls = new LinkedHashSet<>();
Elements imgs = content.select("img");
for (Element img : imgs) {
String src = img.hasAttr("data-src") ? img.attr("data-src") : img.attr("src");
if (src != null && src.startsWith("http")) {
urls.add(src);
}
if (urls.size() >= MAX_IMAGES) {
break;
}
}
return new ArrayList<>(urls);
}
private static String text(Document doc, String selector) {
Element el = doc.selectFirst(selector);
return el == null ? "" : el.text().trim();
}
private static String text(Document doc, String selector, String attr) {
Element el = doc.selectFirst(selector);
return el == null ? "" : el.attr(attr).trim();
}
private static String firstNonBlank(String... values) {
for (String v : values) {
if (v != null && !v.isBlank()) {
return v.trim();
}
}
return "";
}
private static String safe(String v) {
return v == null || v.isBlank() ? "(untitled)" : v.trim();
}
}

View File

@ -0,0 +1,120 @@
package vip.mate.tool.builtin;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import me.chanjar.weixin.mp.api.WxMpService;
import me.chanjar.weixin.mp.api.impl.WxMpServiceImpl;
import me.chanjar.weixin.mp.config.impl.WxMpDefaultConfigImpl;
import org.springframework.stereotype.Component;
import vip.mate.system.service.SystemSettingService;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
/**
* Shares one {@link WxMpService} per {@code appId} and persists its
* {@code access_token} across restarts.
*
* <p>Why this exists: WeChat allows only ONE valid {@code access_token} per
* appId at a time and rate-limits token fetches fetching a new one silently
* invalidates the previous. Building a fresh {@code WxMpServiceImpl} on every
* call (the old {@code GzhPublishTool} behaviour) meant every publish, and every
* process restart, re-fetched a token and could thrash a token shared with other
* callers. Here the service (and its in-memory token) is cached by appId, and the
* token is mirrored into system settings so a restart reuses the live token
* instead of fetching another. Changing the app secret transparently rebuilds the
* cached service.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class WxMpServiceProvider {
private final SystemSettingService settingService;
private final ConcurrentMap<String, Holder> cache = new ConcurrentHashMap<>();
private record Holder(String secret, WxMpService service) {}
/** Get (or build) the shared service for this appId/secret pair. */
public WxMpService getService(String appId, String appSecret) {
Holder existing = cache.get(appId);
if (existing != null && existing.secret().equals(appSecret)) {
return existing.service();
}
WxMpService service = build(appId, appSecret);
cache.put(appId, new Holder(appSecret, service));
return service;
}
/** Drop the cached service for an appId (e.g. after a credential change). */
public void invalidate(String appId) {
cache.remove(appId);
}
private WxMpService build(String appId, String appSecret) {
DbTokenConfig config = new DbTokenConfig(appId, settingService);
config.setAppId(appId);
config.setSecret(appSecret);
config.loadPersistedToken();
WxMpService service = new WxMpServiceImpl();
service.setWxMpConfigStorage(config);
log.debug("[WxMpServiceProvider] built WxMpService for appId={}", appId);
return service;
}
/**
* Config storage that mirrors the access_token into system settings so it
* survives a JVM restart. Token keys are per-appId and short-lived, so they
* are stored as ordinary (non-encrypted) settings.
*/
static final class DbTokenConfig extends WxMpDefaultConfigImpl {
private final String appId;
private final transient SystemSettingService settings;
DbTokenConfig(String appId, SystemSettingService settings) {
this.appId = appId;
this.settings = settings;
}
private String tokenKey() {
return "weixinoa.token." + appId;
}
private String expiresKey() {
return "weixinoa.token_expires." + appId;
}
/** Seed the in-memory token from a previously persisted, still-valid one. */
void loadPersistedToken() {
String token = settings.getString(tokenKey(), "");
String expires = settings.getString(expiresKey(), "");
if (token == null || token.isBlank() || expires == null || expires.isBlank()) {
return;
}
try {
long expiresAt = Long.parseLong(expires.trim());
if (expiresAt > System.currentTimeMillis()) {
setAccessToken(token);
setExpiresTime(expiresAt);
}
} catch (NumberFormatException ignore) {
// Corrupt persisted expiry ignore and let the service fetch fresh.
}
}
@Override
public void updateAccessToken(String accessToken, int expiresInSeconds) {
super.updateAccessToken(accessToken, expiresInSeconds);
// Mirror the freshly minted token so a restart reuses it.
try {
settings.saveString(tokenKey(), accessToken, "WeChat OA access_token cache");
settings.saveString(expiresKey(), String.valueOf(getExpiresTime()),
"WeChat OA access_token expiry (epoch ms)");
} catch (Exception e) {
// Persistence is best-effort; the in-memory token still works this run.
log.debug("[WxMpServiceProvider] could not persist access_token for {}: {}", appId, e.toString());
}
}
}
}

View File

@ -0,0 +1,391 @@
package vip.mate.tool.builtin;
import cn.hutool.http.HttpUtil;
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.context.ChatOrigin;
import vip.mate.content.service.ContentItemService;
import vip.mate.tool.browser.UrlSafetyChecker;
import vip.mate.tool.document.GeneratedFileCache;
import vip.mate.tool.guard.WorkspacePathGuard;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
/**
* Built-in tool: assemble a Xiaohongshu (小红书) image-text note into an
* <b>image-first</b> online preview plus a downloadable material bundle the
* flagship delivery step for 小红书, mirroring what {@code gzh_package} does for
* 公众号.
*
* <p>小红书 is an image-first medium: readers swipe a set of vertical (3:4)
* cards, and the copy is supporting. This tool therefore renders a phone-style
* preview where the images dominate (a horizontal swipe carousel up top) and the
* title / body / topic tags sit beneath as support, and it <b>requires at least
* {@value #MIN_IMAGES} images</b> (a cover plus content cards / photos) packaging
* fewer is refused so a note never ships text-heavy.
*
* <p>Image references are usually {@code render_html_image} / {@code image_generate}
* outputs ({@code /api/v1/files/generated/{id}} links), resolved to bytes via
* {@link GeneratedFileCache}; plain http(s) URLs and workspace file paths also
* work. A reference that points at a file's logical name instead of its issued id
* is self-healed by filename, the same way {@code gzh_package} resolves its cover.
*
* <p>Outputs: an online preview (served {@code text/html} behind a strict CSP),
* plus a {@code .zip} of numbered card images + {@code 文案.txt}. Publishing stays
* manual 小红书 has no official publish API so the result carries the same
* creator-platform upload steps as {@code xhs_publish}.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class XhsPackageTool {
/** 小红书 is image-first: a note must carry at least a cover plus two more images. */
private static final int MIN_IMAGES = 3;
/** Xiaohongshu allows up to 18 images per note. */
private static final int MAX_IMAGES = 18;
private static final String CREATOR_URL = "https://creator.xiaohongshu.com/publish/publish";
// Palette light, clean, 小红书-ish.
private static final String INK = "#222222";
private static final String MUTED = "#7a7a7a";
private static final String TAG = "#13386c";
private static final String CARD_BG = "#ffffff";
private static final String PAGE_BG = "#f4f4f4";
private final GeneratedFileCache cache;
private final ContentItemService contentItemService;
@Tool(name = "xhs_package", description = """
Package a Xiaohongshu (小红书) note into an IMAGE-FIRST online preview plus a
downloadable material bundle the default delivery step of xhs_note.
小红书 leads with images: pass the ordered card images (first = cover) and the
copy plays a supporting role. REQUIRES at least 3 images (a cover + >=2 content
cards / photos); fewer is refused, so generate enough with image_generate
(aspectRatio=portrait) / render_html_image first.
Params: title, body (with emoji + line breaks), tags (comma-separated), and
images (comma-separated references in display order render_html_image /
image_generate URLs /api/v1/files/generated/{id}, http(s) image URLs, or
workspace file paths).
Returns: an 在线预览 link (a phone-style swipe preview: images up top, copy
below), a 素材下载 .zip (numbered card images + 文案.txt), and the manual
creator-platform upload steps. 小红书 has no publish API never auto-uploads.
""")
public String xhs_package(
@ToolParam(description = "Note title (小红书 标题, <=20 chars recommended)")
String title,
@ToolParam(description = "Note body text, with emoji and line breaks")
String body,
@ToolParam(description = "Topic tags, comma-separated, e.g. 咖啡,探店,周末去哪儿", required = false)
String tags,
@ToolParam(description = "Comma-separated image references in display order (first = cover); >=3 required")
String images,
@ToolParam(description = "Selected topic (for the content ledger; falls back to title)", required = false)
String topic,
@Nullable ToolContext ctx) {
if (title == null || title.isBlank()) {
return "Error: title is required.";
}
// Resolve images first 小红书 is image-first, so this is the gate.
List<ResolvedImg> imgs = new ArrayList<>();
List<String> skipped = new ArrayList<>();
if (images != null && !images.isBlank()) {
for (String raw : images.split(",")) {
String ref = raw.trim();
if (ref.isEmpty()) {
continue;
}
if (imgs.size() >= MAX_IMAGES) {
skipped.add(ref + " (超过 " + MAX_IMAGES + " 张上限)");
continue;
}
try {
imgs.add(resolveImage(ref, ctx));
} catch (Exception e) {
skipped.add(ref + " (" + e.getMessage() + ")");
}
}
}
if (imgs.size() < MIN_IMAGES) {
StringBuilder err = new StringBuilder();
err.append("⛔ 小红书以图为主,至少需要 ").append(MIN_IMAGES)
.append(" 张图1 封面 + ≥").append(MIN_IMAGES - 1)
.append(" 张内容图/照片),当前只解析到 ").append(imgs.size()).append(" 张。\n");
if (!skipped.isEmpty()) {
err.append("未解析:").append(String.join("", skipped)).append("\n");
}
err.append("请先用 image_generate(aspectRatio=portrait) 或 render_html_image 生成到 ≥")
.append(MIN_IMAGES).append(" 张竖版图,再调用 xhs_package。");
return err.toString();
}
// Online preview image-first phone layout served inline (text/html + CSP).
String previewDoc = buildPreview(title.trim(), body, tags, imgs);
String previewUrl = store(previewDoc.getBytes(StandardCharsets.UTF_8), "小红书预览.html", "text/html", ctx);
// Material bundle 文案.txt + numbered card images.
String copy = buildCopy(title, body, tags);
String zipUrl;
try {
zipUrl = store(buildZip(copy, imgs), "小红书素材.zip", "application/zip", ctx);
} catch (Exception e) {
log.warn("[XhsPackage] zip build failed: {}", e.getMessage());
zipUrl = null;
}
StringBuilder out = new StringBuilder();
out.append("✅ 小红书笔记已打包完成(").append(imgs.size()).append(" 张图,以图为主)。\n\n");
out.append("🔍 在线预览(手机版滑动预览,图在上、文案在下):").append(previewUrl).append('\n');
if (zipUrl != null) {
out.append("📦 素材下载(按 01、02… 编号的卡片图 + 文案.txt").append(zipUrl).append('\n');
}
if (!skipped.isEmpty()) {
out.append("⚠️ 未打包:").append(String.join("", skipped)).append('\n');
}
// Auto compliance scan on delivery never relies on the model calling it.
ComplianceScanner.Result scan = ComplianceScanner.scan(title + "\n" + (body == null ? "" : body));
if (!scan.clean()) {
out.append(ComplianceScanner.report(scan)).append('\n');
}
// Auto-record into the content ledger.
try {
Long itemId = contentItemService.record(workspaceFromContext(ctx), "xhs",
topic != null && !topic.isBlank() ? topic : title.trim(),
title.trim(), "packaged", previewUrl, null);
out.append("🗓️ 已记入内容日历item id: ").append(itemId).append(")。\n");
} catch (Exception e) {
log.warn("[XhsPackage] auto-record failed: {}", e.getMessage());
}
out.append('\n').append(guideText());
log.info("[XhsPackage] packaged '{}' ({} images, {} skipped, complianceHits={})",
title, imgs.size(), skipped.size(), scan.hits().size());
return out.toString();
}
/** Build the image-first phone-style preview: a swipe carousel of cards, copy beneath. */
private String buildPreview(String title, @Nullable String body, @Nullable String tags, List<ResolvedImg> imgs) {
StringBuilder cards = new StringBuilder();
for (int i = 0; i < imgs.size(); i++) {
cards.append("<div style=\"flex:0 0 100%;scroll-snap-align:center;aspect-ratio:3/4;"
+ "background:#eee;border-radius:14px;overflow:hidden;\">"
+ "<img src=\"").append(escapeAttr(imgs.get(i).url()))
.append("\" alt=\"card ").append(i + 1)
.append("\" style=\"width:100%;height:100%;object-fit:cover;display:block;\" /></div>");
}
String swipeHint = imgs.size() > 1
? "<p style=\"text-align:center;color:" + MUTED + ";font-size:13px;margin:8px 0 0;\">← 左右滑动查看 "
+ imgs.size() + " 张图 →</p>"
: "";
String bodyHtml = (body != null && !body.isBlank())
? "<p style=\"font-size:15px;line-height:1.75;color:" + INK + ";margin:12px 0 0;white-space:pre-wrap;\">"
+ nl2br(body.trim()) + "</p>"
: "";
StringBuilder tagHtml = new StringBuilder();
if (tags != null && !tags.isBlank()) {
tagHtml.append("<p style=\"margin:14px 0 0;line-height:2;\">");
for (String t : tags.split(",")) {
String tag = t.trim().replaceFirst("^#", "");
if (!tag.isEmpty()) {
tagHtml.append("<span style=\"color:").append(TAG)
.append(";font-size:14px;margin-right:10px;\">#")
.append(escapeText(tag)).append("</span>");
}
}
tagHtml.append("</p>");
}
String note =
"<div style=\"max-width:390px;margin:0 auto;background:" + CARD_BG + ";border-radius:18px;"
+ "overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,0.08);\">"
// Image carousel the hero, image-first.
+ "<div style=\"display:flex;overflow-x:auto;scroll-snap-type:x mandatory;gap:8px;"
+ "padding:10px 10px 0;-webkit-overflow-scrolling:touch;\">" + cards + "</div>"
+ swipeHint
// Copy supporting, beneath the images.
+ "<div style=\"padding:6px 16px 20px;\">"
+ "<h1 style=\"font-size:18px;font-weight:700;line-height:1.5;color:" + INK + ";margin:12px 0 0;\">"
+ escapeText(title) + "</h1>"
+ bodyHtml + tagHtml
+ "</div></div>";
return "<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">"
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
+ "<title>" + escapeText(title) + "</title></head>"
+ "<body style=\"margin:0;padding:20px 12px;background:" + PAGE_BG + ";"
+ "font-family:-apple-system,BlinkMacSystemFont,'PingFang SC','Microsoft YaHei',sans-serif;\">"
+ note + "</body></html>";
}
private String buildCopy(@Nullable String title, @Nullable String body, @Nullable String tags) {
StringBuilder sb = new StringBuilder();
if (title != null && !title.isBlank()) {
sb.append("【标题】\n").append(title.trim()).append("\n\n");
}
if (body != null && !body.isBlank()) {
sb.append("【正文】\n").append(body.trim()).append("\n\n");
}
if (tags != null && !tags.isBlank()) {
StringBuilder tagLine = new StringBuilder("【话题标签】\n");
for (String t : tags.split(",")) {
String tag = t.trim().replaceFirst("^#", "");
if (!tag.isEmpty()) {
tagLine.append('#').append(tag).append(' ');
}
}
sb.append(tagLine.toString().trim()).append('\n');
}
return sb.toString().strip();
}
private byte[] buildZip(String copy, List<ResolvedImg> imgs) throws Exception {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(bos)) {
zos.putNextEntry(new ZipEntry("文案.txt"));
zos.write(copy.getBytes(StandardCharsets.UTF_8));
zos.closeEntry();
for (int i = 0; i < imgs.size(); i++) {
zos.putNextEntry(new ZipEntry(String.format("%02d.%s", i + 1, imgs.get(i).ext())));
zos.write(imgs.get(i).bytes());
zos.closeEntry();
}
}
return bos.toByteArray();
}
/**
* Resolve an image reference to bytes + extension + a servable URL. Generated
* ids resolve via the cache (self-healing a name-based reference by filename);
* http(s) URLs are downloaded; workspace files are read and re-stored so the
* preview has a servable URL to embed.
*/
private ResolvedImg resolveImage(String ref, @Nullable ToolContext ctx) throws Exception {
Matcher m = GeneratedFileCache.GENERATED_URL_PATTERN.matcher(ref);
if (m.find()) {
String id = m.group(1);
Optional<GeneratedFileCache.Entry> entry = cache.get(id);
if (entry.isEmpty() || entry.get().bytes() == null || entry.get().bytes().length == 0) {
// Self-heal: the ref may point at the file's name, not its id.
Optional<String> healed = cache.findIdByFilename(lastSegment(ref), "image/");
if (healed.isPresent()) {
id = healed.get();
entry = cache.get(id);
}
}
if (entry.isEmpty() || entry.get().bytes() == null || entry.get().bytes().length == 0) {
throw new IllegalStateException("生成文件已过期或不存在");
}
return new ResolvedImg(entry.get().bytes(),
extFromMime(entry.get().mimeType(), "png"), cache.downloadUrl(id, ctx));
}
if (ref.startsWith("http://") || ref.startsWith("https://")) {
UrlSafetyChecker.check(ref);
byte[] bytes = HttpUtil.downloadBytes(ref);
if (bytes == null || bytes.length == 0) {
throw new IllegalStateException("下载为空");
}
return new ResolvedImg(bytes, extFromUrl(ref), ref);
}
// Workspace file path read, then re-store so the preview can serve it.
Path path = WorkspacePathGuard.validatePath(ref);
if (!Files.exists(path) || Files.isDirectory(path)) {
throw new IllegalStateException("文件不存在");
}
byte[] bytes = Files.readAllBytes(path);
String ext = extFromUrl(ref);
String id = cache.put(bytes, path.getFileName().toString(), mimeFromExt(ext));
return new ResolvedImg(bytes, ext, cache.downloadUrl(id, ctx));
}
private String store(byte[] bytes, String name, String mime, @Nullable ToolContext ctx) {
return cache.downloadUrl(cache.put(bytes, name, mime), ctx);
}
private String guideText() {
return """
📮 小红书发布步骤手动小红书无官方发布 API
1. 下载素材包并解压
2. 打开创作平台 %s 需已登录上传图文
3. 0102 顺序上传卡片图首图即封面
4. 文案.txt复制标题正文话题标签粘贴到对应输入框
5. 核对无违禁词后自行发布""".formatted(CREATOR_URL);
}
private static Long workspaceFromContext(@Nullable ToolContext ctx) {
ChatOrigin origin = ChatOrigin.from(ctx);
return origin != null && origin.workspaceId() != null ? origin.workspaceId() : 1L;
}
/** Last path segment of a reference, minus any {@code ?query} / {@code #fragment}. */
private static String lastSegment(String ref) {
String s = ref.split("[?#]")[0];
int slash = s.lastIndexOf('/');
return slash >= 0 ? s.substring(slash + 1) : s;
}
private static String extFromMime(String mime, String fallback) {
if (mime == null) {
return fallback;
}
return switch (mime.toLowerCase()) {
case "image/png" -> "png";
case "image/jpeg", "image/jpg" -> "jpg";
case "image/webp" -> "webp";
case "image/gif" -> "gif";
default -> fallback;
};
}
private static String mimeFromExt(String ext) {
return switch (ext.toLowerCase()) {
case "jpg", "jpeg" -> "image/jpeg";
case "webp" -> "image/webp";
case "gif" -> "image/gif";
default -> "image/png";
};
}
private static String extFromUrl(String url) {
String clean = url.split("[?#]")[0];
int dot = clean.lastIndexOf('.');
if (dot >= 0 && dot < clean.length() - 1) {
String ext = clean.substring(dot + 1).toLowerCase();
if (ext.length() <= 4 && ext.matches("[a-z0-9]+")) {
return ext;
}
}
return "png";
}
private static String nl2br(String s) {
return escapeText(s).replace("\n", "<br/>");
}
private static String escapeText(String s) {
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
}
private static String escapeAttr(String s) {
return escapeText(s).replace("\"", "&quot;");
}
private record ResolvedImg(byte[] bytes, String ext, String url) {}
}

View File

@ -0,0 +1,241 @@
package vip.mate.tool.builtin;
import cn.hutool.http.HttpUtil;
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.tool.browser.UrlSafetyChecker;
import vip.mate.tool.document.GeneratedFileCache;
import vip.mate.tool.document.GeneratedFileLink;
import vip.mate.tool.guard.WorkspacePathGuard;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
/**
* Built-in tool: package a Xiaohongshu (小红书) image-text note into a single
* downloadable bundle, and hand off to the creator platform for manual upload.
*
* <p>Xiaohongshu has no official open publishing API for personal notes, and
* this tool deliberately does <b>not</b> automate uploads or bypass any risk
* control / human verification. Instead it does the reliable, compliant part:
* collects the copy + tags + rendered card images into one {@code .zip} the
* user downloads in a single click, then points them at the creator platform
* with step-by-step instructions to finish the post themselves.
*
* <p>Card images are usually produced by {@code render_html_image}, whose
* results are {@code /api/v1/files/generated/{id}} links; those are resolved
* back to bytes through {@link GeneratedFileCache}. Plain http(s) URLs and
* workspace file paths are also accepted.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class XhsPublishTool {
private static final String CREATOR_URL = "https://creator.xiaohongshu.com/publish/publish";
private static final int MAX_IMAGES = 18; // Xiaohongshu allows up to 18 images per note.
private final GeneratedFileCache cache;
@Tool(name = "xhs_publish", description = """
Package a Xiaohongshu (小红书) note into one downloadable bundle and give
manual-publish instructions.
Actions:
- export (default): build a .zip containing 文案.txt (title + body + tags)
and the card images in order, then return a download link plus steps to
upload at the creator platform. `images` is a comma-separated list of
card image references: render_html_image download URLs
(/api/v1/files/generated/{id}), plain http(s) image URLs, or workspace
file paths.
- guide: just return the manual-publish steps and the creator URL.
Xiaohongshu has no official publish API; this tool never auto-uploads or
bypasses verification the user completes the post manually.
""")
public String xhs_publish(
@ToolParam(description = "Action: export (default) or guide", required = false)
String action,
@ToolParam(description = "Note title (小红书 标题, <=20 chars recommended)", required = false)
String title,
@ToolParam(description = "Note body text with emoji and line breaks", required = false)
String body,
@ToolParam(description = "Topic tags, comma-separated, e.g. 咖啡,探店,周末去哪儿", required = false)
String tags,
@ToolParam(description = "Comma-separated card image references (generated URLs / http URLs / workspace paths), in display order", required = false)
String images,
@Nullable ToolContext ctx) {
String act = (action == null || action.isBlank()) ? "export" : action.trim().toLowerCase();
if ("guide".equals(act)) {
return guideText();
}
if (!"export".equals(act)) {
return "Error: unknown action '" + act + "'. Use 'export' or 'guide'.";
}
String copy = buildCopy(title, body, tags);
List<byte[]> imageBytes = new ArrayList<>();
List<String> imageExts = new ArrayList<>();
List<String> skipped = new ArrayList<>();
if (images != null && !images.isBlank()) {
String[] refs = images.split(",");
for (String raw : refs) {
String ref = raw.trim();
if (ref.isEmpty()) {
continue;
}
if (imageBytes.size() >= MAX_IMAGES) {
skipped.add(ref + " (超过 " + MAX_IMAGES + " 张上限)");
continue;
}
try {
ResolvedImage img = resolveImage(ref);
imageBytes.add(img.bytes());
imageExts.add(img.ext());
} catch (Exception e) {
skipped.add(ref + " (" + e.getMessage() + ")");
}
}
}
byte[] zip;
try {
zip = buildZip(copy, imageBytes, imageExts);
} catch (Exception e) {
log.warn("[XhsPublish] zip build failed: {}", e.getMessage());
return "Error: failed to build the bundle — " + e.getMessage();
}
String linkMsg = GeneratedFileLink.resultZh(
zip, "小红书发布包.zip", "application/zip", cache, "发布包", ctx);
StringBuilder out = new StringBuilder();
out.append(linkMsg).append("\n\n");
out.append("📦 发布包含:文案.txt");
if (!imageBytes.isEmpty()) {
out.append(" + ").append(imageBytes.size()).append(" 张卡片图(已按顺序编号)");
}
out.append("\n");
if (!skipped.isEmpty()) {
out.append("⚠️ 未打包:").append(String.join("", skipped)).append("\n");
}
out.append('\n').append(guideText());
log.info("[XhsPublish] exported bundle: {} images, {} skipped", imageBytes.size(), skipped.size());
return out.toString();
}
private String buildCopy(String title, String body, String tags) {
StringBuilder sb = new StringBuilder();
if (title != null && !title.isBlank()) {
sb.append("【标题】\n").append(title.trim()).append("\n\n");
}
if (body != null && !body.isBlank()) {
sb.append("【正文】\n").append(body.trim()).append("\n\n");
}
if (tags != null && !tags.isBlank()) {
StringBuilder tagLine = new StringBuilder("【话题标签】\n");
for (String t : tags.split(",")) {
String tag = t.trim().replaceFirst("^#", "");
if (!tag.isEmpty()) {
tagLine.append('#').append(tag).append(' ');
}
}
sb.append(tagLine.toString().trim()).append('\n');
}
return sb.toString().strip();
}
private byte[] buildZip(String copy, List<byte[]> imageBytes, List<String> imageExts) throws Exception {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(bos)) {
zos.putNextEntry(new ZipEntry("文案.txt"));
zos.write(copy.getBytes(StandardCharsets.UTF_8));
zos.closeEntry();
for (int i = 0; i < imageBytes.size(); i++) {
String name = String.format("%02d.%s", i + 1, imageExts.get(i));
zos.putNextEntry(new ZipEntry(name));
zos.write(imageBytes.get(i));
zos.closeEntry();
}
}
return bos.toByteArray();
}
/** Resolve an image reference (generated URL / http URL / workspace path) to bytes + extension. */
private ResolvedImage resolveImage(String ref) throws Exception {
Matcher m = GeneratedFileCache.GENERATED_URL_PATTERN.matcher(ref);
if (m.find()) {
String id = m.group(1);
Optional<GeneratedFileCache.Entry> entry = cache.get(id);
if (entry.isEmpty()) {
throw new IllegalStateException("生成文件已过期或不存在");
}
return new ResolvedImage(entry.get().bytes(), extFromMime(entry.get().mimeType(), "png"));
}
if (ref.startsWith("http://") || ref.startsWith("https://")) {
UrlSafetyChecker.check(ref);
byte[] bytes = HttpUtil.downloadBytes(ref);
if (bytes == null || bytes.length == 0) {
throw new IllegalStateException("下载为空");
}
return new ResolvedImage(bytes, extFromUrl(ref));
}
// Otherwise treat as a workspace-relative/absolute file path.
Path path = WorkspacePathGuard.validatePath(ref);
if (!Files.exists(path) || Files.isDirectory(path)) {
throw new IllegalStateException("文件不存在");
}
return new ResolvedImage(Files.readAllBytes(path), extFromUrl(ref));
}
private static String extFromMime(String mime, String fallback) {
if (mime == null) {
return fallback;
}
return switch (mime.toLowerCase()) {
case "image/png" -> "png";
case "image/jpeg", "image/jpg" -> "jpg";
case "image/webp" -> "webp";
case "image/gif" -> "gif";
default -> fallback;
};
}
private static String extFromUrl(String url) {
String clean = url.split("[?#]")[0];
int dot = clean.lastIndexOf('.');
if (dot >= 0 && dot < clean.length() - 1) {
String ext = clean.substring(dot + 1).toLowerCase();
if (ext.length() <= 4 && ext.matches("[a-z0-9]+")) {
return ext;
}
}
return "png";
}
private String guideText() {
return """
📮 小红书发布步骤手动小红书无官方发布 API
1. 下载上面的发布包并解压
2. 打开创作平台 %s 需已登录上传图文
3. 0102 顺序上传卡片图首图即封面
4. 文案.txt复制标题正文粘贴到对应输入框
5. 添加话题标签核对无违禁词后自行发布""".formatted(CREATOR_URL);
}
private record ResolvedImage(byte[] bytes, String ext) {}
}

View File

@ -92,7 +92,13 @@ public class DefaultToolDisclosureService implements ToolDisclosureService {
}
Long serverId = snap.mcpToolToServerId.get(toolName);
if (serverId != null) {
return snap.serverTierById.getOrDefault(serverId, DisclosureTier.CORE);
// Move 5: MCP tools default to EXTENSION (on-demand exposure).
// A server with 20 tools flooding the CORE tool list makes it
// harder for the model to find the right builtin tool, and the
// MCP schemas are typically the heaviest part of the prompt.
// Users who want a server's tools visible by default can set
// disclosure_tier = core on the mate_mcp_server row.
return snap.serverTierById.getOrDefault(serverId, DisclosureTier.EXTENSION);
}
// Unknown source (ACP / dynamic-skill / plugin) keep visible.
return DisclosureTier.CORE;
@ -132,10 +138,11 @@ public class DefaultToolDisclosureService implements ToolDisclosureService {
* {@inheritDoc}
*
* <p>Protection set: {@link #ALWAYS_CORE} meta-tools and builtin tools
* with an explicit {@code disclosure_tier = core} row. MCP tools remain
* demotable the server-level tier cannot distinguish an explicit core
* choice from the default, and MCP schemas are typically the heaviest
* part of the advertisement.
* with an explicit {@code disclosure_tier = core} row. MCP tools default
* to EXTENSION (Move 5) so they only enter the CORE list when an operator
* explicitly sets {@code disclosure_tier = core} on the server in that
* case they are still demotable, since MCP schemas are typically the
* heaviest part of the advertisement.
*/
@Override
public Set<String> computeAutoDemotions(AgentToolSet baseSet, Integer budgetTokens) {
@ -319,11 +326,18 @@ public class DefaultToolDisclosureService implements ToolDisclosureService {
Map<Long, String> serverNameById = new LinkedHashMap<>();
try {
for (McpServerEntity s : mcpServerService.listAll()) {
serverTierById.put(s.getId(), DisclosureTier.fromToken(s.getDisclosureTier()));
// Move 5: only record an explicit tier. Servers with null
// disclosure_tier are intentionally absent from the map so
// resolveTierByName's getOrDefault(serverId, EXTENSION)
// applies the new on-demand default. Putting CORE here
// (via fromToken(null) CORE) would override the default.
if (s.getDisclosureTier() != null && !s.getDisclosureTier().isBlank()) {
serverTierById.put(s.getId(), DisclosureTier.fromToken(s.getDisclosureTier()));
}
serverNameById.put(s.getId(), s.getName());
}
} catch (Exception e) {
log.warn("ToolDisclosureService: failed to read MCP server tiers, defaulting to core: {}",
log.warn("ToolDisclosureService: failed to read MCP server tiers, defaulting to extension: {}",
e.getMessage());
}

View File

@ -20,6 +20,7 @@ import java.time.Duration;
import java.util.Base64;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@ -235,6 +236,71 @@ public class GeneratedFileCache {
return Optional.of(entry);
}
/**
* Best-effort lookup of a live entry's id by its logical filename, optionally
* constrained to a mime-type prefix (e.g. {@code "image/"}). Scans the
* in-memory cache first which covers the common case of a reference to a
* file generated earlier in the same run then persisted metadata on disk as
* a durable fallback. Returns the first live match, or empty.
*
* <p>This exists to self-heal references that point at a file's logical
* <em>name</em> ({@code cover_xyz.png}) rather than its issued id: such a
* reference cannot be resolved by {@link #GENERATED_URL_PATTERN} (the id
* capture group stops at the first {@code _} or {@code .}), so a name-based
* fallback recovers the real, servable id instead of yielding a broken link.
*/
public Optional<String> findIdByFilename(String filename, @Nullable String mimePrefix) {
if (filename == null || filename.isBlank()) {
return Optional.empty();
}
String target = filename.trim();
// 1. In-memory the fresh-same-run case, and cheap.
synchronized (entries) {
for (Map.Entry<String, Entry> e : entries.entrySet()) {
Entry v = e.getValue();
if (!v.expired() && target.equalsIgnoreCase(v.filename())
&& (mimePrefix == null
|| (v.mimeType() != null && v.mimeType().startsWith(mimePrefix)))) {
return Optional.of(e.getKey());
}
}
}
// 2. Disk metas durable fallback (survives memory eviction / restart).
if (!Files.isDirectory(storageDir)) {
return Optional.empty();
}
List<Path> metas;
try (Stream<Path> files = Files.list(storageDir)) {
metas = files.filter(p -> p.getFileName().toString().endsWith(META_SUFFIX)).toList();
} catch (IOException e) {
log.debug("findIdByFilename disk scan failed: {}", e.toString());
return Optional.empty();
}
long now = System.currentTimeMillis();
for (Path metaPath : metas) {
try {
String[] parts = Files.readString(metaPath).split("\t", 3);
if (Long.parseLong(parts[0].trim()) <= now) {
continue;
}
String mime = parts.length > 1 && !parts[1].isEmpty() ? parts[1] : null;
if (mimePrefix != null && (mime == null || !mime.startsWith(mimePrefix))) {
continue;
}
String fn = parts.length > 2 && !parts[2].isEmpty()
? new String(Base64.getDecoder().decode(parts[2]), StandardCharsets.UTF_8)
: null;
if (fn != null && target.equalsIgnoreCase(fn)) {
String name = metaPath.getFileName().toString();
return Optional.of(name.substring(0, name.length() - META_SUFFIX.length()));
}
} catch (Exception ignore) {
// Skip unreadable / malformed meta.
}
}
return Optional.empty();
}
private void persist(String id, Entry entry) {
if (entry.bytes() == null) {
return;

View File

@ -37,11 +37,23 @@ public class GeneratedFileController {
String encodedName = URLEncoder.encode(entry.filename(), StandardCharsets.UTF_8)
.replace("+", "%20");
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.parseMediaType(entry.mimeType()));
String mime = entry.mimeType();
headers.setContentType(MediaType.parseMediaType(mime));
// RFC 5987 filename* lets non-ASCII names round-trip in browsers.
String disposition = entry.mimeType() != null && entry.mimeType().startsWith("image/")
? "inline"
: "attachment";
// Images and HTML previews render inline; everything else downloads.
boolean isImage = mime != null && mime.startsWith("image/");
boolean isHtml = mime != null && mime.toLowerCase().startsWith("text/html");
String disposition = (isImage || isHtml) ? "inline" : "attachment";
if (isHtml) {
// The bytes are model/tool-generated HTML served from the app's
// own origin. A strict CSP neutralises XSS: scripts, plugins and
// framing are forbidden, only inline styles + images/fonts load.
// This makes an on-demand "open the article" preview safe.
headers.add("Content-Security-Policy",
"default-src 'none'; img-src * data:; style-src 'unsafe-inline'; "
+ "font-src * data:; media-src *; base-uri 'none'; form-action 'none'");
headers.add("X-Content-Type-Options", "nosniff");
}
headers.add(HttpHeaders.CONTENT_DISPOSITION,
disposition + "; filename=\"" + sanitizeAscii(entry.filename())
+ "\"; filename*=UTF-8''" + encodedName);

View File

@ -204,14 +204,20 @@ public final class WorkspacePathGuard {
* transition window.
*/
public static Path validatePath(String rawPath, @Nullable ToolContext ctx) {
Path normalized = Paths.get(rawPath).toAbsolutePath().normalize();
String basePath = resolveBasePath(ctx);
if (basePath == null || basePath.isBlank()) {
return normalized; // 未配置活动目录不限制
// 未配置活动目录不限制此时相对路径仍按进程 CWD 解析遗留行为
return Paths.get(rawPath).toAbsolutePath().normalize();
}
Path root = Paths.get(basePath).toAbsolutePath().normalize();
// A relative path means "relative to the agent's workspace root", not
// the JVM's launch directory. Resolving against process CWD (via
// toAbsolutePath) sent a plain "./foo.html" outside the sandbox whenever
// the server ran from a directory other than the workspace, tripping a
// spurious "工作区越界" block (issue #494). This matches the shell
// scanner, which already resolves relative tokens against root.
Path normalized = resolveAgainstRoot(rawPath, root);
// 先用 normalize 检查再尝试 toRealPath 防符号链接逃逸
if (!normalized.startsWith(root) && !isExempt(normalized)) {
@ -333,13 +339,29 @@ public final class WorkspacePathGuard {
if (rawPath == null || rawPath.isBlank()) return null;
Path root = basePathToRoot(basePath);
if (root == null) return null;
Path normalized = Paths.get(rawPath).toAbsolutePath().normalize();
// Relative paths resolve against the workspace root (see validatePath /
// issue #494), so a plain "./foo.html" stays inside the sandbox
// regardless of the server's launch directory.
Path normalized = resolveAgainstRoot(rawPath, root);
if (!normalized.startsWith(root) && !isExempt(normalized)) {
return "Path is outside workspace boundary: " + normalized + ", allowed root: " + root;
}
return null;
}
/**
* Resolve a user-supplied path against the workspace {@code root}: absolute
* paths are taken as-is, relative paths (including {@code ./foo} and
* {@code ../foo}) are resolved against {@code root} and normalized. A
* traversal that climbs out of the workspace still normalizes to a path
* that fails the {@code startsWith(root)} check, so this only fixes the
* legitimate in-workspace relative case it does not weaken the boundary.
*/
private static Path resolveAgainstRoot(String rawPath, Path root) {
Path p = Paths.get(rawPath);
return (p.isAbsolute() ? p : root.resolve(p)).normalize();
}
/**
* Resolve a base-path string to a normalized root, falling back to the
* global sandbox root when blank. {@code null} only when neither is set.
@ -409,6 +431,20 @@ public final class WorkspacePathGuard {
// idioms (`2>/dev/null`, `cmd <(cat file)`) keep working.
continue;
}
if (isFilesystemRoot(normalized)) {
// A token normalizing to the filesystem root is usually shell
// syntax misread as a path sed's s/pattern//, awk's empty
// field, etc. so it is skipped for non-destructive commands.
// When the command carries a destructive verb, fail closed:
// `rm -rf //` (or `/.`, `/..`) targets the filesystem root and
// must be refused. The verb flag is command-wide, so a compound
// command mixing e.g. `rm` with a sed empty replacement is also
// refused the error tells the caller to split the command.
if (destructive) {
throw filesystemRootDeletionError();
}
continue;
}
if (destructive && normalized.equals(root)) {
throw rootDeletionError(root);
}
@ -533,6 +569,18 @@ public final class WorkspacePathGuard {
return ALLOWED_DEVICE_NODES.contains(s) || ALLOWED_DEV_FD.matcher(s).matches();
}
/**
* True when {@code normalized} is the filesystem root ({@code /} or
* {@code //}). In non-destructive commands these are almost always false
* positives from shell syntax (sed's {@code s/pattern//}, awk's empty
* field, etc.) and are skipped; destructive commands are refused at the
* call site because {@code rm -rf //} really does target the root.
*/
private static boolean isFilesystemRoot(Path normalized) {
String s = normalized.toString();
return "/".equals(s) || "//".equals(s);
}
private static String truncateForError(String s) {
return s.length() > 200 ? s.substring(0, 200) + "..." : s;
}
@ -543,6 +591,14 @@ public final class WorkspacePathGuard {
+ ". Deleting the workspace root is refused — target a path inside it instead.");
}
private static IllegalArgumentException filesystemRootDeletionError() {
return new IllegalArgumentException(
"Shell command combines a destructive verb (rm/rmdir/shred/srm) with a path that "
+ "normalizes to the filesystem root (/), which is refused. If the root-like "
+ "token comes from shell syntax (e.g. an empty sed replacement) rather than a "
+ "delete target, run the delete and the text edit as separate commands.");
}
/**
* Resolve the active workspace base path. Order of preference:
* <ol>

View File

@ -3,10 +3,15 @@ package vip.mate.tool.guard.guardian;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
import vip.mate.tool.guard.WorkspacePathGuard;
import vip.mate.tool.guard.model.*;
import vip.mate.workspace.core.service.ChatUploadLocationResolver;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@ -65,6 +70,20 @@ public class WorkspaceBoundaryGuardian implements ToolGuardGuardian {
private final ObjectMapper objectMapper = new ObjectMapper();
/**
* Chat-upload location resolver injected lazily to avoid a cyclic dependency
* ({@code agentService agentGraphBuilder conversationService this}).
* Used as a fallback when a file-tool path triggers a boundary violation:
* resolves the real upload roots from the database so attachments stored in
* workspace-scoped directories are found even when the thread-local
* {@code workspaceBasePath} is null.
*/
private final ChatUploadLocationResolver chatUploadLocationResolver;
public WorkspaceBoundaryGuardian(@Lazy ChatUploadLocationResolver chatUploadLocationResolver) {
this.chatUploadLocationResolver = chatUploadLocationResolver;
}
@Override
public boolean supports(ToolInvocationContext context) {
String tool = context.toolName();
@ -123,12 +142,82 @@ public class WorkspaceBoundaryGuardian implements ToolGuardGuardian {
String path = extractJsonParam(rawArgs, paramName);
String violation = WorkspacePathGuard.findPathBoundaryViolation(path, basePath);
if (violation != null) {
// Chat-upload fallback: an attachment path may sit outside the
// workspace root yet still be a legitimate user upload. Resolve
// candidate upload roots from the DB (workspace-scoped +
// default) this avoids the workspaceBasePath heuristic that
// can be null when the agent isn't configured with a workspace
// override. Any resolver failure keeps the BLOCK finding.
String conversationId = context.conversationId();
if (conversationId != null && !conversationId.isBlank()) {
try {
List<Path> candidateRoots = chatUploadLocationResolver
.resolveCandidateUploadRoots(conversationId);
if (isInsideUploadDir(path, conversationId, candidateRoots)) {
log.debug("[WorkspaceBoundaryGuardian] Path {} is inside a chat-upload dir "
+ "of conversation {}", path, conversationId);
return List.of();
}
} catch (Exception e) {
log.debug("[WorkspaceBoundaryGuardian] Chat-upload fallback failed for {}: {}",
path, e.getMessage());
}
}
return List.of(boundaryFinding(tool, "path", path, violation));
}
}
return List.of();
}
/**
* True when {@code rawPath} itself normalizes to a location inside one of
* the conversation's candidate upload directories
* ({@code {root}/{conversationId}/}).
* <p>
* The check is a strict prefix match on the <em>requested</em> path a
* basename match against stored attachments is deliberately not enough to
* clear a boundary violation, because an arbitrary outside path could share
* a basename with an uploaded file and would then slip past the guard
* whenever it exists under some other trusted root. Tool-level resolvers
* may still redirect a basename-only request to the stored attachment; the
* redirected path they read lands inside the upload directory and passes
* this same check.
*/
private static boolean isInsideUploadDir(String rawPath, String conversationId,
List<Path> candidateRoots) {
if (rawPath == null || rawPath.isBlank() || candidateRoots == null) {
return false;
}
Path normalized;
try {
normalized = Paths.get(rawPath).toAbsolutePath().normalize();
} catch (Exception e) {
return false;
}
String safeSegment = ChatUploadLocationResolver.sanitizeSegment(conversationId);
for (Path root : candidateRoots) {
// Accept the sanitized dir (where writes land) always a legal
// segment and, for legacy Linux uploads, the raw-id dir when it is
// a legal path on this OS. Write and read must agree here or a valid
// attachment read would be flagged as a boundary escape.
Path sanitizedDir = root.resolve(safeSegment).toAbsolutePath().normalize();
if (normalized.startsWith(sanitizedDir)) {
return true;
}
if (!safeSegment.equals(conversationId)) {
try {
Path rawDir = root.resolve(conversationId).toAbsolutePath().normalize();
if (normalized.startsWith(rawDir)) {
return true;
}
} catch (InvalidPathException ignore) {
// Raw id illegal on this filesystem (e.g. ':' on Windows).
}
}
}
return false;
}
private GuardFinding boundaryFinding(String toolName, String paramName, String matchValue, String reason) {
return new GuardFinding(
"WORKSPACE_BOUNDARY_ESCAPE",

View File

@ -40,7 +40,7 @@ public class ImageFileDownloader {
if (imageUrl == null) {
throw new IOException("imageUrl is null");
}
Path dir = uploadLocationResolver.resolveUploadRoot(conversationId).resolve(conversationId);
Path dir = uploadLocationResolver.resolveConversationDir(conversationId);
Files.createDirectories(dir);
if (imageUrl.startsWith("data:")) {
@ -112,7 +112,7 @@ public class ImageFileDownloader {
* Base64 编码的图片保存到本地
*/
public Path saveBase64(String base64Data, String conversationId, String taskId, int index) throws IOException {
Path dir = uploadLocationResolver.resolveUploadRoot(conversationId).resolve(conversationId);
Path dir = uploadLocationResolver.resolveConversationDir(conversationId);
Files.createDirectories(dir);
String fileName = "image_" + taskId + "_" + index + ".png";

View File

@ -77,7 +77,12 @@ public final class IdentityForwardingToolCallback implements ToolCallback {
return delegate;
}
private String inject(String toolInput, ToolContext toolContext) {
/**
* Inject identity claim into the toolInput JSON.
* Package-private so {@link ProgressAwareMcpToolCallback} can apply identity
* forwarding before calling mcpClient directly (progress path).
*/
String inject(String toolInput, ToolContext toolContext) {
return identityService.resolve(toolContext, audience)
.map(i -> withClaim(toolInput, i.key(), i.value()))
.orElse(toolInput);

View File

@ -1,6 +1,7 @@
package vip.mate.tool.mcp.runtime;
import cn.hutool.json.JSONUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.client.transport.ServerParameters;
@ -67,13 +68,21 @@ public class McpClientManager {
private final McpIdentityForwardService identityForwardService;
private final McpProgressContext progressContext;
private final ObjectMapper objectMapper;
/** serverId -> server name, captured at build time for identity-forward opt-in matching. */
private final ConcurrentHashMap<Long, String> serverNames = new ConcurrentHashMap<>();
public McpClientManager(ApplicationEventPublisher eventPublisher,
McpIdentityForwardService identityForwardService) {
McpIdentityForwardService identityForwardService,
McpProgressContext progressContext,
ObjectMapper objectMapper) {
this.eventPublisher = eventPublisher;
this.identityForwardService = identityForwardService;
this.progressContext = progressContext;
this.objectMapper = objectMapper;
}
/** serverId -> connection result info */
@ -201,7 +210,8 @@ public class McpClientManager {
McpIdentityForwardService idSvc =
identityForwardService.forwardsTo(serverId, serverName) ? identityForwardService : null;
String audience = idSvc != null ? identityForwardService.audienceFor(serverId, serverName) : null;
List<ToolCallback> wrapped = wrapServerCallbacks(serverId, cbs, idSvc, audience);
List<ToolCallback> wrapped = wrapServerCallbacks(serverId, cbs, idSvc, audience, serverName,
entry.getValue(), objectMapper);
lastGoodCallbacks.put(serverId, wrapped);
allCallbacks.addAll(wrapped);
continue;
@ -253,7 +263,7 @@ public class McpClientManager {
* real {@link McpSyncClient}.
*/
static List<ToolCallback> wrapServerCallbacks(long serverId, ToolCallback[] cbs) {
return wrapServerCallbacks(serverId, cbs, null, null);
return wrapServerCallbacks(serverId, cbs, null, null, null, null, null);
}
/**
@ -263,9 +273,18 @@ public class McpClientManager {
* {@link McpIdentityForwardService} opt-in per server; {@code null}
* means this server does not forward identity.
* @param audience the token audience for this server (ignored in plaintext mode).
* @param serverName human-readable MCP server name; forwarded into each
* {@link PrefixedNameToolCallback} so the tool description is tagged
* {@code [MCP server: <name>]}. May be {@code null} when unknown.
* @param mcpClient the active {@link McpSyncClient} for this server; when
* non-null each callback is wrapped in {@link ProgressAwareMcpToolCallback}
* so {@code _meta.progressToken} can be injected into tools/call requests.
* @param objectMapper JSON mapper for argument serialization inside the wrapper.
*/
static List<ToolCallback> wrapServerCallbacks(long serverId, ToolCallback[] cbs,
McpIdentityForwardService identitySvc, String audience) {
McpIdentityForwardService identitySvc, String audience,
String serverName,
McpSyncClient mcpClient, ObjectMapper objectMapper) {
List<String> rawNames = new ArrayList<>(cbs.length);
for (ToolCallback cb : cbs) {
rawNames.add(cb.getToolDefinition() != null ? cb.getToolDefinition().name() : null);
@ -294,7 +313,14 @@ public class McpClientManager {
ToolCallback inner = identitySvc != null
? new IdentityForwardingToolCallback(cb, identitySvc, audience)
: cb;
out.add(new PrefixedNameToolCallback(d.prefixedName(), inner));
// Wrap with progress-aware callback so _meta.progressToken is
// injected when ToolContext carries a progress token. Must sit
// inside PrefixedNameToolCallback so both the prefixed name and
// the call-path see the same chain.
if (mcpClient != null) {
inner = new ProgressAwareMcpToolCallback(inner, mcpClient, raw, objectMapper);
}
out.add(new PrefixedNameToolCallback(d.prefixedName(), inner, serverName));
}
return out;
}
@ -414,6 +440,29 @@ public class McpClientManager {
Long serverId = server.getId();
spec.toolsChangeConsumer(tools ->
eventPublisher.publishEvent(new McpServerChangedEvent("mcp-tools-changed:" + serverId)));
// MCP progress notifications: the server pushes progress for
// long-running tool calls. progressToken context lookup + event
// publish so McpProgressRelay can forward to the SSE stream.
spec.progressConsumer(progressNotification -> {
if (progressNotification == null || progressNotification.progressToken() == null) return;
McpProgressContext.ProgressEntry entry = progressContext.lookup(progressNotification.progressToken());
if (entry == null) return;
try {
McpProgressEvent event = new McpProgressEvent(
this,
entry.conversationId(),
entry.toolCallId(),
entry.toolName(),
progressNotification.progress(),
progressNotification.total(),
progressNotification.message()
);
eventPublisher.publishEvent(event);
} catch (Exception e) {
log.warn("Failed to publish McpProgressEvent: {}", e.getMessage());
}
});
}
return spec.build();
}

View File

@ -0,0 +1,58 @@
package vip.mate.tool.mcp.runtime;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Thread-safe progressToken mapping table.
* Maintains {@code progressToken (conversationId, toolCallId, toolName)} mappings,
* and progress snapshots for SSE reconnect delivery.
*/
@Component
public class McpProgressContext {
private final Map<String, ProgressEntry> tokenMap = new ConcurrentHashMap<>();
/** Progress snapshots: conversationId → (toolCallId → latest progress JSON) */
private final Map<String, Map<String, String>> snapshotMap = new ConcurrentHashMap<>();
public record ProgressEntry(String conversationId, String toolCallId, String toolName) {}
public void register(String progressToken, ProgressEntry entry) {
tokenMap.put(progressToken, entry);
}
public ProgressEntry lookup(String progressToken) {
return tokenMap.get(progressToken);
}
public void remove(String progressToken) {
tokenMap.remove(progressToken);
}
/** Update progress snapshot (called on each progress notification). */
public void updateSnapshot(String conversationId, String toolCallId, String progressJson) {
snapshotMap.computeIfAbsent(conversationId, k -> new ConcurrentHashMap<>())
.put(toolCallId, progressJson);
}
/** Snapshot of every in-progress tool call for a conversation, taken on SSE reconnect. */
public Map<String, String> getSnapshots(String conversationId) {
Map<String, String> tools = snapshotMap.get(conversationId);
return tools != null ? Map.copyOf(tools) : Map.of();
}
/**
* Remove a tool-call snapshot after completion, dropping the conversation's
* inner map once it is empty so the outer map does not accumulate empty
* entries for every conversation that ever ran a progress-reporting tool.
*/
public void removeSnapshot(String conversationId, String toolCallId) {
snapshotMap.computeIfPresent(conversationId, (k, tools) -> {
tools.remove(toolCallId);
return tools.isEmpty() ? null : tools;
});
}
}

View File

@ -0,0 +1,36 @@
package vip.mate.tool.mcp.runtime;
import org.springframework.context.ApplicationEvent;
/**
* MCP tool-call progress event.
* Published by {@code McpClientManager.progressConsumer} and consumed
* by {@link McpProgressRelay} for forwarding to {@code ChatStreamTracker}.
*/
public class McpProgressEvent extends ApplicationEvent {
private final String conversationId;
private final String toolCallId;
private final String toolName;
private final double progress; // 0.0 ~ 1.0
private final Double total; // may be null
private final String message; // current stage description
public McpProgressEvent(Object source, String conversationId, String toolCallId,
String toolName, double progress, Double total, String message) {
super(source);
this.conversationId = conversationId;
this.toolCallId = toolCallId;
this.toolName = toolName;
this.progress = progress;
this.total = total;
this.message = message;
}
public String getConversationId() { return conversationId; }
public String getToolCallId() { return toolCallId; }
public String getToolName() { return toolName; }
public double getProgress() { return progress; }
public Double getTotal() { return total; }
public String getMessage() { return message; }
}

View File

@ -0,0 +1,61 @@
package vip.mate.tool.mcp.runtime;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import vip.mate.channel.web.ChatStreamTracker;
import java.util.Map;
/**
* MCP progress event relay listens for {@link McpProgressEvent} and forwards to
* {@link ChatStreamTracker}. Progress events skip the event buffer ({@code skipBuffer=true})
* and do not participate in SSE reconnect replay. On reconnect, the latest snapshot is
* read from {@link McpProgressContext} by {@code ChatStreamTracker.attach()}.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class McpProgressRelay {
/**
* SSE event name constant, agreed upon between frontend and backend.
*/
public static final String EVENT_TOOL_PROGRESS = "tool_call_progress";
private final ChatStreamTracker streamTracker;
private final McpProgressContext progressContext;
private final ObjectMapper objectMapper;
@EventListener
public void onMcpProgress(McpProgressEvent event) {
try {
Map<String, Object> data = Map.of(
"toolCallId", event.getToolCallId(),
"toolName", event.getToolName(),
"percent", Math.round(event.getProgress() * 10000.0) / 100.0,
"total", event.getTotal() != null ? event.getTotal() : 1.0,
"message", event.getMessage() != null ? event.getMessage() : "",
"stage", inferStage(event.getProgress())
);
String jsonData = objectMapper.writeValueAsString(data);
// Update snapshot for SSE reconnect
progressContext.updateSnapshot(event.getConversationId(), event.getToolCallId(), jsonData);
// Broadcast to SSE (skipBuffer=true, not cached in ring buffer)
streamTracker.broadcastObject(event.getConversationId(), EVENT_TOOL_PROGRESS, data, true);
} catch (Exception e) {
log.warn("Failed to relay MCP progress: {}", e.getMessage());
}
}
/** Infer stage name from progress percentage. */
private String inferStage(double progress) {
if (progress <= 0.05) return "prepare";
if (progress <= 0.95) return "execute";
return "finalize";
}
}

View File

@ -21,13 +21,45 @@ import org.springframework.ai.tool.metadata.ToolMetadata;
* forwarded verbatim the wrapper changes only the name, so guard,
* approval, observability, and return-direct routing all see the same
* string they will write to bindings.
*
* <p>When {@code serverName} is provided (non-null, non-blank), the
* description is prefixed with {@code [MCP server: <name>]} so the LLM
* can identify which server a tool belongs to without parsing the
* opaque numeric {@code serverId} in the tool name. This is critical
* for tasks that mix tools from multiple MCP servers without the
* tag, the LLM cannot distinguish {@code mcp_1928..._search_xxx} from
* {@code mcp_1882..._search_yyy} and will reconstruct wrong tool names
* from memory. See agent-attention-anchoring design doc for details.
*/
public final class PrefixedNameToolCallback implements ToolCallback {
private final ToolCallback delegate;
private final ToolDefinition prefixedDefinition;
/**
* Backward-compatible constructor equivalent to passing
* {@code null} for {@code serverName} (no server tag in description).
*
* <p>Kept so existing tests and call sites that don't yet thread
* the server name through continue to compile.
*/
public PrefixedNameToolCallback(String prefixedName, ToolCallback delegate) {
this(prefixedName, delegate, null);
}
/**
* Primary constructor.
*
* @param prefixedName the {@code mcp_<serverId>_<slug>_<hash6>} name
* @param delegate the underlying MCP tool callback
* @param serverName human-readable MCP server name; when non-blank,
* prepended to the description as
* {@code [MCP server: <name>]} so the LLM can tell
* tools from different servers apart. May be
* {@code null} when the server name is unknown
* (e.g. in unit tests).
*/
public PrefixedNameToolCallback(String prefixedName, ToolCallback delegate, String serverName) {
if (prefixedName == null || prefixedName.isBlank()) {
throw new IllegalArgumentException("prefixedName must not be blank");
}
@ -36,9 +68,16 @@ public final class PrefixedNameToolCallback implements ToolCallback {
}
this.delegate = delegate;
ToolDefinition original = delegate.getToolDefinition();
String originalDesc = original != null ? original.description() : "";
if (originalDesc == null) {
originalDesc = "";
}
String enrichedDesc = (serverName != null && !serverName.isBlank())
? "[MCP server: " + serverName + "] " + originalDesc
: originalDesc;
this.prefixedDefinition = DefaultToolDefinition.builder()
.name(prefixedName)
.description(original != null ? original.description() : "")
.description(enrichedDesc)
.inputSchema(original != null ? original.inputSchema() : "{}")
.build();
}

View File

@ -0,0 +1,122 @@
package vip.mate.tool.mcp.runtime;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.spec.McpSchema;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.tool.metadata.ToolMetadata;
import java.util.Map;
import java.util.UUID;
/**
* MCP tool callback wrapper that injects {@code _meta.progressToken} into
* {@code tools/call} requests, enabling MCP Servers to push progress via
* {@code notifications/progress}.
*
* <p>When {@code MCP_PROGRESS_TOKEN} is present in {@link ToolContext}, this wrapper
* calls {@link McpSyncClient#callTool(McpSchema.CallToolRequest)} directly with the
* progressToken injected. Otherwise it delegates to the original callback
* (compatible with MCP Servers that do not support progress, and with built-in tools).
*/
@Slf4j
public final class ProgressAwareMcpToolCallback implements ToolCallback {
/** Key in ToolContext where the progressToken is stored. */
public static final String MCP_PROGRESS_TOKEN_KEY = "_mcp_progress_token";
private final ToolCallback delegate;
private final McpSyncClient mcpClient;
private final String rawToolName;
private final ObjectMapper objectMapper;
public ProgressAwareMcpToolCallback(ToolCallback delegate, McpSyncClient mcpClient,
String rawToolName, ObjectMapper objectMapper) {
this.delegate = delegate;
this.mcpClient = mcpClient;
this.rawToolName = rawToolName;
this.objectMapper = objectMapper;
}
@Override
public ToolDefinition getToolDefinition() {
return delegate.getToolDefinition();
}
@Override
public ToolMetadata getToolMetadata() {
return delegate.getToolMetadata();
}
@Override
public String call(String toolInput) {
return delegate.call(toolInput);
}
@Override
public String call(String toolInput, ToolContext toolContext) {
String progressToken = null;
if (toolContext != null && toolContext.getContext() != null) {
Object token = toolContext.getContext().get(MCP_PROGRESS_TOKEN_KEY);
if (token instanceof String s && !s.isBlank()) {
progressToken = s;
}
}
if (progressToken == null) {
return delegate.call(toolInput, toolContext);
}
try {
// Apply identity forwarding BEFORE building CallToolRequest
// otherwise the progress path would silently bypass identity injection.
String effectiveInput = toolInput;
if (delegate instanceof IdentityForwardingToolCallback idFwd) {
effectiveInput = idFwd.inject(toolInput, toolContext);
}
Map<String, Object> arguments = parseArguments(effectiveInput);
McpSchema.CallToolRequest request = McpSchema.CallToolRequest.builder()
.name(rawToolName)
.arguments(arguments != null ? arguments : Map.of())
.meta(Map.of("progressToken", progressToken))
.build();
McpSchema.CallToolResult result = mcpClient.callTool(request);
return serializeResult(result);
} catch (Exception e) {
log.warn("Progress-aware MCP call failed for tool '{}', falling back to delegate: {}",
rawToolName, e.getMessage());
return delegate.call(toolInput, toolContext);
}
}
private Map<String, Object> parseArguments(String toolInput) {
if (toolInput == null || toolInput.isBlank()) return Map.of();
try {
return objectMapper.readValue(toolInput, new TypeReference<Map<String, Object>>() {});
} catch (Exception e) {
log.debug("Failed to parse MCP tool arguments as JSON, using raw string: {}", e.getMessage());
return Map.of("input", toolInput);
}
}
private String serializeResult(McpSchema.CallToolResult result) {
if (result == null) return "";
if (result.content() == null || result.content().isEmpty()) return "";
StringBuilder sb = new StringBuilder();
for (var content : result.content()) {
if (content instanceof McpSchema.TextContent tc) {
sb.append(tc.text());
} else {
sb.append(content.toString());
}
}
return sb.toString();
}
/** Return the underlying delegate (for ReturnDirect / IdentityForward detection). */
public ToolCallback getDelegate() {
return delegate;
}
}

View File

@ -24,7 +24,7 @@ public class Model3dFileDownloader {
public Path download(String modelUrl, String conversationId, String taskId,
String preferredExtension) throws IOException {
Path dir = uploadLocationResolver.resolveUploadRoot(conversationId).resolve(conversationId);
Path dir = uploadLocationResolver.resolveConversationDir(conversationId);
Files.createDirectories(dir);
String ext = guessExtension(modelUrl, preferredExtension);

View File

@ -191,7 +191,7 @@ public class MusicGenerationService {
private PersistedAudio persistAudio(String conversationId, String taskId,
MusicGenerationResult result) throws IOException {
Path dir = uploadLocationResolver.resolveUploadRoot(conversationId).resolve(conversationId);
Path dir = uploadLocationResolver.resolveConversationDir(conversationId);
Files.createDirectories(dir);
String fileName = "music_" + taskId + "." + result.getFormat();
Path filePath = dir.resolve(fileName);

View File

@ -31,7 +31,7 @@ public class VideoFileDownloader {
* @return 本地文件路径
*/
public Path download(String videoUrl, String conversationId, String taskId) throws IOException {
Path dir = uploadLocationResolver.resolveUploadRoot(conversationId).resolve(conversationId);
Path dir = uploadLocationResolver.resolveConversationDir(conversationId);
Files.createDirectories(dir);
String extension = guessExtension(videoUrl);

View File

@ -204,7 +204,7 @@ public class TtsService {
private Path saveAudioFile(String conversationId, String fileId, byte[] data, String format)
throws IOException {
Path dir = uploadLocationResolver.resolveUploadRoot(conversationId).resolve(conversationId);
Path dir = uploadLocationResolver.resolveConversationDir(conversationId);
Files.createDirectories(dir);
String fileName = "tts_" + fileId + "." + format;
Path filePath = dir.resolve(fileName);

View File

@ -36,6 +36,9 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@ -509,13 +512,19 @@ public class WikiController {
// ==================== Raw Materials ====================
@RequireWorkspaceRole("viewer")
@Operation(summary = "获取原始材料列表(含每条材料生成的页面数)")
@Operation(summary = "获取原始材料列表(含每条材料生成的页面数),支持按状态/类型/关键词/时间筛选")
@GetMapping("/knowledge-bases/{kbId}/raw")
public R<List<Map<String, Object>>> listRaw(@PathVariable Long kbId,
@RequestParam(required = false) String status,
@RequestParam(required = false) String sourceType,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String startTime,
@RequestParam(required = false) String endTime,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(kbId, workspaceId);
List<WikiRawMaterialEntity> raws = rawService.listByKbId(kbId);
List<Map<String, Object>> result = new java.util.ArrayList<>(raws.size());
List<WikiRawMaterialEntity> raws = rawService.listByKbIdFiltered(
kbId, status, sourceType, keyword, parseTime(startTime, false), parseTime(endTime, true));
List<Map<String, Object>> result = new ArrayList<>(raws.size());
for (WikiRawMaterialEntity raw : raws) {
Map<String, Object> item = new LinkedHashMap<>();
// Serialize all entity fields via Jackson-friendly approach
@ -648,6 +657,137 @@ public class WikiController {
return R.ok();
}
/** Hard cap on how many raw materials one batch call may touch. */
private static final int MAX_RAW_BATCH = 500;
@RequireWorkspaceRole("member")
@Operation(summary = "批量重新处理原始材料(按 ids 或按 status 选取force=true 绕过 content_hash 短路)")
@PostMapping("/knowledge-bases/{kbId}/raw/batch/reprocess")
public R<Map<String, Object>> batchReprocessRaw(@PathVariable Long kbId,
@RequestBody Map<String, Object> body,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(kbId, workspaceId);
List<Long> ids = resolveBatchIds(kbId, body);
if (ids == null) {
return R.fail(400, "Must supply non-empty 'ids' or a 'status' selector");
}
if (ids.size() > MAX_RAW_BATCH) {
return R.fail(400, "Batch too large: " + ids.size() + " > " + MAX_RAW_BATCH);
}
boolean force = Boolean.TRUE.equals(body.get("force"));
int processed = 0;
int skipped = 0;
for (Long id : ids) {
WikiRawMaterialEntity raw = rawService.getById(id);
// Guard against cross-KB ids sneaking in via an explicit id list.
if (raw == null || !kbId.equals(raw.getKbId())) {
skipped++;
continue;
}
try {
if (force) {
rawService.setLastProcessedHash(id, null);
}
rawService.reprocess(id);
processed++;
} catch (Exception e) {
log.warn("[Wiki] Batch reprocess skipped raw={}: {}", id, e.getMessage());
skipped++;
}
}
return R.ok(Map.of("requested", ids.size(), "processed", processed, "skipped", skipped));
}
@RequireWorkspaceRole("admin")
@Operation(summary = "批量删除原始材料(按 ids 或按 status 选取;级联清理页面/分块)")
@PostMapping("/knowledge-bases/{kbId}/raw/batch/delete")
public R<Map<String, Object>> batchDeleteRaw(@PathVariable Long kbId,
@RequestBody Map<String, Object> body,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(kbId, workspaceId);
List<Long> ids = resolveBatchIds(kbId, body);
if (ids == null) {
return R.fail(400, "Must supply non-empty 'ids' or a 'status' selector");
}
if (ids.size() > MAX_RAW_BATCH) {
return R.fail(400, "Batch too large: " + ids.size() + " > " + MAX_RAW_BATCH);
}
int deleted = 0;
int skipped = 0;
for (Long id : ids) {
WikiRawMaterialEntity raw = rawService.getById(id);
if (raw == null || !kbId.equals(raw.getKbId())) {
skipped++;
continue;
}
try {
rawService.delete(id);
kbService.decrementRawCount(kbId);
deleted++;
} catch (Exception e) {
log.warn("[Wiki] Batch delete skipped raw={}: {}", id, e.getMessage());
skipped++;
}
}
return R.ok(Map.of("requested", ids.size(), "deleted", deleted, "skipped", skipped));
}
/**
* Resolve the target id set for a batch operation from the request body.
* An explicit non-empty {@code ids} array wins; otherwise a {@code status}
* selector resolves to every raw in the KB with that status. Returns
* {@code null} when neither is usable (caller returns 400). Ids are parsed
* leniently from string or number JSON forms (Snowflake precision).
*/
private List<Long> resolveBatchIds(Long kbId, Map<String, Object> body) {
Object rawIds = body.get("ids");
if (rawIds instanceof List<?> list && !list.isEmpty()) {
List<Long> ids = new ArrayList<>(list.size());
for (Object o : list) {
if (o == null) continue;
try {
ids.add(Long.parseLong(String.valueOf(o).trim()));
} catch (NumberFormatException ignored) {
// Skip malformed ids rather than failing the whole batch.
}
}
return ids.isEmpty() ? null : ids;
}
Object status = body.get("status");
if (status != null && !String.valueOf(status).isBlank()) {
List<Long> ids = rawService.selectIdsByStatus(kbId, String.valueOf(status).trim());
return ids.isEmpty() ? List.of() : ids;
}
return null;
}
/**
* Parse an optional ISO date or date-time filter bound. Accepts a full
* {@code LocalDateTime} (e.g. {@code 2026-07-10T13:00:00}) or a bare date
* (e.g. {@code 2026-07-10}); a bare date maps to start-of-day for a lower
* bound or end-of-day for an upper bound. Blank/unparseable input yields
* {@code null} (no clause) rather than an error.
*
* @param value the raw query-parameter string
* @param endOfDay when true and {@code value} is date-only, use 23:59:59.999999999
*/
private LocalDateTime parseTime(String value, boolean endOfDay) {
if (value == null || value.isBlank()) return null;
String v = value.trim();
try {
return LocalDateTime.parse(v);
} catch (Exception ignored) {
// Fall through to date-only parsing.
}
try {
LocalDate d = LocalDate.parse(v);
return endOfDay ? d.atTime(java.time.LocalTime.MAX) : d.atStartOfDay();
} catch (Exception ignored) {
log.warn("[Wiki] Ignoring unparseable raw-material time filter: {}", v);
return null;
}
}
@RequireWorkspaceRole("viewer")
@Operation(summary = "下载原始材料")
@GetMapping("/knowledge-bases/{kbId}/raw/{rawId}/download")

View File

@ -55,6 +55,15 @@ public class WikiLinkService {
*/
private static final Pattern WIKILINK = Pattern.compile("\\[\\[([^\\]]+?)]]");
/**
* Matches a cross-KB wikilink target of the form {@code kbId/slug}, where
* {@code kbId} is a numeric knowledge-base id and {@code slug} is a page
* slug inside that KB. A plain single-KB slug never contains {@code /}, so
* this pattern is unambiguous historical {@code [[slug]]} /
* {@code [[Title]]} content is unaffected.
*/
private static final Pattern CROSS_KB = Pattern.compile("^(\\d+)/(.+)$");
/**
* Matches a fenced code block. Anchored to {@code ^```} on a line so a
* stray triple-backtick mid-paragraph does not flip the world into "in
@ -135,11 +144,44 @@ public class WikiLinkService {
if (resolvableKeysLower == null) resolvableKeysLower = Collections.emptySet();
List<String> broken = new ArrayList<>();
for (String t : outlinks) {
// Cross-KB targets ([[kbId/slug]]) can't be validated against this
// KB's key set checking existence would need a cross-KB query.
// Exempt well-formed cross-KB refs from the single-KB broken-link
// rule so they aren't false-flagged; the target KB's viewer surfaces
// a real page-not-found on click if the slug is stale.
if (parseCrossKb(t) != null) continue;
if (!resolvableKeysLower.contains(t)) broken.add(t);
}
return broken;
}
/**
* Parse a cross-KB wikilink target {@code kbId/slug} into its parts, or
* {@code null} when {@code target} is a plain single-KB slug/title.
* Pure function no I/O, no existence check.
*
* @param target the wikilink target (before any {@code |alias}); may be
* already lowercased by {@link #extractOutlinks}
* @return {@link CrossKbRef} when the target has a numeric KB prefix, else null
*/
public CrossKbRef parseCrossKb(String target) {
if (target == null || target.isBlank()) return null;
Matcher m = CROSS_KB.matcher(target.trim());
if (!m.matches()) return null;
try {
long kbId = Long.parseLong(m.group(1));
String slug = m.group(2).trim();
if (slug.isEmpty()) return null;
return new CrossKbRef(kbId, slug);
} catch (NumberFormatException e) {
// kbId overflowed long treat as a plain (broken) single-KB target.
return null;
}
}
/** A parsed cross-KB wikilink target: target KB id + page slug. */
public record CrossKbRef(long kbId, String slug) {}
/**
* Convenience: extract + compute in one call. Used from
* {@code WikiPageService.save/update} where both fields are written in

View File

@ -20,6 +20,7 @@ import vip.mate.wiki.repository.WikiRawMaterialMapper;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.List;
import vip.mate.wiki.dto.WikiFailureItem;
@ -64,9 +65,40 @@ public class WikiRawMaterialService {
private final Set<Long> partialResumeIds = ConcurrentHashMap.newKeySet();
public List<WikiRawMaterialEntity> listByKbId(Long kbId) {
return listByKbIdFiltered(kbId, null, null, null, null, null);
}
/**
* List raw materials in a KB, optionally narrowed by any combination of
* processing status, source type, a title keyword, and a create-time range.
* All filter arguments are optional a {@code null}/blank value drops that
* clause, so calling with all-null is identical to the unfiltered list.
* <p>
* Powers the raw-material panel's filter bar (issue #506): with dozens of
* materials per KB, filtering by {@code status = "failed"} or a title
* keyword replaces page-by-page scrolling.
*
* @param kbId owning knowledge base (required)
* @param status processing status (pending/processing/completed/failed/partial/cancelled)
* @param sourceType source type (text/pdf/docx/image/)
* @param keyword case-insensitive substring matched against the title
* @param startTime inclusive lower bound on create time
* @param endTime inclusive upper bound on create time
*/
public List<WikiRawMaterialEntity> listByKbIdFiltered(Long kbId, String status, String sourceType,
String keyword,
LocalDateTime startTime, LocalDateTime endTime) {
List<WikiRawMaterialEntity> list = rawMapper.selectList(
new LambdaQueryWrapper<WikiRawMaterialEntity>()
.eq(WikiRawMaterialEntity::getKbId, kbId)
.eq(status != null && !status.isBlank(),
WikiRawMaterialEntity::getProcessingStatus, status)
.eq(sourceType != null && !sourceType.isBlank(),
WikiRawMaterialEntity::getSourceType, sourceType)
.like(keyword != null && !keyword.isBlank(),
WikiRawMaterialEntity::getTitle, keyword)
.ge(startTime != null, WikiRawMaterialEntity::getCreateTime, startTime)
.le(endTime != null, WikiRawMaterialEntity::getCreateTime, endTime)
.orderByDesc(WikiRawMaterialEntity::getCreateTime));
// 不返回大文本字段
list.forEach(r -> {
@ -80,6 +112,23 @@ public class WikiRawMaterialService {
return rawMapper.selectById(id);
}
/**
* Ids of all raw materials in {@code kbId} with the given processing
* status, newest first. Used by the batch reprocess/delete endpoints to
* resolve a status selector (e.g. "retry all failed") server-side so the
* client doesn't have to enumerate ids.
*/
public List<Long> selectIdsByStatus(Long kbId, String status) {
if (status == null || status.isBlank()) return List.of();
return rawMapper.selectList(
new LambdaQueryWrapper<WikiRawMaterialEntity>()
.select(WikiRawMaterialEntity::getId)
.eq(WikiRawMaterialEntity::getKbId, kbId)
.eq(WikiRawMaterialEntity::getProcessingStatus, status)
.orderByDesc(WikiRawMaterialEntity::getCreateTime))
.stream().map(WikiRawMaterialEntity::getId).toList();
}
public WikiRawMaterialEntity findBySourcePath(Long kbId, String sourcePath) {
return rawMapper.selectOne(
new LambdaQueryWrapper<WikiRawMaterialEntity>()

View File

@ -36,7 +36,6 @@ import vip.mate.workspace.core.service.WorkspaceService;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.ArrayList;
@ -1692,18 +1691,10 @@ public class ConversationService {
return;
}
boolean cleanedAny = false;
for (Path root : chatUploadLocationResolver.resolveCandidateUploadRoots(conversationId)) {
Path dir;
try {
dir = root.resolve(conversationId);
} catch (InvalidPathException e) {
// Conversation id contains characters illegal on this filesystem
// (e.g. ':' in cron:<jobId> on Windows). No attachments could
// ever have been written under such an id on this OS, so there
// is nothing to clean.
log.debug("Skipping attachment cleanup for non-path-safe conversation id: {}", conversationId);
return;
}
// resolveCandidateConversationDirs sanitizes the id for the path segment
// (so ids like "wecom:XXXX" clean correctly on Windows) and also probes
// the raw-id dir for pre-fix Linux uploads.
for (Path dir : chatUploadLocationResolver.resolveCandidateConversationDirs(conversationId)) {
if (!Files.exists(dir)) {
continue;
}

View File

@ -17,6 +17,7 @@ import vip.mate.workspace.core.model.WorkspaceEntity;
import vip.mate.workspace.conversation.model.ConversationEntity;
import vip.mate.workspace.conversation.repository.ConversationMapper;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
@ -62,6 +63,65 @@ public class ChatUploadLocationResolver {
/** Sub-directory appended under a configured base path. */
public static final String UPLOAD_SUBDIR = "chat-uploads";
/**
* Turn a business conversation id into a filesystem-safe path segment.
* <p>
* IM-channel conversation ids carry a {@code channelType:identifier} shape
* (e.g. {@code wecom:XuZhanFu}); the {@code :} is illegal in a Windows
* filename (reserved for drive / alternate-data-stream syntax), so using the
* raw id as a directory name throws {@link InvalidPathException} on Windows
* and breaks every attachment / media write for IM channels there. Every
* conversationId directory-segment mapping MUST route through this method
* so writes and reads agree on the on-disk layout.
* <p>
* The replacement set matches {@code ToolResultStorage.sanitize} exactly and
* is a no-op for ids that are already {@code [A-Za-z0-9_.-]}-only (web /
* webchat / numeric), so their existing on-disk layout is unchanged.
*
* @param conversationId raw business id ({@code null} yields empty string)
* @return a segment safe to use as a single path component on any OS
*/
public static String sanitizeSegment(String conversationId) {
if (conversationId == null) return "";
return conversationId.replaceAll("[^A-Za-z0-9_.-]", "_");
}
/**
* The single conversation attachment directory (write target):
* {@code {uploadRoot}/{sanitizeSegment(conversationId)}/}.
*/
public Path resolveConversationDir(String conversationId) {
return resolveUploadRoot(conversationId).resolve(sanitizeSegment(conversationId));
}
/**
* Every conversation attachment directory a read / cleanup path should probe,
* ordered: the sanitized dir under each candidate root first, then for
* backward compatibility with pre-fix Linux uploads that used the raw id
* verbatim the raw-id dir (only when it differs from the sanitized form
* and is a legal path on this filesystem).
* <p>
* On Windows a raw id containing {@code :} throws {@link InvalidPathException}
* from {@link Path#resolve(String)}; such an id never produced a directory on
* Windows, so the raw candidate is simply skipped.
*/
public List<Path> resolveCandidateConversationDirs(String conversationId) {
String safe = sanitizeSegment(conversationId);
Set<Path> dirs = new LinkedHashSet<>();
for (Path root : resolveCandidateUploadRoots(conversationId)) {
dirs.add(root.resolve(safe));
if (!safe.equals(conversationId)) {
try {
dirs.add(root.resolve(conversationId));
} catch (InvalidPathException ignore) {
// Raw id is not a legal path on this OS (e.g. ':' on Windows);
// no legacy attachments could exist there, so skip it.
}
}
}
return new ArrayList<>(dirs);
}
private final ConversationMapper conversationMapper;
private final WorkspaceService workspaceService;
private final ChatUploadProperties properties;

View File

@ -133,6 +133,10 @@ MERGE INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, a
KEY (provider_id)
VALUES ('volcengine-plan', 'Volcano Engine Coding Plan', '', 'OpenAIChatModel', '', 'https://ark.cn-beijing.volces.com/api/coding/v3', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW());
MERGE INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time)
KEY (provider_id)
VALUES ('volcengine-agent-plan', 'Volcano Engine Agent Plan', '', 'OpenAIChatModel', '', 'https://ark.cn-beijing.volces.com/api/plan/v3', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW());
MERGE INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time)
KEY (provider_id)
VALUES ('zhipu-cn-codingplan', 'Zhipu Coding Plan (BigModel)', '', 'OpenAIChatModel', '', 'https://open.bigmodel.cn/api/coding/paas/v4', '{"completionsPath":"/chat/completions"}', FALSE, FALSE, FALSE, FALSE, TRUE, TRUE, NOW(), NOW());
@ -319,6 +323,18 @@ MERGE INTO mate_model_config (id, name, provider, model_name, description, tempe
(1000000323, 'GLM 4.7 Coding', 'volcengine-plan', 'glm-4.7', 'GLM 4.7 coding edition (hosted on Volcano Ark), 200K context', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000324, 'Kimi K2 Thinking', 'volcengine-plan', 'kimi-k2-thinking', 'Kimi K2 Thinking (hosted on Volcano Ark), 256K context', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000325, 'Kimi K2.5 Coding', 'volcengine-plan', 'kimi-k2.5', 'Kimi K2.5 coding edition (hosted on Volcano Ark), 256K context', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000350, 'GLM-5.2', 'volcengine-agent-plan', 'glm-5.2', 'Zhipu latest flagship, 1M context, strong on long-horizon tasks (use glm-latest for newest)', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000351, 'Ark Agent Plan (Auto Router)', 'volcengine-agent-plan', 'ark-code-latest', 'Auto-routing entry that dispatches to the best-fit plan model', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000353, 'Doubao-Seed-2.0-Code', 'volcengine-agent-plan', 'doubao-seed-2.0-code', 'Seed 2.0 code-tuned, strong front-end and multi-language; non-thinking by default, deep thinking optional', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000354, 'Doubao-Seed-2.0-pro', 'volcengine-agent-plan', 'doubao-seed-2.0-pro', 'Flagship general model for complex reasoning and long-chain tasks; thinking on by default', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000355, 'Doubao-Seed-2.0-lite', 'volcengine-agent-plan', 'doubao-seed-2.0-lite', 'Balanced quality and speed for general production workloads', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000356, 'Doubao-Seed-2.0-mini', 'volcengine-agent-plan', 'doubao-seed-2.0-mini', 'Low-latency, high-concurrency, cost-sensitive lightweight tasks', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000357, 'Kimi-K2.7-Code', 'volcengine-agent-plan', 'kimi-k2.7-code', 'Latest Kimi coding model; reliable long-context instruction following, text/image/video input', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000358, 'MiniMax-M3', 'volcengine-agent-plan', 'minimax-m3', 'New-gen M-series, top-tier on coding and agent benchmarks', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000359, 'DeepSeek-V4-Flash', 'volcengine-agent-plan', 'deepseek-v4-flash', 'Fast, economical DeepSeek-V4; thinking on by default, can be disabled', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000360, 'DeepSeek-V4-Pro', 'volcengine-agent-plan', 'deepseek-v4-pro', 'DeepSeek-V4 with strengthened agent ability and rich world knowledge', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000361, 'MiniMax-M2.7', 'volcengine-agent-plan', 'minimax-m2.7', 'Builds complex agent harnesses via teams, skills and tools', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000362, 'Kimi-K2.6', 'volcengine-agent-plan', 'kimi-k2.6', 'Moonshot next-gen model; thinking on by default, can be disabled', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000240, 'Kimi for Coding', 'kimi-code', 'kimi-for-coding', 'Kimi Code dedicated coding model', 0.2, 32768, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000250, 'GPT-5.4', 'openai-chatgpt', 'gpt-5.4', 'ChatGPT Plus/Pro member model (OAuth login)', NULL, 128000, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000251, 'GPT-5.4 Mini', 'openai-chatgpt', 'gpt-5.4-mini', 'ChatGPT member lightweight model', NULL, 128000, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
@ -1888,3 +1904,61 @@ WHERE NOT EXISTS (SELECT 1 FROM mate_tool_guard_config WHERE id = 1000000001);
-- Removed 6 legacy SQL rules (rule_id: write_file_any, edit_file_any, shell_rm_approval,
-- shell_rm_rf_block, shell_write_system_file, shell_chmod_777).
-- Their superset is registered in ToolGuardRuleSeedService.buildBuiltinRules() with correct tool names.
-- ==================== Content Studio scenario (公众号 / 小红书 图文创作) ====================
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000630, 'WechatArticleExtractTool', 'WeChat Article Extract', 'Fetch a WeChat Official Account (公众号) article by URL and return cleaned title/author/time/body(Markdown)/images. Preferred over browser_use for mp.weixin.qq.com article pages; use it for reference gathering and summarisation.', 'builtin', 'wechatArticleExtractTool', '📰', TRUE, TRUE, NOW(), NOW(), 0);
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000631, 'GzhPublishTool', 'WeChat OA Publish', 'Publish a generated image-text article to a WeChat Official Account: action=draft uploads the cover and creates a 草稿箱 draft (recommended); action=publish free-publishes for verified accounts and requires explicit confirmation. Needs weixinoa.app_id/app_secret in system settings.', 'builtin', 'gzhPublishTool', '📤', TRUE, TRUE, NOW(), NOW(), 0);
MERGE INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted)
KEY (id)
VALUES (1000000640, 'Content Studio', 'End-to-end 公众号 & 小红书 image-text creation: research, write, illustrate, de-AI, layout, and publish to draft.', 'react', 'You are MateClaw''s Content Studio — a specialist that creates WeChat Official Account (公众号) and Xiaohongshu (小红书) image-text posts end to end.
Workflow (7 stages):
1) Topic use the topic_interests memory + web_search(freshness=week) to find angles.
2) Research for reference links use wechat_article_extract (or browser_use) and summarise, staying original and citing sources; never copy verbatim.
3) Write for load the gzh_article skill, for load the xhs_note skill, honoring the user persona and style.
4) Illustrate image_generate for covers/inline art, and render_html_image to turn card HTML into images.
5) De-AI load the deai_humanize skill and run its detectrewrite loop until the AI-trace score is low.
6) Package & deliver use gzh_package: pass the article body as Markdown and it builds the inline-styled HTML plus an online preview and a downloadable material bundle server-side. Do NOT hand-write a large HTML blob into write_file / render_html_image(html=...) big, escape-heavy tool arguments get truncated and make the call fail.
7) Publish send the gzh_package online preview to the user, and after confirmation default to gzh_publish action=draft (into the 稿).
At the start of a task, recall_structured these keys and honor them: content_persona, writing_style_gzh, writing_style_xhs, topic_interests, banned_words, signature_blocks. If a needed one is missing, ask the user once and remember_structured it.
Publishing is an outward, irreversible action: always show the final content and get explicit user confirmation before calling gzh_publish; never free-publish without confirmPublish=true and the user''s sign-off. Respect banned_words and advertising-law restrictions; keep every piece original.
', NULL, 100, TRUE, 'pi:pen-nib', 'content,gzh,xhs,writing', NOW(), NOW(), 0);
-- ---- Content Studio T3/T4: 小红书发布工具 + 场景化 Cron 模板(默认关闭,用户按需启用)----
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000632, 'XhsPublishTool', 'Xiaohongshu Publish', 'Package a Xiaohongshu (小红书) note (copy + tags + card images) into one downloadable .zip and give manual-publish steps. No official API — never auto-uploads or bypasses verification.', 'builtin', 'xhsPublishTool', '📕', TRUE, TRUE, NOW(), NOW(), 0);
MERGE INTO mate_cron_job (id, name, cron_expression, timezone, agent_id, task_type, trigger_message, request_body, enabled, create_time, update_time, deleted)
KEY (id)
VALUES (1000100020, 'Daily Topic Radar', '0 8 * * *', 'Asia/Shanghai', 1000000640, 'agent', NULL, 'Read the topic_interests structured memory, use web_search(freshness=week) to gather today''s fresh angles on those directions, and produce a Today''s Topic List: each item with a working title, a one-line angle, target platform (公众号/小红书), and a suggested illustration direction. Selection only — do not write the full article.', FALSE, NOW(), NOW(), 0);
MERGE INTO mate_cron_job (id, name, cron_expression, timezone, agent_id, task_type, trigger_message, request_body, enabled, create_time, update_time, deleted)
KEY (id)
VALUES (1000100021, 'Weekly 公众号 Draft', '0 9 * * 1', 'Asia/Shanghai', 1000000640, 'agent', NULL, 'Pick one topic from topic_interests for this week, load the gzh_article skill to produce a full 公众号 image-text article (with illustrations and de-AI pass), laid out as inline-styled HTML. If 公众号 credentials (weixinoa.app_id/app_secret) are configured in system settings, use gzh_publish action=draft to save it to the draft box and remind me to review and publish in the backend; otherwise just send me the HTML and cover.', FALSE, NOW(), NOW(), 0);
-- Content Studio: gzh_package (Markdown -> 在线预览 + 素材下载, avoids big-HTML tool-arg truncation)
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000633, 'GzhPackageTool', 'WeChat Article Package', 'Package a finished 公众号 article from Markdown into an online preview (rendered HTML) plus a downloadable material bundle (article.html + article.md + cover). Builds the inline-styled HTML server-side so a large HTML string never rides on the tool-argument stream (which truncates and fails).', 'builtin', 'gzhPackageTool', '📦', TRUE, TRUE, NOW(), NOW(), 0);
-- Content Studio: capture_screenshot (真实后台截图 -> 可嵌入图片 URL, 供产品教程配图)
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000634, 'ScreenshotTool', 'Console Screenshot', 'Capture a screenshot of a MateClaw console page (relative path like /chat, /channels) and return an embeddable image URL. Use it to put REAL product screenshots into how-to/tutorial articles; embed the returned URL as ![](url) in a gzh_package Markdown body.', 'builtin', 'screenshotTool', '📷', TRUE, TRUE, NOW(), NOW(), 0);
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000635, 'XhsPackageTool', 'Xiaohongshu Package', 'Package a Xiaohongshu (小红书) note into an image-first online preview (phone-style swipe: images up top, copy below) plus a material zip (numbered card images + copy.txt). Requires at least 3 vertical images (1 cover + >=2 content); refuses fewer. 小红书 has no publish API; never auto-uploads.', 'builtin', 'xhsPackageTool', '🖼️', TRUE, TRUE, NOW(), NOW(), 0);
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000636, 'ContentItemTool', 'Content Calendar', 'Content calendar / dedup ledger: check_recent (has this topic run on this platform in the last N days — call before picking a topic), record (log a produced piece with title/preview/status), mark_published. Keeps the daily scheduler from repeating topics and makes publishing auditable.', 'builtin', 'contentItemTool', '🗓️', TRUE, TRUE, NOW(), NOW(), 0);
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000637, 'ComplianceScanTool', 'Compliance Scan', 'Server-side compliance scan before publishing: 广告法 极限词, WeChat 诱导 words (集赞/助力/share-to-unlock/follow-to-read), promised returns, and medical-efficacy claims. Returns hits by category; the 公众号 draft path hard-blocks high-risk hits.', 'builtin', 'complianceScanTool', '🛡️', TRUE, TRUE, NOW(), NOW(), 0);

View File

@ -144,6 +144,10 @@ INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model,
VALUES ('volcengine-plan', 'Volcano Engine Coding Plan', '', 'OpenAIChatModel', '', 'https://ark.cn-beijing.volces.com/api/coding/v3', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW())
ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, chat_model=EXCLUDED.chat_model, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time;
INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time)
VALUES ('volcengine-agent-plan', 'Volcano Engine Agent Plan', '', 'OpenAIChatModel', '', 'https://ark.cn-beijing.volces.com/api/plan/v3', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW())
ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, chat_model=EXCLUDED.chat_model, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time;
INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time)
VALUES ('zhipu-cn-codingplan', 'Zhipu Coding Plan (BigModel)', '', 'OpenAIChatModel', '', 'https://open.bigmodel.cn/api/coding/paas/v4', '{"completionsPath":"/chat/completions"}', FALSE, FALSE, FALSE, FALSE, TRUE, TRUE, NOW(), NOW())
ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, chat_model=EXCLUDED.chat_model, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time;
@ -362,6 +366,18 @@ VALUES
(1000000323, 'GLM 4.7 Coding', 'volcengine-plan', 'glm-4.7', 'GLM 4.7 coding edition (hosted on Volcano Ark), 200K context', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000324, 'Kimi K2 Thinking', 'volcengine-plan', 'kimi-k2-thinking', 'Kimi K2 Thinking (hosted on Volcano Ark), 256K context', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000325, 'Kimi K2.5 Coding', 'volcengine-plan', 'kimi-k2.5', 'Kimi K2.5 coding edition (hosted on Volcano Ark), 256K context', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000350, 'GLM-5.2', 'volcengine-agent-plan', 'glm-5.2', 'Zhipu latest flagship, 1M context, strong on long-horizon tasks (use glm-latest for newest)', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000351, 'Ark Agent Plan (Auto Router)', 'volcengine-agent-plan', 'ark-code-latest', 'Auto-routing entry that dispatches to the best-fit plan model', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000353, 'Doubao-Seed-2.0-Code', 'volcengine-agent-plan', 'doubao-seed-2.0-code', 'Seed 2.0 code-tuned, strong front-end and multi-language; non-thinking by default, deep thinking optional', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000354, 'Doubao-Seed-2.0-pro', 'volcengine-agent-plan', 'doubao-seed-2.0-pro', 'Flagship general model for complex reasoning and long-chain tasks; thinking on by default', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000355, 'Doubao-Seed-2.0-lite', 'volcengine-agent-plan', 'doubao-seed-2.0-lite', 'Balanced quality and speed for general production workloads', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000356, 'Doubao-Seed-2.0-mini', 'volcengine-agent-plan', 'doubao-seed-2.0-mini', 'Low-latency, high-concurrency, cost-sensitive lightweight tasks', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000357, 'Kimi-K2.7-Code', 'volcengine-agent-plan', 'kimi-k2.7-code', 'Latest Kimi coding model; reliable long-context instruction following, text/image/video input', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000358, 'MiniMax-M3', 'volcengine-agent-plan', 'minimax-m3', 'New-gen M-series, top-tier on coding and agent benchmarks', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000359, 'DeepSeek-V4-Flash', 'volcengine-agent-plan', 'deepseek-v4-flash', 'Fast, economical DeepSeek-V4; thinking on by default, can be disabled', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000360, 'DeepSeek-V4-Pro', 'volcengine-agent-plan', 'deepseek-v4-pro', 'DeepSeek-V4 with strengthened agent ability and rich world knowledge', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000361, 'MiniMax-M2.7', 'volcengine-agent-plan', 'minimax-m2.7', 'Builds complex agent harnesses via teams, skills and tools', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000362, 'Kimi-K2.6', 'volcengine-agent-plan', 'kimi-k2.6', 'Moonshot next-gen model; thinking on by default, can be disabled', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000240, 'Kimi for Coding', 'kimi-code', 'kimi-for-coding', 'Kimi Code dedicated coding model', 0.2, 32768, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000250, 'GPT-5.4', 'openai-chatgpt', 'gpt-5.4', 'ChatGPT Plus/Pro member model (OAuth login)', NULL, 128000, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000251, 'GPT-5.4 Mini', 'openai-chatgpt', 'gpt-5.4-mini', 'ChatGPT member lightweight model', NULL, 128000, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
@ -1813,3 +1829,61 @@ ON CONFLICT (id) DO NOTHING;
-- Security rules are managed by ToolGuardRuleSeedService (Java) as single source of truth.
-- Removed 6 legacy SQL rules. Their superset is registered in ToolGuardRuleSeedService.buildBuiltinRules() with correct tool names.
-- ==================== Content Studio scenario (公众号 / 小红书 图文创作) ====================
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000630, 'WechatArticleExtractTool', 'WeChat Article Extract', 'Fetch a WeChat Official Account (公众号) article by URL and return cleaned title/author/time/body(Markdown)/images. Preferred over browser_use for mp.weixin.qq.com article pages; use it for reference gathering and summarisation.', 'builtin', 'wechatArticleExtractTool', '📰', TRUE, TRUE, NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000631, 'GzhPublishTool', 'WeChat OA Publish', 'Publish a generated image-text article to a WeChat Official Account: action=draft uploads the cover and creates a 草稿箱 draft (recommended); action=publish free-publishes for verified accounts and requires explicit confirmation. Needs weixinoa.app_id/app_secret in system settings.', 'builtin', 'gzhPublishTool', '📤', TRUE, TRUE, NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
INSERT INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted)
VALUES (1000000640, 'Content Studio', 'End-to-end 公众号 & 小红书 image-text creation: research, write, illustrate, de-AI, layout, and publish to draft.', 'react', 'You are MateClaw''s Content Studio — a specialist that creates WeChat Official Account (公众号) and Xiaohongshu (小红书) image-text posts end to end.
Workflow (7 stages):
1) Topic use the topic_interests memory + web_search(freshness=week) to find angles.
2) Research for reference links use wechat_article_extract (or browser_use) and summarise, staying original and citing sources; never copy verbatim.
3) Write for load the gzh_article skill, for load the xhs_note skill, honoring the user persona and style.
4) Illustrate image_generate for covers/inline art, and render_html_image to turn card HTML into images.
5) De-AI load the deai_humanize skill and run its detectrewrite loop until the AI-trace score is low.
6) Package & deliver use gzh_package: pass the article body as Markdown and it builds the inline-styled HTML plus an online preview and a downloadable material bundle server-side. Do NOT hand-write a large HTML blob into write_file / render_html_image(html=...) big, escape-heavy tool arguments get truncated and make the call fail.
7) Publish send the gzh_package online preview to the user, and after confirmation default to gzh_publish action=draft (into the 稿).
At the start of a task, recall_structured these keys and honor them: content_persona, writing_style_gzh, writing_style_xhs, topic_interests, banned_words, signature_blocks. If a needed one is missing, ask the user once and remember_structured it.
Publishing is an outward, irreversible action: always show the final content and get explicit user confirmation before calling gzh_publish; never free-publish without confirmPublish=true and the user''s sign-off. Respect banned_words and advertising-law restrictions; keep every piece original.
', NULL, 100, TRUE, 'pi:pen-nib', 'content,gzh,xhs,writing', NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, agent_type=EXCLUDED.agent_type, system_prompt=EXCLUDED.system_prompt, model_name=EXCLUDED.model_name, max_iterations=EXCLUDED.max_iterations, enabled=EXCLUDED.enabled, icon=EXCLUDED.icon, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
-- ---- Content Studio T3/T4: 小红书发布工具 + 场景化 Cron 模板(默认关闭,用户按需启用)----
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000632, 'XhsPublishTool', 'Xiaohongshu Publish', 'Package a Xiaohongshu (小红书) note (copy + tags + card images) into one downloadable .zip and give manual-publish steps. No official API — never auto-uploads or bypasses verification.', 'builtin', 'xhsPublishTool', '📕', TRUE, TRUE, NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
INSERT INTO mate_cron_job (id, name, cron_expression, timezone, agent_id, task_type, trigger_message, request_body, enabled, create_time, update_time, deleted)
VALUES (1000100020, 'Daily Topic Radar', '0 8 * * *', 'Asia/Shanghai', 1000000640, 'agent', NULL, 'Read the topic_interests structured memory, use web_search(freshness=week) to gather today''s fresh angles on those directions, and produce a Today''s Topic List: each item with a working title, a one-line angle, target platform (公众号/小红书), and a suggested illustration direction. Selection only — do not write the full article.', FALSE, NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, cron_expression=EXCLUDED.cron_expression, timezone=EXCLUDED.timezone, agent_id=EXCLUDED.agent_id, task_type=EXCLUDED.task_type, trigger_message=EXCLUDED.trigger_message, request_body=EXCLUDED.request_body, enabled=EXCLUDED.enabled, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
INSERT INTO mate_cron_job (id, name, cron_expression, timezone, agent_id, task_type, trigger_message, request_body, enabled, create_time, update_time, deleted)
VALUES (1000100021, 'Weekly 公众号 Draft', '0 9 * * 1', 'Asia/Shanghai', 1000000640, 'agent', NULL, 'Pick one topic from topic_interests for this week, load the gzh_article skill to produce a full 公众号 image-text article (with illustrations and de-AI pass), laid out as inline-styled HTML. If 公众号 credentials (weixinoa.app_id/app_secret) are configured in system settings, use gzh_publish action=draft to save it to the draft box and remind me to review and publish in the backend; otherwise just send me the HTML and cover.', FALSE, NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, cron_expression=EXCLUDED.cron_expression, timezone=EXCLUDED.timezone, agent_id=EXCLUDED.agent_id, task_type=EXCLUDED.task_type, trigger_message=EXCLUDED.trigger_message, request_body=EXCLUDED.request_body, enabled=EXCLUDED.enabled, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
-- Content Studio: gzh_package (Markdown -> 在线预览 + 素材下载, avoids big-HTML tool-arg truncation)
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000633, 'GzhPackageTool', 'WeChat Article Package', 'Package a finished 公众号 article from Markdown into an online preview (rendered HTML) plus a downloadable material bundle (article.html + article.md + cover). Builds the inline-styled HTML server-side so a large HTML string never rides on the tool-argument stream (which truncates and fails).', 'builtin', 'gzhPackageTool', '📦', TRUE, TRUE, NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
-- Content Studio: capture_screenshot (真实后台截图 -> 可嵌入图片 URL, 供产品教程配图)
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000634, 'ScreenshotTool', 'Console Screenshot', 'Capture a screenshot of a MateClaw console page (relative path like /chat, /channels) and return an embeddable image URL. Use it to put REAL product screenshots into how-to/tutorial articles; embed the returned URL as ![](url) in a gzh_package Markdown body.', 'builtin', 'screenshotTool', '📷', TRUE, TRUE, NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000635, 'XhsPackageTool', 'Xiaohongshu Package', 'Package a Xiaohongshu (小红书) note into an image-first online preview (phone-style swipe: images up top, copy below) plus a material zip (numbered card images + copy.txt). Requires at least 3 vertical images (1 cover + >=2 content); refuses fewer. 小红书 has no publish API; never auto-uploads.', 'builtin', 'xhsPackageTool', '🖼️', TRUE, TRUE, NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000636, 'ContentItemTool', 'Content Calendar', 'Content calendar / dedup ledger: check_recent (has this topic run on this platform in the last N days — call before picking a topic), record (log a produced piece with title/preview/status), mark_published. Keeps the daily scheduler from repeating topics and makes publishing auditable.', 'builtin', 'contentItemTool', '🗓️', TRUE, TRUE, NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000637, 'ComplianceScanTool', 'Compliance Scan', 'Server-side compliance scan before publishing: 广告法 极限词, WeChat 诱导 words (集赞/助力/share-to-unlock/follow-to-read), promised returns, and medical-efficacy claims. Returns hits by category; the 公众号 draft path hard-blocks high-risk hits.', 'builtin', 'complianceScanTool', '🛡️', TRUE, TRUE, NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;

View File

@ -143,6 +143,10 @@ INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model,
VALUES ('volcengine-plan', 'Volcano Engine Coding Plan (火山方舟代码计划)', '', 'OpenAIChatModel', '', 'https://ark.cn-beijing.volces.com/api/coding/v3', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW())
ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, chat_model=EXCLUDED.chat_model, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time;
INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time)
VALUES ('volcengine-agent-plan', 'Volcano Engine Agent Plan (火山方舟 Agent Plan)', '', 'OpenAIChatModel', '', 'https://ark.cn-beijing.volces.com/api/plan/v3', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW())
ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, chat_model=EXCLUDED.chat_model, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time;
INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time)
VALUES ('zhipu-cn-codingplan', 'Zhipu Coding Plan (智谱编码套餐)', '', 'OpenAIChatModel', '', 'https://open.bigmodel.cn/api/coding/paas/v4', '{"completionsPath":"/chat/completions"}', FALSE, FALSE, FALSE, FALSE, TRUE, TRUE, NOW(), NOW())
ON CONFLICT (provider_id) DO UPDATE SET name=EXCLUDED.name, chat_model=EXCLUDED.chat_model, base_url=EXCLUDED.base_url, generate_kwargs=EXCLUDED.generate_kwargs, support_model_discovery=EXCLUDED.support_model_discovery, support_connection_check=EXCLUDED.support_connection_check, freeze_url=EXCLUDED.freeze_url, require_api_key=EXCLUDED.require_api_key, update_time=EXCLUDED.update_time;
@ -359,6 +363,18 @@ VALUES
(1000000323, 'GLM 4.7 Coding', 'volcengine-plan', 'glm-4.7', 'GLM 4.7 编码版火山方舟托管200K 上下文', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000324, 'Kimi K2 Thinking', 'volcengine-plan', 'kimi-k2-thinking', 'Kimi K2 推理版火山方舟托管256K 上下文', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000325, 'Kimi K2.5 Coding', 'volcengine-plan', 'kimi-k2.5', 'Kimi K2.5 编码版火山方舟托管256K 上下文', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000350, 'GLM-5.2', 'volcengine-agent-plan', 'glm-5.2', '智谱最新旗舰模型1M 上下文,长程任务效果突出(可用 glm-latest 访问最新版)', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000351, '方舟 Agent Plan自动路由', 'volcengine-agent-plan', 'ark-code-latest', '自动路由入口,按需分发到最合适的套餐模型', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000353, 'Doubao-Seed-2.0-Code', 'volcengine-agent-plan', 'doubao-seed-2.0-code', 'Seed 2.0 代码强化,前端出众、多语言适配;默认 non-thinking支持开启深度思考', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000354, 'Doubao-Seed-2.0-pro', 'volcengine-agent-plan', 'doubao-seed-2.0-pro', '旗舰级全能通用模型,适合复杂推理与长链路任务;默认开启深度思考,可关闭', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000355, 'Doubao-Seed-2.0-lite', 'volcengine-agent-plan', 'doubao-seed-2.0-lite', '兼顾生成质量与响应速度的通用生产级模型', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000356, 'Doubao-Seed-2.0-mini', 'volcengine-agent-plan', 'doubao-seed-2.0-mini', '面向低时延、高并发与成本敏感场景的轻量模型', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000357, 'Kimi-K2.7-Code', 'volcengine-agent-plan', 'kimi-k2.7-code', 'Kimi 最新 Coding 模型,长上下文指令遵循更可靠,支持文本/图片/视频输入', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000358, 'MiniMax-M3', 'volcengine-agent-plan', 'minimax-m3', '新一代 M 系列,编码与智能体评测行业顶尖', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000359, 'DeepSeek-V4-Flash', 'volcengine-agent-plan', 'deepseek-v4-flash', '更快捷经济的 DeepSeek-V4默认开启深度思考可手动关闭', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000360, 'DeepSeek-V4-Pro', 'volcengine-agent-plan', 'deepseek-v4-pro', 'DeepSeek-V4 Agent 能力显著增强,世界知识丰富;默认开启深度思考,可关闭', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000361, 'MiniMax-M2.7', 'volcengine-agent-plan', 'minimax-m2.7', '可自行构建复杂 Agent Harness完成高度复杂的生产力任务', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000362, 'Kimi-K2.6', 'volcengine-agent-plan', 'kimi-k2.6', '月之暗面新一代智能模型;默认开启深度思考,可关闭', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000240, 'Kimi for Coding', 'kimi-code', 'kimi-for-coding', 'Kimi Code 专用编码模型', 0.2, 32768, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000250, 'GPT-5.4', 'openai-chatgpt', 'gpt-5.4', 'ChatGPT Plus/Pro 会员模型OAuth 登录)', NULL, 128000, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000251, 'GPT-5.4 Mini', 'openai-chatgpt', 'gpt-5.4-mini', 'ChatGPT 会员轻量模型', NULL, 128000, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
@ -1810,3 +1826,61 @@ ON CONFLICT (id) DO NOTHING;
-- 安全规则由 ToolGuardRuleSeedService (Java) 统一种子化,不在 SQL 中重复维护
-- 已移除旧的 6 条 SQL 规则,其超集已在 ToolGuardRuleSeedService.buildBuiltinRules() 中以正确的工具名注册。
-- ==================== Content Studio scenario (公众号 / 小红书 图文创作) ====================
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000630, 'WechatArticleExtractTool', '公众号文章抓取', '抓取微信公众号文章:输入文章 URL返回清洗后的标题/作者/时间/正文(Markdown)/图片。用于「参考公众号信息抓取汇总」,比 browser_use 更适合 mp.weixin.qq.com 文章页。', 'builtin', 'wechatArticleExtractTool', '📰', TRUE, TRUE, NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000631, 'GzhPublishTool', '公众号发布', '将生成的图文发布到微信公众号action=draft 上传封面并存入草稿箱推荐action=publish 为认证号群发,需显式确认。需在系统设置配置 weixinoa.app_id / weixinoa.app_secret。', 'builtin', 'gzhPublishTool', '📤', TRUE, TRUE, NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
INSERT INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted)
VALUES (1000000640, '内容工作室', '端到端创作公众号与小红书图文选题搜集、成文、配图、去AI化、排版、入草稿箱发布。', 'react', '你是 MateClaw 的「内容工作室」——专门端到端创作微信公众号(公众号)与小红书图文。
7
1 topic_interests web_search(freshness=week)
2 wechat_article_extract browser_use稿
3 gzh_article xhs_note
4 image_generate / render_html_image HTML
5AI化 deai_humanize AI
6 gzh_package Markdown HTML + 线 + HTML write_file render_html_image(html=...)
7 gzh_package 线 gzh_publish action=draft 稿
recall_structured content_personawriting_style_gzhwriting_style_xhstopic_interestsbanned_wordssignature_blocks remember_structured
gzh_publish confirmPublish=true banned_words 广
', NULL, 100, TRUE, 'pi:pen-nib', 'content,gzh,xhs,writing', NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, agent_type=EXCLUDED.agent_type, system_prompt=EXCLUDED.system_prompt, model_name=EXCLUDED.model_name, max_iterations=EXCLUDED.max_iterations, enabled=EXCLUDED.enabled, icon=EXCLUDED.icon, tags=EXCLUDED.tags, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
-- ---- Content Studio T3/T4: 小红书发布工具 + 场景化 Cron 模板(默认关闭,用户按需启用)----
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000632, 'XhsPublishTool', '小红书发布打包', '把小红书笔记(文案+标签+卡片图)打包成一个可下载 zip并给出创作平台手动上传步骤。小红书无官方发布 API不自动上传、不绕过风控。', 'builtin', 'xhsPublishTool', '📕', TRUE, TRUE, NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
INSERT INTO mate_cron_job (id, name, cron_expression, timezone, agent_id, task_type, trigger_message, request_body, enabled, create_time, update_time, deleted)
VALUES (1000100020, '每日选题雷达', '0 8 * * *', 'Asia/Shanghai', 1000000640, 'agent', NULL, '读取结构化记忆 topic_interests用 web_searchfreshness=week搜集与这些方向相关的今日热点与新鲜角度产出一份「今日选题清单」每条含选题标题、一句话切入角度、目标平台公众号/小红书)、推荐配图方向。只做选题,不成文。', FALSE, NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, cron_expression=EXCLUDED.cron_expression, timezone=EXCLUDED.timezone, agent_id=EXCLUDED.agent_id, task_type=EXCLUDED.task_type, trigger_message=EXCLUDED.trigger_message, request_body=EXCLUDED.request_body, enabled=EXCLUDED.enabled, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
INSERT INTO mate_cron_job (id, name, cron_expression, timezone, agent_id, task_type, trigger_message, request_body, enabled, create_time, update_time, deleted)
VALUES (1000100021, '每周公众号入草稿箱', '0 9 * * 1', 'Asia/Shanghai', 1000000640, 'agent', NULL, '从 topic_interests 里挑一个当周选题,加载 gzh_article 技能完成一篇公众号图文含配图与去AI化排版为内联样式 HTML。若已在系统设置配置公众号凭证weixinoa.app_id/app_secret用 gzh_publish action=draft 存入草稿箱并提醒我去后台核对发表;未配置则直接把排版 HTML 与封面发我。', FALSE, NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, cron_expression=EXCLUDED.cron_expression, timezone=EXCLUDED.timezone, agent_id=EXCLUDED.agent_id, task_type=EXCLUDED.task_type, trigger_message=EXCLUDED.trigger_message, request_body=EXCLUDED.request_body, enabled=EXCLUDED.enabled, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
-- Content Studio: gzh_package (Markdown -> 在线预览 + 素材下载, avoids big-HTML tool-arg truncation)
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000633, 'GzhPackageTool', '公众号打包', '把公众号成稿Markdown打包成在线预览渲染 HTML+ 素材下载 ziparticle.html/article.md/封面)。服务端生成内联样式 HTML避免大段 HTML 作为工具参数被截断而失败。', 'builtin', 'gzhPackageTool', '📦', TRUE, TRUE, NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
-- Content Studio: capture_screenshot (真实后台截图 -> 可嵌入图片 URL, 供产品教程配图)
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000634, 'ScreenshotTool', '后台截图', '截取 MateClaw 后台页面(站内相对路径如 /chat、/channels并返回可嵌入的图片 URL。用于给「如何用 MateClaw 做 XX」这类操作教程配真实产品截图把返回 URL 以 ![](url) 嵌进 gzh_package 的 Markdown。', 'builtin', 'screenshotTool', '📷', TRUE, TRUE, NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000635, 'XhsPackageTool', '小红书打包', '把小红书笔记打包成在线预览(手机版滑动预览,以图为主、文字辅助)+ 素材下载 zip编号卡片图 + 文案.txt。强制至少 3 张竖版图1 封面 + ≥2 内容图),不足则拒绝打包。小红书无发布 API不自动上传。', 'builtin', 'xhsPackageTool', '🖼️', TRUE, TRUE, NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000636, 'ContentItemTool', '内容日历', '内容日历 / 发布去重台账check_recent 查最近 N 天某平台是否做过同题选题前先查避免重复record 记录产出(含标题/预览链接/状态mark_published 标记为已发布。让每日定时不重复选题、发布可追溯。', 'builtin', 'contentItemTool', '🗓️', TRUE, TRUE, NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000637, 'ComplianceScanTool', '合规扫描', '发布前服务端硬扫合规风险:广告法极限词(最/第一/唯一/国家级/100%)、微信诱导词(集赞/助力/分享解锁/关注才能看)、承诺收益、医疗功效。返回命中清单;公众号进草稿箱前对高危词硬拦截。', 'builtin', 'complianceScanTool', '🛡️', TRUE, TRUE, NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;

View File

@ -150,6 +150,10 @@ INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model,
VALUES ('volcengine-plan', 'Volcano Engine Coding Plan', '', 'OpenAIChatModel', '', 'https://ark.cn-beijing.volces.com/api/coding/v3', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW())
ON DUPLICATE KEY UPDATE name=VALUES(name), chat_model=VALUES(chat_model), base_url=VALUES(base_url), generate_kwargs=VALUES(generate_kwargs), support_model_discovery=VALUES(support_model_discovery), support_connection_check=VALUES(support_connection_check), freeze_url=VALUES(freeze_url), require_api_key=VALUES(require_api_key), update_time=VALUES(update_time);
INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time)
VALUES ('volcengine-agent-plan', 'Volcano Engine Agent Plan', '', 'OpenAIChatModel', '', 'https://ark.cn-beijing.volces.com/api/plan/v3', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW())
ON DUPLICATE KEY UPDATE name=VALUES(name), chat_model=VALUES(chat_model), base_url=VALUES(base_url), generate_kwargs=VALUES(generate_kwargs), support_model_discovery=VALUES(support_model_discovery), support_connection_check=VALUES(support_connection_check), freeze_url=VALUES(freeze_url), require_api_key=VALUES(require_api_key), update_time=VALUES(update_time);
INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time)
VALUES ('zhipu-cn-codingplan', 'Zhipu Coding Plan (BigModel)', '', 'OpenAIChatModel', '', 'https://open.bigmodel.cn/api/coding/paas/v4', '{"completionsPath":"/chat/completions"}', FALSE, FALSE, FALSE, FALSE, TRUE, TRUE, NOW(), NOW())
ON DUPLICATE KEY UPDATE name=VALUES(name), chat_model=VALUES(chat_model), base_url=VALUES(base_url), generate_kwargs=VALUES(generate_kwargs), support_model_discovery=VALUES(support_model_discovery), support_connection_check=VALUES(support_connection_check), freeze_url=VALUES(freeze_url), require_api_key=VALUES(require_api_key), update_time=VALUES(update_time);
@ -368,6 +372,18 @@ VALUES
(1000000323, 'GLM 4.7 Coding', 'volcengine-plan', 'glm-4.7', 'GLM 4.7 coding edition (hosted on Volcano Ark), 200K context', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000324, 'Kimi K2 Thinking', 'volcengine-plan', 'kimi-k2-thinking', 'Kimi K2 Thinking (hosted on Volcano Ark), 256K context', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000325, 'Kimi K2.5 Coding', 'volcengine-plan', 'kimi-k2.5', 'Kimi K2.5 coding edition (hosted on Volcano Ark), 256K context', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000350, 'GLM-5.2', 'volcengine-agent-plan', 'glm-5.2', 'Zhipu latest flagship, 1M context, strong on long-horizon tasks (use glm-latest for newest)', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000351, 'Ark Agent Plan (Auto Router)', 'volcengine-agent-plan', 'ark-code-latest', 'Auto-routing entry that dispatches to the best-fit plan model', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000353, 'Doubao-Seed-2.0-Code', 'volcengine-agent-plan', 'doubao-seed-2.0-code', 'Seed 2.0 code-tuned, strong front-end and multi-language; non-thinking by default, deep thinking optional', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000354, 'Doubao-Seed-2.0-pro', 'volcengine-agent-plan', 'doubao-seed-2.0-pro', 'Flagship general model for complex reasoning and long-chain tasks; thinking on by default', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000355, 'Doubao-Seed-2.0-lite', 'volcengine-agent-plan', 'doubao-seed-2.0-lite', 'Balanced quality and speed for general production workloads', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000356, 'Doubao-Seed-2.0-mini', 'volcengine-agent-plan', 'doubao-seed-2.0-mini', 'Low-latency, high-concurrency, cost-sensitive lightweight tasks', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000357, 'Kimi-K2.7-Code', 'volcengine-agent-plan', 'kimi-k2.7-code', 'Latest Kimi coding model; reliable long-context instruction following, text/image/video input', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000358, 'MiniMax-M3', 'volcengine-agent-plan', 'minimax-m3', 'New-gen M-series, top-tier on coding and agent benchmarks', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000359, 'DeepSeek-V4-Flash', 'volcengine-agent-plan', 'deepseek-v4-flash', 'Fast, economical DeepSeek-V4; thinking on by default, can be disabled', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000360, 'DeepSeek-V4-Pro', 'volcengine-agent-plan', 'deepseek-v4-pro', 'DeepSeek-V4 with strengthened agent ability and rich world knowledge', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000361, 'MiniMax-M2.7', 'volcengine-agent-plan', 'minimax-m2.7', 'Builds complex agent harnesses via teams, skills and tools', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000362, 'Kimi-K2.6', 'volcengine-agent-plan', 'kimi-k2.6', 'Moonshot next-gen model; thinking on by default, can be disabled', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000240, 'Kimi for Coding', 'kimi-code', 'kimi-for-coding', 'Kimi Code dedicated coding model', 0.2, 32768, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000250, 'GPT-5.4', 'openai-chatgpt', 'gpt-5.4', 'ChatGPT Plus/Pro member model (OAuth login)', NULL, 128000, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000251, 'GPT-5.4 Mini', 'openai-chatgpt', 'gpt-5.4-mini', 'ChatGPT member lightweight model', NULL, 128000, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
@ -1929,3 +1945,61 @@ VALUES (
-- Security rules are managed by ToolGuardRuleSeedService (Java) as single source of truth.
-- Removed 6 legacy SQL rules. Their superset is registered in ToolGuardRuleSeedService.buildBuiltinRules() with correct tool names.
-- ==================== Content Studio scenario (公众号 / 小红书 图文创作) ====================
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000630, 'WechatArticleExtractTool', 'WeChat Article Extract', 'Fetch a WeChat Official Account (公众号) article by URL and return cleaned title/author/time/body(Markdown)/images. Preferred over browser_use for mp.weixin.qq.com article pages; use it for reference gathering and summarisation.', 'builtin', 'wechatArticleExtractTool', '📰', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000631, 'GzhPublishTool', 'WeChat OA Publish', 'Publish a generated image-text article to a WeChat Official Account: action=draft uploads the cover and creates a 草稿箱 draft (recommended); action=publish free-publishes for verified accounts and requires explicit confirmation. Needs weixinoa.app_id/app_secret in system settings.', 'builtin', 'gzhPublishTool', '📤', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
INSERT INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted)
VALUES (1000000640, 'Content Studio', 'End-to-end 公众号 & 小红书 image-text creation: research, write, illustrate, de-AI, layout, and publish to draft.', 'react', 'You are MateClaw''s Content Studio — a specialist that creates WeChat Official Account (公众号) and Xiaohongshu (小红书) image-text posts end to end.
Workflow (7 stages):
1) Topic use the topic_interests memory + web_search(freshness=week) to find angles.
2) Research for reference links use wechat_article_extract (or browser_use) and summarise, staying original and citing sources; never copy verbatim.
3) Write for load the gzh_article skill, for load the xhs_note skill, honoring the user persona and style.
4) Illustrate image_generate for covers/inline art, and render_html_image to turn card HTML into images.
5) De-AI load the deai_humanize skill and run its detectrewrite loop until the AI-trace score is low.
6) Package & deliver use gzh_package: pass the article body as Markdown and it builds the inline-styled HTML plus an online preview and a downloadable material bundle server-side. Do NOT hand-write a large HTML blob into write_file / render_html_image(html=...) big, escape-heavy tool arguments get truncated and make the call fail.
7) Publish send the gzh_package online preview to the user, and after confirmation default to gzh_publish action=draft (into the 稿).
At the start of a task, recall_structured these keys and honor them: content_persona, writing_style_gzh, writing_style_xhs, topic_interests, banned_words, signature_blocks. If a needed one is missing, ask the user once and remember_structured it.
Publishing is an outward, irreversible action: always show the final content and get explicit user confirmation before calling gzh_publish; never free-publish without confirmPublish=true and the user''s sign-off. Respect banned_words and advertising-law restrictions; keep every piece original.
', NULL, 100, TRUE, 'pi:pen-nib', 'content,gzh,xhs,writing', NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), agent_type=VALUES(agent_type), system_prompt=VALUES(system_prompt), model_name=VALUES(model_name), max_iterations=VALUES(max_iterations), enabled=VALUES(enabled), icon=VALUES(icon), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(deleted);
-- ---- Content Studio T3/T4: 小红书发布工具 + 场景化 Cron 模板(默认关闭,用户按需启用)----
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000632, 'XhsPublishTool', 'Xiaohongshu Publish', 'Package a Xiaohongshu (小红书) note (copy + tags + card images) into one downloadable .zip and give manual-publish steps. No official API — never auto-uploads or bypasses verification.', 'builtin', 'xhsPublishTool', '📕', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
INSERT INTO mate_cron_job (id, name, cron_expression, timezone, agent_id, task_type, trigger_message, request_body, enabled, create_time, update_time, deleted)
VALUES (1000100020, 'Daily Topic Radar', '0 8 * * *', 'Asia/Shanghai', 1000000640, 'agent', NULL, 'Read the topic_interests structured memory, use web_search(freshness=week) to gather today''s fresh angles on those directions, and produce a Today''s Topic List: each item with a working title, a one-line angle, target platform (公众号/小红书), and a suggested illustration direction. Selection only — do not write the full article.', FALSE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), cron_expression=VALUES(cron_expression), timezone=VALUES(timezone), agent_id=VALUES(agent_id), task_type=VALUES(task_type), trigger_message=VALUES(trigger_message), request_body=VALUES(request_body), enabled=VALUES(enabled), update_time=VALUES(update_time), deleted=VALUES(deleted);
INSERT INTO mate_cron_job (id, name, cron_expression, timezone, agent_id, task_type, trigger_message, request_body, enabled, create_time, update_time, deleted)
VALUES (1000100021, 'Weekly 公众号 Draft', '0 9 * * 1', 'Asia/Shanghai', 1000000640, 'agent', NULL, 'Pick one topic from topic_interests for this week, load the gzh_article skill to produce a full 公众号 image-text article (with illustrations and de-AI pass), laid out as inline-styled HTML. If 公众号 credentials (weixinoa.app_id/app_secret) are configured in system settings, use gzh_publish action=draft to save it to the draft box and remind me to review and publish in the backend; otherwise just send me the HTML and cover.', FALSE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), cron_expression=VALUES(cron_expression), timezone=VALUES(timezone), agent_id=VALUES(agent_id), task_type=VALUES(task_type), trigger_message=VALUES(trigger_message), request_body=VALUES(request_body), enabled=VALUES(enabled), update_time=VALUES(update_time), deleted=VALUES(deleted);
-- Content Studio: gzh_package (Markdown -> 在线预览 + 素材下载, avoids big-HTML tool-arg truncation)
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000633, 'GzhPackageTool', 'WeChat Article Package', 'Package a finished 公众号 article from Markdown into an online preview (rendered HTML) plus a downloadable material bundle (article.html + article.md + cover). Builds the inline-styled HTML server-side so a large HTML string never rides on the tool-argument stream (which truncates and fails).', 'builtin', 'gzhPackageTool', '📦', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
-- Content Studio: capture_screenshot (真实后台截图 -> 可嵌入图片 URL, 供产品教程配图)
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000634, 'ScreenshotTool', 'Console Screenshot', 'Capture a screenshot of a MateClaw console page (relative path like /chat, /channels) and return an embeddable image URL. Use it to put REAL product screenshots into how-to/tutorial articles; embed the returned URL as ![](url) in a gzh_package Markdown body.', 'builtin', 'screenshotTool', '📷', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000635, 'XhsPackageTool', 'Xiaohongshu Package', 'Package a Xiaohongshu (小红书) note into an image-first online preview (phone-style swipe: images up top, copy below) plus a material zip (numbered card images + copy.txt). Requires at least 3 vertical images (1 cover + >=2 content); refuses fewer. 小红书 has no publish API; never auto-uploads.', 'builtin', 'xhsPackageTool', '🖼️', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000636, 'ContentItemTool', 'Content Calendar', 'Content calendar / dedup ledger: check_recent (has this topic run on this platform in the last N days — call before picking a topic), record (log a produced piece with title/preview/status), mark_published. Keeps the daily scheduler from repeating topics and makes publishing auditable.', 'builtin', 'contentItemTool', '🗓️', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000637, 'ComplianceScanTool', 'Compliance Scan', 'Server-side compliance scan before publishing: 广告法 极限词, WeChat 诱导 words (集赞/助力/share-to-unlock/follow-to-read), promised returns, and medical-efficacy claims. Returns hits by category; the 公众号 draft path hard-blocks high-risk hits.', 'builtin', 'complianceScanTool', '🛡️', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);

View File

@ -149,6 +149,10 @@ INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model,
VALUES ('volcengine-plan', 'Volcano Engine Coding Plan (火山方舟代码计划)', '', 'OpenAIChatModel', '', 'https://ark.cn-beijing.volces.com/api/coding/v3', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW())
ON DUPLICATE KEY UPDATE name=VALUES(name), chat_model=VALUES(chat_model), base_url=VALUES(base_url), generate_kwargs=VALUES(generate_kwargs), support_model_discovery=VALUES(support_model_discovery), support_connection_check=VALUES(support_connection_check), freeze_url=VALUES(freeze_url), require_api_key=VALUES(require_api_key), update_time=VALUES(update_time);
INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time)
VALUES ('volcengine-agent-plan', 'Volcano Engine Agent Plan (火山方舟 Agent Plan)', '', 'OpenAIChatModel', '', 'https://ark.cn-beijing.volces.com/api/plan/v3', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW())
ON DUPLICATE KEY UPDATE name=VALUES(name), chat_model=VALUES(chat_model), base_url=VALUES(base_url), generate_kwargs=VALUES(generate_kwargs), support_model_discovery=VALUES(support_model_discovery), support_connection_check=VALUES(support_connection_check), freeze_url=VALUES(freeze_url), require_api_key=VALUES(require_api_key), update_time=VALUES(update_time);
INSERT INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time)
VALUES ('zhipu-cn-codingplan', 'Zhipu Coding Plan (智谱编码套餐)', '', 'OpenAIChatModel', '', 'https://open.bigmodel.cn/api/coding/paas/v4', '{"completionsPath":"/chat/completions"}', FALSE, FALSE, FALSE, FALSE, TRUE, TRUE, NOW(), NOW())
ON DUPLICATE KEY UPDATE name=VALUES(name), chat_model=VALUES(chat_model), base_url=VALUES(base_url), generate_kwargs=VALUES(generate_kwargs), support_model_discovery=VALUES(support_model_discovery), support_connection_check=VALUES(support_connection_check), freeze_url=VALUES(freeze_url), require_api_key=VALUES(require_api_key), update_time=VALUES(update_time);
@ -365,6 +369,18 @@ VALUES
(1000000323, 'GLM 4.7 Coding', 'volcengine-plan', 'glm-4.7', 'GLM 4.7 编码版火山方舟托管200K 上下文', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000324, 'Kimi K2 Thinking', 'volcengine-plan', 'kimi-k2-thinking', 'Kimi K2 推理版火山方舟托管256K 上下文', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000325, 'Kimi K2.5 Coding', 'volcengine-plan', 'kimi-k2.5', 'Kimi K2.5 编码版火山方舟托管256K 上下文', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000350, 'GLM-5.2', 'volcengine-agent-plan', 'glm-5.2', '智谱最新旗舰模型1M 上下文,长程任务效果突出(可用 glm-latest 访问最新版)', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000351, '方舟 Agent Plan自动路由', 'volcengine-agent-plan', 'ark-code-latest', '自动路由入口,按需分发到最合适的套餐模型', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000353, 'Doubao-Seed-2.0-Code', 'volcengine-agent-plan', 'doubao-seed-2.0-code', 'Seed 2.0 代码强化,前端出众、多语言适配;默认 non-thinking支持开启深度思考', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000354, 'Doubao-Seed-2.0-pro', 'volcengine-agent-plan', 'doubao-seed-2.0-pro', '旗舰级全能通用模型,适合复杂推理与长链路任务;默认开启深度思考,可关闭', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000355, 'Doubao-Seed-2.0-lite', 'volcengine-agent-plan', 'doubao-seed-2.0-lite', '兼顾生成质量与响应速度的通用生产级模型', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000356, 'Doubao-Seed-2.0-mini', 'volcengine-agent-plan', 'doubao-seed-2.0-mini', '面向低时延、高并发与成本敏感场景的轻量模型', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000357, 'Kimi-K2.7-Code', 'volcengine-agent-plan', 'kimi-k2.7-code', 'Kimi 最新 Coding 模型,长上下文指令遵循更可靠,支持文本/图片/视频输入', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000358, 'MiniMax-M3', 'volcengine-agent-plan', 'minimax-m3', '新一代 M 系列,编码与智能体评测行业顶尖', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000359, 'DeepSeek-V4-Flash', 'volcengine-agent-plan', 'deepseek-v4-flash', '更快捷经济的 DeepSeek-V4默认开启深度思考可手动关闭', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000360, 'DeepSeek-V4-Pro', 'volcengine-agent-plan', 'deepseek-v4-pro', 'DeepSeek-V4 Agent 能力显著增强,世界知识丰富;默认开启深度思考,可关闭', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000361, 'MiniMax-M2.7', 'volcengine-agent-plan', 'minimax-m2.7', '可自行构建复杂 Agent Harness完成高度复杂的生产力任务', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000362, 'Kimi-K2.6', 'volcengine-agent-plan', 'kimi-k2.6', '月之暗面新一代智能模型;默认开启深度思考,可关闭', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000240, 'Kimi for Coding', 'kimi-code', 'kimi-for-coding', 'Kimi Code 专用编码模型', 0.2, 32768, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000250, 'GPT-5.4', 'openai-chatgpt', 'gpt-5.4', 'ChatGPT Plus/Pro 会员模型OAuth 登录)', NULL, 128000, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000251, 'GPT-5.4 Mini', 'openai-chatgpt', 'gpt-5.4-mini', 'ChatGPT 会员轻量模型', NULL, 128000, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
@ -1926,3 +1942,61 @@ VALUES (
-- 安全规则由 ToolGuardRuleSeedService (Java) 统一种子化,不在 SQL 中重复维护
-- 已移除旧的 6 条 SQL 规则,其超集已在 ToolGuardRuleSeedService.buildBuiltinRules() 中以正确的工具名注册。
-- ==================== Content Studio scenario (公众号 / 小红书 图文创作) ====================
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000630, 'WechatArticleExtractTool', '公众号文章抓取', '抓取微信公众号文章:输入文章 URL返回清洗后的标题/作者/时间/正文(Markdown)/图片。用于「参考公众号信息抓取汇总」,比 browser_use 更适合 mp.weixin.qq.com 文章页。', 'builtin', 'wechatArticleExtractTool', '📰', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000631, 'GzhPublishTool', '公众号发布', '将生成的图文发布到微信公众号action=draft 上传封面并存入草稿箱推荐action=publish 为认证号群发,需显式确认。需在系统设置配置 weixinoa.app_id / weixinoa.app_secret。', 'builtin', 'gzhPublishTool', '📤', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
INSERT INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted)
VALUES (1000000640, '内容工作室', '端到端创作公众号与小红书图文选题搜集、成文、配图、去AI化、排版、入草稿箱发布。', 'react', '你是 MateClaw 的「内容工作室」——专门端到端创作微信公众号(公众号)与小红书图文。
7
1 topic_interests web_search(freshness=week)
2 wechat_article_extract browser_use稿
3 gzh_article xhs_note
4 image_generate / render_html_image HTML
5AI化 deai_humanize AI
6 gzh_package Markdown HTML + 线 + HTML write_file render_html_image(html=...)
7 gzh_package 线 gzh_publish action=draft 稿
recall_structured content_personawriting_style_gzhwriting_style_xhstopic_interestsbanned_wordssignature_blocks remember_structured
gzh_publish confirmPublish=true banned_words 广
', NULL, 100, TRUE, 'pi:pen-nib', 'content,gzh,xhs,writing', NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), agent_type=VALUES(agent_type), system_prompt=VALUES(system_prompt), model_name=VALUES(model_name), max_iterations=VALUES(max_iterations), enabled=VALUES(enabled), icon=VALUES(icon), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(deleted);
-- ---- Content Studio T3/T4: 小红书发布工具 + 场景化 Cron 模板(默认关闭,用户按需启用)----
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000632, 'XhsPublishTool', '小红书发布打包', '把小红书笔记(文案+标签+卡片图)打包成一个可下载 zip并给出创作平台手动上传步骤。小红书无官方发布 API不自动上传、不绕过风控。', 'builtin', 'xhsPublishTool', '📕', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
INSERT INTO mate_cron_job (id, name, cron_expression, timezone, agent_id, task_type, trigger_message, request_body, enabled, create_time, update_time, deleted)
VALUES (1000100020, '每日选题雷达', '0 8 * * *', 'Asia/Shanghai', 1000000640, 'agent', NULL, '读取结构化记忆 topic_interests用 web_searchfreshness=week搜集与这些方向相关的今日热点与新鲜角度产出一份「今日选题清单」每条含选题标题、一句话切入角度、目标平台公众号/小红书)、推荐配图方向。只做选题,不成文。', FALSE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), cron_expression=VALUES(cron_expression), timezone=VALUES(timezone), agent_id=VALUES(agent_id), task_type=VALUES(task_type), trigger_message=VALUES(trigger_message), request_body=VALUES(request_body), enabled=VALUES(enabled), update_time=VALUES(update_time), deleted=VALUES(deleted);
INSERT INTO mate_cron_job (id, name, cron_expression, timezone, agent_id, task_type, trigger_message, request_body, enabled, create_time, update_time, deleted)
VALUES (1000100021, '每周公众号入草稿箱', '0 9 * * 1', 'Asia/Shanghai', 1000000640, 'agent', NULL, '从 topic_interests 里挑一个当周选题,加载 gzh_article 技能完成一篇公众号图文含配图与去AI化排版为内联样式 HTML。若已在系统设置配置公众号凭证weixinoa.app_id/app_secret用 gzh_publish action=draft 存入草稿箱并提醒我去后台核对发表;未配置则直接把排版 HTML 与封面发我。', FALSE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), cron_expression=VALUES(cron_expression), timezone=VALUES(timezone), agent_id=VALUES(agent_id), task_type=VALUES(task_type), trigger_message=VALUES(trigger_message), request_body=VALUES(request_body), enabled=VALUES(enabled), update_time=VALUES(update_time), deleted=VALUES(deleted);
-- Content Studio: gzh_package (Markdown -> 在线预览 + 素材下载, avoids big-HTML tool-arg truncation)
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000633, 'GzhPackageTool', '公众号打包', '把公众号成稿Markdown打包成在线预览渲染 HTML+ 素材下载 ziparticle.html/article.md/封面)。服务端生成内联样式 HTML避免大段 HTML 作为工具参数被截断而失败。', 'builtin', 'gzhPackageTool', '📦', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
-- Content Studio: capture_screenshot (真实后台截图 -> 可嵌入图片 URL, 供产品教程配图)
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000634, 'ScreenshotTool', '后台截图', '截取 MateClaw 后台页面(站内相对路径如 /chat、/channels并返回可嵌入的图片 URL。用于给「如何用 MateClaw 做 XX」这类操作教程配真实产品截图把返回 URL 以 ![](url) 嵌进 gzh_package 的 Markdown。', 'builtin', 'screenshotTool', '📷', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000635, 'XhsPackageTool', '小红书打包', '把小红书笔记打包成在线预览(手机版滑动预览,以图为主、文字辅助)+ 素材下载 zip编号卡片图 + 文案.txt。强制至少 3 张竖版图1 封面 + ≥2 内容图),不足则拒绝打包。小红书无发布 API不自动上传。', 'builtin', 'xhsPackageTool', '🖼️', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000636, 'ContentItemTool', '内容日历', '内容日历 / 发布去重台账check_recent 查最近 N 天某平台是否做过同题选题前先查避免重复record 记录产出(含标题/预览链接/状态mark_published 标记为已发布。让每日定时不重复选题、发布可追溯。', 'builtin', 'contentItemTool', '🗓️', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000637, 'ComplianceScanTool', '合规扫描', '发布前服务端硬扫合规风险:广告法极限词(最/第一/唯一/国家级/100%)、微信诱导词(集赞/助力/分享解锁/关注才能看)、承诺收益、医疗功效。返回命中清单;公众号进草稿箱前对高危词硬拦截。', 'builtin', 'complianceScanTool', '🛡️', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);

View File

@ -133,6 +133,10 @@ MERGE INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, a
KEY (provider_id)
VALUES ('volcengine-plan', 'Volcano Engine Coding Plan (火山方舟代码计划)', '', 'OpenAIChatModel', '', 'https://ark.cn-beijing.volces.com/api/coding/v3', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW());
MERGE INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time)
KEY (provider_id)
VALUES ('volcengine-agent-plan', 'Volcano Engine Agent Plan (火山方舟 Agent Plan)', '', 'OpenAIChatModel', '', 'https://ark.cn-beijing.volces.com/api/plan/v3', '{}', FALSE, FALSE, TRUE, TRUE, TRUE, TRUE, NOW(), NOW());
MERGE INTO mate_model_provider (provider_id, name, api_key_prefix, chat_model, api_key, base_url, generate_kwargs, is_custom, is_local, support_model_discovery, support_connection_check, freeze_url, require_api_key, create_time, update_time)
KEY (provider_id)
VALUES ('zhipu-cn-codingplan', 'Zhipu Coding Plan (智谱编码套餐)', '', 'OpenAIChatModel', '', 'https://open.bigmodel.cn/api/coding/paas/v4', '{"completionsPath":"/chat/completions"}', FALSE, FALSE, FALSE, FALSE, TRUE, TRUE, NOW(), NOW());
@ -321,6 +325,18 @@ MERGE INTO mate_model_config (id, name, provider, model_name, description, tempe
(1000000323, 'GLM 4.7 Coding', 'volcengine-plan', 'glm-4.7', 'GLM 4.7 编码版火山方舟托管200K 上下文', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000324, 'Kimi K2 Thinking', 'volcengine-plan', 'kimi-k2-thinking', 'Kimi K2 推理版火山方舟托管256K 上下文', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000325, 'Kimi K2.5 Coding', 'volcengine-plan', 'kimi-k2.5', 'Kimi K2.5 编码版火山方舟托管256K 上下文', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000350, 'GLM-5.2', 'volcengine-agent-plan', 'glm-5.2', '智谱最新旗舰模型1M 上下文,长程任务效果突出(可用 glm-latest 访问最新版)', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000351, '方舟 Agent Plan自动路由', 'volcengine-agent-plan', 'ark-code-latest', '自动路由入口,按需分发到最合适的套餐模型', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000353, 'Doubao-Seed-2.0-Code', 'volcengine-agent-plan', 'doubao-seed-2.0-code', 'Seed 2.0 代码强化,前端出众、多语言适配;默认 non-thinking支持开启深度思考', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000354, 'Doubao-Seed-2.0-pro', 'volcengine-agent-plan', 'doubao-seed-2.0-pro', '旗舰级全能通用模型,适合复杂推理与长链路任务;默认开启深度思考,可关闭', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000355, 'Doubao-Seed-2.0-lite', 'volcengine-agent-plan', 'doubao-seed-2.0-lite', '兼顾生成质量与响应速度的通用生产级模型', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000356, 'Doubao-Seed-2.0-mini', 'volcengine-agent-plan', 'doubao-seed-2.0-mini', '面向低时延、高并发与成本敏感场景的轻量模型', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000357, 'Kimi-K2.7-Code', 'volcengine-agent-plan', 'kimi-k2.7-code', 'Kimi 最新 Coding 模型,长上下文指令遵循更可靠,支持文本/图片/视频输入', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000358, 'MiniMax-M3', 'volcengine-agent-plan', 'minimax-m3', '新一代 M 系列,编码与智能体评测行业顶尖', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000359, 'DeepSeek-V4-Flash', 'volcengine-agent-plan', 'deepseek-v4-flash', '更快捷经济的 DeepSeek-V4默认开启深度思考可手动关闭', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000360, 'DeepSeek-V4-Pro', 'volcengine-agent-plan', 'deepseek-v4-pro', 'DeepSeek-V4 Agent 能力显著增强,世界知识丰富;默认开启深度思考,可关闭', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000361, 'MiniMax-M2.7', 'volcengine-agent-plan', 'minimax-m2.7', '可自行构建复杂 Agent Harness完成高度复杂的生产力任务', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000362, 'Kimi-K2.6', 'volcengine-agent-plan', 'kimi-k2.6', '月之暗面新一代智能模型;默认开启深度思考,可关闭', 0.2, 4096, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000240, 'Kimi for Coding', 'kimi-code', 'kimi-for-coding', 'Kimi Code 专用编码模型', 0.2, 32768, 0.8, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000250, 'GPT-5.4', 'openai-chatgpt', 'gpt-5.4', 'ChatGPT Plus/Pro 会员模型OAuth 登录)', NULL, 128000, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
(1000000251, 'GPT-5.4 Mini', 'openai-chatgpt', 'gpt-5.4-mini', 'ChatGPT 会员轻量模型', NULL, 128000, NULL, TRUE, TRUE, FALSE, NOW(), NOW(), 0),
@ -1889,3 +1905,61 @@ WHERE NOT EXISTS (SELECT 1 FROM mate_tool_guard_config WHERE id = 1000000001);
-- 已移除旧的 6 条 SQL 规则rule_id: write_file_any, edit_file_any, shell_rm_approval,
-- shell_rm_rf_block, shell_write_system_file, shell_chmod_777
-- 它们的超集已在 ToolGuardRuleSeedService.buildBuiltinRules() 中以正确的工具名注册。
-- ==================== Content Studio scenario (公众号 / 小红书 图文创作) ====================
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000630, 'WechatArticleExtractTool', '公众号文章抓取', '抓取微信公众号文章:输入文章 URL返回清洗后的标题/作者/时间/正文(Markdown)/图片。用于「参考公众号信息抓取汇总」,比 browser_use 更适合 mp.weixin.qq.com 文章页。', 'builtin', 'wechatArticleExtractTool', '📰', TRUE, TRUE, NOW(), NOW(), 0);
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000631, 'GzhPublishTool', '公众号发布', '将生成的图文发布到微信公众号action=draft 上传封面并存入草稿箱推荐action=publish 为认证号群发,需显式确认。需在系统设置配置 weixinoa.app_id / weixinoa.app_secret。', 'builtin', 'gzhPublishTool', '📤', TRUE, TRUE, NOW(), NOW(), 0);
MERGE INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted)
KEY (id)
VALUES (1000000640, '内容工作室', '端到端创作公众号与小红书图文选题搜集、成文、配图、去AI化、排版、入草稿箱发布。', 'react', '你是 MateClaw 的「内容工作室」——专门端到端创作微信公众号(公众号)与小红书图文。
7
1 topic_interests web_search(freshness=week)
2 wechat_article_extract browser_use稿
3 gzh_article xhs_note
4 image_generate / render_html_image HTML
5AI化 deai_humanize AI
6 gzh_package Markdown HTML + 线 + HTML write_file render_html_image(html=...)
7 gzh_package 线 gzh_publish action=draft 稿
recall_structured content_personawriting_style_gzhwriting_style_xhstopic_interestsbanned_wordssignature_blocks remember_structured
gzh_publish confirmPublish=true banned_words 广
', NULL, 100, TRUE, 'pi:pen-nib', 'content,gzh,xhs,writing', NOW(), NOW(), 0);
-- ---- Content Studio T3/T4: 小红书发布工具 + 场景化 Cron 模板(默认关闭,用户按需启用)----
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000632, 'XhsPublishTool', '小红书发布打包', '把小红书笔记(文案+标签+卡片图)打包成一个可下载 zip并给出创作平台手动上传步骤。小红书无官方发布 API不自动上传、不绕过风控。', 'builtin', 'xhsPublishTool', '📕', TRUE, TRUE, NOW(), NOW(), 0);
MERGE INTO mate_cron_job (id, name, cron_expression, timezone, agent_id, task_type, trigger_message, request_body, enabled, create_time, update_time, deleted)
KEY (id)
VALUES (1000100020, '每日选题雷达', '0 8 * * *', 'Asia/Shanghai', 1000000640, 'agent', NULL, '读取结构化记忆 topic_interests用 web_searchfreshness=week搜集与这些方向相关的今日热点与新鲜角度产出一份「今日选题清单」每条含选题标题、一句话切入角度、目标平台公众号/小红书)、推荐配图方向。只做选题,不成文。', FALSE, NOW(), NOW(), 0);
MERGE INTO mate_cron_job (id, name, cron_expression, timezone, agent_id, task_type, trigger_message, request_body, enabled, create_time, update_time, deleted)
KEY (id)
VALUES (1000100021, '每周公众号入草稿箱', '0 9 * * 1', 'Asia/Shanghai', 1000000640, 'agent', NULL, '从 topic_interests 里挑一个当周选题,加载 gzh_article 技能完成一篇公众号图文含配图与去AI化排版为内联样式 HTML。若已在系统设置配置公众号凭证weixinoa.app_id/app_secret用 gzh_publish action=draft 存入草稿箱并提醒我去后台核对发表;未配置则直接把排版 HTML 与封面发我。', FALSE, NOW(), NOW(), 0);
-- Content Studio: gzh_package (Markdown -> 在线预览 + 素材下载, avoids big-HTML tool-arg truncation)
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000633, 'GzhPackageTool', '公众号打包', '把公众号成稿Markdown打包成在线预览渲染 HTML+ 素材下载 ziparticle.html/article.md/封面)。服务端生成内联样式 HTML避免大段 HTML 作为工具参数被截断而失败。', 'builtin', 'gzhPackageTool', '📦', TRUE, TRUE, NOW(), NOW(), 0);
-- Content Studio: capture_screenshot (真实后台截图 -> 可嵌入图片 URL, 供产品教程配图)
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000634, 'ScreenshotTool', '后台截图', '截取 MateClaw 后台页面(站内相对路径如 /chat、/channels并返回可嵌入的图片 URL。用于给「如何用 MateClaw 做 XX」这类操作教程配真实产品截图把返回 URL 以 ![](url) 嵌进 gzh_package 的 Markdown。', 'builtin', 'screenshotTool', '📷', TRUE, TRUE, NOW(), NOW(), 0);
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000635, 'XhsPackageTool', '小红书打包', '把小红书笔记打包成在线预览(手机版滑动预览,以图为主、文字辅助)+ 素材下载 zip编号卡片图 + 文案.txt。强制至少 3 张竖版图1 封面 + ≥2 内容图),不足则拒绝打包。小红书无发布 API不自动上传。', 'builtin', 'xhsPackageTool', '🖼️', TRUE, TRUE, NOW(), NOW(), 0);
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000636, 'ContentItemTool', '内容日历', '内容日历 / 发布去重台账check_recent 查最近 N 天某平台是否做过同题选题前先查避免重复record 记录产出(含标题/预览链接/状态mark_published 标记为已发布。让每日定时不重复选题、发布可追溯。', 'builtin', 'contentItemTool', '🗓️', TRUE, TRUE, NOW(), NOW(), 0);
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000637, 'ComplianceScanTool', '合规扫描', '发布前服务端硬扫合规风险:广告法极限词(最/第一/唯一/国家级/100%)、微信诱导词(集赞/助力/分享解锁/关注才能看)、承诺收益、医疗功效。返回命中清单;公众号进草稿箱前对高危词硬拦截。', 'builtin', 'complianceScanTool', '🛡️', TRUE, TRUE, NOW(), NOW(), 0);

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