Initial commit: MateClaw — Java + Vue 3 AI Assistant System

Full-stack AI assistant built on Spring AI Alibaba.
Features: ReAct Agent, Plan-and-Execute, MCP Protocol, Multi-Model, Multi-Channel.

Apache-2.0 License
This commit is contained in:
matevip 2026-04-04 18:56:13 +08:00
commit 579d60125b
391 changed files with 79979 additions and 0 deletions

17
.env.example Normal file
View File

@ -0,0 +1,17 @@
# MateClaw 环境变量配置
# 复制此文件为 .env 并填写实际值
# 阿里云 DashScope API Key必填
# 申请地址https://dashscope.aliyun.com/
DASHSCOPE_API_KEY=your-dashscope-api-key-here
# Serper 网页搜索 API Key可选用于 WebSearch 工具)
# 申请地址https://serper.dev/
SERPER_API_KEY=
# 数据库配置Docker 部署时无需修改)
DB_HOST=localhost
DB_PORT=3306
DB_NAME=mateclaw
DB_USERNAME=mateclaw
DB_PASSWORD=mateclaw123

91
.gitignore vendored Normal file
View File

@ -0,0 +1,91 @@
# 忽略匹配下列规则的Git 提交 V2.1.0
### gradle ###
.gradle
/build/
!gradle/wrapper/gradle-wrapper.jar
### STS ###
.settings/
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
bin/
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
rebel.xml
### NetBeans ###
nbproject/private/
/build/
nbbuild/
/dist/
nbdist/
.nb-gradle/
### maven ###
target/
*.war
*.ear
*.zip
*.tar
*.tar.gz
### logs ####
/logs/
mateclaw-server/logs/
*.log
### temp ignore ###
*.cache
*.diff
*.patch
*.tmp
*.java~
*.properties~
*.xml~
### system ignore ###
.DS_Store
Thumbs.db
Servers
.metadata
upload
gen_code
### node ###
node_modules
pom.xml.versionsBackup
/server/nacos-server/data
/server/nacos-server/logs
.flattened-pom.xml
.cursor
.gstack/
# mateclaw static build output (do not commit)
mateclaw-server/src/main/resources/static/
# mateclaw local runtime data (H2 DB, logs, etc. - do not commit)
mateclaw-server/data/
# VitePress build output and cache (do not commit)
docs/.vitepress/cache/
docs/.vitepress/dist/
# Astro build cache (do not commit)
.astro/
# SSL certificates (do not commit)
deploy/nginx/ssl/*.crt
deploy/nginx/ssl/*.key
deploy/nginx/ssl/*.pem
# Deploy env
deploy/.env

190
LICENSE Normal file
View File

@ -0,0 +1,190 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2026 mate.vip
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

328
README.md Normal file
View File

@ -0,0 +1,328 @@
<div align="center">
# MateClaw
[![GitHub Repo](https://img.shields.io/badge/GitHub-Repo-black.svg?logo=github)](https://github.com/matevip/mateclaw)
[![Documentation](https://img.shields.io/badge/Docs-Website-green.svg?logo=readthedocs&label=Docs)](https://mateclaw.mate.vip/)
[![Java Version](https://img.shields.io/badge/Java-17+-blue.svg?logo=openjdk&label=Java)](https://adoptium.net/)
[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.5-brightgreen.svg?logo=springboot)](https://spring.io/projects/spring-boot)
[![Vue](https://img.shields.io/badge/Vue-3-4FC08D.svg?logo=vuedotjs)](https://vuejs.org/)
[![Last Commit](https://img.shields.io/github/last-commit/matevip/mateclaw)](https://github.com/matevip/mateclaw)
[![License](https://img.shields.io/badge/license-Apache--2.0-red.svg?logo=opensourceinitiative&label=License)](LICENSE)
[![GitHub Stars](https://img.shields.io/github/stars/matevip/mateclaw?style=flat&logo=github&color=yellow&label=Stars)](https://github.com/matevip/mateclaw/stargazers)
[![GitHub Forks](https://img.shields.io/github/forks/matevip/mateclaw?style=flat&logo=github&color=purple&label=Forks)](https://github.com/matevip/mateclaw/network)
[[Documentation](https://mateclaw.mate.vip/)] [[中文](README_zh.md)]
<p align="center">
<img src="mateclaw-ui/public/logo/mateclaw_logo_s.png" alt="MateClaw Logo" width="120">
</p>
<p align="center"><b>Your AI mate, always ready to lend a claw.</b></p>
</div>
A personal AI assistant system built with **Java + Vue 3**, powered by [Spring AI Alibaba](https://github.com/alibaba/spring-ai-alibaba). Features multi-agent orchestration, a flexible tool/skill system with MCP protocol support, multi-layer memory, and multi-channel adapters.
> **Core capabilities:**
>
> **Multi-Agent Orchestration** — ReAct (Thought → Action → Observation loop) and Plan-and-Execute (auto-decompose complex tasks into ordered sub-steps). Create multiple independent agents, each with their own personality and tools.
>
> **Tool & Skill System** — Built-in tools (web search, date/time) + MCP protocol for external tool integration. Install skill packages from ClawHub marketplace or custom sources.
>
> **Multi-Layer Memory** — Short-term context window with auto-compression, event-driven post-conversation memory extraction, workspace files (PROFILE.md / MEMORY.md / daily notes), and scheduled memory consolidation.
>
> **Every Channel** — Web console, DingTalk, Feishu, WeChat Work, Telegram, Discord, QQ. One MateClaw, connect as needed.
>
> **Multi-Provider Models** — DashScope (Qwen), OpenAI, Ollama, DeepSeek, OpenRouter, Zhipu AI, Volcano Engine, and more. Configure in the web UI.
>
> **Desktop App** — Electron-based desktop application with auto-update support. Download and double-click to run.
---
## Table of Contents
- [Quick Start](#quick-start)
- [Screenshots](#screenshots)
- [Architecture](#architecture)
- [Tech Stack](#tech-stack)
- [Features](#features)
- [Documentation](#documentation)
- [Roadmap](#roadmap)
- [Contributing](#contributing)
- [Contact Us](#contact-us)
- [License](#license)
---
## Quick Start
### Prerequisites
- Java 17+
- Node.js 18+ & pnpm
- Maven 3.9+ (or use `mvnw`)
- At least one LLM API Key (e.g., [DashScope](https://dashscope.aliyun.com/))
### Option 1: Local Development
**1. Start the backend**
```bash
cd mateclaw-server
export DASHSCOPE_API_KEY=your-key-here
mvn spring-boot:run
# Backend runs at http://localhost:18088
# H2 Console: http://localhost:18088/h2-console
# API Docs (Knife4j): http://localhost:18088/doc.html
```
**2. Start the frontend**
```bash
cd mateclaw-ui
pnpm install
pnpm dev
# Frontend runs at http://localhost:5173 (proxies /api to :18088)
```
**3. Log in**
Open http://localhost:5173 and log in with `admin` / `admin123`.
### Option 2: Docker
```bash
cp .env.example .env
# Edit .env — fill in DASHSCOPE_API_KEY and other variables
docker compose up -d
# Service runs at http://localhost:18080 (MySQL + backend)
```
### Option 3: Desktop Application
Download the installer from [GitHub Releases](https://github.com/matevip/mateclaw/releases):
- **macOS**: `MateClaw-<version>-macOS.zip`
- **Windows**: `MateClaw-Setup-<version>.exe`
Double-click to run. The app bundles the Java backend and auto-updates from GitHub Releases.
> **macOS users**: If macOS blocks the app, right-click → Open → Open again, or go to System Settings → Privacy & Security → Open Anyway.
---
## Screenshots
<!-- TODO: Add screenshots of the chat console, agent workspace, skill market, etc. -->
---
## Architecture
```
mateclaw/
├── mateclaw-server/ # Spring Boot backend
│ ├── src/main/java/vip/mate/
│ │ ├── agent/ # Agent engine (ReAct, Plan-and-Execute, StateGraph)
│ │ ├── planning/ # Task planning (Plan / SubPlan models)
│ │ ├── tool/ # Tool system (built-in + MCP adapters)
│ │ ├── skill/ # Skill management (workspace + ClawHub)
│ │ ├── channel/ # Channel adapters (Web, DingTalk, Feishu, etc.)
│ │ ├── workspace/ # Conversations, messages, workspace files
│ │ ├── memory/ # Memory extraction & consolidation
│ │ ├── llm/ # Multi-provider model configs
│ │ ├── cron/ # Scheduled tasks (CronJob)
│ │ ├── auth/ # Spring Security + JWT
│ │ └── config/ # Spring bean configurations
│ └── src/main/resources/
│ ├── application.yml # Main config (H2 for dev)
│ ├── prompts/ # Prompt templates
│ └── db/ # Schema & seed data (schema.sql, data.sql)
├── mateclaw-ui/ # Vue 3 SPA frontend
│ └── src/
│ ├── views/ # Pages (ChatConsole, AgentWorkspace, SkillMarket, etc.)
│ ├── components/ # Reusable components
│ ├── stores/ # Pinia stores (domain-driven)
│ ├── api/ # Axios HTTP client
│ ├── router/ # Vue Router
│ ├── types/ # TypeScript types
│ └── i18n/ # Internationalization (zh-CN, en-US)
├── mateclaw-desktop/ # Electron desktop app
├── docs/ # VitePress documentation (zh + en)
├── docker-compose.yml
└── .env.example
```
---
## Tech Stack
| Layer | Technology |
|-------|-----------|
| Backend Framework | Spring Boot 3.5 + Spring AI Alibaba 1.1 |
| LLM Integration | DashScope, OpenAI, Ollama, DeepSeek, OpenRouter, Zhipu, Volcano Engine |
| Agent Engine | StateGraph (ReAct + Plan-and-Execute) |
| Database | H2 (dev) / MySQL 8.0+ (prod) |
| ORM | MyBatis Plus 3.5 |
| Authentication | Spring Security + JWT |
| API Docs | Knife4j (OpenAPI 3) |
| Frontend | Vue 3 + TypeScript + Vite |
| State Management | Pinia |
| UI Components | Element Plus |
| Styling | TailwindCSS 4 |
| Desktop | Electron + electron-updater |
| Docs Site | VitePress |
---
## Features
### Agent System
- **ReAct Agent** — Thought → Action → Observation reasoning loop with tool calling
- **Plan-and-Execute** — Auto-decompose complex tasks into ordered sub-steps with progress tracking
- **Dynamic Agent** — Load agent configs from database at runtime
- **Multi-Agent** — Create multiple independent agents, each with their own system prompt, tools, and personality
### Tool & Skill System
- **Built-in Tools** — Web search (Serper/Tavily), date/time, workspace memory read/write
- **MCP Protocol** — Connect external tools via Model Context Protocol (stdio and SSE transports)
- **Skill Packages** — Install/uninstall skill packages with `SKILL.md` manifests
- **ClawHub Marketplace** — Browse and install skills from the ClawHub registry
- **Workspace Skills** — Convention-based skill directory at `~/.mateclaw/skills/{name}/`
### Memory System
- **Short-Term** — Conversation context window with auto-compression when token budget exceeded
- **Post-Conversation Extraction** — Event-driven async LLM analysis, writes to PROFILE.md / MEMORY.md / daily notes
- **Memory Consolidation** — Scheduled daily emergence (CronJob at 2:00 AM) merges daily notes into long-term memory
- **Workspace Files** — Per-agent AGENTS.md, SOUL.md, PROFILE.md, MEMORY.md, memory/*.md
- **Agent Memory Tool** — Agents can read/write their own workspace files during conversations
### Multi-Channel
- **Web Console** — SSE streaming with rich message rendering (Markdown, code, plans)
- **DingTalk** — Webhook + event subscription
- **Feishu (Lark)** — Webhook + event subscription
- **WeChat Work** — Callback API
- **Telegram** — Bot API with webhook
- **Discord** — Bot with slash commands
- **QQ** — QQ Bot API
### Model Providers
Configure in the web UI (Settings → Models). Supported providers:
| Provider | Models |
|----------|--------|
| DashScope (Alibaba) | Qwen-Max, Qwen-Plus, Qwen-Turbo, Qwen-Long, QVQ |
| OpenAI | GPT-4o, GPT-4o-mini, o1, o3 |
| DeepSeek | DeepSeek-Chat, DeepSeek-Reasoner |
| Ollama | Any locally-served model |
| OpenRouter | Access 200+ models via unified API |
| Zhipu AI | GLM-4-Plus, GLM-4-Flash |
| Volcano Engine | Doubao-Pro, Doubao-Lite |
| SiliconFlow | DeepSeek, Qwen via SiliconFlow |
### Security
- **Spring Security + JWT** — Token-based authentication
- **Tool Guard** — Approval rules for sensitive tool operations
- **File Validation** — Path traversal prevention for workspace files
- **Skill Security** — Validation during skill installation
### Scheduled Tasks
- **CronJob System** — Create scheduled tasks with 5-field cron expressions
- **Memory Consolidation** — Auto-triggered daily for each agent
- **Custom Tasks** — Schedule any prompt to run periodically
---
## Documentation
| Topic | Description |
|-------|-------------|
| [Introduction](https://mateclaw.mate.vip/en/intro) | What MateClaw is and core concepts |
| [Quick Start](https://mateclaw.mate.vip/en/quickstart) | Install and run (local, Docker, desktop) |
| [Console](https://mateclaw.mate.vip/en/console) | Web UI: chat and agent configuration |
| [Agents](https://mateclaw.mate.vip/en/agents) | Agent engine: ReAct, Plan-and-Execute, StateGraph |
| [Models](https://mateclaw.mate.vip/en/models) | Configure cloud, local, and custom providers |
| [Tools](https://mateclaw.mate.vip/en/tools) | Built-in tools and custom tool development |
| [Skills](https://mateclaw.mate.vip/en/skills) | Skill packages and ClawHub marketplace |
| [MCP](https://mateclaw.mate.vip/en/mcp) | Model Context Protocol integration |
| [Memory](https://mateclaw.mate.vip/en/memory) | Multi-layer memory system |
| [Channels](https://mateclaw.mate.vip/en/channels) | DingTalk, Feishu, Telegram, Discord, and more |
| [Security](https://mateclaw.mate.vip/en/security) | Authentication and tool guard |
| [Desktop](https://mateclaw.mate.vip/en/desktop) | Desktop application guide |
| [API Reference](https://mateclaw.mate.vip/en/api) | REST API documentation |
| [Configuration](https://mateclaw.mate.vip/en/config) | Configuration reference |
| [FAQ](https://mateclaw.mate.vip/en/faq) | Common questions and troubleshooting |
---
## Roadmap
| Area | Item | Status |
|------|------|--------|
| **Agent** | Multi-agent collaboration and delegation | Planned |
| **Agent** | Multimodal input (image, audio, video) | Planned |
| **Models** | Small + large model routing | Planned |
| **Memory** | Vector DB long-term memory (RAG) | Planned |
| **Memory** | Multimodal memory fusion | Planned |
| **Skills** | Richer ClawHub ecosystem | In Progress |
| **Channels** | WeChat personal (iLink Bot) | Planned |
| **Channels** | Email channel | Planned |
| **Desktop** | Linux support | Planned |
| **Security** | Multi-tenant support | Planned |
| **Console** | Plugin marketplace in web UI | Planned |
_Status:_ **In Progress** — actively being worked on; **Planned** — queued or under design.
---
## Contributing
MateClaw is open to contributions! Whether it's bug fixes, new features, documentation improvements, or new channel/tool integrations — all contributions are welcome.
```bash
# Clone the repository
git clone https://github.com/matevip/mateclaw.git
cd mateclaw
# Backend
cd mateclaw-server
mvn clean compile
# Frontend
cd ../mateclaw-ui
pnpm install
pnpm dev
```
Please read [CONTRIBUTING.md](https://github.com/matevip/mateclaw/blob/main/CONTRIBUTING.md) (if available) before submitting a PR.
---
## Contact Us
<!-- TODO: Fill in social accounts -->
| Discord | X (Twitter) | DingTalk |
|---------|-------------|----------|
| Coming soon | Coming soon | Coming soon |
---
## Why MateClaw?
**Mate** — a companion, always by your side. **Claw** — sharp, capable, ready to grab any task. MateClaw is your personal AI mate that lends a claw whenever you need it. Built as a monolith with modular design, it's easy to deploy, extend, and customize.
---
## License
MateClaw is released under the [Apache License 2.0](LICENSE).

328
README_zh.md Normal file
View File

@ -0,0 +1,328 @@
<div align="center">
# MateClaw
[![GitHub 仓库](https://img.shields.io/badge/GitHub-仓库-black.svg?logo=github)](https://github.com/matevip/mateclaw)
[![文档](https://img.shields.io/badge/文档-在线-green.svg?logo=readthedocs&label=Docs)](https://mateclaw.mate.vip/)
[![Java 版本](https://img.shields.io/badge/Java-17+-blue.svg?logo=openjdk&label=Java)](https://adoptium.net/)
[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.5-brightgreen.svg?logo=springboot)](https://spring.io/projects/spring-boot)
[![Vue](https://img.shields.io/badge/Vue-3-4FC08D.svg?logo=vuedotjs)](https://vuejs.org/)
[![最后提交](https://img.shields.io/github/last-commit/matevip/mateclaw)](https://github.com/matevip/mateclaw)
[![许可证](https://img.shields.io/badge/license-Apache--2.0-red.svg?logo=opensourceinitiative&label=License)](LICENSE)
[![GitHub Star](https://img.shields.io/github/stars/matevip/mateclaw?style=flat&logo=github&color=yellow&label=Stars)](https://github.com/matevip/mateclaw/stargazers)
[![GitHub Fork](https://img.shields.io/github/forks/matevip/mateclaw?style=flat&logo=github&color=purple&label=Forks)](https://github.com/matevip/mateclaw/network)
[[文档](https://mateclaw.mate.vip/)] [[English](README.md)]
<p align="center">
<img src="mateclaw-ui/public/logo/mateclaw_logo_s.png" alt="MateClaw Logo" width="120">
</p>
<p align="center"><b>懂你所需,利爪随行。</b></p>
</div>
基于 **Java + Vue 3** 的个人 AI 助手系统,由 [Spring AI Alibaba](https://github.com/alibaba/spring-ai-alibaba) 驱动。支持多 Agent 编排、灵活的工具/技能系统与 MCP 协议、多层记忆体系、多渠道接入。
> **核心能力:**
>
> **多 Agent 编排** — ReAct思考→行动→观察循环和 Plan-and-Execute自动将复杂任务拆解为有序子步骤。创建多个独立 Agent各有专属人格和工具。
>
> **工具与技能系统** — 内置工具(网络搜索、日期时间)+ MCP 协议接入外部工具。从 ClawHub 市场或自定义源安装技能包。
>
> **多层记忆** — 短期上下文窗口自动压缩、事件驱动的对话后记忆提取、工作空间文件PROFILE.md / MEMORY.md / 每日笔记)、定时记忆整合。
>
> **全域触达** — Web 控制台、钉钉、飞书、企业微信、Telegram、Discord、QQ。一个 MateClaw按需连接。
>
> **多厂商模型** — DashScope通义千问、OpenAI、Ollama、DeepSeek、OpenRouter、智谱、火山引擎等。在 Web 界面中配置。
>
> **桌面应用** — 基于 Electron 的桌面应用,支持自动更新。下载即用。
---
## 目录
- [快速开始](#快速开始)
- [截图](#截图)
- [架构](#架构)
- [技术栈](#技术栈)
- [功能特性](#功能特性)
- [文档](#文档)
- [路线图](#路线图)
- [参与贡献](#参与贡献)
- [联系我们](#联系我们)
- [许可证](#许可证)
---
## 快速开始
### 前置条件
- Java 17+
- Node.js 18+ & pnpm
- Maven 3.9+(或使用 `mvnw`
- 至少一个 LLM API Key如 [DashScope](https://dashscope.aliyun.com/)
### 方式一:本地开发
**1. 启动后端**
```bash
cd mateclaw-server
export DASHSCOPE_API_KEY=your-key-here
mvn spring-boot:run
# 后端运行在 http://localhost:18088
# H2 控制台http://localhost:18088/h2-console
# API 文档Knife4jhttp://localhost:18088/doc.html
```
**2. 启动前端**
```bash
cd mateclaw-ui
pnpm install
pnpm dev
# 前端运行在 http://localhost:5173代理 /api 到 :18088
```
**3. 登录**
打开 http://localhost:5173使用 `admin` / `admin123` 登录。
### 方式二Docker 部署
```bash
cp .env.example .env
# 编辑 .env填写 DASHSCOPE_API_KEY 等变量
docker compose up -d
# 服务运行在 http://localhost:18080MySQL + 后端)
```
### 方式三:桌面应用
从 [GitHub Releases](https://github.com/matevip/mateclaw/releases) 下载安装包:
- **macOS**`MateClaw-<version>-macOS.zip`
- **Windows**`MateClaw-Setup-<version>.exe`
双击运行。应用内置 Java 后端,支持从 GitHub Releases 自动更新。
> **macOS 用户**:如果系统阻止打开,右键 → 打开 → 再次点击打开,或前往系统设置 → 隐私与安全性 → 仍要打开。
---
## 截图
<!-- TODO: 添加控制台、Agent 工作台、技能市场等截图 -->
---
## 架构
```
mateclaw/
├── mateclaw-server/ # Spring Boot 后端
│ ├── src/main/java/vip/mate/
│ │ ├── agent/ # Agent 引擎ReAct、Plan-and-Execute、StateGraph
│ │ ├── planning/ # 任务规划Plan / SubPlan 模型)
│ │ ├── tool/ # 工具系统(内置 + MCP 适配器)
│ │ ├── skill/ # 技能管理(工作空间 + ClawHub
│ │ ├── channel/ # 渠道适配器Web、钉钉、飞书等
│ │ ├── workspace/ # 会话、消息、工作空间文件
│ │ ├── memory/ # 记忆提取与整合
│ │ ├── llm/ # 多厂商模型配置
│ │ ├── cron/ # 定时任务CronJob
│ │ ├── auth/ # Spring Security + JWT
│ │ └── config/ # Spring Bean 配置
│ └── src/main/resources/
│ ├── application.yml # 主配置(开发环境用 H2
│ ├── prompts/ # 提示词模板
│ └── db/ # 数据库脚本schema.sql、data.sql
├── mateclaw-ui/ # Vue 3 SPA 前端
│ └── src/
│ ├── views/ # 页面ChatConsole、AgentWorkspace、SkillMarket 等)
│ ├── components/ # 复用组件
│ ├── stores/ # Pinia 状态管理(领域驱动)
│ ├── api/ # Axios HTTP 客户端
│ ├── router/ # Vue Router
│ ├── types/ # TypeScript 类型
│ └── i18n/ # 国际化zh-CN、en-US
├── mateclaw-desktop/ # Electron 桌面应用
├── docs/ # VitePress 文档站(中 + 英)
├── docker-compose.yml
└── .env.example
```
---
## 技术栈
| 层次 | 技术选型 |
|------|---------|
| 后端框架 | Spring Boot 3.5 + Spring AI Alibaba 1.1 |
| 大模型接入 | DashScope、OpenAI、Ollama、DeepSeek、OpenRouter、智谱、火山引擎 |
| Agent 引擎 | StateGraphReAct + Plan-and-Execute |
| 数据库 | H2开发/ MySQL 8.0+(生产) |
| ORM | MyBatis Plus 3.5 |
| 认证 | Spring Security + JWT |
| API 文档 | Knife4j (OpenAPI 3) |
| 前端框架 | Vue 3 + TypeScript + Vite |
| 状态管理 | Pinia |
| UI 组件 | Element Plus |
| 样式 | TailwindCSS 4 |
| 桌面端 | Electron + electron-updater |
| 文档站 | VitePress |
---
## 功能特性
### Agent 系统
- **ReAct Agent** — 思考→行动→观察推理循环,支持工具调用
- **Plan-and-Execute** — 自动将复杂任务拆解为有序子步骤,带进度追踪
- **动态 Agent** — 运行时从数据库加载 Agent 配置
- **多 Agent** — 创建多个独立 Agent各有专属系统提示词、工具和人格
### 工具与技能系统
- **内置工具** — 网络搜索Serper/Tavily、日期时间、工作空间记忆读写
- **MCP 协议** — 通过 Model Context Protocol 接入外部工具stdio 和 SSE 传输)
- **技能包** — 安装/卸载带 `SKILL.md` 清单的技能包
- **ClawHub 市场** — 从 ClawHub 注册中心浏览和安装技能
- **工作空间技能** — 基于约定的技能目录 `~/.mateclaw/skills/{name}/`
### 记忆系统
- **短期记忆** — 会话上下文窗口Token 超出预算时自动压缩
- **对话后提取** — 事件驱动的异步 LLM 分析,写入 PROFILE.md / MEMORY.md / 每日笔记
- **记忆整合** — 定时每日涌现CronJob 凌晨 2:00将每日笔记合并为长期记忆
- **工作空间文件** — 每个 Agent 独立的 AGENTS.md、SOUL.md、PROFILE.md、MEMORY.md、memory/*.md
- **Agent 记忆工具** — Agent 在对话中可主动读写自己的工作空间文件
### 多渠道接入
- **Web 控制台** — SSE 流式输出富消息渲染Markdown、代码、计划
- **钉钉** — Webhook + 事件订阅
- **飞书** — Webhook + 事件订阅
- **企业微信** — 回调接口
- **Telegram** — Bot API + Webhook
- **Discord** — Bot + Slash Commands
- **QQ** — QQ Bot API
### 模型厂商
在 Web 界面中配置(设置 → 模型)。支持的厂商:
| 厂商 | 模型 |
|------|------|
| DashScope阿里云 | Qwen-Max、Qwen-Plus、Qwen-Turbo、Qwen-Long、QVQ |
| OpenAI | GPT-4o、GPT-4o-mini、o1、o3 |
| DeepSeek | DeepSeek-Chat、DeepSeek-Reasoner |
| Ollama | 任意本地服务的模型 |
| OpenRouter | 通过统一 API 接入 200+ 模型 |
| 智谱 AI | GLM-4-Plus、GLM-4-Flash |
| 火山引擎 | 豆包-Pro、豆包-Lite |
| 硅基流动 | DeepSeek、Qwen via SiliconFlow |
### 安全
- **Spring Security + JWT** — 基于 Token 的认证
- **工具防护** — 敏感工具操作的审批规则
- **文件校验** — 工作空间文件路径穿越防护
- **技能安全** — 技能安装时的安全校验
### 定时任务
- **CronJob 系统** — 使用 5 位 cron 表达式创建定时任务
- **记忆整合** — 每个 Agent 每日自动触发
- **自定义任务** — 调度任意提示词定期执行
---
## 文档
| 主题 | 说明 |
|------|------|
| [项目介绍](https://mateclaw.mate.vip/zh/intro) | MateClaw 是什么、核心概念 |
| [快速开始](https://mateclaw.mate.vip/zh/quickstart) | 安装与运行本地、Docker、桌面 |
| [控制台](https://mateclaw.mate.vip/zh/console) | Web 界面:聊天与 Agent 配置 |
| [Agent 引擎](https://mateclaw.mate.vip/zh/agents) | ReAct、Plan-and-Execute、StateGraph |
| [模型配置](https://mateclaw.mate.vip/zh/models) | 配置云端、本地和自定义厂商 |
| [工具系统](https://mateclaw.mate.vip/zh/tools) | 内置工具与自定义工具开发 |
| [技能系统](https://mateclaw.mate.vip/zh/skills) | 技能包与 ClawHub 市场 |
| [MCP](https://mateclaw.mate.vip/zh/mcp) | Model Context Protocol 集成 |
| [记忆系统](https://mateclaw.mate.vip/zh/memory) | 多层记忆体系 |
| [渠道接入](https://mateclaw.mate.vip/zh/channels) | 钉钉、飞书、Telegram、Discord 等 |
| [安全机制](https://mateclaw.mate.vip/zh/security) | 认证与工具防护 |
| [桌面应用](https://mateclaw.mate.vip/zh/desktop) | 桌面应用使用指南 |
| [API 参考](https://mateclaw.mate.vip/zh/api) | REST API 文档 |
| [配置指南](https://mateclaw.mate.vip/zh/config) | 配置参考 |
| [常见问题](https://mateclaw.mate.vip/zh/faq) | 常见问题与故障排查 |
---
## 路线图
| 方向 | 事项 | 状态 |
|------|------|------|
| **Agent** | 多 Agent 协作与任务委派 | 计划中 |
| **Agent** | 多模态输入(图片、音频、视频) | 计划中 |
| **模型** | 大小模型智能路由 | 计划中 |
| **记忆** | 向量数据库长期记忆RAG | 计划中 |
| **记忆** | 多模态记忆融合 | 计划中 |
| **技能** | 丰富 ClawHub 生态 | 进行中 |
| **渠道** | 微信个人号iLink Bot | 计划中 |
| **渠道** | 邮件渠道 | 计划中 |
| **桌面** | Linux 支持 | 计划中 |
| **安全** | 多租户支持 | 计划中 |
| **控制台** | Web 端插件市场 | 计划中 |
_状态说明_ **进行中** — 正在开发;**计划中** — 排期中或设计阶段。
---
## 参与贡献
MateClaw 欢迎各种形式的贡献!无论是 Bug 修复、新功能、文档改进,还是新的渠道/工具集成,我们都非常欢迎。
```bash
# 克隆仓库
git clone https://github.com/matevip/mateclaw.git
cd mateclaw
# 后端
cd mateclaw-server
mvn clean compile
# 前端
cd ../mateclaw-ui
pnpm install
pnpm dev
```
提交 PR 前请阅读 [CONTRIBUTING.md](https://github.com/matevip/mateclaw/blob/main/CONTRIBUTING.md)(如有)。
---
## 联系我们
<!-- TODO: 补充社交账号 -->
| Discord | X (Twitter) | 钉钉群 |
|---------|-------------|--------|
| 即将上线 | 即将上线 | 即将上线 |
---
## 为什么叫 MateClaw
**Mate** — 伙伴,始终陪伴在你身边。**Claw** — 利爪锋利有力随时抓取任何任务。MateClaw 是你的个人 AI 伙伴,在你需要时伸出利爪。采用单体模块化设计,部署简单、扩展灵活、定制方便。
---
## 许可证
MateClaw 基于 [Apache License 2.0](LICENSE) 发布。

54
docker-compose.yml Normal file
View File

@ -0,0 +1,54 @@
version: '3.8'
services:
# MySQL 数据库
mysql:
image: mysql:8.0
container_name: mateclaw-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: mateclaw123
MYSQL_DATABASE: mateclaw
MYSQL_USER: mateclaw
MYSQL_PASSWORD: mateclaw123
TZ: Asia/Shanghai
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
- ./mateclaw-server/src/main/resources/db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql
- ./mateclaw-server/src/main/resources/db/data.sql:/docker-entrypoint-initdb.d/02-data.sql
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
# MateClaw 后端服务
mateclaw-server:
build:
context: ./mateclaw-server
dockerfile: Dockerfile
container_name: mateclaw-server
restart: unless-stopped
depends_on:
mysql:
condition: service_healthy
environment:
SPRING_PROFILES_ACTIVE: mysql
DB_HOST: mysql
DB_PORT: 3306
DB_NAME: mateclaw
DB_USERNAME: mateclaw
DB_PASSWORD: mateclaw123
DASHSCOPE_API_KEY: ${DASHSCOPE_API_KEY}
SERPER_API_KEY: ${SERPER_API_KEY:-}
ports:
- "18080:18080"
volumes:
- server_data:/app/data
volumes:
mysql_data:
server_data:

View File

@ -0,0 +1,13 @@
# 多阶段构建
FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline -q
COPY src ./src
RUN mvn package -DskipTests -q
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /build/target/*.jar app.jar
EXPOSE 18088
ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=mysql", "app.jar"]

264
mateclaw-server/pom.xml Normal file
View File

@ -0,0 +1,264 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>vip.mate</groupId>
<artifactId>mateclaw-server</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>MateClaw Server</name>
<description>MateClaw - Java+Vue Personal AI Assistant powered by Spring AI Alibaba</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.13</version>
<relativePath/>
</parent>
<properties>
<java.version>21</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- Spring AI 1.1.4 正式版 -->
<spring-ai.version>1.1.4</spring-ai.version>
<!-- Spring AI Alibaba 1.1.2.2(对应 Spring AI 1.1.x -->
<spring-ai-alibaba.version>1.1.2.2</spring-ai-alibaba.version>
<mybatis-plus.version>3.5.16</mybatis-plus.version>
<hutool.version>5.8.26</hutool.version>
<knife4j.version>4.5.0</knife4j.version>
<jjwt.version>0.12.6</jjwt.version>
</properties>
<dependencyManagement>
<dependencies>
<!-- Spring AI BOM统一管理 spring-ai-* 版本) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- ===== Web MVC不引入 WebFlux避免自动切换为响应式模式 ===== -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- ===== Spring AI Alibaba DashScope ===== -->
<!--
1.1.2.2 需单独指定版本,不在 BOM 中
内置 DashScope ChatModel / EmbeddingModel / ImageModel
-->
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
<version>${spring-ai-alibaba.version}</version>
<!-- 排除 webflux 传递依赖,保持 MVC 模式 -->
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- ===== Spring AI Alibaba Graph CoreStateGraph 工作流引擎) ===== -->
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-graph-core</artifactId>
<version>${spring-ai-alibaba.version}</version>
</dependency>
<!-- ===== Spring AI OpenAI Compatible ===== -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai</artifactId>
</dependency>
<!-- ===== Spring AI AnthropicClaude 模型支持) ===== -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-anthropic</artifactId>
</dependency>
<!-- ===== Spring AI MCP Client动态 MCP server 连接管理) ===== -->
<!--
使用 spring-ai-mcp-client-spring-boot-starter 引入 MCP 核心库,
但禁用自动配置(我们自己管理 McpSyncClient 生命周期)
-->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-client</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- ===== H2 内嵌数据库(开发环境) ===== -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- ===== MySQL 驱动(生产环境) ===== -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- ===== MyBatis Plus不引入 JPA避免双 ORM 冲突) ===== -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- MyBatis Plus 分页插件3.5.16 拆分为独立模块) -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- ===== Spring Security ===== -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- ===== JJWT (JSON Web Token) ===== -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<!-- ===== Knife4j API 文档 ===== -->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
<version>${knife4j.version}</version>
</dependency>
<!-- ===== Hutool 工具库 ===== -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>${hutool.version}</version>
</dependency>
<!-- ===== 钉钉 Stream SDKWebSocket 长连接,无需公网 IP ===== -->
<dependency>
<groupId>com.dingtalk.open</groupId>
<artifactId>dingtalk-stream</artifactId>
<version>1.3.5</version>
</dependency>
<!-- ===== 飞书 / Lark Open API SDKWebSocket 长连接 + 事件分发) ===== -->
<dependency>
<groupId>com.larksuite.oapi</groupId>
<artifactId>oapi-sdk</artifactId>
<version>2.5.3</version>
</dependency>
<!-- ===== Caffeine Cache用于 skill runtime 缓存) ===== -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<!-- ===== SnakeYAML用于 SKILL.md frontmatter 解析) ===== -->
<dependency>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
</dependency>
<!-- ===== Lombok ===== -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- ===== QR Code Generation (ZXing) ===== -->
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
<version>3.5.3</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId>
<version>3.5.3</version>
</dependency>
<!-- ===== Playwright (Browser Automation) ===== -->
<dependency>
<groupId>com.microsoft.playwright</groupId>
<artifactId>playwright</artifactId>
<version>1.52.0</version>
</dependency>
<!-- ===== JDADiscord Bot Gateway WebSocket 长连接) ===== -->
<dependency>
<groupId>net.dv8tion</groupId>
<artifactId>JDA</artifactId>
<version>5.2.3</version>
<exclusions>
<!-- 排除 audio 相关依赖MateClaw 不需要语音功能) -->
<exclusion>
<groupId>club.minnced</groupId>
<artifactId>opus-java</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- ===== Spring Boot Test ===== -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,44 @@
package vip.mate;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* MateClaw - Personal AI Assistant
* Powered by Spring AI Alibaba
*
* @author MateClaw Team
*/
@SpringBootApplication(exclude = {
// 禁用 Spring AI MCP Client 自动配置 McpClientManager 自行管理生命周期
org.springframework.ai.mcp.client.common.autoconfigure.McpClientAutoConfiguration.class,
org.springframework.ai.mcp.client.common.autoconfigure.McpToolCallbackAutoConfiguration.class,
org.springframework.ai.mcp.client.common.autoconfigure.StdioTransportAutoConfiguration.class,
org.springframework.ai.mcp.client.common.autoconfigure.annotations.McpClientAnnotationScannerAutoConfiguration.class,
org.springframework.ai.mcp.client.httpclient.autoconfigure.SseHttpClientTransportAutoConfiguration.class,
org.springframework.ai.mcp.client.httpclient.autoconfigure.StreamableHttpHttpClientTransportAutoConfiguration.class,
})
@EnableScheduling
@MapperScan("vip.mate.**.repository")
public class MateClawApplication {
public static void main(String[] args) {
SpringApplication.run(MateClawApplication.class, args);
}
/**
* MyBatis Plus 分页插件
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.H2));
return interceptor;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,217 @@
package vip.mate.agent;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import reactor.core.publisher.Flux;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.exception.MateClawException;
import vip.mate.llm.event.ModelConfigChangedEvent;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Agent 业务服务
* <p>
* 负责 Agent CRUD 管理和运行时实例管理
* 构建逻辑委托给 {@link AgentGraphBuilder}
*
* @author MateClaw Team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AgentService {
private final AgentMapper agentMapper;
private final AgentGraphBuilder agentGraphBuilder;
/** 运行时 Agent 实例缓存agentId -> BaseAgent */
private final Map<Long, BaseAgent> agentInstances = new ConcurrentHashMap<>();
// ==================== CRUD ====================
public List<AgentEntity> listAgents() {
return agentMapper.selectList(new LambdaQueryWrapper<AgentEntity>()
.orderByDesc(AgentEntity::getCreateTime));
}
public AgentEntity getAgent(Long id) {
AgentEntity entity = agentMapper.selectById(id);
if (entity == null) {
throw new MateClawException("Agent不存在: " + id);
}
return entity;
}
public AgentEntity createAgent(AgentEntity agent) {
agent.setEnabled(true);
if (agent.getAgentType() == null) {
agent.setAgentType("react");
}
agentMapper.insert(agent);
return agent;
}
public AgentEntity updateAgent(AgentEntity agent) {
agentMapper.updateById(agent);
agentInstances.remove(agent.getId());
return agent;
}
public void deleteAgent(Long id) {
agentMapper.deleteById(id);
agentInstances.remove(id);
}
// ==================== 运行时入口 ====================
public String chat(Long agentId, String message, String conversationId) {
BaseAgent agent = getOrBuildAgent(agentId);
return agent.chat(message, conversationId);
}
public Flux<String> chatStream(Long agentId, String message, String conversationId) {
BaseAgent agent = getOrBuildAgent(agentId);
return agent.chatStream(message, conversationId);
}
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId) {
return chatStructuredStream(agentId, message, conversationId, "");
}
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId,
String requesterId) {
BaseAgent agent = getOrBuildAgent(agentId);
if (agent instanceof StructuredStreamCapable capable) {
return capable.chatStructuredStream(message, conversationId,
requesterId != null ? requesterId : "");
}
// 降级不支持结构化流的 Agent包装为纯内容流
return agent.chatStream(message, conversationId)
.map(chunk -> new StreamDelta(chunk, null));
}
public String execute(Long agentId, String goal, String conversationId) {
BaseAgent agent = getOrBuildAgent(agentId);
return agent.execute(goal, conversationId);
}
/**
* 带工具重放的 chat 调用审批通过后由 ChannelMessageRouter ApprovalController 调用
*
* @param agentId Agent ID
* @param userMessage 用户消息"继续执行已批准的工具"
* @param conversationId 会话 ID
* @param toolCallPayload 要重放的工具调用 JSON
* @return Agent 回复
*/
public String chatWithReplay(Long agentId, String userMessage, String conversationId,
String toolCallPayload) {
BaseAgent agent = getOrBuildAgent(agentId);
return agent.chatWithReplay(userMessage, conversationId, toolCallPayload);
}
/**
* 带工具重放的流式调用Web 端审批通过后使用通过 SSE 推送结果
*/
public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId,
String toolCallPayload) {
return chatWithReplayStream(agentId, userMessage, conversationId, toolCallPayload, "");
}
public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId,
String toolCallPayload, String requesterId) {
BaseAgent agent = getOrBuildAgent(agentId);
return agent.chatWithReplayStream(userMessage, conversationId, toolCallPayload,
requesterId != null ? requesterId : "");
}
public AgentState getAgentState(Long agentId) {
BaseAgent agent = agentInstances.get(agentId);
return agent != null ? agent.getState() : AgentState.IDLE;
}
// ==================== 缓存管理 ====================
public void refreshAgent(Long agentId) {
agentInstances.remove(agentId);
log.info("Agent instance cache cleared: {}", agentId);
}
public void refreshAllAgents() {
agentInstances.clear();
log.info("All agent instance caches cleared");
}
@EventListener
public void onModelConfigChanged(ModelConfigChangedEvent event) {
refreshAllAgents();
log.info("Agent caches refreshed after model config change: {}", event.reason());
}
@EventListener
public void onToolGuardConfigChanged(vip.mate.tool.guard.service.ToolGuardConfigService.ToolGuardConfigChangedEvent event) {
refreshAllAgents();
log.info("Agent caches refreshed after tool guard config change (denied tools may have changed)");
}
// ==================== 内部方法 ====================
private BaseAgent getOrBuildAgent(Long agentId) {
return agentInstances.computeIfAbsent(agentId, id -> {
AgentEntity entity = getAgent(id);
if (!Boolean.TRUE.equals(entity.getEnabled())) {
throw new MateClawException("Agent 已禁用: " + entity.getName());
}
return agentGraphBuilder.build(entity);
});
}
// ==================== StreamDelta ====================
public record StreamDelta(String content, String thinking, String eventType, Map<String, Object> eventData, boolean persistenceOnly) {
// 兼容构造器广播+持久化
public StreamDelta(String content, String thinking) {
this(content, thinking, null, null, false);
}
/** 仅用于持久化,不再广播(内容已由 NodeStreamingChatHelper 实时广播过) */
public static StreamDelta persistOnly(String content, String thinking) {
return new StreamDelta(content, thinking, null, null, true);
}
public static StreamDelta empty() {
return new StreamDelta(null, null, null, null, false);
}
public static StreamDelta event(String type, Map<String, Object> data) {
return new StreamDelta(null, null, type, data, false);
}
public boolean isEvent() {
return eventType != null;
}
public boolean hasPayload() {
return StringUtils.hasText(content) || StringUtils.hasText(thinking);
}
public int contentLength() {
return content != null ? content.length() : 0;
}
public int thinkingLength() {
return thinking != null ? thinking.length() : 0;
}
}
}

View File

@ -0,0 +1,33 @@
package vip.mate.agent;
/**
* Agent 运行状态枚举
*
* @author MateClaw Team
*/
public enum AgentState {
/** 空闲,等待任务 */
IDLE,
/** 规划中,正在生成执行计划 */
PLANNING,
/** 执行中,正在执行工具调用或子任务 */
EXECUTING,
/** 运行中ReAct / PlanExecute 使用) */
RUNNING,
/** 等待用户输入 */
WAITING_USER_INPUT,
/** 已完成 */
DONE,
/** 执行失败 */
FAILED,
/** 错误状态(流式调用异常) */
ERROR
}

View File

@ -0,0 +1,128 @@
package vip.mate.agent;
import org.springframework.ai.support.ToolCallbacks;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbackProvider;
import java.util.*;
import java.util.stream.Collectors;
import java.util.LinkedHashMap;
/**
* Agent 统一工具集合
* <p>
* @Tool BeanToolCallbackProviderMCP server 暴露的 tool callbacks
* 统一收集为一致的 ToolCallback 列表 StateGraph 节点使用
*
* @author MateClaw Team
*/
public class AgentToolSet {
private final List<Object> toolBeans;
private final List<ToolCallback> callbacks;
private final Map<String, ToolCallback> callbackByName;
private AgentToolSet(List<Object> toolBeans, List<ToolCallback> callbacks) {
this.toolBeans = List.copyOf(toolBeans);
// 按工具名去重内置工具在前先添加MCP 工具在后同名时保留内置工具
// 使用 LinkedHashMap 保证插入顺序确保内置工具始终排在 MCP 工具前面影响 LLM 工具选择倾向
this.callbackByName = callbacks.stream()
.collect(Collectors.toMap(
cb -> cb.getToolDefinition().name(),
cb -> cb,
(a, b) -> a,
LinkedHashMap::new));
// callbacks 列表也使用去重后的结果避免 Spring AI ToolCallingChatOptions 校验重名报错
this.callbacks = List.copyOf(callbackByName.values());
}
/**
* @Tool Bean 列表和 ToolCallbackProvider 列表构建统一工具集
*/
public static AgentToolSet from(List<Object> toolBeans, List<ToolCallbackProvider> providers) {
List<ToolCallback> allCallbacks = new ArrayList<>();
// 收集 @Tool Bean callbacks
if (toolBeans != null) {
for (Object bean : toolBeans) {
ToolCallback[] cbs = ToolCallbacks.from(bean);
Collections.addAll(allCallbacks, cbs);
}
}
// 收集 ToolCallbackProvider callbacks
if (providers != null) {
for (ToolCallbackProvider provider : providers) {
ToolCallback[] cbs = provider.getToolCallbacks();
if (cbs != null) {
Collections.addAll(allCallbacks, cbs);
}
}
}
return new AgentToolSet(toolBeans != null ? toolBeans : List.of(), allCallbacks);
}
/**
* 过滤掉 denied 工具后返回新的 AgentToolSet
* denied 工具不会暴露给模型模型完全不知道它们的存在
*
* @param deniedTools denied 工具名集合为空或 null 时直接返回 this
*/
public AgentToolSet withDeniedToolsFiltered(Set<String> deniedTools) {
if (deniedTools == null || deniedTools.isEmpty()) {
return this;
}
List<ToolCallback> filtered = new ArrayList<>(callbacks);
filtered.removeIf(cb -> deniedTools.contains(cb.getToolDefinition().name()));
return new AgentToolSet(toolBeans, filtered);
}
/**
* 获取所有 ToolCallback
*/
public List<ToolCallback> callbacks() {
return callbacks;
}
/**
* 获取按名称索引的 ToolCallback Map
*/
public Map<String, ToolCallback> callbackByName() {
return callbackByName;
}
/**
* 获取原始的 @Tool Bean 列表
*/
public List<Object> toolBeans() {
return toolBeans;
}
/**
* 返回排除指定工具名后的新 AgentToolSet
*/
public AgentToolSet excluding(Set<String> toolNames) {
if (toolNames == null || toolNames.isEmpty()) {
return this;
}
List<ToolCallback> filtered = callbacks.stream()
.filter(cb -> !toolNames.contains(cb.getToolDefinition().name()))
.toList();
return new AgentToolSet(toolBeans, filtered);
}
/**
* 是否为空无任何工具
*/
public boolean isEmpty() {
return callbacks.isEmpty();
}
/**
* 工具数量
*/
public int size() {
return callbacks.size();
}
}

View File

@ -0,0 +1,221 @@
package vip.mate.agent;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import reactor.core.publisher.Flux;
import vip.mate.approval.ApprovalPlaceholderUtil;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.model.MessageEntity;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
/**
* Agent 抽象基类
* 定义所有 Agent 的基础行为与状态管理
*
* @author MateClaw Team
*/
@Slf4j
public abstract class BaseAgent {
protected final ChatClient chatClient;
protected final ConversationService conversationService;
protected final AtomicReference<AgentState> state = new AtomicReference<>(AgentState.IDLE);
/** Agent 唯一标识 */
protected String agentId;
/** Agent 名称 */
protected String agentName;
/** 系统提示词 */
protected String systemPrompt;
/** 最大工具调用迭代次数 */
protected int maxIterations = 10;
/** 模型名称 */
protected String modelName;
/** 采样温度 */
protected Double temperature;
/** 最大输出 token */
protected Integer maxTokens;
/** 最大输入 token上下文窗口 */
protected Integer maxInputTokens;
/** Top P */
protected Double topP;
/** 当前运行时是否启用工具调用 */
protected boolean toolCallingEnabled = true;
/** 构建时使用的 provider ID运行时快照 */
protected String runtimeProviderId;
protected BaseAgent(ChatClient chatClient, ConversationService conversationService) {
this.chatClient = chatClient;
this.conversationService = conversationService;
}
/**
* 同步对话接口
*
* @param userMessage 用户消息
* @param conversationId 会话ID
* @return 助手回复
*/
public abstract String chat(String userMessage, String conversationId);
/**
* 流式对话接口SSE
*
* @param userMessage 用户消息
* @param conversationId 会话ID
* @return 流式文本 Flux
*/
public abstract Flux<String> chatStream(String userMessage, String conversationId);
/**
* 执行复杂任务Plan-and-Execute 模式
*
* @param goal 任务目标
* @param conversationId 会话ID
* @return 执行结果摘要
*/
public abstract String execute(String goal, String conversationId);
/**
* 带工具重放的对话接口审批通过后调用
* <p>
* 默认实现退化为普通 chat子类可覆盖注入 forced_tool_call
*
* @param userMessage 用户消息
* @param conversationId 会话 ID
* @param toolCallPayload 要重放的工具调用 JSON
* @return 助手回复
*/
public String chatWithReplay(String userMessage, String conversationId, String toolCallPayload) {
return chat(userMessage, conversationId);
}
/**
* 带工具重放的流式对话接口Web 端审批通过后调用
*/
public Flux<AgentService.StreamDelta> chatWithReplayStream(String userMessage, String conversationId,
String toolCallPayload) {
return chatWithReplayStream(userMessage, conversationId, toolCallPayload, "");
}
public Flux<AgentService.StreamDelta> chatWithReplayStream(String userMessage, String conversationId,
String toolCallPayload, String requesterId) {
if (this instanceof StructuredStreamCapable capable) {
return capable.chatStructuredStream(userMessage, conversationId, requesterId);
}
return chatStream(userMessage, conversationId)
.map(chunk -> new AgentService.StreamDelta(chunk, null));
}
/**
* 获取当前 Agent 状态
*/
public AgentState getState() {
return state.get();
}
/**
* 设置 Agent 状态
*/
protected void setState(AgentState newState) {
AgentState old = state.getAndSet(newState);
log.debug("[{}] Agent state: {} -> {}", agentName, old, newState);
}
/**
* 判断 Agent 是否空闲
*/
public boolean isIdle() {
return AgentState.IDLE.equals(state.get());
}
public String getAgentId() { return agentId; }
public String getAgentName() { return agentName; }
public String getSystemPrompt() { return systemPrompt; }
protected ChatClient.ChatClientRequestSpec createConversationRequest(String userMessage, String conversationId) {
ChatClient.ChatClientRequestSpec request = chatClient.prompt()
.system(systemPrompt != null ? systemPrompt : "你是一个有帮助的AI助手。");
List<Message> historyMessages = buildConversationHistory(conversationId, userMessage);
if (!historyMessages.isEmpty()) {
request = request.messages(historyMessages);
}
return request.user(userMessage);
}
protected List<Message> buildConversationHistory(String conversationId, String currentUserMessage) {
List<MessageEntity> history = conversationService.listMessages(conversationId);
if (history.isEmpty()) {
return List.of();
}
int limit = history.size();
if (limit > 0) {
MessageEntity last = history.get(limit - 1);
if ("user".equals(last.getRole()) && currentUserMessage.equals(last.getContent())) {
limit -= 1;
}
}
if (limit <= 0) {
return List.of();
}
List<Message> messages = new ArrayList<>(limit);
for (int i = 0; i < limit; i += 1) {
MessageEntity entity = history.get(i);
// 过滤审批占位消息确保 LLM 上下文不包含审批残留
if ("assistant".equals(entity.getRole()) && isApprovalPlaceholder(entity.getContent())) {
log.debug("[{}] Filtering approval placeholder from history: msgId={}", agentName, entity.getId());
continue;
}
Message springMessage = toSpringMessage(entity);
if (springMessage != null) {
messages.add(springMessage);
}
}
return messages;
}
/**
* 判断是否为审批占位消息委托给共享工具类
*/
static boolean isApprovalPlaceholder(String content) {
return ApprovalPlaceholderUtil.isApprovalPlaceholder(content);
}
private Message toSpringMessage(MessageEntity message) {
if (message == null) {
return null;
}
String renderedContent = conversationService.renderMessageContent(message);
if (renderedContent == null || renderedContent.isBlank()) {
return null;
}
return switch (message.getRole()) {
case "assistant" -> new AssistantMessage(renderedContent);
case "system" -> new SystemMessage(renderedContent);
case "user" -> new UserMessage(renderedContent);
default -> null;
};
}
}

View File

@ -0,0 +1,148 @@
package vip.mate.agent;
import com.alibaba.cloud.ai.graph.NodeOutput;
import vip.mate.agent.graph.state.MateClawStateKeys;
import java.util.List;
import java.util.Map;
/**
* Graph 事件发布工具
* <p>
* 所有方法都是 static不做状态管理
* 节点内部收集 List&lt;GraphEvent&gt;最终写入 PENDING_EVENTS
* StateGraph*Agent NodeOutput 中读取这些事件
*
* @author MateClaw Team
*/
public final class GraphEventPublisher {
private GraphEventPublisher() {}
// ===== 事件类型常量 =====
public static final String EVENT_PHASE = "phase";
public static final String EVENT_TOOL_START = "tool_call_started";
public static final String EVENT_TOOL_COMPLETE = "tool_call_completed";
public static final String EVENT_PLAN_CREATED = "plan_created";
public static final String EVENT_STEP_STARTED = "plan_step_started";
public static final String EVENT_STEP_COMPLETED = "plan_step_completed";
public static final String EVENT_TOOL_APPROVAL_REQUESTED = "tool_approval_requested";
/**
* 事件记录
*/
public record GraphEvent(String type, Map<String, Object> data, long timestamp) {}
// ===== 静态工厂方法 =====
public static GraphEvent phase(String phase, Map<String, Object> extra) {
long ts = System.currentTimeMillis();
Map<String, Object> data = new java.util.HashMap<>(extra);
data.put("phase", phase);
data.put("timestamp", ts);
return new GraphEvent(EVENT_PHASE, Map.copyOf(data), ts);
}
public static GraphEvent toolStart(String toolName, String arguments) {
long ts = System.currentTimeMillis();
return new GraphEvent(EVENT_TOOL_START, Map.of(
"toolName", toolName,
"arguments", arguments != null ? arguments : "",
"timestamp", ts
), ts);
}
public static GraphEvent toolComplete(String toolName, String result, boolean success) {
long ts = System.currentTimeMillis();
return new GraphEvent(EVENT_TOOL_COMPLETE, Map.of(
"toolName", toolName,
"result", result != null ? truncateResult(result) : "",
"success", success,
"timestamp", ts
), ts);
}
public static GraphEvent planCreated(Long planId, List<String> steps) {
long ts = System.currentTimeMillis();
return new GraphEvent(EVENT_PLAN_CREATED, Map.of(
"planId", planId,
"steps", steps,
"timestamp", ts
), ts);
}
public static GraphEvent stepStarted(int index, String title) {
long ts = System.currentTimeMillis();
return new GraphEvent(EVENT_STEP_STARTED, Map.of(
"index", index,
"title", title != null ? title : "",
"timestamp", ts
), ts);
}
public static GraphEvent stepCompleted(int index, String result) {
long ts = System.currentTimeMillis();
return new GraphEvent(EVENT_STEP_COMPLETED, Map.of(
"index", index,
"result", result != null ? truncateResult(result) : "",
"timestamp", ts
), ts);
}
public static GraphEvent toolApprovalRequested(String pendingId, String toolName,
String arguments, String reason) {
long ts = System.currentTimeMillis();
return new GraphEvent(EVENT_TOOL_APPROVAL_REQUESTED, Map.of(
"pendingId", pendingId,
"toolName", toolName != null ? toolName : "",
"arguments", arguments != null ? truncateResult(arguments) : "",
"reason", reason != null ? reason : "",
"timestamp", ts
), ts);
}
/**
* 增强版审批事件包含 findingsseveritysummary
*/
public static GraphEvent toolApprovalRequested(String pendingId, String toolName,
String arguments, String reason,
String summary, String maxSeverity,
List<Map<String, Object>> findings) {
long ts = System.currentTimeMillis();
java.util.Map<String, Object> data = new java.util.LinkedHashMap<>();
data.put("pendingId", pendingId);
data.put("toolName", toolName != null ? toolName : "");
data.put("arguments", arguments != null ? truncateForBroadcast(arguments) : "");
data.put("reason", reason != null ? reason : "");
data.put("summary", summary);
data.put("maxSeverity", maxSeverity);
data.put("findings", findings != null ? findings : List.of());
data.put("timestamp", ts);
return new GraphEvent(EVENT_TOOL_APPROVAL_REQUESTED, Map.copyOf(data), ts);
}
// ===== 提取方法 =====
/**
* NodeOutput 中提取 PENDING_EVENTS
*/
@SuppressWarnings("unchecked")
public static List<GraphEvent> extractEvents(NodeOutput output) {
if (output == null || output.state() == null) {
return List.of();
}
return output.state().<List<GraphEvent>>value(MateClawStateKeys.PENDING_EVENTS)
.orElse(List.of());
}
private static String truncateResult(String result) {
return result.length() > 500 ? result.substring(0, 500) + "..." : result;
}
/**
* 截断字符串用于直推广播公共方法 Node 直接构造广播数据时使用
*/
public static String truncateForBroadcast(String text) {
return truncateResult(text);
}
}

View File

@ -0,0 +1,36 @@
package vip.mate.agent;
import reactor.core.publisher.Flux;
/**
* 支持结构化流的 Agent 接口
* <p>
* 实现此接口的 Agent 可以在 SSE 流中同时发送事件工具调用阶段变更等和内容
*
* @author MateClaw Team
*/
public interface StructuredStreamCapable {
/**
* 结构化流式对话
* <p>
* 返回的 Flux 中包含两类 StreamDelta
* - 事件类型isEvent() == true工具调用开始/完成阶段变更等
* - 内容类型hasPayload() == trueLLM 生成的文本内容
*
* @param userMessage 用户消息
* @param conversationId 会话ID
* @return 结构化流
*/
Flux<AgentService.StreamDelta> chatStructuredStream(String userMessage, String conversationId);
/**
* 结构化流式对话带请求者身份
*
* @param requesterId 请求发起者 ID用于审批身份校验
*/
default Flux<AgentService.StreamDelta> chatStructuredStream(String userMessage, String conversationId,
String requesterId) {
return chatStructuredStream(userMessage, conversationId);
}
}

View File

@ -0,0 +1,271 @@
package vip.mate.agent.context;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions;
import org.springframework.stereotype.Component;
import vip.mate.agent.prompt.PromptLoader;
import vip.mate.config.ConversationWindowProperties;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
/**
* 会话历史上下文窗口管理器
* <p>
* 在消息注入 StateGraph 之前检测 token 是否超出模型上下文窗口
* 若超出则将较早的消息通过 LLM 压缩为摘要保留最近 N 轮原始消息
* <p>
* 安全设计摘要内容作为 UserMessage 注入 SystemMessage
* 避免历史用户输入被提升为系统级指令防止指令污染
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ConversationWindowManager {
private static final String SUMMARY_SYSTEM_PROMPT = PromptLoader.loadPrompt("context/conversation-summary-system");
private static final String SUMMARY_USER_TEMPLATE = PromptLoader.loadPrompt("context/conversation-summary-user");
private final ConversationWindowProperties properties;
/** 摘要缓存key = "conversationId:oldMessageCount" */
private final ConcurrentHashMap<String, CachedSummary> summaryCache = new ConcurrentHashMap<>();
/** 缓存 TTL30 分钟 */
private static final long CACHE_TTL_MS = 30 * 60 * 1000L;
/**
* 将会话历史裁剪到上下文窗口内
* <p>
* 预算计算包含 systemPrompt + 历史消息 + 当前用户消息
* 确保最终拼接后不超出模型上下文窗口
*
* @param messages 已转换的 Spring AI 消息列表不含当前用户消息
* @param systemPrompt 系统提示词文本
* @param currentUserMessage 当前用户输入纳入窗口预算计算但不会拼入返回结果
* @param maxInputTokens 模型最大输入 token0 null 使用全局默认
* @param chatModel 用于生成摘要的 ChatModel
* @param conversationId 会话 ID用于缓存
* @return 裁剪后的消息列表可能包含摘要前缀
*/
public List<Message> fitToWindow(List<Message> messages, String systemPrompt,
String currentUserMessage,
Integer maxInputTokens, ChatModel chatModel,
String conversationId) {
if (messages == null || messages.isEmpty()) {
return messages;
}
int effectiveMax = (maxInputTokens != null && maxInputTokens > 0)
? maxInputTokens : properties.getDefaultMaxInputTokens();
int triggerThreshold = (int) (effectiveMax * properties.getCompactTriggerRatio());
int systemTokens = TokenEstimator.estimateTokens(systemPrompt);
int currentMsgTokens = TokenEstimator.estimateTokens(currentUserMessage) + TokenEstimator.PER_MESSAGE_OVERHEAD;
int historyTokens = TokenEstimator.estimateTokens(messages);
int totalTokens = systemTokens + currentMsgTokens + historyTokens;
if (totalTokens <= triggerThreshold) {
return messages;
}
log.info("[ConversationWindow] 超阈值: {} tokens (system={}, current={}, history={}) > {} 触发阈值 (max={}), conversationId={}",
totalTokens, systemTokens, currentMsgTokens, historyTokens,
triggerThreshold, effectiveMax, conversationId);
// 清理过期缓存
evictExpiredEntries();
// 可用于历史的 token 预算 = max - system - currentMsg - 安全余量
int reservedTokens = systemTokens + currentMsgTokens + (int) (effectiveMax * 0.05);
int historyBudget = effectiveMax - reservedTokens;
return compactMessages(messages, historyBudget, chatModel, conversationId);
}
private List<Message> compactMessages(List<Message> messages, int historyBudget,
ChatModel chatModel, String conversationId) {
// 计算保留多少条最近消息
int preserveCount = calculatePreserveCount(messages);
// 如果消息总数不够拆分尝试逐步减少保留数
if (preserveCount >= messages.size()) {
// 消息太少无法拆分尝试保留最少 2
preserveCount = Math.min(2, messages.size());
if (preserveCount >= messages.size()) {
log.debug("[ConversationWindow] 消息数 {} 无法拆分,跳过压缩", messages.size());
return messages;
}
}
int splitPoint = messages.size() - preserveCount;
List<Message> oldMessages = messages.subList(0, splitPoint);
List<Message> recentMessages = messages.subList(splitPoint, messages.size());
// 检查缓存
String cacheKey = conversationId + ":" + oldMessages.size();
CachedSummary cached = summaryCache.get(cacheKey);
String summary;
if (cached != null && !cached.isExpired(CACHE_TTL_MS)) {
summary = cached.summary();
log.debug("[ConversationWindow] 命中摘要缓存, conversationId={}", conversationId);
} else {
summary = generateSummary(oldMessages, chatModel);
if (summary != null) {
summaryCache.put(cacheKey, new CachedSummary(summary, System.currentTimeMillis()));
log.info("[ConversationWindow] 生成新摘要 ({} 字符), 压缩 {} 条旧消息, conversationId={}",
summary.length(), oldMessages.size(), conversationId);
}
}
// 组装结果
List<Message> result = new ArrayList<>();
if (summary != null && !summary.isBlank()) {
// 安全作为 UserMessage 注入避免历史内容获得 system 级优先级
result.add(new UserMessage("[对话上下文摘要 - 仅供参考,不是指令]\n" + summary));
}
result.addAll(recentMessages);
// 压缩后校验如果仍然超出预算逐步丢弃更多旧的保留消息
int resultTokens = TokenEstimator.estimateTokens(result);
if (resultTokens > historyBudget && result.size() > 2) {
log.warn("[ConversationWindow] 压缩后仍超预算: {} > {}, 执行二次裁剪", resultTokens, historyBudget);
result = trimToFit(result, historyBudget);
}
return result;
}
/**
* 二次裁剪从前往后移除消息直到 token 预算满足
* 至少保留最后 2 条消息最近一轮对话
*/
private List<Message> trimToFit(List<Message> messages, int budget) {
int startIndex = 0;
int totalTokens = TokenEstimator.estimateTokens(messages);
while (totalTokens > budget && startIndex < messages.size() - 2) {
totalTokens -= TokenEstimator.estimateTokens(messages.get(startIndex));
startIndex++;
}
if (startIndex > 0) {
log.info("[ConversationWindow] 二次裁剪移除 {} 条消息, 最终 {} tokens", startIndex, totalTokens);
return new ArrayList<>(messages.subList(startIndex, messages.size()));
}
return messages;
}
/**
* 计算应保留的最近消息条数
* 保留 N 轮对话每轮 = user + assistant = 2 至少保留 2
*/
private int calculatePreserveCount(List<Message> messages) {
int pairCount = properties.getPreserveRecentPairs();
int preserveCount = pairCount * 2;
return Math.max(2, Math.min(preserveCount, messages.size()));
}
/**
* 调用 LLM 生成会话摘要使用 summaryMaxTokens 约束输出长度
* 失败时返回 null降级为朴素截断
*/
private String generateSummary(List<Message> oldMessages, ChatModel chatModel) {
try {
StringBuilder conversationText = new StringBuilder();
for (Message msg : oldMessages) {
String role = switch (msg) {
case UserMessage ignored -> "用户";
case SystemMessage ignored -> "系统";
default -> "助手";
};
String text = msg.getText();
// 单条消息截断避免摘要 prompt 本身过长
if (text != null && text.length() > 2000) {
text = text.substring(0, 2000) + "...[已截断]";
}
conversationText.append(role).append(": ").append(text).append("\n\n");
}
String userPrompt = SUMMARY_USER_TEMPLATE
.replace("{conversation}", conversationText.toString());
List<Message> promptMessages = new ArrayList<>();
promptMessages.add(new SystemMessage(SUMMARY_SYSTEM_PROMPT));
promptMessages.add(new UserMessage(userPrompt));
// 使用 summaryMaxTokens 约束摘要输出长度
ChatOptions options = DashScopeChatOptions.builder()
.withMaxToken(properties.getSummaryMaxTokens())
.build();
ChatResponse response = chatModel.call(new Prompt(promptMessages, options));
if (response != null && response.getResult() != null
&& response.getResult().getOutput() != null) {
return response.getResult().getOutput().getText();
}
log.warn("[ConversationWindow] LLM 摘要返回空结果");
return null;
} catch (Exception e) {
log.warn("[ConversationWindow] LLM 摘要生成失败,降级为朴素截断: {}", e.getMessage());
return null;
}
}
/**
* PTL (Prompt Too Long) 恢复用的紧急压缩
* <p>
* LLM 返回 context_length_exceeded 错误时 Node 层调用此方法
* 对消息列表做更激进的裁剪保留最近 2 + 朴素截断不调用 LLM 摘要
*
* @param messages 原始消息列表
* @return 压缩后的消息列表如果无法压缩返回 null
*/
public List<Message> compactForRetry(List<Message> messages) {
if (messages == null || messages.size() <= 2) {
return null;
}
// 紧急模式不调用 LLM 摘要直接丢弃较旧消息只保留最近 2 (4 )
int preserveCount = Math.min(4, messages.size());
int splitPoint = messages.size() - preserveCount;
if (splitPoint <= 0) {
return null;
}
List<Message> recentMessages = new ArrayList<>(messages.subList(splitPoint, messages.size()));
log.info("[ConversationWindow] PTL 紧急压缩: {} -> {} 条消息 (丢弃 {} 条旧消息)",
messages.size(), recentMessages.size(), splitPoint);
return recentMessages;
}
/**
* 清理过期缓存条目
*/
private void evictExpiredEntries() {
summaryCache.entrySet().removeIf(entry -> entry.getValue().isExpired(CACHE_TTL_MS));
}
/**
* 缓存条目
*/
record CachedSummary(String summary, long createdAt) {
boolean isExpired(long ttlMs) {
return System.currentTimeMillis() - createdAt > ttlMs;
}
}
}

View File

@ -0,0 +1,90 @@
package vip.mate.agent.context;
import org.springframework.ai.chat.messages.Message;
import java.util.List;
/**
* Token 估算工具类
* <p>
* 按字符类型分段估算
* <ul>
* <li>CJK 字符中日韩 1 字符 1 token</li>
* <li>ASCII 字符英文数字符号 4 字符 1 token</li>
* </ul>
* 这是保守估算偏高确保压缩阈值不会触发过晚
*
* @author MateClaw Team
*/
public final class TokenEstimator {
/** 每条消息的固定开销 tokenrole 标记、分隔符等) */
static final int PER_MESSAGE_OVERHEAD = 4;
private TokenEstimator() {
}
/**
* 估算文本 token
* CJK 字符按 1:1ASCII 4:1其他 Unicode 1.5:1
*/
public static int estimateTokens(String text) {
if (text == null || text.isEmpty()) {
return 0;
}
int cjkChars = 0;
int asciiChars = 0;
int otherChars = 0;
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
if (isCJK(c)) {
cjkChars++;
} else if (c <= 0x7F) {
asciiChars++;
} else {
otherChars++;
}
}
// CJK: 1 char 1 token; ASCII: 4 chars 1 token; Other: 1.5 chars 1 token
return cjkChars + (asciiChars + 3) / 4 + (otherChars * 2 + 2) / 3;
}
/**
* 估算单条消息 token 内容 + 消息开销
*/
public static int estimateTokens(Message message) {
if (message == null) {
return 0;
}
return estimateTokens(message.getText()) + PER_MESSAGE_OVERHEAD;
}
/**
* 估算消息列表总 token
*/
public static int estimateTokens(List<Message> messages) {
if (messages == null || messages.isEmpty()) {
return 0;
}
return messages.stream()
.mapToInt(TokenEstimator::estimateTokens)
.sum();
}
/**
* 判断是否为 CJK 字符中日韩统一表意文字 + 常用标点
*/
private static boolean isCJK(char c) {
Character.UnicodeBlock block = Character.UnicodeBlock.of(c);
return block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B
|| block == Character.UnicodeBlock.CJK_COMPATIBILITY_IDEOGRAPHS
|| block == Character.UnicodeBlock.CJK_SYMBOLS_AND_PUNCTUATION
|| block == Character.UnicodeBlock.HALFWIDTH_AND_FULLWIDTH_FORMS
|| block == Character.UnicodeBlock.HIRAGANA
|| block == Character.UnicodeBlock.KATAKANA
|| block == Character.UnicodeBlock.HANGUL_SYLLABLES
|| block == Character.UnicodeBlock.BOPOMOFO;
}
}

View File

@ -0,0 +1,128 @@
package vip.mate.agent.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import vip.mate.agent.AgentService;
import vip.mate.agent.AgentState;
import vip.mate.agent.model.AgentEntity;
import vip.mate.common.result.R;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* Agent 管理接口
*
* @author MateClaw Team
*/
@Tag(name = "Agent管理")
@Slf4j
@RestController
@RequestMapping("/api/v1/agents")
@RequiredArgsConstructor
public class AgentController {
private final AgentService agentService;
private final ExecutorService sseExecutor = Executors.newCachedThreadPool();
@Operation(summary = "获取Agent列表")
@GetMapping
public R<List<AgentEntity>> list() {
return R.ok(agentService.listAgents());
}
@Operation(summary = "获取Agent详情")
@GetMapping("/{id}")
public R<AgentEntity> get(@PathVariable Long id) {
return R.ok(agentService.getAgent(id));
}
@Operation(summary = "创建Agent")
@PostMapping
public R<AgentEntity> create(@RequestBody AgentEntity agent) {
return R.ok(agentService.createAgent(agent));
}
@Operation(summary = "更新Agent")
@PutMapping("/{id}")
public R<AgentEntity> update(@PathVariable Long id, @RequestBody AgentEntity agent) {
agent.setId(id);
return R.ok(agentService.updateAgent(agent));
}
@Operation(summary = "删除Agent")
@DeleteMapping("/{id}")
public R<Void> delete(@PathVariable Long id) {
agentService.deleteAgent(id);
return R.ok();
}
@Operation(summary = "流式对话SSE")
@GetMapping("/{id}/chat/stream")
public SseEmitter chatStream(
@PathVariable Long id,
@RequestParam String message,
@RequestParam(defaultValue = "default") String conversationId) {
SseEmitter emitter = new SseEmitter(5 * 60 * 1000L);
sseExecutor.execute(() -> {
try {
agentService.chatStream(id, message, conversationId)
.doOnNext(chunk -> {
try {
emitter.send(SseEmitter.event().name("message").data(chunk));
} catch (IOException e) {
log.warn("SSE send error: {}", e.getMessage());
}
})
.doOnComplete(() -> {
try {
emitter.send(SseEmitter.event().name("done").data("[DONE]"));
emitter.complete();
} catch (IOException e) {
emitter.completeWithError(e);
}
})
.doOnError(emitter::completeWithError)
.subscribe();
} catch (Exception e) {
emitter.completeWithError(e);
}
});
return emitter;
}
@Operation(summary = "同步对话")
@PostMapping("/{id}/chat")
public R<String> chat(
@PathVariable Long id,
@RequestBody ChatRequest request) {
return R.ok(agentService.chat(id, request.getMessage(), request.getConversationId()));
}
@Operation(summary = "执行复杂任务Plan-Execute")
@PostMapping("/{id}/execute")
public R<String> execute(
@PathVariable Long id,
@RequestBody ChatRequest request) {
return R.ok(agentService.execute(id, request.getMessage(), request.getConversationId()));
}
@Operation(summary = "获取Agent运行状态")
@GetMapping("/{id}/state")
public R<AgentState> getState(@PathVariable Long id) {
return R.ok(agentService.getAgentState(id));
}
@lombok.Data
public static class ChatRequest {
private String message;
private String conversationId = "default";
}
}

View File

@ -0,0 +1,736 @@
package vip.mate.agent.graph;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import vip.mate.channel.web.ChatStreamTracker;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
/**
* 节点级流式 LLM 调用辅助
* <p>
* 核心原则模型流驱动渠道流State 只保存最终聚合结果
* <ul>
* <li>调用 {@code chatModel.stream(prompt)} chunk 处理</li>
* <li>从每个 chunk 中提取 content delta thinking deltareasoningContent</li>
* <li>通过 {@link ChatStreamTracker} 实时广播 content_delta / thinking_delta</li>
* <li>同时内部累积完整 textthinking tool calls</li>
* <li>流结束后返回 {@link StreamResult} 供节点写回 State</li>
* </ul>
* <p>
* 所有面向用户的 LLM 节点ReasoningNodeStepExecutionNodePlanSummaryNode
* 统一使用此 helper而不是各自散落 {@code chatModel.call()}
*
* @author MateClaw Team
*/
@Slf4j
public class NodeStreamingChatHelper {
private final ChatStreamTracker streamTracker;
/** 备选模型(主模型连续失败后使用) */
private final ChatModel fallbackModel;
public NodeStreamingChatHelper(ChatStreamTracker streamTracker) {
this.streamTracker = streamTracker;
this.fallbackModel = null;
}
public NodeStreamingChatHelper(ChatStreamTracker streamTracker, ChatModel fallbackModel) {
this.streamTracker = streamTracker;
this.fallbackModel = fallbackModel;
}
/**
* 流式调用 LLM 并实时广播增量内容
*
* @param chatModel LLM 模型
* @param prompt 完整 prompt
* @param conversationId 会话 ID用于广播
* @param phase 阶段标识用于日志 "reasoning""step_execution"
* @return 聚合结果
*/
public StreamResult streamCall(ChatModel chatModel, Prompt prompt,
String conversationId, String phase) {
return streamCallInternal(chatModel, prompt, conversationId, phase, true);
}
/**
* 流式调用 LLM 但不广播增量内容到前端
* <p>
* 用于 PlanGenerationNode 等返回结构化 JSON 的节点 LLM 输出不应直接展示给用户
* 需要后续解析后再决定是否广播
*
* @param chatModel LLM 模型
* @param prompt 完整 prompt
* @param conversationId 会话 ID仅用于日志不广播
* @param phase 阶段标识
* @return 聚合结果
*/
public StreamResult streamCallSilent(ChatModel chatModel, Prompt prompt,
String conversationId, String phase) {
return streamCallInternal(chatModel, prompt, conversationId, phase, false);
}
/**
* 广播文本内容到前端用于 silent 调用后手动推送 direct_answer
*/
public void broadcastContent(String conversationId, String content) {
if (content != null && !content.isEmpty()) {
broadcastDelta(conversationId, "content_delta", content);
}
}
// ==================== 重试配置 ====================
private static final int MAX_RETRIES = 3;
private static final long BACKOFF_BASE_MS = 1000;
private static final long BACKOFF_CAP_MS = 10_000;
/**
* 判断错误是否可重试基于状态码/异常类型
*/
private static boolean isRetryable(Throwable error) {
String msg = extractFullErrorChain(error);
// Kimi engine_overloaded / 标准 HTTP 错误 / 速率限制
return msg.contains("engine_overloaded")
|| msg.contains("rate_limit") || msg.contains("RateLimitError")
|| msg.contains("429") || msg.contains("Too Many Requests")
|| msg.contains("500") || msg.contains("502") || msg.contains("503") || msg.contains("504")
|| msg.contains("APITimeoutError") || msg.contains("APIConnectionError")
|| msg.contains("Connection reset") || msg.contains("Connection refused");
}
/**
* 分类错误类型用于分级重试和上层 Node 决策
*/
private static ErrorType classifyError(Throwable error) {
String msg = extractFullErrorChain(error);
// PTL: prompt too long / context length exceeded
if (msg.contains("prompt is too long")
|| msg.contains("context_length_exceeded")
|| msg.contains("context length exceeded")
|| msg.contains("maximum context length")
|| msg.contains("token limit")
|| msg.contains("This model's maximum context length")
|| msg.contains("请求体中的 input tokens 总数超出了模型允许")) {
return ErrorType.PROMPT_TOO_LONG;
}
// Auth errors
if (msg.contains("401") || msg.contains("Unauthorized") || msg.contains("Invalid API Key")
|| msg.contains("authentication") || msg.contains("AuthenticationError")) {
return ErrorType.AUTH_ERROR;
}
// Rate limit
if (msg.contains("429") || msg.contains("rate_limit") || msg.contains("RateLimitError")
|| msg.contains("Too Many Requests") || msg.contains("engine_overloaded")) {
return ErrorType.RATE_LIMIT;
}
// Server errors
if (msg.contains("500") || msg.contains("502") || msg.contains("503") || msg.contains("504")
|| msg.contains("APITimeoutError") || msg.contains("APIConnectionError")
|| msg.contains("Connection reset") || msg.contains("Connection refused")
|| msg.contains("timeout") || msg.contains("Timeout")) {
return ErrorType.SERVER_ERROR;
}
return ErrorType.UNKNOWN;
}
/** 提取完整异常链信息用于关键字匹配 */
private static String extractFullErrorChain(Throwable error) {
StringBuilder sb = new StringBuilder();
Throwable cur = error;
while (cur != null) {
if (cur.getMessage() != null) {
sb.append(cur.getMessage()).append(" | ");
}
sb.append(cur.getClass().getSimpleName()).append(" | ");
cur = cur.getCause();
}
return sb.toString();
}
private StreamResult streamCallInternal(ChatModel chatModel, Prompt prompt,
String conversationId, String phase,
boolean broadcast) {
// 在开始 LLM 调用前检查停止标志
if (streamTracker.isStopRequested(conversationId)) {
log.info("[{}] Stop requested before LLM call, aborting: conversationId={}", phase, conversationId);
throw new CancellationException("Stream stopped by user");
}
// 主模型重试循环
StreamResult lastResult = null;
for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) {
lastResult = doStreamCall(chatModel, prompt, conversationId, phase, broadcast, attempt);
if (lastResult != null) {
// PTL: 不重试直接返回给上层 Node 处理
if (lastResult.errorType() == ErrorType.PROMPT_TOO_LONG) {
return lastResult;
}
// AUTH: 不重试
if (lastResult.errorType() == ErrorType.AUTH_ERROR) {
return lastResult;
}
// 成功或不可重试
if (lastResult.errorMessage() == null || lastResult.errorType() == ErrorType.NONE) {
return lastResult;
}
}
// lastResult == null 表示需要重试
}
// 主模型耗尽重试 尝试 fallback model
if (fallbackModel != null && fallbackModel != chatModel) {
log.warn("[{}] Primary model exhausted retries, switching to fallback model for conversation {}",
phase, conversationId);
if (broadcast) {
broadcastDelta(conversationId, "warning",
buildDeltaJson("主模型不可用,正在切换到备选模型..."));
}
StreamResult fallbackResult = doStreamCall(fallbackModel, prompt, conversationId,
phase + "_fallback", broadcast, 0);
if (fallbackResult != null) {
return fallbackResult;
}
}
return lastResult != null ? lastResult
: buildErrorResult("LLM 调用失败,已达最大重试次数", conversationId, phase);
}
/**
* 单次流式调用尝试
* @return StreamResult 如果成功/降级/不可重试null 如果应该重试
*/
private StreamResult doStreamCall(ChatModel chatModel, Prompt prompt,
String conversationId, String phase,
boolean broadcast, int attempt) {
if (attempt > 0) {
long delay = Math.min(BACKOFF_BASE_MS * (1L << (attempt - 1)), BACKOFF_CAP_MS);
log.warn("[{}] Retry attempt {}/{} after {}ms for conversation {}",
phase, attempt, MAX_RETRIES, delay, conversationId);
try {
Thread.sleep(delay);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return buildErrorResult("LLM 调用被中断", conversationId, phase);
}
}
StringBuilder contentAccum = new StringBuilder();
StringBuilder thinkingAccum = new StringBuilder();
List<ToolCallAccumulator> toolCallAccumulators = new ArrayList<>();
AtomicReference<AssistantMessage> lastAssistantMessage = new AtomicReference<>();
AtomicReference<Throwable> errorRef = new AtomicReference<>();
AtomicInteger promptTokens = new AtomicInteger(0);
AtomicInteger completionTokens = new AtomicInteger(0);
CountDownLatch latch = new CountDownLatch(1);
chatModel.stream(prompt)
.doOnNext(chatResponse -> {
if (chatResponse == null || chatResponse.getResults() == null || chatResponse.getResults().isEmpty()) {
return;
}
var generation = chatResponse.getResult();
AssistantMessage msg = generation.getOutput();
lastAssistantMessage.set(msg);
// 1. 提取 content delta
String contentDelta = msg.getText();
if (contentDelta != null && !contentDelta.isEmpty()) {
contentAccum.append(contentDelta);
if (broadcast) {
broadcastDelta(conversationId, "content_delta", contentDelta);
}
}
// 2. 提取 thinking delta properties 中的 reasoningContent
String thinkingDelta = extractReasoningContent(msg);
if (thinkingDelta != null && !thinkingDelta.isEmpty()) {
thinkingAccum.append(thinkingDelta);
if (broadcast) {
broadcastDelta(conversationId, "thinking_delta", thinkingDelta);
}
}
// 3. 累积 tool calls处理分片
if (msg.hasToolCalls()) {
accumulateToolCalls(msg.getToolCalls(), toolCallAccumulators);
}
// 4. 提取 token usage通常最后一个 chunk 携带完整 usage
if (chatResponse.getMetadata() != null && chatResponse.getMetadata().getUsage() != null) {
var usage = chatResponse.getMetadata().getUsage();
if (usage.getPromptTokens() != null && usage.getPromptTokens() > 0) {
promptTokens.set(usage.getPromptTokens().intValue());
}
if (usage.getCompletionTokens() != null && usage.getCompletionTokens() > 0) {
completionTokens.set(usage.getCompletionTokens().intValue());
}
}
})
.subscribe(
chunk -> { /* 处理逻辑已在 doOnNext 中完成 */ },
err -> { errorRef.set(err); latch.countDown(); },
latch::countDown
);
// 阻塞等待流完成节点本身是同步 NodeAction 500ms 检查一次停止标志
try {
long deadlineMs = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(10);
while (!latch.await(500, TimeUnit.MILLISECONDS)) {
if (streamTracker.isStopRequested(conversationId)) {
// 不直接抛异常 先检查是否已有累积内容有则返回 partial stopped result
boolean hasContent = !contentAccum.isEmpty() || !thinkingAccum.isEmpty()
|| !toolCallAccumulators.isEmpty();
if (hasContent) {
log.info("[{}] Stop requested during LLM call with partial content " +
"(content={} chars, thinking={} chars, toolCalls={}), " +
"returning stopped partial result: conversationId={}",
phase, contentAccum.length(), thinkingAccum.length(),
toolCallAccumulators.size(), conversationId);
return assembleStoppedResult(contentAccum, thinkingAccum, toolCallAccumulators,
promptTokens.get(), completionTokens.get(), phase);
}
log.info("[{}] Stop requested during LLM call, no content accumulated, aborting: conversationId={}",
phase, conversationId);
throw new CancellationException("Stream stopped by user");
}
if (System.currentTimeMillis() > deadlineMs) {
log.warn("[{}] Stream call timed out for conversation {}", phase, conversationId);
return buildErrorResult("LLM 调用超时", conversationId, phase);
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return buildErrorResult("LLM 调用被中断", conversationId, phase);
}
Throwable error = errorRef.get();
if (error != null) {
boolean hasAccumulatedContent = !contentAccum.isEmpty() || !toolCallAccumulators.isEmpty();
if (hasAccumulatedContent) {
// ===== 优雅降级LLM 已产出部分内容 engine_overloaded 在流尾部触发 =====
log.warn("[{}] Stream error after partial content ({} chars, {} tool calls), " +
"using accumulated content as partial result: {}",
phase, contentAccum.length(), toolCallAccumulators.size(), error.getMessage());
if (broadcast) {
broadcastDelta(conversationId, "warning",
buildDeltaJson("LLM 响应中断,使用已生成的部分内容继续"));
}
return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators,
promptTokens.get(), completionTokens.get(), phase, true, error.getMessage());
}
// ===== 无内容分类错误并决定是否重试 =====
ErrorType errorType = classifyError(error);
// PTL: 不重试返回给上层 Node 处理压缩
if (errorType == ErrorType.PROMPT_TOO_LONG) {
log.warn("[{}] Prompt too long error, returning to node for compaction: {}",
phase, error.getMessage());
return buildErrorResultWithType("Prompt 过长: " + extractUserFriendlyError(error),
conversationId, phase, errorType);
}
// Auth: 不重试
if (errorType == ErrorType.AUTH_ERROR) {
log.error("[{}] Authentication error, not retrying: {}", phase, error.getMessage());
return buildErrorResultWithType("认证失败: " + extractUserFriendlyError(error),
conversationId, phase, errorType);
}
// Rate limit / Server error: 重试
if (attempt < MAX_RETRIES && (errorType == ErrorType.RATE_LIMIT || errorType == ErrorType.SERVER_ERROR)) {
log.warn("[{}] Retryable error (attempt {}/{}, type={}): {}",
phase, attempt, MAX_RETRIES, errorType, error.getMessage());
return null; // 返回 null 触发重试
}
// 不可重试或已耗尽重试
log.error("[{}] LLM call failed after {} attempts for conversation {}: {}",
phase, attempt + 1, conversationId, error.getMessage());
return buildErrorResultWithType("LLM 调用失败: " + extractUserFriendlyError(error),
conversationId, phase, errorType);
}
// ===== 成功 =====
return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators,
promptTokens.get(), completionTokens.get(), phase, false, null);
}
/** 组装 stopped partial 结果(用户主动停止,有已累积内容) */
private StreamResult assembleStoppedResult(StringBuilder contentAccum, StringBuilder thinkingAccum,
List<ToolCallAccumulator> toolCallAccumulators,
int promptTok, int completionTok, String phase) {
List<AssistantMessage.ToolCall> finalToolCalls = buildFinalToolCalls(toolCallAccumulators);
String fullContent = contentAccum.toString();
String fullThinking = thinkingAccum.toString();
// Fallback: <think> 标签提取
if (fullThinking.isEmpty() && fullContent.contains("<think>")) {
var extracted = extractThinkTags(fullContent);
if (!extracted.thinking.isEmpty()) {
fullThinking = extracted.thinking;
fullContent = extracted.content;
}
}
AssistantMessage assembledMessage = !finalToolCalls.isEmpty()
? AssistantMessage.builder().content(fullContent).toolCalls(finalToolCalls).build()
: new AssistantMessage(fullContent);
return new StreamResult(fullContent, fullThinking, assembledMessage,
finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok,
true, null, ErrorType.NONE, true);
}
/** 组装最终 StreamResult成功或 partial */
private StreamResult assembleResult(StringBuilder contentAccum, StringBuilder thinkingAccum,
List<ToolCallAccumulator> toolCallAccumulators,
int promptTok, int completionTok,
String phase, boolean partial, String errorMsg) {
List<AssistantMessage.ToolCall> finalToolCalls = buildFinalToolCalls(toolCallAccumulators);
String fullContent = contentAccum.toString();
String fullThinking = thinkingAccum.toString();
// Fallback: <think> 标签提取
if (fullThinking.isEmpty() && fullContent.contains("<think>")) {
var extracted = extractThinkTags(fullContent);
if (!extracted.thinking.isEmpty()) {
fullThinking = extracted.thinking;
fullContent = extracted.content;
log.debug("[{}] Extracted <think> tags from content: {} thinking chars, {} content chars",
phase, fullThinking.length(), fullContent.length());
}
}
AssistantMessage assembledMessage;
if (!finalToolCalls.isEmpty()) {
assembledMessage = AssistantMessage.builder()
.content(fullContent)
.toolCalls(finalToolCalls)
.build();
} else {
assembledMessage = new AssistantMessage(fullContent);
}
return new StreamResult(fullContent, fullThinking, assembledMessage,
finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok,
partial, errorMsg, ErrorType.NONE);
}
/** 构建纯错误 StreamResult无任何内容 */
private StreamResult buildErrorResult(String errorMsg, String conversationId, String phase) {
log.error("[{}] Building error result for conversation {}: {}", phase, conversationId, errorMsg);
if (streamTracker != null && conversationId != null) {
broadcastDelta(conversationId, "warning",
buildDeltaJson(errorMsg));
}
AssistantMessage errorMessage = new AssistantMessage("[错误] " + errorMsg);
return new StreamResult("[错误] " + errorMsg, "", errorMessage,
List.of(), false, 0, 0, false, errorMsg, ErrorType.UNKNOWN);
}
/** 构建带错误类型的 StreamResult */
private StreamResult buildErrorResultWithType(String errorMsg, String conversationId,
String phase, ErrorType errorType) {
log.error("[{}] Building typed error result for conversation {}: {} (type={})",
phase, conversationId, errorMsg, errorType);
if (streamTracker != null && conversationId != null) {
broadcastDelta(conversationId, "warning", buildDeltaJson(errorMsg));
// 广播结构化 error 事件供前端展示错误卡片
String errorJson = buildErrorEventJson(errorMsg, conversationId, errorType);
streamTracker.broadcast(conversationId, "error", errorJson);
}
AssistantMessage errorMessage = new AssistantMessage("[错误] " + errorMsg);
return new StreamResult("[错误] " + errorMsg, "", errorMessage,
List.of(), false, 0, 0, false, errorMsg, errorType);
}
/** 构建 error 事件的 JSON payload */
private static String buildErrorEventJson(String message, String conversationId, ErrorType errorType) {
StringBuilder sb = new StringBuilder("{");
sb.append("\"message\":\"");
appendJsonEscaped(sb, message);
sb.append("\",\"conversationId\":\"");
appendJsonEscaped(sb, conversationId);
sb.append("\",\"errorType\":\"");
sb.append(errorType.name());
sb.append("\"}");
return sb.toString();
}
/** JSON 字符串转义辅助 */
private static void appendJsonEscaped(StringBuilder sb, String value) {
if (value == null) return;
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (c == '"') sb.append("\\\"");
else if (c == '\\') sb.append("\\\\");
else if (c == '\n') sb.append("\\n");
else if (c == '\t') sb.append("\\t");
else if (c == '\r') sb.append("\\r");
else sb.append(c);
}
}
/** 从异常链提取用户友好的错误信息 */
private static String extractUserFriendlyError(Throwable error) {
String msg = error.getMessage();
if (msg == null) return error.getClass().getSimpleName();
// Jackson 反序列化错误提取关键信息
if (msg.contains("engine_overloaded")) return "模型服务过载,请稍后重试";
if (msg.contains("rate_limit") || msg.contains("429")) return "请求频率过高,请稍后重试";
if (msg.contains("timeout") || msg.contains("Timeout")) return "请求超时,请重试";
if (msg.contains("502") || msg.contains("503") || msg.contains("504")) return "模型服务暂时不可用";
// 截断过长的原始消息
return msg.length() > 100 ? msg.substring(0, 100) + "..." : msg;
}
/**
* LLM 调用错误类型分类
*/
public enum ErrorType {
/** 无错误 */
NONE,
/** 速率限制 (429) */
RATE_LIMIT,
/** 服务端错误 (5xx, timeout) */
SERVER_ERROR,
/** Prompt 过长 (context length exceeded) */
PROMPT_TOO_LONG,
/** 认证错误 */
AUTH_ERROR,
/** 其他未知错误 */
UNKNOWN
}
/**
* 流式调用结果
*/
public record StreamResult(
/** 完整内容文本 */
String text,
/** 完整 thinking 文本 */
String thinking,
/** 重建的完整 AssistantMessage含 toolCalls */
AssistantMessage assistantMessage,
/** 完整工具调用列表 */
List<AssistantMessage.ToolCall> toolCalls,
/** 是否包含工具调用 */
boolean hasToolCalls,
/** 本次调用消耗的 prompt tokens */
int promptTokens,
/** 本次调用消耗的 completion tokens */
int completionTokens,
/** 结果是否不完整LLM 中途断开但已有部分内容) */
boolean partial,
/** 错误信息(非空表示调用失败,但可能仍有 partial 内容可用) */
String errorMessage,
/** 错误类型分类 */
ErrorType errorType,
/** 用户主动停止stopRequested导致的提前返回 */
boolean stopped
) {
/** 兼容旧调用方 — 无 partial/error/stopped 的正常结果 */
public StreamResult(String text, String thinking, AssistantMessage assistantMessage,
List<AssistantMessage.ToolCall> toolCalls, boolean hasToolCalls,
int promptTokens, int completionTokens) {
this(text, thinking, assistantMessage, toolCalls, hasToolCalls,
promptTokens, completionTokens, false, null, ErrorType.NONE, false);
}
/** 兼容 10-arg 调用点 */
public StreamResult(String text, String thinking, AssistantMessage assistantMessage,
List<AssistantMessage.ToolCall> toolCalls, boolean hasToolCalls,
int promptTokens, int completionTokens,
boolean partial, String errorMessage, ErrorType errorType) {
this(text, thinking, assistantMessage, toolCalls, hasToolCalls,
promptTokens, completionTokens, partial, errorMessage, errorType, false);
}
/** 是否有不可忽略的错误(无内容 + 有错误) */
public boolean hasFatalError() {
return errorMessage != null && (text == null || text.isBlank()) && !hasToolCalls;
}
/** 是否为 Prompt 过长错误 */
public boolean isPromptTooLong() {
return errorType == ErrorType.PROMPT_TOO_LONG;
}
/** 是否有任何可保存的内容text/thinking/toolCalls */
public boolean hasAnyContent() {
return (text != null && !text.isBlank())
|| (thinking != null && !thinking.isBlank())
|| hasToolCalls;
}
}
// ==================== 内部方法 ====================
/**
* AssistantMessage properties 中提取 reasoningContent
* <p>
* Spring AI 1.1.3 OpenAiChatModel 在流式路径中会将 delta.reasoning_content
* 放入 properties "reasoningContent" key
*/
private String extractReasoningContent(AssistantMessage msg) {
Map<String, Object> metadata = msg.getMetadata();
if (metadata == null) {
return null;
}
Object rc = metadata.get("reasoningContent");
if (rc instanceof String s && !s.isEmpty()) {
return s;
}
return null;
}
/**
* 广播 delta 事件content_delta / thinking_delta
*/
private void broadcastDelta(String conversationId, String eventName, String delta) {
if (streamTracker == null || conversationId == null || conversationId.isEmpty()) {
return;
}
// 手动构建 JSON 避免序列化开销格式与 ChatController.broadcastEvent 一致
String json = buildDeltaJson(delta);
streamTracker.broadcast(conversationId, eventName, json);
}
/**
* 构建 {"delta":"..."} JSON
*/
private static String buildDeltaJson(String delta) {
StringBuilder sb = new StringBuilder("{\"delta\":\"");
for (int k = 0; k < delta.length(); k++) {
char c = delta.charAt(k);
if (c == '"') sb.append("\\\"");
else if (c == '\\') sb.append("\\\\");
else if (c == '\n') sb.append("\\n");
else if (c == '\t') sb.append("\\t");
else if (c == '\r') sb.append("\\r");
else sb.append(c);
}
sb.append("\"}");
return sb.toString();
}
/**
* 累积 tool call 分片
* <p>
* 流式模式下 tool calls 可能分多个 chunk 到来
* - 第一个 chunk 携带 idname 和部分 arguments
* - 后续 chunk 只有 arguments 增量
* <p>
* 采用增量累积方式合并分片 tool_call
*/
private void accumulateToolCalls(List<AssistantMessage.ToolCall> chunkToolCalls,
List<ToolCallAccumulator> accumulators) {
for (AssistantMessage.ToolCall tc : chunkToolCalls) {
if (tc.id() != null && !tc.id().isEmpty()) {
// 新的 tool call 或完整 tool call
ToolCallAccumulator existing = findAccumulator(accumulators, tc.id());
if (existing != null) {
// 追加 arguments
if (tc.arguments() != null) {
existing.arguments.append(tc.arguments());
}
} else {
ToolCallAccumulator acc = new ToolCallAccumulator();
acc.id = tc.id();
acc.type = tc.type();
acc.name = tc.name();
acc.arguments = new StringBuilder(tc.arguments() != null ? tc.arguments() : "");
accumulators.add(acc);
}
} else if (!accumulators.isEmpty()) {
// id chunk追加到最后一个 accumulator arguments
ToolCallAccumulator last = accumulators.get(accumulators.size() - 1);
if (tc.arguments() != null) {
last.arguments.append(tc.arguments());
}
if (tc.name() != null && !tc.name().isEmpty() && (last.name == null || last.name.isEmpty())) {
last.name = tc.name();
}
}
}
}
private ToolCallAccumulator findAccumulator(List<ToolCallAccumulator> accumulators, String id) {
for (ToolCallAccumulator acc : accumulators) {
if (id.equals(acc.id)) {
return acc;
}
}
return null;
}
private List<AssistantMessage.ToolCall> buildFinalToolCalls(List<ToolCallAccumulator> accumulators) {
if (accumulators.isEmpty()) {
return List.of();
}
List<AssistantMessage.ToolCall> result = new ArrayList<>();
for (ToolCallAccumulator acc : accumulators) {
result.add(new AssistantMessage.ToolCall(
acc.id,
acc.type != null ? acc.type : "function",
acc.name,
acc.arguments.toString()));
}
return result;
}
private static class ToolCallAccumulator {
String id;
String type;
String name;
StringBuilder arguments = new StringBuilder();
}
// ==================== <think> 标签 fallback 解析 ====================
private record ThinkExtracted(String thinking, String content) {}
/**
* 从内容中提取 &lt;think&gt;...&lt;/think&gt; 标签内的文本作为 thinking
* 仅作为 fallback当模型不支持结构化 reasoningContent 时使用
*/
private static ThinkExtracted extractThinkTags(String content) {
StringBuilder thinking = new StringBuilder();
StringBuilder cleaned = new StringBuilder();
int i = 0;
while (i < content.length()) {
int tagStart = content.indexOf("<think>", i);
if (tagStart < 0) {
cleaned.append(content, i, content.length());
break;
}
cleaned.append(content, i, tagStart);
int tagEnd = content.indexOf("</think>", tagStart);
if (tagEnd < 0) {
// 未闭合的 <think> 标签将剩余部分视为 thinking
thinking.append(content, tagStart + 7, content.length());
break;
}
thinking.append(content, tagStart + 7, tagEnd);
i = tagEnd + 8;
}
return new ThinkExtracted(thinking.toString().trim(), cleaned.toString().trim());
}
}

View File

@ -0,0 +1,405 @@
package vip.mate.agent.graph;
import com.alibaba.cloud.ai.graph.CompiledGraph;
import com.alibaba.cloud.ai.graph.NodeOutput;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.RunnableConfig;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import vip.mate.agent.AgentService;
import vip.mate.agent.AgentState;
import vip.mate.agent.BaseAgent;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.StructuredStreamCapable;
import vip.mate.agent.context.ConversationWindowManager;
import vip.mate.agent.graph.state.MateClawStateKeys;
import vip.mate.workspace.conversation.ConversationService;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static vip.mate.agent.graph.state.MateClawStateKeys.*;
/**
* 基于 StateGraph v2 ReAct Agent
* <p>
* 使用 spring-ai-alibaba-graph-core StateGraph 引擎
* 实现显式可控的 Thought Action Observation 循环
* SummarizingLimitExceeded FinalAnswer 节点
* <p>
* 关键特性
* - 迭代次数强制控制maxIterations 真正生效
* - ToolGuard 安全拦截 ActionNode 中执行
* - 工具调用过程可观测
* - Summarizing 阶段收束冗长上下文
* - 超限友好提示
* - 结构化生命周期日志
* <p>
* content_delta thinking_delta 由节点内 {@link NodeStreamingChatHelper} 直推
* chatStructuredStream() 只处理 phase/tool/事件等结构化事件
* 不再从 NodeOutput 二次整段下发已流式推送的内容
*
* @author MateClaw Team
*/
@Slf4j
public class StateGraphReActAgent extends BaseAgent implements StructuredStreamCapable {
private final CompiledGraph compiledGraph;
private final org.springframework.ai.chat.model.ChatModel chatModel;
private final ConversationWindowManager conversationWindowManager;
public StateGraphReActAgent(ChatClient chatClient, ConversationService conversationService,
CompiledGraph compiledGraph,
org.springframework.ai.chat.model.ChatModel chatModel,
ConversationWindowManager conversationWindowManager) {
super(chatClient, conversationService);
this.compiledGraph = compiledGraph;
this.chatModel = chatModel;
this.conversationWindowManager = conversationWindowManager;
}
@Override
public String chat(String userMessage, String conversationId) {
setState(AgentState.RUNNING);
try {
log.info("[{}] StateGraph chat: conversationId={}", agentName, conversationId);
Map<String, Object> inputs = buildInitialState(userMessage, conversationId);
Optional<OverAllState> result = compiledGraph.invoke(inputs);
return result
.flatMap(s -> s.<String>value(FINAL_ANSWER))
.orElse("未能生成回答。");
} catch (Exception e) {
log.error("[{}] StateGraph chat failed: {}", agentName, e.getMessage(), e);
setState(AgentState.ERROR);
throw new RuntimeException("对话失败:" + e.getMessage(), e);
} finally {
if (getState() != AgentState.ERROR) {
setState(AgentState.IDLE);
}
}
}
@Override
public Flux<String> chatStream(String userMessage, String conversationId) {
setState(AgentState.RUNNING);
try {
log.info("[{}] StateGraph stream: conversationId={}", agentName, conversationId);
Map<String, Object> inputs = buildInitialState(userMessage, conversationId);
String threadId = UUID.randomUUID().toString();
RunnableConfig config = RunnableConfig.builder().threadId(threadId).build();
return compiledGraph.stream(inputs, config)
.filter(this::hasFinalAnswer)
.map(this::extractFinalAnswer)
.filter(content -> content != null && !content.isEmpty())
.next() // 只取第一个 finalAnswer避免多个节点重复 emit
.flux()
.doOnComplete(() -> setState(AgentState.IDLE))
.doOnError(e -> {
log.error("[{}] StateGraph stream error: {}", agentName, e.getMessage());
setState(AgentState.ERROR);
});
} catch (Exception e) {
log.error("[{}] StateGraph stream setup failed: {}", agentName, e.getMessage(), e);
setState(AgentState.ERROR);
return Flux.error(e);
}
}
@Override
public String execute(String goal, String conversationId) {
return chat(goal, conversationId);
}
@Override
public String chatWithReplay(String userMessage, String conversationId, String toolCallPayload) {
setState(AgentState.RUNNING);
try {
log.info("[{}] StateGraph chatWithReplay: conversationId={}", agentName, conversationId);
Map<String, Object> inputs = buildInitialState(userMessage, conversationId);
if (toolCallPayload != null && !toolCallPayload.isEmpty()) {
inputs.put(FORCED_TOOL_CALL, toolCallPayload);
}
Optional<OverAllState> result = compiledGraph.invoke(inputs);
return result
.flatMap(s -> s.<String>value(FINAL_ANSWER))
.orElse("工具已执行。");
} catch (Exception e) {
log.error("[{}] StateGraph chatWithReplay failed: {}", agentName, e.getMessage(), e);
setState(AgentState.ERROR);
throw new RuntimeException("重放执行失败:" + e.getMessage(), e);
} finally {
if (getState() != AgentState.ERROR) {
setState(AgentState.IDLE);
}
}
}
@Override
public Flux<AgentService.StreamDelta> chatWithReplayStream(String userMessage, String conversationId,
String toolCallPayload) {
return chatWithReplayStream(userMessage, conversationId, toolCallPayload, "");
}
@Override
public Flux<AgentService.StreamDelta> chatWithReplayStream(String userMessage, String conversationId,
String toolCallPayload, String requesterId) {
setState(AgentState.RUNNING);
try {
log.info("[{}] StateGraph chatWithReplayStream: conversationId={}", agentName, conversationId);
Map<String, Object> inputs = buildInitialState(userMessage, conversationId);
inputs.put(REQUESTER_ID, requesterId != null ? requesterId : "");
if (toolCallPayload != null && !toolCallPayload.isEmpty()) {
inputs.put(FORCED_TOOL_CALL, toolCallPayload);
}
String threadId = UUID.randomUUID().toString();
RunnableConfig config = RunnableConfig.builder().threadId(threadId).build();
AtomicInteger sentEventCount = new AtomicInteger(0);
AtomicInteger finalPromptTokens = new AtomicInteger(0);
AtomicInteger finalCompletionTokens = new AtomicInteger(0);
AtomicReference<String> finalModelName = new AtomicReference<>("");
AtomicReference<String> finalProviderId = new AtomicReference<>("");
// 防重保护 chatStructuredStream
AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false);
AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false);
return compiledGraph.stream(inputs, config)
.flatMapIterable(output -> {
List<AgentService.StreamDelta> deltas = new ArrayList<>();
List<GraphEventPublisher.GraphEvent> allEvents = GraphEventPublisher.extractEvents(output);
int newStart = sentEventCount.get();
if (newStart < allEvents.size()) {
for (int i = newStart; i < allEvents.size(); i++) {
var event = allEvents.get(i);
deltas.add(AgentService.StreamDelta.event(event.type(), event.data()));
}
sentEventCount.set(allEvents.size());
}
boolean contentAlreadyStreamed = output.state().value(CONTENT_STREAMED, false);
boolean thinkingAlreadyStreamed = output.state().value(THINKING_STREAMED, false);
if (hasFinalAnswer(output) && finalAnswerEmitted.compareAndSet(false, true)) {
String answer = extractFinalAnswer(output);
if (answer != null && !answer.isEmpty()) {
deltas.add(contentAlreadyStreamed
? AgentService.StreamDelta.persistOnly(answer, null)
: new AgentService.StreamDelta(answer, null));
}
}
String thinking = extractFinalThinking(output);
if (thinking != null && !thinking.isEmpty()
&& finalThinkingEmitted.compareAndSet(false, true)) {
deltas.add(thinkingAlreadyStreamed
? AgentService.StreamDelta.persistOnly(null, thinking)
: new AgentService.StreamDelta(null, thinking));
}
finalPromptTokens.set(output.state().value(PROMPT_TOKENS, 0));
finalCompletionTokens.set(output.state().value(COMPLETION_TOKENS, 0));
finalModelName.set(output.state().value(RUNTIME_MODEL_NAME, ""));
finalProviderId.set(output.state().value(RUNTIME_PROVIDER_ID, ""));
return deltas;
})
.concatWith(Mono.fromSupplier(() -> {
if (finalPromptTokens.get() > 0 || finalCompletionTokens.get() > 0) {
return AgentService.StreamDelta.event("_usage_final", Map.of(
"promptTokens", finalPromptTokens.get(),
"completionTokens", finalCompletionTokens.get(),
"runtimeModelName", finalModelName.get(),
"runtimeProviderId", finalProviderId.get()
));
}
return null;
}).flatMapMany(d -> d != null ? Flux.just(d) : Flux.empty()))
.doOnComplete(() -> setState(AgentState.IDLE))
.doOnError(e -> {
log.error("[{}] StateGraph replay stream error: {}", agentName, e.getMessage());
setState(AgentState.ERROR);
});
} catch (Exception e) {
setState(AgentState.ERROR);
return Flux.error(e);
}
}
@Override
public Flux<AgentService.StreamDelta> chatStructuredStream(String userMessage, String conversationId) {
return chatStructuredStream(userMessage, conversationId, "");
}
@Override
public Flux<AgentService.StreamDelta> chatStructuredStream(String userMessage, String conversationId,
String requesterId) {
setState(AgentState.RUNNING);
try {
log.info("[{}] StateGraph structured stream: conversationId={}", agentName, conversationId);
Map<String, Object> inputs = buildInitialState(userMessage, conversationId);
inputs.put(REQUESTER_ID, requesterId != null ? requesterId : "");
String threadId = UUID.randomUUID().toString();
RunnableConfig config = RunnableConfig.builder().threadId(threadId).build();
// Lambda 内需要维护已发送事件偏移这里只是局部可变计数器不涉及跨会话共享
AtomicInteger sentEventCount = new AtomicInteger(0);
// Token usage 追踪每次 NodeOutput 更新最新累计值最后一次即最终值
AtomicInteger finalPromptTokens = new AtomicInteger(0);
AtomicInteger finalCompletionTokens = new AtomicInteger(0);
AtomicReference<String> finalModelName = new AtomicReference<>("");
AtomicReference<String> finalProviderId = new AtomicReference<>("");
// 防重保护StateGraph 对每个节点都 emit NodeOutputFINAL_ANSWER 一旦写入后续节点都携带
// compareAndSet 保证只取第一次避免 content/thinking 被重复追加
AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false);
AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false);
return compiledGraph.stream(inputs, config)
.flatMapIterable(output -> {
List<AgentService.StreamDelta> deltas = new ArrayList<>();
// 1. 提取所有累积的事件只发送新增部分
List<GraphEventPublisher.GraphEvent> allEvents = GraphEventPublisher.extractEvents(output);
int newStart = sentEventCount.get();
if (newStart < allEvents.size()) {
for (int i = newStart; i < allEvents.size(); i++) {
var event = allEvents.get(i);
deltas.add(AgentService.StreamDelta.event(event.type(), event.data()));
}
sentEventCount.set(allEvents.size());
}
// 2. 内容始终通过 StreamDelta 发给 Accumulator 用于持久化
// 已由 NodeStreamingChatHelper 广播过的标记 persistOnly避免前端收到重复 content_delta
boolean contentAlreadyStreamed = output.state()
.value(CONTENT_STREAMED, false);
boolean thinkingAlreadyStreamed = output.state()
.value(THINKING_STREAMED, false);
if (hasFinalAnswer(output) && finalAnswerEmitted.compareAndSet(false, true)) {
String answer = extractFinalAnswer(output);
if (answer != null && !answer.isEmpty()) {
deltas.add(contentAlreadyStreamed
? AgentService.StreamDelta.persistOnly(answer, null)
: new AgentService.StreamDelta(answer, null));
}
}
String thinking = extractFinalThinking(output);
if (thinking != null && !thinking.isEmpty()
&& finalThinkingEmitted.compareAndSet(false, true)) {
deltas.add(thinkingAlreadyStreamed
? AgentService.StreamDelta.persistOnly(null, thinking)
: new AgentService.StreamDelta(null, thinking));
}
// 3. 更新最新累计 token usage
finalPromptTokens.set(output.state().value(PROMPT_TOKENS, 0));
finalCompletionTokens.set(output.state().value(COMPLETION_TOKENS, 0));
finalModelName.set(output.state().value(RUNTIME_MODEL_NAME, ""));
finalProviderId.set(output.state().value(RUNTIME_PROVIDER_ID, ""));
return deltas;
})
// 流正常完成后追加内部 usage 事件
.concatWith(Mono.fromSupplier(() -> {
if (finalPromptTokens.get() > 0 || finalCompletionTokens.get() > 0) {
return AgentService.StreamDelta.event("_usage_final", Map.of(
"promptTokens", finalPromptTokens.get(),
"completionTokens", finalCompletionTokens.get(),
"runtimeModelName", finalModelName.get(),
"runtimeProviderId", finalProviderId.get()
));
}
return null;
}).flatMapMany(d -> d != null ? Flux.just(d) : Flux.empty()))
.doOnComplete(() -> setState(AgentState.IDLE))
.doOnError(e -> {
log.error("[{}] StateGraph structured stream error: {}", agentName, e.getMessage());
setState(AgentState.ERROR);
});
} catch (Exception e) {
setState(AgentState.ERROR);
return Flux.error(e);
}
}
private Map<String, Object> buildInitialState(String userMessage, String conversationId) {
// 加载会话历史
List<Message> historyMessages = buildConversationHistory(conversationId, userMessage);
// 上下文窗口管理裁剪超出模型 context window 的历史含当前消息预算
if (conversationWindowManager != null) {
historyMessages = conversationWindowManager.fitToWindow(
historyMessages,
systemPrompt != null ? systemPrompt : "",
userMessage,
maxInputTokens,
chatModel,
conversationId);
}
List<Message> messages = new ArrayList<>(historyMessages);
messages.add(new UserMessage(userMessage));
Map<String, Object> inputs = new HashMap<>();
// 输入
inputs.put(USER_MESSAGE, userMessage);
inputs.put(CONVERSATION_ID, conversationId);
inputs.put(AGENT_ID, agentId != null ? agentId : "");
inputs.put(SYSTEM_PROMPT, systemPrompt != null ? systemPrompt : "你是一个有帮助的AI助手。");
inputs.put(MESSAGES, messages);
// 迭代控制
inputs.put(MAX_ITERATIONS, maxIterations);
inputs.put(CURRENT_ITERATION, 0);
// 初始化新字段
inputs.put(TOOL_CALL_COUNT, 0);
inputs.put(ERROR_COUNT, 0);
inputs.put(SHOULD_SUMMARIZE, false);
inputs.put(LIMIT_EXCEEDED, false);
inputs.put(CONTENT_STREAMED, false);
inputs.put(THINKING_STREAMED, false);
inputs.put(AWAITING_APPROVAL, false);
inputs.put(STREAMED_CONTENT, "");
inputs.put(STREAMED_THINKING, "");
inputs.put(REQUESTER_ID, "");
inputs.put(FORCED_TOOL_CALL, "");
inputs.put(PROMPT_TOKENS, 0);
inputs.put(COMPLETION_TOKENS, 0);
inputs.put(RUNTIME_MODEL_NAME, modelName != null ? modelName : "");
inputs.put(RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
inputs.put(TRACE_ID, UUID.randomUUID().toString().substring(0, 8));
return inputs;
}
private boolean hasFinalAnswer(NodeOutput output) {
if (output == null || output.state() == null) {
return false;
}
return output.state().<String>value(FINAL_ANSWER)
.filter(s -> !s.isEmpty())
.isPresent();
}
private String extractFinalAnswer(NodeOutput output) {
return output.state().<String>value(FINAL_ANSWER).orElse("");
}
private String extractFinalThinking(NodeOutput output) {
if (output == null || output.state() == null) {
return null;
}
return output.state().<String>value(FINAL_THINKING).orElse(null);
}
}

View File

@ -0,0 +1,67 @@
package vip.mate.agent.graph.edge;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.EdgeAction;
import lombok.extern.slf4j.Slf4j;
import vip.mate.agent.graph.state.MateClawStateAccessor;
import static vip.mate.agent.graph.state.MateClawStateKeys.*;
/**
* 观察路由3 路分支迭代控制核心
* <p>
* 决定 ReAct 循环在 Observation 后的走向
* <ol>
* <li>迭代超限 limitExceededNode强制终止</li>
* <li>需要总结 summarizingNode观察够多/结果太长</li>
* <li>继续循环 reasoningNode</li>
* </ol>
* <p>
* 这是 maxIterations 字段的核心执行点
*
* @author MateClaw Team
*/
@Slf4j
public class ObservationDispatcher implements EdgeAction {
@Override
public String apply(OverAllState state) throws Exception {
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
int currentIteration = accessor.iterationCount();
int maxIterations = accessor.maxIterations();
// 0. 审批等待检查 Graph 必须立即终止 Replay 继续
if (accessor.awaitingApproval()) {
log.info("[ObservationDispatcher] AWAITING_APPROVAL=true, terminating graph " +
"(replay will continue after user decision), iteration {}/{}", currentIteration, maxIterations);
return FINAL_ANSWER_NODE;
}
// 1. 迭代超限检查
if (currentIteration >= maxIterations) {
log.warn("[ObservationDispatcher] Max iterations ({}) reached at iteration {}, " +
"routing to limitExceededNode", maxIterations, currentIteration);
return LIMIT_EXCEEDED_NODE;
}
// 2. 错误检查
if (accessor.hasError()) {
log.warn("[ObservationDispatcher] Error detected, routing to limitExceededNode: {}",
accessor.error());
return LIMIT_EXCEEDED_NODE;
}
// 3. 需要总结ObservationNode 已判断并设置 shouldSummarize
if (accessor.shouldSummarize()) {
log.info("[ObservationDispatcher] shouldSummarize=true, routing to summarizingNode " +
"(iteration {}/{}, observations={} entries)",
currentIteration, maxIterations, accessor.observationHistory().size());
return SUMMARIZING_NODE;
}
// 4. 继续循环
log.debug("[ObservationDispatcher] Continuing loop, iteration {}/{}", currentIteration, maxIterations);
return REASONING_NODE;
}
}

View File

@ -0,0 +1,53 @@
package vip.mate.agent.graph.edge;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.EdgeAction;
import lombok.extern.slf4j.Slf4j;
import vip.mate.agent.graph.state.MateClawStateAccessor;
import static vip.mate.agent.graph.state.MateClawStateKeys.*;
/**
* 推理路由4 路分支
* <p>
* 根据 ReasoningNode 产出的状态决定下一步去向
* <ol>
* <li>迭代超限 limitExceededNode最高优先级</li>
* <li>需要工具调用 actionNode</li>
* <li>需要总结压缩 summarizingNode</li>
* <li>可直接回答 finalAnswerNode</li>
* </ol>
*
* @author MateClaw Team
*/
@Slf4j
public class ReasoningDispatcher implements EdgeAction {
@Override
public String apply(OverAllState state) throws Exception {
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
// 1. 超限检查优先
if (accessor.isLimitReached()) {
log.warn("[ReasoningDispatcher] Iteration limit reached ({}/{}), routing to limitExceededNode",
accessor.iterationCount(), accessor.maxIterations());
return LIMIT_EXCEEDED_NODE;
}
// 2. 工具调用
if (accessor.needsToolCall()) {
log.debug("[ReasoningDispatcher] Routing to actionNode (tool call needed)");
return ACTION_NODE;
}
// 3. 需要总结上下文过长最终回答前先压缩
if (accessor.shouldSummarize()) {
log.info("[ReasoningDispatcher] Routing to summarizingNode (observation context too large)");
return SUMMARIZING_NODE;
}
// 4. 直接回答
log.debug("[ReasoningDispatcher] Routing to finalAnswerNode (direct answer)");
return FINAL_ANSWER_NODE;
}
}

View File

@ -0,0 +1,487 @@
package vip.mate.agent.graph.executor;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.tool.ToolCallback;
import vip.mate.agent.AgentToolSet;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.approval.ApprovalWorkflowService;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.tool.guard.ToolExecutionGuardHelper;
import vip.mate.tool.guard.ToolGuard;
import vip.mate.tool.guard.ToolGuardResult;
import vip.mate.tool.guard.model.GuardEvaluation;
import vip.mate.tool.guard.model.ToolInvocationContext;
import vip.mate.tool.guard.service.ToolGuardService;
import java.util.*;
import java.util.Collections;
import java.util.concurrent.*;
/**
* 统一工具执行器共享于 ActionNode StepExecutionNode
* <p>
* 两阶段执行模型
* <ol>
* <li><b>Phase 1 顺序 Guard + 分段</b>按原始顺序逐个做 JSON 校验 ToolGuard barrier 判定 callback 查找 + concurrencySafe 分类</li>
* <li><b>Phase 2 分段并发执行</b>barrier 之前的 safe 工具并行执行unsafe 工具独占执行结果按原始顺序返回</li>
* </ol>
* <p>
* 审批有前序语义如果第 N 个工具需要审批 N+1N+2 个工具不会执行
*
* @author MateClaw Team
*/
@Slf4j
public class ToolExecutionExecutor {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static final ExecutorService TOOL_EXECUTOR = Executors.newFixedThreadPool(
Math.max(4, Runtime.getRuntime().availableProcessors()),
r -> {
Thread t = new Thread(r, "tool-executor");
t.setDaemon(true);
return t;
});
/** 默认不安全工具列表(写操作、浏览器交互等) */
private static final Set<String> DEFAULT_UNSAFE_TOOLS = Set.of(
"browser_use", "BrowserUseTool", "write_file", "edit_file"
);
/** 工具结果最大字符数(防止超长结果膨胀 ToolResponseMessage → 撑爆 LLM 上下文) */
private static final int MAX_TOOL_RESULT_CHARS = 8000;
private final Map<String, ToolCallback> toolCallbackMap;
private final ToolGuardService toolGuardService;
private final ToolGuard toolGuard; // legacy fallback
private final ApprovalWorkflowService approvalService;
private final ChatStreamTracker streamTracker;
public ToolExecutionExecutor(AgentToolSet toolSet, ToolGuardService toolGuardService,
ApprovalWorkflowService approvalService, ChatStreamTracker streamTracker) {
this.toolCallbackMap = toolSet.callbackByName();
this.toolGuardService = toolGuardService;
this.toolGuard = null;
this.approvalService = approvalService;
this.streamTracker = streamTracker;
}
public ToolExecutionExecutor(AgentToolSet toolSet, ToolGuard toolGuard,
ApprovalWorkflowService approvalService, ChatStreamTracker streamTracker) {
this.toolCallbackMap = toolSet.callbackByName();
this.toolGuardService = null;
this.toolGuard = toolGuard;
this.approvalService = approvalService;
this.streamTracker = streamTracker;
}
/**
* 执行工具调用列表
*
* @param toolCalls LLM 请求的工具调用列表
* @param conversationId 会话 ID
* @param agentId Agent ID
* @param isReplay 是否为审批通过后的重放模式跳过 ToolGuard
* @return 执行结果
*/
public ToolExecutionResult execute(List<AssistantMessage.ToolCall> toolCalls,
String conversationId, String agentId,
boolean isReplay) {
return execute(toolCalls, conversationId, agentId, isReplay, "");
}
public ToolExecutionResult execute(List<AssistantMessage.ToolCall> toolCalls,
String conversationId, String agentId,
boolean isReplay, String requesterId) {
List<ToolResponseMessage.ToolResponse> allResponses = new ArrayList<>();
List<GraphEventPublisher.GraphEvent> events = Collections.synchronizedList(new ArrayList<>());
events.add(GraphEventPublisher.phase("action", Map.of("toolCount", toolCalls.size())));
// Phase 1: 顺序 Guard + 分段
List<PreparedToolCall> preparedCalls = new ArrayList<>();
ApprovalBarrier barrier = null;
for (int i = 0; i < toolCalls.size(); i++) {
AssistantMessage.ToolCall toolCall = toolCalls.get(i);
String toolName = toolCall.name();
String arguments = toolCall.arguments();
events.add(GraphEventPublisher.toolStart(toolName, arguments));
// 1. JSON 校验
if (arguments != null && !arguments.isBlank()) {
try {
OBJECT_MAPPER.readTree(arguments);
} catch (Exception jsonEx) {
log.warn("[ToolExecutor] Tool {} arguments invalid/truncated JSON (len={}): {}",
toolName, arguments.length(), jsonEx.getMessage());
String truncationError = normalizeToolExecutionError(jsonEx);
events.add(GraphEventPublisher.toolComplete(toolName, truncationError, false));
allResponses.add(new ToolResponseMessage.ToolResponse(
toolCall.id(), toolName, truncationError));
continue;
}
}
// 2. ToolGuard 安全检查replay 模式跳过
if (!isReplay) {
GuardDecision decision = evaluateGuard(toolCall, toolName, arguments,
conversationId, agentId, toolCalls, i, events, requesterId);
if (decision.blocked) {
allResponses.add(new ToolResponseMessage.ToolResponse(
toolCall.id(), toolName, decision.response));
continue;
}
if (decision.needsApproval) {
// Barrier: 当前工具创建审批后续工具不执行
allResponses.add(new ToolResponseMessage.ToolResponse(
toolCall.id(), toolName, decision.response));
// 标记后续工具为等待审批
for (int j = i + 1; j < toolCalls.size(); j++) {
AssistantMessage.ToolCall remaining = toolCalls.get(j);
allResponses.add(new ToolResponseMessage.ToolResponse(
remaining.id(), remaining.name(),
"[⏳ 等待审批] 前序工具等待审批中,本工具暂缓执行。"));
}
barrier = new ApprovalBarrier(decision.pendingId, toolName);
break;
}
} else {
log.info("[ToolExecutor] Replay mode: skipping guard for pre-approved tool {}", toolName);
}
// 3. Callback 查找跳过 provider 内置工具 Kimi $web_search
if (toolName.startsWith("$")) {
log.info("[ToolExecutor] Skipping provider builtin tool: {}", toolName);
allResponses.add(new ToolResponseMessage.ToolResponse(
toolCall.id(), toolName, "Provider builtin tool executed server-side"));
continue;
}
ToolCallback callback = toolCallbackMap.get(toolName);
if (callback == null) {
log.warn("[ToolExecutor] Tool not found: {}", toolName);
events.add(GraphEventPublisher.toolComplete(toolName, "工具不存在: " + toolName, false));
allResponses.add(new ToolResponseMessage.ToolResponse(
toolCall.id(), toolName, "工具不存在: " + toolName));
continue;
}
// 4. 分类: concurrencySafe
boolean safe = isConcurrencySafe(toolName);
preparedCalls.add(new PreparedToolCall(toolCall, callback, arguments, safe, allResponses.size()));
// 占位Phase 2 填充
allResponses.add(null);
}
// Phase 2: 分段并发执行
if (!preparedCalls.isEmpty()) {
executePreparedCalls(preparedCalls, allResponses, events);
}
// 清除 null 占位不应该有但防御性处理
allResponses.removeIf(Objects::isNull);
boolean hasApprovalPending = barrier != null;
return new ToolExecutionResult(allResponses, events, hasApprovalPending,
barrier != null ? barrier.pendingId : null,
barrier != null ? barrier.toolName : null);
}
/**
* 执行预批准的工具调用用于 StepExecutionNode replay 路径
*/
public ToolResponseMessage.ToolResponse executePreApproved(
AssistantMessage.ToolCall toolCall, String storedArguments,
List<GraphEventPublisher.GraphEvent> events) {
String toolName = toolCall.name();
String callArguments = storedArguments != null ? storedArguments : toolCall.arguments();
ToolCallback callback = toolCallbackMap.get(toolName);
if (callback == null) {
log.warn("[ToolExecutor] Pre-approved tool not found: {}", toolName);
events.add(GraphEventPublisher.toolComplete(toolName, "工具不存在: " + toolName, false));
return new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, "工具不存在: " + toolName);
}
try {
log.info("[ToolExecutor] Executing pre-approved tool: {}", toolName);
String result = callback.call(callArguments);
int rawLen = result != null ? result.length() : 0;
if (result != null && result.length() > MAX_TOOL_RESULT_CHARS) {
int headLen = (int) (MAX_TOOL_RESULT_CHARS * 0.4);
int tailLen = MAX_TOOL_RESULT_CHARS - headLen - 80;
result = result.substring(0, headLen)
+ "\n\n... [结果已截断,原始 " + rawLen + " 字符] ...\n\n"
+ result.substring(rawLen - tailLen);
}
log.info("[ToolExecutor] Pre-approved tool {} returned {} chars", toolName, rawLen);
events.add(GraphEventPublisher.toolComplete(toolName, result, true));
return new ToolResponseMessage.ToolResponse(
toolCall.id(), toolName, result != null ? result : "");
} catch (Exception e) {
log.error("[ToolExecutor] Pre-approved tool {} failed: {}", toolName, e.getMessage());
events.add(GraphEventPublisher.toolComplete(toolName, e.getMessage(), false));
return new ToolResponseMessage.ToolResponse(
toolCall.id(), toolName, "工具执行失败: " + e.getMessage());
}
}
// ==================== Phase 2: 并发执行 ====================
private void executePreparedCalls(List<PreparedToolCall> preparedCalls,
List<ToolResponseMessage.ToolResponse> allResponses,
List<GraphEventPublisher.GraphEvent> events) {
// 分组: 连续的 safe 工具可以并行遇到 unsafe 工具则先等待所有 safe 完成再独占执行
List<List<PreparedToolCall>> batches = buildExecutionBatches(preparedCalls);
for (List<PreparedToolCall> batch : batches) {
if (batch.size() == 1) {
// 单个工具safe unsafe直接执行
PreparedToolCall pc = batch.get(0);
ToolResponseMessage.ToolResponse response = executeSingleTool(pc, events);
allResponses.set(pc.resultIndex, response);
} else {
// 多个 safe 工具并行执行
executeParallelBatch(batch, allResponses, events);
}
}
}
/**
* prepared calls 分成执行批次
* - 连续的 safe 工具组成一个并行批次
* - unsafe 工具单独成为一个批次
*/
private List<List<PreparedToolCall>> buildExecutionBatches(List<PreparedToolCall> preparedCalls) {
List<List<PreparedToolCall>> batches = new ArrayList<>();
List<PreparedToolCall> currentSafeBatch = new ArrayList<>();
for (PreparedToolCall pc : preparedCalls) {
if (pc.concurrencySafe) {
currentSafeBatch.add(pc);
} else {
// Flush pending safe batch
if (!currentSafeBatch.isEmpty()) {
batches.add(new ArrayList<>(currentSafeBatch));
currentSafeBatch.clear();
}
// Unsafe tool as solo batch
batches.add(List.of(pc));
}
}
// Flush remaining safe batch
if (!currentSafeBatch.isEmpty()) {
batches.add(currentSafeBatch);
}
return batches;
}
private void executeParallelBatch(List<PreparedToolCall> batch,
List<ToolResponseMessage.ToolResponse> allResponses,
List<GraphEventPublisher.GraphEvent> events) {
log.info("[ToolExecutor] Executing {} safe tools in parallel: {}",
batch.size(), batch.stream().map(pc -> pc.toolCall.name()).toList());
long batchStartMs = System.currentTimeMillis();
Map<Integer, CompletableFuture<ToolResponseMessage.ToolResponse>> futures = new LinkedHashMap<>();
for (PreparedToolCall pc : batch) {
CompletableFuture<ToolResponseMessage.ToolResponse> future =
CompletableFuture.supplyAsync(() -> executeSingleTool(pc, events), TOOL_EXECUTOR);
futures.put(pc.resultIndex, future);
}
// 等待所有并行工具完成按原始顺序填入结果
for (var entry : futures.entrySet()) {
try {
ToolResponseMessage.ToolResponse response = entry.getValue().get(5, TimeUnit.MINUTES);
allResponses.set(entry.getKey(), response);
} catch (Exception e) {
// 超时或异常 填入错误响应
PreparedToolCall pc = batch.stream()
.filter(p -> p.resultIndex == entry.getKey())
.findFirst().orElse(null);
String toolName = pc != null ? pc.toolCall.name() : "unknown";
String toolId = pc != null ? pc.toolCall.id() : "";
log.error("[ToolExecutor] Parallel tool {} failed: {}", toolName, e.getMessage());
allResponses.set(entry.getKey(), new ToolResponseMessage.ToolResponse(
toolId, toolName, normalizeToolExecutionError(
e instanceof ExecutionException ? (Exception) e.getCause() : (Exception) e)));
}
}
log.info("[ToolExecutor] Parallel batch completed: {} tools in {}ms",
batch.size(), System.currentTimeMillis() - batchStartMs);
}
private ToolResponseMessage.ToolResponse executeSingleTool(PreparedToolCall pc,
List<GraphEventPublisher.GraphEvent> events) {
String toolName = pc.toolCall.name();
try {
log.info("[ToolExecutor] Executing tool: {} with args: {}",
toolName, pc.arguments != null && pc.arguments.length() > 200
? pc.arguments.substring(0, 200) + "..." : pc.arguments);
String result = pc.callback.call(pc.arguments);
int rawLen = result != null ? result.length() : 0;
// 截断过长结果防止 ToolResponseMessage 撑爆 LLM 上下文
if (result != null && result.length() > MAX_TOOL_RESULT_CHARS) {
int headLen = (int) (MAX_TOOL_RESULT_CHARS * 0.4);
int tailLen = MAX_TOOL_RESULT_CHARS - headLen - 80;
result = result.substring(0, headLen)
+ "\n\n... [结果已截断,原始 " + rawLen + " 字符,保留首尾关键片段] ...\n\n"
+ result.substring(rawLen - tailLen);
log.info("[ToolExecutor] Tool {} returned {} chars, truncated to {} chars",
toolName, rawLen, result.length());
} else {
log.info("[ToolExecutor] Tool {} returned {} chars", toolName, rawLen);
}
events.add(GraphEventPublisher.toolComplete(toolName, result, true));
return new ToolResponseMessage.ToolResponse(
pc.toolCall.id(), toolName, result != null ? result : "");
} catch (Exception e) {
log.error("[ToolExecutor] Tool {} execution failed: {}", toolName, e.getMessage(), e);
String normalizedError = normalizeToolExecutionError(e);
events.add(GraphEventPublisher.toolComplete(toolName, normalizedError, false));
return new ToolResponseMessage.ToolResponse(
pc.toolCall.id(), toolName, normalizedError);
}
}
// ==================== Guard 评估 ====================
private GuardDecision evaluateGuard(AssistantMessage.ToolCall toolCall, String toolName, String arguments,
String conversationId, String agentId,
List<AssistantMessage.ToolCall> allToolCalls, int currentIndex,
List<GraphEventPublisher.GraphEvent> events, String requesterId) {
ToolInvocationContext guardCtx = ToolInvocationContext.of(toolName, arguments, conversationId, agentId);
if (toolGuardService != null) {
GuardEvaluation evaluation = toolGuardService.evaluate(guardCtx);
if (evaluation.shouldBlock()) {
log.warn("[ToolExecutor] Tool call BLOCKED: tool={}, summary={}", toolName, evaluation.summary());
events.add(GraphEventPublisher.toolComplete(toolName, evaluation.summary(), false));
return GuardDecision.blocked(
"[安全拦截] " + evaluation.summary() + "。请使用更安全的替代方案。");
}
if (evaluation.shouldRequireApproval()) {
List<AssistantMessage.ToolCall> remaining = allToolCalls.subList(currentIndex + 1, allToolCalls.size());
String approvalResponse = ToolExecutionGuardHelper.handleToolApproval(
toolCall, toolName, arguments, evaluation,
conversationId, agentId, requesterId, approvalService, streamTracker,
events, remaining);
// Extract pendingId from response (format: "[APPROVAL_PENDING] tool=xxx awaiting user decision")
return GuardDecision.needsApproval(approvalResponse, extractPendingId(approvalResponse));
}
} else if (toolGuard != null) {
ToolGuardResult guardResult = toolGuard.check(toolName, arguments);
if (guardResult.isBlocked()) {
log.warn("[ToolExecutor] Tool call BLOCKED by ToolGuard: tool={}, reason={}", toolName, guardResult.reason());
events.add(GraphEventPublisher.toolComplete(toolName, guardResult.reason(), false));
return GuardDecision.blocked(
"[安全拦截] " + guardResult.reason() + "。请使用更安全的替代方案。");
}
if (guardResult.needsApproval()) {
List<AssistantMessage.ToolCall> remaining = allToolCalls.subList(currentIndex + 1, allToolCalls.size());
String approvalResponse = ToolExecutionGuardHelper.handleToolApprovalLegacy(
toolCall, toolName, arguments, guardResult,
conversationId, agentId, requesterId, approvalService, streamTracker,
events, remaining);
return GuardDecision.needsApproval(approvalResponse, extractPendingId(approvalResponse));
}
}
return GuardDecision.allowed();
}
// ==================== 辅助方法 ====================
/**
* 判断工具是否并发安全
*/
private boolean isConcurrencySafe(String toolName) {
return !DEFAULT_UNSAFE_TOOLS.contains(toolName);
}
private String normalizeToolExecutionError(Exception e) {
String message = e != null && e.getMessage() != null ? e.getMessage() : "未知错误";
String lower = message.toLowerCase(Locale.ROOT);
if (lower.contains("conversion from json")
|| lower.contains("unexpected end-of-input")
|| lower.contains("unexpected character escape sequence")
|| lower.contains("json parse error")
|| lower.contains("malformed json")) {
return "工具执行失败:模型生成的工具参数不是合法 JSON通常表示单次 tool call 内容过长,"
+ "或在字符串转义位置被截断。请改为分步骤写入,拆成多个文件,或缩小单次 write_file/edit_file 的内容后重试。";
}
if (lower.contains("access denied") && lower.contains("path outside allowed directories")) {
// 提取目标路径和允许路径
return "工具执行失败:目标路径不在允许的工作目录范围内。请将文件操作改为用户主目录下的路径(如 ~/Documents/ 或 ~/Desktop/)。";
}
return "工具执行失败: " + message;
}
/**
* approval response 中提取 pendingIdbest-effort
*/
private String extractPendingId(String approvalResponse) {
// handleToolApproval 内部已经创建了 pending这里只做标记
return approvalResponse;
}
// ==================== 内部数据类 ====================
private record PreparedToolCall(
AssistantMessage.ToolCall toolCall,
ToolCallback callback,
String arguments,
boolean concurrencySafe,
int resultIndex
) {}
private record ApprovalBarrier(String pendingId, String toolName) {}
private static final class GuardDecision {
final boolean blocked;
final boolean needsApproval;
final String response;
final String pendingId;
private GuardDecision(boolean blocked, boolean needsApproval, String response, String pendingId) {
this.blocked = blocked;
this.needsApproval = needsApproval;
this.response = response;
this.pendingId = pendingId;
}
static GuardDecision allowed() { return new GuardDecision(false, false, null, null); }
static GuardDecision blocked(String response) { return new GuardDecision(true, false, response, null); }
static GuardDecision needsApproval(String response, String pendingId) {
return new GuardDecision(false, true, response, pendingId);
}
}
/**
* 工具执行结果
*/
public record ToolExecutionResult(
/** 所有工具的响应(按原始顺序) */
List<ToolResponseMessage.ToolResponse> responses,
/** 执行过程中的事件 */
List<GraphEventPublisher.GraphEvent> events,
/** 是否有待审批的工具 */
boolean awaitingApproval,
/** 审批 pending ID如果 awaitingApproval=true */
String pendingId,
/** 触发审批 barrier 的工具名(如果 awaitingApproval=true */
String barrierToolName
) {}
}

View File

@ -0,0 +1,132 @@
package vip.mate.agent.graph.lifecycle;
import com.alibaba.cloud.ai.graph.GraphLifecycleListener;
import com.alibaba.cloud.ai.graph.RunnableConfig;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import static vip.mate.agent.graph.state.MateClawStateKeys.*;
/**
* ReAct 状态图生命周期监听器
* <p>
* 利用 spring-ai-alibaba-graph-core {@link GraphLifecycleListener} 接口
* 在图执行的关键节点输出结构化日志不与业务逻辑耦合
* <p>
* 通过 {@code CompileConfig.builder().withLifecycleListener(new ReActLifecycleListener())} 注册
* <p>
* 输出日志示例
* <pre>
* [ReAct] node=reasoning event=start iteration=2 traceId=abc123
* [ReAct] node=reasoning event=complete iteration=2 durationMs=1234 toolCallCount=3
* [ReAct] node=limit_exceeded event=complete iteration=10 finishReason=max_iterations_reached
* </pre>
*
* @author MateClaw Team
*/
@Slf4j
public class ReActLifecycleListener implements GraphLifecycleListener {
/** 记录每个节点的开始时间key = nodeId + threadId */
private final ConcurrentHashMap<String, Long> nodeStartTimes = new ConcurrentHashMap<>();
@Override
public void onStart(String nodeId, Map<String, Object> state, RunnableConfig config) {
String traceId = getStringValue(state, TRACE_ID);
int iteration = getIntValue(state, CURRENT_ITERATION);
log.info("[ReAct] node={} event=start iteration={} traceId={}",
nodeId, iteration, traceId);
}
@Override
public void before(String nodeId, Map<String, Object> state, RunnableConfig config, Long curTime) {
String key = nodeId + ":" + Thread.currentThread().getId();
nodeStartTimes.put(key, curTime != null ? curTime : System.currentTimeMillis());
}
@Override
public void after(String nodeId, Map<String, Object> state, RunnableConfig config, Long curTime) {
String key = nodeId + ":" + Thread.currentThread().getId();
Long startTime = nodeStartTimes.remove(key);
long durationMs = 0;
if (startTime != null && curTime != null) {
durationMs = curTime - startTime;
}
int iteration = getIntValue(state, CURRENT_ITERATION);
int toolCallCount = getIntValue(state, TOOL_CALL_COUNT);
String traceId = getStringValue(state, TRACE_ID);
String finishReason = getStringValue(state, FINISH_REASON);
boolean shouldSummarize = getBooleanValue(state, SHOULD_SUMMARIZE);
// 结构化日志
StringBuilder logMsg = new StringBuilder();
logMsg.append(String.format("[ReAct] node=%s event=complete iteration=%d durationMs=%d",
nodeId, iteration, durationMs));
logMsg.append(String.format(" toolCallCount=%d", toolCallCount));
if (!traceId.isEmpty()) {
logMsg.append(String.format(" traceId=%s", traceId));
}
if (!finishReason.isEmpty()) {
logMsg.append(String.format(" finishReason=%s", finishReason));
}
if (shouldSummarize) {
logMsg.append(" shouldSummarize=true");
}
// 观察历史大小
Object obsHistory = state.get(OBSERVATION_HISTORY);
if (obsHistory instanceof List<?> list) {
logMsg.append(String.format(" observationCount=%d", list.size()));
}
log.info(logMsg.toString());
}
@Override
public void onError(String nodeId, Map<String, Object> state, Throwable ex, RunnableConfig config) {
String traceId = getStringValue(state, TRACE_ID);
int iteration = getIntValue(state, CURRENT_ITERATION);
log.error("[ReAct] node={} event=error iteration={} traceId={} error={}",
nodeId, iteration, traceId, ex.getMessage(), ex);
}
@Override
public void onComplete(String nodeId, Map<String, Object> state, RunnableConfig config) {
String finishReason = getStringValue(state, FINISH_REASON);
String traceId = getStringValue(state, TRACE_ID);
int iteration = getIntValue(state, CURRENT_ITERATION);
int toolCallCount = getIntValue(state, TOOL_CALL_COUNT);
boolean limitExceeded = getBooleanValue(state, LIMIT_EXCEEDED);
if (FINAL_ANSWER_NODE.equals(nodeId)) {
log.info("[ReAct] graph=complete node={} iteration={} toolCallCount={} " +
"finishReason={} limitExceeded={} traceId={}",
nodeId, iteration, toolCallCount, finishReason, limitExceeded, traceId);
}
}
// ===== 安全取值工具方法 =====
private static String getStringValue(Map<String, Object> state, String key) {
Object val = state.get(key);
return val instanceof String s ? s : "";
}
private static int getIntValue(Map<String, Object> state, String key) {
Object val = state.get(key);
if (val instanceof Integer i) return i;
if (val instanceof Number n) return n.intValue();
return 0;
}
private static boolean getBooleanValue(Map<String, Object> state, String key) {
Object val = state.get(key);
return val instanceof Boolean b && b;
}
}

View File

@ -0,0 +1,91 @@
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.Message;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import vip.mate.agent.graph.executor.ToolExecutionExecutor;
import vip.mate.agent.graph.state.MateClawStateAccessor;
import vip.mate.agent.graph.state.MateClawStateKeys;
import java.util.*;
import java.util.concurrent.CancellationException;
import static vip.mate.agent.graph.state.MateClawStateKeys.*;
/**
* 工具执行节点ReAct Action 阶段
* <p>
* 委托 {@link ToolExecutionExecutor} 执行工具调用支持并发执行和审批 barrier
* <p>
* 支持 forced_replay 阶段当审批通过后的重放调用到达时跳过 ToolGuard 检查直接执行
*
* @author MateClaw Team
*/
@Slf4j
public class ActionNode implements NodeAction {
private final ToolExecutionExecutor executor;
private final vip.mate.channel.web.ChatStreamTracker streamTracker;
public ActionNode(ToolExecutionExecutor executor) {
this(executor, null);
}
public ActionNode(ToolExecutionExecutor executor, vip.mate.channel.web.ChatStreamTracker streamTracker) {
this.executor = executor;
this.streamTracker = streamTracker;
}
@Override
@SuppressWarnings("unchecked")
public Map<String, Object> apply(OverAllState state) throws Exception {
List<AssistantMessage.ToolCall> toolCalls = state.<List<AssistantMessage.ToolCall>>value(TOOL_CALLS)
.orElse(List.of());
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
String conversationId = accessor.conversationId();
String agentId = accessor.agentId();
// 检查停止标志
if (streamTracker != null && streamTracker.isStopRequested(conversationId)) {
log.info("[ActionNode] Stop requested, aborting tool execution: conversationId={}", conversationId);
throw new CancellationException("Stream stopped by user");
}
// 检测是否为 forced_replay 阶段审批通过后的重放
String currentPhase = state.value(MateClawStateKeys.CURRENT_PHASE, "");
boolean isReplay = "forced_replay".equals(currentPhase);
// 请求者身份用于审批记录
String requesterId = accessor.requesterId();
// 委托 ToolExecutionExecutor 执行两阶段顺序 Guard + 分段并发执行
ToolExecutionExecutor.ToolExecutionResult result = executor.execute(
toolCalls, conversationId, agentId, isReplay, requesterId);
ToolResponseMessage toolResponseMessage = ToolResponseMessage.builder()
.responses(result.responses())
.build();
MateClawStateAccessor.OutputBuilder output = MateClawStateAccessor.output()
.toolResults(result.responses())
.messages(List.of((Message) toolResponseMessage))
.currentPhase("action")
.events(result.events());
if (result.awaitingApproval()) {
output.awaitingApproval(true);
log.info("[ActionNode] Approval pending detected, setting AWAITING_APPROVAL=true to terminate graph");
}
// replay 完成后清空 forced_tool_call防止下一轮再触发
if (isReplay) {
output.forcedToolCall("");
}
return output.build();
}
}

View File

@ -0,0 +1,120 @@
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 vip.mate.agent.graph.state.FinishReason;
import vip.mate.agent.graph.state.MateClawStateAccessor;
import java.util.Map;
/**
* 最终回答节点
* <p>
* 汇聚所有终止路径的最终回答生成
* <ul>
* <li>直接回答路径使用 ReasoningNode 产出的 finalAnswer</li>
* <li>Summarizing 路径基于 summarizedContext 构建回答</li>
* <li>LimitExceeded 路径使用 finalAnswerDraft</li>
* </ul>
* <p>
* 负责设置最终的 finalAnswerfinalThinking finishReason
* 保留上游节点设置的 CONTENT_STREAMED / THINKING_STREAMED 标志位
*
* @author MateClaw Team
*/
@Slf4j
public class FinalAnswerNode implements NodeAction {
@Override
public Map<String, Object> apply(OverAllState state) throws Exception {
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
String finalAnswer;
String finalThinking;
FinishReason finishReason;
// 审批等待路径Graph AWAITING_APPROVAL 终止保留已流式推送的内容用于持久化
if (accessor.awaitingApproval()) {
String preservedContent = accessor.streamedContent();
String preservedThinking = !accessor.streamedThinking().isEmpty()
? accessor.streamedThinking() : accessor.currentThinking();
log.info("[FinalAnswerNode] AWAITING_APPROVAL — preserving streamed content " +
"({} chars, thinking {} chars) for persistence",
preservedContent.length(), preservedThinking.length());
var builder = MateClawStateAccessor.output()
.finalAnswer(preservedContent)
.finishReason(FinishReason.NORMAL)
.contentStreamed(true)
.thinkingStreamed(true);
if (!preservedThinking.isEmpty()) {
builder.finalThinking(preservedThinking);
}
return builder.build();
}
// 优先级finalAnswerDraft来自 limitExceeded/summarizing 路径> finalAnswer来自 reasoning 直接路径
String draft = accessor.finalAnswerDraft();
String existingAnswer = accessor.finalAnswer();
String existingReason = accessor.finishReason();
String existingThinking = accessor.finalThinking();
String currentThinking = accessor.currentThinking();
if (!draft.isEmpty()) {
// 来自 limitExceeded summarizing + LLM 回答
finalAnswer = draft;
// currentThinking 来自 SummarizingNode LimitExceededNode
finalThinking = !currentThinking.isEmpty() ? currentThinking : existingThinking;
finishReason = parseFinishReason(existingReason);
log.info("[FinalAnswerNode] Using finalAnswerDraft ({} chars), reason={}",
finalAnswer.length(), finishReason);
} else if (!existingAnswer.isEmpty()) {
// 来自 reasoning 直接回答 stopped partial
finalAnswer = existingAnswer;
finalThinking = !currentThinking.isEmpty() ? currentThinking : existingThinking;
// 尊重上游已设的 finishReason STOPPED只有未设时才默认 NORMAL
finishReason = !existingReason.isEmpty() ? parseFinishReason(existingReason) : FinishReason.NORMAL;
log.info("[FinalAnswerNode] Using existing finalAnswer ({} chars), reason={}",
finalAnswer.length(), finishReason);
} else {
// 异常兜底使用 summarizedContext
String summary = accessor.summarizedContext();
if (!summary.isEmpty()) {
finalAnswer = summary;
finalThinking = currentThinking;
finishReason = FinishReason.SUMMARIZED;
log.warn("[FinalAnswerNode] No finalAnswer or draft found, falling back to summarizedContext");
} else {
finalAnswer = "未能生成回答,请重试。";
finalThinking = "";
finishReason = FinishReason.ERROR_FALLBACK;
log.error("[FinalAnswerNode] No answer source available, returning fallback");
}
}
// 不重置 CONTENT_STREAMED/THINKING_STREAMED保留上游节点的标志
var builder = MateClawStateAccessor.output()
.finalAnswer(finalAnswer)
.finishReason(finishReason);
if (!finalThinking.isEmpty()) {
builder.finalThinking(finalThinking);
}
return builder.build();
}
private FinishReason parseFinishReason(String reason) {
if (reason == null || reason.isEmpty()) {
return FinishReason.NORMAL;
}
for (FinishReason fr : FinishReason.values()) {
if (fr.getValue().equals(reason)) {
return fr;
}
}
return FinishReason.NORMAL;
}
}

View File

@ -0,0 +1,123 @@
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.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.Prompt;
import vip.mate.agent.graph.NodeStreamingChatHelper;
import vip.mate.agent.graph.observation.ObservationProcessor;
import vip.mate.agent.graph.state.FinishReason;
import vip.mate.agent.graph.state.MateClawStateAccessor;
import vip.mate.agent.prompt.PromptLoader;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 超限处理节点
* <p>
* 当迭代次数达到 maxIterations 时由 dispatcher 路由至此节点
* <b>不会直接抛异常</b>而是向 LLM 注入友好的系统提示
* 要求其基于已有信息给出最终回答明确标注不确定项
* <p>
* 工程化超限机制
* 1. 如果 observationHistory 过长先做内联压缩
* 2. 注入 "停止工具调用" 系统指令
* 3. LLM 生成简洁最终回答
* 4. 标记 finishReason = MAX_ITERATIONS_REACHED
* <p>
* 使用 {@link NodeStreamingChatHelper} 进行流式调用实时推送 content/thinking 增量
*
* @author MateClaw Team
*/
@Slf4j
public class LimitExceededNode implements NodeAction {
private static final String SYSTEM_TEMPLATE = PromptLoader.loadPrompt("graph/limit-exceeded-system");
private static final String USER_TEMPLATE = PromptLoader.loadPrompt("graph/limit-exceeded-user");
private final ChatModel chatModel;
private final ObservationProcessor observationProcessor;
private final NodeStreamingChatHelper streamingHelper;
public LimitExceededNode(ChatModel chatModel, ObservationProcessor observationProcessor,
NodeStreamingChatHelper streamingHelper) {
this.chatModel = chatModel;
this.observationProcessor = observationProcessor;
this.streamingHelper = streamingHelper;
}
/**
* @deprecated Use constructor with NodeStreamingChatHelper
*/
@Deprecated
public LimitExceededNode(ChatModel chatModel, ObservationProcessor observationProcessor) {
this(chatModel, observationProcessor, null);
}
@Override
public Map<String, Object> apply(OverAllState state) throws Exception {
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
int maxIterations = accessor.maxIterations();
String userInput = accessor.userMessage();
String conversationId = accessor.conversationId();
List<String> observations = accessor.observationHistory();
String existingSummary = accessor.summarizedContext();
log.warn("[LimitExceededNode] Max iterations ({}) reached. Generating graceful final answer. " +
"Observations: {} entries, {} chars, existing summary: {} chars",
maxIterations, observations.size(), accessor.totalObservationChars(),
existingSummary.length());
// 准备上下文优先使用已有 summary否则压缩 observationHistory
String contextForLLM;
if (!existingSummary.isEmpty()) {
contextForLLM = existingSummary;
} else if (!observations.isEmpty()) {
// 内联压缩拼接观察历史截断到可控长度
StringBuilder sb = new StringBuilder();
for (int i = 0; i < observations.size(); i++) {
sb.append(String.format("【第 %d 轮】%s\n", i + 1, observations.get(i)));
}
contextForLLM = observationProcessor.truncate(sb.toString(),
observationProcessor.getMaxTotalObservationChars());
} else {
contextForLLM = "(尚未收集到工具调用结果)";
}
// 构建 prompt
String systemPrompt = SYSTEM_TEMPLATE.replace("{maxIterations}", String.valueOf(maxIterations));
String userPrompt = USER_TEMPLATE
.replace("{question}", userInput)
.replace("{context}", contextForLLM);
List<Message> promptMessages = new ArrayList<>();
promptMessages.add(new SystemMessage(systemPrompt));
promptMessages.add(new UserMessage(userPrompt));
// 流式调用 LLM实时推送 content/thinking
NodeStreamingChatHelper.StreamResult result = streamingHelper.streamCall(
chatModel, new Prompt(promptMessages), conversationId, "limit_exceeded");
String finalDraft = result.text();
log.info("[LimitExceededNode] Generated limit-exceeded final answer: {} chars",
finalDraft != null ? finalDraft.length() : 0);
return MateClawStateAccessor.output()
.finalAnswerDraft(finalDraft != null ? finalDraft : "抱歉,已达到最大推理步数,未能获得完整结果。")
.currentThinking(result.thinking())
.limitExceeded(true)
.contentStreamed(true)
.thinkingStreamed(!result.thinking().isEmpty())
.mergeUsage(state, result)
.finishReason(FinishReason.MAX_ITERATIONS_REACHED)
.build();
}
}

View File

@ -0,0 +1,99 @@
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.ToolResponseMessage;
import vip.mate.agent.graph.observation.ObservationProcessor;
import vip.mate.agent.graph.state.MateClawStateAccessor;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CancellationException;
import java.util.stream.Collectors;
import static vip.mate.agent.graph.state.MateClawStateKeys.*;
/**
* 观察节点ReAct Observation 阶段
* <p>
* 处理工具执行结果通过 {@link ObservationProcessor} 进行标准化和截断
* 递增迭代计数器并判断是否需要进入 summarizing 阶段
* <p>
* 这是 maxIterations 强制执行的核心节点之一配合 ObservationDispatcher 实现迭代控制
*
* @author MateClaw Team
*/
@Slf4j
public class ObservationNode implements NodeAction {
private final ObservationProcessor observationProcessor;
private final vip.mate.channel.web.ChatStreamTracker streamTracker;
public ObservationNode(ObservationProcessor observationProcessor) {
this(observationProcessor, null);
}
public ObservationNode(ObservationProcessor observationProcessor,
vip.mate.channel.web.ChatStreamTracker streamTracker) {
this.observationProcessor = observationProcessor;
this.streamTracker = streamTracker;
}
@Override
@SuppressWarnings("unchecked")
public Map<String, Object> apply(OverAllState state) throws Exception {
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
// 检查停止标志
String conversationId = accessor.conversationId();
if (streamTracker != null && streamTracker.isStopRequested(conversationId)) {
log.info("[ObservationNode] Stop requested, aborting: conversationId={}", conversationId);
throw new CancellationException("Stream stopped by user");
}
int currentIteration = accessor.iterationCount();
int maxIterations = accessor.maxIterations();
int nextIteration = currentIteration + 1;
log.info("[ObservationNode] Iteration {}/{}", nextIteration, maxIterations);
// 提取最新的工具结果并处理
List<ToolResponseMessage.ToolResponse> toolResults =
state.<List<ToolResponseMessage.ToolResponse>>value(TOOL_RESULTS).orElse(List.of());
// 将每个工具结果通过 ObservationProcessor 标准化和截断
List<String> processedObservations = toolResults.stream()
.map(tr -> observationProcessor.process(tr.name(), tr.responseData()))
.collect(Collectors.toList());
// 合并为单条观察记录
String combinedObservation = String.join("\n---\n", processedObservations);
// 手动累加观察历史OBSERVATION_HISTORY 使用 REPLACE 策略以便 SummarizingNode 可清空
List<String> existingHistory = accessor.observationHistory();
List<String> updatedHistory = new ArrayList<>(existingHistory);
updatedHistory.add(combinedObservation);
// 判断是否需要 summarize
boolean shouldSummarize = observationProcessor.needsSummarizing(
existingHistory, combinedObservation);
// 统计工具调用次数
int newToolCallCount = accessor.toolCallCount() + toolResults.size();
if (shouldSummarize) {
log.info("[ObservationNode] Marking shouldSummarize=true (history={} entries, " +
"current={} chars, total tool calls={})",
existingHistory.size(), combinedObservation.length(), newToolCallCount);
}
return MateClawStateAccessor.output()
.iterationCount(nextIteration)
.put(OBSERVATION_HISTORY, updatedHistory)
.shouldSummarize(shouldSummarize)
.toolCallCount(newToolCallCount)
.build();
}
}

View File

@ -0,0 +1,293 @@
package vip.mate.agent.graph.node;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.util.StringUtils;
import vip.mate.agent.AgentToolSet;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.graph.NodeStreamingChatHelper;
import vip.mate.agent.context.ConversationWindowManager;
import vip.mate.agent.graph.state.FinishReason;
import vip.mate.agent.graph.state.MateClawStateAccessor;
import vip.mate.agent.graph.state.MateClawStateKeys;
import vip.mate.channel.web.ChatStreamTracker;
import java.util.*;
import java.util.concurrent.CancellationException;
import static vip.mate.agent.graph.state.MateClawStateKeys.*;
/**
* 推理节点ReAct Thought 阶段
* <p>
* 调用 LLM 进行单次推理判断是否需要工具调用
* 关键通过 internalToolExecutionEnabled=false 禁用 ChatModel 内部工具循环
* 使 StateGraph 完全控制 ReAct 循环
* <p>
* 支持 forced_tool_call 机制当审批通过后的重放请求到达时
* 跳过 LLM 调用直接发出预批准的工具调用
* <p>
* 使用 {@link NodeStreamingChatHelper} 进行流式调用实时推送 content/thinking 增量
*
* @author MateClaw Team
*/
@Slf4j
public class ReasoningNode implements NodeAction {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final ChatModel chatModel;
private final List<ToolCallback> toolCallbacks;
private final String reasoningEffort;
private final NodeStreamingChatHelper streamingHelper;
private final ConversationWindowManager conversationWindowManager;
private final ChatStreamTracker streamTracker;
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort,
NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager,
ChatStreamTracker streamTracker) {
this.chatModel = chatModel;
this.toolCallbacks = toolSet.callbacks();
this.reasoningEffort = reasoningEffort;
this.streamingHelper = streamingHelper;
this.conversationWindowManager = conversationWindowManager;
this.streamTracker = streamTracker;
}
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort,
NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager) {
this(chatModel, toolSet, reasoningEffort, streamingHelper, conversationWindowManager, null);
}
/**
* @deprecated Use {@link #ReasoningNode(ChatModel, AgentToolSet, String, NodeStreamingChatHelper)} instead
*/
@Deprecated
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort) {
this(chatModel, toolSet, reasoningEffort, null, null);
}
/**
* @deprecated Use {@link #ReasoningNode(ChatModel, AgentToolSet, String, NodeStreamingChatHelper)} instead
*/
@Deprecated
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet) {
this(chatModel, toolSet, null, null, null);
}
/**
* @deprecated Use {@link #ReasoningNode(ChatModel, AgentToolSet, String, NodeStreamingChatHelper)} instead
*/
@Deprecated
public ReasoningNode(ChatModel chatModel, List<ToolCallback> toolCallbacks) {
this.chatModel = chatModel;
this.toolCallbacks = toolCallbacks;
this.reasoningEffort = null;
this.streamingHelper = null;
this.conversationWindowManager = null;
this.streamTracker = null;
}
@Override
@SuppressWarnings("unchecked")
public Map<String, Object> apply(OverAllState state) throws Exception {
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
// ======= 取消检查 =======
String conversationId = accessor.conversationId();
if (streamTracker != null && streamTracker.isStopRequested(conversationId)) {
log.info("[ReasoningNode] Stop requested, aborting LLM call");
throw new CancellationException("Stream stopped by user");
}
// ======= forced_tool_call 检测审批通过后的重放 =======
String forcedToolCallJson = accessor.forcedToolCall();
if (!forcedToolCallJson.isEmpty()) {
try {
log.info("[ReasoningNode] Detected forced_tool_call, skipping LLM, emitting tool call directly");
AssistantMessage.ToolCall toolCall = deserializeToolCall(forcedToolCallJson);
// 构造合成的 AssistantMessage
AssistantMessage syntheticMsg = AssistantMessage.builder()
.content("")
.toolCalls(List.of(toolCall))
.build();
return MateClawStateAccessor.output()
.needsToolCall(true)
.toolCalls(List.of(toolCall))
.messages(List.of((Message) syntheticMsg))
.iterationCount(accessor.iterationCount() + 1)
.forcedToolCall("") // 清空防止下一轮再触发
.currentPhase("forced_replay")
.contentStreamed(true) // content 需要流式推送
.thinkingStreamed(true) // thinking 需要流式推送
.events(List.of(GraphEventPublisher.phase("forced_replay", Map.of(
"toolName", toolCall.name(),
"iteration", accessor.iterationCount() + 1))))
.build();
} catch (Exception e) {
log.error("[ReasoningNode] Failed to deserialize forced_tool_call, falling through to normal LLM: {}",
e.getMessage());
// return清空 forcedToolCall 后走正常 LLM 流程
}
}
// ======= forced_tool_call 检测结束 =======
String systemPrompt = accessor.systemPrompt();
List<Message> messages = accessor.messages();
// 构建 Prompt附带工具定义但禁用内部工具执行
List<Message> promptMessages = new ArrayList<>();
promptMessages.add(new SystemMessage(systemPrompt));
promptMessages.addAll(messages);
ChatOptions options;
if (StringUtils.hasText(reasoningEffort)) {
OpenAiChatOptions oaiOpts = OpenAiChatOptions.builder()
.toolCallbacks(toolCallbacks)
.reasoningEffort(reasoningEffort)
.build();
oaiOpts.setInternalToolExecutionEnabled(false);
options = oaiOpts;
} else {
options = ToolCallingChatOptions.builder()
.toolCallbacks(toolCallbacks)
.internalToolExecutionEnabled(false)
.build();
}
Prompt prompt = new Prompt(promptMessages, options);
log.debug("[ReasoningNode] Calling LLM with {} messages, {} tool definitions, iteration {}/{}",
promptMessages.size(), toolCallbacks.size(),
accessor.iterationCount(), accessor.maxIterations());
// 构建 phase 事件
GraphEventPublisher.GraphEvent phaseEvent = GraphEventPublisher.phase("reasoning",
Map.of("iteration", accessor.iterationCount()));
// 流式 LLM 调用content/thinking 增量实时推送给前端
NodeStreamingChatHelper.StreamResult result = streamingHelper.streamCall(
chatModel, prompt, conversationId, "reasoning");
// PTL 处理压缩后重试 Node 层负责因为 helper 不知道哪些消息可压缩
if (result.isPromptTooLong() && conversationWindowManager != null) {
log.warn("[ReasoningNode] Prompt too long, attempting compaction and retry");
List<Message> compactedMessages = conversationWindowManager.compactForRetry(messages);
if (compactedMessages != null && compactedMessages.size() < messages.size()) {
List<Message> retryPromptMessages = new ArrayList<>();
retryPromptMessages.add(new SystemMessage(systemPrompt));
retryPromptMessages.addAll(compactedMessages);
Prompt retryPrompt = new Prompt(retryPromptMessages, options);
log.info("[ReasoningNode] Retrying with compacted messages: {} -> {} messages",
messages.size(), compactedMessages.size());
result = streamingHelper.streamCall(chatModel, retryPrompt, conversationId, "reasoning_compact_retry");
} else {
log.warn("[ReasoningNode] Compaction did not reduce messages, cannot retry");
}
}
// 用户主动停止且有部分内容设为 finalAnswer + finalThinking accumulator 持久化
if (result.stopped() && result.hasAnyContent()) {
String partialText = result.text() != null ? result.text() : "";
String partialThinking = result.thinking() != null ? result.thinking() : "";
log.info("[ReasoningNode] Stop with partial content ({} chars, thinking {} chars), " +
"flushing as final answer",
partialText.length(), partialThinking.length());
var builder = MateClawStateAccessor.output()
.finalAnswer(partialText)
.contentStreamed(true)
.mergeUsage(state, result)
.finishReason(FinishReason.STOPPED);
if (!partialThinking.isEmpty()) {
builder.finalThinking(partialThinking);
builder.thinkingStreamed(true);
}
return builder.build();
}
// 错误处理无任何可用内容时直接终止图执行
// NodeStreamingChatHelper 已广播结构化 error 事件这里不能再把错误文本当成正常 final answer
if (result.hasFatalError()) {
log.error("[ReasoningNode] Fatal LLM error: {}", result.errorMessage());
throw new IllegalStateException(result.errorMessage());
}
if (result.partial()) {
// 有部分内容 当作最终回答处理LLM 已经回答了大部分
log.warn("[ReasoningNode] Partial LLM result ({} chars), treating as final answer", result.text().length());
}
if (result.hasToolCalls()) {
// LLM 请求工具调用
log.info("[ReasoningNode] LLM requested {} tool call(s): {}",
result.toolCalls().size(),
result.toolCalls().stream().map(AssistantMessage.ToolCall::name).toList());
return MateClawStateAccessor.output()
.needsToolCall(true)
.toolCalls(result.toolCalls())
.messages(List.of((Message) result.assistantMessage()))
.currentPhase("reasoning")
.currentThinking(result.thinking())
// 暂存已流式推送的 content/thinking AWAITING_APPROVAL 路径持久化
.streamedContent(result.text() != null ? result.text() : "")
.streamedThinking(result.thinking())
.contentStreamed(true)
.thinkingStreamed(!result.thinking().isEmpty())
.mergeUsage(state, result)
.events(List.of(phaseEvent))
.build();
} else {
// LLM 给出最终回答
String content = result.text();
log.info("[ReasoningNode] LLM produced final answer ({} chars)", content != null ? content.length() : 0);
return MateClawStateAccessor.output()
.needsToolCall(false)
.finalAnswer(content != null ? content : "")
.finalThinking(result.thinking())
.messages(List.of((Message) result.assistantMessage()))
.currentPhase("reasoning")
.contentStreamed(true)
.thinkingStreamed(!result.thinking().isEmpty())
.mergeUsage(state, result)
.events(List.of(phaseEvent))
.build();
}
}
/**
* 反序列化 JSON ToolCall
*/
private AssistantMessage.ToolCall deserializeToolCall(String json) {
try {
@SuppressWarnings("unchecked")
Map<String, String> map = OBJECT_MAPPER.readValue(json, Map.class);
return new AssistantMessage.ToolCall(
map.getOrDefault("id", UUID.randomUUID().toString()),
map.getOrDefault("type", "function"),
map.getOrDefault("name", ""),
map.getOrDefault("arguments", "")
);
} catch (Exception e) {
log.error("[ReasoningNode] Failed to deserialize forced_tool_call: {}", e.getMessage());
throw new RuntimeException("无法反序列化 forced_tool_call: " + e.getMessage(), e);
}
}
}

View File

@ -0,0 +1,179 @@
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.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.Prompt;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.graph.NodeStreamingChatHelper;
import vip.mate.agent.graph.state.MateClawStateAccessor;
import vip.mate.agent.prompt.PromptLoader;
import vip.mate.agent.graph.state.FinishReason;
import vip.mate.channel.web.ChatStreamTracker;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CancellationException;
import static vip.mate.agent.graph.state.MateClawStateKeys.OBSERVATION_HISTORY;
/**
* 总结压缩节点Summarizing 阶段
* <p>
* 当满足以下条件之一时由 dispatcher 路由至此节点
* <ul>
* <li>最后一轮不再需要工具调用 observationHistory 过长</li>
* <li>单次工具结果超过阈值</li>
* <li>多轮观察已经足够回答但直接传给 FinalAnswerNode 过于冗长</li>
* </ul>
* <p>
* 使用 {@link NodeStreamingChatHelper} 进行流式调用实时推送 content/thinking 增量
*
* @author MateClaw Team
*/
@Slf4j
public class SummarizingNode implements NodeAction {
private static final String SYSTEM_PROMPT = PromptLoader.loadPrompt("graph/summarize-system");
private static final String USER_TEMPLATE = PromptLoader.loadPrompt("graph/summarize-user");
private final ChatModel chatModel;
private final NodeStreamingChatHelper streamingHelper;
private final ChatStreamTracker streamTracker;
public SummarizingNode(ChatModel chatModel, NodeStreamingChatHelper streamingHelper, ChatStreamTracker streamTracker) {
this.chatModel = chatModel;
this.streamingHelper = streamingHelper;
this.streamTracker = streamTracker;
}
public SummarizingNode(ChatModel chatModel, NodeStreamingChatHelper streamingHelper) {
this(chatModel, streamingHelper, null);
}
/**
* @deprecated Use constructor with NodeStreamingChatHelper
*/
@Deprecated
public SummarizingNode(ChatModel chatModel) {
this(chatModel, null, null);
}
@Override
public Map<String, Object> apply(OverAllState state) throws Exception {
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
// 取消检查
String cid = accessor.conversationId();
if (streamTracker != null && streamTracker.isStopRequested(cid)) {
log.info("[SummarizingNode] Stop requested, aborting");
throw new CancellationException("Stream stopped by user");
}
String userInput = accessor.userMessage();
String conversationId = accessor.conversationId();
List<String> observations = accessor.observationHistory();
log.info("[SummarizingNode] Summarizing {} observations ({} total chars) for user query",
observations.size(), accessor.totalObservationChars());
// 构建 summarize prompt
StringBuilder observationText = new StringBuilder();
for (int i = 0; i < observations.size(); i++) {
observationText.append(String.format("【第 %d 轮观察】\n%s\n\n", i + 1, observations.get(i)));
}
String userPrompt = USER_TEMPLATE
.replace("{question}", userInput)
.replace("{observations}", observationText.toString());
List<Message> promptMessages = new ArrayList<>();
promptMessages.add(new SystemMessage(SYSTEM_PROMPT));
promptMessages.add(new UserMessage(userPrompt));
// 流式调用 LLM实时推送 content/thinking
NodeStreamingChatHelper.StreamResult result = streamingHelper.streamCall(
chatModel, new Prompt(promptMessages), conversationId, "summarizing");
// 错误处理摘要失败时用原始观察的前 500 字符作为 fallback
if (result.hasFatalError()) {
log.warn("[SummarizingNode] Summarization LLM call failed: {}, using raw observations as fallback",
result.errorMessage());
String fallback = observationText.length() > 500
? observationText.substring(0, 500) + "...[摘要生成失败,已截断]"
: observationText.toString();
AssistantMessage fallbackMsg = new AssistantMessage("[工具观察摘要(降级)]\n" + fallback);
return MateClawStateAccessor.output()
.summarizedContext(fallback)
.shouldSummarize(false)
.put(OBSERVATION_HISTORY, List.of())
.messages(List.of((Message) fallbackMsg))
.contentStreamed(true)
.thinkingStreamed(true)
.mergeUsage(state, result)
.events(List.of(GraphEventPublisher.phase("summarize_fallback", Map.of(
"error", result.errorMessage() != null ? result.errorMessage() : "unknown"))))
.build();
}
// 用户主动停止将已生成的部分摘要写入 state 作为 finalAnswer + finalThinking
if (result.stopped()) {
String partialText = result.text() != null ? result.text() : "";
String partialThinking = result.thinking() != null ? result.thinking() : "";
log.info("[SummarizingNode] Stop requested with partial summary ({} chars, thinking {} chars), " +
"flushing to state before cancellation",
partialText.length(), partialThinking.length());
var builder = MateClawStateAccessor.output()
.summarizedContext(partialText)
.shouldSummarize(false)
.put(OBSERVATION_HISTORY, List.of())
.messages(List.of())
.finalAnswer(partialText)
.contentStreamed(true)
.mergeUsage(state, result)
.finishReason(FinishReason.STOPPED);
if (!partialThinking.isEmpty()) {
builder.finalThinking(partialThinking);
builder.thinkingStreamed(true);
}
return builder.build();
}
if (result.partial()) {
log.warn("[SummarizingNode] Partial summarization result, using available content");
}
String summarized = result.text();
log.info("[SummarizingNode] Generated summarized context: {} chars, " +
"clearing observation history and injecting into messages for next reasoning iteration",
summarized != null ? summarized.length() : 0);
// 将摘要注入 messages让下一轮 ReasoningNode 能看到之前的工具调用结论
String summaryContent = summarized != null ? summarized : "";
AssistantMessage summaryMessage = new AssistantMessage(
"[工具观察摘要]\n" + summaryContent);
return MateClawStateAccessor.output()
.summarizedContext(summaryContent)
.shouldSummarize(false)
// 清空观察历史REPLACE 策略防止下一轮立刻再次触发 summarize
.put(OBSERVATION_HISTORY, List.of())
// 注入摘要消息 ReasoningNode LLM 继续推理
.messages(List.of((Message) summaryMessage))
.currentThinking(result.thinking())
// 摘要的 content 已流式推送但它不是最终回答标记防重即可
.contentStreamed(true)
.thinkingStreamed(!result.thinking().isEmpty())
.mergeUsage(state, result)
// 不设 finishReason summarizing 不是终止循环继续
.events(List.of(GraphEventPublisher.phase("summarized", Map.of(
"observationCount", observations.size(),
"summaryChars", summaryContent.length()))))
.build();
}
}

View File

@ -0,0 +1,130 @@
package vip.mate.agent.graph.observation;
import lombok.extern.slf4j.Slf4j;
import vip.mate.config.GraphObservationProperties;
import java.util.List;
/**
* 观察结果处理器
* <p>
* 负责工具调用结果的标准化截断压缩以及 shouldSummarize 判断
* 防止 observation 无限膨胀保证传给 LLM 的上下文可控
*
* @author MateClaw Team
*/
@Slf4j
public class ObservationProcessor {
private final GraphObservationProperties properties;
public ObservationProcessor(GraphObservationProperties properties) {
this.properties = properties;
}
/**
* 获取最大总观察字符数供外部节点读取阈值
*/
public int getMaxTotalObservationChars() {
return properties.getMaxTotalObservationChars();
}
/**
* 标准化工具结果
* <p>
* 格式化为统一的 "[工具名] 结果" 结构方便 LLM summarizing 处理
*
* @param toolName 工具名称
* @param rawResult 原始工具返回
* @return 标准化后的观察文本
*/
public String normalize(String toolName, String rawResult) {
if (rawResult == null || rawResult.isBlank()) {
return String.format("[%s] 工具返回空结果", toolName);
}
String trimmed = rawResult.strip();
return String.format("[%s] %s", toolName, trimmed);
}
/**
* 截断大文本保留首尾关键片段
* <p>
* 保留前 40% 和后 60% 扣除标记长度后的内容
*
* @param text 原始文本
* @param maxLen 最大允许长度
* @return 截断后的文本
*/
public String truncate(String text, int maxLen) {
if (text == null || text.length() <= maxLen) {
return text;
}
int originalLen = text.length();
String marker = String.format(properties.getTruncationMarker(), originalLen);
int available = maxLen - marker.length();
if (available <= 0) {
return text.substring(0, maxLen);
}
int headLen = (int) (available * properties.getHeadRatio());
int tailLen = available - headLen;
String head = text.substring(0, headLen);
String tail = text.substring(originalLen - tailLen);
log.debug("[ObservationProcessor] Truncated observation from {} to {} chars", originalLen, maxLen);
return head + marker + tail;
}
/**
* 处理单次工具结果标准化 + 截断
*
* @param toolName 工具名
* @param rawResult 原始结果
* @return 处理后的观察文本
*/
public String process(String toolName, String rawResult) {
String normalized = normalize(toolName, rawResult);
return truncate(normalized, properties.getMaxSingleObservationChars());
}
/**
* 判断是否需要进入 summarizing 阶段
* <p>
* 触发条件任一满足即返回 true
* 1. 单次工具结果超过大结果阈值
* 2. 历史观察总字符数超过总量上限
* 3. 观察轮次 >= 最小轮次阈值
*
* @param observationHistory 已有的观察历史
* @param lastResult 最新一次工具结果处理后
* @return 是否需要 summarize
*/
public boolean needsSummarizing(List<String> observationHistory, String lastResult) {
// 条件 1单次结果过大
if (lastResult != null && lastResult.length() > properties.getLargeResultThreshold()) {
log.debug("[ObservationProcessor] Summarize triggered: large result ({} chars)", lastResult.length());
return true;
}
// 条件 2总量超限
int totalChars = observationHistory.stream().mapToInt(String::length).sum();
if (lastResult != null) {
totalChars += lastResult.length();
}
if (totalChars > properties.getMaxTotalObservationChars()) {
log.debug("[ObservationProcessor] Summarize triggered: total observations {} chars", totalChars);
return true;
}
// 条件 3轮次足够多
int rounds = observationHistory.size() + (lastResult != null ? 1 : 0);
if (rounds >= properties.getMinRoundsForSummarize()) {
log.debug("[ObservationProcessor] Summarize triggered: {} observation rounds", rounds);
return true;
}
return false;
}
}

View File

@ -0,0 +1,346 @@
package vip.mate.agent.graph.plan;
import com.alibaba.cloud.ai.graph.CompiledGraph;
import com.alibaba.cloud.ai.graph.RunnableConfig;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import vip.mate.agent.AgentService;
import vip.mate.agent.AgentState;
import vip.mate.agent.BaseAgent;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.StructuredStreamCapable;
import vip.mate.agent.graph.plan.state.PlanStateKeys;
import vip.mate.agent.graph.state.MateClawStateKeys;
import vip.mate.agent.context.ConversationWindowManager;
import vip.mate.planning.service.PlanningService;
import vip.mate.workspace.conversation.ConversationService;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
/**
* 基于 StateGraph Plan-Execute Agent
* <p>
* 使用 spring-ai-alibaba-graph-core StateGraph 引擎实现
* <ol>
* <li>简单问答快速退出PlanGenerationNode 前置判断</li>
* <li>多步任务规划 逐步执行带工具调用 汇总</li>
* </ol>
* <p>
* content_delta thinking_delta 由节点内 NodeStreamingChatHelper 直推
* chatStructuredStream() 只处理 phase/tool/plan/step 等结构化事件
* 不再从 NodeOutput 二次整段下发已流式推送的内容
*
* @author MateClaw Team
*/
@Slf4j
public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredStreamCapable {
private final CompiledGraph compiledGraph;
private final PlanningService planningService;
private final org.springframework.ai.chat.model.ChatModel chatModel;
private final ConversationWindowManager conversationWindowManager;
public StateGraphPlanExecuteAgent(ChatClient chatClient, ConversationService conversationService,
CompiledGraph compiledGraph, PlanningService planningService,
org.springframework.ai.chat.model.ChatModel chatModel,
ConversationWindowManager conversationWindowManager) {
super(chatClient, conversationService);
this.compiledGraph = compiledGraph;
this.planningService = planningService;
this.chatModel = chatModel;
this.conversationWindowManager = conversationWindowManager;
}
@Override
public Flux<AgentService.StreamDelta> chatStructuredStream(String userMessage, String conversationId) {
return chatStructuredStream(userMessage, conversationId, "");
}
@Override
public Flux<AgentService.StreamDelta> chatStructuredStream(String userMessage, String conversationId,
String requesterId) {
setState(AgentState.RUNNING);
try {
log.info("[{}] Plan-Execute structured stream: conversationId={}", agentName, conversationId);
Map<String, Object> inputs = buildInitialState(userMessage, conversationId);
inputs.put(MateClawStateKeys.REQUESTER_ID, requesterId != null ? requesterId : "");
return executeStream(inputs);
} catch (Exception e) {
setState(AgentState.ERROR);
return Flux.error(e);
}
}
@Override
public Flux<AgentService.StreamDelta> chatWithReplayStream(String userMessage, String conversationId,
String toolCallPayload) {
setState(AgentState.RUNNING);
try {
log.info("[{}] Plan-Execute replay stream: conversationId={}", agentName, conversationId);
Map<String, Object> inputs = buildInitialState(userMessage, conversationId);
// DB 恢复 awaiting_approval 状态的计划上下文
PlanningService.PlanResumeContext ctx = planningService.findAwaitingApprovalContext();
if (ctx != null) {
inputs.put(PlanStateKeys.PLAN_ID, ctx.planId());
inputs.put(PlanStateKeys.PLAN_STEPS, ctx.steps());
inputs.put(PlanStateKeys.NEEDS_PLANNING, true);
inputs.put(PlanStateKeys.PLAN_VALID, true);
inputs.put(PlanStateKeys.CURRENT_STEP_INDEX, ctx.awaitingStepIndex());
if (!ctx.completedResults().isEmpty()) {
inputs.put(PlanStateKeys.COMPLETED_RESULTS, ctx.completedResults());
// 重建 working context包含历史消息和已完成步骤结果
@SuppressWarnings("unchecked")
List<Message> messages = (List<Message>) inputs.get(MateClawStateKeys.MESSAGES);
// messages 中最后一条是当前 UserMessage去掉再算历史
List<Message> history = messages.size() > 1
? messages.subList(0, messages.size() - 1) : List.of();
inputs.put(PlanStateKeys.WORKING_CONTEXT,
buildWorkingContext(history, ctx.completedResults()));
}
log.info("[{}] Replay: restored plan {} at step {}/{}", agentName,
ctx.planId(), ctx.awaitingStepIndex(), ctx.steps().size());
} else {
log.warn("[{}] Replay: no awaiting-approval plan found, falling back to fresh run", agentName);
}
// 注入预批准的工具调用StepExecutionNode 匹配后跳过 ToolGuard
if (toolCallPayload != null && !toolCallPayload.isEmpty()) {
inputs.put(MateClawStateKeys.PRE_APPROVED_TOOL_CALL, toolCallPayload);
}
return executeStream(inputs);
} catch (Exception e) {
setState(AgentState.ERROR);
return Flux.error(e);
}
}
/** 公共流执行逻辑,由 chatStructuredStream 和 chatWithReplayStream 共用 */
private Flux<AgentService.StreamDelta> executeStream(Map<String, Object> inputs) {
String threadId = UUID.randomUUID().toString();
RunnableConfig config = RunnableConfig.builder().threadId(threadId).build();
AtomicInteger sentEventCount = new AtomicInteger(0);
AtomicInteger finalPromptTokens = new AtomicInteger(0);
AtomicInteger finalCompletionTokens = new AtomicInteger(0);
AtomicReference<String> finalModelName = new AtomicReference<>("");
AtomicReference<String> finalProviderId = new AtomicReference<>("");
// 去重记录上一次已持久化的 step 结果和 thinking防止 PlanSummaryNode 重复 emit 上一步内容
AtomicReference<String> lastPersistedStepResult = new AtomicReference<>("");
AtomicReference<String> lastPersistedStepThinking = new AtomicReference<>("");
return compiledGraph.stream(inputs, config)
.flatMapIterable(output -> {
List<AgentService.StreamDelta> deltas = new ArrayList<>();
// 1. 提取事件只发送新增部分
List<GraphEventPublisher.GraphEvent> allEvents = GraphEventPublisher.extractEvents(output);
int newStart = sentEventCount.get();
if (newStart < allEvents.size()) {
for (int i = newStart; i < allEvents.size(); i++) {
var event = allEvents.get(i);
deltas.add(AgentService.StreamDelta.event(event.type(), event.data()));
}
sentEventCount.set(allEvents.size());
}
// 2. 内容始终通过 StreamDelta 返回用于持久化已广播过的标记 persistOnly 避免重复推送
boolean contentAlreadyStreamed = output.state()
.value(MateClawStateKeys.CONTENT_STREAMED, false);
boolean thinkingAlreadyStreamed = output.state()
.value(MateClawStateKeys.THINKING_STREAMED, false);
// 2a. 各步骤执行结果StepExecutionNode 已通过 NodeStreamingChatHelper 直推 SSE
// 这里仅作为 persistOnly 送入 Accumulator确保写入 mate_message
// 利用内容本身去重避免 PlanSummaryNode 输出时重复 emit 上一步残留在 state 的值
output.state().<String>value(PlanStateKeys.CURRENT_STEP_RESULT)
.filter(s -> !s.isEmpty())
.filter(s -> !s.equals(lastPersistedStepResult.get()))
.ifPresent(stepContent -> {
deltas.add(AgentService.StreamDelta.persistOnly(stepContent, null));
lastPersistedStepResult.set(stepContent);
});
output.state().<String>value(PlanStateKeys.CURRENT_STEP_THINKING)
.filter(s -> !s.isEmpty())
.filter(s -> !s.equals(lastPersistedStepThinking.get()))
.ifPresent(stepThinking -> {
deltas.add(AgentService.StreamDelta.persistOnly(null, stepThinking));
lastPersistedStepThinking.set(stepThinking);
});
// 2b. 最终汇总
output.state().<String>value(PlanStateKeys.FINAL_SUMMARY)
.filter(s -> !s.isEmpty())
.ifPresent(summary -> deltas.add(contentAlreadyStreamed
? AgentService.StreamDelta.persistOnly(summary, null)
: new AgentService.StreamDelta(summary, null)));
output.state().<String>value(PlanStateKeys.FINAL_SUMMARY_THINKING)
.filter(s -> !s.isEmpty())
.ifPresent(thinking -> deltas.add(thinkingAlreadyStreamed
? AgentService.StreamDelta.persistOnly(null, thinking)
: new AgentService.StreamDelta(null, thinking)));
// 3. 更新最新累计 token usage
finalPromptTokens.set(output.state().value(MateClawStateKeys.PROMPT_TOKENS, 0));
finalCompletionTokens.set(output.state().value(MateClawStateKeys.COMPLETION_TOKENS, 0));
finalModelName.set(output.state().value(MateClawStateKeys.RUNTIME_MODEL_NAME, ""));
finalProviderId.set(output.state().value(MateClawStateKeys.RUNTIME_PROVIDER_ID, ""));
return deltas;
})
.concatWith(Mono.fromSupplier(() -> {
if (finalPromptTokens.get() > 0 || finalCompletionTokens.get() > 0) {
return AgentService.StreamDelta.event("_usage_final", Map.of(
"promptTokens", finalPromptTokens.get(),
"completionTokens", finalCompletionTokens.get(),
"runtimeModelName", finalModelName.get(),
"runtimeProviderId", finalProviderId.get()
));
}
return null;
}).flatMapMany(d -> d != null ? Flux.just(d) : Flux.empty()))
.doOnComplete(() -> setState(AgentState.IDLE))
.doOnError(e -> {
log.error("[{}] Plan-Execute stream error: {}", agentName, e.getMessage());
setState(AgentState.ERROR);
});
}
@Override
public String chat(String userMessage, String conversationId) {
// 委托到 chatStructuredStream过滤事件拼接内容
return chatStructuredStream(userMessage, conversationId)
.filter(delta -> !delta.isEvent() && delta.content() != null)
.map(AgentService.StreamDelta::content)
.collectList()
.map(chunks -> String.join("", chunks))
.block();
}
@Override
public Flux<String> chatStream(String userMessage, String conversationId) {
// 委托到 chatStructuredStream过滤事件只保留内容
return chatStructuredStream(userMessage, conversationId)
.filter(delta -> !delta.isEvent() && delta.content() != null)
.map(AgentService.StreamDelta::content);
}
@Override
public String execute(String goal, String conversationId) {
// chat()走同一套 Plan-Execute Graph
return chat(goal, conversationId);
}
private Map<String, Object> buildInitialState(String userMessage, String conversationId) {
// 加载会话历史复用 BaseAgent.buildConversationHistory ReAct 对齐
List<Message> historyMessages = buildConversationHistory(conversationId, userMessage);
// 上下文窗口管理裁剪超出模型 context window 的历史含当前消息预算
if (conversationWindowManager != null) {
historyMessages = conversationWindowManager.fitToWindow(
historyMessages,
systemPrompt != null ? systemPrompt : "",
userMessage,
maxInputTokens,
chatModel,
conversationId);
}
List<Message> messages = new ArrayList<>(historyMessages);
messages.add(new UserMessage(userMessage));
// 构建 working context对历史消息做受控长度摘要
String workingContext = buildWorkingContext(historyMessages, List.of());
Map<String, Object> inputs = new HashMap<>();
inputs.put(PlanStateKeys.GOAL, userMessage);
inputs.put(MateClawStateKeys.SYSTEM_PROMPT,
systemPrompt != null ? systemPrompt : "你是一个有帮助的AI助手。");
inputs.put(MateClawStateKeys.CONVERSATION_ID, conversationId);
inputs.put(MateClawStateKeys.AGENT_ID, agentId != null ? agentId : "");
// 注入会话消息复用 MateClawStateKeys.MESSAGES ReAct 一致
inputs.put(MateClawStateKeys.MESSAGES, messages);
// 注入 working context
inputs.put(PlanStateKeys.WORKING_CONTEXT, workingContext);
inputs.put(PlanStateKeys.CURRENT_STEP_INDEX, 0);
inputs.put(MateClawStateKeys.CONTENT_STREAMED, false);
inputs.put(MateClawStateKeys.THINKING_STREAMED, false);
inputs.put(MateClawStateKeys.STREAMED_CONTENT, "");
inputs.put(MateClawStateKeys.STREAMED_THINKING, "");
inputs.put(MateClawStateKeys.REQUESTER_ID, "");
inputs.put(MateClawStateKeys.PROMPT_TOKENS, 0);
inputs.put(MateClawStateKeys.COMPLETION_TOKENS, 0);
inputs.put(MateClawStateKeys.RUNTIME_MODEL_NAME, modelName != null ? modelName : "");
inputs.put(MateClawStateKeys.RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
inputs.put(MateClawStateKeys.TRACE_ID, UUID.randomUUID().toString().substring(0, 8));
return inputs;
}
/**
* 构建受控长度的 working context
* <p>
* 将会话历史 + 已完成步骤结果压缩为结构化摘要块
* 避免 prompt 随对话和步骤执行无限膨胀
* <p>
* 规则
* <ul>
* <li>历史消息保留最近 MAX_HISTORY_MESSAGES 每条截断至 MAX_MSG_CHARS 字符</li>
* <li>步骤结果保留最近 MAX_STEP_RESULTS 每条截断至 MAX_STEP_CHARS 字符</li>
* <li>总体截断至 MAX_CONTEXT_CHARS 字符</li>
* </ul>
*/
static String buildWorkingContext(List<Message> historyMessages, List<String> completedResults) {
StringBuilder sb = new StringBuilder();
// 历史消息摘要
if (historyMessages != null && !historyMessages.isEmpty()) {
sb.append("=== 对话历史摘要 ===\n");
int startIdx = Math.max(0, historyMessages.size() - MAX_HISTORY_MESSAGES);
for (int i = startIdx; i < historyMessages.size(); i++) {
Message msg = historyMessages.get(i);
String role = msg.getMessageType().name().toLowerCase();
String content = msg.getText();
if (content != null && !content.isEmpty()) {
String truncated = content.length() > MAX_MSG_CHARS
? content.substring(0, MAX_MSG_CHARS) + "" : content;
sb.append("[").append(role).append("] ").append(truncated).append("\n");
}
}
sb.append("\n");
}
// 已完成步骤结果摘要
if (completedResults != null && !completedResults.isEmpty()) {
sb.append("=== 已完成步骤结果 ===\n");
int startIdx = Math.max(0, completedResults.size() - MAX_STEP_RESULTS);
for (int i = startIdx; i < completedResults.size(); i++) {
String result = completedResults.get(i);
String truncated = result.length() > MAX_STEP_CHARS
? result.substring(0, MAX_STEP_CHARS) + "" : result;
sb.append(truncated).append("\n");
}
}
// 总体截断
String context = sb.toString();
if (context.length() > MAX_CONTEXT_CHARS) {
context = context.substring(0, MAX_CONTEXT_CHARS) + "\n…上下文已截断";
}
return context;
}
// Working context 长度控制参数
private static final int MAX_HISTORY_MESSAGES = 10;
private static final int MAX_MSG_CHARS = 500;
private static final int MAX_STEP_RESULTS = 5;
private static final int MAX_STEP_CHARS = 800;
private static final int MAX_CONTEXT_CHARS = 6000;
}

View File

@ -0,0 +1,28 @@
package vip.mate.agent.graph.plan.edge;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.EdgeAction;
import vip.mate.agent.graph.plan.state.PlanStateKeys;
/**
* 计划生成后的路由分发器
* <p>
* 根据 needs_planning 判断
* <ul>
* <li>false 路由到 DIRECT_ANSWER_NODE简单问答快速退出</li>
* <li>true 路由到 STEP_EXECUTION_NODE开始步骤执行</li>
* </ul>
*
* @author MateClaw Team
*/
public class PlanGenerationDispatcher implements EdgeAction {
@Override
public String apply(OverAllState state) {
boolean needsPlanning = state.value(PlanStateKeys.NEEDS_PLANNING, true);
if (!needsPlanning) {
return PlanStateKeys.DIRECT_ANSWER_NODE;
}
return PlanStateKeys.STEP_EXECUTION_NODE;
}
}

View File

@ -0,0 +1,41 @@
package vip.mate.agent.graph.plan.edge;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.StateGraph;
import com.alibaba.cloud.ai.graph.action.EdgeAction;
import vip.mate.agent.graph.plan.state.PlanStateKeys;
import vip.mate.agent.graph.state.MateClawStateKeys;
import java.util.List;
/**
* 步骤进度分发器
* <p>
* 根据 current_phase current_step_index / plan_steps 判断路由
* <ul>
* <li>current_phase == "awaiting_approval" END暂停图执行等待用户审批后 replay</li>
* <li>当前步骤索引 &lt; 步骤总数 继续执行下一步STEP_EXECUTION_NODE</li>
* <li>所有步骤完成 路由到汇总节点PLAN_SUMMARY_NODE</li>
* </ul>
*
* @author MateClaw Team
*/
public class StepProgressDispatcher implements EdgeAction {
@Override
@SuppressWarnings("unchecked")
public String apply(OverAllState state) {
// 审批暂停态或步骤执行失败中止态直接结束当前图 tick
String currentPhase = state.value(MateClawStateKeys.CURRENT_PHASE, "");
if ("awaiting_approval".equals(currentPhase) || "plan_aborted".equals(currentPhase)) {
return StateGraph.END;
}
int currentIndex = state.value(PlanStateKeys.CURRENT_STEP_INDEX, 0);
List<String> steps = state.<List<String>>value(PlanStateKeys.PLAN_STEPS).orElse(List.of());
if (currentIndex >= steps.size()) {
return PlanStateKeys.PLAN_SUMMARY_NODE;
}
return PlanStateKeys.STEP_EXECUTION_NODE;
}
}

View File

@ -0,0 +1,24 @@
package vip.mate.agent.graph.plan.node;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import vip.mate.agent.graph.plan.state.PlanStateKeys;
import java.util.Map;
/**
* 直接回答节点
* <p>
* PlanGenerationNode 判定用户消息是简单问答时
* direct_answer 透传为 final_summary直接结束图执行
*
* @author MateClaw Team
*/
public class DirectAnswerNode implements NodeAction {
@Override
public Map<String, Object> apply(OverAllState state) {
String directAnswer = state.value(PlanStateKeys.DIRECT_ANSWER, "");
return Map.of(PlanStateKeys.FINAL_SUMMARY, directAnswer);
}
}

View File

@ -0,0 +1,258 @@
package vip.mate.agent.graph.plan.node;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.Prompt;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.graph.NodeStreamingChatHelper;
import vip.mate.agent.graph.plan.state.PlanStateAccessor;
import vip.mate.agent.graph.plan.state.PlanStateKeys;
import vip.mate.agent.graph.state.MateClawStateKeys;
import vip.mate.agent.context.ConversationWindowManager;
import vip.mate.planning.service.PlanningService;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 计划生成节点
* <p>
* 职责
* <ol>
* <li>判断是否需要规划简单问答快速退出</li>
* <li>如需规划生成计划 JSON解析校验</li>
* <li> PlanningService.createPlan() 持久化</li>
* <li>发布 plan_created 事件</li>
* </ol>
* <p>
* 使用 {@link NodeStreamingChatHelper} 进行流式调用
* 即便最终返回 JSON也允许模型的 planning 输出以流式产生最终再聚合解析
* 直接回答路径也通过流式 helper 实时输出给前端
*
* @author MateClaw Team
*/
@Slf4j
public class PlanGenerationNode implements NodeAction {
private final ChatModel chatModel;
private final PlanningService planningService;
private final NodeStreamingChatHelper streamingHelper;
private final ConversationWindowManager conversationWindowManager;
private final ObjectMapper objectMapper = new ObjectMapper();
private static final String PLANNING_PROMPT = """
你是任务规划器不是聊天助手
你的输出必须满足以下规则
1. 只能返回一个 JSON 对象
2. 不允许输出任何 JSON 之外的文字
3. 不允许使用 markdown 代码块
4. 不要解释不要寒暄不要先说"我来...""我先..."
返回格式二选一
不需要规划时
{"needs_planning": false, "direct_answer": "..."}
需要规划时
{"needs_planning": true, "steps": ["步骤1", "步骤2", "步骤3"]}
要求
- steps 数量 2 6
- 每个步骤必须是可执行动作不要写空话
- 默认不要把 MEMORY.mdPROFILE.md记忆文件当成独立步骤但如果用户目标明显依赖历史偏好长期约束过往决策或持续上下文可以加入必要的记忆读取步骤
- 不要把技能文件当成独立步骤除非用户任务明确要求
- 如果用户目标包含执行修改搜索分析生成文件调用工具等多步行为优先返回规划
- 如果无法确定也必须返回合法 JSON不能输出自然语言
""";
public PlanGenerationNode(ChatModel chatModel, PlanningService planningService,
NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager) {
this.chatModel = chatModel;
this.planningService = planningService;
this.streamingHelper = streamingHelper;
this.conversationWindowManager = conversationWindowManager;
}
/**
* @deprecated Use constructor with NodeStreamingChatHelper
*/
@Deprecated
public PlanGenerationNode(ChatModel chatModel, PlanningService planningService) {
this(chatModel, planningService, null, null);
}
@Override
public Map<String, Object> apply(OverAllState state) throws Exception {
PlanStateAccessor accessor = new PlanStateAccessor(state);
String goal = accessor.goal();
String systemPrompt = accessor.systemPrompt();
String agentId = state.value(MateClawStateKeys.TRACE_ID, "unknown");
String conversationId = accessor.conversationId();
log.info("[PlanGeneration] Evaluating goal: {}", goal.length() > 100 ? goal.substring(0, 100) + "..." : goal);
List<GraphEventPublisher.GraphEvent> events = new ArrayList<>();
events.add(GraphEventPublisher.phase("planning", Map.of("goal", goal)));
// Replay 模式计划已在 state chatWithReplayStream 注入直接跳过 LLM
Long existingPlanId = state.<Long>value(PlanStateKeys.PLAN_ID).orElse(null);
if (existingPlanId != null) {
List<String> existingSteps = accessor.planSteps();
int resumeIndex = accessor.currentStepIndex();
log.info("[PlanGeneration] Replay mode — reusing plan {} at step {}/{}", existingPlanId, resumeIndex, existingSteps.size());
return PlanStateAccessor.output()
.needsPlanning(true)
.planId(existingPlanId)
.planSteps(existingSteps)
.planValid(true)
.currentStepIndex(resumeIndex)
.currentPhase("plan_generated")
.events(events)
.build();
}
try {
// 构建 prompt 消息列表system + 历史上下文 + 当前规划请求
List<Message> promptMessages = new ArrayList<>();
promptMessages.add(new SystemMessage(systemPrompt + "\n\n" + PLANNING_PROMPT));
// 注入 working context对话历史摘要让规划能感知之前对话的约束和补充条件
String workingContext = accessor.workingContext();
if (!workingContext.isEmpty()) {
promptMessages.add(new UserMessage(
"以下是此前对话中用户提出的约束、说明和上下文,请在规划时充分考虑:\n\n"
+ workingContext));
}
promptMessages.add(new UserMessage("用户目标:" + goal));
Prompt prompt = new Prompt(promptMessages);
// 静默流式调用 LLM 返回结构化 JSON不直接推送给前端
NodeStreamingChatHelper.StreamResult result = streamingHelper.streamCallSilent(
chatModel, prompt, conversationId, "plan_generation");
// PTL 处理压缩后重试
if (result.isPromptTooLong() && conversationWindowManager != null) {
log.warn("[PlanGeneration] Prompt too long, attempting compaction and retry");
List<Message> compactedMessages = conversationWindowManager.compactForRetry(
promptMessages.subList(1, promptMessages.size()));
if (compactedMessages != null) {
List<Message> retryMessages = new ArrayList<>();
retryMessages.add(promptMessages.get(0));
retryMessages.addAll(compactedMessages);
result = streamingHelper.streamCallSilent(
chatModel, new Prompt(retryMessages), conversationId, "plan_generation_compact_retry");
}
}
String llmResponse = result.text();
log.debug("[PlanGeneration] LLM response: {}", llmResponse);
// 清理 markdown 代码块标记
String cleanedJson = cleanJsonResponse(llmResponse);
// 解析 JSON
Map<String, Object> parsed = objectMapper.readValue(cleanedJson, new TypeReference<>() {});
boolean needsPlanning = Boolean.TRUE.equals(parsed.get("needs_planning"));
if (!needsPlanning) {
// 简单问答快速退出 解析出 direct_answer 后手动推送给前端
String directAnswer = parsed.get("direct_answer") != null
? parsed.get("direct_answer").toString() : llmResponse;
log.info("[PlanGeneration] Simple question detected, returning direct answer");
// 手动广播 direct_answer 文本而不是原始 JSON
streamingHelper.broadcastContent(conversationId, directAnswer);
return PlanStateAccessor.output()
.needsPlanning(false)
.directAnswer(directAnswer)
.currentPhase("direct_answer")
.contentStreamed(true)
.thinkingStreamed(!result.thinking().isEmpty())
.mergeUsage(state, result)
.events(events)
.build();
}
// 需要规划提取步骤
@SuppressWarnings("unchecked")
List<String> steps = (List<String>) parsed.get("steps");
if (steps == null || steps.isEmpty()) {
log.warn("[PlanGeneration] LLM returned needs_planning=true but empty steps, falling back to direct answer");
return PlanStateAccessor.output()
.needsPlanning(false)
.directAnswer(llmResponse)
.currentPhase("direct_answer")
.contentStreamed(true)
.thinkingStreamed(!result.thinking().isEmpty())
.mergeUsage(state, result)
.events(events)
.build();
}
// 持久化计划
var plan = planningService.createPlan(agentId, goal, steps);
log.info("[PlanGeneration] Plan created: id={}, steps={}", plan.getId(), steps.size());
// 发布 plan_created 事件
events.add(GraphEventPublisher.planCreated(plan.getId(), steps));
return PlanStateAccessor.output()
.needsPlanning(true)
.planId(plan.getId())
.planSteps(steps)
.planValid(true)
.currentStepIndex(0)
.currentPhase("plan_generated")
.contentStreamed(true)
.thinkingStreamed(!result.thinking().isEmpty())
.mergeUsage(state, result)
.events(events)
.build();
} catch (Exception e) {
log.error("[PlanGeneration] Failed to generate plan: {}", e.getMessage(), e);
// 降级作为简单问答处理不向前端暴露内部异常细节
return PlanStateAccessor.output()
.needsPlanning(false)
.directAnswer("抱歉,我暂时无法完成规划,请重试或换一种方式描述任务。")
.currentPhase("direct_answer")
.events(events)
.build();
}
}
/**
* 清理 LLM 返回的 JSON移除可能的 markdown 代码块标记
* 若响应中不包含合法的 JSON 对象抛出异常让调用方走降级路径
*/
private String cleanJsonResponse(String response) {
if (response == null) {
throw new IllegalArgumentException("LLM returned null response");
}
String cleaned = response.trim();
if (cleaned.startsWith("```")) {
cleaned = cleaned.replaceAll("```json?\\n?", "").replaceAll("```", "").trim();
}
// 找到第一个 { 和最后一个 }
int start = cleaned.indexOf('{');
int end = cleaned.lastIndexOf('}');
if (start < 0 || end <= start) {
throw new IllegalArgumentException(
"LLM response does not contain a valid JSON object: " + cleaned.substring(0, Math.min(80, cleaned.length())));
}
return cleaned.substring(start, end + 1);
}
}

View File

@ -0,0 +1,122 @@
package vip.mate.agent.graph.plan.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.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.Prompt;
import vip.mate.agent.graph.NodeStreamingChatHelper;
import vip.mate.agent.graph.plan.state.PlanStateAccessor;
import vip.mate.agent.graph.plan.state.PlanStateKeys;
import vip.mate.agent.graph.state.MateClawStateKeys;
import vip.mate.planning.service.PlanningService;
import java.util.List;
import java.util.Map;
/**
* 计划汇总节点
* <p>
* 汇总所有步骤结果 LLM 生成最终总结
* planningService.completePlan() 标记计划完成
* <p>
* 使用 {@link NodeStreamingChatHelper} 进行流式调用实时推送 content/thinking 增量
*
* @author MateClaw Team
*/
@Slf4j
public class PlanSummaryNode implements NodeAction {
private final ChatModel chatModel;
private final PlanningService planningService;
private final NodeStreamingChatHelper streamingHelper;
public PlanSummaryNode(ChatModel chatModel, PlanningService planningService,
NodeStreamingChatHelper streamingHelper) {
this.chatModel = chatModel;
this.planningService = planningService;
this.streamingHelper = streamingHelper;
}
/**
* @deprecated Use constructor with NodeStreamingChatHelper
*/
@Deprecated
public PlanSummaryNode(ChatModel chatModel, PlanningService planningService) {
this(chatModel, planningService, null);
}
@Override
public Map<String, Object> apply(OverAllState state) throws Exception {
PlanStateAccessor accessor = new PlanStateAccessor(state);
Long planId = accessor.planId();
String goal = accessor.goal();
List<String> completedResults = accessor.completedResults();
String conversationId = accessor.conversationId();
String workingContext = accessor.workingContext();
log.info("[PlanSummary] Summarizing plan {}: {} completed results", planId, completedResults.size());
try {
// 构建汇总 prompt结合 working context 和步骤结果
StringBuilder userContent = new StringBuilder();
userContent.append("原始目标:").append(goal).append("\n\n");
// 注入 working context包含对话历史摘要让汇总感知用户此前提过的要求
if (workingContext != null && !workingContext.isEmpty()) {
userContent.append("对话上下文:\n").append(workingContext).append("\n\n");
}
userContent.append("执行结果:\n").append(String.join("\n", completedResults));
Prompt prompt = new Prompt(List.of(
new SystemMessage("请根据以下各步骤的执行结果,给出一个简洁完整的总结回答。"
+ "直接回答用户的原始问题,不要罗列步骤。"
+ "如果对话上下文中包含用户的特殊要求(如风格、语言、格式等),请在总结中体现。"),
new UserMessage(userContent.toString())
));
// 流式调用 LLM实时推送 content/thinking
NodeStreamingChatHelper.StreamResult result = streamingHelper.streamCall(
chatModel, prompt, conversationId, "plan_summary");
String summary = result.text();
planningService.completePlan(planId, summary);
log.info("[PlanSummary] Plan {} completed with summary: {}",
planId, summary.length() > 100 ? summary.substring(0, 100) + "..." : summary);
return PlanStateAccessor.output()
.finalSummary(summary)
.finalSummaryThinking(result.thinking())
.contentStreamed(true)
.thinkingStreamed(!result.thinking().isEmpty())
.mergeUsage(state, result)
.build();
} catch (Exception e) {
log.error("[PlanSummary] Failed to summarize plan {}: {}", planId, e.getMessage(), e);
String fallbackSummary = buildFallbackSummary(goal, completedResults);
planningService.markPlanFailed(planId, "汇总阶段失败:" + truncate(e.getMessage(), 100));
return Map.of(PlanStateKeys.FINAL_SUMMARY, fallbackSummary);
}
}
/**
* LLM 汇总调用失败时生成本地 fallback 摘要
* 每条步骤结果截断至 300 避免把过长内容包括错误体直接暴露给用户
*/
private static String buildFallbackSummary(String goal, List<String> completedResults) {
StringBuilder sb = new StringBuilder("目标:").append(goal).append("\n\n执行摘要LLM 汇总失败,以下为步骤原始结果):\n");
for (String r : completedResults) {
sb.append(truncate(r, 300)).append("\n");
}
return sb.toString();
}
private static String truncate(String s, int maxLen) {
if (s == null) return "";
return s.length() > maxLen ? s.substring(0, maxLen) + "" : s;
}
}

View File

@ -0,0 +1,458 @@
package vip.mate.agent.graph.plan.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.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.util.StringUtils;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import vip.mate.agent.AgentToolSet;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.graph.NodeStreamingChatHelper;
import vip.mate.agent.graph.plan.state.PlanStateAccessor;
import vip.mate.agent.graph.plan.state.PlanStateKeys;
import vip.mate.agent.graph.state.MateClawStateKeys;
import vip.mate.agent.context.ConversationWindowManager;
import vip.mate.agent.graph.executor.ToolExecutionExecutor;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.planning.service.PlanningService;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 步骤执行节点
* <p>
* 执行当前步骤使用显式工具执行循环internalToolExecutionEnabled=false
* 单步最大工具调用次数限制为 5 防止无限循环
* <p>
* 支持 NEEDS_APPROVAL 审批流程对需要审批的工具调用创建 pending
* 发出 SSE 事件后立即返回审批提示非阻塞审批通过后通过 replay 重新执行
*
* @author MateClaw Team
*/
@Slf4j
public class StepExecutionNode implements NodeAction {
private final ChatModel chatModel;
private final AgentToolSet toolSet;
private final ToolExecutionExecutor executor;
private final PlanningService planningService;
private final ChatStreamTracker streamTracker;
private final ConversationWindowManager conversationWindowManager;
private final String reasoningEffort;
private final NodeStreamingChatHelper streamingHelper;
private static final int MAX_TOOL_CALLS_PER_STEP = 5;
private static final ObjectMapper MAPPER = new ObjectMapper();
public StepExecutionNode(ChatModel chatModel, AgentToolSet toolSet,
ToolExecutionExecutor executor,
PlanningService planningService,
ChatStreamTracker streamTracker,
String reasoningEffort, NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager) {
this.chatModel = chatModel;
this.toolSet = toolSet;
this.executor = executor;
this.planningService = planningService;
this.streamTracker = streamTracker;
this.conversationWindowManager = conversationWindowManager;
this.reasoningEffort = reasoningEffort;
this.streamingHelper = streamingHelper;
}
@Override
@SuppressWarnings("unchecked")
public Map<String, Object> apply(OverAllState state) throws Exception {
PlanStateAccessor accessor = new PlanStateAccessor(state);
int stepIndex = accessor.currentStepIndex();
List<String> steps = accessor.planSteps();
Long planId = accessor.planId();
String systemPrompt = accessor.systemPrompt();
String conversationId = state.value(MateClawStateKeys.CONVERSATION_ID, "");
String agentId = state.value(MateClawStateKeys.AGENT_ID, "");
if (stepIndex >= steps.size()) {
log.warn("[StepExecution] stepIndex {} >= steps.size() {}, skipping", stepIndex, steps.size());
return PlanStateAccessor.output()
.currentStepResult("步骤索引越界")
.completedResults(formatStepResult(stepIndex, "步骤索引越界"))
.currentStepIndex(stepIndex + 1)
.build();
}
String step = steps.get(stepIndex);
log.info("[StepExecution] Executing step {}/{}: {}", stepIndex + 1, steps.size(), step);
List<GraphEventPublisher.GraphEvent> events = new ArrayList<>();
events.add(GraphEventPublisher.stepStarted(stepIndex, step));
events.add(GraphEventPublisher.phase("executing", Map.of("stepIndex", stepIndex, "stepTitle", step)));
planningService.updateSubPlanStatus(planId, stepIndex, "running");
// 构建消息列表
List<Message> messages = buildStepMessages(accessor, step, systemPrompt);
// 显式工具执行循环
String finalResult = null;
String stepThinking = "";
int toolCallCount = 0;
boolean approvalTriggered = false;
String approvalToolName = null;
int stepPromptTokens = 0;
int stepCompletionTokens = 0;
try {
while (toolCallCount < MAX_TOOL_CALLS_PER_STEP) {
ChatOptions options;
if (StringUtils.hasText(reasoningEffort)) {
OpenAiChatOptions oaiOpts = OpenAiChatOptions.builder()
.toolCallbacks(toolSet.callbacks())
.reasoningEffort(reasoningEffort)
.build();
oaiOpts.setInternalToolExecutionEnabled(false);
options = oaiOpts;
} else {
options = ToolCallingChatOptions.builder()
.toolCallbacks(toolSet.callbacks())
.internalToolExecutionEnabled(false)
.build();
}
NodeStreamingChatHelper.StreamResult result = streamingHelper.streamCall(
chatModel, new Prompt(messages, options), conversationId,
"step_execution[" + stepIndex + "]");
// PTL 处理压缩后重试
if (result.isPromptTooLong() && conversationWindowManager != null) {
log.warn("[StepExecution] Prompt too long at step {}, attempting compaction", stepIndex);
List<Message> compactedMessages = conversationWindowManager.compactForRetry(
messages.subList(1, messages.size()));
if (compactedMessages != null) {
List<Message> retryMessages = new ArrayList<>();
retryMessages.add(messages.get(0));
retryMessages.addAll(compactedMessages);
result = streamingHelper.streamCall(
chatModel, new Prompt(retryMessages, options), conversationId,
"step_execution_compact_retry[" + stepIndex + "]");
}
}
stepPromptTokens += result.promptTokens();
stepCompletionTokens += result.completionTokens();
if (!result.thinking().isEmpty()) {
stepThinking = result.thinking();
}
messages.add(result.assistantMessage());
if (!result.hasToolCalls()) {
finalResult = result.text();
break;
}
// 手动执行 tool calls
List<ToolResponseMessage.ToolResponse> toolResponses = new ArrayList<>();
List<AssistantMessage.ToolCall> allToolCalls = result.toolCalls();
// state 读取预批准的工具调用replay 注入
String preApprovedPayload = state.value(MateClawStateKeys.PRE_APPROVED_TOOL_CALL, "");
if (!preApprovedPayload.isEmpty()) {
// Replay 路径处理预批准工具
for (AssistantMessage.ToolCall toolCall : allToolCalls) {
if (isPreApprovedToolCall(toolCall.name(), preApprovedPayload)) {
String storedArguments = extractArgumentsFromPayload(preApprovedPayload);
events.add(GraphEventPublisher.toolStart(toolCall.name(), toolCall.arguments()));
ToolResponseMessage.ToolResponse response = executor.executePreApproved(
toolCall, storedArguments, events);
toolResponses.add(response);
preApprovedPayload = ""; // 只消费一次
} else {
// 非预批准工具走正常执行器
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
List.of(toolCall), conversationId, agentId, false);
toolResponses.addAll(execResult.responses());
events.addAll(execResult.events());
if (execResult.awaitingApproval()) {
approvalTriggered = true;
approvalToolName = toolCall.name();
break;
}
}
}
} else {
// 正常路径委托 ToolExecutionExecutor支持并发执行 + 审批 barrier
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
allToolCalls, conversationId, agentId, false);
toolResponses.addAll(execResult.responses());
events.addAll(execResult.events());
if (execResult.awaitingApproval()) {
approvalTriggered = true;
approvalToolName = execResult.barrierToolName() != null
? execResult.barrierToolName() : "unknown";
}
}
// 将工具响应追加到消息
ToolResponseMessage toolResponseMessage = ToolResponseMessage.builder()
.responses(toolResponses)
.build();
messages.add(toolResponseMessage);
toolCallCount++;
// 如果审批触发退出 while 循环
if (approvalTriggered) {
break;
}
}
// 处理审批暂停
if (approvalTriggered) {
planningService.updateSubPlanStatus(planId, stepIndex, "awaiting_approval");
String awaitingResult = "[APPROVAL_PENDING] " + approvalToolName + " awaiting user decision";
return PlanStateAccessor.output()
.currentStepResult(awaitingResult)
.currentStepIndex(stepIndex) // 不递增下次重放从同一步开始
.currentPhase("awaiting_approval")
.contentStreamed(true)
.thinkingStreamed(!stepThinking.isEmpty())
.put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
.put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
.events(events)
.build();
}
if (finalResult == null) {
finalResult = "步骤执行超过最大工具调用次数限制(" + MAX_TOOL_CALLS_PER_STEP + "次)";
log.warn("[StepExecution] Step {} exceeded max tool call limit", stepIndex);
}
} catch (Exception e) {
log.error("[StepExecution] Step {} execution failed: {}", stepIndex, e.getMessage(), e);
String shortError = summarizeError(e);
planningService.updateSubPlanFailure(planId, stepIndex, shortError);
planningService.markPlanFailed(planId, "步骤" + (stepIndex + 1) + " 执行失败:" + shortError);
events.add(GraphEventPublisher.stepCompleted(stepIndex, shortError));
return PlanStateAccessor.output()
.currentStepResult(shortError)
.currentPhase("plan_aborted")
.contentStreamed(false)
.put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
.put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
.events(events)
.build();
}
planningService.updateSubPlanResult(planId, stepIndex, finalResult);
events.add(GraphEventPublisher.stepCompleted(stepIndex, finalResult));
log.info("[StepExecution] Step {}/{} completed: {}",
stepIndex + 1, steps.size(),
finalResult.length() > 100 ? finalResult.substring(0, 100) + "..." : finalResult);
// 更新 working context将最新完成的步骤结果纳入摘要
List<String> allCompleted = new ArrayList<>(accessor.completedResults());
allCompleted.add(formatStepResult(stepIndex, finalResult));
String updatedWorkingContext = rebuildWorkingContext(accessor, allCompleted);
return PlanStateAccessor.output()
.currentStepResult(finalResult)
.completedResults(formatStepResult(stepIndex, finalResult))
.currentStepIndex(stepIndex + 1)
.currentStepThinking(stepThinking)
.workingContext(updatedWorkingContext)
.currentPhase("step_completed")
.contentStreamed(true)
.thinkingStreamed(!stepThinking.isEmpty())
.put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
.put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
.events(events)
.build();
}
private List<Message> buildStepMessages(PlanStateAccessor accessor, String step, String systemPrompt) {
List<Message> messages = new ArrayList<>();
// Layer 1: System prompt增强指令
String enhancedSystemPrompt = systemPrompt + """
你是任务执行器只负责执行"当前步骤"
硬性规则
1. 不要先解释你要做什么直接行动
2. 如果需要工具直接调用工具不要先用自然语言描述
3. 如果某个工具进入审批等待立刻停止不要改写命令重试不要继续调用其他工具
4. 默认不要额外读取 MEMORY.mdPROFILE.md memory/ 每日日记但如果当前步骤明显依赖历史偏好既有决策长期约束或持续上下文可以做一次必要的记忆读取
5. 不要输出"我来先看一下""现在我来..."之类的过程话术
6. 当前步骤完成后只返回这一步的结果不要总结整个任务
7. 如果前一步已经有结果默认信任不要重复验证除非当前步骤必须依赖再次确认
8. 每一步最多做一个必要的检查和一个必要的执行不要无意义循环
""";
messages.add(new SystemMessage(enhancedSystemPrompt));
// Layer 2: Working context对话历史 + 步骤结果的受控长度摘要
String workingContext = accessor.workingContext();
if (!workingContext.isEmpty()) {
messages.add(new UserMessage(
"以下是此前对话上下文和已完成工作的摘要,请参考但不必重复验证:\n\n"
+ workingContext));
}
// Layer 3: Plan context + current step instruction
List<String> steps = accessor.planSteps();
int currentIndex = accessor.currentStepIndex();
List<String> completedResults = accessor.completedResults();
StringBuilder context = new StringBuilder();
context.append("总目标:").append(accessor.goal()).append("\n\n");
// 展示计划全貌步骤标题列表让执行器知道自己在整个流程中的位置
context.append("执行计划(共 ").append(steps.size()).append(" 步):\n");
for (int i = 0; i < steps.size(); i++) {
String status = i < currentIndex ? "" : (i == currentIndex ? "" : "");
context.append(" ").append(status).append(" 步骤").append(i + 1).append("").append(steps.get(i)).append("\n");
}
context.append("\n");
// Layer 4: 最近完成步骤结果精简后避免与 working context 重复太多
if (!completedResults.isEmpty()) {
context.append("最近完成的步骤结果:\n");
// 只保留最近 3 每条截断至 500
List<String> recentResults = completedResults.size() > 3
? completedResults.subList(completedResults.size() - 3, completedResults.size())
: completedResults;
for (String result : recentResults) {
String summary = result.length() > 500 ? result.substring(0, 500) + "" : result;
context.append(summary).append("\n");
}
context.append("\n");
}
// Layer 5: Current step instruction
context.append("当前需要执行的步骤(第 ").append(currentIndex + 1).append(" 步):").append(step);
context.append("\n\n请执行当前步骤并给出结果。");
messages.add(new UserMessage(context.toString()));
return messages;
}
private String formatStepResult(int stepIndex, String result) {
return String.format("步骤%d结果%s", stepIndex + 1, result);
}
/**
* 判断当前工具调用是否与预批准 payload 中的工具名匹配
* payload 格式: {"name":"toolName","arguments":"...","status":"running"}
*/
private boolean isPreApprovedToolCall(String toolName, String preApprovedPayload) {
if (preApprovedPayload == null || preApprovedPayload.isEmpty()) return false;
try {
JsonNode node = MAPPER.readTree(preApprovedPayload);
String approvedName = node.path("name").asText("");
return toolName.equals(approvedName);
} catch (Exception e) {
log.warn("[StepExecution] Failed to parse pre-approved payload: {}", e.getMessage());
return false;
}
}
/**
* 将异常转换为简短的错误摘要避免将完整异常体尤其是 429 JSON写入后续 prompt
* <ul>
* <li>限流错误429 / rate_limit / overloaded 固定简短提示</li>
* <li>其他错误 取前 200 字符</li>
* </ul>
*/
private static String summarizeError(Exception e) {
String msg = e.getMessage();
if (msg == null) {
msg = e.getClass().getSimpleName();
}
String lower = msg.toLowerCase();
if (lower.contains("429") || lower.contains("rate limit") || lower.contains("rate_limit")
|| lower.contains("too many requests") || lower.contains("overloaded")) {
return "LLM 限流rate limit请稍后重试";
}
return msg.length() > 200 ? msg.substring(0, 200) + "" : msg;
}
/**
* 从预批准 payload 中提取完整的 arguments 字符串
* 审批创建时存储的是原始完整参数优先使用避免 LLM 流式截断导致 JSON 残缺
*
* @return arguments 字符串若解析失败返回 null调用方回退到 LLM 流式参数
*/
private String extractArgumentsFromPayload(String preApprovedPayload) {
if (preApprovedPayload == null || preApprovedPayload.isEmpty()) return null;
try {
JsonNode node = MAPPER.readTree(preApprovedPayload);
JsonNode argsNode = node.path("arguments");
if (argsNode.isMissingNode() || argsNode.isNull()) return null;
return argsNode.asText();
} catch (Exception e) {
log.warn("[StepExecution] Failed to extract arguments from pre-approved payload: {}", e.getMessage());
return null;
}
}
/**
* 根据当前 accessor 中的会话历史消息和更新后的已完成步骤结果
* 重建 working context复用与 StateGraphPlanExecuteAgent.buildWorkingContext 相同的逻辑
*/
private static String rebuildWorkingContext(PlanStateAccessor accessor, List<String> allCompletedResults) {
List<Message> messages = accessor.messages();
// messages 中最后一条通常是当前 UserMessagegoal前面的是历史
List<Message> history = messages.size() > 1 ? messages.subList(0, messages.size() - 1) : List.of();
StringBuilder sb = new StringBuilder();
// 历史消息摘要
if (!history.isEmpty()) {
sb.append("=== 对话历史摘要 ===\n");
int startIdx = Math.max(0, history.size() - 10);
for (int i = startIdx; i < history.size(); i++) {
Message msg = history.get(i);
String role = msg.getMessageType().name().toLowerCase();
String content = msg.getText();
if (content != null && !content.isEmpty()) {
String truncated = content.length() > 500 ? content.substring(0, 500) + "" : content;
sb.append("[").append(role).append("] ").append(truncated).append("\n");
}
}
sb.append("\n");
}
// 已完成步骤结果摘要
if (!allCompletedResults.isEmpty()) {
sb.append("=== 已完成步骤结果 ===\n");
int startIdx = Math.max(0, allCompletedResults.size() - 5);
for (int i = startIdx; i < allCompletedResults.size(); i++) {
String result = allCompletedResults.get(i);
String truncated = result.length() > 800 ? result.substring(0, 800) + "" : result;
sb.append(truncated).append("\n");
}
}
// 总体截断
String context = sb.toString();
if (context.length() > 6000) {
context = context.substring(0, 6000) + "\n…上下文已截断";
}
return context;
}
}

View File

@ -0,0 +1,244 @@
package vip.mate.agent.graph.plan.state;
import com.alibaba.cloud.ai.graph.OverAllState;
import org.springframework.ai.chat.messages.Message;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.graph.NodeStreamingChatHelper;
import vip.mate.agent.graph.state.MateClawStateKeys;
import java.util.*;
import static vip.mate.agent.graph.plan.state.PlanStateKeys.*;
/**
* Plan-Execute 类型安全的状态访问器
* <p>
* 参照 {@link vip.mate.agent.graph.state.MateClawStateAccessor} 的模式
* Plan-Execute 特有的状态字段提供类型安全读取和 fluent 输出构建
*
* @author MateClaw Team
*/
public final class PlanStateAccessor {
private final OverAllState state;
public PlanStateAccessor(OverAllState state) {
this.state = Objects.requireNonNull(state, "state must not be null");
}
// ===== 输入 =====
public String goal() {
return state.value(GOAL, "");
}
// ===== 计划 =====
public Long planId() {
return state.value(PLAN_ID, 0L);
}
@SuppressWarnings("unchecked")
public List<String> planSteps() {
return state.<List<String>>value(PLAN_STEPS).orElse(List.of());
}
public boolean planValid() {
return state.value(PLAN_VALID, false);
}
public boolean needsPlanning() {
return state.value(NEEDS_PLANNING, true);
}
// ===== 步骤控制 =====
public int currentStepIndex() {
return state.value(CURRENT_STEP_INDEX, 0);
}
public String currentStepTitle() {
return state.value(CURRENT_STEP_TITLE, "");
}
public String currentStepResult() {
return state.value(CURRENT_STEP_RESULT, "");
}
@SuppressWarnings("unchecked")
public List<String> completedResults() {
return state.<List<String>>value(COMPLETED_RESULTS).orElse(List.of());
}
// ===== 终止 =====
public String finalSummary() {
return state.value(FINAL_SUMMARY, "");
}
public String directAnswer() {
return state.value(DIRECT_ANSWER, "");
}
// ===== Thinking =====
public String finalSummaryThinking() {
return state.value(FINAL_SUMMARY_THINKING, "");
}
public String currentStepThinking() {
return state.value(CURRENT_STEP_THINKING, "");
}
// ===== 共享键 =====
public String systemPrompt() {
return state.value(MateClawStateKeys.SYSTEM_PROMPT, "你是一个有帮助的AI助手。");
}
public String conversationId() {
return state.value(MateClawStateKeys.CONVERSATION_ID, "");
}
public String traceId() {
return state.value(MateClawStateKeys.TRACE_ID, "");
}
// ===== 会话消息复用 MateClawStateKeys.MESSAGES=====
@SuppressWarnings("unchecked")
public List<Message> messages() {
return state.<List<Message>>value(MateClawStateKeys.MESSAGES).orElse(List.of());
}
// ===== 工作上下文 =====
public String workingContext() {
return state.value(WORKING_CONTEXT, "");
}
// ===== 输出构建器 =====
public static OutputBuilder output() {
return new OutputBuilder();
}
/**
* Fluent 输出构建器
*/
public static final class OutputBuilder {
private final Map<String, Object> map = new HashMap<>();
private OutputBuilder() {}
public OutputBuilder put(String key, Object value) {
map.put(key, value);
return this;
}
// ---- 输入 ----
public OutputBuilder goal(String goal) {
return put(GOAL, goal);
}
// ---- 会话消息写入共享键 MateClawStateKeys.MESSAGES----
public OutputBuilder messages(List<Message> msgs) {
return put(MateClawStateKeys.MESSAGES, msgs);
}
// ---- 工作上下文 ----
public OutputBuilder workingContext(String ctx) {
return put(WORKING_CONTEXT, ctx);
}
// ---- 计划 ----
public OutputBuilder planId(Long id) {
return put(PLAN_ID, id);
}
public OutputBuilder planSteps(List<String> steps) {
return put(PLAN_STEPS, steps);
}
public OutputBuilder planValid(boolean valid) {
return put(PLAN_VALID, valid);
}
public OutputBuilder needsPlanning(boolean needs) {
return put(NEEDS_PLANNING, needs);
}
// ---- 步骤控制 ----
public OutputBuilder currentStepIndex(int index) {
return put(CURRENT_STEP_INDEX, index);
}
public OutputBuilder currentStepTitle(String title) {
return put(CURRENT_STEP_TITLE, title);
}
public OutputBuilder currentStepResult(String result) {
return put(CURRENT_STEP_RESULT, result);
}
/**
* 追加到 COMPLETED_RESULTSAPPEND 策略传入单条结果包装为 List
*/
public OutputBuilder completedResults(String result) {
return put(COMPLETED_RESULTS, List.of(result));
}
// ---- 终止 ----
public OutputBuilder finalSummary(String summary) {
return put(FINAL_SUMMARY, summary);
}
public OutputBuilder directAnswer(String answer) {
return put(DIRECT_ANSWER, answer);
}
// ---- Thinking ----
public OutputBuilder finalSummaryThinking(String thinking) {
return put(FINAL_SUMMARY_THINKING, thinking);
}
public OutputBuilder currentStepThinking(String thinking) {
return put(CURRENT_STEP_THINKING, thinking);
}
// ---- 流式防重写入共享键----
public OutputBuilder contentStreamed(boolean streamed) {
return put(MateClawStateKeys.CONTENT_STREAMED, streamed);
}
public OutputBuilder thinkingStreamed(boolean streamed) {
return put(MateClawStateKeys.THINKING_STREAMED, streamed);
}
// ---- 事件流写入共享键 MateClawStateKeys.PENDING_EVENTS----
public OutputBuilder events(List<GraphEventPublisher.GraphEvent> events) {
return put(MateClawStateKeys.PENDING_EVENTS, events);
}
// ---- 阶段标记写入共享键 MateClawStateKeys.CURRENT_PHASE----
public OutputBuilder currentPhase(String phase) {
return put(MateClawStateKeys.CURRENT_PHASE, phase);
}
// ---- Token Usage写入共享键----
/** 将本次 LLM 调用的 usage 累加到 state 已有值上 */
public OutputBuilder mergeUsage(OverAllState currentState,
NodeStreamingChatHelper.StreamResult result) {
int existingPrompt = currentState.value(MateClawStateKeys.PROMPT_TOKENS, 0);
int existingCompletion = currentState.value(MateClawStateKeys.COMPLETION_TOKENS, 0);
map.put(MateClawStateKeys.PROMPT_TOKENS, existingPrompt + result.promptTokens());
map.put(MateClawStateKeys.COMPLETION_TOKENS, existingCompletion + result.completionTokens());
return this;
}
public Map<String, Object> build() {
return map;
}
}
}

View File

@ -0,0 +1,57 @@
package vip.mate.agent.graph.plan.state;
/**
* Plan-Execute 特有的状态键常量
* <p>
* 共享键 PENDING_EVENTSCURRENT_PHASE直接引用 {@link vip.mate.agent.graph.state.MateClawStateKeys}
* 不在此处重复定义
*
* @author MateClaw Team
*/
public final class PlanStateKeys {
private PlanStateKeys() {}
// ===== 输入 =====
public static final String GOAL = "goal";
// ===== 计划 =====
public static final String PLAN_ID = "plan_id";
public static final String PLAN_STEPS = "plan_steps"; // List<String>
public static final String PLAN_VALID = "plan_valid";
public static final String NEEDS_PLANNING = "needs_planning"; // boolean
// ===== 步骤控制 =====
public static final String CURRENT_STEP_INDEX = "current_step_index";
public static final String CURRENT_STEP_TITLE = "current_step_title";
public static final String CURRENT_STEP_RESULT = "current_step_result";
public static final String COMPLETED_RESULTS = "completed_results"; // APPEND 策略
// ===== 终止 =====
public static final String FINAL_SUMMARY = "final_summary";
public static final String DIRECT_ANSWER = "direct_answer"; // 简单问答的直接回答
// ===== 上下文 =====
/**
* 工作上下文 / 摘要上下文REPLACE 策略
* <p>
* 保存对 conversation history + 已完成步骤结果的压缩摘要
* StepExecutionNode / PlanSummaryNode 使用避免 prompt 无限膨胀
*/
public static final String WORKING_CONTEXT = "working_context";
// ===== Thinking =====
/** 汇总阶段的完整 thinking */
public static final String FINAL_SUMMARY_THINKING = "final_summary_thinking";
/** 当前步骤的完整 thinking */
public static final String CURRENT_STEP_THINKING = "current_step_thinking";
// ===== 节点名称 =====
public static final String PLAN_GENERATION_NODE = "plan_generation";
public static final String STEP_EXECUTION_NODE = "step_execution";
public static final String PLAN_SUMMARY_NODE = "plan_summary";
public static final String DIRECT_ANSWER_NODE = "direct_answer_node";
// 注意PENDING_EVENTS 直接使用 MateClawStateKeys.PENDING_EVENTS不在此重复定义
}

View File

@ -0,0 +1,39 @@
package vip.mate.agent.graph.state;
/**
* ReAct 状态图终止原因枚举
*
* @author MateClaw Team
*/
public enum FinishReason {
/** 正常完成LLM 直接给出最终回答 */
NORMAL("normal"),
/** 经过 summarizing 后完成 */
SUMMARIZED("summarized"),
/** 达到最大迭代次数后强制收束 */
MAX_ITERATIONS_REACHED("max_iterations_reached"),
/** 发生错误后降级回答 */
ERROR_FALLBACK("error_fallback"),
/** 用户主动停止 */
STOPPED("stopped");
private final String value;
FinishReason(String value) {
this.value = value;
}
public String getValue() {
return value;
}
@Override
public String toString() {
return value;
}
}

View File

@ -0,0 +1,388 @@
package vip.mate.agent.graph.state;
import com.alibaba.cloud.ai.graph.OverAllState;
import org.springframework.ai.chat.messages.Message;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.graph.NodeStreamingChatHelper;
import java.util.*;
import static vip.mate.agent.graph.state.MateClawStateKeys.*;
/**
* 类型安全的状态访问器
* <p>
* 封装 {@link OverAllState} 的字符串 key 读写
* 提供带默认值的强类型方法避免业务代码散落 state.value("xxx") 调用
*
* @author MateClaw Team
*/
public final class MateClawStateAccessor {
private final OverAllState state;
public MateClawStateAccessor(OverAllState state) {
this.state = Objects.requireNonNull(state, "state must not be null");
}
// ===== 输入字段 =====
public String userMessage() {
return state.value(USER_MESSAGE, "");
}
public String conversationId() {
return state.value(CONVERSATION_ID, "");
}
public String agentId() {
return state.value(AGENT_ID, "");
}
public String systemPrompt() {
return state.value(SYSTEM_PROMPT, "你是一个有帮助的AI助手。");
}
// ===== 消息列表 =====
@SuppressWarnings("unchecked")
public List<Message> messages() {
return state.<List<Message>>value(MESSAGES).orElse(List.of());
}
// ===== 迭代控制 =====
public int iterationCount() {
return state.value(CURRENT_ITERATION, 0);
}
public int maxIterations() {
return state.value(MAX_ITERATIONS, 10);
}
public boolean isLimitReached() {
return iterationCount() >= maxIterations();
}
// ===== 工具调用 =====
public boolean needsToolCall() {
return state.value(NEEDS_TOOL_CALL, false);
}
public int toolCallCount() {
return state.value(TOOL_CALL_COUNT, 0);
}
// ===== 观察历史 =====
@SuppressWarnings("unchecked")
public List<String> observationHistory() {
return state.<List<String>>value(OBSERVATION_HISTORY).orElse(List.of());
}
/**
* 计算所有观察记录的总字符数
*/
public int totalObservationChars() {
return observationHistory().stream().mapToInt(String::length).sum();
}
// ===== Summarizing =====
public boolean shouldSummarize() {
return state.value(SHOULD_SUMMARIZE, false);
}
public String summarizedContext() {
return state.value(SUMMARIZED_CONTEXT, "");
}
// ===== 终止控制 =====
public String finalAnswer() {
return state.value(FINAL_ANSWER, "");
}
public String finalAnswerDraft() {
return state.value(FINAL_ANSWER_DRAFT, "");
}
public boolean limitExceeded() {
return state.value(LIMIT_EXCEEDED, false);
}
public String finishReason() {
return state.value(FINISH_REASON, "");
}
// ===== 错误 =====
public String error() {
return state.value(ERROR, (String) null);
}
public boolean hasError() {
String err = error();
return err != null && !err.isEmpty();
}
public int errorCount() {
return state.value(ERROR_COUNT, 0);
}
// ===== 追踪 =====
public String traceId() {
return state.value(TRACE_ID, "");
}
// ===== 事件流 =====
@SuppressWarnings("unchecked")
public List<GraphEventPublisher.GraphEvent> pendingEvents() {
return state.<List<GraphEventPublisher.GraphEvent>>value(PENDING_EVENTS).orElse(List.of());
}
public String currentPhase() {
return state.value(CURRENT_PHASE, "");
}
// ===== Thinking =====
public String finalThinking() {
return state.value(FINAL_THINKING, "");
}
public String currentThinking() {
return state.value(CURRENT_THINKING, "");
}
// ===== 流式防重 =====
public boolean contentStreamed() {
return state.value(CONTENT_STREAMED, false);
}
public boolean thinkingStreamed() {
return state.value(THINKING_STREAMED, false);
}
// ===== 请求者身份 =====
public String requesterId() {
return state.value(REQUESTER_ID, "");
}
// ===== 流式内容暂存 =====
public String streamedContent() {
return state.value(STREAMED_CONTENT, "");
}
public String streamedThinking() {
return state.value(STREAMED_THINKING, "");
}
// ===== 审批控制 =====
public boolean awaitingApproval() {
return state.value(AWAITING_APPROVAL, false);
}
// ===== 审批重放 =====
public String forcedToolCall() {
return state.value(FORCED_TOOL_CALL, "");
}
// ===== Token Usage =====
public int promptTokens() {
return state.value(PROMPT_TOKENS, 0);
}
public int completionTokens() {
return state.value(COMPLETION_TOKENS, 0);
}
public String runtimeModelName() {
return state.value(RUNTIME_MODEL_NAME, "");
}
public String runtimeProviderId() {
return state.value(RUNTIME_PROVIDER_ID, "");
}
// ===== 输出构建器 =====
/**
* 创建一个 fluent 输出构建器用于 NodeAction.apply() 返回值
*/
public static OutputBuilder output() {
return new OutputBuilder();
}
/**
* Fluent 输出构建器
* <p>
* 使用示例
* <pre>
* return MateClawStateAccessor.output()
* .iterationCount(3)
* .shouldSummarize(true)
* .observationHistory("搜索结果xxx")
* .build();
* </pre>
*/
public static final class OutputBuilder {
private final Map<String, Object> map = new HashMap<>();
private OutputBuilder() {
}
public OutputBuilder put(String key, Object value) {
map.put(key, value);
return this;
}
// ---- 迭代控制 ----
public OutputBuilder iterationCount(int count) {
return put(CURRENT_ITERATION, count);
}
public OutputBuilder needsToolCall(boolean needs) {
return put(NEEDS_TOOL_CALL, needs);
}
// ---- 消息 ----
public OutputBuilder messages(List<Message> msgs) {
return put(MESSAGES, msgs);
}
// ---- 工具调用 ----
public OutputBuilder toolCalls(Object calls) {
return put(TOOL_CALLS, calls);
}
public OutputBuilder toolResults(Object results) {
return put(TOOL_RESULTS, results);
}
public OutputBuilder toolCallCount(int count) {
return put(TOOL_CALL_COUNT, count);
}
// ---- 观察 ----
public OutputBuilder observationHistory(String observation) {
return put(OBSERVATION_HISTORY, List.of(observation));
}
public OutputBuilder shouldSummarize(boolean should) {
return put(SHOULD_SUMMARIZE, should);
}
// ---- Summarizing ----
public OutputBuilder summarizedContext(String ctx) {
return put(SUMMARIZED_CONTEXT, ctx);
}
public OutputBuilder finalAnswerDraft(String draft) {
return put(FINAL_ANSWER_DRAFT, draft);
}
// ---- 终止 ----
public OutputBuilder finalAnswer(String answer) {
return put(FINAL_ANSWER, answer);
}
public OutputBuilder finishReason(FinishReason reason) {
return put(FINISH_REASON, reason.getValue());
}
public OutputBuilder limitExceeded(boolean exceeded) {
return put(LIMIT_EXCEEDED, exceeded);
}
// ---- 错误 ----
public OutputBuilder error(String err) {
return put(ERROR, err);
}
public OutputBuilder errorCount(int count) {
return put(ERROR_COUNT, count);
}
// ---- 追踪 ----
public OutputBuilder traceId(String id) {
return put(TRACE_ID, id);
}
// ---- 事件流 ----
public OutputBuilder events(List<GraphEventPublisher.GraphEvent> events) {
return put(PENDING_EVENTS, events);
}
public OutputBuilder currentPhase(String phase) {
return put(CURRENT_PHASE, phase);
}
// ---- Thinking ----
public OutputBuilder finalThinking(String thinking) {
return put(FINAL_THINKING, thinking);
}
public OutputBuilder currentThinking(String thinking) {
return put(CURRENT_THINKING, thinking);
}
// ---- 流式防重 ----
public OutputBuilder contentStreamed(boolean streamed) {
return put(CONTENT_STREAMED, streamed);
}
public OutputBuilder thinkingStreamed(boolean streamed) {
return put(THINKING_STREAMED, streamed);
}
// ---- 请求者身份 ----
public OutputBuilder requesterId(String id) {
return put(REQUESTER_ID, id);
}
// ---- 流式内容暂存 ----
public OutputBuilder streamedContent(String content) {
return put(STREAMED_CONTENT, content);
}
public OutputBuilder streamedThinking(String thinking) {
return put(STREAMED_THINKING, thinking);
}
// ---- 审批控制 ----
public OutputBuilder awaitingApproval(boolean awaiting) {
return put(AWAITING_APPROVAL, awaiting);
}
// ---- 审批重放 ----
public OutputBuilder forcedToolCall(String json) {
return put(FORCED_TOOL_CALL, json);
}
// ---- Token Usage ----
/** 将本次 LLM 调用的 usage 累加到 state 已有值上 */
public OutputBuilder mergeUsage(OverAllState currentState,
NodeStreamingChatHelper.StreamResult result) {
int existingPrompt = currentState.value(PROMPT_TOKENS, 0);
int existingCompletion = currentState.value(COMPLETION_TOKENS, 0);
map.put(PROMPT_TOKENS, existingPrompt + result.promptTokens());
map.put(COMPLETION_TOKENS, existingCompletion + result.completionTokens());
return this;
}
public Map<String, Object> build() {
return map;
}
}
}

View File

@ -0,0 +1,137 @@
package vip.mate.agent.graph.state;
/**
* MateClaw 增强版状态键常量
* <p>
* 包含原 ReActStateKeys 的所有字段并新增 summarizing超限处理
* 观察压缩等字段支撑完整的标准 ReAct 状态图
* <p>
* 所有节点和路由统一引用此类避免字符串散落
*
* @author MateClaw Team
*/
public final class MateClawStateKeys {
private MateClawStateKeys() {
}
// ===== 输入 =====
public static final String USER_MESSAGE = "user_message";
public static final String CONVERSATION_ID = "conversation_id";
public static final String SYSTEM_PROMPT = "system_prompt";
public static final String AGENT_ID = "agent_id";
// ===== 消息列表APPEND 策略=====
public static final String MESSAGES = "messages";
// ===== 迭代控制REPLACE 策略=====
public static final String CURRENT_ITERATION = "current_iteration";
public static final String MAX_ITERATIONS = "max_iterations";
// ===== 工具调用REPLACE 策略=====
public static final String TOOL_CALLS = "tool_calls";
public static final String TOOL_RESULTS = "tool_results";
// ===== 控制流REPLACE 策略=====
public static final String FINAL_ANSWER = "final_answer";
public static final String NEEDS_TOOL_CALL = "needs_tool_call";
public static final String ERROR = "error";
// ===== 节点名称基础=====
public static final String REASONING_NODE = "reasoning";
public static final String ACTION_NODE = "action";
public static final String OBSERVATION_NODE = "observation";
// ===== 观察历史APPEND 策略=====
/** 每轮工具调用的处理后观察记录,由 ObservationProcessor 输出 */
public static final String OBSERVATION_HISTORY = "observation_history";
// ===== Summarizing 相关REPLACE 策略=====
/** 经过 SummarizingNode 压缩后的上下文 */
public static final String SUMMARIZED_CONTEXT = "summarized_context";
/** 最终回答草稿(由 summarizing 或 limitExceeded 节点生成) */
public static final String FINAL_ANSWER_DRAFT = "final_answer_draft";
/** 是否需要进入 summarizing 阶段 */
public static final String SHOULD_SUMMARIZE = "should_summarize";
// ===== 终止控制REPLACE 策略=====
/** 终止原因,{@link FinishReason#getValue()} */
public static final String FINISH_REASON = "finish_reason";
/** 是否已超过最大迭代次数 */
public static final String LIMIT_EXCEEDED = "limit_exceeded";
// ===== 统计与追踪REPLACE 策略=====
/** 累计工具调用次数 */
public static final String TOOL_CALL_COUNT = "tool_call_count";
/** 累计错误次数 */
public static final String ERROR_COUNT = "error_count";
/** 本次对话的追踪 ID */
public static final String TRACE_ID = "trace_id";
// ===== 节点名称新增=====
public static final String SUMMARIZING_NODE = "summarizing";
public static final String FINAL_ANSWER_NODE = "final_answer_node";
public static final String LIMIT_EXCEEDED_NODE = "limit_exceeded";
// ===== 事件流APPEND 策略=====
public static final String PENDING_EVENTS = "pending_events";
// ===== 阶段标记REPLACE 策略=====
public static final String CURRENT_PHASE = "current_phase";
// ===== ThinkingREPLACE 策略=====
/** 最终完整 thinking由 FinalAnswerNode 或直接回答路径聚合) */
public static final String FINAL_THINKING = "final_thinking";
/** 当前节点的完整 thinking节点结束时写入 */
public static final String CURRENT_THINKING = "current_thinking";
// ===== 流式防重REPLACE 策略=====
/** 当前节点的 content 是否已通过 streaming helper 实时推送 */
public static final String CONTENT_STREAMED = "content_streamed";
/** 当前节点的 thinking 是否已通过 streaming helper 实时推送 */
public static final String THINKING_STREAMED = "thinking_streamed";
// ===== 流式内容暂存REPLACE 策略=====
/** ReasoningNode 流式推送后暂存的文本内容,供 AWAITING_APPROVAL 路径持久化使用 */
public static final String STREAMED_CONTENT = "streamed_content";
/** ReasoningNode 流式推送后暂存的 thinking 内容,供 AWAITING_APPROVAL 路径持久化使用 */
public static final String STREAMED_THINKING = "streamed_thinking";
// ===== 审批控制REPLACE 策略=====
/** 当 ActionNode 遇到需要审批的工具时设为 trueObservationDispatcher 据此终止 Graph */
public static final String AWAITING_APPROVAL = "awaiting_approval";
// ===== 审批重放REPLACE 策略=====
/** 预批准的工具调用 JSON由 chatWithReplay 注入ReasoningNode 检测后跳过 LLM 直接发出 */
public static final String FORCED_TOOL_CALL = "forced_tool_call";
/**
* Plan-Execute replay 专用审批通过的工具调用 payload工具名+参数
* StateGraphPlanExecuteAgent.chatWithReplayStream 注入
* StepExecutionNode 检测到匹配时跳过 ToolGuard 直接执行
*/
public static final String PRE_APPROVED_TOOL_CALL = "pre_approved_tool_call";
// ===== 请求者身份REPLACE 策略=====
/** 原始请求者 IDIM senderId / Web Authentication.getName()),用于审批身份校验 */
public static final String REQUESTER_ID = "requester_id";
// ===== 取消控制REPLACE 策略=====
/** 取消标志:外部请求停止时设为 true各节点在入口处检查 */
public static final String STOP_REQUESTED = "stop_requested";
// ===== Token Usage 累计REPLACE 策略节点内累加后写回=====
public static final String PROMPT_TOKENS = "prompt_tokens";
public static final String COMPLETION_TOKENS = "completion_tokens";
// ===== 运行时模型快照REPLACE 策略buildInitialState 注入=====
public static final String RUNTIME_MODEL_NAME = "runtime_model_name";
public static final String RUNTIME_PROVIDER_ID = "runtime_provider_id";
}

View File

@ -0,0 +1,60 @@
package vip.mate.agent.model;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Agent 配置实体
*
* @author MateClaw Team
*/
@Data
@TableName("mate_agent")
public class AgentEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** Agent 名称 */
private String name;
/** Agent 描述 */
private String description;
/** Agent 类型react / plan_execute */
private String agentType;
/** 系统提示词 */
@TableField(value = "system_prompt", updateStrategy = FieldStrategy.ALWAYS)
private String systemPrompt;
/**
* 保留但不再生效运行时统一使用全局默认模型ModelConfigService.getDefaultModel()
* 该字段为历史残留仅保留以避免数据库迁移
*/
@Deprecated
private String modelName;
/** 最大迭代次数 */
private Integer maxIterations;
/** 是否启用 */
private Boolean enabled;
/** 图标emoji 或 URL */
private String icon;
/** 标签(逗号分隔) */
private String tags;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
private Integer deleted;
}

View File

@ -0,0 +1,67 @@
package vip.mate.agent.prompt;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StreamUtils;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.ConcurrentHashMap;
/**
* Prompt 文件加载器
* <p>
* classpath:/prompts/ 目录加载 .txt 文件使用 ConcurrentHashMap 做线程安全的懒加载缓存
* <p>
* 未来扩展点可在 loadPrompt() 中增加"先查数据库覆盖 → 再读 resource → 最后代码兜底"的优先级链
* 但本次只实现 resource 读取
*
* @author MateClaw Team
*/
@Slf4j
public final class PromptLoader {
private static final String PROMPT_PATH_PREFIX = "prompts/";
private static final ConcurrentHashMap<String, String> promptCache = new ConcurrentHashMap<>();
private PromptLoader() {}
/**
* 加载 prompt 文件内容
*
* @param promptName 文件名不含路径前缀和 .txt 后缀例如 "graph/summarize-system"
* @return 文件文本内容
* @throws RuntimeException 文件不存在或读取失败时抛出不会静默返回空字符串
*/
public static String loadPrompt(String promptName) {
return promptCache.computeIfAbsent(promptName, name -> {
String fileName = PROMPT_PATH_PREFIX + name + ".txt";
try (InputStream inputStream = PromptLoader.class.getClassLoader().getResourceAsStream(fileName)) {
if (inputStream == null) {
throw new RuntimeException("Prompt 文件不存在: " + fileName);
}
return StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
} catch (IOException e) {
log.error("加载 Prompt 失败!{}", e.getMessage(), e);
throw new RuntimeException("加载 Prompt 失败: " + name, e);
}
});
}
/**
* 清空缓存
*/
public static void clearCache() {
promptCache.clear();
}
/**
* 获取缓存大小
*
* @return 已缓存的 prompt 数量
*/
public static int getCacheSize() {
return promptCache.size();
}
}

View File

@ -0,0 +1,14 @@
package vip.mate.agent.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import vip.mate.agent.model.AgentEntity;
/**
* Agent 数据访问层
*
* @author MateClaw Team
*/
@Mapper
public interface AgentMapper extends BaseMapper<AgentEntity> {
}

View File

@ -0,0 +1,124 @@
package vip.mate.approval;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.common.result.R;
import vip.mate.workspace.conversation.ConversationService;
import java.util.List;
import java.util.Map;
/**
* 工具执行审批接口
* <p>
* 提供 approve / deny 端点供前端在收到 tool_approval_requested SSE 事件后调用
* 批准后自动触发工具重放结果通过 SSE 流推送给前端
*
* @author MateClaw Team
*/
@Tag(name = "工具审批")
@Slf4j
@RestController
@RequestMapping("/api/v1/chat")
@RequiredArgsConstructor
public class ApprovalController {
private final ApprovalService approvalService;
private final ConversationService conversationService;
private final ChatStreamTracker streamTracker;
/**
* 批准或拒绝工具执行
* <p>
* 批准后自动触发工具重放异步执行结果通过已有的 SSE 连接推送给前端
*/
@Operation(summary = "审批工具执行")
@PostMapping("/{conversationId}/approve")
public R<String> approve(
@PathVariable String conversationId,
@RequestBody ApprovalRequest request,
Authentication auth) {
if (auth == null) {
return R.fail(401, "未登录,请先登录");
}
String username = auth.getName();
// 校验会话归属
if (!conversationService.isConversationOwner(conversationId, username)) {
log.warn("[Approval] Unauthorized: user={} is not owner of conversation={}", username, conversationId);
return R.fail(403, "无权操作该会话");
}
// 校验 pendingId
if (request.getPendingId() == null || request.getPendingId().isBlank()) {
return R.fail("pendingId 不能为空");
}
// 校验 decision
String decision = request.getDecision();
if (decision == null || (!decision.equalsIgnoreCase("approved") && !decision.equalsIgnoreCase("denied"))) {
return R.fail("decision 必须为 approved 或 denied");
}
try {
approvalService.resolve(request.getPendingId(), username, decision);
log.info("[Approval] User {} {} pending {} for conversation {}",
username, decision, request.getPendingId(), conversationId);
// Web 端的 replay 由前端发送 /approve 消息到 POST /stream 触发ChatController 拦截
// 此端点只更新审批状态保留给 IM 渠道DingTalk/Feishu 等通过 ChannelMessageRouter 调用
// 拒绝时通过 SSE 通知前端如果流还活着
if ("denied".equalsIgnoreCase(decision) && streamTracker.isRunning(conversationId)) {
streamTracker.broadcastObject(conversationId, "tool_approval_resolved", Map.of(
"pendingId", request.getPendingId(),
"decision", "denied",
"timestamp", System.currentTimeMillis()
));
}
return R.ok("操作成功");
} catch (IllegalArgumentException e) {
log.warn("[Approval] Resolve failed: {}", e.getMessage());
return R.fail(e.getMessage());
}
}
/**
* 查询指定会话下的待审批记录
* <p>
* 用于页面刷新后恢复审批卡片hydration
*/
@Operation(summary = "查询待审批记录")
@GetMapping("/{conversationId}/pending-approvals")
public R<List<Map<String, Object>>> getPendingApprovals(
@PathVariable String conversationId,
Authentication auth) {
if (auth == null) {
return R.fail(401, "未登录,请先登录");
}
String username = auth.getName();
if (!conversationService.isConversationOwner(conversationId, username)) {
return R.fail(403, "无权访问该会话");
}
List<Map<String, Object>> pending = approvalService.getPendingByConversation(conversationId);
return R.ok(pending);
}
@Data
public static class ApprovalRequest {
private String pendingId;
/** "approved" 或 "denied" */
private String decision;
}
}

View File

@ -0,0 +1,10 @@
package vip.mate.approval;
/**
* 审批决策
*/
public enum ApprovalDecision {
APPROVED,
DENIED,
TIMEOUT
}

View File

@ -0,0 +1,31 @@
package vip.mate.approval;
/**
* 审批占位消息检测工具共享单点定义
* <p>
* 实现 TOOL_GUARD_DENIED_MARK 语义
* 检测 assistant 消息内容是否为审批占位文本用于
* <ul>
* <li>BaseAgent.buildConversationHistory() 运行时过滤防止 LLM 看到审批残留</li>
* <li>ConversationService.removeApprovalPlaceholders() DB 物理清理</li>
* </ul>
*
* @author MateClaw Team
*/
public final class ApprovalPlaceholderUtil {
private ApprovalPlaceholderUtil() {
}
/**
* 判断消息内容是否为审批占位消息
*/
public static boolean isApprovalPlaceholder(String content) {
if (content == null || content.isEmpty()) return false;
return content.contains("[⏳ 等待审批]")
|| content.contains("[APPROVAL_PENDING]")
|| content.contains("[等待审批]")
|| content.contains("请输入 /approve")
|| content.contains("等待您的批准");
}
}

View File

@ -0,0 +1,325 @@
package vip.mate.approval;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* 工具执行审批服务消息驱动版 非阻塞
* <p>
* 核心变化不再阻塞线程等待审批
* <ul>
* <li>{@link #createPending} 创建待审批记录后立即返回</li>
* <li>{@link #resolve} 更新状态为 approved/denied</li>
* <li>{@link #findPendingByConversation} 查找会话最早的 pendingFIFO</li>
* <li>{@link #consumeApproved} 一次性消费已批准记录供重放</li>
* <li>{@link #garbageCollect} 定时清理过期记录</li>
* </ul>
*
* @author MateClaw Team
*/
@Slf4j
@Service
public class ApprovalService {
private final ConcurrentHashMap<String, PendingApproval> pendingMap = new ConcurrentHashMap<>();
/** GC 常量 */
private static final Duration PENDING_TTL = Duration.ofMinutes(30);
private static final Duration RESOLVED_TTL = Duration.ofHours(1);
private static final int MAX_PENDING = 200;
private static final int MAX_RESOLVED = 500;
private ScheduledExecutorService gcScheduler;
@PostConstruct
void initGc() {
gcScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "approval-gc");
t.setDaemon(true);
return t;
});
gcScheduler.scheduleAtFixedRate(this::garbageCollect, 5, 5, TimeUnit.MINUTES);
log.info("[Approval] GC scheduler started (interval=5min)");
}
@PreDestroy
void shutdownGc() {
if (gcScheduler != null) {
gcScheduler.shutdownNow();
}
}
// ==================== 创建 ====================
/**
* 创建待审批记录基础版向后兼容
*/
public String createPending(String conversationId, String userId,
String toolName, String toolArguments, String reason) {
return createPending(conversationId, userId, toolName, toolArguments, reason,
null, null, null);
}
/**
* 创建待审批记录增强版含重放载荷
*
* @param toolCallPayload 序列化的 tool call JSON
* @param siblingToolCalls 序列化的 sibling tool calls JSON 数组
* @param agentId 发起审批的 Agent ID
* @return pendingId
*/
public String createPending(String conversationId, String userId,
String toolName, String toolArguments, String reason,
String toolCallPayload, String siblingToolCalls, String agentId) {
String pendingId = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
PendingApproval pending = new PendingApproval(
pendingId, conversationId, userId, toolName, toolArguments, reason);
pending.setToolCallPayload(toolCallPayload);
pending.setSiblingToolCalls(siblingToolCalls);
pending.setAgentId(agentId);
pendingMap.put(pendingId, pending);
log.info("[Approval] Created pending: id={}, tool={}, agent={}, conversation={}",
pendingId, toolName, agentId, conversationId);
return pendingId;
}
// ==================== 解决 ====================
/**
* 解决审批批准或拒绝
*
* @param pendingId 待审批 ID
* @param userId 操作用户
* @param decision "approved" "denied"
* @throws IllegalArgumentException 如果 pending 不存在
*/
public void resolve(String pendingId, String userId, String decision) {
PendingApproval pending = pendingMap.get(pendingId);
if (pending == null) {
throw new IllegalArgumentException("审批记录不存在或已过期: " + pendingId);
}
if ("approved".equalsIgnoreCase(decision)) {
pending.setStatus("approved");
} else {
pending.setStatus("denied");
}
pending.setResolvedAt(Instant.now());
pending.setResolvedBy(userId);
log.info("[Approval] Resolved: id={}, decision={}, by={}", pendingId, decision, userId);
}
// ==================== 查询 ====================
/**
* 获取待审批记录
*/
public Optional<PendingApproval> getPending(String pendingId) {
return Optional.ofNullable(pendingMap.get(pendingId));
}
/**
* 查找指定会话最早的 pending 审批FIFO 语义
* 用于 ChannelMessageRouter 在处理新消息前检查是否有待审批
*/
public PendingApproval findPendingByConversation(String conversationId) {
return pendingMap.values().stream()
.filter(p -> conversationId.equals(p.getConversationId()))
.filter(p -> "pending".equals(p.getStatus()))
.min(Comparator.comparing(PendingApproval::getCreatedAt))
.orElse(null);
}
/**
* 获取指定会话下所有 pending 状态的审批记录供前端 hydration
*/
public List<Map<String, Object>> getPendingByConversation(String conversationId) {
List<Map<String, Object>> result = new ArrayList<>();
for (PendingApproval pending : pendingMap.values()) {
if (conversationId.equals(pending.getConversationId())
&& "pending".equals(pending.getStatus())) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("pendingId", pending.getPendingId());
entry.put("toolName", pending.getToolName());
entry.put("toolArguments", pending.getToolArguments() != null ? pending.getToolArguments() : "");
entry.put("reason", pending.getReason() != null ? pending.getReason() : "");
entry.put("status", pending.getStatus());
entry.put("createdAt", pending.getCreatedAt().toString());
// 增强字段Phase 5: 结构化风险信息
if (pending.getFindingsJson() != null) {
entry.put("findingsJson", pending.getFindingsJson());
}
if (pending.getMaxSeverity() != null) {
entry.put("maxSeverity", pending.getMaxSeverity());
}
if (pending.getSummary() != null) {
entry.put("summary", pending.getSummary());
}
result.add(entry);
}
}
return result;
}
// ==================== 原子解决+消费IM 渠道 /approve 命令 ====================
/**
* 原子地 resolve consume 审批记录用于 IM 渠道 /approve 命令
* <p>
* 合并 resolve() + consumeApproved() 为单一操作消除 race condition
*
* @param pendingId 待审批 ID
* @param userId 操作用户
* @return 已消费的 PendingApproval toolCallPayload不存在或已处理返回 null
*/
public synchronized PendingApproval resolveAndConsume(String pendingId, String userId) {
PendingApproval pending = pendingMap.get(pendingId);
if (pending == null || !"pending".equals(pending.getStatus())) {
log.warn("[Approval] resolveAndConsume: not found or not pending: id={}", pendingId);
return null;
}
pending.setStatus("consumed");
pending.setResolvedAt(Instant.now());
pending.setResolvedBy(userId);
pendingMap.remove(pendingId);
log.info("[Approval] Resolved and consumed atomically: id={}, tool={}", pendingId, pending.getToolName());
return pending;
}
// ==================== 消费重放时调用 ====================
/**
* 消费已批准的审批记录一次性消费
* <p>
* 验证 toolName 匹配如果指定防止参数替换攻击
* 移除记录并返回 PendingApproval 供重放
*
* @param conversationId 会话 ID
* @param toolName 要验证的工具名null 跳过验证
* @return 已消费的 PendingApproval null 如果无匹配
*/
public PendingApproval consumeApproved(String conversationId, String toolName) {
return consumeApproved(conversationId, toolName, null);
}
/**
* 消费一条已审批的记录带参数匹配校验防止审批后参数替换攻击
*/
public PendingApproval consumeApproved(String conversationId, String toolName, String toolArguments) {
PendingApproval target = pendingMap.values().stream()
.filter(p -> conversationId.equals(p.getConversationId()))
.filter(p -> "approved".equals(p.getStatus()))
.filter(p -> toolName == null || toolName.equals(p.getToolName()))
.filter(p -> toolArguments == null || toolArguments.equals(p.getToolArguments()))
.min(Comparator.comparing(PendingApproval::getCreatedAt))
.orElse(null);
if (target == null) {
return null;
}
target.setStatus("consumed");
pendingMap.remove(target.getPendingId());
log.info("[Approval] Consumed approved: id={}, tool={}, conversation={}",
target.getPendingId(), target.getToolName(), conversationId);
return target;
}
// ==================== 取消与清理 ====================
/**
* 取消指定会话的所有 pending用户发新消息时旧 pending 自动取消
*
* @param conversationId 会话 ID
* @param excludePendingId 排除的 pendingId当前正在创建的可为 null
*/
public void cancelStalePending(String conversationId, String excludePendingId) {
pendingMap.values().stream()
.filter(p -> conversationId.equals(p.getConversationId()))
.filter(p -> "pending".equals(p.getStatus()))
.filter(p -> !p.getPendingId().equals(excludePendingId))
.forEach(p -> {
p.setStatus("superseded");
p.setResolvedAt(Instant.now());
pendingMap.remove(p.getPendingId());
log.info("[Approval] Cancelled stale pending: id={}", p.getPendingId());
});
}
/**
* 定时清理过期记录
* <ul>
* <li>pending 超过 30 分钟 标记 TIMEOUT 并清除</li>
* <li>resolved pending超过 1 小时 清除</li>
* <li>上限pending 200 resolved 500 </li>
* </ul>
*/
public void garbageCollect() {
Instant now = Instant.now();
int expiredPending = 0;
int expiredResolved = 0;
List<String> toRemove = new ArrayList<>();
for (PendingApproval p : pendingMap.values()) {
if ("pending".equals(p.getStatus())) {
if (Duration.between(p.getCreatedAt(), now).compareTo(PENDING_TTL) > 0) {
p.setStatus("timeout");
p.setResolvedAt(now);
toRemove.add(p.getPendingId());
expiredPending++;
}
} else {
// 已解决的记录
Instant resolvedAt = p.getResolvedAt() != null ? p.getResolvedAt() : p.getCreatedAt();
if (Duration.between(resolvedAt, now).compareTo(RESOLVED_TTL) > 0) {
toRemove.add(p.getPendingId());
expiredResolved++;
}
}
}
toRemove.forEach(pendingMap::remove);
// 上限检查
enforceLimit("pending", MAX_PENDING);
enforceLimit("resolved", MAX_RESOLVED);
if (expiredPending > 0 || expiredResolved > 0) {
log.info("[Approval] GC: expired {} pending, {} resolved, remaining={}",
expiredPending, expiredResolved, pendingMap.size());
}
}
private void enforceLimit(String statusType, int maxCount) {
boolean isPending = "pending".equals(statusType);
List<PendingApproval> matching = pendingMap.values().stream()
.filter(p -> isPending ? "pending".equals(p.getStatus()) : !"pending".equals(p.getStatus()))
.sorted(Comparator.comparing(PendingApproval::getCreatedAt))
.toList();
if (matching.size() > maxCount) {
int toEvict = matching.size() - maxCount;
for (int i = 0; i < toEvict; i++) {
PendingApproval oldest = matching.get(i);
if (isPending) {
oldest.setStatus("timeout");
oldest.setResolvedAt(Instant.now());
}
pendingMap.remove(oldest.getPendingId());
}
log.info("[Approval] Evicted {} {} records (exceeded limit {})", toEvict, statusType, maxCount);
}
}
}

View File

@ -0,0 +1,26 @@
package vip.mate.approval;
/**
* 审批状态枚举
*/
public enum ApprovalStatus {
PENDING,
APPROVED,
DENIED,
CONSUMED,
TIMEOUT,
SUPERSEDED;
/**
* 从字符串解析兼容现有 PendingApproval status 字段
*/
public static ApprovalStatus fromString(String status) {
if (status == null) return PENDING;
try {
return valueOf(status.toUpperCase());
} catch (IllegalArgumentException e) {
return PENDING;
}
}
}

View File

@ -0,0 +1,268 @@
package vip.mate.approval;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Service;
import vip.mate.approval.model.ToolApprovalEntity;
import vip.mate.approval.repository.ToolApprovalMapper;
import vip.mate.tool.guard.model.GuardEvaluation;
import vip.mate.tool.guard.model.GuardFinding;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;
import java.util.Map;
/**
* 审批工作流服务write-through: 内存 + DB 双写
* <p>
* 在现有 ApprovalService内存层之上增加 DB 持久化
* 所有写操作先走 ApprovalService再写 DB
* 启动时从 DB 恢复 PENDING 状态到内存
*/
@Slf4j
@Service
@Order(55) // ApprovalSchemaMigration(50) 之后
@RequiredArgsConstructor
public class ApprovalWorkflowService implements ApplicationRunner {
private final ApprovalService approvalService;
private final ToolApprovalMapper approvalMapper;
private final ObjectMapper objectMapper;
@Override
public void run(ApplicationArguments args) {
recoverFromDb();
}
/**
* 启动时从 DB 恢复 PENDING 审批到内存
*/
void recoverFromDb() {
try {
List<ToolApprovalEntity> pendingRecords = approvalMapper.selectList(
new LambdaQueryWrapper<ToolApprovalEntity>()
.eq(ToolApprovalEntity::getStatus, "PENDING")
.orderByAsc(ToolApprovalEntity::getCreatedAt)
);
int recovered = 0;
for (ToolApprovalEntity entity : pendingRecords) {
// 检查是否已过期30 分钟
if (entity.getCreatedAt() != null) {
Instant createdAt = entity.getCreatedAt().atZone(ZoneId.systemDefault()).toInstant();
if (Instant.now().minusSeconds(1800).isAfter(createdAt)) {
// 已过期更新 DB 状态
entity.setStatus("TIMEOUT");
entity.setResolvedAt(LocalDateTime.now());
approvalMapper.updateById(entity);
continue;
}
}
// 恢复到内存
String pendingId = approvalService.createPending(
entity.getConversationId(),
entity.getUserId(),
entity.getToolName(),
entity.getToolArguments(),
entity.getSummary(),
entity.getToolCallPayload(),
entity.getSiblingToolCalls(),
entity.getAgentId()
);
// 修正内存中的 pendingId 以匹配 DB
// 由于 ApprovalService.createPending 会生成新 ID我们需要取消它并使用原始 ID
approvalService.cancelStalePending(entity.getConversationId(), null);
pendingId = approvalService.createPending(
entity.getConversationId(),
entity.getUserId(),
entity.getToolName(),
entity.getToolArguments(),
entity.getSummary(),
entity.getToolCallPayload(),
entity.getSiblingToolCalls(),
entity.getAgentId()
);
recovered++;
}
if (recovered > 0) {
log.info("[ApprovalWorkflow] Recovered {} pending approvals from DB", recovered);
}
} catch (Exception e) {
log.warn("[ApprovalWorkflow] Failed to recover from DB (table may not exist yet): {}", e.getMessage());
}
}
/**
* 创建待审批记录增强版 GuardEvaluation
*/
public String createPending(String conversationId, String userId,
String toolName, String toolArguments, String reason,
String toolCallPayload, String siblingToolCalls, String agentId,
GuardEvaluation evaluation) {
// 1. 内存层
String pendingId = approvalService.createPending(
conversationId, userId, toolName, toolArguments, reason,
toolCallPayload, siblingToolCalls, agentId);
// 2. 增强内存记录
approvalService.getPending(pendingId).ifPresent(pending -> {
if (evaluation != null) {
pending.setFindingsJson(serializeFindings(evaluation.findings()));
pending.setMaxSeverity(evaluation.maxSeverity() != null ? evaluation.maxSeverity().name() : null);
pending.setSummary(evaluation.summary());
}
});
// 3. DB
persistToDb(pendingId, conversationId, userId, toolName, toolArguments,
toolCallPayload, siblingToolCalls, agentId, evaluation);
return pendingId;
}
/**
* 创建待审批记录基础版向后兼容
*/
public String createPending(String conversationId, String userId,
String toolName, String toolArguments, String reason,
String toolCallPayload, String siblingToolCalls, String agentId) {
return createPending(conversationId, userId, toolName, toolArguments, reason,
toolCallPayload, siblingToolCalls, agentId, null);
}
/**
* 解决审批
*/
public void resolve(String pendingId, String userId, String decision) {
approvalService.resolve(pendingId, userId, decision);
updateDbStatus(pendingId, decision.toUpperCase(), userId);
}
/**
* 原子解决+消费
*/
public PendingApproval resolveAndConsume(String pendingId, String userId) {
PendingApproval consumed = approvalService.resolveAndConsume(pendingId, userId);
if (consumed != null) {
updateDbStatus(pendingId, "CONSUMED", userId);
}
return consumed;
}
/**
* 消费已批准记录
*/
public PendingApproval consumeApproved(String conversationId, String toolName) {
PendingApproval consumed = approvalService.consumeApproved(conversationId, toolName);
if (consumed != null) {
updateDbStatus(consumed.getPendingId(), "CONSUMED", null);
}
return consumed;
}
/**
* 取消过期 pending
*/
public void cancelStalePending(String conversationId, String excludePendingId) {
approvalService.cancelStalePending(conversationId, excludePendingId);
try {
approvalMapper.update(null, new LambdaUpdateWrapper<ToolApprovalEntity>()
.eq(ToolApprovalEntity::getConversationId, conversationId)
.eq(ToolApprovalEntity::getStatus, "PENDING")
.ne(excludePendingId != null, ToolApprovalEntity::getPendingId, excludePendingId)
.set(ToolApprovalEntity::getStatus, "SUPERSEDED")
.set(ToolApprovalEntity::getResolvedAt, LocalDateTime.now()));
} catch (Exception e) {
log.warn("[ApprovalWorkflow] Failed to cancel stale in DB: {}", e.getMessage());
}
}
/**
* 代理查询方法
*/
public PendingApproval findPendingByConversation(String conversationId) {
return approvalService.findPendingByConversation(conversationId);
}
public List<Map<String, Object>> getPendingByConversation(String conversationId) {
return approvalService.getPendingByConversation(conversationId);
}
// ==================== 内部方法 ====================
private void persistToDb(String pendingId, String conversationId, String userId,
String toolName, String toolArguments,
String toolCallPayload, String siblingToolCalls, String agentId,
GuardEvaluation evaluation) {
try {
ToolApprovalEntity entity = new ToolApprovalEntity();
entity.setPendingId(pendingId);
entity.setConversationId(conversationId);
entity.setUserId(userId);
entity.setAgentId(agentId);
entity.setToolName(toolName);
entity.setToolArguments(toolArguments);
entity.setToolCallPayload(toolCallPayload);
entity.setSiblingToolCalls(siblingToolCalls);
entity.setStatus("PENDING");
entity.setCreatedAt(LocalDateTime.now());
entity.setExpireAt(LocalDateTime.now().plusMinutes(30));
if (evaluation != null) {
entity.setFindingsJson(serializeFindings(evaluation.findings()));
entity.setMaxSeverity(evaluation.maxSeverity() != null ? evaluation.maxSeverity().name() : null);
entity.setSummary(evaluation.summary());
if (toolCallPayload != null) {
entity.setToolCallHash(String.valueOf(toolCallPayload.hashCode()));
}
}
approvalMapper.insert(entity);
} catch (Exception e) {
log.warn("[ApprovalWorkflow] Failed to persist approval to DB: {}", e.getMessage());
}
}
private void updateDbStatus(String pendingId, String status, String resolvedBy) {
try {
LambdaUpdateWrapper<ToolApprovalEntity> wrapper = new LambdaUpdateWrapper<ToolApprovalEntity>()
.eq(ToolApprovalEntity::getPendingId, pendingId)
.set(ToolApprovalEntity::getStatus, status)
.set(ToolApprovalEntity::getResolvedAt, LocalDateTime.now());
if (resolvedBy != null) {
wrapper.set(ToolApprovalEntity::getResolvedBy, resolvedBy);
}
approvalMapper.update(null, wrapper);
} catch (Exception e) {
log.warn("[ApprovalWorkflow] Failed to update DB status: {}", e.getMessage());
}
}
private String serializeFindings(List<GuardFinding> findings) {
if (findings == null || findings.isEmpty()) return null;
try {
return objectMapper.writeValueAsString(
findings.stream().map(GuardFinding::toMap).toList()
);
} catch (JsonProcessingException e) {
log.warn("[ApprovalWorkflow] Failed to serialize findings: {}", e.getMessage());
return null;
}
}
}

View File

@ -0,0 +1,110 @@
package vip.mate.approval;
import java.time.Instant;
/**
* 待审批记录消息驱动版
* <p>
* 不再持有 CompletableFuture状态流转由 status 字段驱动
* 包含工具调用重放所需的全部信息
*/
public class PendingApproval {
private final String pendingId;
private final String conversationId;
private final String userId;
private final String toolName;
private final String toolArguments;
private final String reason;
private final Instant createdAt;
// === 状态 ===
// pending approved consumed / denied / timeout / superseded
private volatile String status;
// === 重放相关字段 ===
/** 发起审批的渠道类型 */
private String channelType;
/** 发送者名称(审计日志) */
private String requesterName;
/** 回复目标标识(飞书 chatId、钉钉 conversationId 等) */
private String replyTarget;
/** 完整的 tool call 载荷JSON用于 replay 重放 */
private String toolCallPayload;
/** 同一轮中其他被阻塞的 tool callsJSON 数组) */
private String siblingToolCalls;
/** Agent ID重放时需要知道用哪个 Agent */
private String agentId;
/** 审批解决时间 */
private Instant resolvedAt;
/** 审批解决者 userId */
private String resolvedBy;
// === 增强字段Phase 2: 结构化风险信息===
/** Guard findings JSON结构化风险发现列表 */
private String findingsJson;
/** 最高风险等级 */
private String maxSeverity;
/** 风险摘要 */
private String summary;
public PendingApproval(String pendingId, String conversationId, String userId,
String toolName, String toolArguments, String reason) {
this.pendingId = pendingId;
this.conversationId = conversationId;
this.userId = userId;
this.toolName = toolName;
this.toolArguments = toolArguments;
this.reason = reason;
this.createdAt = Instant.now();
this.status = "pending";
}
// === Getters ===
public String getPendingId() { return pendingId; }
public String getConversationId() { return conversationId; }
public String getUserId() { return userId; }
public String getToolName() { return toolName; }
public String getToolArguments() { return toolArguments; }
public String getReason() { return reason; }
public Instant getCreatedAt() { return createdAt; }
public String getStatus() { return status; }
public String getChannelType() { return channelType; }
public String getRequesterName() { return requesterName; }
public String getReplyTarget() { return replyTarget; }
public String getToolCallPayload() { return toolCallPayload; }
public String getSiblingToolCalls() { return siblingToolCalls; }
public String getAgentId() { return agentId; }
public Instant getResolvedAt() { return resolvedAt; }
public String getResolvedBy() { return resolvedBy; }
public String getFindingsJson() { return findingsJson; }
public String getMaxSeverity() { return maxSeverity; }
public String getSummary() { return summary; }
// === Setters ===
public void setStatus(String status) { this.status = status; }
public void setChannelType(String channelType) { this.channelType = channelType; }
public void setRequesterName(String requesterName) { this.requesterName = requesterName; }
public void setReplyTarget(String replyTarget) { this.replyTarget = replyTarget; }
public void setToolCallPayload(String toolCallPayload) { this.toolCallPayload = toolCallPayload; }
public void setSiblingToolCalls(String siblingToolCalls) { this.siblingToolCalls = siblingToolCalls; }
public void setAgentId(String agentId) { this.agentId = agentId; }
public void setResolvedAt(Instant resolvedAt) { this.resolvedAt = resolvedAt; }
public void setResolvedBy(String resolvedBy) { this.resolvedBy = resolvedBy; }
public void setFindingsJson(String findingsJson) { this.findingsJson = findingsJson; }
public void setMaxSeverity(String maxSeverity) { this.maxSeverity = maxSeverity; }
public void setSummary(String summary) { this.summary = summary; }
}

View File

@ -0,0 +1,75 @@
package vip.mate.approval.config;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* 审批表 Schema 迁移
*/
@Slf4j
@Component
@Order(50)
@RequiredArgsConstructor
public class ApprovalSchemaMigration implements ApplicationRunner {
private final JdbcTemplate jdbcTemplate;
@Override
public void run(ApplicationArguments args) {
createToolApprovalTable();
}
private void createToolApprovalTable() {
try {
jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS mate_tool_approval (
id BIGINT NOT NULL PRIMARY KEY,
pending_id VARCHAR(32) NOT NULL UNIQUE,
conversation_id VARCHAR(128) NOT NULL,
user_id VARCHAR(64),
agent_id VARCHAR(64),
channel_type VARCHAR(32),
requester_name VARCHAR(128),
reply_target VARCHAR(512),
tool_name VARCHAR(128) NOT NULL,
tool_arguments TEXT,
tool_call_payload TEXT,
tool_call_hash VARCHAR(64),
sibling_tool_calls TEXT,
summary TEXT,
findings_json TEXT,
max_severity VARCHAR(16),
status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
resolved_by VARCHAR(64),
created_at DATETIME NOT NULL,
resolved_at DATETIME,
expire_at DATETIME,
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
deleted INT NOT NULL DEFAULT 0
)
""");
safeExecute("CREATE INDEX IF NOT EXISTS idx_tool_approval_conv ON mate_tool_approval(conversation_id)");
safeExecute("CREATE INDEX IF NOT EXISTS idx_tool_approval_status ON mate_tool_approval(status)");
safeExecute("CREATE INDEX IF NOT EXISTS idx_tool_approval_pending_id ON mate_tool_approval(pending_id)");
log.info("[ApprovalSchemaMigration] mate_tool_approval table ready");
} catch (Exception e) {
log.warn("[ApprovalSchemaMigration] Failed to create mate_tool_approval: {}", e.getMessage());
}
}
private void safeExecute(String sql) {
try {
jdbcTemplate.execute(sql);
} catch (Exception e) {
log.debug("[ApprovalSchemaMigration] Index may already exist: {}", e.getMessage());
}
}
}

View File

@ -0,0 +1,47 @@
package vip.mate.approval.model;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 工具审批记录实体
*/
@Data
@TableName("mate_tool_approval")
public class ToolApprovalEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String pendingId;
private String conversationId;
private String userId;
private String agentId;
private String channelType;
private String requesterName;
private String replyTarget;
private String toolName;
private String toolArguments;
private String toolCallPayload;
private String toolCallHash;
private String siblingToolCalls;
private String summary;
private String findingsJson;
private String maxSeverity;
private String status;
private String resolvedBy;
private LocalDateTime createdAt;
private LocalDateTime resolvedAt;
private LocalDateTime expireAt;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
private Integer deleted;
}

View File

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

View File

@ -0,0 +1,55 @@
package vip.mate.auth.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import vip.mate.auth.model.LoginRequest;
import vip.mate.auth.model.LoginResponse;
import vip.mate.auth.model.UserEntity;
import vip.mate.auth.service.AuthService;
import vip.mate.common.result.R;
import java.util.List;
/**
* 认证接口
*
* @author MateClaw Team
*/
@Tag(name = "认证管理")
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@Operation(summary = "用户登录")
@PostMapping("/login")
public R<LoginResponse> login(@RequestBody LoginRequest request) {
return R.ok(authService.login(request));
}
@Operation(summary = "获取用户列表")
@GetMapping("/users")
public R<List<UserEntity>> listUsers() {
return R.ok(authService.listUsers());
}
@Operation(summary = "创建用户")
@PostMapping("/users")
public R<UserEntity> createUser(@RequestBody UserEntity user) {
return R.ok(authService.createUser(user));
}
@Operation(summary = "修改密码")
@PutMapping("/users/{id}/password")
public R<Void> changePassword(
@PathVariable Long id,
@RequestParam String oldPassword,
@RequestParam String newPassword) {
authService.changePassword(id, oldPassword, newPassword);
return R.ok();
}
}

View File

@ -0,0 +1,14 @@
package vip.mate.auth.model;
import lombok.Data;
/**
* 登录请求
*
* @author MateClaw Team
*/
@Data
public class LoginRequest {
private String username;
private String password;
}

View File

@ -0,0 +1,18 @@
package vip.mate.auth.model;
import lombok.AllArgsConstructor;
import lombok.Data;
/**
* 登录响应
*
* @author MateClaw Team
*/
@Data
@AllArgsConstructor
public class LoginResponse {
private String token;
private String username;
private String nickname;
private String role;
}

View File

@ -0,0 +1,49 @@
package vip.mate.auth.model;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 用户实体
*
* @author MateClaw Team
*/
@Data
@TableName("mate_user")
public class UserEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 用户名 */
private String username;
/** 密码BCrypt加密 */
private String password;
/** 昵称 */
private String nickname;
/** 头像URL */
private String avatar;
/** 邮箱 */
private String email;
/** 角色admin / user */
private String role;
/** 是否启用 */
private Boolean enabled;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
private Integer deleted;
}

View File

@ -0,0 +1,14 @@
package vip.mate.auth.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import vip.mate.auth.model.UserEntity;
/**
* 用户 Mapper
*
* @author MateClaw Team
*/
@Mapper
public interface UserMapper extends BaseMapper<UserEntity> {
}

View File

@ -0,0 +1,186 @@
package vip.mate.auth.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import vip.mate.auth.model.LoginRequest;
import vip.mate.auth.model.LoginResponse;
import vip.mate.auth.model.UserEntity;
import vip.mate.auth.repository.UserMapper;
import vip.mate.exception.MateClawException;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.List;
/**
* 认证服务JWT
*
* @author MateClaw Team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserMapper userMapper;
private final BCryptPasswordEncoder passwordEncoder;
@Value("${mateclaw.jwt.secret:MateClaw-Secret-Key-2024-Very-Long-String}")
private String jwtSecret;
@Value("${mateclaw.jwt.expiration:86400000}")
private long jwtExpiration;
@Value("${mateclaw.jwt.renewal-threshold:7200000}")
private long renewalThreshold;
/**
* 登录
*/
public LoginResponse login(LoginRequest request) {
UserEntity user = userMapper.selectOne(new LambdaQueryWrapper<UserEntity>()
.eq(UserEntity::getUsername, request.getUsername())
.eq(UserEntity::getEnabled, true));
if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPassword())) {
throw new MateClawException("用户名或密码错误");
}
String token = generateToken(user);
return new LoginResponse(token, user.getUsername(), user.getNickname(), user.getRole());
}
/**
* 获取用户列表管理员
*/
public List<UserEntity> listUsers() {
return userMapper.selectList(new LambdaQueryWrapper<UserEntity>()
.eq(UserEntity::getEnabled, true));
}
/**
* 创建用户
*/
public UserEntity createUser(UserEntity user) {
// 检查用户名是否已存在
Long count = userMapper.selectCount(new LambdaQueryWrapper<UserEntity>()
.eq(UserEntity::getUsername, user.getUsername()));
if (count > 0) {
throw new MateClawException("用户名已存在: " + user.getUsername());
}
user.setPassword(passwordEncoder.encode(user.getPassword()));
user.setEnabled(true);
if (user.getRole() == null) {
user.setRole("user");
}
userMapper.insert(user);
user.setPassword(null);
return user;
}
/**
* 修改密码
*/
public void changePassword(Long userId, String oldPassword, String newPassword) {
UserEntity user = userMapper.selectById(userId);
if (user == null) {
throw new MateClawException("用户不存在");
}
if (!passwordEncoder.matches(oldPassword, user.getPassword())) {
throw new MateClawException("原密码错误");
}
user.setPassword(passwordEncoder.encode(newPassword));
userMapper.updateById(user);
}
/**
* 解析 Token 获取用户名
*/
public String parseToken(String token) {
try {
Claims claims = Jwts.parser()
.verifyWith(getSignKey())
.build()
.parseSignedClaims(token)
.getPayload();
return claims.getSubject();
} catch (Exception e) {
return null;
}
}
/**
* 解析 Token 获取完整 Claims含过期时间
*/
public Claims parseClaims(String token) {
try {
return Jwts.parser()
.verifyWith(getSignKey())
.build()
.parseSignedClaims(token)
.getPayload();
} catch (Exception e) {
return null;
}
}
/**
* 判断 Token 是否接近过期剩余有效期 < renewalThreshold
*/
public boolean isNearExpiry(Claims claims) {
if (claims == null || claims.getExpiration() == null) {
return false;
}
long remaining = claims.getExpiration().getTime() - System.currentTimeMillis();
return remaining > 0 && remaining < renewalThreshold;
}
/**
* 根据用户名续签 Token
*/
public String renewToken(String username) {
UserEntity user = findByUsername(username);
if (user != null && Boolean.TRUE.equals(user.getEnabled())) {
return generateToken(user);
}
return null;
}
/**
* 根据用户名查询用户
*/
public UserEntity findByUsername(String username) {
return userMapper.selectOne(new LambdaQueryWrapper<UserEntity>()
.eq(UserEntity::getUsername, username));
}
private String generateToken(UserEntity user) {
return Jwts.builder()
.subject(user.getUsername())
.claim("userId", user.getId())
.claim("role", user.getRole())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + jwtExpiration))
.signWith(getSignKey())
.compact();
}
private SecretKey getSignKey() {
byte[] keyBytes = jwtSecret.getBytes(StandardCharsets.UTF_8);
// 确保密钥长度至少 32 字节HMAC-SHA256
if (keyBytes.length < 32) {
byte[] padded = new byte[32];
System.arraycopy(keyBytes, 0, padded, 0, keyBytes.length);
keyBytes = padded;
}
return Keys.hmacShaKeyFor(keyBytes);
}
}

View File

@ -0,0 +1,442 @@
package vip.mate.channel;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import vip.mate.channel.model.ChannelEntity;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
/**
* 渠道适配器抽象基类
* <p>
* 渠道适配器抽象基类设计
* - 统一的生命周期管理start/stop/isRunning
* - Bot 前缀过滤群消息中只响应 @bot 或指定前缀的消息
* - 配置解析 ChannelEntity.configJson 读取渠道特有配置
* - 消息路由通过 ChannelMessageRouter 转发到 Agent
*
* @author MateClaw Team
*/
@Slf4j
public abstract class AbstractChannelAdapter implements ChannelAdapter {
protected final ChannelEntity channelEntity;
protected final ChannelMessageRouter messageRouter;
protected final ObjectMapper objectMapper;
protected final AtomicBoolean running = new AtomicBoolean(false);
/** 解析后的渠道配置 */
protected Map<String, Object> config;
// ==================== 连接状态 & 重连基础设施 ====================
/** 渠道连接状态 */
public enum ConnectionState {
CONNECTED, // 已连接
RECONNECTING, // 重连中
DISCONNECTED, // 已断开
ERROR // 错误超过最大重试次数
}
@Getter
protected final AtomicReference<ConnectionState> connectionState =
new AtomicReference<>(ConnectionState.DISCONNECTED);
@Getter
protected volatile String lastError;
protected ExponentialBackoff backoff = new ExponentialBackoff();
/** 重连调度器(懒初始化,仅 IM 渠道使用) */
protected ScheduledExecutorService reconnectScheduler;
/** 当前重连任务的 Future可取消 */
protected volatile ScheduledFuture<?> reconnectFuture;
protected AbstractChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter,
ObjectMapper objectMapper) {
this.channelEntity = channelEntity;
this.messageRouter = messageRouter;
this.objectMapper = objectMapper;
this.config = parseConfig(channelEntity.getConfigJson());
}
/**
* 获取或创建重连调度器
*/
protected ScheduledExecutorService ensureReconnectScheduler() {
if (reconnectScheduler == null || reconnectScheduler.isShutdown()) {
reconnectScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, getChannelType() + "-reconnect-" + channelEntity.getId());
t.setDaemon(true);
return t;
});
}
return reconnectScheduler;
}
/**
* 调度一次重连尝试指数退避延迟
* <p>
* 子类在检测到连接断开时调用此方法方法内部会
* 1. 检查是否已超过最大重试次数
* 2. 计算下一次重试延迟
* 3. 通过 ScheduledExecutorService 调度 {@link #doReconnect()}
*/
protected void scheduleReconnect() {
if (!running.get()) {
log.debug("[{}] Not running, skipping reconnect", getChannelType());
return;
}
if (backoff.isExhausted()) {
connectionState.set(ConnectionState.ERROR);
lastError = "Max reconnect attempts (" + backoff.getMaxAttempts() + ") exhausted";
log.error("[{}] {}: {}", getChannelType(), channelEntity.getName(), lastError);
return;
}
connectionState.set(ConnectionState.RECONNECTING);
long delayMs = backoff.nextDelayMs();
log.info("[{}] Scheduling reconnect for {} in {}ms (attempt #{})",
getChannelType(), channelEntity.getName(), delayMs, backoff.getAttempts());
reconnectFuture = ensureReconnectScheduler().schedule(() -> {
if (!running.get()) return;
try {
doReconnect();
onReconnectSuccess();
} catch (Exception e) {
onReconnectFailed(e);
}
}, delayMs, TimeUnit.MILLISECONDS);
}
/**
* 实际重连逻辑子类覆写
* <p>
* 默认实现调用 doStop() + doStart()子类可覆写以实现更细粒度的重连
*/
protected void doReconnect() {
log.info("[{}] Reconnecting: {}", getChannelType(), channelEntity.getName());
try {
doStop();
} catch (Exception e) {
log.debug("[{}] doStop during reconnect: {}", getChannelType(), e.getMessage());
}
doStart();
}
/**
* 连接断开时调用子类调用此方法触发重连流程
*/
protected void onDisconnected(String reason) {
if (!running.get()) return;
lastError = reason;
log.warn("[{}] Disconnected: {} - {}", getChannelType(), channelEntity.getName(), reason);
scheduleReconnect();
}
/**
* 重连成功回调
*/
protected void onReconnectSuccess() {
backoff.reset();
connectionState.set(ConnectionState.CONNECTED);
lastError = null;
log.info("[{}] Reconnected successfully: {} (backoff reset)",
getChannelType(), channelEntity.getName());
}
/**
* 重连失败回调
*/
protected void onReconnectFailed(Exception e) {
lastError = e.getMessage();
log.warn("[{}] Reconnect failed for {}: {} (attempt #{})",
getChannelType(), channelEntity.getName(), e.getMessage(), backoff.getAttempts());
scheduleReconnect();
}
@Override
public void start() {
if (running.compareAndSet(false, true)) {
log.info("[{}] Starting channel: {}", getChannelType(), channelEntity.getName());
try {
doStart();
connectionState.set(ConnectionState.CONNECTED);
lastError = null;
backoff.reset();
log.info("[{}] Channel started successfully: {}", getChannelType(), channelEntity.getName());
} catch (Exception e) {
running.set(false);
connectionState.set(ConnectionState.ERROR);
lastError = e.getMessage();
log.error("[{}] Failed to start channel {}: {}", getChannelType(), channelEntity.getName(), e.getMessage(), e);
throw new RuntimeException("Channel start failed: " + e.getMessage(), e);
}
}
}
@Override
public void stop() {
if (running.compareAndSet(true, false)) {
log.info("[{}] Stopping channel: {}", getChannelType(), channelEntity.getName());
// 取消挂起的重连任务
if (reconnectFuture != null) {
reconnectFuture.cancel(false);
reconnectFuture = null;
}
if (reconnectScheduler != null && !reconnectScheduler.isShutdown()) {
reconnectScheduler.shutdownNow();
reconnectScheduler = null;
}
try {
doStop();
log.info("[{}] Channel stopped: {}", getChannelType(), channelEntity.getName());
} catch (Exception e) {
log.error("[{}] Error stopping channel {}: {}", getChannelType(), channelEntity.getName(), e.getMessage(), e);
}
connectionState.set(ConnectionState.DISCONNECTED);
}
}
@Override
public boolean isRunning() {
return running.get();
}
@Override
public void onMessage(ChannelMessage message) {
// Bot 前缀过滤
if (!shouldProcess(message)) {
log.debug("[{}] Message filtered (bot prefix not matched): {}", getChannelType(), message.getContent());
return;
}
// 清理 bot 前缀
String cleaned = cleanBotPrefix(message.getContent());
if (cleaned.isBlank()) {
log.debug("[{}] Empty message after prefix cleaning, ignoring", getChannelType());
return;
}
message.setContent(cleaned);
// 访问控制检查
if (!checkAccess(message)) {
return;
}
// 路由到 Agent 处理
messageRouter.enqueue(message, this, channelEntity);
}
@Override
public String getDisplayName() {
return channelEntity.getName();
}
/**
* 渲染并发送消息根据 configJson 中的渲染配置过滤内容按平台限制分割后逐段发送
*/
@Override
public void renderAndSend(String targetId, String content) {
boolean filterThinking = getConfigBoolean("filter_thinking", true);
boolean filterToolMessages = getConfigBoolean("filter_tool_messages", true);
String format = getConfigString("message_format", "auto");
int maxLen = ChannelMessageRenderer.PLATFORM_LIMITS.getOrDefault(getChannelType(), 20000);
List<String> segments = ChannelMessageRenderer.renderForChannel(
content, filterThinking, filterToolMessages, format, maxLen);
for (String segment : segments) {
sendMessage(targetId, segment);
}
}
// ==================== 模板方法子类实现 ====================
/**
* 实际启动逻辑建立连接注册 Webhook
*/
protected abstract void doStart();
/**
* 实际停止逻辑断开连接清理资源
*/
protected abstract void doStop();
// ==================== Bot 前缀处理 ====================
/**
* 判断消息是否需要处理
* <p>
* 实现 require_mention / bot_prefix 过滤机制
* - 私聊chatId == null 或等于 senderId始终处理
* - 群聊如果设置了 botPrefix只处理以该前缀开头的消息
*/
protected boolean shouldProcess(ChannelMessage message) {
String botPrefix = channelEntity.getBotPrefix();
if (botPrefix == null || botPrefix.isBlank()) {
return true; // 未设置前缀处理所有消息
}
// 私聊始终处理
if (isDirectMessage(message)) {
return true;
}
// 群聊检查前缀
String content = message.getContent();
return content != null && content.trim().startsWith(botPrefix.trim());
}
/**
* 清理消息中的 bot 前缀
*/
protected String cleanBotPrefix(String content) {
if (content == null) return "";
String botPrefix = channelEntity.getBotPrefix();
if (botPrefix != null && !botPrefix.isBlank() && content.trim().startsWith(botPrefix.trim())) {
return content.trim().substring(botPrefix.trim().length()).trim();
}
return content.trim();
}
/**
* 判断是否为私聊消息
*/
protected boolean isDirectMessage(ChannelMessage message) {
return message.getChatId() == null
|| message.getChatId().equals(message.getSenderId());
}
// ==================== 访问控制 ====================
/**
* 检查消息发送者是否有权访问此渠道
* <p>
* 基于策略的访问控制设计
* - dm_policy / group_policy控制私聊/群聊是否开放
* - allow_from用户白名单
* - deny_message拒绝时的提示消息
* - require_mention群聊中是否需要 @机器人
*/
protected boolean checkAccess(ChannelMessage message) {
boolean isDM = isDirectMessage(message);
// 1. 检查私聊/群聊策略
String policy = isDM
? getConfigString("dm_policy", "open")
: getConfigString("group_policy", "open");
if ("closed".equals(policy)) {
log.info("[{}] {} blocked by {} policy=closed, sender={}",
getChannelType(), isDM ? "DM" : "Group", isDM ? "dm" : "group", message.getSenderId());
sendDenyMessage(message);
return false;
}
// 2. 群聊中检查 require_mention需要 @机器人才响应
// 如果已设置 botPrefixshouldProcess() 已处理此处处理 configJson 中的 require_mention
if (!isDM && getConfigBoolean("require_mention", false)) {
String botPrefix = channelEntity.getBotPrefix();
if (botPrefix == null || botPrefix.isBlank()) {
// 设置了 require_mention 但没有 botPrefix无法判断 mention放行
log.debug("[{}] require_mention=true but no botPrefix configured, allowing", getChannelType());
}
// 如果有 botPrefixshouldProcess() 已经过滤过非 mention 消息此处放行
}
// 3. 检查 allow_from 白名单
List<String> allowFrom = getConfigList("allow_from");
if (!allowFrom.isEmpty()) {
if (!allowFrom.contains(message.getSenderId())) {
log.info("[{}] Sender {} not in allow_from list", getChannelType(), message.getSenderId());
sendDenyMessage(message);
return false;
}
}
return true;
}
/**
* 发送拒绝消息
*/
private void sendDenyMessage(ChannelMessage message) {
String denyMsg = getConfigString("deny_message", "抱歉,您没有使用权限");
try {
String target = message.getReplyToken() != null ? message.getReplyToken()
: (message.getChatId() != null ? message.getChatId() : message.getSenderId());
sendMessage(target, denyMsg);
} catch (Exception e) {
log.error("[{}] Failed to send deny message: {}", getChannelType(), e.getMessage());
}
}
// ==================== 配置解析 ====================
/**
* configJson 解析配置
*/
protected Map<String, Object> parseConfig(String configJson) {
if (configJson == null || configJson.isBlank()) {
return Collections.emptyMap();
}
try {
return objectMapper.readValue(configJson, new TypeReference<>() {});
} catch (Exception e) {
log.warn("[{}] Failed to parse configJson: {}", getChannelType(), e.getMessage());
return Collections.emptyMap();
}
}
/**
* 获取配置值
*/
protected String getConfigString(String key) {
Object value = config.get(key);
return value != null ? value.toString() : null;
}
protected String getConfigString(String key, String defaultValue) {
String value = getConfigString(key);
return value != null ? value : defaultValue;
}
protected boolean getConfigBoolean(String key, boolean defaultValue) {
Object value = config.get(key);
if (value instanceof Boolean b) return b;
if (value instanceof String s) return Boolean.parseBoolean(s);
return defaultValue;
}
/**
* 获取配置中的列表值
*/
protected List<String> getConfigList(String key) {
Object value = config.get(key);
if (value instanceof List<?> list) {
return list.stream().map(Object::toString).toList();
}
return Collections.emptyList();
}
/**
* 获取渠道实体
*/
public ChannelEntity getChannelEntity() {
return channelEntity;
}
}

View File

@ -0,0 +1,139 @@
package vip.mate.channel;
import vip.mate.workspace.conversation.model.MessageContentPart;
import java.util.List;
/**
* 渠道适配器接口
* <p>
* 所有 IM 渠道钉钉飞书企业微信等均需实现此接口
* 统一生命周期管理 + 消息收发抽象
*
* @author MateClaw Team
*/
public interface ChannelAdapter {
// ==================== 生命周期 ====================
/**
* 启动渠道建立长连接注册 Webhook
* 启动失败应抛出异常不影响其他渠道
*/
void start();
/**
* 停止渠道断开连接清理资源
*/
void stop();
/**
* 渠道是否正在运行
*/
boolean isRunning();
// ==================== 消息收发 ====================
/**
* 处理来自渠道的入站消息
* <p>
* 由渠道实现类在收到消息后调用Webhook 回调 / 长连接推送
* 通常内部会调用 {@link ChannelMessageRouter} 路由到 Agent 处理
*
* @param message 渠道消息已转换为统一格式
*/
void onMessage(ChannelMessage message);
/**
* 向渠道发送消息主动推送
* <p>
* 用于 Agent 回复定时任务结果推送等场景
*
* @param targetId 目标标识 openIdchatIdsessionWebhook
* @param content 消息内容Markdown 格式具体渠道可自行渲染
*/
void sendMessage(String targetId, String content);
/**
* 发送结构化内容多模态文本 + 图片 + 文件等
* <p>
* 默认实现提取纯文本后退化为 sendMessage各渠道可覆写此方法
* 调用平台对应的富媒体 API 发送图片文件等
*
* @param targetId 目标标识
* @param parts 结构化内容片段
*/
default void sendContentParts(String targetId, List<MessageContentPart> parts) {
// 默认退化提取文本忽略媒体
StringBuilder text = new StringBuilder();
for (MessageContentPart part : parts) {
if (part == null) continue;
switch (part.getType()) {
case "text" -> { if (part.getText() != null) text.append(part.getText()); }
case "image" -> text.append("[图片]");
case "file" -> text.append("[文件: ").append(part.getFileName() != null ? part.getFileName() : "").append("]");
case "audio" -> text.append("[音频]");
case "video" -> text.append("[视频]");
default -> { if (part.getText() != null) text.append(part.getText()); }
}
}
sendMessage(targetId, text.toString());
}
/**
* 渲染并发送消息过滤 thinking/tool_call 标签按平台限制分割后逐段发送
* <p>
* 默认实现直接调用 sendMessage不做渲染
* AbstractChannelAdapter 覆写此方法读取 configJson 中的渲染配置
*
* @param targetId 目标标识
* @param content 原始消息内容
*/
default void renderAndSend(String targetId, String content) {
sendMessage(targetId, content);
}
// ==================== 主动推送 ====================
/**
* 主动发送消息到指定目标不依赖 Webhook 回调上下文
* <p>
* sendMessage 的区别sendMessage 通常在 Webhook 回调链路中使用
* targetId 来自 replyToken如钉钉的 sessionWebhook
* proactiveSend 用于无回调上下文的主动推送场景如定时任务
* targetId 为平台的用户/群组/频道标识
* <p>
* 不支持主动推送的渠道 Web默认抛出 UnsupportedOperationException
*
* @param targetId 目标标识用户ID / 群组ID / 频道ID因渠道而异
* @param content 消息内容Markdown 格式
*/
default void proactiveSend(String targetId, String content) {
throw new UnsupportedOperationException(getChannelType() + " does not support proactive send");
}
/**
* 当前渠道是否支持主动推送
*
* @return true 表示支持 proactiveSend
*/
default boolean supportsProactiveSend() {
return false;
}
// ==================== 元信息 ====================
/**
* 获取渠道类型标识
*
* @return 渠道类型 "web", "dingtalk", "feishu", "telegram"
*/
String getChannelType();
/**
* 获取渠道显示名称
*/
default String getDisplayName() {
return getChannelType();
}
}

View File

@ -0,0 +1,405 @@
package vip.mate.channel;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import vip.mate.channel.dingtalk.DingTalkChannelAdapter;
import vip.mate.channel.discord.DiscordChannelAdapter;
import vip.mate.channel.feishu.FeishuChannelAdapter;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.channel.qq.QQChannelAdapter;
import vip.mate.channel.service.ChannelService;
import vip.mate.channel.telegram.TelegramChannelAdapter;
import vip.mate.channel.web.WebChannelAdapter;
import vip.mate.channel.wecom.WeComChannelAdapter;
import vip.mate.channel.weixin.WeixinChannelAdapter;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
* 渠道管理器
* <p>
* 实现渠道生命周期管理 + 热替换机制
* - 管理所有渠道适配器的生命周期启动/停止/热替换
* - 维护渠道类型注册表根据 channelType 创建对应适配器
* - 支持动态增删渠道通过 API 启用/禁用时自动 start/stop
* - 应用启动时自动加载并启动所有 enabled 渠道
* - activeAdapters 使用 ReadWriteLock 保护读操作并发安全热替换使用写锁
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ChannelManager {
private final ChannelService channelService;
private final ChannelMessageRouter messageRouter;
private final ChannelSessionStore channelSessionStore;
private final ObjectMapper objectMapper;
/** 运行中的渠道适配器channelId -> adapter */
private final Map<Long, ChannelAdapter> activeAdapters = new HashMap<>();
/** 读写锁读操作getAdapter 等用读锁写操作start/stop/replace用写锁 */
private final ReadWriteLock adapterLock = new ReentrantReadWriteLock();
/** 旧 Adapter stop() 的超时线程池 */
private final ExecutorService stopExecutor = Executors.newCachedThreadPool(r -> {
Thread t = new Thread(r, "channel-stop");
t.setDaemon(true);
return t;
});
/** 旧 Adapter stop() 超时时间(秒) */
private static final int STOP_TIMEOUT_SECONDS = 5;
/** 支持的渠道类型 */
private static final Set<String> SUPPORTED_TYPES = Set.of(
"web", "dingtalk", "feishu", "telegram", "discord", "wecom", "qq", "weixin"
);
/**
* 应用启动完成后自动加载并启动所有已启用的渠道
* 使用 ApplicationReadyEvent 确保数据库 schema/data 初始化完成
*/
@EventListener(ApplicationReadyEvent.class)
public void init() {
log.info("Initializing ChannelManager...");
List<ChannelEntity> channels = channelService.listEnabledChannels();
int started = 0;
for (ChannelEntity channel : channels) {
try {
startChannel(channel);
started++;
} catch (Exception e) {
log.error("Failed to start channel {}: {}", channel.getName(), e.getMessage());
}
}
log.info("ChannelManager initialized: {}/{} channels started", started, channels.size());
}
/**
* 应用关闭时停止所有渠道
*/
@PreDestroy
public void destroy() {
log.info("Shutting down ChannelManager, stopping {} active channels...", activeAdapters.size());
stopAll();
stopExecutor.shutdownNow();
messageRouter.shutdown();
}
// ==================== 渠道生命周期管理 ====================
/**
* 启动指定渠道
*/
public void startChannel(ChannelEntity channel) {
adapterLock.writeLock().lock();
try {
if (activeAdapters.containsKey(channel.getId())) {
log.info("Channel {} already running, skipping", channel.getName());
return;
}
ChannelAdapter adapter = createAdapter(channel);
adapter.start();
activeAdapters.put(channel.getId(), adapter);
log.info("Channel started: {} (type={}, id={})", channel.getName(), channel.getChannelType(), channel.getId());
} finally {
adapterLock.writeLock().unlock();
}
}
/**
* 停止指定渠道
*/
public void stopChannel(Long channelId) {
ChannelAdapter oldAdapter;
adapterLock.writeLock().lock();
try {
oldAdapter = activeAdapters.remove(channelId);
} finally {
adapterLock.writeLock().unlock();
}
if (oldAdapter != null) {
stopAdapterSafely(oldAdapter, "stopChannel");
}
}
/**
* 热替换渠道配置变更后调用
* <p>
* 热替换流程
* 1. 用新配置创建并启动新 Adapter在锁外完成避免长时间持锁
* 2. Adapter 就绪后加写锁替换 activeAdapters 中的引用
* 3. 释放锁后异步停止旧 Adapter给定超时
* 4. 如果新 Adapter start() 失败保留旧的不变
*
* @param channelId 渠道ID
*/
public void restartChannel(Long channelId) {
ChannelEntity channel = channelService.getChannel(channelId);
if (!Boolean.TRUE.equals(channel.getEnabled())) {
// 渠道已禁用直接停止旧的
log.info("[hot-swap] Channel {} is disabled, stopping old adapter", channel.getName());
stopChannel(channelId);
return;
}
log.info("[hot-swap] Starting hot-swap for channel: {} (type={}, id={})",
channel.getName(), channel.getChannelType(), channelId);
// Step 1: 在锁外创建并启动新 Adapter
ChannelAdapter newAdapter = createAdapter(channel);
try {
log.info("[hot-swap] Starting new adapter for channel: {}", channel.getName());
newAdapter.start();
log.info("[hot-swap] New adapter started successfully: {}", channel.getName());
} catch (Exception e) {
// Adapter 启动失败保留旧的不变
log.error("[hot-swap] New adapter failed to start for channel {}, keeping old adapter: {}",
channel.getName(), e.getMessage(), e);
return;
}
// Step 2: 加写锁原子替换
ChannelAdapter oldAdapter;
adapterLock.writeLock().lock();
try {
oldAdapter = activeAdapters.put(channelId, newAdapter);
log.info("[hot-swap] Adapter reference swapped for channel: {} (old={})",
channel.getName(), oldAdapter != null ? "present" : "none");
} finally {
adapterLock.writeLock().unlock();
}
// Step 3: 锁外异步停止旧 Adapter
if (oldAdapter != null) {
log.info("[hot-swap] Stopping old adapter for channel: {}", channel.getName());
stopAdapterAsync(oldAdapter, channel.getName());
}
log.info("[hot-swap] Hot-swap completed for channel: {} (type={}, id={})",
channel.getName(), channel.getChannelType(), channelId);
}
/**
* 停止所有渠道
*/
public void stopAll() {
List<ChannelAdapter> adaptersToStop;
adapterLock.writeLock().lock();
try {
adaptersToStop = new ArrayList<>(activeAdapters.values());
activeAdapters.clear();
} finally {
adapterLock.writeLock().unlock();
}
for (ChannelAdapter adapter : adaptersToStop) {
stopAdapterSafely(adapter, "stopAll");
}
}
// ==================== 查询读锁保护 ====================
/**
* 获取指定渠道的适配器
*/
public Optional<ChannelAdapter> getAdapter(Long channelId) {
adapterLock.readLock().lock();
try {
return Optional.ofNullable(activeAdapters.get(channelId));
} finally {
adapterLock.readLock().unlock();
}
}
/**
* 按渠道类型获取适配器返回第一个匹配的
*/
public Optional<ChannelAdapter> getAdapterByType(String channelType) {
adapterLock.readLock().lock();
try {
return activeAdapters.values().stream()
.filter(a -> a.getChannelType().equals(channelType))
.findFirst();
} finally {
adapterLock.readLock().unlock();
}
}
/**
* 获取所有运行中的渠道适配器
*/
public Collection<ChannelAdapter> getActiveAdapters() {
adapterLock.readLock().lock();
try {
return List.copyOf(activeAdapters.values());
} finally {
adapterLock.readLock().unlock();
}
}
/**
* 获取渠道运行状态摘要含连接状态和最后错误信息
*/
public Map<String, Object> getStatus() {
adapterLock.readLock().lock();
try {
Map<String, Object> status = new LinkedHashMap<>();
status.put("activeCount", activeAdapters.size());
status.put("supportedTypes", SUPPORTED_TYPES);
List<Map<String, Object>> channels = new ArrayList<>();
activeAdapters.forEach((id, adapter) -> {
Map<String, Object> info = new LinkedHashMap<>();
info.put("id", id);
info.put("type", adapter.getChannelType());
info.put("name", adapter.getDisplayName());
info.put("running", adapter.isRunning());
// 连接状态和错误信息
if (adapter instanceof AbstractChannelAdapter aca) {
info.put("connectionState", aca.getConnectionState().get().name());
info.put("lastError", aca.getLastError());
info.put("reconnectAttempts", aca.backoff.getAttempts());
} else {
info.put("connectionState", adapter.isRunning() ? "CONNECTED" : "DISCONNECTED");
info.put("lastError", null);
info.put("reconnectAttempts", 0);
}
channels.add(info);
});
status.put("channels", channels);
return status;
} finally {
adapterLock.readLock().unlock();
}
}
/**
* 判断是否支持该渠道类型
*/
public boolean isSupported(String channelType) {
return SUPPORTED_TYPES.contains(channelType);
}
// ==================== 主动推送 ====================
/**
* 通过指定渠道主动推送消息
* <p>
* CronJob 等模块调用实现定时消息推送
*
* @param channelId 渠道配置ID
* @param targetId 目标标识用户ID / 群组ID / 频道ID / sessionWebhook
* @param content 消息内容
* @throws IllegalStateException 渠道未启动或不支持主动推送
*/
public void sendToChannel(Long channelId, String targetId, String content) {
ChannelAdapter adapter = getAdapter(channelId)
.orElseThrow(() -> new IllegalStateException("Channel not active: " + channelId));
if (!adapter.supportsProactiveSend()) {
throw new UnsupportedOperationException(
"Channel " + adapter.getDisplayName() + " (" + adapter.getChannelType() + ") does not support proactive send");
}
adapter.proactiveSend(targetId, content);
log.info("Proactive message sent via channel {} to {}: {}chars",
adapter.getDisplayName(), targetId, content.length());
}
/**
* 通过 conversationId 主动推送消息自动查找渠道和目标
* <p>
* ChannelSessionStore 中查找 conversationId 对应的渠道和推送目标
*
* @param conversationId 会话ID dingtalk:xxx
* @param content 消息内容
* @throws IllegalStateException 找不到会话或渠道未启动
*/
public void sendToConversation(String conversationId, String content) {
var session = channelSessionStore.getSession(conversationId);
if (session == null) {
throw new IllegalStateException("No channel session found for conversation: " + conversationId);
}
sendToChannel(session.getChannelId(), session.getTargetId(), content);
}
// ==================== 内部方法 ====================
/**
* 安全停止 Adapter捕获异常不影响调用方
*/
private void stopAdapterSafely(ChannelAdapter adapter, String context) {
try {
adapter.stop();
log.info("[{}] Adapter stopped: {} (type={})", context, adapter.getDisplayName(), adapter.getChannelType());
} catch (Exception e) {
log.error("[{}] Error stopping adapter {} (type={}): {}",
context, adapter.getDisplayName(), adapter.getChannelType(), e.getMessage(), e);
}
}
/**
* 异步停止旧 Adapter带超时保护
* <p>
* Adapter stop() 异常不影响新 Adapter 运行
*/
private void stopAdapterAsync(ChannelAdapter oldAdapter, String channelName) {
Future<?> future = stopExecutor.submit(() -> {
try {
oldAdapter.stop();
log.info("[hot-swap] Old adapter stopped: {}", channelName);
} catch (Exception e) {
log.error("[hot-swap] Error stopping old adapter {}: {}", channelName, e.getMessage(), e);
}
});
// 超时监控也在后台执行不阻塞调用方
stopExecutor.submit(() -> {
try {
future.get(STOP_TIMEOUT_SECONDS, TimeUnit.SECONDS);
} catch (TimeoutException e) {
log.warn("[hot-swap] Old adapter stop timed out after {}s: {}, cancelling",
STOP_TIMEOUT_SECONDS, channelName);
future.cancel(true);
} catch (Exception e) {
log.error("[hot-swap] Unexpected error waiting for old adapter stop: {}", e.getMessage());
}
});
}
// ==================== 工厂方法 ====================
/**
* 根据渠道实体创建对应的适配器实例
* 采用渠道注册表模式根据类型创建对应适配器
*/
private ChannelAdapter createAdapter(ChannelEntity channel) {
String type = channel.getChannelType();
return switch (type) {
case "web" -> new WebChannelAdapter(channel, messageRouter, objectMapper);
case "dingtalk" -> new DingTalkChannelAdapter(channel, messageRouter, objectMapper);
case "feishu" -> new FeishuChannelAdapter(channel, messageRouter, objectMapper);
case "telegram" -> new TelegramChannelAdapter(channel, messageRouter, objectMapper);
case "discord" -> new DiscordChannelAdapter(channel, messageRouter, objectMapper);
case "wecom" -> new WeComChannelAdapter(channel, messageRouter, objectMapper);
case "qq" -> new QQChannelAdapter(channel, messageRouter, objectMapper);
case "weixin" -> new WeixinChannelAdapter(channel, messageRouter, objectMapper);
default -> throw new IllegalArgumentException("Unsupported channel type: " + type);
};
}
}

View File

@ -0,0 +1,71 @@
package vip.mate.channel;
import lombok.Builder;
import lombok.Data;
import vip.mate.workspace.conversation.model.MessageContentPart;
import java.time.LocalDateTime;
import java.util.List;
/**
* 渠道消息模型
* <p>
* 统一封装来自不同渠道的消息采用渠道地址 + 原生 payload 设计
* 所有渠道的入站消息先转为此格式再由 ChannelMessageRouter 路由到 Agent
* <p>
* 实际消息内容以 contentParts 为准content 字段保留纯文本摘要用于向后兼容
*
* @author MateClaw Team
*/
@Data
@Builder
public class ChannelMessage {
/** 消息ID渠道原始ID */
private String messageId;
/** 渠道类型 */
private String channelType;
/** 发送者ID */
private String senderId;
/** 发送者名称 */
private String senderName;
/** 会话/群组ID私聊时为 null */
private String chatId;
/** 纯文本摘要(向后兼容) */
private String content;
/** 消息类型text / image / file */
private String contentType;
/**
* 结构化消息内容多模态
* 各渠道 Adapter 在解析原生消息时构建此列表
* Router 据此传给 AgentService使 Agent 能看到完整的多模态输入
*/
@Builder.Default
private List<MessageContentPart> contentParts = List.of();
/** 消息时间 */
private LocalDateTime timestamp;
/**
* 回复 Token
* <p>
* 不同渠道含义不同
* - 钉钉sessionWebhook URL
* - 飞书chat_id
* - Telegramchat_id
* - Discordchannel_id
* <p>
* 用于 sendMessage 回复时确定目标
*/
private String replyToken;
/** 原始 payload用于调试 */
private Object rawPayload;
}

View File

@ -0,0 +1,247 @@
package vip.mate.channel;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
/**
* 渠道消息渲染器
* <p>
* 渠道消息渲染设计
* - 过滤 thinking 标签和工具调用信息
* - 按平台字数限制分割长消息
* - 保持代码块完整性不在 ``` 中间切割
*
* @author MateClaw Team
*/
public final class ChannelMessageRenderer {
private ChannelMessageRenderer() {}
/** 各平台消息字数限制 */
public static final Map<String, Integer> PLATFORM_LIMITS = Map.of(
"telegram", 4096,
"discord", 2000,
"dingtalk", 20000,
"feishu", 10000,
"wecom", 2048,
"qq", 4096,
"weixin", 4096
);
/** 匹配 <think>...</think> 标签(含嵌套内容) */
private static final Pattern THINK_PATTERN = Pattern.compile(
"<think>[\\s\\S]*?</think>", Pattern.CASE_INSENSITIVE);
/** 匹配 <tool_call>...</tool_call> */
private static final Pattern TOOL_CALL_PATTERN = Pattern.compile(
"<tool_call>[\\s\\S]*?</tool_call>", Pattern.CASE_INSENSITIVE);
/** 匹配 <tool_result>...</tool_result> */
private static final Pattern TOOL_RESULT_PATTERN = Pattern.compile(
"<tool_result>[\\s\\S]*?</tool_result>", Pattern.CASE_INSENSITIVE);
/** 匹配 ReAct 格式的中间步骤行Action: / Action Input: / Observation: */
private static final Pattern REACT_STEP_PATTERN = Pattern.compile(
"(?m)^(Action|Action Input|Observation):.*$");
/** 代码块围栏标记 */
private static final String CODE_FENCE = "```";
/** 代码块围栏最大额外开销:开启 "```lang\n" + 关闭 "\n```" ≈ 最长语言标识20字符 + 固定8字符 */
private static final int CODE_FENCE_OVERHEAD = 30;
/** 分割时的安全余量(为代码块关闭/开启标记留空间) */
private static final int SAFETY_MARGIN = 100 + CODE_FENCE_OVERHEAD;
// ==================== 核心 API ====================
/**
* 综合渲染过滤 + 分割
*
* @param content 原始内容
* @param filterThinking 是否过滤 thinking 标签
* @param filterToolMessages 是否过滤工具调用信息
* @param messageFormat 消息格式暂留扩展当前不做转换
* @param maxLength 平台字数限制
* @return 分割后的消息段列表
*/
public static List<String> renderForChannel(String content,
boolean filterThinking,
boolean filterToolMessages,
String messageFormat,
int maxLength) {
if (content == null || content.isBlank()) {
return List.of("");
}
String rendered = content;
// 1. 过滤 thinking
if (filterThinking) {
rendered = stripThinking(rendered);
}
// 2. 过滤工具调用
if (filterToolMessages) {
rendered = stripToolCalls(rendered);
}
// 3. 清理多余空行
rendered = rendered.replaceAll("\n{3,}", "\n\n").trim();
if (rendered.isEmpty()) {
return List.of("");
}
// 4. 按平台限制分割
return truncateForPlatform(rendered, maxLength);
}
// ==================== 过滤方法 ====================
/**
* 移除 &lt;think&gt;...&lt;/think&gt; 标签及其内容
*/
public static String stripThinking(String content) {
if (content == null) return "";
return THINK_PATTERN.matcher(content).replaceAll("").trim();
}
/**
* 移除工具调用信息
* - &lt;tool_call&gt;...&lt;/tool_call&gt;
* - &lt;tool_result&gt;...&lt;/tool_result&gt;
* - Action: / Action Input: / Observation: ReAct 格式
*/
public static String stripToolCalls(String content) {
if (content == null) return "";
String result = content;
result = TOOL_CALL_PATTERN.matcher(result).replaceAll("");
result = TOOL_RESULT_PATTERN.matcher(result).replaceAll("");
result = REACT_STEP_PATTERN.matcher(result).replaceAll("");
return result.trim();
}
// ==================== 分割方法 ====================
/**
* 按平台字数限制分割消息保持代码块完整性
*
* @param content 内容
* @param maxLength 最大长度
* @return 分割后的消息段列表
*/
public static List<String> truncateForPlatform(String content, int maxLength) {
if (content == null || content.isEmpty()) {
return List.of("");
}
if (content.length() <= maxLength) {
return List.of(content);
}
List<String> segments = new ArrayList<>();
int effectiveMax = maxLength - SAFETY_MARGIN;
if (effectiveMax <= 0) {
effectiveMax = maxLength;
}
int pos = 0;
boolean inCodeBlock = false;
String codeBlockLang = ""; // 记录代码块语言标识
while (pos < content.length()) {
int remaining = content.length() - pos;
if (remaining <= maxLength) {
// 剩余内容不超限直接作为最后一段
String lastSegment = content.substring(pos);
if (inCodeBlock) {
lastSegment = CODE_FENCE + codeBlockLang + "\n" + lastSegment;
}
segments.add(lastSegment);
break;
}
// effectiveMax 范围内寻找最佳切割点
int cutPoint = findCutPoint(content, pos, effectiveMax);
String chunk = content.substring(pos, cutPoint);
// 如果上一段结束时在代码块内本段开头需要重新打开
if (inCodeBlock) {
chunk = CODE_FENCE + codeBlockLang + "\n" + chunk;
}
// 统计本段中的代码块围栏数量更新状态
CodeBlockState state = analyzeCodeFences(chunk, inCodeBlock, codeBlockLang);
inCodeBlock = state.inCodeBlock;
codeBlockLang = state.lang;
// 如果本段结束时仍在代码块内需要关闭
if (inCodeBlock) {
chunk = chunk + "\n" + CODE_FENCE;
}
segments.add(chunk);
pos = cutPoint;
}
return segments;
}
// ==================== 内部辅助 ====================
/**
* [start, start + maxLen] 范围内寻找最佳切割点
* 优先在换行符处切割如果找不到硬切
*/
private static int findCutPoint(String content, int start, int maxLen) {
int end = Math.min(start + maxLen, content.length());
// end 往前找最近的换行符
for (int i = end - 1; i > start + maxLen / 2; i--) {
if (content.charAt(i) == '\n') {
return i + 1; // 包含换行符
}
}
// 找不到合适的换行符硬切
return end;
}
/**
* 分析文本中的代码块围栏返回结束时的状态
*/
private static CodeBlockState analyzeCodeFences(String text, boolean initiallyInBlock, String initialLang) {
boolean inBlock = initiallyInBlock;
String lang = initialLang;
int idx = 0;
while (idx < text.length()) {
int fencePos = text.indexOf(CODE_FENCE, idx);
if (fencePos == -1) break;
if (!inBlock) {
// 进入代码块尝试提取语言标识
int lineEnd = text.indexOf('\n', fencePos);
if (lineEnd == -1) lineEnd = text.length();
lang = text.substring(fencePos + CODE_FENCE.length(), lineEnd).trim();
if (!lang.isEmpty() && !lang.matches("[a-zA-Z0-9+#_.-]+")) {
lang = ""; // 无效的语言标识
}
inBlock = true;
} else {
// 退出代码块
inBlock = false;
lang = "";
}
idx = fencePos + CODE_FENCE.length();
}
return new CodeBlockState(inBlock, lang);
}
private record CodeBlockState(boolean inCodeBlock, String lang) {}
}

View File

@ -0,0 +1,684 @@
package vip.mate.channel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux;
import vip.mate.agent.AgentService;
import vip.mate.approval.ApprovalService;
import vip.mate.approval.PendingApproval;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.channel.notification.ApprovalNotificationService;
import vip.mate.channel.service.ChannelService;
import org.springframework.context.ApplicationEventPublisher;
import vip.mate.memory.event.ConversationCompletedEvent;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.model.MessageContentPart;
import java.util.List;
import java.util.Map;
import java.util.concurrent.*;
import java.util.concurrent.locks.ReentrantLock;
/**
* 渠道消息路由器
* <p>
* 采用每渠道独立队列架构
* - 每渠道一个 BlockingQueueN 个消费线程从队列取消息处理
* - 会话级锁保证同一 conversationId 串行处理
* - 500ms 防抖同一会话的连续消息合并为一条
* - Web 渠道不走队列有自己的 SSE 流程
*
* @author MateClaw Team
*/
@Slf4j
@Component
public class ChannelMessageRouter {
private final AgentService agentService;
private final ConversationService conversationService;
private final ChannelService channelService;
private final ChannelSessionStore channelSessionStore;
private final ApprovalService approvalService;
private final ApprovalNotificationService approvalNotificationService;
private final ApplicationEventPublisher eventPublisher;
/** 队列条目:封装消息及其路由上下文 */
private record QueueEntry(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {}
/** 每个渠道类型的消息队列 */
private final ConcurrentHashMap<String, LinkedBlockingQueue<QueueEntry>> channelQueues = new ConcurrentHashMap<>();
/** 每个渠道类型的消费线程池 */
private final ConcurrentHashMap<String, ExecutorService> channelExecutors = new ConcurrentHashMap<>();
/** 会话级别的锁:保证同一 conversationId 串行处理 */
private final ConcurrentHashMap<String, ReentrantLock> sessionLocks = new ConcurrentHashMap<>();
/** 防抖调度器 */
private final ScheduledExecutorService debounceScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "channel-debounce-scheduler");
t.setDaemon(true);
return t;
});
/** 防抖缓冲区conversationId -> 待合并消息 */
private final ConcurrentHashMap<String, PendingMessage> pendingMessages = new ConcurrentHashMap<>();
/** 每个渠道的消费线程数 */
private static final int CONSUMERS_PER_CHANNEL = 4;
/** 每个渠道的队列容量 */
private static final int QUEUE_CAPACITY = 1000;
/** 防抖等待时间(毫秒) */
private static final long DEBOUNCE_MS = 500;
/** 是否已关闭 */
private volatile boolean shutdown = false;
public ChannelMessageRouter(AgentService agentService,
ConversationService conversationService,
ChannelService channelService,
ChannelSessionStore channelSessionStore,
ApprovalService approvalService,
ApprovalNotificationService approvalNotificationService,
ApplicationEventPublisher eventPublisher) {
this.agentService = agentService;
this.conversationService = conversationService;
this.channelService = channelService;
this.channelSessionStore = channelSessionStore;
this.approvalService = approvalService;
this.approvalNotificationService = approvalNotificationService;
this.eventPublisher = eventPublisher;
}
// ==================== 防抖辅助类 ====================
/**
* 防抖待合并消息
*/
private static class PendingMessage {
final ChannelAdapter adapter;
final ChannelEntity channelEntity;
final ChannelMessage firstMessage;
final StringBuilder mergedContent;
volatile ScheduledFuture<?> timer;
PendingMessage(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {
this.firstMessage = message;
this.adapter = adapter;
this.channelEntity = channelEntity;
this.mergedContent = new StringBuilder(message.getContent() != null ? message.getContent() : "");
}
synchronized void appendContent(String content) {
if (content != null && !content.isBlank()) {
if (!mergedContent.isEmpty()) {
mergedContent.append('\n');
}
mergedContent.append(content);
}
}
synchronized String getMergedContent() {
return mergedContent.toString();
}
}
// ==================== 入队替代原 route 方法 ====================
/**
* 将渠道消息入队到对应渠道的处理队列防抖后入队
* <p>
* Webhook 调用此方法后立即返回不阻塞
*
* @param message 入站消息
* @param adapter 来源渠道适配器用于回复
* @param channelEntity 渠道配置含关联 agentId
*/
public void enqueue(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {
Long agentId = channelEntity.getAgentId();
if (agentId == null) {
log.warn("Channel {} has no associated agent, ignoring message from {}",
channelEntity.getName(), message.getSenderId());
return;
}
if (shutdown) {
log.warn("Router is shutting down, rejecting message from {}", message.getSenderId());
return;
}
String channelType = adapter.getChannelType();
String conversationId = buildConversationId(message);
log.info("[{}] Enqueuing message: sender={}, conversationId={}, agentId={}",
channelType, message.getSenderId(), conversationId, agentId);
// 防抖同一会话 500ms 内的连续消息合并
synchronized (pendingMessages) {
PendingMessage existing = pendingMessages.get(conversationId);
if (existing != null) {
// 合并到已有的 pending 消息
if (existing.timer != null) {
existing.timer.cancel(false);
}
existing.appendContent(message.getContent());
existing.timer = debounceScheduler.schedule(
() -> flushPending(conversationId), DEBOUNCE_MS, TimeUnit.MILLISECONDS);
log.debug("[{}] Message merged with pending (debounce): conversationId={}",
channelType, conversationId);
return;
}
// 首条消息创建 PendingMessage 并设定防抖定时器
PendingMessage pending = new PendingMessage(message, adapter, channelEntity);
pendingMessages.put(conversationId, pending);
pending.timer = debounceScheduler.schedule(
() -> flushPending(conversationId), DEBOUNCE_MS, TimeUnit.MILLISECONDS);
}
}
/**
* 防抖到期将合并后的消息真正放入渠道队列
*/
private void flushPending(String conversationId) {
PendingMessage pending;
synchronized (pendingMessages) {
pending = pendingMessages.remove(conversationId);
}
if (pending == null) return;
// 更新消息内容为合并后的文本
pending.firstMessage.setContent(pending.getMergedContent());
String channelType = pending.adapter.getChannelType();
LinkedBlockingQueue<QueueEntry> queue = channelQueues.computeIfAbsent(channelType, this::createChannelQueue);
boolean offered = queue.offer(new QueueEntry(pending.firstMessage, pending.adapter, pending.channelEntity));
if (!offered) {
log.error("[{}] Message queue full (capacity={}), dropping message from {}",
channelType, QUEUE_CAPACITY, pending.firstMessage.getSenderId());
try {
String replyTarget = resolveReplyTarget(pending.firstMessage);
pending.adapter.sendMessage(replyTarget, "系统繁忙,请稍后再试");
} catch (Exception e) {
log.error("[{}] Failed to send busy message: {}", channelType, e.getMessage());
}
} else {
log.debug("[{}] Message flushed to queue: conversationId={}, queueSize={}",
channelType, conversationId, queue.size());
}
}
// ==================== 消费线程 ====================
/**
* 为渠道类型创建队列并启动消费线程
*/
private LinkedBlockingQueue<QueueEntry> createChannelQueue(String channelType) {
LinkedBlockingQueue<QueueEntry> queue = new LinkedBlockingQueue<>(QUEUE_CAPACITY);
ExecutorService executor = Executors.newFixedThreadPool(CONSUMERS_PER_CHANNEL, new ThreadFactory() {
private int counter = 0;
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "channel-consumer-" + channelType + "-" + (counter++));
t.setDaemon(true);
return t;
}
});
for (int i = 0; i < CONSUMERS_PER_CHANNEL; i++) {
executor.execute(() -> consumeLoop(channelType, queue));
}
channelExecutors.put(channelType, executor);
log.info("[{}] Created message queue (capacity={}) with {} consumer threads",
channelType, QUEUE_CAPACITY, CONSUMERS_PER_CHANNEL);
return queue;
}
/**
* 消费线程循环从队列取消息加会话锁后串行处理
*/
private void consumeLoop(String channelType, LinkedBlockingQueue<QueueEntry> queue) {
log.info("[{}] Consumer thread started: {}", channelType, Thread.currentThread().getName());
while (!shutdown) {
try {
QueueEntry entry = queue.poll(1, TimeUnit.SECONDS);
if (entry == null) {
continue; // 超时重新检查 shutdown 标志
}
String conversationId = buildConversationId(entry.message());
ReentrantLock lock = sessionLocks.computeIfAbsent(conversationId, k -> new ReentrantLock());
lock.lock();
try {
processMessage(entry.message(), entry.adapter(), entry.channelEntity(), conversationId);
} finally {
lock.unlock();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
} catch (Exception e) {
log.error("[{}] Unexpected error in consumer loop: {}", channelType, e.getMessage(), e);
}
}
log.info("[{}] Consumer thread stopped: {}", channelType, Thread.currentThread().getName());
}
// ==================== 审批命令识别 ====================
private static final java.util.Set<String> APPROVE_COMMANDS = java.util.Set.of(
"approve", "/approve", "批准", "/批准");
private static final java.util.Set<String> DENY_COMMANDS = java.util.Set.of(
"deny", "/deny", "拒绝", "/拒绝");
/** 带 pendingId 的审批命令格式:/approve a1b2c3 */
private static final java.util.regex.Pattern APPROVE_WITH_ID =
java.util.regex.Pattern.compile("^/?(approve|批准)\\s+([a-f0-9]{6,16})$",
java.util.regex.Pattern.CASE_INSENSITIVE);
private static final java.util.regex.Pattern DENY_WITH_ID =
java.util.regex.Pattern.compile("^/?(deny|拒绝)\\s+([a-f0-9]{6,16})$",
java.util.regex.Pattern.CASE_INSENSITIVE);
private boolean isApproveCommand(String text) {
String t = text.toLowerCase().strip();
return APPROVE_COMMANDS.contains(t) || APPROVE_WITH_ID.matcher(t).matches();
}
private boolean isDenyCommand(String text) {
String t = text.toLowerCase().strip();
return DENY_COMMANDS.contains(t) || DENY_WITH_ID.matcher(t).matches();
}
/**
* 从审批命令中提取 shortId "/approve a1b2c3" "a1b2c3" id 则返回 null
*/
private String extractShortId(String text) {
java.util.regex.Matcher m = APPROVE_WITH_ID.matcher(text.strip());
if (m.matches()) return m.group(2);
m = DENY_WITH_ID.matcher(text.strip());
if (m.matches()) return m.group(2);
return null;
}
// ==================== 消息处理 route 逻辑 + 审批拦截层 ====================
/**
* 处理单条消息保存 -> 调用 Agent -> 保存回复 -> 发送回复
* <p>
* 当钉钉渠道启用 AI Card 走流式卡片路径
*/
private void processMessage(ChannelMessage message, ChannelAdapter adapter,
ChannelEntity channelEntity, String conversationId) {
Long agentId = channelEntity.getAgentId();
log.info("[{}] Processing message: sender={}, conversationId={}, agentId={}",
adapter.getChannelType(), message.getSenderId(), conversationId, agentId);
try {
// ======= 审批拦截层 =======
String userText = message.getContent() != null ? message.getContent().trim() : "";
PendingApproval pending = approvalService.findPendingByConversation(conversationId);
if (pending != null) {
String replyTarget = resolveReplyTarget(message);
if (isApproveCommand(userText)) {
// pendingId 校验如果命令包含 shortId验证是否匹配当前 pending
String shortId = extractShortId(userText);
if (shortId != null && !pending.getPendingId().startsWith(shortId)) {
adapter.sendMessage(replyTarget, "⚠️ 审批ID不匹配。当前待审批: "
+ pending.getPendingId().substring(0, Math.min(6, pending.getPendingId().length())));
return;
}
// 身份校验只有原始请求者可以审批群聊安全
String originalRequester = pending.getUserId();
if (originalRequester != null && !"system".equals(originalRequester)
&& !originalRequester.equals(message.getSenderId())) {
adapter.sendMessage(replyTarget, "⚠️ 只有原始请求者可以审批此操作。");
log.warn("[{}] Approval rejected: sender={} != requester={}",
adapter.getChannelType(), message.getSenderId(), originalRequester);
return;
}
// 批准原子解决+消费审批记录消除 resolve/consume race condition
PendingApproval consumed = approvalService.resolveAndConsume(
pending.getPendingId(), message.getSenderId());
if (consumed == null) {
adapter.sendMessage(replyTarget, "⚠️ 审批记录已过期或已被处理。");
return;
}
log.info("[{}] Approval APPROVED via IM command: pendingId={}, tool={}",
adapter.getChannelType(), consumed.getPendingId(), consumed.getToolName());
replayApprovedToolCall(consumed, conversationId, adapter, message, channelEntity);
return;
} else if (isDenyCommand(userText)) {
// 拒绝 + 清理 DB 残留审批占位消息
approvalService.resolve(pending.getPendingId(), message.getSenderId(), "denied");
conversationService.removeApprovalPlaceholders(conversationId);
adapter.sendMessage(replyTarget, "⛔ 已拒绝执行工具: " + pending.getToolName());
log.info("[{}] Approval DENIED via IM command: pendingId={}, tool={}",
adapter.getChannelType(), pending.getPendingId(), pending.getToolName());
return;
} else {
// 非审批命令但有 pending 视为隐式拒绝 + 清理残留
approvalService.resolve(pending.getPendingId(), message.getSenderId(), "denied");
conversationService.removeApprovalPlaceholders(conversationId);
adapter.sendMessage(replyTarget, "⛔ 审批已取消。将继续处理您的新消息。");
log.info("[{}] Approval auto-cancelled (non-approval message): pendingId={}",
adapter.getChannelType(), pending.getPendingId());
// 继续正常流程处理当前消息
}
}
// ======= 审批拦截层结束 =======
// 确保会话存在
conversationService.getOrCreateSharedConversation(conversationId, agentId);
// 更新渠道会话存储用于主动推送
String replyTarget = resolveReplyTarget(message);
if (replyTarget != null) {
channelSessionStore.saveOrUpdate(
conversationId,
adapter.getChannelType(),
replyTarget,
message.getSenderId(),
message.getSenderName(),
channelEntity.getId()
);
} else {
log.warn("[{}] No reply target resolved for sender={}, skipping session store update",
adapter.getChannelType(), message.getSenderId());
}
// 保存用户消息 contentParts
List<MessageContentPart> parts = message.getContentParts();
conversationService.saveMessage(conversationId, "user", message.getContent(), parts);
// 构建 prompt
String promptText = buildPromptFromParts(message.getContent(), parts);
// 流式路径渠道实现了 StreamingChannelAdapter 则委托渠道渲染流式事件
if (adapter instanceof StreamingChannelAdapter streamingAdapter) {
processWithStreaming(message, streamingAdapter, conversationId, agentId, promptText);
} else {
// 同步路径直接获取完整回复
String reply = agentService.chat(agentId, promptText, conversationId);
// 检查 chat 过程中是否产生了审批 pending
PendingApproval newPending = approvalService.findPendingByConversation(conversationId);
if (newPending != null) {
// 有审批需求不保存 LLM 的审批占位回复到 DB直接从 pending 元数据构建通知
String approvalNotice = buildApprovalNotice(newPending);
adapter.renderAndSend(replyTarget, approvalNotice);
log.info("[{}] Approval triggered during chat, sent notice (NOT saved to DB): tool={}",
adapter.getChannelType(), newPending.getToolName());
} else {
// 正常回复保存并发送
conversationService.saveMessage(conversationId, "assistant", reply);
publishConversationCompletedEvent(agentId, conversationId, message.getContent(), reply);
adapter.renderAndSend(replyTarget, reply);
log.info("[{}] Reply sent to {}: {}chars",
adapter.getChannelType(), replyTarget, reply.length());
}
}
} catch (Exception e) {
log.error("[{}] Failed to process message from {}: {}",
adapter.getChannelType(), message.getSenderId(), e.getMessage(), e);
// 尝试发送错误提示
try {
String errorTarget = resolveReplyTarget(message);
adapter.sendMessage(errorTarget, "抱歉,处理消息时出现错误:" + e.getMessage());
} catch (Exception sendErr) {
log.error("[{}] Failed to send error message: {}",
adapter.getChannelType(), sendErr.getMessage());
}
}
}
/**
* 流式处理路径渠道无关
* <p>
* 事件流与渲染分离
* - Router 负责产生 StreamDelta 调用 AgentService
* - StreamingChannelAdapter 负责渲染AI Card / 卡片更新 / 文本累积等
* - Router 负责后续的审批检查消息持久化事件发布
*/
private void processWithStreaming(ChannelMessage message, StreamingChannelAdapter streamingAdapter,
String conversationId, Long agentId, String promptText) {
String channelType = streamingAdapter.getChannelType();
log.info("[{}] Streaming processing started: conversationId={}", channelType, conversationId);
try {
// Step 1: 产生事件流
Flux<AgentService.StreamDelta> stream = agentService.chatStructuredStream(
agentId, promptText, conversationId, message.getSenderId());
// Step 2: 委托渠道渲染渠道内部消费 Flux 并处理 UI 更新
String finalContent = streamingAdapter.processStream(stream, message, conversationId);
// Step 3: 审批检查 + 持久化渠道无关逻辑 Router 统一处理
PendingApproval newPending = approvalService.findPendingByConversation(conversationId);
if (newPending != null) {
String replyTarget = resolveReplyTarget(message);
streamingAdapter.sendMessage(replyTarget, buildApprovalNotice(newPending));
log.info("[{}] Approval triggered during streaming (NOT saved to DB): tool={}",
channelType, newPending.getToolName());
} else if (finalContent != null && !finalContent.isBlank()) {
conversationService.saveMessage(conversationId, "assistant", finalContent);
publishConversationCompletedEvent(agentId, conversationId, promptText, finalContent);
log.info("[{}] Streaming completed: contentLen={}", channelType, finalContent.length());
}
} catch (Exception e) {
log.error("[{}] Streaming processing failed: {}", channelType, e.getMessage(), e);
// 尝试发送错误提示
try {
String errorTarget = resolveReplyTarget(message);
streamingAdapter.sendMessage(errorTarget, "抱歉,流式处理失败:" + e.getMessage());
} catch (Exception sendErr) {
log.error("[{}] Failed to send streaming error message: {}", channelType, sendErr.getMessage());
}
}
}
// ==================== 审批重放 ====================
/**
* 重放被审批阻塞的工具调用
* <p>
* 接收已消费的审批记录 resolveAndConsume 原子获取通过 AgentService.chatWithReplay 重新执行工具
* 重放前清理 DB 中的审批占位消息防止 LLM 看到残留文本后重新发起工具调用死循环根因
*/
private void replayApprovedToolCall(PendingApproval consumed, String conversationId,
ChannelAdapter adapter, ChannelMessage triggerMessage,
ChannelEntity channelEntity) {
String replyTarget = resolveReplyTarget(triggerMessage);
Long agentId = channelEntity.getAgentId();
// 通知用户审批已通过
adapter.sendMessage(replyTarget, "✅ 已批准执行工具: " + consumed.getToolName());
// 清理 DB 中残留的审批占位消息
conversationService.removeApprovalPlaceholders(conversationId);
// 简化 replay prompt不重复工具名防止 LLM 误解
String replayPrompt = "继续执行已批准的工具调用。";
try {
String reply = agentService.chatWithReplay(
agentId, replayPrompt, conversationId, consumed.getToolCallPayload());
// 保存 replay 结果这是正常结果入库
conversationService.saveMessage(conversationId, "assistant", reply);
// 发送回复
adapter.renderAndSend(replyTarget, reply);
log.info("[{}] Replay completed: tool={}, replyLen={}",
adapter.getChannelType(), consumed.getToolName(), reply.length());
} catch (Exception e) {
log.error("[approval-replay] Replay failed: {}", e.getMessage(), e);
adapter.sendMessage(replyTarget, "❌ 工具执行失败: " + e.getMessage());
}
}
/**
* PendingApproval 元数据构建 IM 友好的审批通知委托给 ApprovalNotificationService
*/
private String buildApprovalNotice(PendingApproval pending) {
return approvalNotificationService.buildApprovalText(pending);
}
/**
* 发布对话完成事件触发异步记忆提取失败不影响正常流程
*/
private void publishConversationCompletedEvent(Long agentId, String conversationId,
String userMessage, String assistantReply) {
try {
int msgCount = conversationService.getMessageCount(conversationId);
eventPublisher.publishEvent(new ConversationCompletedEvent(
agentId, conversationId, userMessage, assistantReply, msgCount, "channel"));
} catch (Exception e) {
log.debug("[Memory] Failed to publish ConversationCompletedEvent: {}", e.getMessage());
}
}
// ==================== 流式处理Web 渠道专用不走队列 ====================
/**
* 路由消息并使用流式处理用于支持流式的渠道 Web
*/
public Flux<String> routeStream(ChannelMessage message, ChannelEntity channelEntity) {
Long agentId = channelEntity.getAgentId();
if (agentId == null) {
return Flux.error(new IllegalStateException("Channel has no associated agent"));
}
String conversationId = buildConversationId(message);
String username = message.getSenderName() != null ? message.getSenderName() : message.getSenderId();
conversationService.getOrCreateConversation(conversationId, agentId, username);
List<MessageContentPart> parts = message.getContentParts();
conversationService.saveMessage(conversationId, "user", message.getContent(), parts);
String promptText = buildPromptFromParts(message.getContent(), parts);
return agentService.chatStream(agentId, promptText, conversationId);
}
// ==================== 优雅关闭 ====================
/**
* 优雅关闭停止防抖调度器和所有消费线程
*/
public void shutdown() {
log.info("Shutting down ChannelMessageRouter...");
shutdown = true;
// 1. 关闭防抖调度器
debounceScheduler.shutdownNow();
// 2. 清理残留的 pending 消息
synchronized (pendingMessages) {
pendingMessages.forEach((convId, pending) -> {
if (pending.timer != null) {
pending.timer.cancel(false);
}
log.warn("Dropping pending debounced message for conversation: {}", convId);
});
pendingMessages.clear();
}
// 3. 关闭每个渠道的消费线程池shutdown -> 等待 5 -> shutdownNow
channelExecutors.forEach((channelType, executor) -> {
log.info("[{}] Shutting down consumer threads...", channelType);
executor.shutdown();
try {
if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
log.warn("[{}] Consumer threads did not terminate in 5s, forcing shutdown", channelType);
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}
});
channelExecutors.clear();
channelQueues.clear();
sessionLocks.clear();
log.info("ChannelMessageRouter shutdown complete");
}
// ==================== 工具方法 ====================
/**
* 构建会话 ID
* 格式{channelType}:{chatId senderId}
* 格式采用 {channelType}:{identifier} 命名规则
*/
private String buildConversationId(ChannelMessage message) {
String identifier = message.getChatId() != null ? message.getChatId() : message.getSenderId();
return message.getChannelType() + ":" + identifier;
}
/**
* 确定回复目标
* 优先使用 replyToken渠道特有的回复标识其次 chatId最后 senderId
*/
private String resolveReplyTarget(ChannelMessage message) {
if (message.getReplyToken() != null) {
return message.getReplyToken();
}
return message.getChatId() != null ? message.getChatId() : message.getSenderId();
}
/**
* contentParts 构建完整 prompt 文本
* 文本直接拼接媒体类型生成描述性占位符 Agent 知道用户发送了什么
*/
private String buildPromptFromParts(String fallbackContent, List<MessageContentPart> parts) {
if (parts == null || parts.isEmpty()) {
return fallbackContent != null ? fallbackContent : "";
}
StringBuilder sb = new StringBuilder();
for (MessageContentPart part : parts) {
if (part == null || part.getType() == null) continue;
switch (part.getType()) {
case "text" -> appendLine(sb, part.getText());
case "image" -> appendLine(sb, "[用户发送了图片" + descMedia(part) + "]");
case "file" -> appendLine(sb, "[用户发送了文件: " + safe(part.getFileName()) + "]");
case "audio" -> appendLine(sb, "[用户发送了音频" + descMedia(part) + "]");
case "video" -> appendLine(sb, "[用户发送了视频" + descMedia(part) + "]");
default -> appendLine(sb, part.getText());
}
}
String result = sb.toString().trim();
return result.isEmpty() ? (fallbackContent != null ? fallbackContent : "") : result;
}
private void appendLine(StringBuilder sb, String text) {
if (text == null || text.isBlank()) return;
if (!sb.isEmpty()) sb.append('\n');
sb.append(text);
}
private String descMedia(MessageContentPart part) {
if (part.getFileName() != null && !part.getFileName().isBlank()) {
return ": " + part.getFileName();
}
return "";
}
private String safe(String s) {
return s == null ? "" : s;
}
}

View File

@ -0,0 +1,201 @@
package vip.mate.channel;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import vip.mate.channel.model.ChannelSessionEntity;
import vip.mate.channel.repository.ChannelSessionMapper;
import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 渠道会话存储
* <p>
* 实现 proactive send 机制缓存各渠道的会话标识映射
* 每次收到用户消息时自动更新 conversationId 映射到平台推送所需的标识
* <p>
* 内存 + DB 双层持久化
* - 内存层ConcurrentHashMap提供快速查询
* - DB mate_channel_session 保证重启后恢复
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ChannelSessionStore {
private final ChannelSessionMapper sessionMapper;
/** 内存缓存conversationId -> ChannelSessionEntity */
private final ConcurrentHashMap<String, ChannelSessionEntity> cache = new ConcurrentHashMap<>();
/** 缓存最大容量 */
private static final int MAX_CACHE_SIZE = 10000;
/** 会话过期时间(天) */
private static final int SESSION_TTL_DAYS = 30;
/**
* 应用启动时从 DB 加载所有会话到内存
*/
@EventListener(ApplicationReadyEvent.class)
public void init() {
List<ChannelSessionEntity> sessions = sessionMapper.selectList(
new LambdaQueryWrapper<ChannelSessionEntity>().orderByDesc(ChannelSessionEntity::getLastActiveTime));
for (ChannelSessionEntity session : sessions) {
cache.put(session.getConversationId(), session);
}
log.info("ChannelSessionStore initialized: loaded {} sessions from DB", sessions.size());
}
/**
* 保存或更新会话标识收到用户消息时调用
*
* @param conversationId 会话ID dingtalk:xxx
* @param channelType 渠道类型
* @param targetId 推送目标标识sessionWebhook / chat_id / channel_id
* @param senderId 发送者ID
* @param senderName 发送者名称
* @param channelId 渠道配置ID
*/
public void saveOrUpdate(String conversationId, String channelType, String targetId,
String senderId, String senderName, Long channelId) {
LocalDateTime now = LocalDateTime.now();
ChannelSessionEntity existing = cache.get(conversationId);
if (existing != null) {
// 更新内存和 DB
existing.setTargetId(targetId);
existing.setSenderId(senderId);
existing.setSenderName(senderName);
existing.setChannelId(channelId);
existing.setLastActiveTime(now);
sessionMapper.updateById(existing);
log.debug("Updated channel session: conversationId={}, targetId={}", conversationId, targetId);
} else {
// 先查 DB可能是上次启动后的新记录
ChannelSessionEntity dbEntity = sessionMapper.selectOne(
new LambdaQueryWrapper<ChannelSessionEntity>()
.eq(ChannelSessionEntity::getConversationId, conversationId));
if (dbEntity != null) {
dbEntity.setTargetId(targetId);
dbEntity.setSenderId(senderId);
dbEntity.setSenderName(senderName);
dbEntity.setChannelId(channelId);
dbEntity.setLastActiveTime(now);
sessionMapper.updateById(dbEntity);
cache.put(conversationId, dbEntity);
log.debug("Updated channel session from DB: conversationId={}", conversationId);
} else {
// 新建
ChannelSessionEntity entity = new ChannelSessionEntity();
entity.setConversationId(conversationId);
entity.setChannelType(channelType);
entity.setTargetId(targetId);
entity.setSenderId(senderId);
entity.setSenderName(senderName);
entity.setChannelId(channelId);
entity.setLastActiveTime(now);
sessionMapper.insert(entity);
cache.put(conversationId, entity);
log.debug("Created channel session: conversationId={}, targetId={}", conversationId, targetId);
// 容量保护超过上限时淘汰最久未活跃的会话
evictIfNeeded();
}
}
}
/**
* 淘汰过期和超量的缓存条目
*/
private void evictIfNeeded() {
if (cache.size() <= MAX_CACHE_SIZE) {
return;
}
// 先淘汰过期条目超过 TTL 天未活跃的
LocalDateTime cutoff = LocalDateTime.now().minusDays(SESSION_TTL_DAYS);
cache.entrySet().removeIf(entry -> {
ChannelSessionEntity session = entry.getValue();
if (session.getLastActiveTime() != null && session.getLastActiveTime().isBefore(cutoff)) {
log.debug("Evicting expired session: conversationId={}, lastActive={}",
entry.getKey(), session.getLastActiveTime());
return true;
}
return false;
});
// 仍超量则按 lastActiveTime 淘汰最老的 10%
if (cache.size() > MAX_CACHE_SIZE) {
int toEvict = cache.size() - (int)(MAX_CACHE_SIZE * 0.9);
cache.entrySet().stream()
.sorted(Comparator.comparing(
e -> e.getValue().getLastActiveTime() != null
? e.getValue().getLastActiveTime()
: LocalDateTime.MIN))
.limit(toEvict)
.map(Map.Entry::getKey)
.toList()
.forEach(key -> {
log.debug("Evicting LRU session: conversationId={}", key);
cache.remove(key);
});
}
}
/**
* 根据 conversationId 获取推送目标标识
*
* @return targetId不存在则返回 null
*/
public String getTargetId(String conversationId) {
ChannelSessionEntity entity = cache.get(conversationId);
return entity != null ? entity.getTargetId() : null;
}
/**
* 根据 conversationId 获取完整会话信息
*/
public ChannelSessionEntity getSession(String conversationId) {
return cache.get(conversationId);
}
/**
* 获取指定渠道类型的所有会话
*/
public List<ChannelSessionEntity> listByChannelType(String channelType) {
return cache.values().stream()
.filter(s -> channelType.equals(s.getChannelType()))
.toList();
}
/**
* 获取指定渠道配置ID的所有会话
*/
public List<ChannelSessionEntity> listByChannelId(Long channelId) {
return cache.values().stream()
.filter(s -> channelId.equals(s.getChannelId()))
.toList();
}
/**
* 删除会话
*/
public void remove(String conversationId) {
ChannelSessionEntity removed = cache.remove(conversationId);
if (removed != null) {
sessionMapper.deleteById(removed.getId());
log.debug("Removed channel session: conversationId={}", conversationId);
}
}
}

View File

@ -0,0 +1,84 @@
package vip.mate.channel;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 指数退避工具类
* <p>
* 用于断线重连Token 刷新失败重试等场景
* 每次调用 {@link #nextDelayMs()} 返回递增的延迟时间带上限
* 重连成功后调用 {@link #reset()} 重置计数器
*
* @author MateClaw Team
*/
public class ExponentialBackoff {
private final long initialDelayMs;
private final long maxDelayMs;
private final double factor;
private final int maxAttempts;
private final AtomicInteger attempts = new AtomicInteger(0);
/**
* @param initialDelayMs 初始延迟毫秒
* @param maxDelayMs 最大延迟上限毫秒
* @param factor 退避倍数通常为 2.0
* @param maxAttempts 最大重试次数-1 表示无限重试
*/
public ExponentialBackoff(long initialDelayMs, long maxDelayMs, double factor, int maxAttempts) {
this.initialDelayMs = initialDelayMs;
this.maxDelayMs = maxDelayMs;
this.factor = factor;
this.maxAttempts = maxAttempts;
}
/** 默认配置2s 起步30s 上限2 倍递增,无限重试 */
public ExponentialBackoff() {
this(2000, 30000, 2.0, -1);
}
/**
* 计算下一次延迟毫秒并递增尝试次数
*
* @return 延迟毫秒数
*/
public long nextDelayMs() {
int attempt = attempts.getAndIncrement();
long delay = (long) (initialDelayMs * Math.pow(factor, attempt));
return Math.min(delay, maxDelayMs);
}
/**
* 是否已超过最大重试次数
*/
public boolean isExhausted() {
if (maxAttempts < 0) return false;
return attempts.get() >= maxAttempts;
}
/**
* 重置退避计数器重连成功后调用
*/
public void reset() {
attempts.set(0);
}
/**
* 当前已尝试次数
*/
public int getAttempts() {
return attempts.get();
}
public int getMaxAttempts() {
return maxAttempts;
}
public long getInitialDelayMs() {
return initialDelayMs;
}
public long getMaxDelayMs() {
return maxDelayMs;
}
}

View File

@ -0,0 +1,39 @@
package vip.mate.channel;
import reactor.core.publisher.Flux;
import vip.mate.agent.AgentService.StreamDelta;
/**
* 支持流式处理的渠道适配器接口
* <p>
* 实现此接口的渠道能够以自身方式渲染流式事件如钉钉 AI Card飞书卡片更新等
* 而非等待完整回复后一次性发送
* <p>
* 设计参考 MateClaw 的事件流与渲染分离模式
* - ChannelMessageRouter 负责"事件产生"调用 Agent 获取 StreamDelta
* - StreamingChannelAdapter 负责"UI 渲染"决定如何呈现流式事件
*
* @author MateClaw Team
*/
public interface StreamingChannelAdapter extends ChannelAdapter {
/**
* 处理流式事件并渲染到渠道
* <p>
* Router Agent 产生的 StreamDelta 流传入由渠道实现决定渲染策略
* - 钉钉创建 AI Card 流式更新卡片 完成/失败
* - 飞书可更新消息卡片
* - 其他可累积后分段发送
* <p>
* 实现约定
* - 方法内部消费整个 Flux阻塞当前线程直到流结束
* - 返回最终完整回复内容用于保存到 DB
* - 异常应向上抛出 Router 统一处理
*
* @param stream Agent 产生的结构化流式事件
* @param message 原始入站消息 replyTokenrawPayload 等上下文
* @param conversationId 会话 ID
* @return 最终完整回复内容
*/
String processStream(Flux<StreamDelta> stream, ChannelMessage message, String conversationId);
}

View File

@ -0,0 +1,93 @@
package vip.mate.channel.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import vip.mate.channel.ChannelManager;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.channel.service.ChannelService;
import vip.mate.common.result.R;
import java.util.List;
import java.util.Map;
/**
* 渠道管理接口
* <p>
* 提供渠道的 CRUD启用/禁用联动 ChannelManager 生命周期状态查询等能力
* 对应前端 Channel 管理页面
*
* @author MateClaw Team
*/
@Tag(name = "渠道管理")
@RestController
@RequestMapping("/api/v1/channels")
@RequiredArgsConstructor
public class ChannelController {
private final ChannelService channelService;
private final ChannelManager channelManager;
@Operation(summary = "获取渠道列表")
@GetMapping
public R<List<ChannelEntity>> list() {
return R.ok(channelService.listChannels());
}
@Operation(summary = "按类型获取渠道列表")
@GetMapping("/type/{channelType}")
public R<List<ChannelEntity>> listByType(@PathVariable String channelType) {
return R.ok(channelService.listChannelsByType(channelType));
}
@Operation(summary = "获取渠道详情")
@GetMapping("/{id}")
public R<ChannelEntity> get(@PathVariable Long id) {
return R.ok(channelService.getChannel(id));
}
@Operation(summary = "创建渠道")
@PostMapping
public R<ChannelEntity> create(@RequestBody ChannelEntity channel) {
return R.ok(channelService.createChannel(channel));
}
@Operation(summary = "更新渠道")
@PutMapping("/{id}")
public R<ChannelEntity> update(@PathVariable Long id, @RequestBody ChannelEntity channel) {
channel.setId(id);
ChannelEntity updated = channelService.updateChannel(channel);
// 配置变更后热替换渠道 Adapter 就绪后才替换旧的失败则保留旧的
channelManager.restartChannel(id);
return R.ok(updated);
}
@Operation(summary = "删除渠道")
@DeleteMapping("/{id}")
public R<Void> delete(@PathVariable Long id) {
// 先停止渠道再删除
channelManager.stopChannel(id);
channelService.deleteChannel(id);
return R.ok();
}
@Operation(summary = "启用/禁用渠道")
@PutMapping("/{id}/toggle")
public R<ChannelEntity> toggle(@PathVariable Long id, @RequestParam boolean enabled) {
ChannelEntity channel = channelService.toggleChannel(id, enabled);
// 联动 ChannelManager启用时启动禁用时停止
if (enabled) {
channelManager.startChannel(channel);
} else {
channelManager.stopChannel(id);
}
return R.ok(channel);
}
@Operation(summary = "获取渠道运行状态")
@GetMapping("/status")
public R<Map<String, Object>> status() {
return R.ok(channelManager.getStatus());
}
}

View File

@ -0,0 +1,212 @@
package vip.mate.channel.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import vip.mate.channel.ChannelAdapter;
import vip.mate.channel.ChannelManager;
import vip.mate.channel.dingtalk.DingTalkChannelAdapter;
import vip.mate.channel.discord.DiscordChannelAdapter;
import vip.mate.channel.feishu.FeishuChannelAdapter;
import vip.mate.channel.telegram.TelegramChannelAdapter;
import vip.mate.channel.weixin.ILinkClient;
import vip.mate.channel.weixin.WeixinChannelAdapter;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.EncodeHintType;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
import java.io.ByteArrayOutputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
/**
* 渠道 Webhook 回调接口
* <p>
* 接收来自各 IM 平台的消息推送回调
* 各平台钉钉飞书Telegram 将此 URL 配置为消息回调地址
* <p>
* URL 格式/api/v1/channels/webhook/{channelType}
* 此接口不需要 JWT 认证由各平台的签名/Token 机制保障安全
*
* @author MateClaw Team
*/
@Tag(name = "渠道Webhook")
@Slf4j
@RestController
@RequestMapping("/api/v1/channels/webhook")
@RequiredArgsConstructor
public class ChannelWebhookController {
private final ChannelManager channelManager;
@Operation(summary = "钉钉消息回调")
@PostMapping("/dingtalk")
public ResponseEntity<Map<String, Object>> dingtalkWebhook(@RequestBody Map<String, Object> payload) {
log.debug("[webhook] DingTalk callback received");
Optional<ChannelAdapter> adapter = channelManager.getAdapterByType("dingtalk");
if (adapter.isPresent() && adapter.get() instanceof DingTalkChannelAdapter dingtalk) {
dingtalk.handleWebhook(payload);
return ResponseEntity.ok(Map.of("status", "ok"));
}
log.warn("[webhook] DingTalk channel not active, ignoring callback");
return ResponseEntity.ok(Map.of("status", "channel_not_active"));
}
@Operation(summary = "飞书消息回调")
@PostMapping("/feishu")
public ResponseEntity<Map<String, Object>> feishuWebhook(@RequestBody Map<String, Object> payload) {
log.debug("[webhook] Feishu callback received");
Optional<ChannelAdapter> adapter = channelManager.getAdapterByType("feishu");
if (adapter.isPresent() && adapter.get() instanceof FeishuChannelAdapter feishu) {
Map<String, Object> result = feishu.handleWebhook(payload);
return ResponseEntity.ok(result);
}
// 即使渠道未激活也需要响应 URL 验证
String type = (String) payload.get("type");
if ("url_verification".equals(type)) {
String challenge = (String) payload.get("challenge");
return ResponseEntity.ok(Map.of("challenge", challenge != null ? challenge : ""));
}
log.warn("[webhook] Feishu channel not active, ignoring callback");
return ResponseEntity.ok(Map.of("code", 0));
}
@Operation(summary = "Telegram 消息回调")
@PostMapping("/telegram")
public ResponseEntity<String> telegramWebhook(@RequestBody Map<String, Object> payload) {
log.debug("[webhook] Telegram callback received");
Optional<ChannelAdapter> adapter = channelManager.getAdapterByType("telegram");
if (adapter.isPresent() && adapter.get() instanceof TelegramChannelAdapter telegram) {
telegram.handleWebhook(payload);
} else {
log.warn("[webhook] Telegram channel not active, ignoring callback");
}
return ResponseEntity.ok("ok");
}
@Operation(summary = "Discord 消息回调已废弃Discord 已切换为 Gateway WebSocket 模式)")
@PostMapping("/discord")
public ResponseEntity<Map<String, Object>> discordWebhook(@RequestBody Map<String, Object> payload) {
// Discord Interaction PING 仍需响应防止 Discord 删除 Interaction URL
Integer type = (Integer) payload.get("type");
if (type != null && type == 1) {
return ResponseEntity.ok(Map.of("type", 1));
}
// Discord 已切换为 Gateway WebSocketwebhook 回调不再用于接收消息
log.warn("[webhook] Discord webhook called, but messages are now received via Gateway WebSocket");
Optional<ChannelAdapter> adapter = channelManager.getAdapterByType("discord");
if (adapter.isPresent() && adapter.get() instanceof DiscordChannelAdapter discord) {
discord.handleWebhook(payload);
}
return ResponseEntity.ok(Map.of("status", "ok"));
}
@Operation(summary = "企业微信消息回调(智能机器人模式不使用,保留兼容)")
@PostMapping("/wecom")
public ResponseEntity<String> wecomWebhook(@RequestBody Map<String, Object> payload) {
// 智能机器人模式通过 WebSocket 长连接接收消息不再使用 HTTP 回调
log.debug("[webhook] WeCom callback received (not used in bot mode, messages are received via WebSocket)");
return ResponseEntity.ok("success");
}
// ==================== 微信 iLink Bot ====================
/** 微信扫码深链接模板 */
private static final String WEIXIN_SCAN_URL_TEMPLATE =
"https://liteapp.weixin.qq.com/q/7GiQu1?qrcode=%s&bot_type=3";
/**
* 创建临时 ILinkClient 用于 QR 码操作不依赖渠道是否已启动
*/
private ILinkClient createWeixinClient() {
return new ILinkClient("", ILinkClient.DEFAULT_BASE_URL,
new com.fasterxml.jackson.databind.ObjectMapper());
}
/**
* 使用 ZXing 生成 QR PNG 图片并返回 Base64 编码
*/
private String generateQrCodeBase64(String content) throws Exception {
QRCodeWriter writer = new QRCodeWriter();
Map<EncodeHintType, Object> hints = Map.of(
EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M,
EncodeHintType.MARGIN, 2
);
BitMatrix bitMatrix = writer.encode(content, BarcodeFormat.QR_CODE, 300, 300, hints);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
MatrixToImageWriter.writeToStream(bitMatrix, "PNG", baos);
return java.util.Base64.getEncoder().encodeToString(baos.toByteArray());
}
@Operation(summary = "获取微信登录二维码")
@GetMapping("/weixin/qrcode")
public ResponseEntity<Map<String, Object>> weixinQrcode() {
try {
ILinkClient client = createWeixinClient();
Map<String, Object> apiResult = client.getBotQrcode();
String qrcode = String.valueOf(apiResult.getOrDefault("qrcode", ""));
if (qrcode.isBlank()) {
return ResponseEntity.internalServerError()
.body(Map.of("error", "iLink API returned empty qrcode"));
}
// qrcode 构建微信扫码深链接再生成 QR 码图片
String scanUrl;
Object urlObj = apiResult.get("url");
if (urlObj != null && urlObj.toString().startsWith("http")) {
scanUrl = urlObj.toString();
} else {
String encoded = URLEncoder.encode(qrcode, StandardCharsets.UTF_8);
scanUrl = String.format(WEIXIN_SCAN_URL_TEMPLATE, encoded);
}
String qrCodeImgBase64 = generateQrCodeBase64(scanUrl);
Map<String, Object> result = new LinkedHashMap<>();
result.put("qrcode", qrcode);
result.put("qrcode_img", qrCodeImgBase64);
return ResponseEntity.ok(result);
} catch (Exception e) {
log.error("[webhook] WeChat QR code fetch failed: {}", e.getMessage(), e);
return ResponseEntity.internalServerError()
.body(Map.of("error", "Failed to get QR code: " + e.getMessage()));
}
}
@Operation(summary = "查询微信二维码扫码状态")
@GetMapping("/weixin/qrcode/status")
public ResponseEntity<Map<String, Object>> weixinQrcodeStatus(@RequestParam String qrcode) {
try {
ILinkClient client = createWeixinClient();
Map<String, Object> apiResult = client.getQrcodeStatus(qrcode);
// 只返回前端需要的字段
Map<String, Object> result = new LinkedHashMap<>();
result.put("status", apiResult.getOrDefault("status", "waiting"));
result.put("bot_token", apiResult.getOrDefault("bot_token", ""));
result.put("base_url", apiResult.getOrDefault("baseurl", ""));
return ResponseEntity.ok(result);
} catch (Exception e) {
log.error("[webhook] WeChat QR code status check failed: {}", e.getMessage(), e);
return ResponseEntity.internalServerError()
.body(Map.of("error", "Failed to check QR code status: " + e.getMessage()));
}
}
@Operation(summary = "获取渠道运行状态")
@GetMapping("/status")
public ResponseEntity<Map<String, Object>> status() {
return ResponseEntity.ok(channelManager.getStatus());
}
}

View File

@ -0,0 +1,375 @@
package vip.mate.channel.dingtalk;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* 钉钉 AI Card 管理器
* <p>
* 钉钉 AI Card 流式卡片管理器管理卡片的完整生命周期
* - Token 管理缓存 + 预刷新
* - 卡片创建与投放createAndDeliver
* - 流式内容更新streaming update500ms 节流
* - 卡片状态管理PROCESSING FINISHED / FAILED
* <p>
* 使用钉钉开放平台 Card API
* - POST /v1.0/card/instances/createAndDeliver 创建并投放卡片
* - PUT /v1.0/card/streaming 流式追加内容
* <p>
* 卡片状态持久化在内存中服务重启后丢失可接受
*
* @author MateClaw Team
*/
@Slf4j
public class DingTalkAICardManager {
private static final String API_BASE = "https://api.dingtalk.com";
/** 创建并投放卡片 */
private static final String CREATE_AND_DELIVER_URL = API_BASE + "/v1.0/card/instances/createAndDeliver";
/** 流式更新卡片内容 */
private static final String STREAMING_URL = API_BASE + "/v1.0/card/streaming";
/** 流式更新节流间隔(毫秒) */
private static final long THROTTLE_INTERVAL_MS = 500;
private final HttpClient httpClient;
private final ObjectMapper objectMapper;
private final String clientId;
private final String clientSecret;
/** 缓存的 access_token */
private volatile String accessToken;
/** token 过期时间 (epoch ms) */
private volatile long tokenExpireTime;
/** 活跃卡片outTrackId → CardInstance */
private final ConcurrentHashMap<String, CardInstance> activeCards = new ConcurrentHashMap<>();
/**
* 卡片实例状态
*/
static class CardInstance {
final String outTrackId;
volatile long lastUpdateTime;
volatile String accumulatedContent;
volatile boolean finished;
CardInstance(String outTrackId) {
this.outTrackId = outTrackId;
this.lastUpdateTime = 0;
this.accumulatedContent = "";
this.finished = false;
}
}
public DingTalkAICardManager(HttpClient httpClient, ObjectMapper objectMapper,
String clientId, String clientSecret) {
this.httpClient = httpClient;
this.objectMapper = objectMapper;
this.clientId = clientId;
this.clientSecret = clientSecret;
}
// ==================== Token 管理 ====================
/**
* 获取有效的 access_token带缓存和预刷新
*/
public String ensureAccessToken() {
if (accessToken != null && System.currentTimeMillis() < tokenExpireTime) {
return accessToken;
}
return refreshAccessToken();
}
private synchronized String refreshAccessToken() {
// Double-check after acquiring lock
if (accessToken != null && System.currentTimeMillis() < tokenExpireTime) {
return accessToken;
}
try {
String jsonBody = objectMapper.writeValueAsString(Map.of(
"appKey", clientId,
"appSecret", clientSecret
));
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(API_BASE + "/v1.0/oauth2/accessToken"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
@SuppressWarnings("unchecked")
Map<String, Object> result = objectMapper.readValue(response.body(), Map.class);
this.accessToken = (String) result.get("accessToken");
Object expireIn = result.get("expireIn");
int seconds = expireIn instanceof Number n ? n.intValue() : 7200;
// 提前 5 分钟刷新
this.tokenExpireTime = System.currentTimeMillis() + (seconds - 300) * 1000L;
log.info("[dingtalk-card] access_token refreshed, expires in {}s", seconds);
return this.accessToken;
} catch (Exception e) {
log.error("[dingtalk-card] Failed to refresh access_token: {}", e.getMessage(), e);
return null;
}
}
// ==================== 卡片创建 ====================
/**
* 创建并投放 AI 卡片显示"思考中..."状态
*
* @param cardTemplateId 卡片模板 ID
* @param conversationId 钉钉会话 IDopenConversationId
* @param chatType 会话类型"1" 单聊"2" 群聊
* @param robotCode 机器人编码
* @return outTrackId卡片实例追踪 ID失败返回 null
*/
public String createAndDeliverCard(String cardTemplateId, String conversationId,
String chatType, String robotCode) {
String token = ensureAccessToken();
if (token == null) {
log.error("[dingtalk-card] Cannot create card: no access_token");
return null;
}
String outTrackId = UUID.randomUUID().toString();
try {
// 卡片数据初始显示"思考中..."
Map<String, String> cardData = Map.of(
"content", "思考中...",
"status", "PROCESSING"
);
Map<String, Object> body = new HashMap<>();
body.put("cardTemplateId", cardTemplateId);
body.put("outTrackId", outTrackId);
body.put("cardData", Map.of("cardParamMap", cardData));
body.put("callbackType", "STREAM");
if ("1".equals(chatType)) {
// 单聊通过 IM_ROBOT 投放
body.put("openSpaceId", "dtv1.card//IM_ROBOT." + robotCode);
body.put("imRobotOpenDeliverModel", Map.of(
"spaceType", "IM_ROBOT",
"robotCode", robotCode
));
} else {
// 群聊通过 IM_GROUP 投放需要 openConversationId
body.put("openSpaceId", "dtv1.card//IM_GROUP." + conversationId);
body.put("imGroupOpenDeliverModel", Map.of(
"robotCode", robotCode
));
}
body.put("openDynamicDataConfig", Map.of(
"dynamicDataSourceConfigs", java.util.List.of(Map.of(
"constParams", Map.of(
"content", "思考中..."
)
))
));
String jsonBody = objectMapper.writeValueAsString(body);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(CREATE_AND_DELIVER_URL))
.header("Content-Type", "application/json")
.header("x-acs-dingtalk-access-token", token)
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
// 注册活跃卡片
activeCards.put(outTrackId, new CardInstance(outTrackId));
log.info("[dingtalk-card] Card created: outTrackId={}, conversationId={}", outTrackId, conversationId);
return outTrackId;
} else {
log.error("[dingtalk-card] Create card failed: status={}, body={}", response.statusCode(), response.body());
return null;
}
} catch (Exception e) {
log.error("[dingtalk-card] Failed to create card: {}", e.getMessage(), e);
return null;
}
}
// ==================== 流式更新 ====================
/**
* 追加流式内容到卡片
* <p>
* 内部做 500ms 节流内容先累积到 accumulatedContent
* 只有距上次更新超过 THROTTLE_INTERVAL_MS 才真正调用 API
*
* @param outTrackId 卡片追踪 ID
* @param contentDelta 本次增量内容
* @param forceFlush 是否强制刷新不等待节流用于最后一次更新
*/
public void appendContent(String outTrackId, String contentDelta, boolean forceFlush) {
CardInstance card = activeCards.get(outTrackId);
if (card == null || card.finished) {
log.debug("[dingtalk-card] Card not found or already finished: {}", outTrackId);
return;
}
// 累积内容
synchronized (card) {
card.accumulatedContent += contentDelta;
}
long now = System.currentTimeMillis();
boolean shouldFlush = forceFlush || (now - card.lastUpdateTime >= THROTTLE_INTERVAL_MS);
if (shouldFlush) {
String contentToSend;
synchronized (card) {
contentToSend = card.accumulatedContent;
}
doStreamingUpdate(outTrackId, contentToSend, false, null);
card.lastUpdateTime = now;
}
}
/**
* 标记卡片完成FINISHED 状态发送最终内容
*
* @param outTrackId 卡片追踪 ID
* @param finalContent 最终完整内容
*/
public void finishCard(String outTrackId, String finalContent) {
CardInstance card = activeCards.get(outTrackId);
if (card == null) {
log.debug("[dingtalk-card] Card not found for finish: {}", outTrackId);
return;
}
card.finished = true;
doStreamingUpdate(outTrackId, finalContent, true, "FINISHED");
activeCards.remove(outTrackId);
log.info("[dingtalk-card] Card finished: outTrackId={}", outTrackId);
}
/**
* 标记卡片失败FAILED 状态
*
* @param outTrackId 卡片追踪 ID
* @param errorMessage 错误信息
*/
public void failCard(String outTrackId, String errorMessage) {
CardInstance card = activeCards.get(outTrackId);
if (card == null) {
log.debug("[dingtalk-card] Card not found for fail: {}", outTrackId);
return;
}
card.finished = true;
String content = card.accumulatedContent.isEmpty()
? "处理失败:" + errorMessage
: card.accumulatedContent + "\n\n⚠ " + errorMessage;
doStreamingUpdate(outTrackId, content, true, "FAILED");
activeCards.remove(outTrackId);
log.warn("[dingtalk-card] Card failed: outTrackId={}, error={}", outTrackId, errorMessage);
}
/**
* 调用钉钉流式更新 API
*
* @param outTrackId 卡片追踪 ID
* @param content 当前完整内容非增量
* @param isFinish 是否为最终更新
* @param status 卡片状态FINISHED / FAILED非最终更新时为 null
*/
private void doStreamingUpdate(String outTrackId, String content, boolean isFinish, String status) {
String token = ensureAccessToken();
if (token == null) {
log.error("[dingtalk-card] Cannot update card: no access_token");
return;
}
try {
Map<String, Object> body = new HashMap<>();
body.put("outTrackId", outTrackId);
// 更新的 key
String key = "content";
if (isFinish) {
body.put("isFull", true);
body.put("isFinalize", true);
body.put("guid", UUID.randomUUID().toString());
body.put("key", key);
body.put("value", content);
} else {
body.put("isFull", true);
body.put("isFinalize", false);
body.put("guid", UUID.randomUUID().toString());
body.put("key", key);
body.put("value", content);
}
String jsonBody = objectMapper.writeValueAsString(body);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(STREAMING_URL))
.header("Content-Type", "application/json")
.header("x-acs-dingtalk-access-token", token)
.PUT(HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
log.warn("[dingtalk-card] Streaming update failed: status={}, body={}",
response.statusCode(), response.body());
} else {
log.debug("[dingtalk-card] Streaming update: outTrackId={}, contentLen={}, finish={}",
outTrackId, content.length(), isFinish);
}
} catch (Exception e) {
log.error("[dingtalk-card] Failed to do streaming update: {}", e.getMessage(), e);
}
}
// ==================== 查询 ====================
/**
* 获取活跃卡片数量
*/
public int getActiveCardCount() {
return activeCards.size();
}
/**
* 检查是否有活跃卡片
*/
public boolean hasActiveCard(String outTrackId) {
CardInstance card = activeCards.get(outTrackId);
return card != null && !card.finished;
}
/**
* 清理所有活跃卡片渠道停止时调用
*/
public void cleanup() {
activeCards.clear();
log.info("[dingtalk-card] Cleaned up {} active cards", activeCards.size());
}
}

View File

@ -0,0 +1,646 @@
package vip.mate.channel.dingtalk;
import com.dingtalk.open.app.api.OpenDingTalkClient;
import com.dingtalk.open.app.api.OpenDingTalkStreamClientBuilder;
import com.dingtalk.open.app.api.callback.DingTalkStreamTopics;
import com.dingtalk.open.app.api.callback.OpenDingTalkCallbackListener;
import com.dingtalk.open.app.api.models.bot.ChatbotMessage;
import com.dingtalk.open.app.api.security.AuthClientCredential;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import reactor.core.publisher.Flux;
import vip.mate.agent.AgentService.StreamDelta;
import vip.mate.channel.AbstractChannelAdapter;
import vip.mate.channel.ChannelMessage;
import vip.mate.channel.ChannelMessageRouter;
import vip.mate.channel.ExponentialBackoff;
import vip.mate.channel.StreamingChannelAdapter;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.workspace.conversation.model.MessageContentPart;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 钉钉渠道适配器
* <p>
* 支持两种接入模式
* - <b>Stream 模式推荐</b>WebSocket 长连接无需公网 IP钉钉官方推荐
* - <b>Webhook 模式</b>HTTP 回调需要公网可访问的 URL
* <p>
* 消息格式
* - <b>markdown</b>普通 Markdown 消息
* - <b>card</b>AI Card 流式卡片需配置 card_template_id
* <p>
* 配置项configJson
* - connection_mode: 接入模式stream / webhook默认 stream
* - client_id: 钉钉应用 AppKey
* - client_secret: 钉钉应用 AppSecret
* - message_type: 消息格式markdown / card默认 markdown
* - card_template_id: AI Card 模板 IDmessage_type=card 时必填
* - robot_code: 机器人编码card 模式群聊建议配置
*
* @author MateClaw Team
*/
@Slf4j
public class DingTalkChannelAdapter extends AbstractChannelAdapter implements StreamingChannelAdapter {
public static final String CHANNEL_TYPE = "dingtalk";
private HttpClient httpClient;
/** 钉钉 Stream 客户端Stream 模式下使用) */
private OpenDingTalkClient streamClient;
/** AI Card 管理器message_type=card 时初始化) */
private DingTalkAICardManager aiCardManager;
public DingTalkChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter,
ObjectMapper objectMapper) {
super(channelEntity, messageRouter, objectMapper);
// 钉钉 Stream 重连2s4s8s16s30s无限重试
this.backoff = new ExponentialBackoff(2000, 30000, 2.0, -1);
}
/**
* 获取接入模式stream默认推荐 webhook
*/
public String getConnectionMode() {
return getConfigString("connection_mode", "stream");
}
/**
* 是否为 Stream 长连接模式
*/
public boolean isStreamMode() {
return "stream".equals(getConnectionMode());
}
@Override
protected void doStart() {
String clientId = getConfigString("client_id");
String clientSecret = getConfigString("client_secret");
if (clientId == null || clientSecret == null) {
throw new IllegalStateException("DingTalk channel requires client_id and client_secret in configJson");
}
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
// 初始化 AI Card 管理器message_type=card 且配置了模板 ID
String cardTemplateId = getConfigString("card_template_id");
String messageType = getConfigString("message_type", "markdown");
if ("card".equals(messageType) && cardTemplateId != null && !cardTemplateId.isBlank()) {
this.aiCardManager = new DingTalkAICardManager(httpClient, objectMapper, clientId, clientSecret);
log.info("[dingtalk] AI Card enabled: templateId={}", cardTemplateId);
}
// 启动 Stream 模式或 Webhook 模式
if (isStreamMode()) {
startStreamMode(clientId, clientSecret);
} else {
log.info("[dingtalk] Webhook mode: waiting for callbacks at /api/v1/channels/webhook/dingtalk");
}
log.info("[dingtalk] DingTalk channel initialized: mode={}, clientId={}, robotCode={}, aiCard={}",
getConnectionMode(), clientId, getConfigString("robot_code"), isAICardEnabled());
}
/**
* 启动 Stream 长连接模式
* <p>
* 使用钉钉 Stream SDKdingtalk-stream建立 WebSocket 长连接
* 通过 {@link OpenDingTalkCallbackListener} 回调接收机器人消息无需公网 IP
* <p>
* SDK 内部自带断线重连机制
*/
private void startStreamMode(String clientId, String clientSecret) {
try {
OpenDingTalkCallbackListener<ChatbotMessage, Void> botListener = message -> {
try {
handleStreamMessage(message);
} catch (Exception e) {
log.error("[dingtalk-stream] Failed to handle message: {}", e.getMessage(), e);
}
return null;
};
this.streamClient = OpenDingTalkStreamClientBuilder.custom()
.credential(new AuthClientCredential(clientId, clientSecret))
.registerCallbackListener(DingTalkStreamTopics.BOT_MESSAGE_TOPIC, botListener)
.build();
streamClient.start();
log.info("[dingtalk-stream] Stream connection established (no public IP needed)");
} catch (Exception e) {
log.error("[dingtalk-stream] Failed to start stream client: {}", e.getMessage(), e);
throw new RuntimeException("DingTalk Stream start failed: " + e.getMessage(), e);
}
}
/**
* 处理 Stream 模式收到的机器人消息
* <p>
* SDK {@link ChatbotMessage} 提取字段构建与 Webhook 兼容的 payload Map
* 复用 {@link #handleWebhook(Map)} 进行统一处理
*/
private void handleStreamMessage(ChatbotMessage msg) {
try {
// 构建与 Webhook payload 格式兼容的 Map复用已有解析逻辑
Map<String, Object> payload = new java.util.HashMap<>();
payload.put("msgId", msg.getMsgId());
payload.put("senderStaffId", msg.getSenderStaffId());
payload.put("senderId", msg.getSenderId());
payload.put("senderNick", msg.getSenderNick());
payload.put("conversationId", msg.getConversationId());
payload.put("conversationType", msg.getConversationType());
payload.put("sessionWebhook", msg.getSessionWebhook());
// 消息内容
if (msg.getText() != null) {
payload.put("msgtype", "text");
payload.put("text", Map.of("content", msg.getText().getContent() != null ? msg.getText().getContent() : ""));
}
// richText 等复杂类型暂由 handleWebhook 内部处理
handleWebhook(payload);
} catch (Exception e) {
log.error("[dingtalk-stream] Failed to parse stream message: {}", e.getMessage(), e);
}
}
@Override
protected void doStop() {
// 关闭 Stream 客户端
if (streamClient != null) {
try {
streamClient.stop();
log.info("[dingtalk-stream] Stream client stopped");
} catch (Exception e) {
log.warn("[dingtalk-stream] Error stopping stream client: {}", e.getMessage());
}
streamClient = null;
}
if (aiCardManager != null) {
aiCardManager.cleanup();
aiCardManager = null;
}
this.httpClient = null;
log.info("[dingtalk] DingTalk channel stopped");
}
// ==================== AI Card ====================
/**
* 是否启用了 AI Card 流式输出
* <p>
* message_type=card card_template_id 已配置时启用
*/
public boolean isAICardEnabled() {
return aiCardManager != null
&& "card".equals(getConfigString("message_type"))
&& getConfigString("card_template_id") != null;
}
/**
* 获取 AI Card 管理器
*/
public DingTalkAICardManager getAICardManager() {
return aiCardManager;
}
/**
* 获取 AI Card 模板 ID
*/
public String getCardTemplateId() {
return getConfigString("card_template_id");
}
/**
* 获取机器人编码
*/
public String getRobotCode() {
return getConfigString("robot_code");
}
// ==================== StreamingChannelAdapter ====================
/**
* 流式处理 Agent 事件并渲染到钉钉
* <p>
* 渲染策略
* - AI Card 启用时创建卡片 流式更新 完成/失败
* - AI Card 未启用时累积全部内容后通过 sessionWebhook 一次性发送
*/
@Override
public String processStream(Flux<StreamDelta> stream, ChannelMessage message, String conversationId) {
if (isAICardEnabled()) {
return processStreamWithAICard(stream, message);
}
// AI Card累积后发送退化为文本模式但仍走 streaming 获取内容
return processStreamAsText(stream, message);
}
/**
* AI Card 流式渲染路径
* <p>
* 参考 MateClaw _process_dingtalk_core() 模式
* 1. 创建"思考中..."卡片
* 2. 消费事件流流式更新卡片500ms 节流
* 3. 完成时标记 FINISHED异常时标记 FAILED
* 4. 卡片创建失败时退化为文本模式
*/
private String processStreamWithAICard(Flux<StreamDelta> stream, ChannelMessage message) {
String cardTemplateId = getCardTemplateId();
String robotCode = getRobotCode();
String chatType = message.getChatId() != null ? "2" : "1";
String dtConversationId = extractDingTalkConversationId(message);
// Step 1: 创建并投放"思考中..."卡片
String outTrackId = aiCardManager.createAndDeliverCard(
cardTemplateId, dtConversationId, chatType, robotCode);
if (outTrackId == null) {
log.warn("[dingtalk] AI Card creation failed, falling back to text mode");
return processStreamAsText(stream, message);
}
log.info("[dingtalk] AI Card streaming started: outTrackId={}", outTrackId);
// Step 2: 消费事件流流式更新卡片
StringBuilder contentAccumulator = new StringBuilder();
try {
stream.doOnNext(delta -> {
if (delta.content() != null) {
contentAccumulator.append(delta.content());
aiCardManager.appendContent(outTrackId, delta.content(), false);
}
})
.doOnError(error -> {
log.error("[dingtalk] AI Card stream error: outTrackId={}, error={}",
outTrackId, error.getMessage());
aiCardManager.failCard(outTrackId, error.getMessage());
})
.blockLast(Duration.ofMinutes(5));
// Step 3: 完成
String finalContent = contentAccumulator.toString();
if (finalContent.isBlank()) {
finalContent = "(无回复内容)";
}
aiCardManager.finishCard(outTrackId, finalContent);
log.info("[dingtalk] AI Card streaming completed: outTrackId={}, contentLen={}",
outTrackId, finalContent.length());
return finalContent;
} catch (Exception e) {
log.error("[dingtalk] AI Card streaming failed: outTrackId={}, error={}",
outTrackId, e.getMessage(), e);
aiCardManager.failCard(outTrackId, e.getMessage());
String partial = contentAccumulator.toString();
if (!partial.isBlank()) {
return partial;
}
throw new RuntimeException("AI Card streaming failed: " + e.getMessage(), e);
}
}
/**
* 文本模式流式处理累积全部内容后通过 renderAndSend 发送
*/
private String processStreamAsText(Flux<StreamDelta> stream, ChannelMessage message) {
StringBuilder contentAccumulator = new StringBuilder();
stream.doOnNext(delta -> {
if (delta.content() != null) {
contentAccumulator.append(delta.content());
}
})
.blockLast(Duration.ofMinutes(5));
String finalContent = contentAccumulator.toString();
if (!finalContent.isBlank()) {
String replyTarget = message.getReplyToken() != null ? message.getReplyToken()
: (message.getChatId() != null ? message.getChatId() : message.getSenderId());
renderAndSend(replyTarget, finalContent);
}
return finalContent;
}
/**
* rawPayload 中提取钉钉原生 conversationId
*/
@SuppressWarnings("unchecked")
private String extractDingTalkConversationId(ChannelMessage message) {
if (message.getRawPayload() instanceof Map<?, ?> payload) {
Object convId = payload.get("conversationId");
if (convId instanceof String s) {
return s;
}
}
return message.getChatId() != null ? message.getChatId() : message.getSenderId();
}
/**
* 处理来自钉钉 Webhook 的回调消息
* ChannelWebhookController 调用
*/
@SuppressWarnings("unchecked")
public void handleWebhook(Map<String, Object> payload) {
try {
String msgtype = (String) payload.get("msgtype");
List<MessageContentPart> contentParts = new ArrayList<>();
String textContent = null;
if ("richText".equals(msgtype)) {
// richText 消息可包含文本 + 图片
Map<String, Object> richTextBody = (Map<String, Object>) payload.get("richText");
if (richTextBody != null) {
List<Map<String, Object>> richTextList = (List<Map<String, Object>>) richTextBody.get("richText");
if (richTextList != null) {
StringBuilder textBuilder = new StringBuilder();
for (Map<String, Object> item : richTextList) {
String text = (String) item.get("text");
if (text != null && !text.isBlank()) {
contentParts.add(MessageContentPart.text(text));
textBuilder.append(text);
}
String downloadCode = (String) item.get("downloadCode");
String pictureUrl = (String) item.get("pictureUrl");
if (downloadCode != null || pictureUrl != null) {
contentParts.add(MessageContentPart.image(downloadCode, pictureUrl));
}
}
textContent = textBuilder.toString().trim();
}
}
} else {
// 默认 text 消息
Map<String, Object> msgBody = (Map<String, Object>) payload.get("text");
textContent = msgBody != null ? (String) msgBody.get("content") : null;
if (textContent != null && !textContent.isBlank()) {
contentParts.add(MessageContentPart.text(textContent.trim()));
}
}
String senderId = (String) payload.get("senderStaffId");
if (senderId == null) {
senderId = (String) payload.get("senderId");
}
if (senderId == null) {
log.warn("[dingtalk] No senderId found in webhook payload, ignoring message");
return;
}
String senderNick = (String) payload.get("senderNick");
String conversationId = (String) payload.get("conversationId");
String msgId = (String) payload.get("msgId");
String conversationType = (String) payload.get("conversationType");
String sessionWebhook = (String) payload.get("sessionWebhook");
if (contentParts.isEmpty() && (textContent == null || textContent.isBlank())) {
log.debug("[dingtalk] Empty message content, ignoring");
return;
}
String content = textContent != null ? textContent.trim() : "";
ChannelMessage message = ChannelMessage.builder()
.messageId(msgId)
.channelType(CHANNEL_TYPE)
.senderId(senderId)
.senderName(senderNick)
.chatId("1".equals(conversationType) ? null : conversationId)
.content(content)
.contentType(contentParts.stream().anyMatch(p -> "image".equals(p.getType())) ? "image" : "text")
.contentParts(contentParts)
.timestamp(LocalDateTime.now())
.rawPayload(payload)
.build();
message.setReplyToken(sessionWebhook);
onMessage(message);
} catch (Exception e) {
log.error("[dingtalk] Failed to handle webhook: {}", e.getMessage(), e);
}
}
@Override
public void sendMessage(String targetId, String content) {
if (httpClient == null) {
log.warn("[dingtalk] Channel not started, cannot send message");
return;
}
String messageType = getConfigString("message_type", "markdown");
try {
String jsonBody;
// card 模式的文本回退也使用 markdown 格式
if ("markdown".equals(messageType) || "card".equals(messageType)) {
jsonBody = objectMapper.writeValueAsString(Map.of(
"msgtype", "markdown",
"markdown", Map.of(
"title", "MateClaw",
"text", content
)
));
} else {
jsonBody = objectMapper.writeValueAsString(Map.of(
"msgtype", "text",
"text", Map.of("content", content)
));
}
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(targetId))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
log.warn("[dingtalk] Send message failed: status={}, body={}", response.statusCode(), response.body());
} else {
log.debug("[dingtalk] Message sent successfully via sessionWebhook");
}
} catch (Exception e) {
log.error("[dingtalk] Failed to send message: {}", e.getMessage(), e);
}
}
@Override
public void sendContentParts(String targetId, List<MessageContentPart> parts) {
if (httpClient == null) {
log.warn("[dingtalk] Channel not started, cannot send message");
return;
}
// 钉钉 sessionWebhook 只支持 text/markdown/link/actionCard 等类型
// 图片需要通过上传 media 后发送这里暂时将媒体内容以 Markdown 图片语法发出
StringBuilder markdown = new StringBuilder();
for (MessageContentPart part : parts) {
if (part == null) continue;
switch (part.getType()) {
case "text" -> { if (part.getText() != null) markdown.append(part.getText()); }
case "image" -> {
if (part.getFileUrl() != null) {
markdown.append("\n![图片](").append(part.getFileUrl()).append(")\n");
} else {
markdown.append("\n[图片]\n");
}
}
case "file" -> markdown.append("\n[文件: ").append(part.getFileName() != null ? part.getFileName() : "").append("]\n");
default -> { if (part.getText() != null) markdown.append(part.getText()); }
}
}
sendMessage(targetId, markdown.toString().trim());
}
// ==================== 主动推送 ====================
@Override
public boolean supportsProactiveSend() {
return true;
}
/**
* 主动推送消息
* <p>
* targetId 可以是
* - sessionWebhook URL http 开头直接通过 Webhook 发送
* - conversationId通过 Robot API orgGroupSend / privateSend 发送 access_token
*/
@Override
public void proactiveSend(String targetId, String content) {
if (httpClient == null) {
log.warn("[dingtalk] Channel not started, cannot proactive send");
return;
}
if (targetId.startsWith("http")) {
// sessionWebhook 直接发送
sendMessage(targetId, content);
return;
}
// 通过 Robot API 发送获取 access_token 后调用 /v1.0/robot/oToMessages/batchSend
String robotCode = getConfigString("robot_code");
if (robotCode == null || robotCode.isBlank()) {
log.warn("[dingtalk] robot_code not configured, falling back to sendMessage");
sendMessage(targetId, content);
return;
}
try {
String accessToken = getDingTalkAccessToken();
if (accessToken == null) {
log.error("[dingtalk] Failed to obtain access_token for proactive send");
return;
}
String messageType = getConfigString("message_type", "markdown");
Map<String, Object> msgParam;
String msgKey;
if ("markdown".equals(messageType) || "card".equals(messageType)) {
msgKey = "sampleMarkdown";
msgParam = Map.of("title", "MateClaw", "text", content);
} else {
msgKey = "sampleText";
msgParam = Map.of("content", content);
}
String jsonBody = objectMapper.writeValueAsString(Map.of(
"robotCode", robotCode,
"userIds", List.of(targetId),
"msgKey", msgKey,
"msgParam", objectMapper.writeValueAsString(msgParam)
));
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend"))
.header("Content-Type", "application/json")
.header("x-acs-dingtalk-access-token", accessToken)
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
log.warn("[dingtalk] Proactive send failed: status={}, body={}", response.statusCode(), response.body());
} else {
log.debug("[dingtalk] Proactive message sent to {}", targetId);
}
} catch (Exception e) {
log.error("[dingtalk] Failed to proactive send: {}", e.getMessage(), e);
}
}
/**
* 获取钉钉 access_token用于 Robot API
*/
private String getDingTalkAccessToken() {
// 如果有 AI Card Manager复用其 token
if (aiCardManager != null) {
return aiCardManager.ensureAccessToken();
}
String clientId = getConfigString("client_id");
String clientSecret = getConfigString("client_secret");
try {
String jsonBody = objectMapper.writeValueAsString(Map.of(
"appKey", clientId,
"appSecret", clientSecret
));
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.dingtalk.com/v1.0/oauth2/accessToken"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
@SuppressWarnings("unchecked")
Map<String, Object> result = objectMapper.readValue(response.body(), Map.class);
return (String) result.get("accessToken");
} catch (Exception e) {
log.error("[dingtalk] Failed to get access_token: {}", e.getMessage(), e);
return null;
}
}
@Override
public String getChannelType() {
return CHANNEL_TYPE;
}
// ==================== Stream 断线重连 ====================
/**
* Stream 连接断开时由外部调用或内部检测到断开时调用
* <p>
* 触发指数退避重连重新初始化 httpClient AI Card Manager
*/
public void notifyStreamDisconnected(String reason) {
onDisconnected("Stream disconnected: " + reason);
}
@Override
protected void doReconnect() {
log.info("[dingtalk] Reconnecting: {} (mode={})", channelEntity.getName(), getConnectionMode());
// 完整重建doStop() + doStart()默认 AbstractChannelAdapter 行为
super.doReconnect();
}
}

View File

@ -0,0 +1,527 @@
package vip.mate.channel.discord;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.JDABuilder;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.User;
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
import net.dv8tion.jda.api.events.session.ReadyEvent;
import net.dv8tion.jda.api.events.session.SessionDisconnectEvent;
import net.dv8tion.jda.api.events.session.SessionResumeEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
import net.dv8tion.jda.api.requests.GatewayIntent;
import net.dv8tion.jda.api.utils.FileUpload;
import net.dv8tion.jda.api.utils.cache.CacheFlag;
import vip.mate.channel.AbstractChannelAdapter;
import vip.mate.channel.ChannelMessage;
import vip.mate.channel.ChannelMessageRouter;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.workspace.conversation.model.MessageContentPart;
import okhttp3.OkHttpClient;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.net.Proxy;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.ConcurrentLinkedDeque;
/**
* Discord 渠道适配器 基于 JDA Gateway WebSocket
* <p>
* 通过 Discord GatewayWebSocket 长连接接收消息通过 REST API 发送消息
* JDA 内置自动重连机制无需手动管理 WebSocket 生命周期
* <p>
* 配置项configJson
* - bot_token: Discord Bot Token必填
* - accept_bot_messages: 是否接收其他 Bot 消息默认 false
*
* @author MateClaw Team
*/
@Slf4j
public class DiscordChannelAdapter extends AbstractChannelAdapter {
public static final String CHANNEL_TYPE = "discord";
private volatile JDA jda;
private volatile String selfId;
/** 媒体下载用 HttpClient复用 http_proxy 配置) */
private volatile HttpClient mediaHttpClient;
/** 已处理消息去重LRU最多保留 500 条) */
private final Set<String> processedMessageIds = Collections.newSetFromMap(new LinkedHashMap<>() {
@Override
protected boolean removeEldestEntry(Map.Entry<String, Boolean> eldest) {
return size() > 500;
}
});
public DiscordChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter,
ObjectMapper objectMapper) {
super(channelEntity, messageRouter, objectMapper);
}
@Override
protected void doStart() {
String botToken = getConfigString("bot_token");
if (botToken == null || botToken.isBlank()) {
throw new IllegalStateException("Discord channel requires bot_token in configJson");
}
try {
JDABuilder builder = JDABuilder.createDefault(botToken)
.enableIntents(
GatewayIntent.GUILD_MESSAGES,
GatewayIntent.DIRECT_MESSAGES,
GatewayIntent.MESSAGE_CONTENT
)
.disableCache(
CacheFlag.VOICE_STATE,
CacheFlag.EMOJI,
CacheFlag.STICKER,
CacheFlag.SCHEDULED_EVENTS
)
.setAutoReconnect(true)
.addEventListeners(new DiscordEventListener());
// 代理配置统一解析同时应用到 JDAOkHttp和媒体下载HttpClient
Proxy proxy = parseProxy();
if (proxy != null) {
OkHttpClient okHttpClient = new OkHttpClient.Builder().proxy(proxy).build();
builder.setHttpClientBuilder(okHttpClient.newBuilder());
}
HttpClient.Builder mediaClientBuilder = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.followRedirects(HttpClient.Redirect.NORMAL);
if (proxy != null) {
mediaClientBuilder.proxy(java.net.ProxySelector.of(
(InetSocketAddress) proxy.address()));
}
this.mediaHttpClient = mediaClientBuilder.build();
this.jda = builder.build();
// 等待 JDA 就绪最多 30
this.jda.awaitReady();
this.selfId = this.jda.getSelfUser().getId();
log.info("[discord] Discord Gateway connected, bot: {} ({})",
jda.getSelfUser().getName(), selfId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Discord JDA startup interrupted", e);
} catch (Exception e) {
throw new RuntimeException("Discord JDA startup failed: " + e.getMessage(), e);
}
}
@Override
protected void doStop() {
if (jda != null) {
jda.shutdown();
try {
// 等待最多 5 秒优雅关闭
if (!jda.awaitShutdown(java.time.Duration.ofSeconds(5))) {
jda.shutdownNow();
}
} catch (InterruptedException e) {
jda.shutdownNow();
Thread.currentThread().interrupt();
}
jda = null;
}
selfId = null;
mediaHttpClient = null;
processedMessageIds.clear();
log.info("[discord] Discord channel stopped");
}
/**
* configJson.http_proxy 解析代理返回 null 表示直连
*/
private Proxy parseProxy() {
String httpProxy = getConfigString("http_proxy");
if (httpProxy == null || httpProxy.isBlank()) {
return null;
}
try {
URI proxyUri = URI.create(httpProxy);
String proxyHost = proxyUri.getHost();
int proxyPort = proxyUri.getPort();
if (proxyHost != null && proxyPort > 0) {
log.info("[discord] Using HTTP proxy: {}:{}", proxyHost, proxyPort);
return new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyHost, proxyPort));
}
log.warn("[discord] Invalid http_proxy (missing host or port): '{}'", httpProxy);
} catch (Exception e) {
log.warn("[discord] Invalid http_proxy '{}': {}", httpProxy, e.getMessage());
}
return null;
}
// ==================== 消息发送 ====================
@Override
public void sendMessage(String targetId, String content) {
JDA currentJda = this.jda;
if (currentJda == null) {
log.warn("[discord] JDA not ready, cannot send message");
return;
}
try {
MessageChannel channel = currentJda.getChannelById(MessageChannel.class, targetId);
if (channel == null) {
log.warn("[discord] Channel not found: {}", targetId);
return;
}
// 显示输入指示
channel.sendTyping().queue();
channel.sendMessage(content).queue(
success -> log.debug("[discord] Message sent to {}", targetId),
error -> log.warn("[discord] Failed to send message to {}: {}", targetId, error.getMessage())
);
} catch (Exception e) {
log.error("[discord] Failed to send message: {}", e.getMessage(), e);
}
}
@Override
public void sendContentParts(String targetId, List<MessageContentPart> parts) {
JDA currentJda = this.jda;
if (currentJda == null) {
log.warn("[discord] JDA not ready, cannot send message");
return;
}
MessageChannel channel = currentJda.getChannelById(MessageChannel.class, targetId);
if (channel == null) {
log.warn("[discord] Channel not found: {}", targetId);
return;
}
StringBuilder text = new StringBuilder();
List<MediaPart> mediaParts = new ArrayList<>();
for (MessageContentPart part : parts) {
if (part == null) continue;
switch (part.getType()) {
case "text" -> { if (part.getText() != null) text.append(part.getText()); }
case "image", "file", "audio", "video" -> {
String url = part.getFileUrl();
String fileName = part.getFileName();
if (url != null) {
mediaParts.add(new MediaPart(url, fileName, part.getType()));
} else {
text.append("\n[").append(part.getType()).append("]");
}
}
default -> { if (part.getText() != null) text.append(part.getText()); }
}
}
// 先发文本
String content = text.toString().trim();
if (content.length() > 2000) {
renderAndSend(targetId, content);
} else if (!content.isEmpty()) {
sendMessage(targetId, content);
}
// 逐个上传媒体文件作为 Discord attachment
for (MediaPart media : mediaParts) {
sendMediaAttachment(channel, media);
}
}
/**
* 将媒体文件作为 Discord attachment 上传发送
* <p>
* 参考 MateClaw远程 URL 先下载到临时文件再通过 JDA FileUpload 上传
*/
private void sendMediaAttachment(MessageChannel channel, MediaPart media) {
Path tempFile = null;
try {
String url = media.url;
if (url.startsWith("file://")) {
// 本地文件
Path localPath = Path.of(URI.create(url));
String fileName = media.fileName != null ? media.fileName : localPath.getFileName().toString();
channel.sendFiles(FileUpload.fromData(localPath, fileName)).queue(
ok -> log.debug("[discord] Media uploaded: {}", fileName),
err -> {
log.warn("[discord] Failed to upload media: {}", err.getMessage());
sendMediaFallbackText(channel, media);
}
);
return;
}
if (url.startsWith("http://") || url.startsWith("https://")) {
// 远程 URL下载到临时文件后上传
String fileName = media.fileName;
if (fileName == null || fileName.isBlank()) {
String path = URI.create(url).getPath();
fileName = path.contains("/") ? path.substring(path.lastIndexOf('/') + 1) : "file";
if (fileName.isBlank()) fileName = "file";
}
// 推导文件后缀
String suffix = "";
int dotIdx = fileName.lastIndexOf('.');
if (dotIdx >= 0) {
suffix = fileName.substring(dotIdx);
}
tempFile = Files.createTempFile("discord-media-", suffix);
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(30))
.GET()
.build();
HttpResponse<InputStream> resp = mediaHttpClient.send(req, HttpResponse.BodyHandlers.ofInputStream());
if (resp.statusCode() == 200) {
try (InputStream is = resp.body()) {
Files.copy(is, tempFile, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
}
String finalName = fileName;
Path finalTemp = tempFile;
channel.sendFiles(FileUpload.fromData(tempFile, finalName)).queue(
ok -> {
log.debug("[discord] Media uploaded: {}", finalName);
deleteTempQuietly(finalTemp);
},
err -> {
log.warn("[discord] Failed to upload media {}: {}", finalName, err.getMessage());
deleteTempQuietly(finalTemp);
sendMediaFallbackText(channel, media);
}
);
tempFile = null; // 清理交给回调
} else {
log.warn("[discord] Failed to download media (status={}): {}", resp.statusCode(), url);
sendMediaFallbackText(channel, media);
}
return;
}
// 未知协议降级为文本
sendMediaFallbackText(channel, media);
} catch (Exception e) {
log.error("[discord] Failed to send media attachment: {}", e.getMessage(), e);
sendMediaFallbackText(channel, media);
} finally {
if (tempFile != null) {
deleteTempQuietly(tempFile);
}
}
}
/**
* 媒体上传/下载失败时降级发送 URL 文本防止消息静默丢失
*/
private void sendMediaFallbackText(MessageChannel channel, MediaPart media) {
try {
channel.sendMessage("[" + media.type + ": " + media.url + "]").queue();
} catch (Exception e) {
log.warn("[discord] Fallback text also failed: {}", e.getMessage());
}
}
private static void deleteTempQuietly(Path path) {
try {
Files.deleteIfExists(path);
} catch (Exception ignored) {}
}
private record MediaPart(String url, String fileName, String type) {}
// ==================== 主动推送 ====================
@Override
public boolean supportsProactiveSend() {
return true;
}
@Override
public void proactiveSend(String targetId, String content) {
sendMessage(targetId, content);
}
@Override
public String getChannelType() {
return CHANNEL_TYPE;
}
// ==================== Webhook 兼容保留接口不再使用 ====================
/**
* 处理 Discord Webhook 回调已废弃保留兼容性
* <p>
* Discord 已切换为 Gateway WebSocket 模式不再需要 Webhook 回调
* 此方法仅在 webhook 端点被调用时记录警告日志
*/
public void handleWebhook(Map<String, Object> payload) {
log.warn("[discord] Received webhook callback, but Discord is now using Gateway mode. " +
"This webhook endpoint is deprecated.");
}
// ==================== JDA 事件监听器 ====================
private class DiscordEventListener extends ListenerAdapter {
@Override
public void onReady(ReadyEvent event) {
log.info("[discord] Gateway ready, guilds: {}", event.getGuildTotalCount());
connectionState.set(ConnectionState.CONNECTED);
lastError = null;
backoff.reset();
}
@Override
public void onSessionDisconnect(SessionDisconnectEvent event) {
log.warn("[discord] Gateway disconnected (JDA will auto-reconnect)");
connectionState.set(ConnectionState.RECONNECTING);
}
@Override
public void onSessionResume(SessionResumeEvent event) {
log.info("[discord] Gateway session resumed");
connectionState.set(ConnectionState.CONNECTED);
lastError = null;
}
@Override
public void onMessageReceived(MessageReceivedEvent event) {
try {
processIncomingMessage(event);
} catch (Exception e) {
log.error("[discord] Failed to process message: {}", e.getMessage(), e);
}
}
}
private void processIncomingMessage(MessageReceivedEvent event) {
Message message = event.getMessage();
User author = message.getAuthor();
// 忽略自身消息
if (author.getId().equals(selfId)) {
return;
}
// 忽略其他 Bot 消息除非配置允许
if (author.isBot() && !getConfigBoolean("accept_bot_messages", false)) {
return;
}
// 去重
String msgId = message.getId();
synchronized (processedMessageIds) {
if (processedMessageIds.contains(msgId)) {
return;
}
processedMessageIds.add(msgId);
}
String channelId = message.getChannel().getId();
String guildId = message.isFromGuild() ? message.getGuild().getId() : null;
String senderId = author.getId();
String senderName = author.getName();
// 处理消息内容清理 Bot mention
String textContent = message.getContentRaw();
if (selfId != null) {
// 清理 <@botId> <@!botId> mention 标记
textContent = textContent.replaceAll("<@!?" + selfId + ">", "").trim();
}
// 构建 contentParts
List<MessageContentPart> contentParts = new ArrayList<>();
if (!textContent.isBlank()) {
contentParts.add(MessageContentPart.text(textContent));
}
// 解析附件
for (Message.Attachment attachment : message.getAttachments()) {
String url = attachment.getUrl();
String fileName = attachment.getFileName();
String contentType = attachment.getContentType();
long size = attachment.getSize();
MessageContentPart part;
if (attachment.isImage()) {
part = MessageContentPart.image(attachment.getId(), url);
} else if (attachment.isVideo()) {
part = MessageContentPart.video(attachment.getId(), fileName);
part.setFileUrl(url);
} else if (contentType != null && contentType.startsWith("audio/")) {
part = MessageContentPart.audio(attachment.getId(), fileName);
part.setFileUrl(url);
} else {
part = MessageContentPart.file(attachment.getId(), fileName, contentType);
part.setFileUrl(url);
}
part.setFileName(fileName);
if (contentType != null) part.setContentType(contentType);
part.setFileSize(size);
contentParts.add(part);
}
if (contentParts.isEmpty()) {
return;
}
// 文本摘要
String textSummary = textContent.isBlank() ? "" : textContent;
if (textSummary.isBlank() && !message.getAttachments().isEmpty()) {
textSummary = "[附件 x" + message.getAttachments().size() + "]";
}
// 判断是否为 Bot mention群聊中
boolean isBotMentioned = message.getMentions().isMentioned(jda.getSelfUser());
ChannelMessage channelMessage = ChannelMessage.builder()
.messageId(msgId)
.channelType(CHANNEL_TYPE)
.senderId(senderId)
.senderName(senderName)
.chatId(guildId != null ? channelId : null) // 群聊用 channelId私聊为 null
.content(textSummary)
.contentType(contentParts.stream().anyMatch(p -> !"text".equals(p.getType())) ? "mixed" : "text")
.contentParts(contentParts)
.timestamp(LocalDateTime.now())
.replyToken(channelId)
.rawPayload(Map.of(
"message_id", msgId,
"channel_id", channelId,
"guild_id", guildId != null ? guildId : "",
"is_dm", !message.isFromGuild(),
"bot_mentioned", isBotMentioned
))
.build();
onMessage(channelMessage);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,51 @@
package vip.mate.channel.model;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 渠道实体
* 渠道实体支持多种 IM 渠道接入
*
* @author MateClaw Team
*/
@Data
@TableName("mate_channel")
public class ChannelEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 渠道名称 */
private String name;
/** 渠道类型web / dingtalk / feishu / wechat / discord / qq */
private String channelType;
/** 关联的 Agent ID */
private Long agentId;
/** Bot 前缀(触发关键词) */
private String botPrefix;
/** 渠道配置JSON存储 Token/AppId 等) */
@TableField(value = "config_json", updateStrategy = FieldStrategy.ALWAYS)
private String configJson;
/** 是否启用 */
private Boolean enabled;
/** 渠道描述 */
private String description;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
private Integer deleted;
}

View File

@ -0,0 +1,62 @@
package vip.mate.channel.model;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 渠道会话存储实体
* <p>
* 缓存各渠道的会话标识映射用于主动推送场景
* key conversationId dingtalk:sw:xxx
* value 为平台推送所需的标识sessionWebhook / chat_id / channel_id
*
* @author MateClaw Team
*/
@Data
@TableName("mate_channel_session")
public class ChannelSessionEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 会话ID格式{channelType}:{identifier} */
private String conversationId;
/** 渠道类型 */
private String channelType;
/**
* 推送目标标识
* <p>
* 不同渠道含义不同
* - 钉钉sessionWebhook URL userId
* - 飞书chat_idoc_xxx open_idou_xxx
* - Telegramchat_id
* - Discordchannel_id
* - 企业微信userId
*/
private String targetId;
/** 发送者ID */
private String senderId;
/** 发送者名称 */
private String senderName;
/** 关联的渠道配置ID */
private Long channelId;
/** 最后活跃时间 */
private LocalDateTime lastActiveTime;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
private Integer deleted;
}

View File

@ -0,0 +1,21 @@
package vip.mate.channel.notification;
import java.util.List;
import java.util.Map;
/**
* 审批通知数据载体
* <p>
* 统一渠道通知模型 PendingApproval 元数据构建
*/
public record ApprovalNotice(
String pendingId,
String toolName,
String summary,
String argumentsPreview,
String maxSeverity,
List<Map<String, Object>> findings,
String approveCommand,
String denyCommand
) {
}

View File

@ -0,0 +1,140 @@
package vip.mate.channel.notification;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.approval.PendingApproval;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 审批通知服务
* <p>
* 统一构建审批通知内容替代各处硬编码的字符串拼接
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ApprovalNotificationService {
private final ObjectMapper objectMapper;
/**
* PendingApproval 构建通知数据
*/
public ApprovalNotice buildNotice(PendingApproval pending) {
String argsPreview = pending.getToolArguments();
if (argsPreview != null && argsPreview.length() > 300) {
argsPreview = argsPreview.substring(0, 300) + "...";
}
List<Map<String, Object>> findings = parseFindings(pending.getFindingsJson());
// 在审批命令中包含 shortId支持群聊多审批并发场景下精确定位
String shortId = pending.getPendingId().substring(0, Math.min(6, pending.getPendingId().length()));
return new ApprovalNotice(
pending.getPendingId(),
pending.getToolName(),
pending.getSummary(),
argsPreview,
pending.getMaxSeverity(),
findings,
"/approve " + shortId,
"/deny " + shortId
);
}
/**
* 构建 IM 渠道友好的文本通知替代 ChannelMessageRouter.buildApprovalNotice
*/
public String buildApprovalText(PendingApproval pending) {
ApprovalNotice notice = buildNotice(pending);
return buildApprovalText(notice);
}
/**
* ApprovalNotice 构建文本
*/
public String buildApprovalText(ApprovalNotice notice) {
StringBuilder sb = new StringBuilder();
sb.append("🔐 **工具需要审批**\n\n");
sb.append("**工具名称**: ").append(notice.toolName()).append("\n");
// 风险等级
if (notice.maxSeverity() != null) {
sb.append("**风险等级**: ").append(severityLabel(notice.maxSeverity())).append("\n");
}
// 摘要
if (notice.summary() != null && !notice.summary().isEmpty()) {
sb.append("**摘要**: ").append(notice.summary()).append("\n");
}
// 参数预览
if (notice.argumentsPreview() != null && !notice.argumentsPreview().isEmpty()) {
sb.append("**参数**: `").append(notice.argumentsPreview()).append("`\n");
}
// Findings 摘要最多显示 3
if (notice.findings() != null && !notice.findings().isEmpty()) {
sb.append("\n**发现的问题**:\n");
int shown = 0;
for (Map<String, Object> finding : notice.findings()) {
if (shown >= 3) {
sb.append(" ... 还有 ").append(notice.findings().size() - 3).append("\n");
break;
}
String title = String.valueOf(finding.getOrDefault("title", ""));
String severity = String.valueOf(finding.getOrDefault("severity", ""));
sb.append(" • [").append(severity).append("] ").append(title).append("\n");
shown++;
}
}
sb.append("\n输入 `").append(notice.approveCommand()).append("` 批准执行,或 `")
.append(notice.denyCommand()).append("` 拒绝。");
return sb.toString();
}
/**
* 构建 Web SSE 事件数据
*/
public Map<String, Object> buildWebEventData(ApprovalNotice notice) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("pendingId", notice.pendingId());
data.put("toolName", notice.toolName());
data.put("argumentsPreview", notice.argumentsPreview());
data.put("maxSeverity", notice.maxSeverity());
data.put("summary", notice.summary());
data.put("findings", notice.findings());
data.put("approveCommand", notice.approveCommand());
data.put("denyCommand", notice.denyCommand());
return data;
}
private String severityLabel(String severity) {
if (severity == null) return "";
return switch (severity) {
case "CRITICAL" -> "🔴 CRITICAL";
case "HIGH" -> "🟠 HIGH";
case "MEDIUM" -> "🟡 MEDIUM";
case "LOW" -> "🔵 LOW";
case "INFO" -> "⚪ INFO";
default -> severity;
};
}
private List<Map<String, Object>> parseFindings(String findingsJson) {
if (findingsJson == null || findingsJson.isBlank()) return List.of();
try {
return objectMapper.readValue(findingsJson, new TypeReference<>() {});
} catch (Exception e) {
log.warn("[ApprovalNotification] Failed to parse findings: {}", e.getMessage());
return List.of();
}
}
}

View File

@ -0,0 +1,964 @@
package vip.mate.channel.qq;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import vip.mate.channel.AbstractChannelAdapter;
import vip.mate.channel.ChannelMessage;
import vip.mate.channel.ChannelMessageRouter;
import vip.mate.channel.ExponentialBackoff;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.workspace.conversation.model.MessageContentPart;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.WebSocket;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
/**
* QQ 渠道适配器
* <p>
* QQ 渠道实现
* - WebSocket 长连接接收消息事件
* - HTTP API 发送消息C2C / Group / Guild / DM
* - Access Token 自动获取与缓存
* - 心跳保活 + 自动重连RESUME / IDENTIFY
* - 富媒体消息支持图片视频音频文件
* - URL 过滤QQ API 拒绝明文 URL
* <p>
* 配置项configJson
* - app_id: QQ Bot AppID必填
* - client_secret: QQ Bot AppSecret必填
* - markdown_enabled: 是否启用 Markdown 消息格式默认 true
* - max_reconnect_attempts: 最大重连次数默认 100
*
* @author MateClaw Team
*/
@Slf4j
public class QQChannelAdapter extends AbstractChannelAdapter {
public static final String CHANNEL_TYPE = "qq";
// ==================== QQ WebSocket 协议常量 ====================
private static final int OP_DISPATCH = 0;
private static final int OP_HEARTBEAT = 1;
private static final int OP_IDENTIFY = 2;
private static final int OP_RESUME = 6;
private static final int OP_RECONNECT = 7;
private static final int OP_INVALID_SESSION = 9;
private static final int OP_HELLO = 10;
private static final int OP_HEARTBEAT_ACK = 11;
// Intents 位掩码
private static final int INTENT_PUBLIC_GUILD_MESSAGES = 1 << 30;
private static final int INTENT_DIRECT_MESSAGE = 1 << 12;
private static final int INTENT_GROUP_AND_C2C = 1 << 25;
private static final String DEFAULT_API_BASE = "https://api.sgroup.qq.com";
private static final String TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
// 快速断连检测
private static final int QUICK_DISCONNECT_THRESHOLD_SECONDS = 5;
private static final int MAX_QUICK_DISCONNECT_COUNT = 3;
private static final long RATE_LIMIT_DELAY_MS = 60_000;
// URL 匹配模式QQ API 拒绝消息中包含 URL
private static final java.util.regex.Pattern URL_PATTERN =
java.util.regex.Pattern.compile("https?://[^\\s]+|www\\.[^\\s]+", java.util.regex.Pattern.CASE_INSENSITIVE);
private static final java.util.regex.Pattern IMAGE_TAG_PATTERN =
java.util.regex.Pattern.compile("\\[Image: (https?://[^\\]]+)\\]", java.util.regex.Pattern.CASE_INSENSITIVE);
// ==================== 配置 ====================
private String appId;
private String clientSecret;
private boolean markdownEnabled;
// ==================== 运行时状态 ====================
private HttpClient httpClient;
/** Access Token 缓存 */
private volatile String cachedToken;
private volatile Instant tokenExpiry = Instant.EPOCH;
private final Object tokenLock = new Object();
/** WebSocket 状态 */
private volatile String sessionId;
private final AtomicInteger lastSeq = new AtomicInteger(0);
private volatile int reconnectAttempts = 0;
private volatile long lastConnectTime = 0;
private volatile int quickDisconnectCount = 0;
/** 消息序号QQ API 要求递增 msg_seq */
private final AtomicLong msgSeqCounter = new AtomicLong(1);
/** WebSocket 连接线程 */
private Thread wsThread;
private final AtomicBoolean stopRequested = new AtomicBoolean(false);
/** 心跳调度器 */
private ScheduledExecutorService heartbeatScheduler;
private volatile ScheduledFuture<?> heartbeatFuture;
private volatile WebSocket currentWs;
public QQChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter,
ObjectMapper objectMapper) {
super(channelEntity, messageRouter, objectMapper);
this.backoff = new ExponentialBackoff(1000, 60000, 2.0, 100);
}
// ==================== 生命周期 ====================
@Override
protected void doStart() {
this.appId = getConfigString("app_id");
this.clientSecret = getConfigString("client_secret");
if (appId == null || appId.isBlank() || clientSecret == null || clientSecret.isBlank()) {
throw new IllegalStateException("QQ channel requires app_id and client_secret in configJson");
}
this.markdownEnabled = getConfigBoolean("markdown_enabled", true);
int maxAttempts = 100;
try {
String val = getConfigString("max_reconnect_attempts");
if (val != null) maxAttempts = Integer.parseInt(val);
} catch (NumberFormatException ignored) {}
this.backoff = new ExponentialBackoff(1000, 60000, 2.0, maxAttempts);
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
this.stopRequested.set(false);
this.sessionId = null;
this.lastSeq.set(0);
this.reconnectAttempts = 0;
this.quickDisconnectCount = 0;
// 启动 WebSocket 连接线程
wsThread = new Thread(this::runWsForever, "qq-ws-" + channelEntity.getId());
wsThread.setDaemon(true);
wsThread.start();
log.info("[qq] QQ channel initialized (appId={})", appId);
}
@Override
protected void doStop() {
stopRequested.set(true);
// 停止心跳
stopHeartbeat();
// 关闭 WebSocket
if (currentWs != null) {
try {
currentWs.sendClose(WebSocket.NORMAL_CLOSURE, "shutdown");
} catch (Exception e) {
log.debug("[qq] Error closing WebSocket: {}", e.getMessage());
}
currentWs = null;
}
// 中断 WebSocket 线程
if (wsThread != null) {
wsThread.interrupt();
try {
wsThread.join(3000);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
wsThread = null;
}
this.httpClient = null;
log.info("[qq] QQ channel stopped");
}
@Override
protected void doReconnect() {
// WebSocket 线程自带重连逻辑这里只需重启线程
doStop();
doStart();
}
@Override
public String getChannelType() {
return CHANNEL_TYPE;
}
// ==================== Access Token 管理 ====================
/**
* 获取 Access Token带缓存5 分钟刷新缓冲
*/
private String getAccessToken() {
if (cachedToken != null && Instant.now().plusSeconds(300).isBefore(tokenExpiry)) {
return cachedToken;
}
synchronized (tokenLock) {
// 双重检查
if (cachedToken != null && Instant.now().plusSeconds(300).isBefore(tokenExpiry)) {
return cachedToken;
}
return refreshAccessToken();
}
}
private String refreshAccessToken() {
try {
String body = objectMapper.writeValueAsString(Map.of(
"appId", appId,
"clientSecret", clientSecret
));
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(TOKEN_URL))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.timeout(Duration.ofSeconds(10))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new RuntimeException("Token request failed: status=" + response.statusCode());
}
@SuppressWarnings("unchecked")
Map<String, Object> result = objectMapper.readValue(response.body(), Map.class);
String token = (String) result.get("access_token");
Object expiresIn = result.get("expires_in");
if (token == null || token.isBlank()) {
throw new RuntimeException("Empty access_token in response: " + response.body());
}
int ttl = 7200;
if (expiresIn instanceof Number n) {
ttl = n.intValue();
} else if (expiresIn instanceof String s) {
ttl = Integer.parseInt(s);
}
this.cachedToken = token;
this.tokenExpiry = Instant.now().plusSeconds(ttl);
log.debug("[qq] Access token refreshed, expires in {}s", ttl);
return token;
} catch (Exception e) {
log.error("[qq] Failed to refresh access token: {}", e.getMessage());
throw new RuntimeException("Token refresh failed: " + e.getMessage(), e);
}
}
// ==================== WebSocket 连接管理 ====================
/**
* WebSocket 主循环持续连接断开后自动重连
*/
private void runWsForever() {
while (!stopRequested.get() && running.get()) {
// 快速断连检测如果频繁断连加大等待时间
if (quickDisconnectCount >= MAX_QUICK_DISCONNECT_COUNT) {
log.warn("[qq] Too many quick disconnects ({}), waiting {}ms before retry",
quickDisconnectCount, RATE_LIMIT_DELAY_MS);
sleep(RATE_LIMIT_DELAY_MS);
quickDisconnectCount = 0;
}
try {
wsConnectOnce();
} catch (Exception e) {
if (stopRequested.get()) break;
log.warn("[qq] WebSocket connection error: {}", e.getMessage());
}
if (stopRequested.get()) break;
// 计算重连延迟
reconnectAttempts++;
int maxAttempts = backoff.getMaxAttempts();
if (maxAttempts > 0 && reconnectAttempts >= maxAttempts) {
log.error("[qq] Max reconnect attempts ({}) exhausted", maxAttempts);
connectionState.set(ConnectionState.ERROR);
lastError = "Max reconnect attempts exhausted";
break;
}
long delay = Math.min(1000L * Math.min(reconnectAttempts, 60), 60000);
log.info("[qq] Reconnecting in {}ms (attempt #{})", delay, reconnectAttempts);
connectionState.set(ConnectionState.RECONNECTING);
sleep(delay);
}
log.info("[qq] WebSocket loop exited");
}
/**
* 单次 WebSocket 连接
*/
private void wsConnectOnce() throws Exception {
// 1. 获取 Gateway URL
String token = getAccessToken();
String gatewayUrl = fetchGatewayUrl(token);
log.info("[qq] Connecting to gateway: {}", gatewayUrl);
lastConnectTime = System.currentTimeMillis();
// 2. 建立 WebSocket 连接
CompletableFuture<Void> closeFuture = new CompletableFuture<>();
StringBuilder messageBuffer = new StringBuilder();
WebSocket ws = httpClient.newWebSocketBuilder()
.buildAsync(URI.create(gatewayUrl), new WebSocket.Listener() {
@Override
public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {
messageBuffer.append(data);
if (last) {
String fullMessage = messageBuffer.toString();
messageBuffer.setLength(0);
handleWsMessage(fullMessage, webSocket);
}
webSocket.request(1);
return null;
}
@Override
public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
log.info("[qq] WebSocket closed: code={}, reason={}", statusCode, reason);
closeFuture.complete(null);
return null;
}
@Override
public void onError(WebSocket webSocket, Throwable error) {
log.warn("[qq] WebSocket error: {}", error.getMessage());
closeFuture.completeExceptionally(error);
}
}).join();
currentWs = ws;
try {
// 等待连接关闭
closeFuture.get();
} catch (Exception e) {
if (!stopRequested.get()) {
log.warn("[qq] WebSocket closed unexpectedly: {}", e.getMessage());
}
} finally {
stopHeartbeat();
currentWs = null;
// 检测快速断连
long connected = System.currentTimeMillis() - lastConnectTime;
if (connected < QUICK_DISCONNECT_THRESHOLD_SECONDS * 1000L) {
quickDisconnectCount++;
log.warn("[qq] Quick disconnect detected ({}/{})", quickDisconnectCount, MAX_QUICK_DISCONNECT_COUNT);
} else {
quickDisconnectCount = 0;
}
}
}
/**
* 获取 WebSocket Gateway URL
*/
private String fetchGatewayUrl(String token) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(DEFAULT_API_BASE + "/gateway"))
.header("Authorization", "QQBot " + token)
.GET()
.timeout(Duration.ofSeconds(10))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new RuntimeException("Gateway request failed: status=" + response.statusCode() + ", body=" + response.body());
}
@SuppressWarnings("unchecked")
Map<String, Object> result = objectMapper.readValue(response.body(), Map.class);
String url = (String) result.get("url");
if (url == null || url.isBlank()) {
throw new RuntimeException("Empty gateway URL in response");
}
return url;
}
// ==================== WebSocket 消息处理 ====================
@SuppressWarnings("unchecked")
private void handleWsMessage(String message, WebSocket ws) {
try {
Map<String, Object> payload = objectMapper.readValue(message, Map.class);
int op = ((Number) payload.getOrDefault("op", -1)).intValue();
Object data = payload.get("d");
Number seqNum = (Number) payload.get("s");
String eventType = (String) payload.get("t");
// 更新序列号
if (seqNum != null) {
lastSeq.set(seqNum.intValue());
}
switch (op) {
case OP_HELLO -> handleHello((Map<String, Object>) data, ws);
case OP_DISPATCH -> handleDispatch(eventType, (Map<String, Object>) data);
case OP_HEARTBEAT_ACK -> log.trace("[qq] Heartbeat ACK received");
case OP_RECONNECT -> {
log.info("[qq] Server requested reconnect");
ws.sendClose(WebSocket.NORMAL_CLOSURE, "reconnect");
}
case OP_INVALID_SESSION -> {
boolean resumable = data instanceof Boolean b && b;
log.warn("[qq] Invalid session, resumable={}", resumable);
if (!resumable) {
sessionId = null;
lastSeq.set(0);
}
ws.sendClose(WebSocket.NORMAL_CLOSURE, "invalid_session");
}
default -> log.debug("[qq] Unhandled op: {}", op);
}
} catch (Exception e) {
log.error("[qq] Error handling WS message: {}", e.getMessage(), e);
}
}
/**
* 处理 HELLO启动心跳发送 IDENTIFY RESUME
*/
@SuppressWarnings("unchecked")
private void handleHello(Map<String, Object> data, WebSocket ws) {
int heartbeatInterval = ((Number) data.getOrDefault("heartbeat_interval", 45000)).intValue();
log.info("[qq] Received HELLO, heartbeat_interval={}ms", heartbeatInterval);
// 启动心跳
startHeartbeat(ws, heartbeatInterval);
// 发送 IDENTIFY RESUME
if (sessionId != null && lastSeq.get() > 0) {
sendResume(ws);
} else {
sendIdentify(ws);
}
}
/**
* 发送 IDENTIFY
*/
private void sendIdentify(WebSocket ws) {
try {
String token = getAccessToken();
int intents = INTENT_PUBLIC_GUILD_MESSAGES | INTENT_DIRECT_MESSAGE | INTENT_GROUP_AND_C2C;
Map<String, Object> identify = Map.of(
"op", OP_IDENTIFY,
"d", Map.of(
"token", "QQBot " + token,
"intents", intents,
"shard", List.of(0, 1)
)
);
String json = objectMapper.writeValueAsString(identify);
ws.sendText(json, true);
log.info("[qq] IDENTIFY sent (intents={})", intents);
} catch (Exception e) {
log.error("[qq] Failed to send IDENTIFY: {}", e.getMessage(), e);
}
}
/**
* 发送 RESUME断线恢复
*/
private void sendResume(WebSocket ws) {
try {
String token = getAccessToken();
Map<String, Object> resume = Map.of(
"op", OP_RESUME,
"d", Map.of(
"token", "QQBot " + token,
"session_id", sessionId,
"seq", lastSeq.get()
)
);
String json = objectMapper.writeValueAsString(resume);
ws.sendText(json, true);
log.info("[qq] RESUME sent (session={}, seq={})", sessionId, lastSeq.get());
} catch (Exception e) {
log.error("[qq] Failed to send RESUME: {}", e.getMessage(), e);
}
}
/**
* 处理 DISPATCH 事件
*/
@SuppressWarnings("unchecked")
private void handleDispatch(String eventType, Map<String, Object> data) {
if (eventType == null || data == null) return;
switch (eventType) {
case "READY" -> {
sessionId = (String) data.get("session_id");
reconnectAttempts = 0;
quickDisconnectCount = 0;
connectionState.set(ConnectionState.CONNECTED);
lastError = null;
log.info("[qq] READY received, session_id={}", sessionId);
}
case "RESUMED" -> {
reconnectAttempts = 0;
connectionState.set(ConnectionState.CONNECTED);
lastError = null;
log.info("[qq] RESUMED successfully");
}
case "C2C_MESSAGE_CREATE" -> handleMessageEvent("c2c", data);
case "GROUP_AT_MESSAGE_CREATE" -> handleMessageEvent("group", data);
case "AT_MESSAGE_CREATE" -> handleMessageEvent("guild", data);
case "DIRECT_MESSAGE_CREATE" -> handleMessageEvent("dm", data);
default -> log.debug("[qq] Unhandled event: {}", eventType);
}
}
/**
* 处理消息事件C2C / Group / Guild / DM
*/
@SuppressWarnings("unchecked")
private void handleMessageEvent(String messageType, Map<String, Object> data) {
try {
// 提取发送者 ID
String senderId = extractSenderId(messageType, data);
if (senderId == null || senderId.isBlank()) {
log.warn("[qq] Cannot determine sender ID for {}: {}", messageType, data);
return;
}
// 提取消息内容
String content = (String) data.get("content");
if (content != null) {
content = content.trim();
}
// 消息 ID
String messageId = (String) data.get("id");
// 构建 contentParts
List<MessageContentPart> contentParts = new ArrayList<>();
// 文本内容
if (content != null && !content.isBlank()) {
contentParts.add(MessageContentPart.text(content));
}
// 附件图片视频音频文件
List<Map<String, Object>> attachments = (List<Map<String, Object>>) data.get("attachments");
if (attachments != null) {
for (Map<String, Object> att : attachments) {
String attContentType = (String) att.get("content_type");
String url = (String) att.get("url");
String filename = (String) att.get("filename");
if (attContentType == null) attContentType = "";
if (url != null && !url.startsWith("http")) {
url = "https://" + url;
}
if (attContentType.startsWith("image/")) {
contentParts.add(MessageContentPart.image(url, filename));
} else if (attContentType.startsWith("video/")) {
contentParts.add(MessageContentPart.video(url, filename));
} else if (attContentType.startsWith("audio/")) {
contentParts.add(MessageContentPart.audio(url, filename));
} else {
contentParts.add(MessageContentPart.file(url, filename, attContentType));
}
if ((content == null || content.isBlank()) && filename != null) {
content = "[" + (attContentType.startsWith("image/") ? "图片" : "文件") + ": " + filename + "]";
}
}
}
if (contentParts.isEmpty()) {
log.debug("[qq] Empty message, ignoring");
return;
}
// 构建 replyToken格式: messageType:targetId:msgId
String replyToken = buildReplyToken(messageType, senderId, data, messageId);
// chatId/频道消息用群/频道 ID私聊为 null
String chatId = null;
if ("group".equals(messageType)) {
chatId = (String) data.get("group_openid");
} else if ("guild".equals(messageType) || "dm".equals(messageType)) {
chatId = (String) data.get("channel_id");
}
ChannelMessage channelMessage = ChannelMessage.builder()
.messageId(messageId)
.channelType(CHANNEL_TYPE)
.senderId(senderId)
.senderName(extractSenderName(messageType, data))
.chatId(chatId)
.content(content != null ? content : "")
.contentType(determineContentType(contentParts))
.contentParts(contentParts)
.timestamp(LocalDateTime.now())
.replyToken(replyToken)
.rawPayload(data)
.build();
onMessage(channelMessage);
} catch (Exception e) {
log.error("[qq] Failed to handle {} message: {}", messageType, e.getMessage(), e);
}
}
/**
* 提取发送者 ID不同消息类型字段不同
*/
@SuppressWarnings("unchecked")
private String extractSenderId(String messageType, Map<String, Object> data) {
return switch (messageType) {
case "c2c" -> {
// C2C: author.user_openid user_openid
Map<String, Object> author = (Map<String, Object>) data.get("author");
if (author != null && author.get("user_openid") != null) {
yield (String) author.get("user_openid");
}
yield (String) data.get("user_openid");
}
case "group" -> {
// Group: author.member_openid member_openid
Map<String, Object> author = (Map<String, Object>) data.get("author");
if (author != null && author.get("member_openid") != null) {
yield (String) author.get("member_openid");
}
yield (String) data.get("member_openid");
}
case "guild", "dm" -> {
// Guild/DM: author.id
Map<String, Object> author = (Map<String, Object>) data.get("author");
yield author != null ? (String) author.get("id") : null;
}
default -> null;
};
}
/**
* 提取发送者名称
*/
@SuppressWarnings("unchecked")
private String extractSenderName(String messageType, Map<String, Object> data) {
Map<String, Object> author = (Map<String, Object>) data.get("author");
if (author == null) return null;
String username = (String) author.get("username");
return username != null ? username : (String) author.get("nickname");
}
/**
* 构建回复 Token
* <p>
* 格式: messageType:targetId:originalMsgId
* 发送回复时解析此 token 确定目标和回复的消息 ID
*/
private String buildReplyToken(String messageType, String senderId,
Map<String, Object> data, String messageId) {
String targetId;
switch (messageType) {
case "c2c" -> targetId = senderId;
case "group" -> targetId = (String) data.get("group_openid");
case "guild" -> targetId = (String) data.get("channel_id");
case "dm" -> targetId = (String) data.get("guild_id");
default -> targetId = senderId;
}
return messageType + ":" + targetId + ":" + (messageId != null ? messageId : "");
}
private String determineContentType(List<MessageContentPart> parts) {
for (MessageContentPart p : parts) {
if (!"text".equals(p.getType())) return p.getType();
}
return "text";
}
// ==================== 心跳 ====================
private void startHeartbeat(WebSocket ws, int intervalMs) {
stopHeartbeat();
heartbeatScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "qq-heartbeat-" + channelEntity.getId());
t.setDaemon(true);
return t;
});
heartbeatFuture = heartbeatScheduler.scheduleAtFixedRate(() -> {
try {
int seq = lastSeq.get();
String hb = objectMapper.writeValueAsString(Map.of(
"op", OP_HEARTBEAT,
"d", seq > 0 ? seq : null
));
ws.sendText(hb, true);
log.trace("[qq] Heartbeat sent (seq={})", seq);
} catch (Exception e) {
log.warn("[qq] Failed to send heartbeat: {}", e.getMessage());
}
}, intervalMs, intervalMs, TimeUnit.MILLISECONDS);
}
private void stopHeartbeat() {
if (heartbeatFuture != null) {
heartbeatFuture.cancel(false);
heartbeatFuture = null;
}
if (heartbeatScheduler != null && !heartbeatScheduler.isShutdown()) {
heartbeatScheduler.shutdownNow();
heartbeatScheduler = null;
}
}
// ==================== 消息发送 ====================
@Override
public void sendMessage(String targetId, String content) {
if (httpClient == null) {
log.warn("[qq] Channel not started, cannot send message");
return;
}
// 解析 replyToken 格式: messageType:target:originalMsgId
String[] parts = targetId.split(":", 3);
if (parts.length < 2) {
log.warn("[qq] Invalid replyToken format: {}", targetId);
return;
}
String messageType = parts[0];
String target = parts[1];
String originalMsgId = parts.length > 2 ? parts[2] : null;
// 提取图片 URL[Image: URL] 标签
List<String> imageUrls = new ArrayList<>();
var matcher = IMAGE_TAG_PATTERN.matcher(content);
while (matcher.find()) {
imageUrls.add(matcher.group(1));
}
String textContent = IMAGE_TAG_PATTERN.matcher(content).replaceAll("").trim();
// 发送文本
if (!textContent.isBlank()) {
sendTextWithFallback(messageType, target, textContent, originalMsgId);
}
// 发送图片
for (String imageUrl : imageUrls) {
sendImage(messageType, target, imageUrl, originalMsgId);
}
}
/**
* 发送文本消息 Markdown 降级和 URL 过滤回退
*/
private void sendTextWithFallback(String messageType, String target,
String text, String originalMsgId) {
try {
// 尝试 Markdown 或纯文本
if (markdownEnabled && !"guild".equals(messageType) && !"dm".equals(messageType)) {
try {
dispatchText(messageType, target, text, originalMsgId, true);
return;
} catch (Exception e) {
log.debug("[qq] Markdown send failed, falling back to plain text: {}", e.getMessage());
}
}
// 纯文本
try {
dispatchText(messageType, target, text, originalMsgId, false);
} catch (Exception e) {
// URL 过滤后重试
String sanitized = sanitizeQQText(text);
if (!sanitized.equals(text) && !sanitized.isBlank()) {
log.debug("[qq] Retrying with URL-sanitized text");
dispatchText(messageType, target, sanitized, originalMsgId, false);
} else {
throw e;
}
}
} catch (Exception e) {
log.error("[qq] Failed to send text (type={}, target={}): {}", messageType, target, e.getMessage());
}
}
/**
* 根据消息类型分派文本消息到对应 API
*/
private void dispatchText(String messageType, String target, String text,
String originalMsgId, boolean markdown) throws Exception {
String token = getAccessToken();
long seq = msgSeqCounter.getAndIncrement();
Map<String, Object> body = new LinkedHashMap<>();
if (markdown) {
body.put("markdown", Map.of("content", text));
body.put("msg_type", 2);
} else {
body.put("content", text);
body.put("msg_type", 0);
}
body.put("msg_seq", seq);
if (originalMsgId != null && !originalMsgId.isBlank()) {
body.put("msg_id", originalMsgId);
}
String apiUrl = switch (messageType) {
case "c2c" -> DEFAULT_API_BASE + "/v2/users/" + target + "/messages";
case "group" -> DEFAULT_API_BASE + "/v2/groups/" + target + "/messages";
case "guild" -> DEFAULT_API_BASE + "/channels/" + target + "/messages";
case "dm" -> DEFAULT_API_BASE + "/dms/" + target + "/messages";
default -> throw new IllegalArgumentException("Unknown message type: " + messageType);
};
String jsonBody = objectMapper.writeValueAsString(body);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(apiUrl))
.header("Authorization", "QQBot " + token)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.timeout(Duration.ofSeconds(10))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new RuntimeException("Send message failed: status=" + response.statusCode() + ", body=" + response.body());
}
}
/**
* 发送图片通过富媒体上传 API
*/
private void sendImage(String messageType, String target, String imageUrl, String originalMsgId) {
// Guild/DM 不支持富媒体 API跳过
if ("guild".equals(messageType) || "dm".equals(messageType)) {
log.debug("[qq] Rich media not supported for {}, skipping image", messageType);
return;
}
try {
String token = getAccessToken();
long seq = msgSeqCounter.getAndIncrement();
// Step 1: 上传文件获取 file_info
String uploadUrl = switch (messageType) {
case "c2c" -> DEFAULT_API_BASE + "/v2/users/" + target + "/files";
case "group" -> DEFAULT_API_BASE + "/v2/groups/" + target + "/files";
default -> throw new IllegalArgumentException("Unsupported media type: " + messageType);
};
Map<String, Object> uploadBody = Map.of(
"file_type", 1, // 1=图片
"url", imageUrl,
"srv_send_msg", false
);
String uploadJson = objectMapper.writeValueAsString(uploadBody);
HttpRequest uploadRequest = HttpRequest.newBuilder()
.uri(URI.create(uploadUrl))
.header("Authorization", "QQBot " + token)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(uploadJson))
.timeout(Duration.ofSeconds(30))
.build();
HttpResponse<String> uploadResponse = httpClient.send(uploadRequest, HttpResponse.BodyHandlers.ofString());
if (uploadResponse.statusCode() != 200) {
log.warn("[qq] Image upload failed: status={}, body={}", uploadResponse.statusCode(), uploadResponse.body());
return;
}
@SuppressWarnings("unchecked")
Map<String, Object> uploadResult = objectMapper.readValue(uploadResponse.body(), Map.class);
String fileInfo = (String) uploadResult.get("file_info");
if (fileInfo == null || fileInfo.isBlank()) {
log.warn("[qq] No file_info in upload response");
return;
}
// Step 2: 发送富媒体消息
String sendUrl = switch (messageType) {
case "c2c" -> DEFAULT_API_BASE + "/v2/users/" + target + "/messages";
case "group" -> DEFAULT_API_BASE + "/v2/groups/" + target + "/messages";
default -> throw new IllegalArgumentException("Unsupported: " + messageType);
};
Map<String, Object> sendBody = new LinkedHashMap<>();
sendBody.put("msg_type", 7);
sendBody.put("media", Map.of("file_info", fileInfo));
sendBody.put("msg_seq", seq);
if (originalMsgId != null && !originalMsgId.isBlank()) {
sendBody.put("msg_id", originalMsgId);
}
String sendJson = objectMapper.writeValueAsString(sendBody);
HttpRequest sendRequest = HttpRequest.newBuilder()
.uri(URI.create(sendUrl))
.header("Authorization", "QQBot " + token)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(sendJson))
.timeout(Duration.ofSeconds(10))
.build();
HttpResponse<String> sendResponse = httpClient.send(sendRequest, HttpResponse.BodyHandlers.ofString());
if (sendResponse.statusCode() != 200) {
log.warn("[qq] Image send failed: status={}, body={}", sendResponse.statusCode(), sendResponse.body());
}
} catch (Exception e) {
log.error("[qq] Failed to send image: {}", e.getMessage(), e);
}
}
/**
* 过滤 QQ 不允许的 URL
*/
private String sanitizeQQText(String text) {
return URL_PATTERN.matcher(text).replaceAll("[链接已过滤]");
}
// ==================== 主动推送 ====================
@Override
public boolean supportsProactiveSend() {
return true;
}
@Override
public void proactiveSend(String targetId, String content) {
sendMessage(targetId, content);
}
// ==================== 工具方法 ====================
private void sleep(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}

View File

@ -0,0 +1,14 @@
package vip.mate.channel.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import vip.mate.channel.model.ChannelEntity;
/**
* 渠道 Mapper
*
* @author MateClaw Team
*/
@Mapper
public interface ChannelMapper extends BaseMapper<ChannelEntity> {
}

View File

@ -0,0 +1,14 @@
package vip.mate.channel.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import vip.mate.channel.model.ChannelSessionEntity;
/**
* 渠道会话 Mapper
*
* @author MateClaw Team
*/
@Mapper
public interface ChannelSessionMapper extends BaseMapper<ChannelSessionEntity> {
}

View File

@ -0,0 +1,114 @@
package vip.mate.channel.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.channel.repository.ChannelMapper;
import vip.mate.exception.MateClawException;
import java.util.List;
/**
* 渠道业务服务
* <p>
* 负责渠道的 CRUD 管理
* 渠道的运行时生命周期由 ChannelManager 管理
*
* @author MateClaw Team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ChannelService {
private final ChannelMapper channelMapper;
/**
* 获取所有渠道列表
*/
public List<ChannelEntity> listChannels() {
return channelMapper.selectList(new LambdaQueryWrapper<ChannelEntity>()
.orderByDesc(ChannelEntity::getEnabled)
.orderByDesc(ChannelEntity::getCreateTime));
}
/**
* 获取已启用的渠道列表ChannelManager 启动时使用
*/
public List<ChannelEntity> listEnabledChannels() {
return channelMapper.selectList(new LambdaQueryWrapper<ChannelEntity>()
.eq(ChannelEntity::getEnabled, true)
.orderByAsc(ChannelEntity::getChannelType));
}
/**
* 按类型获取渠道列表
*/
public List<ChannelEntity> listChannelsByType(String channelType) {
return channelMapper.selectList(new LambdaQueryWrapper<ChannelEntity>()
.eq(ChannelEntity::getChannelType, channelType)
.orderByDesc(ChannelEntity::getCreateTime));
}
/**
* 获取渠道详情
*/
public ChannelEntity getChannel(Long id) {
ChannelEntity channel = channelMapper.selectById(id);
if (channel == null) {
throw new MateClawException("渠道不存在: " + id);
}
return channel;
}
/**
* 创建渠道
*/
public ChannelEntity createChannel(ChannelEntity channel) {
// 验证名称
if (channel.getName() == null || channel.getName().isBlank()) {
throw new MateClawException("渠道名称不能为空");
}
if (channel.getChannelType() == null || channel.getChannelType().isBlank()) {
throw new MateClawException("渠道类型不能为空");
}
if (channel.getEnabled() == null) {
channel.setEnabled(false);
}
channelMapper.insert(channel);
log.info("Created channel: {} (type={})", channel.getName(), channel.getChannelType());
return channel;
}
/**
* 更新渠道
*/
public ChannelEntity updateChannel(ChannelEntity channel) {
ChannelEntity existing = getChannel(channel.getId());
channelMapper.updateById(channel);
log.info("Updated channel: {}", existing.getName());
return channel;
}
/**
* 删除渠道
*/
public void deleteChannel(Long id) {
ChannelEntity channel = getChannel(id);
channelMapper.deleteById(id);
log.info("Deleted channel: {}", channel.getName());
}
/**
* 启用/禁用渠道
*/
public ChannelEntity toggleChannel(Long id, boolean enabled) {
ChannelEntity channel = getChannel(id);
channel.setEnabled(enabled);
channelMapper.updateById(channel);
log.info("Channel {} {}", channel.getName(), enabled ? "enabled" : "disabled");
return channel;
}
}

View File

@ -0,0 +1,717 @@
package vip.mate.channel.telegram;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import vip.mate.channel.AbstractChannelAdapter;
import vip.mate.channel.ChannelMessage;
import vip.mate.channel.ChannelMessageRouter;
import vip.mate.channel.ExponentialBackoff;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.workspace.conversation.model.MessageContentPart;
import java.net.InetSocketAddress;
import java.net.ProxySelector;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
/**
* Telegram 渠道适配器
* <p>
* 支持两种接入模式
* - Long-Polling默认通过 getUpdates 轮询无需公网 IP适合开发和内网部署
* - Webhook配置 webhook_url 后自动切换需要公网可访问的 URL
* <p>
* 参考 MateClaw 实现增强了
* - 持续 Typing 指示器 4 秒发送一次直到回复完成
* - 指数退避重连2s30s无限重试
* - Markdown 解析失败时自动降级为纯文本
* <p>
* 配置项configJson
* - bot_token: Telegram Bot Token @BotFather 获取必填
* - webhook_url: Webhook 地址可选配置后切换为 Webhook 模式
* - show_typing: 是否显示"正在输入"状态默认 true
* - polling_timeout: Long-Polling 超时秒数默认 20
*
* @author MateClaw Team
*/
@Slf4j
public class TelegramChannelAdapter extends AbstractChannelAdapter {
public static final String CHANNEL_TYPE = "telegram";
private HttpClient httpClient;
private String botToken;
private String apiBaseUrl;
/** Long-Polling 线程 */
private volatile Thread pollingThread;
private volatile boolean polling;
/** getUpdates offset用于确认已处理的 update */
private final AtomicLong updateOffset = new AtomicLong(0);
/** 活跃的 Typing 任务chatId -> ScheduledFuture */
private final ConcurrentHashMap<String, ScheduledFuture<?>> typingTasks = new ConcurrentHashMap<>();
private ScheduledExecutorService typingScheduler;
/** Typing 指示器发送间隔(秒) */
private static final int TYPING_INTERVAL_S = 4;
/** Typing 最大持续时间(秒) */
private static final int TYPING_TIMEOUT_S = 180;
public TelegramChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter,
ObjectMapper objectMapper) {
super(channelEntity, messageRouter, objectMapper);
// Telegram: 2s4s8s16s30s 指数退避无限重试
this.backoff = new ExponentialBackoff(2000, 30000, 2.0, -1);
}
@Override
protected void doStart() {
this.botToken = getConfigString("bot_token");
if (botToken == null || botToken.isBlank()) {
throw new IllegalStateException("Telegram channel requires bot_token in configJson");
}
this.apiBaseUrl = "https://api.telegram.org/bot" + botToken;
HttpClient.Builder clientBuilder = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10));
// 代理配置覆盖所有 Telegram Bot API 请求polling / webhook / send / typing
String httpProxy = getConfigString("http_proxy");
if (httpProxy != null && !httpProxy.isBlank()) {
try {
URI proxyUri = URI.create(httpProxy);
String proxyHost = proxyUri.getHost();
int proxyPort = proxyUri.getPort();
if (proxyHost != null && proxyPort > 0) {
clientBuilder.proxy(ProxySelector.of(new InetSocketAddress(proxyHost, proxyPort)));
log.info("[telegram] Using HTTP proxy: {}:{}", proxyHost, proxyPort);
} else {
log.warn("[telegram] Invalid http_proxy (missing host or port): '{}'", httpProxy);
}
} catch (Exception e) {
log.warn("[telegram] Invalid http_proxy '{}', falling back to direct: {}", httpProxy, e.getMessage());
}
}
this.httpClient = clientBuilder.build();
this.typingScheduler = Executors.newScheduledThreadPool(1, r -> {
Thread t = new Thread(r, "telegram-typing-" + channelEntity.getId());
t.setDaemon(true);
return t;
});
if (resolveWebhookMode()) {
String webhookUrl = getConfigString("webhook_url");
registerWebhook(webhookUrl);
log.info("[telegram] Telegram channel initialized (Webhook mode)");
log.info("[telegram] Webhook URL: {}", webhookUrl);
} else {
// Long-Polling 模式<EFBFBD><EFBFBD>
deleteWebhook(); // 确保清除旧的 webhook
startPolling();
log.info("[telegram] Telegram channel initialized (Long-Polling mode)");
}
}
@Override
protected void doStop() {
stopPolling();
stopAllTyping();
if (typingScheduler != null) {
typingScheduler.shutdownNow();
typingScheduler = null;
}
this.httpClient = null;
this.botToken = null;
this.apiBaseUrl = null;
log.info("[telegram] Telegram channel stopped");
}
/**
* 重连时根据模式执行对应操作
*/
@Override
protected void doReconnect() {
log.info("[telegram] Reconnecting...");
if (resolveWebhookMode()) {
registerWebhookOrThrow(getConfigString("webhook_url"));
log.info("[telegram] Webhook re-registered successfully");
} else {
stopPolling();
startPolling();
log.info("[telegram] Polling restarted");
}
}
/**
* 判断是否使用 Webhook 模式
* <p>
* 兼容旧配置如果 connection_mode 未设置根据 webhook_url 是否存在来推断
* - connection_mode=webhook + webhook_url 非空 Webhook
* - connection_mode=polling Polling
* - connection_mode 缺失 + webhook_url 非空 Webhook兼容旧配置
* - 其余 Polling
*/
private boolean resolveWebhookMode() {
String connectionMode = getConfigString("connection_mode");
String webhookUrl = getConfigString("webhook_url");
boolean hasWebhookUrl = webhookUrl != null && !webhookUrl.isBlank();
if (connectionMode != null) {
// 显式指定了 connection_mode按其值决定
return "webhook".equals(connectionMode) && hasWebhookUrl;
}
// 未设置 connection_mode旧配置 webhook_url 则走 Webhook否则 Polling
return hasWebhookUrl;
}
// ==================== Long-Polling ====================
private void startPolling() {
this.polling = true;
this.pollingThread = new Thread(this::pollingLoop, "telegram-polling-" + channelEntity.getId());
this.pollingThread.setDaemon(true);
this.pollingThread.start();
}
private void stopPolling() {
this.polling = false;
if (pollingThread != null) {
pollingThread.interrupt();
pollingThread = null;
}
}
/**
* Long-Polling 主循环
* <p>
* 参考 MateClaw _polling_cycle
* - 使用 long polltimeout=20sTelegram 服务器在有新消息时立即返回
* - 失败时通过 AbstractChannelAdapter 的指数退避重连
*/
@SuppressWarnings("unchecked")
private void pollingLoop() {
int pollingTimeout = 20;
try {
pollingTimeout = Integer.parseInt(getConfigString("polling_timeout", "20"));
} catch (NumberFormatException ignored) {}
log.info("[telegram] Polling loop started (timeout={}s)", pollingTimeout);
while (polling && running.get()) {
try {
Map<String, Object> params = new java.util.LinkedHashMap<>();
params.put("timeout", pollingTimeout);
params.put("allowed_updates", List.of("message", "edited_message"));
long offset = updateOffset.get();
if (offset > 0) {
params.put("offset", offset);
}
String jsonBody = objectMapper.writeValueAsString(params);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(apiBaseUrl + "/getUpdates"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
// 请求超时 = polling 超时 + 10s 网络余量
.timeout(Duration.ofSeconds(pollingTimeout + 10))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 401) {
log.error("[telegram] Invalid bot token (401 Unauthorized), stopping polling");
polling = false;
connectionState.set(ConnectionState.ERROR);
lastError = "Invalid bot token";
return;
}
if (response.statusCode() != 200) {
throw new RuntimeException("getUpdates failed: status=" + response.statusCode());
}
Map<String, Object> result = objectMapper.readValue(response.body(), Map.class);
if (!Boolean.TRUE.equals(result.get("ok"))) {
throw new RuntimeException("getUpdates returned ok=false: " + result.get("description"));
}
// 连接正常
if (connectionState.get() != ConnectionState.CONNECTED) {
connectionState.set(ConnectionState.CONNECTED);
lastError = null;
backoff.reset();
}
List<Map<String, Object>> updates = (List<Map<String, Object>>) result.get("result");
if (updates != null && !updates.isEmpty()) {
for (Map<String, Object> update : updates) {
Number updateId = (Number) update.get("update_id");
if (updateId != null) {
updateOffset.set(updateId.longValue() + 1);
}
processUpdate(update);
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.debug("[telegram] Polling interrupted");
break;
} catch (Exception e) {
if (!polling || !running.get()) break;
log.warn("[telegram] Polling error: {}", e.getMessage());
onDisconnected("Polling error: " + e.getMessage());
// 退避等待后重试
try {
long delay = backoff.nextDelayMs();
log.info("[telegram] Retrying in {}ms", delay);
Thread.sleep(delay);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
break;
}
}
}
log.info("[telegram] Polling loop ended");
}
// ==================== Webhook ====================
/**
* 注册 Webhook失败时触发重连
*/
private void registerWebhook(String webhookUrl) {
try {
registerWebhookOrThrow(webhookUrl);
log.info("[telegram] Webhook registered: {}", webhookUrl);
} catch (Exception e) {
log.error("[telegram] Webhook registration failed: {}", e.getMessage());
onDisconnected("Webhook registration failed: " + e.getMessage());
}
}
private void registerWebhookOrThrow(String webhookUrl) {
try {
String jsonBody = objectMapper.writeValueAsString(Map.of("url", webhookUrl));
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(apiBaseUrl + "/setWebhook"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new RuntimeException("setWebhook failed: status=" + response.statusCode() + ", body=" + response.body());
}
@SuppressWarnings("unchecked")
Map<String, Object> result = objectMapper.readValue(response.body(), Map.class);
if (!Boolean.TRUE.equals(result.get("ok"))) {
throw new RuntimeException("setWebhook returned ok=false: " + result.get("description"));
}
} catch (Exception e) {
throw new RuntimeException("Webhook registration failed: " + e.getMessage(), e);
}
}
/**
* 删除 Webhook切换到 Long-Polling 前必须调用
*/
private void deleteWebhook() {
try {
String jsonBody = objectMapper.writeValueAsString(Map.of("drop_pending_updates", false));
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(apiBaseUrl + "/deleteWebhook"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
log.debug("[telegram] Webhook deleted (switching to Long-Polling)");
}
} catch (Exception e) {
log.debug("[telegram] Failed to delete webhook (may not exist): {}", e.getMessage());
}
}
// ==================== 消息处理 ====================
/**
* 处理 Telegram Webhook 回调Webhook 模式使用
*/
public void handleWebhook(Map<String, Object> payload) {
try {
processUpdate(payload);
} catch (Exception e) {
log.error("[telegram] Failed to handle webhook: {}", e.getMessage(), e);
}
}
/**
* 处理单个 UpdateLong-Polling Webhook 共用
*/
@SuppressWarnings("unchecked")
private void processUpdate(Map<String, Object> update) {
Map<String, Object> message = (Map<String, Object>) update.get("message");
if (message == null) {
// 也尝试处理 edited_message
message = (Map<String, Object>) update.get("edited_message");
}
if (message == null) {
log.debug("[telegram] No message in update, ignoring");
return;
}
// 发送者
Map<String, Object> from = (Map<String, Object>) message.get("from");
String senderId = from != null ? String.valueOf(from.get("id")) : "unknown";
String senderName = from != null ? (String) from.get("first_name") : null;
// 会话
Map<String, Object> chat = (Map<String, Object>) message.get("chat");
String chatId = chat != null ? String.valueOf(chat.get("id")) : senderId;
String chatType = chat != null ? (String) chat.get("type") : "private";
Integer messageId = (Integer) message.get("message_id");
// 构建 contentParts
List<MessageContentPart> contentParts = new ArrayList<>();
String textContent = (String) message.get("text");
String caption = (String) message.get("caption");
if (textContent != null && !textContent.isBlank()) {
contentParts.add(MessageContentPart.text(textContent));
}
// 图片photo 是尺寸数组取最大尺寸最后一个
List<Map<String, Object>> photos = (List<Map<String, Object>>) message.get("photo");
if (photos != null && !photos.isEmpty()) {
Map<String, Object> bestPhoto = photos.get(photos.size() - 1);
String fileId = (String) bestPhoto.get("file_id");
if (fileId != null) {
contentParts.add(MessageContentPart.image(fileId, null));
}
if (textContent == null) textContent = caption != null ? caption : "[图片]";
}
// 文件
Map<String, Object> document = (Map<String, Object>) message.get("document");
if (document != null) {
String fileId = (String) document.get("file_id");
String fileName = (String) document.get("file_name");
String mimeType = (String) document.get("mime_type");
if (fileId != null) {
contentParts.add(MessageContentPart.file(fileId, fileName, mimeType));
}
if (textContent == null) textContent = caption != null ? caption : "[文件: " + (fileName != null ? fileName : "") + "]";
}
// 语音
Map<String, Object> voice = (Map<String, Object>) message.get("voice");
if (voice != null) {
String fileId = (String) voice.get("file_id");
if (fileId != null) {
contentParts.add(MessageContentPart.audio(fileId, "voice.ogg"));
}
if (textContent == null) textContent = "[语音]";
}
// 视频
Map<String, Object> video = (Map<String, Object>) message.get("video");
if (video != null) {
String fileId = (String) video.get("file_id");
String fileName = (String) video.get("file_name");
if (fileId != null) {
contentParts.add(MessageContentPart.video(fileId, fileName));
}
if (textContent == null) textContent = caption != null ? caption : "[视频]";
}
// caption 作为文本内容补充
if (caption != null && !caption.isBlank() && message.get("text") == null) {
contentParts.add(0, MessageContentPart.text(caption));
}
if (contentParts.isEmpty()) {
return;
}
ChannelMessage channelMessage = ChannelMessage.builder()
.messageId(messageId != null ? String.valueOf(messageId) : null)
.channelType(CHANNEL_TYPE)
.senderId(senderId)
.senderName(senderName)
.chatId("private".equals(chatType) ? null : chatId)
.content(textContent != null ? textContent : "")
.contentType(determineContentType(contentParts))
.contentParts(contentParts)
.timestamp(LocalDateTime.now())
.replyToken(chatId)
.rawPayload(update)
.build();
onMessage(channelMessage);
}
private String determineContentType(List<MessageContentPart> parts) {
for (MessageContentPart p : parts) {
if (!"text".equals(p.getType())) return p.getType();
}
return "text";
}
// ==================== 消息发送 ====================
@Override
public void sendMessage(String targetId, String content) {
if (httpClient == null || botToken == null) {
log.warn("[telegram] Channel not started, cannot send message");
return;
}
// 启动持续 Typing 指示
if (getConfigBoolean("show_typing", true)) {
startTyping(targetId);
}
try {
// 先尝试 Markdown 格式发送
boolean sent = trySendText(targetId, content, "Markdown");
if (!sent) {
// Markdown 解析失败降级为纯文本
log.debug("[telegram] Markdown failed, retrying as plain text");
trySendText(targetId, content, null);
}
} finally {
stopTyping(targetId);
}
}
/**
* 尝试发送文本消息
*
* @return true 如果发送成功或遇到非 parse_mode 相关的错误不应重试
*/
@SuppressWarnings("unchecked")
private boolean trySendText(String targetId, String content, String parseMode) {
try {
Map<String, Object> body = new java.util.LinkedHashMap<>();
body.put("chat_id", targetId);
body.put("text", content);
if (parseMode != null) {
body.put("parse_mode", parseMode);
}
String jsonBody = objectMapper.writeValueAsString(body);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(apiBaseUrl + "/sendMessage"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
return true;
}
// 仅当 400 + parse_mode description 明确指向解析错误时才降级重试
if (response.statusCode() == 400 && parseMode != null) {
boolean isParseError = false;
try {
Map<String, Object> errResult = objectMapper.readValue(response.body(), Map.class);
String desc = String.valueOf(errResult.getOrDefault("description", ""));
// Telegram 返回类似 "Bad Request: can't parse entities" "can't parse message text"
isParseError = desc.contains("can't parse");
} catch (Exception ignored) {}
if (isParseError) {
log.debug("[telegram] Markdown parse error, will retry as plain text: {}", response.body());
return false;
}
}
log.warn("[telegram] Send message failed: status={}, body={}", response.statusCode(), response.body());
return true; // 非解析错误不再重试
} catch (Exception e) {
log.error("[telegram] Failed to send message: {}", e.getMessage(), e);
return true; // 网络错误不再重试
}
}
@Override
public void sendContentParts(String targetId, List<MessageContentPart> parts) {
if (httpClient == null || botToken == null) {
log.warn("[telegram] Channel not started, cannot send message");
return;
}
if (getConfigBoolean("show_typing", true)) {
startTyping(targetId);
}
try {
for (MessageContentPart part : parts) {
if (part == null) continue;
try {
switch (part.getType()) {
case "text" -> {
if (part.getText() != null && !part.getText().isBlank()) {
sendMessage(targetId, part.getText());
}
}
case "image" -> {
if (part.getMediaId() != null) {
sendTelegramMedia(targetId, "sendPhoto", "photo", part.getMediaId());
} else if (part.getFileUrl() != null) {
sendTelegramMedia(targetId, "sendPhoto", "photo", part.getFileUrl());
}
}
case "file" -> {
if (part.getMediaId() != null) {
sendTelegramMedia(targetId, "sendDocument", "document", part.getMediaId());
}
}
case "audio" -> {
if (part.getMediaId() != null) {
sendTelegramMedia(targetId, "sendVoice", "voice", part.getMediaId());
}
}
case "video" -> {
if (part.getMediaId() != null) {
sendTelegramMedia(targetId, "sendVideo", "video", part.getMediaId());
}
}
default -> {
if (part.getText() != null) sendMessage(targetId, part.getText());
}
}
} catch (Exception e) {
log.error("[telegram] Failed to send content part ({}): {}", part.getType(), e.getMessage());
}
}
} finally {
stopTyping(targetId);
}
}
/**
* 通过 Telegram Bot API 发送媒体消息
*/
private void sendTelegramMedia(String chatId, String method, String mediaField, String mediaValue) {
try {
String jsonBody = objectMapper.writeValueAsString(Map.of(
"chat_id", chatId,
mediaField, mediaValue
));
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(apiBaseUrl + "/" + method))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
log.warn("[telegram] {} failed: status={}, body={}", method, response.statusCode(), response.body());
}
} catch (Exception e) {
log.error("[telegram] Failed to {}: {}", method, e.getMessage(), e);
}
}
// ==================== Typing 指示器 ====================
/**
* 启动持续 Typing 指示 4 秒发送一次最长 180
* <p>
* 参考 MateClaw _typing_loop 实现
* Telegram typing 状态持续约 5 所以每 4 秒重发一次
*/
private void startTyping(String chatId) {
if (typingScheduler == null || typingScheduler.isShutdown()) return;
// 先取消已存在的同一 chatId typing 任务
stopTyping(chatId);
// 立即发送一次
sendTypingAction(chatId);
// 4 秒重发
long startTime = System.currentTimeMillis();
ScheduledFuture<?> future = typingScheduler.scheduleAtFixedRate(() -> {
if (System.currentTimeMillis() - startTime > TYPING_TIMEOUT_S * 1000L) {
stopTyping(chatId);
return;
}
sendTypingAction(chatId);
}, TYPING_INTERVAL_S, TYPING_INTERVAL_S, TimeUnit.SECONDS);
typingTasks.put(chatId, future);
}
/**
* 停止 Typing 指示
*/
private void stopTyping(String chatId) {
ScheduledFuture<?> future = typingTasks.remove(chatId);
if (future != null) {
future.cancel(false);
}
}
private void stopAllTyping() {
typingTasks.forEach((id, future) -> future.cancel(false));
typingTasks.clear();
}
private void sendTypingAction(String chatId) {
try {
String jsonBody = objectMapper.writeValueAsString(Map.of(
"chat_id", chatId,
"action", "typing"
));
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(apiBaseUrl + "/sendChatAction"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString());
} catch (Exception e) {
log.debug("[telegram] Failed to send typing action: {}", e.getMessage());
}
}
// ==================== 主动推送 ====================
@Override
public boolean supportsProactiveSend() {
return true;
}
@Override
public void proactiveSend(String targetId, String content) {
sendMessage(targetId, content);
}
@Override
public String getChannelType() {
return CHANNEL_TYPE;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,742 @@
package vip.mate.channel.web;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import reactor.core.Disposable;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* 聊天流状态追踪器
* <p>
* 采用生产者-消费者解耦设计 SSE 事件的生产Flux 订阅与消费SseEmitter 连接解耦
* 一个后台 Flux 生产者持续产出事件广播给所有 SseEmitter 订阅者并缓存到 buffer
* 新连接重连到来时先回放 buffer再接入实时流
*
* @author MateClaw Team
*/
@Slf4j
@Component
public class ChatStreamTracker {
/** buffer 最大事件数,超出后丢弃最早的 thinking_delta 事件以释放空间 */
private static final int MAX_BUFFER_SIZE = 8000;
private final ObjectMapper objectMapper;
public ChatStreamTracker(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
record SseEvent(String name, String json) {}
/**
* 中断类型区分用户主动停止和用户在运行中追加新消息
*/
public enum InterruptType {
/** 用户点击 Stop终止当前 turn不自动续跑 */
USER_STOP,
/** 用户在执行中追加新消息,中断当前 turn 后自动续跑排队消息 */
USER_INTERRUPT_WITH_FOLLOWUP
}
static final class RunState {
final String conversationId;
final List<SseEmitter> subscribers = new ArrayList<>();
final List<SseEvent> buffer = new ArrayList<>();
final Object lock = new Object();
volatile boolean done;
/** Flux 订阅的 Disposable用于取消 LLM 流 */
volatile Disposable disposable;
/** 停止标志requestStop() 设为 true各图节点和 LLM 调用检查此标志以提前退出 */
final AtomicBoolean stopRequested = new AtomicBoolean(false);
/**
* 当前活跃的 Flux 数量原始流 + 审批 Replay 流共享同一个 RunState
* complete() 仅在计数归零时才真正移除 RunState防止 Replay 仍在运行时被原始流的完成误删
*/
volatile int activeFluxCount = 0;
// ===== Interrupt + Queue 新增字段 =====
/** 中断类型null 表示未请求中断) */
volatile InterruptType interruptType;
/** 当前执行阶段(用于 heartbeat 和前端状态展示) */
volatile String currentPhase = "thinking";
/** 当前正在执行的工具名称 */
volatile String runningToolName;
/** 等待原因(审批等待时有值) */
volatile String waitingReason;
/** 排队的用户消息队列(支持多条排队消息,按序消费) */
final java.util.Queue<QueuedInput> messageQueue = new java.util.concurrent.ConcurrentLinkedQueue<>();
/** 心跳定时器 */
volatile ScheduledFuture<?> heartbeatFuture;
/** 已广播的 pending approval ID 集合(用于幂等去重) */
final java.util.Set<String> broadcastedApprovalIds = java.util.concurrent.ConcurrentHashMap.newKeySet();
RunState(String conversationId) {
this.conversationId = conversationId;
}
}
private final ConcurrentHashMap<String, RunState> runs = new ConcurrentHashMap<>();
/** 心跳调度线程池(守护线程) */
private final ScheduledExecutorService heartbeatScheduler =
Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "stream-heartbeat");
t.setDaemon(true);
return t;
});
/**
* 注册流状态开始生成时调用
* 幂等如果已存在活跃的 RunStateReplay 与原始流共享场景复用它而非覆盖
*/
public void register(String conversationId) {
runs.computeIfAbsent(conversationId, RunState::new);
// 如果已存在但 done=true上一轮残留替换为新的
RunState state = runs.get(conversationId);
if (state != null && state.done) {
stopHeartbeat(conversationId);
runs.put(conversationId, new RunState(conversationId));
}
startHeartbeat(conversationId);
log.debug("Stream registered: {}", conversationId);
}
/**
* 设置 Flux 订阅的 Disposable流开始后立即调用
*/
public void setDisposable(String conversationId, Disposable disposable) {
RunState state = runs.get(conversationId);
if (state != null) {
state.disposable = disposable;
}
}
/**
* 请求停止指定会话的流
* 取消 Flux 订阅底层 HTTP 连接也会随之关闭返回 true 表示确实停止了正在运行的流
*/
public boolean requestStop(String conversationId) {
RunState state = runs.get(conversationId);
if (state == null || state.done) {
return false;
}
// 设置停止标志图节点和 LLM 调用会检查此标志以提前退出
boolean firstRequest = !state.stopRequested.getAndSet(true);
Disposable d = state.disposable;
if (d != null && !d.isDisposed()) {
d.dispose();
log.info("Stream stopped via requestStop: {}", conversationId);
return true;
}
return firstRequest;
}
/**
* 检查指定会话是否已被请求停止
* 图节点在每次迭代入口处调用此方法若返回 true 则抛出 CancellationException 中断执行
*/
public boolean isStopRequested(String conversationId) {
RunState state = runs.get(conversationId);
return state != null && state.stopRequested.get();
}
/**
* 广播事件到所有订阅者并缓存到 buffer
* 注意"done" 事件即使在流已完成状态下也会被发送确保客户端能收到完成信号
*/
public void broadcast(String conversationId, String eventName, String jsonData) {
RunState state = runs.get(conversationId);
// 特殊处理 "done" 事件即使流已完成仍然尝试发送给所有订阅者
if ("done".equals(eventName)) {
if (state != null) {
synchronized (state.lock) {
Iterator<SseEmitter> it = state.subscribers.iterator();
while (it.hasNext()) {
SseEmitter emitter = it.next();
try {
emitter.send(SseEmitter.event().name(eventName).data(jsonData));
log.debug("Sent final 'done' event to subscriber for {}", conversationId);
} catch (IOException | IllegalStateException e) {
log.debug("Removing dead subscriber for {} while sending done event: {}", conversationId, e.getMessage());
it.remove();
}
}
}
}
return;
}
// 普通事件检查流状态
if (state == null || state.done) {
return;
}
SseEvent event = new SseEvent(eventName, jsonData);
synchronized (state.lock) {
state.buffer.add(event);
// buffer 容量保护超出上限时优先丢弃 thinking_delta占比最大且非关键
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().name(eventName).data(jsonData));
} catch (IOException | IllegalStateException e) {
log.debug("Removing dead subscriber for {}: {}", conversationId, e.getMessage());
it.remove();
}
}
}
}
/**
* 直推事件Object 自动序列化为 JSON
* <p>
* 用于在 Node 内部直接向前端推送 SSE 事件绕过 NodeOutput 管道
* 典型场景审批请求在 awaitDecision() 阻塞前必须先送达前端
*
* @param conversationId 会话 ID
* @param eventName SSE 事件名称 tool_approval_requested
* @param data 事件载荷将被 Jackson 序列化为 JSON
*/
public void broadcastObject(String conversationId, String eventName, Object data) {
String json;
try {
json = objectMapper.writeValueAsString(data);
} catch (Exception e) {
log.warn("Failed to serialize broadcast data for event {}: {}", eventName, e.getMessage());
json = "{\"error\":\"serialization_failed\"}";
}
broadcast(conversationId, eventName, json);
}
/**
* emitter 附着到现有的运行中的流
* 先回放 buffer 中的全部事件再加入订阅者列表接收后续实时事件
*
* @return true 如果成功附着流正在运行false 如果没有活跃的流
*/
public boolean attach(String conversationId, SseEmitter emitter) {
RunState state = runs.get(conversationId);
if (state == null || state.done) {
return false;
}
synchronized (state.lock) {
if (state.done) {
return false;
}
// 回放全部缓冲事件
for (SseEvent event : state.buffer) {
try {
emitter.send(SseEmitter.event().name(event.name()).data(event.json()));
} catch (IOException | IllegalStateException e) {
log.warn("Failed to replay buffer to reconnecting client for {}: {}",
conversationId, e.getMessage());
return false;
}
}
state.subscribers.add(emitter);
}
log.debug("Emitter attached to stream: {} (subscribers={})",
conversationId, state.subscribers.size());
return true;
}
/**
* 递增活跃 Flux 计数每个 Flux 订阅开始时调用
* 原始流和审批 Replay 流共享同一个 RunState通过计数协调生命周期
*/
public void incrementFlux(String conversationId) {
RunState state = runs.get(conversationId);
if (state != null) {
synchronized (state.lock) {
state.activeFluxCount++;
log.debug("Flux count incremented: {} (count={})", conversationId, state.activeFluxCount);
}
}
}
/**
* 完成结果包含是否全部完成排队消息快照
*/
public record CompletionResult(boolean allDone, QueuedInput queuedInput) {}
/**
* 标记一个 Flux 完成仅在所有 Flux 都完成时才真正移除 RunState
* <p>
* 这解决了"原始流完成关闭 SSE但 Replay 流仍在运行"的竞态问题
* <p>
* <b>无副作用</b>不消费排队消息适用于不关心 queue 的路径approval denysetup error
* 需要链式续跑的路径应使用 {@link #completeAndConsumeIfLast(String)}
*
* @return true 如果这是最后一个 FluxRunState 已被移除false 如果仍有活跃 Flux
*/
public boolean complete(String conversationId) {
RunState state = runs.get(conversationId);
if (state == null) {
return true;
}
synchronized (state.lock) {
state.activeFluxCount = Math.max(0, state.activeFluxCount - 1);
if (state.activeFluxCount > 0) {
log.debug("Stream partially completed (no queue drain): {} (remaining flux={})",
conversationId, state.activeFluxCount);
return false;
}
}
// 所有 Flux 都已完成停止心跳并移除 RunState不消费 queue
stopHeartbeat(conversationId);
runs.remove(conversationId);
state.done = true;
log.debug("Stream fully completed (no queue drain): {}", conversationId);
return true;
}
/**
* 原子地递减 activeFluxCount仅在最后一个 Flux 完成时消费排队消息并移除 RunState
* <p>
* 递减计数 消费 queue 删除 RunState三步收口到同一个临界区
* 避免非最后一个 flux 提前 consume 导致 queue 丢失也避免 complete 后查不到 queue
*
* @return CompletionResult(allDone, queuedInput)
*/
public CompletionResult completeAndConsumeIfLast(String conversationId) {
RunState state = runs.get(conversationId);
if (state == null) {
return new CompletionResult(true, null);
}
QueuedInput consumed = null;
synchronized (state.lock) {
state.activeFluxCount = Math.max(0, state.activeFluxCount - 1);
if (state.activeFluxCount > 0) {
log.debug("Stream partially completed: {} (remaining flux={}, queuePreserved={})",
conversationId, state.activeFluxCount, !state.messageQueue.isEmpty());
return new CompletionResult(false, null);
}
// 最后一个 Flux在同一个锁内消费排队消息取队首
consumed = state.messageQueue.poll();
}
// 锁外停止心跳并移除 RunState
stopHeartbeat(conversationId);
runs.remove(conversationId);
state.done = true;
log.debug("Stream fully completed: {} (hasQueuedSnapshot={})", conversationId, consumed != null);
return new CompletionResult(true, consumed);
}
/**
* 检查指定会话是否有正在运行的流
*/
public boolean isRunning(String conversationId) {
RunState state = runs.get(conversationId);
return state != null && !state.done;
}
/**
* 从订阅者列表中移除指定 emitter连接断开/超时时调用
*/
public void detach(String conversationId, SseEmitter emitter) {
RunState state = runs.get(conversationId);
if (state == null) {
return;
}
synchronized (state.lock) {
state.subscribers.remove(emitter);
}
log.debug("Emitter detached from stream: {} (remaining={})",
conversationId, state.subscribers.size());
}
// ===== Heartbeat =====
/** 心跳间隔(秒) */
private static final int HEARTBEAT_INTERVAL_SEC = 10;
/**
* 启动心跳定时器在流注册后调用定期向前端发送 heartbeat 事件
* 防止 useStream 60 秒无数据 timeout 误杀等待审批/长工具的流
*/
public void startHeartbeat(String conversationId) {
RunState state = runs.get(conversationId);
if (state == null) return;
// 避免重复启动
if (state.heartbeatFuture != null && !state.heartbeatFuture.isDone()) return;
state.heartbeatFuture = heartbeatScheduler.scheduleAtFixedRate(() -> {
try {
RunState s = runs.get(conversationId);
if (s == null || s.done) {
stopHeartbeat(conversationId);
return;
}
String json;
try {
json = objectMapper.writeValueAsString(Map.of(
"conversationId", conversationId,
"currentPhase", safe(s.currentPhase),
"waitingReason", safe(s.waitingReason),
"runningToolName", safe(s.runningToolName),
"queueLength", s.messageQueue.size(),
"timestamp", System.currentTimeMillis()
));
} catch (Exception e) {
json = "{\"conversationId\":\"" + conversationId + "\"}";
}
broadcast(conversationId, "heartbeat", json);
} catch (Exception e) {
log.debug("Heartbeat error for {}: {}", conversationId, e.getMessage());
}
}, HEARTBEAT_INTERVAL_SEC, HEARTBEAT_INTERVAL_SEC, TimeUnit.SECONDS);
}
/**
* 停止心跳定时器
*/
public void stopHeartbeat(String conversationId) {
RunState state = runs.get(conversationId);
if (state != null && state.heartbeatFuture != null) {
state.heartbeatFuture.cancel(false);
state.heartbeatFuture = null;
}
}
// ===== Phase tracking =====
/**
* 更新当前执行阶段用于 heartbeat 和前端状态展示
*/
public void updatePhase(String conversationId, String phase) {
RunState state = runs.get(conversationId);
if (state != null) {
state.currentPhase = phase;
}
}
/**
* 更新当前正在执行的工具名称
*/
public void updateRunningTool(String conversationId, String toolName) {
RunState state = runs.get(conversationId);
if (state != null) {
state.runningToolName = toolName;
}
}
/**
* 设置等待原因
*/
public void setWaitingReason(String conversationId, String reason) {
RunState state = runs.get(conversationId);
if (state != null) {
state.waitingReason = reason;
}
}
// ===== Interrupt with follow-up =====
/**
* 请求中断当前流并排队一条用户消息
* requestStop 的区别中断后自动续跑排队消息而非停在原地
*
* @return true 如果成功请求了中断
*/
public boolean requestInterrupt(String conversationId, String queuedMessage, Long agentId, boolean persisted) {
RunState state = runs.get(conversationId);
if (state == null || state.done) {
return false;
}
// 在锁内完成入队和 Disposable 可用性判断锁外执行 dispose/broadcast
Disposable toDispose = null;
boolean canInterrupt;
synchronized (state.lock) {
Disposable d = state.disposable;
canInterrupt = d != null && !d.isDisposed();
// 无论是否可中断都入队支持多条排队消息
state.messageQueue.offer(new QueuedInput(queuedMessage, agentId, persisted));
if (canInterrupt) {
state.interruptType = InterruptType.USER_INTERRUPT_WITH_FOLLOWUP;
state.stopRequested.set(true);
toDispose = d;
}
// 不可中断时不设 interruptType / stopRequested
}
// 锁外执行 dispose broadcast这些可能阻塞或耗时
if (canInterrupt) {
toDispose.dispose();
log.info("Stream interrupted for follow-up: {} (queued: {})", conversationId,
queuedMessage != null ? queuedMessage.substring(0, Math.min(30, queuedMessage.length())) : "null");
try {
String json = objectMapper.writeValueAsString(Map.of(
"conversationId", conversationId,
"queuedMessage", queuedMessage != null ? queuedMessage : "",
"timestamp", System.currentTimeMillis()
));
broadcast(conversationId, "turn_interrupt_requested", json);
} catch (Exception e) {
log.warn("Failed to broadcast turn_interrupt_requested: {}", e.getMessage());
}
return true;
}
log.info("Interrupt requested but Disposable unavailable, message queued only: {} (queued: {})",
conversationId,
queuedMessage != null ? queuedMessage.substring(0, Math.min(30, queuedMessage.length())) : "null");
try {
String json = objectMapper.writeValueAsString(Map.of(
"conversationId", conversationId,
"queuedMessage", queuedMessage != null ? queuedMessage : "",
"timestamp", System.currentTimeMillis()
));
broadcast(conversationId, "queued_input_accepted", json);
} catch (Exception e) {
log.warn("Failed to broadcast queued_input_accepted: {}", e.getMessage());
}
return false;
}
/**
* 将消息加入队列但不中断当前执行用于不可中断阶段
*/
public boolean enqueueMessage(String conversationId, String message, Long agentId, boolean persisted) {
RunState state = runs.get(conversationId);
if (state == null || state.done) {
return false;
}
state.messageQueue.offer(new QueuedInput(message, agentId, persisted));
// broadcast 在锁外
try {
String json = objectMapper.writeValueAsString(Map.of(
"conversationId", conversationId,
"queuedMessage", message,
"timestamp", System.currentTimeMillis()
));
broadcast(conversationId, "queued_input_accepted", json);
} catch (Exception e) {
log.warn("Failed to broadcast queued_input_accepted: {}", e.getMessage());
}
return true;
}
/**
* 排队输入的原子快照message + agentId + persisted 一起返回避免分离读取导致不一致
*/
public record QueuedInput(String message, Long agentId, boolean persisted) {}
/**
* 原子消费排队的输入流完成/中断后调用
* 从队列头部取出一条消息
*/
public QueuedInput consumeQueuedInput(String conversationId) {
RunState state = runs.get(conversationId);
if (state == null) return null;
return state.messageQueue.poll();
}
/**
* @deprecated Use {@link #consumeQueuedInput(String)} instead.
*/
@Deprecated
public String consumeQueuedMessage(String conversationId) {
QueuedInput input = consumeQueuedInput(conversationId);
return input != null ? input.message() : null;
}
/**
* @deprecated 多消息队列模式下改为在入队时直接传入 persisted 参数
*/
@Deprecated
public boolean markQueuedMessagePersisted(String conversationId) {
// 向后兼容无操作persisted 已在入队时设定
return true;
}
/**
* 获取中断类型
*/
public InterruptType getInterruptType(String conversationId) {
RunState state = runs.get(conversationId);
return state != null ? state.interruptType : null;
}
/**
* 清除中断状态
*/
public void clearInterruptState(String conversationId) {
RunState state = runs.get(conversationId);
if (state != null) {
state.interruptType = null;
}
}
/**
* 检查是否有排队消息
*/
public boolean hasQueuedMessage(String conversationId) {
RunState state = runs.get(conversationId);
return state != null && !state.messageQueue.isEmpty();
}
/**
* 获取当前排队消息数量
*/
public int getQueueSize(String conversationId) {
RunState state = runs.get(conversationId);
return state != null ? state.messageQueue.size() : 0;
}
// ===== Approval idempotency =====
/**
* 尝试标记一个 approval ID 为已广播如果已经广播过则返回 false幂等去重
*/
public boolean markApprovalBroadcasted(String conversationId, String pendingId) {
RunState state = runs.get(conversationId);
if (state == null) return false;
return state.broadcastedApprovalIds.add(pendingId);
}
// ===== Utility =====
private static String safe(String s) {
return s != null ? s : "";
}
/**
* buffer 裁剪到 MAX_BUFFER_SIZE 以内
* 策略将连续的同类型 delta 事件合并为一条拼接 delta 文本保留完整内容但减少条目数
* 如果合并后仍超限丢弃最早的 thinking_deltathinking 对重连恢复不是关键内容
* 必须在 state.lock 内调用
*/
private static void trimBuffer(List<SseEvent> buffer) {
if (buffer.size() <= MAX_BUFFER_SIZE) return;
// 第一步合并连续的同类型 delta 事件拼接 delta 文本而非丢弃
List<SseEvent> compacted = new ArrayList<>(buffer.size());
int i = 0;
while (i < buffer.size()) {
SseEvent current = buffer.get(i);
if ("thinking_delta".equals(current.name()) || "content_delta".equals(current.name())) {
// 收集连续同类型 delta 的文本
StringBuilder merged = new StringBuilder();
merged.append(extractDelta(current.json()));
int j = i + 1;
while (j < buffer.size() && current.name().equals(buffer.get(j).name())) {
merged.append(extractDelta(buffer.get(j).json()));
j++;
}
// 合并为一条事件
compacted.add(new SseEvent(current.name(), buildDeltaJson(merged.toString())));
i = j;
} else {
compacted.add(current);
i++;
}
}
// 第二步如果仍超限丢弃最早的 thinking_delta对重连恢复不是关键
if (compacted.size() > MAX_BUFFER_SIZE) {
Iterator<SseEvent> it = compacted.iterator();
int removed = 0;
int target = compacted.size() - MAX_BUFFER_SIZE;
while (it.hasNext() && removed < target) {
SseEvent e = it.next();
if ("thinking_delta".equals(e.name())) {
it.remove();
removed++;
}
}
}
buffer.clear();
buffer.addAll(compacted);
log.debug("Buffer trimmed: {} events", buffer.size());
}
/**
* delta JSON {"delta":"text"}中提取 delta
*/
private static String extractDelta(String json) {
// 快速解析 {"delta":"..."} 避免引入完整 JSON 解析器依赖
int idx = json.indexOf("\"delta\"");
if (idx < 0) return "";
int colonIdx = json.indexOf(':', idx);
if (colonIdx < 0) return "";
int startQuote = json.indexOf('"', colonIdx + 1);
if (startQuote < 0) return "";
StringBuilder sb = new StringBuilder();
for (int k = startQuote + 1; k < json.length(); k++) {
char c = json.charAt(k);
if (c == '\\' && k + 1 < json.length()) {
char next = json.charAt(k + 1);
if (next == '"') { sb.append('"'); k++; }
else if (next == '\\') { sb.append('\\'); k++; }
else if (next == 'n') { sb.append('\n'); k++; }
else if (next == 't') { sb.append('\t'); k++; }
else if (next == 'r') { sb.append('\r'); k++; }
else if (next == '/') { sb.append('/'); k++; }
else if (next == 'b') { sb.append('\b'); k++; }
else if (next == 'f') { sb.append('\f'); k++; }
else if (next == 'u' && k + 5 < json.length()) {
// Unicode escape: backslash-u followed by 4 hex digits
String hex = json.substring(k + 2, k + 6);
try {
sb.append((char) Integer.parseInt(hex, 16));
k += 5;
} catch (NumberFormatException e) {
sb.append(c); // 无法解析保留原样
}
}
else { sb.append(c); }
} else if (c == '"') {
break;
} else {
sb.append(c);
}
}
return sb.toString();
}
/**
* 构建 delta JSON 字符串
*/
private static String buildDeltaJson(String delta) {
StringBuilder sb = new StringBuilder("{\"delta\":\"");
for (int k = 0; k < delta.length(); k++) {
char c = delta.charAt(k);
if (c == '"') sb.append("\\\"");
else if (c == '\\') sb.append("\\\\");
else if (c == '\n') sb.append("\\n");
else if (c == '\t') sb.append("\\t");
else if (c == '\r') sb.append("\\r");
else sb.append(c);
}
sb.append("\"}");
return sb.toString();
}
}

View File

@ -0,0 +1,54 @@
package vip.mate.channel.web;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import vip.mate.channel.AbstractChannelAdapter;
import vip.mate.channel.ChannelMessage;
import vip.mate.channel.ChannelMessageRouter;
import vip.mate.channel.model.ChannelEntity;
/**
* Web 渠道适配器
* <p>
* Web 渠道是 MateClaw 的默认渠道通过 HTTP API SSE 与前端交互
* 不同于 IM 渠道Web 渠道不需要长连接消息通过 ChatController 直接处理
* 此适配器主要提供统一的生命周期管理和消息格式兼容
*
* @author MateClaw Team
*/
@Slf4j
public class WebChannelAdapter extends AbstractChannelAdapter {
public static final String CHANNEL_TYPE = "web";
public WebChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter,
ObjectMapper objectMapper) {
super(channelEntity, messageRouter, objectMapper);
}
@Override
protected void doStart() {
// Web 渠道无需额外启动HTTP 端点由 Spring MVC 管理
log.info("[web] Web channel ready (HTTP/SSE endpoints managed by Spring MVC)");
}
@Override
protected void doStop() {
// Web 渠道无需显式停止
log.info("[web] Web channel stopped");
}
@Override
public void sendMessage(String targetId, String content) {
// Web 渠道的消息发送通过 SSE HTTP 响应完成
// 此方法仅用于主动推送场景如定时任务可通过 WebSocket 实现
log.debug("[web] sendMessage to {}: {}chars (push not implemented, use SSE)",
targetId, content != null ? content.length() : 0);
}
@Override
public String getChannelType() {
return CHANNEL_TYPE;
}
}

View File

@ -0,0 +1,971 @@
package vip.mate.channel.wecom;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import vip.mate.channel.AbstractChannelAdapter;
import vip.mate.channel.ChannelMessage;
import vip.mate.channel.ChannelMessageRouter;
import vip.mate.channel.ExponentialBackoff;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.workspace.conversation.model.MessageContentPart;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.WebSocket;
import java.nio.ByteBuffer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 企业微信智能机器人渠道适配器 WebSocket 长连接模式
* <p>
* 基于企业微信智能机器人API 长连接协议wecom-aibot-python-sdk 逆向
* <ul>
* <li>WebSocket 连接 wss://openws.work.weixin.qq.com</li>
* <li>bot_id + secret 认证aibot_subscribe </li>
* <li>30 秒心跳ping </li>
* <li>aibot_msg_callback / aibot_event_callback 消息推送</li>
* <li>reply_stream 流式回复覆盖更新"思考中..."</li>
* <li>send_message 主动推送</li>
* </ul>
* <p>
* 用户在企业微信后台创建智能机器人 选择API 模式 配置长连接
* 获得 bot_id secret 填入 MateClaw 启动即可对话
* 无需公网 IP无需回调 URL
* <p>
* 配置项configJson
* <ul>
* <li>bot_id: 机器人 ID</li>
* <li>secret: 机器人 Secret</li>
* <li>welcome_text: 欢迎消息可选</li>
* <li>media_download_enabled: 是否下载媒体文件默认 false</li>
* <li>media_dir: 媒体文件保存目录默认 data/media</li>
* </ul>
*
* @author MateClaw Team
*/
@Slf4j
public class WeComChannelAdapter extends AbstractChannelAdapter {
public static final String CHANNEL_TYPE = "wecom";
/** 企业微信智能机器人 WebSocket 地址 */
private static final String DEFAULT_WS_URL = "wss://openws.work.weixin.qq.com";
/** 心跳间隔 30 秒 */
private static final long HEARTBEAT_INTERVAL_MS = 30_000;
/** 连续未收到 pong 的最大次数(超过则认为连接已死) */
private static final int MAX_MISSED_PONG = 2;
/** 回复 ACK 等待超时 5 秒 */
private static final long REPLY_ACK_TIMEOUT_MS = 5_000;
/** 消息去重:最大记录数 */
private static final int PROCESSED_IDS_MAX = 2000;
// ==================== WebSocket 命令常量 ====================
private static final String CMD_SUBSCRIBE = "aibot_subscribe";
private static final String CMD_HEARTBEAT = "ping";
private static final String CMD_RESPONSE = "aibot_respond_msg";
private static final String CMD_RESPONSE_WELCOME = "aibot_respond_welcome_msg";
private static final String CMD_SEND_MSG = "aibot_send_msg";
private static final String CMD_CALLBACK = "aibot_msg_callback";
private static final String CMD_EVENT_CALLBACK = "aibot_event_callback";
// ==================== 运行时状态 ====================
private HttpClient httpClient;
private volatile WebSocket webSocket;
private volatile Thread wsThread;
/** 心跳定时任务 */
private volatile ScheduledFuture<?> heartbeatFuture;
/** 连续未收到 pong 的计数 */
private final AtomicInteger missedPongCount = new AtomicInteger(0);
/** 消息去重集合 */
private final Set<String> processedMessageIds = ConcurrentHashMap.newKeySet();
/** 回复 ACK 等待reqId -> CompletableFuture */
private final ConcurrentHashMap<String, CompletableFuture<Map<String, Object>>> pendingAcks = new ConcurrentHashMap<>();
/** 回复队列reqId -> 串行队列(保证同一 reqId 的回复按序发送) */
private final ConcurrentHashMap<String, LinkedBlockingQueue<ReplyTask>> replyQueues = new ConcurrentHashMap<>();
/** 回复队列处理线程池 */
private final ExecutorService replyExecutor = Executors.newCachedThreadPool(r -> {
Thread t = new Thread(r, "wecom-reply");
t.setDaemon(true);
return t;
});
/** WebSocket 消息碎片缓冲区 */
private final StringBuilder wsBuffer = new StringBuilder();
/** 请求 ID 计数器 */
private final AtomicInteger reqIdCounter = new AtomicInteger(0);
/** 记录消息中 reqId -> frame 的映射,用于 reply_stream 回复 */
private final ConcurrentHashMap<String, Map<String, Object>> pendingFrames = new ConcurrentHashMap<>();
public WeComChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter,
ObjectMapper objectMapper) {
super(channelEntity, messageRouter, objectMapper);
int maxAttempts = -1;
Object val = config.get("max_reconnect_attempts");
if (val instanceof Number n) {
maxAttempts = n.intValue();
} else if (val instanceof String s) {
try { maxAttempts = Integer.parseInt(s); } catch (NumberFormatException ignored) {}
}
this.backoff = new ExponentialBackoff(2000, 30000, 2.0, maxAttempts);
}
// ==================== 生命周期 ====================
@Override
protected void doStart() {
String botId = getConfigString("bot_id");
String secret = getConfigString("secret");
if (botId == null || botId.isBlank() || secret == null || secret.isBlank()) {
throw new IllegalStateException("WeCom bot channel requires bot_id and secret in configJson");
}
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
connectWebSocket(botId, secret);
log.info("[wecom] WeCom bot channel initialized: botId={}, maxReconnectAttempts={}",
botId.length() > 12 ? botId.substring(0, 12) + "..." : botId, backoff.getMaxAttempts());
}
@Override
protected void doStop() {
// 停止心跳
if (heartbeatFuture != null) {
heartbeatFuture.cancel(false);
heartbeatFuture = null;
}
// 关闭 WebSocket
if (webSocket != null) {
try {
webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "Manual disconnect")
.orTimeout(3, TimeUnit.SECONDS)
.exceptionally(ex -> null)
.join();
} catch (Exception e) {
log.debug("[wecom] Error closing WebSocket: {}", e.getMessage());
}
webSocket = null;
}
// 等待 WS 线程结束
if (wsThread != null) {
wsThread.interrupt();
try {
wsThread.join(5000);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
wsThread = null;
}
// 清理挂起的 ACK
pendingAcks.forEach((k, f) -> f.completeExceptionally(new RuntimeException("Channel stopped")));
pendingAcks.clear();
replyQueues.clear();
pendingFrames.clear();
processedMessageIds.clear();
this.httpClient = null;
log.info("[wecom] WeCom bot channel stopped");
}
@Override
protected void doReconnect() {
log.info("[wecom] Reconnecting WebSocket...");
// 清理旧连接
if (heartbeatFuture != null) {
heartbeatFuture.cancel(false);
heartbeatFuture = null;
}
if (webSocket != null) {
try { webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "Reconnecting"); } catch (Exception ignored) {}
webSocket = null;
}
if (wsThread != null) {
wsThread.interrupt();
try { wsThread.join(3000); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); }
wsThread = null;
}
pendingAcks.forEach((k, f) -> f.completeExceptionally(new RuntimeException("Reconnecting")));
pendingAcks.clear();
replyQueues.clear();
pendingFrames.clear();
missedPongCount.set(0);
if (this.httpClient == null) {
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
}
String botId = getConfigString("bot_id");
String secret = getConfigString("secret");
connectWebSocket(botId, secret);
}
// ==================== WebSocket 连接 ====================
/**
* 在守护线程中建立 WebSocket 连接
*/
private void connectWebSocket(String botId, String secret) {
wsThread = new Thread(() -> {
try {
log.info("[wecom] WebSocket connecting to {}...", DEFAULT_WS_URL);
CompletableFuture<WebSocket> wsFuture = httpClient.newWebSocketBuilder()
.connectTimeout(Duration.ofSeconds(15))
.buildAsync(URI.create(DEFAULT_WS_URL), new WeComWebSocketListener());
webSocket = wsFuture.get(20, TimeUnit.SECONDS);
log.info("[wecom] WebSocket connected, sending auth...");
// 发送认证帧
sendAuth(botId, secret);
} catch (Exception e) {
log.error("[wecom] WebSocket connection failed: {}", e.getMessage(), e);
if (running.get()) {
onDisconnected("WebSocket connection failed: " + e.getMessage());
}
}
}, "wecom-ws-" + channelEntity.getId());
wsThread.setDaemon(true);
wsThread.start();
}
/**
* WebSocket 监听器接收消息帧并分发处理
*/
private class WeComWebSocketListener implements WebSocket.Listener {
@Override
public void onOpen(WebSocket webSocket) {
log.debug("[wecom] WebSocket onOpen");
webSocket.request(1);
}
@Override
public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {
wsBuffer.append(data);
if (last) {
String fullMessage = wsBuffer.toString();
wsBuffer.setLength(0);
handleWebSocketFrame(fullMessage);
}
webSocket.request(1);
return null;
}
@Override
public CompletionStage<?> onBinary(WebSocket webSocket, ByteBuffer data, boolean last) {
byte[] bytes = new byte[data.remaining()];
data.get(bytes);
wsBuffer.append(new String(bytes));
if (last) {
String fullMessage = wsBuffer.toString();
wsBuffer.setLength(0);
handleWebSocketFrame(fullMessage);
}
webSocket.request(1);
return null;
}
@Override
public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
log.warn("[wecom] WebSocket closed: code={}, reason={}", statusCode, reason);
if (running.get()) {
onDisconnected("WebSocket closed: code=" + statusCode + ", reason=" + reason);
}
return null;
}
@Override
public void onError(WebSocket webSocket, Throwable error) {
log.error("[wecom] WebSocket error: {}", error.getMessage());
if (running.get()) {
onDisconnected("WebSocket error: " + error.getMessage());
}
}
}
// ==================== 帧处理 ====================
/**
* 处理收到的 WebSocket JSON
*/
@SuppressWarnings("unchecked")
private void handleWebSocketFrame(String jsonStr) {
try {
Map<String, Object> frame = objectMapper.readValue(jsonStr, Map.class);
String cmd = (String) frame.get("cmd");
// 消息推送
if (CMD_CALLBACK.equals(cmd)) {
handleMessageCallback(frame);
return;
}
// 事件推送
if (CMD_EVENT_CALLBACK.equals(cmd)) {
handleEventCallback(frame);
return;
}
// cmd 的帧认证响应心跳响应或回复 ACK
Map<String, Object> headers = (Map<String, Object>) frame.getOrDefault("headers", Map.of());
String reqId = (String) headers.getOrDefault("req_id", "");
// 检查是否是回复消息的 ACK
CompletableFuture<Map<String, Object>> ackFuture = pendingAcks.remove(reqId);
if (ackFuture != null) {
Integer errcode = frame.get("errcode") instanceof Number n ? n.intValue() : null;
if (errcode != null && errcode != 0) {
ackFuture.completeExceptionally(new RuntimeException(
"Reply ACK error: errcode=" + errcode + ", errmsg=" + frame.get("errmsg")));
} else {
ackFuture.complete(frame);
}
return;
}
// 认证响应
if (reqId.startsWith(CMD_SUBSCRIBE)) {
Integer errcode = frame.get("errcode") instanceof Number n ? n.intValue() : null;
if (errcode != null && errcode != 0) {
log.error("[wecom] Authentication failed: errcode={}, errmsg={}", errcode, frame.get("errmsg"));
lastError = "Authentication failed: " + frame.get("errmsg");
return;
}
log.info("[wecom] Authentication successful");
missedPongCount.set(0);
startHeartbeat();
return;
}
// 心跳响应
if (reqId.startsWith(CMD_HEARTBEAT)) {
Integer errcode = frame.get("errcode") instanceof Number n ? n.intValue() : null;
if (errcode != null && errcode != 0) {
log.warn("[wecom] Heartbeat ACK error: errcode={}", errcode);
return;
}
missedPongCount.set(0);
log.debug("[wecom] Heartbeat ACK received");
return;
}
log.debug("[wecom] Received unknown frame: {}", jsonStr.length() > 200 ? jsonStr.substring(0, 200) : jsonStr);
} catch (Exception e) {
log.error("[wecom] Failed to handle WebSocket frame: {}", e.getMessage(), e);
}
}
// ==================== 认证 & 心跳 ====================
private void sendAuth(String botId, String secret) {
String reqId = generateReqId(CMD_SUBSCRIBE);
Map<String, Object> frame = Map.of(
"cmd", CMD_SUBSCRIBE,
"headers", Map.of("req_id", reqId),
"body", Map.of("bot_id", botId, "secret", secret)
);
sendFrame(frame);
log.info("[wecom] Auth frame sent");
}
private void startHeartbeat() {
if (heartbeatFuture != null) {
heartbeatFuture.cancel(false);
}
heartbeatFuture = ensureReconnectScheduler().scheduleAtFixedRate(() -> {
if (!running.get()) return;
try {
sendHeartbeat();
} catch (Exception e) {
log.warn("[wecom] Heartbeat send failed: {}", e.getMessage());
}
}, HEARTBEAT_INTERVAL_MS, HEARTBEAT_INTERVAL_MS, TimeUnit.MILLISECONDS);
log.debug("[wecom] Heartbeat started (interval={}ms)", HEARTBEAT_INTERVAL_MS);
}
private void sendHeartbeat() {
if (missedPongCount.get() >= MAX_MISSED_PONG) {
log.warn("[wecom] No heartbeat ACK for {} consecutive pings, connection considered dead",
missedPongCount.get());
if (heartbeatFuture != null) {
heartbeatFuture.cancel(false);
heartbeatFuture = null;
}
if (running.get()) {
onDisconnected("Heartbeat timeout: " + missedPongCount.get() + " missed pongs");
}
return;
}
missedPongCount.incrementAndGet();
String reqId = generateReqId(CMD_HEARTBEAT);
sendFrame(Map.of(
"cmd", CMD_HEARTBEAT,
"headers", Map.of("req_id", reqId)
));
log.debug("[wecom] Heartbeat sent (missed={})", missedPongCount.get());
}
// ==================== 消息接收 ====================
/**
* 处理消息推送回调 (aibot_msg_callback)
*/
@SuppressWarnings("unchecked")
private void handleMessageCallback(Map<String, Object> frame) {
try {
Map<String, Object> body = (Map<String, Object>) frame.getOrDefault("body", Map.of());
Map<String, Object> headers = (Map<String, Object>) frame.getOrDefault("headers", Map.of());
String frameReqId = (String) headers.getOrDefault("req_id", "");
String msgType = (String) body.getOrDefault("msgtype", "");
Map<String, Object> fromMap = (Map<String, Object>) body.getOrDefault("from", Map.of());
String senderId = (String) fromMap.getOrDefault("userid", "");
String chatId = (String) body.getOrDefault("chatid", "");
String chatType = (String) body.getOrDefault("chattype", "single");
String msgId = (String) body.getOrDefault("msgid", "");
// 补充 msgId如果为空则用 senderId + send_time 合成
if (msgId.isBlank()) {
msgId = senderId + "_" + body.getOrDefault("send_time", System.currentTimeMillis());
}
// 消息去重
if (!msgId.isBlank() && !processedMessageIds.add(msgId)) {
log.debug("[wecom] Duplicate msgId: {}, skipping", msgId);
return;
}
// 去重集合超限清理
if (processedMessageIds.size() > PROCESSED_IDS_MAX) {
int toRemove = processedMessageIds.size() / 2;
var it = processedMessageIds.iterator();
while (it.hasNext() && toRemove > 0) { it.next(); it.remove(); toRemove--; }
}
// 保存 frame 用于 reply_stream
pendingFrames.put(frameReqId, frame);
List<MessageContentPart> contentParts = new ArrayList<>();
String textContent = null;
switch (msgType) {
case "text" -> {
Map<String, Object> textBody = (Map<String, Object>) body.getOrDefault("text", Map.of());
textContent = ((String) textBody.getOrDefault("content", "")).trim();
if (!textContent.isBlank()) {
contentParts.add(MessageContentPart.text(textContent));
}
}
case "image" -> {
Map<String, Object> imgBody = (Map<String, Object>) body.getOrDefault("image", Map.of());
String url = (String) imgBody.getOrDefault("url", "");
String aesKey = (String) imgBody.getOrDefault("aeskey", "");
if (getConfigBoolean("media_download_enabled", false) && !url.isBlank()) {
String localPath = downloadAndDecryptMedia(url, aesKey, msgId, "image.jpg");
if (localPath != null) {
contentParts.add(MessageContentPart.image(localPath, url));
} else {
contentParts.add(MessageContentPart.image(url, url));
}
} else if (!url.isBlank()) {
contentParts.add(MessageContentPart.image(url, url));
}
textContent = "[图片]";
}
case "voice" -> {
Map<String, Object> voiceBody = (Map<String, Object>) body.getOrDefault("voice", Map.of());
String asrText = ((String) voiceBody.getOrDefault("content", "")).trim();
if (!asrText.isBlank()) {
contentParts.add(MessageContentPart.text(asrText));
textContent = asrText;
} else {
textContent = "[语音消息]";
}
}
case "file" -> {
Map<String, Object> fileBody = (Map<String, Object>) body.getOrDefault("file", Map.of());
String url = (String) fileBody.getOrDefault("url", "");
String aesKey = (String) fileBody.getOrDefault("aeskey", "");
String filename = (String) fileBody.getOrDefault("filename", "file.bin");
if (getConfigBoolean("media_download_enabled", false) && !url.isBlank()) {
String localPath = downloadAndDecryptMedia(url, aesKey, msgId, filename);
if (localPath != null) {
contentParts.add(MessageContentPart.file(localPath, filename, null));
}
}
textContent = "[文件: " + filename + "]";
}
case "mixed" -> {
Map<String, Object> mixedBody = (Map<String, Object>) body.getOrDefault("mixed", Map.of());
List<Map<String, Object>> items = (List<Map<String, Object>>) mixedBody.getOrDefault("msg_item", List.of());
StringBuilder textBuilder = new StringBuilder();
for (Map<String, Object> item : items) {
String itemType = (String) item.getOrDefault("msgtype", "");
if ("text".equals(itemType)) {
Map<String, Object> t = (Map<String, Object>) item.getOrDefault("text", Map.of());
String txt = ((String) t.getOrDefault("content", "")).trim();
if (!txt.isBlank()) {
textBuilder.append(txt).append('\n');
}
} else if ("image".equals(itemType)) {
Map<String, Object> img = (Map<String, Object>) item.getOrDefault("image", Map.of());
String url = (String) img.getOrDefault("url", "");
if (!url.isBlank()) {
contentParts.add(MessageContentPart.image(url, url));
}
}
}
textContent = textBuilder.toString().trim();
if (!textContent.isBlank()) {
contentParts.add(0, MessageContentPart.text(textContent));
}
}
default -> {
log.debug("[wecom] Ignoring unsupported message type: {}", msgType);
return;
}
}
if (contentParts.isEmpty()) {
if (textContent != null && !textContent.isBlank()) {
contentParts.add(MessageContentPart.text(textContent));
} else {
return;
}
}
// 发送"🤔 思考中..."处理指示器
String processingStreamId = "";
if (textContent != null && !textContent.isBlank()) {
processingStreamId = generateReqId("stream");
try {
replyStream(frameReqId, processingStreamId, "🤔 思考中...", false);
} catch (Exception e) {
log.debug("[wecom] Failed to send processing indicator: {}", e.getMessage());
}
}
boolean isGroup = "group".equals(chatType);
String effectiveChatId = isGroup ? chatId : null;
// conversationId 格式wecom:{userid} wecom:group:{chatid}
// ChannelMessageRouter.buildConversationId() 根据 channelType + chatId/senderId 构建
ChannelMessage channelMessage = ChannelMessage.builder()
.messageId(msgId)
.channelType(CHANNEL_TYPE)
.senderId(senderId)
.senderName(senderId)
.chatId(effectiveChatId)
.content(textContent != null ? textContent.trim() : "")
.contentType(msgType)
.contentParts(contentParts)
.timestamp(LocalDateTime.now())
.replyToken(isGroup ? chatId : senderId)
.rawPayload(Map.of(
"wecom_frame_req_id", frameReqId,
"wecom_processing_stream_id", processingStreamId,
"wecom_chat_type", chatType,
"wecom_chatid", chatId
))
.build();
log.info("[wecom] Received message: sender={}, chatType={}, msgType={}, textLen={}",
senderId.length() > 20 ? senderId.substring(0, 20) : senderId,
chatType, msgType,
textContent != null ? textContent.length() : 0);
onMessage(channelMessage);
} catch (Exception e) {
log.error("[wecom] Failed to handle message callback: {}", e.getMessage(), e);
}
}
/**
* 处理事件推送回调 (aibot_event_callback)
*/
@SuppressWarnings("unchecked")
private void handleEventCallback(Map<String, Object> frame) {
try {
Map<String, Object> body = (Map<String, Object>) frame.getOrDefault("body", Map.of());
Map<String, Object> event = body.get("event") instanceof Map<?, ?> m ? (Map<String, Object>) m : Map.of();
String eventType = (String) event.getOrDefault("eventtype", "");
if ("enter_chat".equals(eventType)) {
String welcomeText = getConfigString("welcome_text", "");
if (!welcomeText.isBlank()) {
try {
Map<String, Object> headers = (Map<String, Object>) frame.getOrDefault("headers", Map.of());
String reqId = (String) headers.getOrDefault("req_id", "");
replyWelcome(reqId, welcomeText);
log.info("[wecom] Welcome message sent");
} catch (Exception e) {
log.warn("[wecom] Failed to send welcome message: {}", e.getMessage());
}
}
return;
}
log.debug("[wecom] Ignoring event type: {}", eventType);
} catch (Exception e) {
log.error("[wecom] Failed to handle event callback: {}", e.getMessage(), e);
}
}
// ==================== 消息发送 ====================
@Override
public void sendMessage(String targetId, String content) {
if (webSocket == null) {
log.warn("[wecom] Channel not started, cannot send message");
return;
}
// 检查是否有 pending frame用于 reply_stream 覆盖"思考中..."
// sendMessage renderAndSend 调用时尝试用 reply_stream 覆盖
// 但由于 rawPayload 信息在 ChannelMessageRouter 层已丢失
// 这里走 send_message 主动推送路径
sendMessageToChat(targetId, content);
}
/**
* 通过 WebSocket send_message 命令主动推送消息
*/
private void sendMessageToChat(String chatId, String content) {
if (webSocket == null || content == null || content.isBlank()) return;
try {
String reqId = generateReqId(CMD_SEND_MSG);
Map<String, Object> frame = Map.of(
"cmd", CMD_SEND_MSG,
"headers", Map.of("req_id", reqId),
"body", Map.of(
"chatid", chatId,
"msgtype", "markdown",
"markdown", Map.of("content", content)
)
);
sendFrameWithAck(reqId, frame);
} catch (Exception e) {
log.error("[wecom] Failed to send message to {}: {}", chatId, e.getMessage(), e);
}
}
/**
* 覆写 renderAndSend如果有 processing_stream_id 则用 reply_stream 覆盖"思考中..."
*/
@Override
public void renderAndSend(String targetId, String content) {
// 尝试查找匹配的 pending frame通过 target 反查
// renderAndSend ChannelMessageRouter.processMessage() 中被调用
// 此时 targetId replyTokenuserId chatId
// 先进行正常的内容渲染过滤 thinking分割长文本
boolean filterThinking = getConfigBoolean("filter_thinking", true);
boolean filterToolMessages = getConfigBoolean("filter_tool_messages", true);
String format = getConfigString("message_format", "auto");
int maxLen = vip.mate.channel.ChannelMessageRenderer.PLATFORM_LIMITS.getOrDefault(getChannelType(), 2048);
List<String> segments = vip.mate.channel.ChannelMessageRenderer.renderForChannel(
content, filterThinking, filterToolMessages, format, maxLen);
for (String segment : segments) {
sendMessage(targetId, segment);
}
}
@Override
public void sendContentParts(String targetId, List<MessageContentPart> parts) {
for (MessageContentPart part : parts) {
if (part == null) continue;
switch (part.getType()) {
case "text" -> { if (part.getText() != null) sendMessage(targetId, part.getText()); }
case "image" -> {
String imgUrl = part.getFileUrl() != null ? part.getFileUrl() : part.getMediaId();
if (imgUrl != null) {
sendMessage(targetId, "![image](" + imgUrl + ")");
}
}
case "file" -> {
String fileName = part.getFileName() != null ? part.getFileName() : "file";
sendMessage(targetId, "[文件: " + fileName + "]");
}
default -> { if (part.getText() != null) sendMessage(targetId, part.getText()); }
}
}
}
// ==================== reply_stream 协议实现 ====================
/**
* 发送流式回复reply_stream
* <p>
* 通过 WebSocket 回复通道使用相同 stream_id 可以覆盖更新已发送的消息
*
* @param originalReqId 原始消息的 reqId用于路由回复
* @param streamId 流式消息 ID相同 ID 会覆盖之前的消息
* @param content 回复内容支持 Markdown
* @param finish 是否结束流式消息
*/
private void replyStream(String originalReqId, String streamId, String content, boolean finish) {
Map<String, Object> streamBody = new LinkedHashMap<>();
streamBody.put("id", streamId);
streamBody.put("finish", finish);
streamBody.put("content", content);
Map<String, Object> body = Map.of(
"msgtype", "stream",
"stream", streamBody
);
Map<String, Object> frame = Map.of(
"cmd", CMD_RESPONSE,
"headers", Map.of("req_id", originalReqId),
"body", body
);
sendFrameWithAck(originalReqId, frame);
}
/**
* 发送欢迎消息
*/
private void replyWelcome(String reqId, String text) {
Map<String, Object> body = Map.of(
"msgtype", "text",
"text", Map.of("content", text)
);
Map<String, Object> frame = Map.of(
"cmd", CMD_RESPONSE_WELCOME,
"headers", Map.of("req_id", reqId),
"body", body
);
sendFrameWithAck(reqId, frame);
}
// ==================== 帧发送基础设施 ====================
/**
* 发送 WebSocket fire and forget
*/
private void sendFrame(Map<String, Object> frame) {
WebSocket ws = this.webSocket;
if (ws == null) {
log.warn("[wecom] WebSocket not connected, cannot send frame");
return;
}
try {
String json = objectMapper.writeValueAsString(frame);
ws.sendText(json, true);
} catch (Exception e) {
log.error("[wecom] Failed to send frame: {}", e.getMessage(), e);
}
}
/**
* 串行队列发送帧等待 ACK带超时
* <p>
* 同一 reqId 的消息按顺序发送每条等待 ACK 后再发下一条
*/
private void sendFrameWithAck(String reqId, Map<String, Object> frame) {
CompletableFuture<Map<String, Object>> ackFuture = new CompletableFuture<>();
// 注册 ACK 等待
pendingAcks.put(reqId, ackFuture);
// 发送帧
sendFrame(frame);
// 等待 ACK超时 5 不阻塞当前线程 fire and forget
ackFuture.orTimeout(REPLY_ACK_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.whenComplete((result, ex) -> {
pendingAcks.remove(reqId);
if (ex != null) {
log.debug("[wecom] Reply ACK timeout or error for reqId={}: {}", reqId, ex.getMessage());
}
});
}
// ==================== 媒体文件下载与 AES 解密 ====================
/**
* 下载并解密企业微信媒体文件
* <p>
* AES-256-CBC 解密base64 decode aesKey IV = 16 字节 PKCS#7 去填充
*
* @param url 文件下载 URL
* @param aesKey Base64 编码的 AES-256 密钥
* @param msgId 消息 ID用于生成文件名
* @param fileNameHint 文件名提示
* @return 本地文件路径失败返回 null
*/
private String downloadAndDecryptMedia(String url, String aesKey, String msgId, String fileNameHint) {
try {
String mediaDir = getConfigString("media_dir", "data/media");
Path mediaDirPath = Path.of(mediaDir);
Files.createDirectories(mediaDirPath);
// 1. HTTP GET 下载文件
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(30))
.GET()
.build();
HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
byte[] encryptedData = response.body().readAllBytes();
byte[] fileData;
// 2. AES 解密如果提供了 aesKey
if (aesKey != null && !aesKey.isBlank()) {
fileData = decryptAes256Cbc(encryptedData, aesKey);
} else {
fileData = encryptedData;
}
// 3. 保存到本地
String urlHash = md5Hex(url).substring(0, 8);
String safeName = fileNameHint.replaceAll("[^a-zA-Z0-9._-]", "_");
if (safeName.isBlank()) safeName = "media";
Path filePath = mediaDirPath.resolve("wecom_" + urlHash + "_" + safeName);
Files.write(filePath, fileData);
log.info("[wecom] Media downloaded: {} ({} bytes)", filePath, fileData.length);
return filePath.toAbsolutePath().toString();
} catch (Exception e) {
log.error("[wecom] Failed to download media: {}", e.getMessage(), e);
return null;
}
}
/**
* AES-256-CBC 解密对齐 wecom-aibot-python-sdk crypto_utils.py
* <p>
* 1. Base64 decode aesKey自动补齐 padding
* 2. IV = decoded key 16 字节
* 3. AES-256-CBC 解密
* 4. PKCS#7 去填充
*/
private byte[] decryptAes256Cbc(byte[] encryptedData, String aesKeyBase64) throws Exception {
// 补齐 Base64 padding
int padCount = (4 - aesKeyBase64.length() % 4) % 4;
String padded = aesKeyBase64 + "=".repeat(padCount);
byte[] keyBytes = Base64.getDecoder().decode(padded);
// IV = 16 字节
byte[] iv = Arrays.copyOf(keyBytes, 16);
// 确保数据是 16 字节的倍数
int blockSize = 16;
int remainder = encryptedData.length % blockSize;
if (remainder != 0) {
encryptedData = Arrays.copyOf(encryptedData, encryptedData.length + (blockSize - remainder));
}
// AES-256-CBC 解密NoPadding 手动去 PKCS#7
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
IvParameterSpec ivSpec = new IvParameterSpec(iv);
Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
byte[] decrypted = cipher.doFinal(encryptedData);
// PKCS#7 去填充
int padLen = decrypted[decrypted.length - 1] & 0xFF;
if (padLen < 1 || padLen > 32 || padLen > decrypted.length) {
throw new IllegalArgumentException("Invalid PKCS#7 padding value: " + padLen);
}
for (int i = decrypted.length - padLen; i < decrypted.length; i++) {
if ((decrypted[i] & 0xFF) != padLen) {
throw new IllegalArgumentException("Invalid PKCS#7 padding: bytes mismatch");
}
}
return Arrays.copyOf(decrypted, decrypted.length - padLen);
}
// ==================== 主动推送 ====================
@Override
public boolean supportsProactiveSend() {
return true;
}
@Override
public void proactiveSend(String targetId, String content) {
sendMessageToChat(targetId, content);
}
@Override
public String getChannelType() {
return CHANNEL_TYPE;
}
// ==================== 工具方法 ====================
/**
* 生成唯一请求 ID{prefix}_{timestamp}_{counter}
*/
private String generateReqId(String prefix) {
return prefix + "_" + System.currentTimeMillis() + "_" + reqIdCounter.incrementAndGet();
}
/**
* MD5 哈希hex 字符串
*/
private String md5Hex(String input) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] hash = md.digest(input.getBytes());
StringBuilder sb = new StringBuilder();
for (byte b : hash) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (Exception e) {
return Integer.toHexString(input.hashCode());
}
}
// ==================== 回复队列内部类 ====================
private record ReplyTask(Map<String, Object> frame, CompletableFuture<Map<String, Object>> future) {}
}

View File

@ -0,0 +1,280 @@
package vip.mate.channel.weixin;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.*;
/**
* 微信 iLink Bot HTTP 客户端
* <p>
* 微信 iLink Bot HTTP 客户端实现
* <ul>
* <li>iLink API 基础地址https://ilinkai.weixin.qq.com</li>
* <li>HTTP/JSON 协议无需第三方 SDK</li>
* <li>Bearer Token 认证通过 QR 码登录获取</li>
* <li>长轮询 getupdates服务端最长持有 35 </li>
* </ul>
* <p>
* 认证流程
* <ol>
* <li>GET /ilink/bot/get_bot_qrcode?bot_type=3 获取二维码</li>
* <li>轮询 GET /ilink/bot/get_qrcode_status?qrcode=xxx 等待扫码确认</li>
* <li>确认后获得 bot_token + baseurl</li>
* <li>后续请求均带 Bearer token</li>
* </ol>
*
* @author MateClaw Team
*/
@Slf4j
public class ILinkClient {
public static final String DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
private static final String CHANNEL_VERSION = "2.0.1";
/** 长轮询超时(服务端最长 35s客户端设 45s */
private static final Duration GETUPDATES_TIMEOUT = Duration.ofSeconds(45);
/** 普通请求超时 */
private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(15);
/** 媒体下载超时 */
private static final Duration DOWNLOAD_TIMEOUT = Duration.ofSeconds(60);
@Setter
private String botToken;
@Setter
private String baseUrl;
private final HttpClient httpClient;
private final ObjectMapper objectMapper;
public ILinkClient(String botToken, String baseUrl, ObjectMapper objectMapper) {
this.botToken = botToken;
this.baseUrl = (baseUrl != null && !baseUrl.isBlank()) ? baseUrl.replaceAll("/+$", "") : DEFAULT_BASE_URL;
this.objectMapper = objectMapper;
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
}
// ==================== 请求头构建 ====================
/**
* 构建 iLink API 请求头
* <p>
* X-WECHAT-UIN: base64(str(random_uint32)) 每请求一个随机值防重放
* Authorization: Bearer {token}
* AuthorizationType: ilink_bot_token
*/
private Map<String, String> makeHeaders() {
long uinVal = new Random().nextLong(0, 0xFFFFFFFFL + 1);
String uinB64 = Base64.getEncoder().encodeToString(
String.valueOf(uinVal).getBytes(StandardCharsets.UTF_8));
Map<String, String> headers = new LinkedHashMap<>();
headers.put("Content-Type", "application/json");
headers.put("AuthorizationType", "ilink_bot_token");
headers.put("X-WECHAT-UIN", uinB64);
if (botToken != null && !botToken.isBlank()) {
headers.put("Authorization", "Bearer " + botToken);
}
return headers;
}
private HttpRequest.Builder applyHeaders(HttpRequest.Builder builder) {
makeHeaders().forEach(builder::header);
return builder;
}
// ==================== 认证 API ====================
/**
* 获取登录二维码
*
* @return 包含 qrcode, qrcode_img_content(Base64 PNG), url 等字段
*/
public Map<String, Object> getBotQrcode() throws Exception {
HttpRequest request = applyHeaders(HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/ilink/bot/get_bot_qrcode?bot_type=3"))
.GET())
.timeout(DEFAULT_TIMEOUT)
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new RuntimeException("getBotQrcode failed: HTTP " + response.statusCode());
}
return objectMapper.readValue(response.body(), new TypeReference<>() {});
}
/**
* 轮询二维码扫码状态
*
* @param qrcode 二维码标识来自 getBotQrcode
* @return 包含 status(waiting/scanned/confirmed/expired), bot_token, baseurl
*/
public Map<String, Object> getQrcodeStatus(String qrcode) throws Exception {
String encoded = URLEncoder.encode(qrcode, StandardCharsets.UTF_8);
HttpRequest request = applyHeaders(HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/ilink/bot/get_qrcode_status?qrcode=" + encoded))
.GET())
.timeout(DEFAULT_TIMEOUT)
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new RuntimeException("getQrcodeStatus failed: HTTP " + response.statusCode());
}
return objectMapper.readValue(response.body(), new TypeReference<>() {});
}
/**
* 等待 QR 码扫码确认阻塞最长 maxWaitSeconds
*
* @param qrcode 二维码标识
* @param pollIntervalMs 轮询间隔毫秒
* @param maxWaitSeconds 最长等待时间
* @return QrLoginResult 包含 token baseUrl
*/
public QrLoginResult waitForLogin(String qrcode, long pollIntervalMs, int maxWaitSeconds) throws Exception {
long deadline = System.currentTimeMillis() + maxWaitSeconds * 1000L;
while (System.currentTimeMillis() < deadline) {
Map<String, Object> data = getQrcodeStatus(qrcode);
String status = (String) data.getOrDefault("status", "");
if ("confirmed".equals(status)) {
String token = (String) data.getOrDefault("bot_token", "");
String newBaseUrl = (String) data.getOrDefault("baseurl", baseUrl);
return new QrLoginResult(token, newBaseUrl);
}
if ("expired".equals(status)) {
throw new RuntimeException("WeChat QR code expired, please retry login");
}
Thread.sleep(pollIntervalMs);
}
throw new RuntimeException("WeChat QR code not scanned within " + maxWaitSeconds + "s");
}
// ==================== 消息 API ====================
/**
* 长轮询获取新消息服务端最长持有 35
*
* @param cursor 上一次返回的 get_updates_buf首次传空字符串
* @return 包含 ret, msgs, get_updates_buf 等字段
*/
public Map<String, Object> getUpdates(String cursor) throws Exception {
Map<String, Object> body = new LinkedHashMap<>();
body.put("get_updates_buf", cursor != null ? cursor : "");
body.put("base_info", Map.of("channel_version", CHANNEL_VERSION));
HttpRequest request = applyHeaders(HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/ilink/bot/getupdates"))
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))))
.timeout(GETUPDATES_TIMEOUT)
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new RuntimeException("getUpdates failed: HTTP " + response.statusCode());
}
return objectMapper.readValue(response.body(), new TypeReference<>() {});
}
/**
* 发送消息
*
* @param msg 消息体遵循 iLink sendmessage 协议
* @return API 响应
*/
public Map<String, Object> sendMessage(Map<String, Object> msg) throws Exception {
Map<String, Object> body = new LinkedHashMap<>();
body.put("msg", msg);
body.put("base_info", Map.of("channel_version", CHANNEL_VERSION));
HttpRequest request = applyHeaders(HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/ilink/bot/sendmessage"))
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))))
.timeout(DEFAULT_TIMEOUT)
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new RuntimeException("sendMessage failed: HTTP " + response.statusCode());
}
return objectMapper.readValue(response.body(), new TypeReference<>() {});
}
/**
* 发送纯文本消息便捷方法
*
* @param toUserId 收件人 ID
* @param text 消息文本
* @param contextToken 上下文 token来自入站消息必需
*/
public void sendText(String toUserId, String text, String contextToken) throws Exception {
Map<String, Object> msg = new LinkedHashMap<>();
msg.put("from_user_id", "");
msg.put("to_user_id", toUserId);
msg.put("client_id", UUID.randomUUID().toString());
msg.put("message_type", 2); // BOT
msg.put("message_state", 2); // FINISH
msg.put("context_token", contextToken);
msg.put("item_list", List.of(Map.of(
"type", 1,
"text_item", Map.of("text", text)
)));
sendMessage(msg);
}
// ==================== 媒体下载 ====================
/**
* 下载 CDN 媒体文件并可选解密
* <p>
* iLink 媒体文件存储在 https://novac2c.cdn.weixin.qq.com/c2c
* 下载 URL 通过 encrypt_query_param 构建
*
* @param url 直接 HTTP URL如果有
* @param aesKeyParam AES keyhex / base64为空则不解密
* @param encryptQueryParam CDN 查询参数
* @return 解密后的文件字节
*/
public byte[] downloadMedia(String url, String aesKeyParam, String encryptQueryParam) throws Exception {
String downloadUrl;
if (encryptQueryParam != null && !encryptQueryParam.isBlank()) {
String cdnBase = "https://novac2c.cdn.weixin.qq.com/c2c";
String enc = URLEncoder.encode(encryptQueryParam, StandardCharsets.UTF_8);
downloadUrl = cdnBase + "/download?encrypted_query_param=" + enc;
} else if (url != null && url.startsWith("http")) {
downloadUrl = url;
} else {
throw new IllegalArgumentException("Cannot download media: no valid URL. url=" + url);
}
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(downloadUrl))
.GET()
.timeout(DOWNLOAD_TIMEOUT)
.build();
HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() != 200) {
throw new RuntimeException("downloadMedia failed: HTTP " + response.statusCode());
}
byte[] data = response.body();
if (aesKeyParam != null && !aesKeyParam.isBlank()) {
data = WeixinAesUtil.aesEcbDecrypt(data, aesKeyParam);
}
return data;
}
// ==================== 内部模型 ====================
/**
* QR 码登录结果
*/
public record QrLoginResult(String token, String baseUrl) {}
}

View File

@ -0,0 +1,103 @@
package vip.mate.channel.weixin;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
/**
* 微信 iLink Bot 媒体文件 AES-128-ECB 解密工具
* <p>
* CDN 上的媒体文件使用 AES-128-ECB + PKCS5Padding 加密
* key 有三种格式
* <ul>
* <li>Hex 字符串32 chars = 16 bytes image_item.aeskey</li>
* <li>Base64 编码的原始 16 字节 media.aes_key (Format A)</li>
* <li>Base64 编码的 hex 字符串 media.aes_key (Format B)</li>
* </ul>
*
* @author MateClaw Team
*/
public final class WeixinAesUtil {
private WeixinAesUtil() {}
/**
* AES-128-ECB 解密自动识别 key 格式
*
* @param data 加密数据
* @param keyParam AES keyhex / base64 / raw
* @return 解密后的数据
*/
public static byte[] aesEcbDecrypt(byte[] data, String keyParam) throws Exception {
byte[] key = parseAesKey(keyParam);
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"));
return cipher.doFinal(data);
}
/**
* 自动识别并解析 AES key
* <p>
* AES key 解析逻辑
* 1. 如果是 32/48/64 位纯 hex 字符串 直接 hex decode
* 2. 否则 Base64 decode如果结果是 16 字节 直接用Format A
* 3. 如果 Base64 decode 结果是 32 字节纯 hex hex decodeFormat B
*/
static byte[] parseAesKey(String keyParam) {
String raw = keyParam.strip();
// Format: raw hex string (e.g. image_item.aeskey 32 hex chars = 16 bytes)
if (isHex(raw) && (raw.length() == 32 || raw.length() == 48 || raw.length() == 64)) {
return hexToBytes(raw);
}
// Format: base64-encoded
byte[] decoded;
try {
// 补齐 base64 padding
String padded = raw;
while (padded.length() % 4 != 0) {
padded += "=";
}
decoded = Base64.getDecoder().decode(padded);
} catch (IllegalArgumentException e) {
decoded = raw.getBytes();
}
if (decoded.length == 16) {
// Format A: base64(raw 16 bytes)
return decoded;
}
if (decoded.length == 32 && isHex(new String(decoded))) {
// Format B: base64(hex string)
return hexToBytes(new String(decoded));
}
// Fallback: use as-is
if (decoded.length != 16 && decoded.length != 24 && decoded.length != 32) {
throw new IllegalArgumentException("Invalid AES key length: " + decoded.length);
}
return decoded;
}
private static boolean isHex(String s) {
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) {
return false;
}
}
return !s.isEmpty();
}
private static byte[] hexToBytes(String hex) {
int len = hex.length();
byte[] data = new byte[len / 2];
for (int i = 0; i < len; i += 2) {
data[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4)
+ Character.digit(hex.charAt(i + 1), 16));
}
return data;
}
}

View File

@ -0,0 +1,499 @@
package vip.mate.channel.weixin;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import vip.mate.channel.AbstractChannelAdapter;
import vip.mate.channel.ChannelMessage;
import vip.mate.channel.ChannelMessageRouter;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.workspace.conversation.model.MessageContentPart;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* 微信个人号渠道适配器 基于 iLink Bot HTTP API
* <p>
* 微信个人号渠道实现基于 iLink Bot HTTP API
* <ul>
* <li>HTTP 长轮询接收消息getupdates服务端最长 35s</li>
* <li>HTTP POST 发送消息sendmessage</li>
* <li>Bearer Token 认证可通过 QR 码扫码登录获取</li>
* <li>支持 text(1), image(2), voice/ASR(3), file(4), video(5) 消息类型</li>
* <li>基于 context_token 的消息去重和主动推送</li>
* </ul>
* <p>
* 会话 ID 规则
* <ul>
* <li>私聊weixin:{fromUserId}</li>
* <li>群聊weixin:group:{groupId}</li>
* </ul>
* <p>
* configJson 配置项
* <ul>
* <li>bot_token: iLink Bot Token扫码登录获取</li>
* <li>base_url: API 基础地址默认 https://ilinkai.weixin.qq.com</li>
* <li>media_download_enabled: 是否下载媒体文件默认 false</li>
* <li>media_dir: 媒体文件保存目录默认 data/media</li>
* </ul>
*
* @author MateClaw Team
*/
@Slf4j
public class WeixinChannelAdapter extends AbstractChannelAdapter {
public static final String CHANNEL_TYPE = "weixin";
/** 消息去重最大记录数 */
private static final int PROCESSED_IDS_MAX = 2000;
// ==================== 运行时状态 ====================
private ILinkClient client;
/** 长轮询线程 */
private volatile Thread pollThread;
/** 停止信号 */
private final AtomicBoolean stopSignal = new AtomicBoolean(false);
/** 长轮询游标 */
private volatile String cursor = "";
/** 消息去重集合LRU */
private final LinkedHashMap<String, Boolean> processedIds = new LinkedHashMap<>(256, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, Boolean> eldest) {
return size() > PROCESSED_IDS_MAX;
}
};
/** 用户最新 context_token 缓存(用于主动推送) */
private final ConcurrentHashMap<String, String> userContextTokens = new ConcurrentHashMap<>();
public WeixinChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter,
ObjectMapper objectMapper) {
super(channelEntity, messageRouter, objectMapper);
}
@Override
public String getChannelType() {
return CHANNEL_TYPE;
}
// ==================== 生命周期 ====================
@Override
protected void doStart() {
String botToken = getConfigString("bot_token", "");
String baseUrl = getConfigString("base_url", ILinkClient.DEFAULT_BASE_URL);
if (botToken.isBlank()) {
throw new RuntimeException("weixin: bot_token is required. Please scan QR code to obtain one.");
}
client = new ILinkClient(botToken, baseUrl, objectMapper);
// 启动长轮询线程
stopSignal.set(false);
cursor = "";
pollThread = new Thread(this::pollLoop, "weixin-poll-" + channelEntity.getId());
pollThread.setDaemon(true);
pollThread.start();
log.info("[weixin] Channel started: {} (token={}...)", channelEntity.getName(),
botToken.substring(0, Math.min(12, botToken.length())));
}
@Override
protected void doStop() {
stopSignal.set(true);
if (pollThread != null) {
pollThread.interrupt();
try {
pollThread.join(10_000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
pollThread = null;
}
client = null;
log.info("[weixin] Channel stopped: {}", channelEntity.getName());
}
// ==================== 长轮询循环 ====================
private void pollLoop() {
log.info("[weixin] Poll thread started");
while (!stopSignal.get() && !Thread.currentThread().isInterrupted()) {
try {
Map<String, Object> data = client.getUpdates(cursor);
// 更新游标
Object newCursor = data.get("get_updates_buf");
if (newCursor != null) {
cursor = newCursor.toString();
}
// 处理消息
Object msgsObj = data.get("msgs");
if (msgsObj instanceof List<?> msgs) {
for (Object msgObj : msgs) {
if (msgObj instanceof Map<?, ?> msg) {
try {
@SuppressWarnings("unchecked")
Map<String, Object> msgMap = (Map<String, Object>) msg;
handleInboundMessage(msgMap);
} catch (Exception e) {
log.error("[weixin] Failed to handle message: {}", e.getMessage(), e);
}
}
}
}
// ret=-1 是正常的长轮询超时无新消息
Object retObj = data.get("ret");
int ret = retObj instanceof Number n ? n.intValue() : -1;
if (ret != 0 && (msgsObj == null || ((List<?>) msgsObj).isEmpty())) {
if (ret != -1) {
log.warn("[weixin] getUpdates non-zero ret={}, retry in 3s", ret);
Thread.sleep(3000);
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
} catch (Exception e) {
if (!stopSignal.get()) {
log.error("[weixin] Poll error, retry in 5s: {}", e.getMessage());
try {
Thread.sleep(5000);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
break;
}
}
}
}
log.info("[weixin] Poll thread stopped");
}
// ==================== 入站消息处理 ====================
@SuppressWarnings("unchecked")
private void handleInboundMessage(Map<String, Object> msg) {
String fromUserId = getStr(msg, "from_user_id");
String toUserId = getStr(msg, "to_user_id");
String contextToken = getStr(msg, "context_token");
String groupId = getStr(msg, "group_id");
int msgType = msg.get("message_type") instanceof Number n ? n.intValue() : 0;
// 只处理用户机器人消息 (message_type == 1)
if (msgType != 1) {
return;
}
// 去重
String dedupKey = !contextToken.isBlank() ? contextToken
: fromUserId + "_" + getStr(msg, "msg_id");
synchronized (processedIds) {
if (processedIds.containsKey(dedupKey)) {
log.debug("[weixin] Duplicate message skipped: {}", dedupKey.substring(0, Math.min(40, dedupKey.length())));
return;
}
processedIds.put(dedupKey, Boolean.TRUE);
}
// 解析消息内容
List<MessageContentPart> contentParts = new ArrayList<>();
List<String> textParts = new ArrayList<>();
List<Map<String, Object>> itemList = (List<Map<String, Object>>) msg.getOrDefault("item_list", List.of());
boolean mediaDownloadEnabled = getConfigBoolean("media_download_enabled", false);
String mediaDir = getConfigString("media_dir", "data/media");
for (Map<String, Object> item : itemList) {
int itemType = item.get("type") instanceof Number n ? n.intValue() : 0;
switch (itemType) {
case 1 -> {
// Text
Map<String, Object> textItem = (Map<String, Object>) item.getOrDefault("text_item", Map.of());
String text = getStr(textItem, "text").strip();
if (!text.isEmpty()) {
textParts.add(text);
}
}
case 2 -> {
// Image
if (mediaDownloadEnabled) {
String path = downloadMediaItem(item, "image_item", "image.jpg", mediaDir);
if (path != null) {
MessageContentPart part = new MessageContentPart();
part.setType("image");
part.setPath(path);
part.setContentType("image/*");
contentParts.add(part);
} else {
textParts.add("[图片: 下载失败]");
}
} else {
textParts.add("[图片]");
}
}
case 3 -> {
// Voice 使用 ASR 语音识别文本
Map<String, Object> voiceItem = (Map<String, Object>) item.getOrDefault("voice_item", Map.of());
Map<String, Object> voiceTextItem = (Map<String, Object>) voiceItem.getOrDefault("text_item", Map.of());
String asrText = getStr(voiceTextItem, "text").strip();
if (!asrText.isEmpty()) {
textParts.add(asrText);
} else {
textParts.add("[语音: 无转写结果]");
}
}
case 4 -> {
// File
if (mediaDownloadEnabled) {
Map<String, Object> fileItem = (Map<String, Object>) item.getOrDefault("file_item", Map.of());
String fileName = getStr(fileItem, "file_name");
if (fileName.isBlank()) fileName = "file.bin";
String path = downloadMediaItem(item, "file_item", fileName, mediaDir);
if (path != null) {
MessageContentPart part = new MessageContentPart();
part.setType("file");
part.setPath(path);
part.setFileName(fileName);
contentParts.add(part);
} else {
textParts.add("[文件: 下载失败]");
}
} else {
textParts.add("[文件]");
}
}
case 5 -> {
// Video
if (mediaDownloadEnabled) {
String path = downloadMediaItem(item, "video_item", "video.mp4", mediaDir);
if (path != null) {
MessageContentPart part = new MessageContentPart();
part.setType("video");
part.setPath(path);
part.setContentType("video/*");
contentParts.add(part);
} else {
textParts.add("[视频: 下载失败]");
}
} else {
textParts.add("[视频]");
}
}
default -> textParts.add("[不支持的消息类型: " + itemType + "]");
}
}
// 组装文本
String textContent = String.join("\n", textParts).strip();
if (!textContent.isEmpty()) {
contentParts.addFirst(MessageContentPart.text(textContent));
}
if (contentParts.isEmpty()) {
return;
}
// 缓存 context_token用于主动推送
if (!fromUserId.isBlank() && !contextToken.isBlank()) {
userContextTokens.put(fromUserId, contextToken);
}
// 构建统一消息
boolean isGroup = !groupId.isBlank();
String chatId = isGroup ? groupId : null;
// replyToken 存储 contextToken + fromUserId格式: contextToken|fromUserId
String replyToken = contextToken + "|" + fromUserId;
ChannelMessage channelMessage = ChannelMessage.builder()
.messageId(getStr(msg, "msg_id"))
.channelType(CHANNEL_TYPE)
.senderId(fromUserId)
.senderName(fromUserId) // iLink API 不提供昵称
.chatId(chatId)
.content(textContent)
.contentType(contentParts.size() == 1 && "text".equals(contentParts.getFirst().getType()) ? "text" : "mixed")
.contentParts(contentParts)
.timestamp(LocalDateTime.now())
.replyToken(replyToken)
.rawPayload(msg)
.build();
log.info("[weixin] Recv: from={} group={} text_len={}",
fromUserId.length() > 20 ? fromUserId.substring(0, 20) : fromUserId,
groupId.length() > 20 ? groupId.substring(0, 20) : groupId,
textContent.length());
onMessage(channelMessage);
}
// ==================== 媒体下载 ====================
@SuppressWarnings("unchecked")
private String downloadMediaItem(Map<String, Object> item, String itemKey, String filenameHint, String mediaDir) {
try {
Map<String, Object> mediaItem = (Map<String, Object>) item.getOrDefault(itemKey, Map.of());
Map<String, Object> media = (Map<String, Object>) mediaItem.getOrDefault("media", Map.of());
String encryptQueryParam = getStr(media, "encrypt_query_param");
String aesKey;
// image_item 有顶级 aeskey (hex)
String aeskeyHex = getStr(mediaItem, "aeskey");
if (!aeskeyHex.isBlank()) {
aesKey = Base64.getEncoder().encodeToString(hexToBytes(aeskeyHex));
} else {
aesKey = getStr(media, "aes_key");
}
if (encryptQueryParam.isBlank()) {
log.warn("[weixin] No encrypt_query_param for media download");
return null;
}
byte[] data = client.downloadMedia("", aesKey, encryptQueryParam);
// 保存到本地
Path dir = Path.of(mediaDir);
Files.createDirectories(dir);
String safeFilename = filenameHint.replaceAll("[^a-zA-Z0-9._-]", "");
if (safeFilename.isBlank()) safeFilename = "media";
String urlHash = md5Short(encryptQueryParam);
Path filePath = dir.resolve("weixin_" + urlHash + "_" + safeFilename);
Files.write(filePath, data);
return filePath.toString();
} catch (Exception e) {
log.error("[weixin] Media download failed: {}", e.getMessage(), e);
return null;
}
}
// ==================== 发送消息 ====================
@Override
public void sendMessage(String targetId, String content) {
if (client == null || content == null || content.isBlank()) {
return;
}
try {
// targetId 格式: contextToken|userId
String[] parts = targetId.split("\\|", 2);
String contextToken = parts.length > 0 ? parts[0] : "";
String toUserId = parts.length > 1 ? parts[1] : "";
if (toUserId.isBlank() || contextToken.isBlank()) {
log.warn("[weixin] Cannot send: missing userId or contextToken in targetId");
return;
}
client.sendText(toUserId, content, contextToken);
} catch (Exception e) {
log.error("[weixin] Failed to send message: {}", e.getMessage(), e);
}
}
// ==================== 主动推送 ====================
@Override
public boolean supportsProactiveSend() {
return true;
}
@Override
public void proactiveSend(String targetId, String content) {
if (client == null || content == null || content.isBlank()) {
return;
}
try {
// targetId 可以是 userId weixin:userId
String userId = targetId;
if (userId.startsWith("weixin:group:")) {
userId = userId.substring("weixin:group:".length());
} else if (userId.startsWith("weixin:")) {
userId = userId.substring("weixin:".length());
}
String contextToken = userContextTokens.get(userId);
if (contextToken == null || contextToken.isBlank()) {
log.warn("[weixin] No cached context_token for user {}, cannot proactive send", userId);
return;
}
client.sendText(userId, content, contextToken);
log.info("[weixin] Proactive message sent to {}: {}chars", userId, content.length());
} catch (Exception e) {
log.error("[weixin] Proactive send failed: {}", e.getMessage(), e);
}
}
// ==================== QR 码登录 Controller 调用 ====================
/**
* 获取 QR 码登录信息
*
* @return 包含 qrcode, qrcode_img_content 等字段
*/
public Map<String, Object> getQrCode() throws Exception {
String baseUrl = getConfigString("base_url", ILinkClient.DEFAULT_BASE_URL);
ILinkClient tempClient = new ILinkClient("", baseUrl, objectMapper);
return tempClient.getBotQrcode();
}
/**
* 查询 QR 码扫码状态
*
* @param qrcode QR 码标识
* @return 状态信息
*/
public Map<String, Object> getQrCodeStatus(String qrcode) throws Exception {
String baseUrl = getConfigString("base_url", ILinkClient.DEFAULT_BASE_URL);
ILinkClient tempClient = new ILinkClient("", baseUrl, objectMapper);
return tempClient.getQrcodeStatus(qrcode);
}
// ==================== 工具方法 ====================
private static String getStr(Map<String, Object> map, String key) {
Object val = map.get(key);
return val != null ? val.toString() : "";
}
private static String md5Short(String input) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(input.getBytes());
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 4; i++) {
sb.append(String.format("%02x", digest[i]));
}
return sb.toString();
} catch (Exception e) {
return String.valueOf(input.hashCode());
}
}
private static byte[] hexToBytes(String hex) {
int len = hex.length();
byte[] data = new byte[len / 2];
for (int i = 0; i < len; i += 2) {
data[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4)
+ Character.digit(hex.charAt(i + 1), 16));
}
return data;
}
}

View File

@ -0,0 +1,57 @@
package vip.mate.common.result;
import lombok.Data;
import java.io.Serializable;
/**
* 统一响应结果封装
*
* @author MateClaw Team
*/
@Data
public class R<T> implements Serializable {
private static final long serialVersionUID = 1L;
/** 状态码 */
private int code;
/** 提示信息 */
private String msg;
/** 数据 */
private T data;
public static <T> R<T> ok() {
return result(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMsg(), null);
}
public static <T> R<T> ok(T data) {
return result(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMsg(), data);
}
public static <T> R<T> ok(String msg, T data) {
return result(ResultCode.SUCCESS.getCode(), msg, data);
}
public static <T> R<T> fail() {
return result(ResultCode.SYSTEM_ERROR.getCode(), ResultCode.SYSTEM_ERROR.getMsg(), null);
}
public static <T> R<T> fail(String msg) {
return result(ResultCode.SYSTEM_ERROR.getCode(), msg, null);
}
public static <T> R<T> fail(int code, String msg) {
return result(code, msg, null);
}
private static <T> R<T> result(int code, String msg, T data) {
R<T> r = new R<>();
r.setCode(code);
r.setMsg(msg);
r.setData(data);
return r;
}
}

View File

@ -0,0 +1,32 @@
package vip.mate.common.result;
import lombok.Getter;
/**
* 响应状态码枚举
*
* @author MateClaw Team
*/
@Getter
public enum ResultCode {
SUCCESS(200, "操作成功"),
UNAUTHORIZED(401, "未登录或Token已过期"),
FORBIDDEN(403, "没有权限"),
NOT_FOUND(404, "资源不存在"),
SYSTEM_ERROR(500, "系统内部错误"),
PARAM_ERROR(400, "参数校验失败"),
AGENT_NOT_FOUND(1001, "Agent不存在"),
AGENT_BUSY(1002, "Agent正在执行任务请稍后"),
LLM_ERROR(2001, "大模型调用失败"),
TOOL_NOT_FOUND(3001, "工具不存在"),
CHANNEL_ERROR(4001, "渠道消息发送失败");
private final int code;
private final String msg;
ResultCode(int code, String msg) {
this.code = code;
this.msg = msg;
}
}

View File

@ -0,0 +1,26 @@
package vip.mate.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* 会话历史上下文窗口管理配置
*
* @author MateClaw Team
*/
@Data
@ConfigurationProperties(prefix = "mate.agent.conversation.window")
public class ConversationWindowProperties {
/** 全局默认最大输入 token上下文窗口 */
private int defaultMaxInputTokens = 128000;
/** 历史 token 占比达此阈值触发压缩0-1 */
private double compactTriggerRatio = 0.75;
/** 压缩后保留最近 N 轮对话user+assistant 算一轮) */
private int preserveRecentPairs = 5;
/** 摘要自身最大 token 数 */
private int summaryMaxTokens = 800;
}

View File

@ -0,0 +1,187 @@
package vip.mate.config;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
import org.springframework.stereotype.Component;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.ResultSet;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Database bootstrap runner.
* <p>
* Executes schema.sql and tools-sync.sql on every startup.
* <p>
* For data.sql (seed data with locale-specific content):
* <ul>
* <li><b>Web/dev mode</b> (default, {@code mateclaw.setup.await-language-selection=false}):
* auto-initializes immediately with {@code mateclaw.setup.default-locale} (zh-CN).</li>
* <li><b>Desktop mode</b> ({@code mateclaw.setup.await-language-selection=true}):
* defers until the user selects a language via {@code POST /api/v1/setup/init}.</li>
* </ul>
*/
@Slf4j
@Component
@Order(1)
public class DatabaseBootstrapRunner implements ApplicationRunner {
private final DataSource dataSource;
private final JdbcTemplate jdbcTemplate;
/** Cached flag: true when running on MySQL/MariaDB, false for H2. */
private volatile Boolean isMySQL;
/**
* When true, wait for Desktop splash screen to call /setup/init with chosen language.
* When false (default), auto-initialize immediately on startup.
* <p>
* Desktop sets this via: {@code --mateclaw.setup.await-language-selection=true}
*/
@Value("${mateclaw.setup.await-language-selection:false}")
private boolean awaitLanguageSelection;
/** Default locale for auto-initialization. */
@Value("${mateclaw.setup.default-locale:zh-CN}")
private String defaultLocale;
/** Whether the database has been seeded with data (user table has rows). */
@Getter
private volatile boolean initialized = false;
/** Guards against concurrent init attempts. */
private final AtomicBoolean initInProgress = new AtomicBoolean(false);
public DatabaseBootstrapRunner(DataSource dataSource, JdbcTemplate jdbcTemplate) {
this.dataSource = dataSource;
this.jdbcTemplate = jdbcTemplate;
}
@Override
public void run(ApplicationArguments args) throws Exception {
runSchemaScript();
runToolSyncScript();
if (isDataAlreadySeeded()) {
initialized = true;
log.info("Database already initialized, skipping seed data");
return;
}
if (awaitLanguageSelection) {
// Desktop mode: wait for /api/v1/setup/init
log.info("Desktop mode: waiting for language selection via /api/v1/setup/init");
} else {
// Web/dev mode: auto-initialize immediately
log.info("Auto-initializing database with default locale: {}", defaultLocale);
initWithLocale(defaultLocale);
}
}
/**
* Initialize seed data with the given locale.
*
* @param locale "zh-CN" or "en-US"
* @return true if initialization was performed, false if already initialized or in progress
*/
public boolean initWithLocale(String locale) {
if (initialized) {
log.info("Database already initialized, ignoring init request");
return false;
}
if (!initInProgress.compareAndSet(false, true)) {
log.info("Initialization already in progress, ignoring concurrent request");
return false;
}
try {
// Double-check after acquiring the lock
if (isDataAlreadySeeded()) {
initialized = true;
return false;
}
String scriptName;
if (isMySQL()) {
scriptName = "en-US".equals(locale) ? "db/data-mysql-en.sql" : "db/data-mysql-zh.sql";
} else {
scriptName = "en-US".equals(locale) ? "db/data-en.sql" : "db/data-zh.sql";
}
log.info("Initializing database with locale={} using {}", locale, scriptName);
runScript(scriptName);
initialized = true;
log.info("Database initialization completed successfully");
return true;
} catch (Exception e) {
log.error("Failed to initialize database with locale={}", locale, e);
throw new RuntimeException("Database initialization failed", e);
} finally {
initInProgress.set(false);
}
}
private boolean isDataAlreadySeeded() {
try {
if (!tableExists("mate_user")) {
return false;
}
Integer userCount = jdbcTemplate.queryForObject("SELECT COUNT(1) FROM mate_user", Integer.class);
return userCount != null && userCount > 0;
} catch (Exception e) {
log.warn("Error checking database state", e);
return false;
}
}
private boolean tableExists(String tableName) throws Exception {
try (Connection connection = dataSource.getConnection()) {
DatabaseMetaData metaData = connection.getMetaData();
try (ResultSet rs = metaData.getTables(null, null, tableName.toUpperCase(), null)) {
if (rs.next()) {
return true;
}
}
try (ResultSet rs = metaData.getTables(null, null, tableName.toLowerCase(), null)) {
return rs.next();
}
}
}
private boolean isMySQL() {
if (isMySQL == null) {
try (Connection connection = dataSource.getConnection()) {
String dbProduct = connection.getMetaData().getDatabaseProductName().toLowerCase();
isMySQL = dbProduct.contains("mysql") || dbProduct.contains("mariadb");
log.info("Detected database: {} (MySQL mode: {})", dbProduct, isMySQL);
} catch (Exception e) {
log.warn("Failed to detect database type, falling back to H2 mode", e);
isMySQL = false;
}
}
return isMySQL;
}
private void runSchemaScript() {
runScript(isMySQL() ? "db/schema-mysql.sql" : "db/schema.sql");
}
private void runToolSyncScript() {
String script = isMySQL() ? "db/tools-sync-mysql.sql" : "db/tools-sync.sql";
runScript(script);
log.info("Tool sync completed ({})", script);
}
private void runScript(String path) {
ResourceDatabasePopulator populator = new ResourceDatabasePopulator();
populator.setContinueOnError(false);
populator.addScript(new ClassPathResource(path));
populator.execute(dataSource);
}
}

View File

@ -0,0 +1,32 @@
package vip.mate.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Graph 观察结果处理阈值配置
*
* @author MateClaw Team
*/
@Data
@ConfigurationProperties(prefix = "mate.agent.graph.observation")
public class GraphObservationProperties {
/** 单次工具结果最大字符数 */
private int maxSingleObservationChars = 4000;
/** 所有观察记录总字符数上限 */
private int maxTotalObservationChars = 12000;
/** 单次结果超过此阈值视为"大结果" */
private int largeResultThreshold = 3000;
/** 触发 summarize 的最小观察轮次 */
private int minRoundsForSummarize = 3;
/** 截断时保留前部占比0-1 */
private double headRatio = 0.4;
/** 截断省略标记(%d 会被替换为原始字符数) */
private String truncationMarker = "\n\n... [内容已截断,共 %d 字符,保留前后关键片段] ...\n\n";
}

View File

@ -0,0 +1,38 @@
package vip.mate.config;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Jackson 全局配置
* <p>
* 1. 容错非标准 LLM 响应启用 {@code READ_UNKNOWN_ENUM_VALUES_AS_NULL}
* 2. LongStringMyBatis Plus 生成的 19 Snowflake ID 超过 JS Number.MAX_SAFE_INTEGER (2^53-1)
* 序列化为字符串避免前端精度丢失
*
* @author MateClaw Team
*/
@Configuration
public class JacksonConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer enumTolerantCustomizer() {
return builder -> builder.featuresToEnable(
DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL
);
}
/**
* 全局 Long/long String 序列化防止前端 JS 精度丢失
*/
@Bean
public Jackson2ObjectMapperBuilderCustomizer longToStringCustomizer() {
return builder -> {
builder.serializerByType(Long.class, ToStringSerializer.instance);
builder.serializerByType(Long.TYPE, ToStringSerializer.instance);
};
}
}

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