mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 12:27:53 +08:00
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:
commit
579d60125b
17
.env.example
Normal file
17
.env.example
Normal 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
91
.gitignore
vendored
Normal 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
190
LICENSE
Normal 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
328
README.md
Normal file
@ -0,0 +1,328 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
# MateClaw
|
||||||
|
|
||||||
|
[](https://github.com/matevip/mateclaw)
|
||||||
|
[](https://mateclaw.mate.vip/)
|
||||||
|
[](https://adoptium.net/)
|
||||||
|
[](https://spring.io/projects/spring-boot)
|
||||||
|
[](https://vuejs.org/)
|
||||||
|
[](https://github.com/matevip/mateclaw)
|
||||||
|
[](LICENSE)
|
||||||
|
[](https://github.com/matevip/mateclaw/stargazers)
|
||||||
|
[](https://github.com/matevip/mateclaw/network)
|
||||||
|
|
||||||
|
[[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
328
README_zh.md
Normal file
@ -0,0 +1,328 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
# MateClaw
|
||||||
|
|
||||||
|
[](https://github.com/matevip/mateclaw)
|
||||||
|
[](https://mateclaw.mate.vip/)
|
||||||
|
[](https://adoptium.net/)
|
||||||
|
[](https://spring.io/projects/spring-boot)
|
||||||
|
[](https://vuejs.org/)
|
||||||
|
[](https://github.com/matevip/mateclaw)
|
||||||
|
[](LICENSE)
|
||||||
|
[](https://github.com/matevip/mateclaw/stargazers)
|
||||||
|
[](https://github.com/matevip/mateclaw/network)
|
||||||
|
|
||||||
|
[[文档](https://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 文档(Knife4j):http://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:18080(MySQL + 后端)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式三:桌面应用
|
||||||
|
|
||||||
|
从 [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 引擎 | StateGraph(ReAct + 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
54
docker-compose.yml
Normal 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:
|
||||||
13
mateclaw-server/Dockerfile
Normal file
13
mateclaw-server/Dockerfile
Normal 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
264
mateclaw-server/pom.xml
Normal 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 Core(StateGraph 工作流引擎) ===== -->
|
||||||
|
<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 Anthropic(Claude 模型支持) ===== -->
|
||||||
|
<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 SDK(WebSocket 长连接,无需公网 IP) ===== -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.dingtalk.open</groupId>
|
||||||
|
<artifactId>dingtalk-stream</artifactId>
|
||||||
|
<version>1.3.5</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- ===== 飞书 / Lark Open API SDK(WebSocket 长连接 + 事件分发) ===== -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- ===== JDA(Discord 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>
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1394
mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
Normal file
1394
mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
Normal file
File diff suppressed because it is too large
Load Diff
217
mateclaw-server/src/main/java/vip/mate/agent/AgentService.java
Normal file
217
mateclaw-server/src/main/java/vip/mate/agent/AgentService.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
mateclaw-server/src/main/java/vip/mate/agent/AgentState.java
Normal file
33
mateclaw-server/src/main/java/vip/mate/agent/AgentState.java
Normal 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
|
||||||
|
}
|
||||||
128
mateclaw-server/src/main/java/vip/mate/agent/AgentToolSet.java
Normal file
128
mateclaw-server/src/main/java/vip/mate/agent/AgentToolSet.java
Normal 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 Bean、ToolCallbackProvider、MCP 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
221
mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java
Normal file
221
mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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<GraphEvent>,最终写入 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增强版审批事件(包含 findings、severity、summary)
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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() == true):LLM 生成的文本内容
|
||||||
|
*
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<>();
|
||||||
|
|
||||||
|
/** 缓存 TTL:30 分钟 */
|
||||||
|
private static final long CACHE_TTL_MS = 30 * 60 * 1000L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将会话历史裁剪到上下文窗口内。
|
||||||
|
* <p>
|
||||||
|
* 预算计算包含 systemPrompt + 历史消息 + 当前用户消息,
|
||||||
|
* 确保最终拼接后不超出模型上下文窗口。
|
||||||
|
*
|
||||||
|
* @param messages 已转换的 Spring AI 消息列表(不含当前用户消息)
|
||||||
|
* @param systemPrompt 系统提示词文本
|
||||||
|
* @param currentUserMessage 当前用户输入(纳入窗口预算计算,但不会拼入返回结果)
|
||||||
|
* @param maxInputTokens 模型最大输入 token(0 或 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 {
|
||||||
|
|
||||||
|
/** 每条消息的固定开销 token(role 标记、分隔符等) */
|
||||||
|
static final int PER_MESSAGE_OVERHEAD = 4;
|
||||||
|
|
||||||
|
private TokenEstimator() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 估算文本 token 数。
|
||||||
|
* CJK 字符按 1:1,ASCII 按 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 delta(reasoningContent)</li>
|
||||||
|
* <li>通过 {@link ChatStreamTracker} 实时广播 content_delta / thinking_delta</li>
|
||||||
|
* <li>同时内部累积完整 text、thinking 和 tool calls</li>
|
||||||
|
* <li>流结束后返回 {@link StreamResult} 供节点写回 State</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* 所有面向用户的 LLM 节点(ReasoningNode、StepExecutionNode、PlanSummaryNode 等)
|
||||||
|
* 统一使用此 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 携带 id、name 和部分 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) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从内容中提取 <think>...</think> 标签内的文本作为 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 循环,
|
||||||
|
* 含 Summarizing、LimitExceeded 和 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 NodeOutput,FINAL_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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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+1、N+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 中提取 pendingId(best-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
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
* 负责设置最终的 finalAnswer、finalThinking 和 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>当前步骤索引 < 步骤总数 → 继续执行下一步(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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.md、PROFILE.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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.md、PROFILE.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 中最后一条通常是当前 UserMessage(goal),前面的是历史
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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_RESULTS(APPEND 策略,传入单条结果包装为 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
package vip.mate.agent.graph.plan.state;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plan-Execute 特有的状态键常量
|
||||||
|
* <p>
|
||||||
|
* 共享键(如 PENDING_EVENTS、CURRENT_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,不在此重复定义
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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";
|
||||||
|
|
||||||
|
// ===== Thinking(REPLACE 策略)=====
|
||||||
|
/** 最终完整 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 遇到需要审批的工具时设为 true,ObservationDispatcher 据此终止 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 策略)=====
|
||||||
|
/** 原始请求者 ID(IM 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";
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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> {
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
package vip.mate.approval;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批决策
|
||||||
|
*/
|
||||||
|
public enum ApprovalDecision {
|
||||||
|
APPROVED,
|
||||||
|
DENIED,
|
||||||
|
TIMEOUT
|
||||||
|
}
|
||||||
@ -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("等待您的批准");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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} 查找会话最早的 pending(FIFO)</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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 calls(JSON 数组) */
|
||||||
|
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; }
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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> {
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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> {
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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(需要 @机器人才响应)
|
||||||
|
// 注:如果已设置 botPrefix,shouldProcess() 已处理;此处处理 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());
|
||||||
|
}
|
||||||
|
// 如果有 botPrefix,shouldProcess() 已经过滤过非 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 目标标识(如 openId、chatId、sessionWebhook 等)
|
||||||
|
* @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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
* - Telegram:chat_id
|
||||||
|
* - Discord:channel_id
|
||||||
|
* <p>
|
||||||
|
* 用于 sendMessage 回复时确定目标
|
||||||
|
*/
|
||||||
|
private String replyToken;
|
||||||
|
|
||||||
|
/** 原始 payload(用于调试) */
|
||||||
|
private Object rawPayload;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 过滤方法 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除 <think>...</think> 标签及其内容
|
||||||
|
*/
|
||||||
|
public static String stripThinking(String content) {
|
||||||
|
if (content == null) return "";
|
||||||
|
return THINK_PATTERN.matcher(content).replaceAll("").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除工具调用信息:
|
||||||
|
* - <tool_call>...</tool_call>
|
||||||
|
* - <tool_result>...</tool_result>
|
||||||
|
* - 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) {}
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
* 采用每渠道独立队列架构:
|
||||||
|
* - 每渠道一个 BlockingQueue,N 个消费线程从队列取消息处理
|
||||||
|
* - 会话级锁保证同一 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 原始入站消息(含 replyToken、rawPayload 等上下文)
|
||||||
|
* @param conversationId 会话 ID
|
||||||
|
* @return 最终完整回复内容
|
||||||
|
*/
|
||||||
|
String processStream(Flux<StreamDelta> stream, ChannelMessage message, String conversationId);
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 WebSocket,webhook 回调不再用于接收消息
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 update,500ms 节流)
|
||||||
|
* - 卡片状态管理(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 钉钉会话 ID(openConversationId)
|
||||||
|
* @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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 模板 ID(message_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 重连:2s→4s→8s→16s→30s,无限重试
|
||||||
|
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 SDK(dingtalk-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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 Gateway(WebSocket 长连接)接收消息,通过 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());
|
||||||
|
|
||||||
|
// 代理配置:统一解析,同时应用到 JDA(OkHttp)和媒体下载(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
@ -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;
|
||||||
|
}
|
||||||
@ -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_id(oc_xxx)或 open_id(ou_xxx)
|
||||||
|
* - Telegram:chat_id
|
||||||
|
* - Discord:channel_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;
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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> {
|
||||||
|
}
|
||||||
@ -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> {
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 秒发送一次,直到回复完成)
|
||||||
|
* - 指数退避重连(2s→30s,无限重试)
|
||||||
|
* - 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: 2s→4s→8s→16s→30s 指数退避,无限重试
|
||||||
|
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 poll(timeout=20s),Telegram 服务器在有新消息时立即返回
|
||||||
|
* - 失败时通过 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理单个 Update(Long-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
@ -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;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册流状态(开始生成时调用)。
|
||||||
|
* 幂等:如果已存在活跃的 RunState(Replay 与原始流共享场景),复用它而非覆盖。
|
||||||
|
*/
|
||||||
|
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 deny、setup error 等)。
|
||||||
|
* 需要链式续跑的路径应使用 {@link #completeAndConsumeIfLast(String)}。
|
||||||
|
*
|
||||||
|
* @return true 如果这是最后一个 Flux(RunState 已被移除),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_delta(thinking 对重连恢复不是关键内容)。
|
||||||
|
* 必须在 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 是 replyToken(userId 或 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, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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) {}
|
||||||
|
}
|
||||||
@ -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 key(hex / 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) {}
|
||||||
|
}
|
||||||
@ -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 key(hex / 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 decode(Format 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
57
mateclaw-server/src/main/java/vip/mate/common/result/R.java
Normal file
57
mateclaw-server/src/main/java/vip/mate/common/result/R.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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";
|
||||||
|
}
|
||||||
@ -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. Long→String:MyBatis 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
Loading…
Reference in New Issue
Block a user