mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
release: v1.8.0
This commit is contained in:
parent
c8bf4e0f89
commit
c6f6f10fd0
42
.env.example
42
.env.example
@ -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/simple(trusted-host 自动推导)
|
||||
#PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
#PIP_TRUSTED_HOST=
|
||||
# ── 桌面版补充(非 Docker,宿主机直接跑 Java)─────────────────────
|
||||
# 桌面版不继承上面的 Docker 变量。用 Spring 配置注入 Python 子进程;
|
||||
# 也可直接设系统环境变量 PIP_INDEX_URL / PIP_TRUSTED_HOST(覆盖更全)。
|
||||
#MATECLAW_PIP_INDEX_URL=
|
||||
#MATECLAW_PIP_TRUSTED_HOST=
|
||||
|
||||
# ── Maven 镜像(国内加速)─────────────────────────────────────────
|
||||
# 在中国大陆构建时取消注释,将 Aliyun 仓库优先级提前,大幅提速 mvn 拉包。
|
||||
# 空值(默认)使用 US Maven Central → Google CDN → Aliyun 的顺序。
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@ -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
|
||||
|
||||
16
README.md
16
README.md
@ -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
|
||||
|
||||
16
README_zh.md
16
README_zh.md
@ -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.0(2026-07-12 发布)** — 员工*转向对外、干完一整件活*:**内容工作室**——第一个完全用 MateClaw 自身原子能力端到端搭起来的招牌场景:
|
||||
|
||||
- **内容工作室——一句话到可发布成品** — 预置「内容工作室」员工跑通 选题 → 搜集 → 成文 → 配图 → 去 AI 化 → 排版 → 交付。**微信公众号(公众号)** 图文文章(内联样式 HTML → 草稿箱)与 **小红书** 以图为主图文笔记(≥3 张竖版 3:4 卡片 + 在线预览)首批一等公民
|
||||
- **可度量的去 AI 化** — 启发式 AI 痕迹评分(无 LLM、确定性)驱动 检测 → 改写 → 复检 闭环,硬上限 3 轮
|
||||
- **为长期投产而加固的发布链** — 正文图上传进微信(不再外链发布即裂)、AES-GCM 加密密钥、服务复用 + token 持久化、重试 + 中文错误提示、兜底封面;草稿箱优先,发表走审批
|
||||
- **会去重、会记账的内容日历** — 每次交付都合规扫描 + 自动落台账、选题指纹防重复选题、只读内容日历页展示草稿/已打包/已发布/失败
|
||||
- **浏览器 Agent 按引用去看** — 无障碍树 ref 快照 + 按 ref 交互(点元素而非像素)、真实浏览器隐私护栏、受控 CDP 逃生舱
|
||||
- **注意力更聚焦、循环更收得住** — 注意力锚定与环境感知(MCP 工具溯源 + skill 约束固定 + 事件通知)、工具调用循环护栏、改动后校验提醒
|
||||
|
||||
外加:一次快加载优化(初始加载 ↓约 78%)、聊天上下文占用面板、跨知识库 wikilink、MCP 进度通知、火山方舟供应商,以及公开 Docker 栈切到 PostgreSQL 16。
|
||||
|
||||
完整故事见 [v1.8.0 release notes](https://claw.mate.vip/docs/zh/releases/1.8.0)。
|
||||
|
||||
**v1.7.0(2026-07-04 发布)** — 一次*生产化加固*:把它放进真正的协作里之后,那些看不见、收不拢、够不着、装不下、连不通的地方全补上:
|
||||
|
||||
- **审批三条链路彻底闭环** — 工作流 `await_approval` 真的推到渠道并 resolve→恢复执行、WebChat(API-Key)渠道能批准/拒绝并重放、飞书/企微点卡片直接 resolve 工作流审批
|
||||
|
||||
@ -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/simple(trusted-host 自动推导)
|
||||
PIP_INDEX_URL: ${PIP_INDEX_URL:-}
|
||||
PIP_TRUSTED_HOST: ${PIP_TRUSTED_HOST:-}
|
||||
# Chromium needs a real /dev/shm. Docker defaults to 64MB which causes
|
||||
# SIGBUS / "Target page closed" errors under load. 2GB is the usual
|
||||
# recommendation for Playwright / headless chrome.
|
||||
@ -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
|
||||
|
||||
43
docker/postgres/init/10-app-role.sh
Executable file
43
docker/postgres/init/10-app-role.sh
Executable 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"
|
||||
@ -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",
|
||||
|
||||
170
mateclaw-desktop/scripts/README.md
Normal file
170
mateclaw-desktop/scripts/README.md
Normal 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
|
||||
```
|
||||
@ -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),
|
||||
])
|
||||
}
|
||||
153
mateclaw-desktop/scripts/branding.cjs
Normal file
153
mateclaw-desktop/scripts/branding.cjs
Normal 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 }
|
||||
35
mateclaw-desktop/scripts/build-all-platforms.sh
Executable file
35
mateclaw-desktop/scripts/build-all-platforms.sh
Executable 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."
|
||||
30
mateclaw-desktop/scripts/build.sh
Executable file
30
mateclaw-desktop/scripts/build.sh
Executable 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)"
|
||||
86
mateclaw-desktop/scripts/download-jre.sh
Executable file
86
mateclaw-desktop/scripts/download-jre.sh
Executable 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."
|
||||
324
mateclaw-desktop/scripts/publish-github.sh
Executable file
324
mateclaw-desktop/scripts/publish-github.sh
Executable 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'
|
||||
360
mateclaw-desktop/scripts/trim-playwright-driver.cjs
Normal file
360
mateclaw-desktop/scripts/trim-playwright-driver.cjs
Normal 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
|
||||
@ -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"]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 15–75s). A pooled
|
||||
* HTTP/1.1 connection therefore outlives the peer's socket, and the next
|
||||
* request onto that now-closed socket is reset by the peer before any
|
||||
* response byte arrives, surfacing as
|
||||
* {@code "HTTP/1.1 header parser received no bytes"} / {@code Connection reset}.
|
||||
* Capping it to 15s makes the client evict idle connections before most
|
||||
* gateways do, eliminating stale reuse. (curl never hits this because it
|
||||
* opens a fresh connection per invocation.)</li>
|
||||
* <li><b>allow the {@code Connection} request header</b> — {@code Connection}
|
||||
* is a restricted header the JDK client strips by default; allowing it lets
|
||||
* the OpenAI-compatible path send {@code Connection: close} to force a fresh
|
||||
* connection per request against flaky self-hosted gateways.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Both are only set when the operator has not already provided an explicit
|
||||
* {@code -D} override, so deliberate tuning is respected.
|
||||
*/
|
||||
private static void configureHttpClientDefaults() {
|
||||
if (System.getProperty("jdk.httpclient.keepalive.timeout") == null) {
|
||||
System.setProperty("jdk.httpclient.keepalive.timeout", "15");
|
||||
}
|
||||
String allowRestricted = System.getProperty("jdk.httpclient.allowRestrictedHeaders");
|
||||
if (allowRestricted == null) {
|
||||
System.setProperty("jdk.httpclient.allowRestrictedHeaders", "connection");
|
||||
} else if (!allowRestricted.toLowerCase().contains("connection")) {
|
||||
System.setProperty("jdk.httpclient.allowRestrictedHeaders", allowRestricted + ",connection");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the actual database type from the live DataSource so the
|
||||
* {@link PaginationInnerInterceptor} always uses the correct dialect,
|
||||
|
||||
@ -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,详见 🔒 固定约束 段落,全程不可忽略)";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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()));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 已有值上 */
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
@ -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 > 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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('\\', '/');
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
() -> {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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> {
|
||||
}
|
||||
@ -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 record→check 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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) -> {
|
||||
|
||||
@ -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) {
|
||||
}
|
||||
@ -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 ====================
|
||||
|
||||
/**
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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 "";
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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"));
|
||||
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<>();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 + "']";
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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 <style>/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("&", "&").replace("<", "<").replace(">", ">");
|
||||
}
|
||||
|
||||
private static String escapeAttr(String s) {
|
||||
return escapeText(s).replace("\"", """);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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.",
|
||||
|
||||
@ -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 } 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 `` 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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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. 按 01、02… 顺序上传卡片图(首图即封面)。
|
||||
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("&", "&").replace("<", "<").replace(">", ">");
|
||||
}
|
||||
|
||||
private static String escapeAttr(String s) {
|
||||
return escapeText(s).replace("\"", """);
|
||||
}
|
||||
|
||||
private record ResolvedImg(byte[] bytes, String ext, String url) {}
|
||||
}
|
||||
@ -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. 按 01、02… 顺序上传卡片图(首图即封面)。
|
||||
4. 从「文案.txt」复制标题、正文,粘贴到对应输入框。
|
||||
5. 添加话题标签,核对无违禁词后自行发布。""".formatted(CREATOR_URL);
|
||||
}
|
||||
|
||||
private record ResolvedImage(byte[] bytes, String ext) {}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>()
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 detect→rewrite 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  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);
|
||||
|
||||
@ -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 detect→rewrite 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  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;
|
||||
|
||||
@ -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 渲染成图。
|
||||
5)去AI化——加载 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_persona、writing_style_gzh、writing_style_xhs、topic_interests、banned_words、signature_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_search(freshness=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)+ 素材下载 zip(article.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 以  嵌进 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;
|
||||
|
||||
@ -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 detect→rewrite 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  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);
|
||||
|
||||
@ -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 渲染成图。
|
||||
5)去AI化——加载 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_persona、writing_style_gzh、writing_style_xhs、topic_interests、banned_words、signature_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_search(freshness=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)+ 素材下载 zip(article.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 以  嵌进 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);
|
||||
|
||||
@ -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 渲染成图。
|
||||
5)去AI化——加载 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_persona、writing_style_gzh、writing_style_xhs、topic_interests、banned_words、signature_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_search(freshness=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)+ 素材下载 zip(article.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 以  嵌进 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
Loading…
Reference in New Issue
Block a user