独立智能体开发-MyManus【实战】
课程安排
- 了解MyManus
- MyManus的实现原理
- 搭建MyManus工程
- 实现websocket业务模块
- 编写ReAct智能体
- 控制浏览器与页面标注
- 实现文件存储管理
- 数据多形态智能体
MyManus
什么是MyManus
理解MyManus之前,我们先了解下什么是Manus。
Manus,是中国的创业公司Monica发布的全球首款通用Agent(自主智能体)产品。Manus定位于一位性能强大的通用型助手,对于用户不仅仅是提供想法,而是能将想法付诸实践,真正解决问题。
下面通过一个视频来看下Manus的强大:
Manus
MyManus就是自己用代码来实现类似manus的智能体,来打造自己的manus。
实现效果
下面我们来看一下实现的效果,这里以【打开百度,搜索北京最近7天的天气,使用图表进行总结】为例,进行测试效果:

从上面的截图可以看出,当对AI大模型提出问题,由ReActPlanningAgent进行分析规划,再由ReActBrowserAgent分步执行:
- 打开浏览器,导航到百度页面
- 在百度搜索框中输入’北京最近7天天气’并点击搜索按钮
- 从搜索结果页面中提取北京最近7天的天气数据
最后由ChartAgent进行图表绘制生成图表页面。

也可以生成表格:
也可以生成驾车导航路线页面:
实现原理
实现MyManus的核心关键点:
- ReAct,即Reasoning Action,特指一个固定的 思考 → 行动 →观察 的循环执行模式,也就是需要AI大模型进行思考,做出规划,分步达成目标。
- 控制浏览器并且进行标注,就是AI大模型需要具备控制浏览器的能力,并且在浏览器内容中进行标注,方便进行操作,如:点击按钮、文本框输入内容等操作。
基本的实现流程如下(以统计北京近7天的天气为例):
搭建工程
注册Deepseek账号
由于Deepseek官方的服务相对比较慢,所以我们采用【火山引擎】的Deepseek账号,地址:https://www.volcengine.com/
注册账号并且完成实名认证,访问控制台,点击【火山方舟】:

创建API key:

将创建的API key保存到系统环境变量中:VOLCES_API_KEY

开通Deepseek服务:

拉取代码
通过git仓库,拉取基础代码:https://gitee.com/zhijun.zhang/my-manus.git

项目导入的依赖:
<?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>
<!-- 父项目配置,继承 Spring Boot 的默认配置 -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.3</version>
</parent>
<!-- 项目的坐标信息 -->
<groupId>cn.itcast</groupId>
<artifactId>my-manus</artifactId>
<version>1.0-SNAPSHOT</version>
<!-- 自定义属性配置 -->
<properties>
<!-- Spring AI 版本 -->
<spring-ai.version>1.0.0</spring-ai.version>
<!-- Java 编译版本 -->
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<!-- 项目编码格式 -->
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<!-- 依赖管理:统一管理依赖版本 -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<!-- 项目实际依赖 -->
<dependencies>
<!-- Spring Boot 测试模块 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Web 开发支持 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- WebSocket 支持 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- OpenAI 集成支持 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai</artifactId>
</dependency>
<!-- MCP 协议客户端支持 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-client</artifactId>
</dependency>
<!-- Lombok 简化实体类开发 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<!-- Hutool 工具类库 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.36</version>
</dependency>
<!-- Playwright 浏览器自动化工具 -->
<dependency>
<groupId>com.microsoft.playwright</groupId>
<artifactId>playwright</artifactId>
<version>1.51.0</version>
</dependency>
<!-- Jsoup HTML 解析库 -->
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.17.2</version>
</dependency>
<!-- Markdown 转换工具 -->
<dependency>
<groupId>com.vladsch.flexmark</groupId>
<artifactId>flexmark-html2md-converter</artifactId>
<version>0.64.0</version>
</dependency>
<!-- Apache Tika 核心库,用于文档解析 -->
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-core</artifactId>
<version>3.1.0</version>
</dependency>
</dependencies>
<!-- 构建插件 -->
<build>
<plugins>
<!-- Spring Boot Maven 插件,用于打包和运行 -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

运行前端
通过 npm run dev 命令运行前端:

通过浏览访问:http://localhost:5173/

websocket通信
前后端的网络通信采用websocket协议,之所以采用websocket,是因为在前后端交互的过程中,前后端需要互相通信,也就是前端会向后端发送数据,后端也会向前端发送数据。
WebSocketConfig
需要在项目中增加websocket相关的配置,才能支持websocket的通信。代码如下:
package cn.itcast.manus.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@Configuration
@EnableWebSocketMessageBroker // 启用websocket消息代理功能
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
// 启用简单的消息代理,用于向客户端发送消息数据
config.enableSimpleBroker("/queue");
// 设置应用目标的前缀
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// 注册一个STOMP的端点,允许任何客户端访问
registry.addEndpoint("/bs-dialog-websocket")
.setAllowedOriginPatterns("*");
}
}
对应的前端代码:

可以看到指定了连接地址,以及订阅接收消息的地址。

接收消息
数据结构
根据前端发来的数据结构,在后端需要接收数据,结构如下:
{ type: 'user', text: this.newMessage }
在后端定义DTO接收数据:
package cn.itcast.manus.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DialogMessageDTO {
public static final String TYPE_SERVER = "server";
// 消息类型
private String type = TYPE_SERVER;
// 消息内容
private String text;
// 图片地址
private String imageUrl;
// 文件地址
private String fileUrl;
// 链接地址
private String openUrl;
}
编写Controller
下面编写Controller来接收客户端发来的消息:
package cn.itcast.manus.controller;
import cn.itcast.manus.dto.DialogMessageDTO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.stereotype.Controller;
/**
* WebSocket 控制器,用于处理与 WebSocket 相关的消息路由和业务逻辑。
*/
@Slf4j
@Controller
@RequiredArgsConstructor
public class WebSocketController {
/**
* 处理发送到 "/enhanced-dialog" 路径的消息。
*
* @param message 接收到的消息内容,封装为 `DialogMessageDTO` 对象
* @param headerAccessor 提供对消息头部的访问,用于获取会话相关信息
*/
@MessageMapping("/enhanced-dialog")
public void enhancedDialog(@Payload DialogMessageDTO message, SimpMessageHeaderAccessor headerAccessor){
log.info("enhanced-dialog: {}", message);
//TODO 业务处理
}
}
测试

可以看到已经连接到服务端了。
下面测试发送消息:

消息会话
下面需要实现消息会话,完成消息的读取、发送功能。
定义接口
package cn.itcast.manus.message;
import cn.itcast.manus.dto.DialogMessageDTO;
public interface MessageSession {
/**
* 接收消息
*
* @param messageDTO 消息对象
*/
void receiveMessages(DialogMessageDTO messageDTO);
/**
* 读取消息
*/
String readMessage();
/**
* 发送消息
*/
void sendMessage(String msg);
/**
* 发送消息
*/
void sendMessage(DialogMessageDTO messageDTO);
}
编写实现
package cn.itcast.manus.message;
import cn.itcast.manus.dto.DialogMessageDTO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.simp.SimpMessageType;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.function.Supplier;
@Slf4j
public class WSSession implements MessageSession {
// 消息发送模板
private final SimpMessagingTemplate messagingTemplate;
// 消息缓存区,使用线程安全队列
private final BlockingQueue<DialogMessageDTO> buffer = new ArrayBlockingQueue<>(1024);
// 会话id提供者
private final Supplier<String> sessionIdProvider;
// 发送消息的目标
private final String destination;
public WSSession(Supplier<String> sessionIdProvider, String destination, SimpMessagingTemplate messagingTemplate) {
this.sessionIdProvider = sessionIdProvider;
this.destination = destination;
this.messagingTemplate = messagingTemplate;
}
@Override
public String readMessage() {
try {
return buffer.take().getText();
} catch (Exception e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
}
@Override
public void sendMessage(String msg) {
this.sendToClient(DialogMessageDTO.builder()
.type(DialogMessageDTO.TYPE_SERVER)
.text(msg)
.build());
}
/**
* 接收消息,将接受到的消息存储到缓冲区
*/
@Override
public void receiveMessages(DialogMessageDTO messageDTO) {
try {
buffer.put(messageDTO);
} catch (Exception e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
}
@Override
public void sendMessage(DialogMessageDTO messageDTO) {
messageDTO.setType(DialogMessageDTO.TYPE_SERVER);
this.sendToClient(messageDTO);
}
/**
* 将消息发送给客户端
* <p>
* 该方法负责将一个对话消息DTO(DialogMessageDTO)发送给特定客户端它通过获取当前会话ID(sessionId),
* 创建适当的消息头,并使用Spring的MessagingTemplate将消息实际发送到客户端如果在发送过程中发生任何异常,
* 它将捕获并记录错误
*
* @param messageDTO 包含要发送给客户端的消息信息的数据传输对象
*/
private void sendToClient(DialogMessageDTO messageDTO) {
try {
// 获取当前会话的ID
String sessionId = this.sessionIdProvider.get();
// 创建一个下行消息头,用于发送消息到客户端
SimpMessageHeaderAccessor downHeader = SimpMessageHeaderAccessor.create(SimpMessageType.MESSAGE);
// 设置消息头的会话ID
downHeader.setSessionId(sessionId);
// 允许底层消息头被修改
downHeader.setLeaveMutable(true);
// 使用MessagingTemplate将消息发送到特定用户
this.messagingTemplate.convertAndSendToUser(sessionId, destination, messageDTO, downHeader.getMessageHeaders());
} catch (Exception e) {
// 记录在发送消息过程中发生的异常
log.error("Exception in sendToClient", e);
}
}
}
会话管理
前面实现了会话对象,现在需要编写会话管理实现,需要完成sessionId与MessageSession关联以及管理。
package cn.itcast.manus.message;
import lombok.RequiredArgsConstructor;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component
@RequiredArgsConstructor
public class WSSessionManagement {
// 基本的消息发送模板
public final SimpMessagingTemplate messagingTemplate;
// 定义会话对象,来用存储会话信息,key为会话ID,value为会话对象
private final Map<String, MessageSession> sessionMap = new ConcurrentHashMap<>();
/**
* 通过会话id获取会话对象
*/
public MessageSession sessionDialog(String sessionId) {
return sessionMap.computeIfAbsent(sessionId, k -> new WSSession(() -> k, "/queue/dialog", this.messagingTemplate));
}
/**
* 清理会话对象
*
* @param sessionId 会话ID
*/
public void clean(String sessionId) {
this.sessionMap.remove(sessionId);
}
}
完善接收消息
前面已经测试了接收消息,接下来就将接收到的消息交由会话管理,方便后续的调用。
定义接口:
package cn.itcast.manus.service;
import cn.itcast.manus.dto.DialogMessageDTO;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
public interface WebSocketService {
/**
* 对话处理
*
* @param message 消息数据
* @param headerAccessor 消息头
*/
void enhancedDialog(DialogMessageDTO message, SimpMessageHeaderAccessor headerAccessor);
}
编写实现类:
package cn.itcast.manus.service.impl;
import cn.hutool.core.util.StrUtil;
import cn.itcast.manus.dto.DialogMessageDTO;
import cn.itcast.manus.message.WSSessionManagement;
import cn.itcast.manus.service.WebSocketService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class WebSocketServiceImpl implements WebSocketService {
private final WSSessionManagement wsSessionManagement;
@Override
public void enhancedDialog(DialogMessageDTO message, SimpMessageHeaderAccessor headerAccessor) {
if(StrUtil.isEmpty(message.getText())){
log.info("receive empty message!!!");
return;
}
// 获取会话id
var sessionId = headerAccessor.getSessionId();
// 根据会话id获取对话对象
var wsSession = this.wsSessionManagement.sessionDialog(sessionId);
// 将消息对象写入到会话中
wsSession.receiveMessages(message);
//对客户端发送消息
wsSession.sendMessage("消息已收到!");
}
}
在Controller中导入Service进行调用:
package cn.itcast.manus.controller;
import cn.itcast.manus.dto.DialogMessageDTO;
import cn.itcast.manus.service.WebSocketService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.stereotype.Controller;
/**
* WebSocket 控制器,用于处理与 WebSocket 相关的消息路由和业务逻辑。
*/
@Slf4j
@Controller
@RequiredArgsConstructor
public class WebSocketController {
private final WebSocketService webSocketService;
/**
* 处理发送到 "/enhanced-dialog" 路径的消息。
*
* @param message 接收到的消息内容,封装为 `DialogMessageDTO` 对象
* @param headerAccessor 提供对消息头部的访问,用于获取会话相关信息
*/
@MessageMapping("/enhanced-dialog")
public void enhancedDialog(@Payload DialogMessageDTO message, SimpMessageHeaderAccessor headerAccessor) {
log.info("enhanced-dialog: {}", message);
this.webSocketService.enhancedDialog(message, headerAccessor);
}
}
测试

可以看到,服务端已经可以向客户端发送消息了。
BaseAgent
通过前面的实现原理可以看出,MyManus的实现主要是各种Agent协调配置完成的,所以就需要通过代码实现各种Agent,而一些Agent必然会有些相同的逻辑,所以就需要对Agent进行抽取,实现代码的规范以及复用。
Agent接口
package cn.itcast.manus.agent;
import org.springframework.ai.chat.model.ChatModel;
public interface Agent {
/**
* 处理任务
*
* @param task 任务描述
* @return 处理结果
*/
String solveTask(String task);
/**
* 获取大模型实例
*
* @return ChatModel实例
*/
ChatModel chatModel();
/**
* 获取agent名称
*
* @return agent名称
*/
default String name() {
return this.getClass().getSimpleName();
}
}
BaseAgent
package cn.itcast.manus.agent;
import cn.hutool.core.util.StrUtil;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Getter
public abstract class BaseAgent implements Agent{
// 标识该agent是否在处理中,true:处理中,false:空闲中
private volatile boolean solving;
/**
* 处理任务
*
* @param task 任务描述
* @return 处理结果
*/
@Override
public synchronized String solveTask(String task) {
try {
this.solving = true;
return this.solve(task);
} catch (Exception e) {
log.error("error in agent solve", e);
return StrUtil.format("[{}]:{}", name(), e.getMessage());
} finally {
this.solving = false;
}
}
/**
* 抽象方法,由子类实现
*
* @param task 任务描述
* @return 处理结果
*/
protected abstract String solve(String task);
}
ReActAgent
ReAct(Reasoning and Acting)是一种结合逻辑推理与行动执行的框架,以提升大型语言模型(LLM)处理复杂任务的能力。其核心思想是让模型通过动态交替的思考→行动 → 观察循环,模拟人类解决问题的思维过程,并调用外部工具验证信息、获取数据,最终达成目标。
将LLM从“文本生成器”升级为“问题解决者”。
核心组成
- 推理(Reasoning)
模型通过思维链(Chain-of-Thought)分解任务需求,规划解决路径。例如,回答“如何分析销售数据下滑原因?”时,模型会先推理出需要调取数据、筛选关键指标、对比历史趋势等步骤 - 行动(Acting)
根据推理结果调用外部工具(如API、数据库)执行具体操作。例如:- 调用
query_by_product_name工具检索产品信息****; - 使用
calculate工具计算优惠价格****。
- 调用
- 观察(Observation)
接收工具返回的结果或环境反馈,更新模型对任务的理解,并触发下一轮推理或行动。例如,若调用促销查询工具后发现无优惠活动,模型会调整策略直接返回原价
📚 以电商客服场景为例:
- 用户提问:“足球现在有优惠吗?买一个多少钱?”
- 推理:模型判断需先查询产品库存和促销信息。
- 行动:调用
read_store_promotions工具获取促销数据。 - 观察:工具返回“足球当前享8折优惠”。
- 推理:计算折后价格需结合原价。
- 行动:调用
calculate工具计算最终价格。 - 观察:确认价格无误后生成回复****。
ReAct的优势
- 减少幻觉(Hallucination)
通过工具验证信息,避免模型虚构答案。例如,调用搜索引擎或数据库可确保促销信息的准确性****。 - 增强可解释性
推理步骤和工具调用过程可追溯,提升决策透明度****。 - 动态适应性
根据环境反馈实时调整策略,适用于多变场景(如库存变动、促销更新)****。 - 高效处理复杂任务
多轮循环机制支持拆解多步骤任务(如数据分析、行程规划)。
ReAct的设计
Prompt
ReAct的Prompt模式:
- 包含一个代表思考的字段: 【Thinking】通常需要表达【我因为何种原因选择执行了何种操作】
- 包含一个代表行动的字段: 【Action】通常这里包括具体的操作类型及参数
- 在Action被执行后,包含执行结果情况的字段: 【Observation】
- 其核心逻辑是,不断的重复 思考-行动-观察的过程,一步步抵达最终目标。
具体的Prompt如下:
你是一个任务规划AI,你遵循‘思考-行动-观察’循环来完成任务,在完成任务的过程中请遵循如下规则:
# 输入格式
[最终目标] 这是你最终要完成的任务目标
[可选Agent] 可用Agent列表及其能力概述,需要从中选择一个恰当的Agent来完成某个子任务
[子任务链] 当前的子任务链数据,包括子任务内容、执行所属Agent,在子任务链的末尾是最近执行的子任务,举例如下:
[BrowserAgent,Task:收集XXX数据,Result:XXX数据已收集,具体内容为...] -> [BrowserAgent,Task:收集YYY数据,Result:YYY数据已收集,具体内容为...] -> [CharAgent,Task:将之前收集到的数据绘制成表格,Result:表格已绘制完成,内容地址为...]
[最新已完成子任务结果] 你需要根据当前最新的结果,修正你的进一步规划
# 返回值规则
1.返回值格式 你需要保证返回合法的JSON格式,定义如下
{
"current_state": {
"evaluation_previous_goal": "Success|Failed|Unknown - 请分析当前的子任务链,检查最新目标/动作是否按任务原定目标成功完成。若发生意外情况需明确指出。简要说明达成或未达成目标的原因。",
"memory": "已执行事项及备忘要点详细说明。确保内容具体明确;必须严格记录每项操作的执行次数与剩余次数(例如:当前子任务完成度 0/10。后续继续执行abc和xyz步骤)",
"thinking": "针对行动清单中的每个执行动作,总结导致该操作的输入数据来源并简要说明,此处需要你模拟人类思考的行为,带入人称及情绪,在行动正确且逐渐向最终目标完成时体现出喜悦情感,在行动目标出错或对最终目标毫无进展时体现出悲伤情绪,在负面情绪严重时尝试其他思路",
},
"action":[{"one_action_name": {// action-specific parameter}}]
}
2.ACTION 尽管定义成了列表结构,但在任务规划流程中,你每次仅能使用1个action,可用action的定义如下:
- {"generateNext": {"agent": "BrowserAgent", "subTask": "从XX网站获取XX信息...", "maxStep":10 }} 使用此action规划下一个子任务节点
- {"done": {"success": true, "text": "..."}} 当最终完成目标任务时,使用此action结束你的规划任务
3.子任务的拆解
- 在subTask上使用中文
- subTask应该是独立且具体、明确的任务
- 不要将用户原始任务中的要素具体化,在满足用户要求的前提下保持抽象性
- 不要丢失任何原始任务中的要求,例如使用某个指定网站或是某种获取数据的方式
- 对于ReAct类型的Agent,结合当前分配子任务的复杂度,参考已完成子任务中的实际消耗步骤(resultStep,-1代表不支持设置步骤),预估给定一个maxStep作为最大行动步数限制,maxStep最大不能超过16
- 当子任务未能在指定步数内完成时,你需要结合完成情况考虑选择合适的解决方案,包括进一步拆分更细粒度的子任务、在允许的范围内增加步数配置、使用其他思路代替原有子任务内容等
- 请注意,一个子任务可能被部分完成,请你结合部分完成的具体情况,恰当的生成新的子任务,不要让部分完成的任务在新生成的子任务中重复执行。
4.任务的完成(done)
- 最重要的一点:在任务完成时,将最后一个子任务的结果完整的作为done操作的text参数传入,这个过程不允许省略、总结、或任何形式的丢失数据
- 完成前请对整个任务链上的子任务执行情况进行完整的考量,确定最终目标确实被达成
5.其他重要事项
- 最重要的一点:你的职责仅包括对任务的拆解与规划,不要使用done来直接来完成用户的任务,你必须将任务交给合适的Agent处理
- 不要虚构Agent,agent名称要包含在给出的可选Agent范围
- 当你需要为浏览器指定具体的访问站点时,请保证选择中国大陆境内站点,外网由于众所周知的原因暂时无法访问
程序流程设计

ReAct模式本身并未具体定义实现的方案,这里结合文献及开源项目Browser-use给出一个具体实现:
- 维持一个端上的系统状态上下文,它是一个抽象的概念,取决于Agent的功能需求,对于浏览器Agent来讲就是浏览器的最新状态(打开了几个tab、当前tab以及当前页面内容等大量数据共同构成)
- Action部分使用LLM的ToolCalling功能实现
- Thinking与Action均来自同一个AssistantMessage(ToolCall)返回,为了诱导LLM进行思考动作,这里将thinking作为一个固定参数,此时ReAct的一个基本参数结构被固定下来,即包含思考的参数以及一个action列表
- Observation独占一个UserMessage,并结合系统最新状态上下文触发下一次循环
- 使用一个固定的【done】Action来结束循环过程并收集最终结果,非done的Action的执行会反馈为对系统状态上下文进行的修改上
ReActBaseAgent
对于MyManus项目而言,任务规划Agent 和 浏览器Agent 都是基于ReAct思想实现的,而这两个Agent的实现也会存在一些相同的业务逻辑,所以需要封装ReActBaseAgent,后续只要继承ReActBaseAgent,再扩展编写自己的业务即可。
基本结构
package cn.itcast.manus.agent;
import cn.itcast.manus.config.ModelConfig;
import cn.itcast.manus.config.ReActConfig;
import jakarta.annotation.Resource;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
@Slf4j
public abstract class ReActBaseAgent extends BaseAgent {
@Resource(name = ModelConfig.MAIN_AGENT)
private ChatModel chatModel;
@Resource
protected ReActConfig reActConfig;
@Getter
protected int currentStep = 1;
// 是否停止标识
private boolean agentStop = false;
// 最终结果
private String finalResult;
@Override
protected String solve(String task) {
return this.reActSolve(task);
}
public String reActSolve(String task) {
// 初始化当前步骤
this.currentStep = 1;
// 初始化动作列表
this.initActionMap();
// 定义输入消息列表
List<Message> inputList = new LinkedList<>();
// 将当前任务加入到列表中
this.addInitInput(inputList, task);
// 定义 token 计数器
var tokenCount = new AtomicLong(0L);
// 如果没有标记为停止, 并且当前的步骤小于等于最大步骤,则进行循环
while (!this.agentStop && this.currentStep <= this.reActConfig.getMaxStep()) {
this.buildCurrentParse(inputList);
this.callingLLM(inputList, tokenCount);
log.info("step:{},tokenCount:{}", this.currentStep, tokenCount.get());
log.info("[{}👆]------------------", getClass().getSimpleName());
this.currentStep++;
}
// 最后一步检查
if (this.currentStep >= this.reActConfig.getMaxStep() && !this.agentStop) {
String text = """
注意,你的执行已达到最大步数限制,请立即使用done操作返回截至到目前的工作成果,
需要包含具体的内容,不允许任何省略,最后总结尚未完成的任务并对遇到的问题加以说明
""";
inputList.add(new UserMessage(text));
this.callingLLM(inputList, tokenCount);
}
return finalResult;
}
/**
* 调用 LLM
*/
private void callingLLM(List<Message> inputList, AtomicLong tokenCount) {
}
private void buildCurrentParse(List<Message> inputList) {
inputList.add(new UserMessage(this.getCurrentStatus()));
}
/**
* 获取任务当前的状态数据,使用L2M规则每次放入上下文的最末端
*
* @return
*/
protected abstract String getCurrentStatus();
/**
* 1.增加SystemMessage
* 2.增加主任务对应的UserMessage
* 3.增加One-Shot Example(可选)
*/
protected abstract void addInitInput(List<Message> inputList, String task);
/**
* 初始化action方法绑定,应与构造参数中传入的schema匹配
*/
protected void initActionMap() {
}
@Override
public ChatModel chatModel() {
return this.chatModel;
}
}
package cn.itcast.manus.config;
import lombok.Data;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Scope;
@Data
@Configuration
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class ReActConfig {
int maxStep = 100; // 最大步数
int maxActionPerCall = 10; // 每次调用最多执行多少次动作
boolean useVision = false; // 是否使用视觉
}
初始化动作
实现要点:
- 由于tool的定义是在
schema/schemaBaseReAct.json文件中,需要读取该文件的内容 - 将子类实现的具体tool,合并到工具定义中
代码实现:
package cn.itcast.manus.agent;
import cn.hutool.core.io.resource.ResourceUtil;
import cn.hutool.core.map.MapUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import cn.itcast.manus.config.ModelConfig;
import cn.itcast.manus.config.ReActConfig;
import cn.itcast.manus.message.MessageSession;
import jakarta.annotation.Resource;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.definition.DefaultToolDefinition;
import org.springframework.ai.tool.definition.ToolDefinition;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Function;
@Slf4j
public abstract class ReActBaseAgent extends BaseAgent {
@Resource(name = ModelConfig.MAIN_AGENT)
private ChatModel chatModel;
@Resource
protected ReActConfig reActConfig;
@Getter
protected int currentStep = 1;
// 是否停止标识
private boolean agentStop = false;
// 最终结果
private String finalResult;
protected MessageSession messageSession;
// 工具定义结构内容
private String toolSchema;
// 工具定义
private ToolDefinition toolDefinition;
// 定义动作映射,key为动作名称,value为动作执行函数(key:参数,value:执行返回值)
private final Map<String, Function<Map<String, Object>, String>> actionRegistry = new HashMap<>();
public ReActBaseAgent(MessageSession messageSession) {
this(messageSession, "schema/schemaBaseReAct.json");
}
public ReActBaseAgent(MessageSession messageSession, String toolSchemaPath) {
this.messageSession = messageSession;
this.toolSchema = ResourceUtil.readStr(toolSchemaPath, StandardCharsets.UTF_8);
this.refreshToolDefinition();
}
@Override
protected String solve(String task) {
return this.reActSolve(task);
}
public String reActSolve(String task) {
// 初始化当前步骤
this.currentStep = 1;
// 初始化动作列表
this.initActionMap();
// 定义输入消息列表
List<Message> inputList = new LinkedList<>();
// 将当前任务加入到列表中
this.addInitInput(inputList, task);
// 定义 token 计数器
var tokenCount = new AtomicLong(0L);
// 如果没有标记为停止, 并且当前的步骤小于等于最大步骤,则进行循环
while (!this.agentStop && this.currentStep <= this.reActConfig.getMaxStep()) {
this.buildCurrentParse(inputList);
this.callingLLM(inputList, tokenCount);
log.info("step:{},tokenCount:{}", this.currentStep, tokenCount.get());
log.info("[{}👆]------------------", getClass().getSimpleName());
this.currentStep++;
}
// 最后一步检查
if (this.currentStep >= this.reActConfig.getMaxStep()) {
String text = """
注意,你的执行已达到最大步数限制,请立即使用done操作返回截至到目前的工作成果,
需要包含具体的内容,不允许任何省略,最后总结尚未完成的任务并对遇到的问题加以说明
""";
inputList.add(new UserMessage(text));
this.callingLLM(inputList, tokenCount);
}
return finalResult;
}
protected void refreshToolDefinition(){
JSONObject jsonObject = JSONUtil.parseObj(this.toolSchema);
this.toolDefinition = DefaultToolDefinition.builder()
.name(jsonObject.getStr("name"))
.description(jsonObject.getStr("description"))
.inputSchema(jsonObject.getStr("inputSchema"))
.build();
}
/**
* 调用 LLM
*/
private void callingLLM(List<Message> inputList, AtomicLong tokenCount) {
}
private void buildCurrentParse(List<Message> inputList) {
inputList.add(new UserMessage(this.getCurrentStatus()));
}
/**
* 获取任务当前的状态数据,使用L2M规则每次放入上下文的最末端
*
* @return
*/
protected abstract String getCurrentStatus();
/**
* 1.增加SystemMessage
* 2.增加主任务对应的UserMessage
* 3.增加One-Shot Example(可选)
*/
protected abstract void addInitInput(List<Message> inputList, String task);
/**
* 初始化action方法绑定,应与构造参数中传入的schema匹配
*/
protected void initActionMap() {
actionRegistry.put("done", paramMap -> {
var result = MapUtil.getStr(paramMap, "text");
this.done(result);
return result;
});
toolCallbackProvider().forEach(this::mergeByToolCallbackProvider);
}
protected List<ToolCallbackProvider> toolCallbackProvider() {
return List.of();
}
/**
* 将已有Tool的定义并入ReAct的Schema中,例如MCP服务提供的能力
*/
private void mergeByToolCallbackProvider(ToolCallbackProvider toolCallback) {
// 读取原始的工具定义
JSONObject jsonObject = JSONUtil.parseObj(this.toolSchema);
String expression = "inputSchema.properties.action.items.properties";
// 读取具体工具的定义
JSONObject actionEntry = jsonObject.getByPath(expression, JSONObject.class);
for (ToolCallback tool : toolCallback.getToolCallbacks()) {
var def = tool.getToolDefinition();
// 将子类中定义的工具加入到工具定义中
actionEntry.set(def.name(), JSONUtil.parseObj(def.inputSchema()));
actionRegistry.put(def.name(), mp -> tool.call(JSONUtil.toJsonStr(mp)));
}
// 回写到原始对象中
jsonObject.putByPath(expression, actionEntry);
this.toolSchema = jsonObject.toString();
this.refreshToolDefinition();
}
/**
* 完成动作
*/
protected void done(String result) {
this.agentStop = true;
this.finalResult = result;
}
@Override
public ChatModel chatModel() {
return this.chatModel;
}
}
调用大模型
下面完成调用AI大模型的业务逻辑:
package cn.itcast.manus.agent;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.io.resource.ResourceUtil;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import cn.itcast.manus.agent.bean.ReActInput;
import cn.itcast.manus.config.ModelConfig;
import cn.itcast.manus.config.ReActConfig;
import cn.itcast.manus.message.MessageSession;
import cn.itcast.manus.util.JsonFinder;
import jakarta.annotation.Resource;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.metadata.ChatResponseMetadata;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.definition.DefaultToolDefinition;
import org.springframework.ai.tool.definition.ToolDefinition;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Function;
@Slf4j
public abstract class ReActBaseAgent extends BaseAgent {
public static final String AGENT_OUTPUT_METHOD = "AgentOutput";
@Resource(name = ModelConfig.MAIN_AGENT)
private ChatModel chatModel;
@Resource
protected ReActConfig reActConfig;
@Resource(name = ModelConfig.MAIN_AGENT_MODEL_CONFIG)
private ModelConfig.BaseModelConfig modelConfig;
@Getter
protected int currentStep = 1;
// 是否停止标识
private boolean agentStop = false;
// 最终结果
private String finalResult;
protected MessageSession messageSession;
// 工具定义结构内容
private String toolSchema;
// 工具定义
private DefaultToolDefinition toolDefinition;
// 定义动作映射,key为动作名称,value为动作执行函数(key:参数,value:执行返回值)
private final Map<String, Function<Map<String, Object>, String>> actionRegistry = new HashMap<>();
public ReActBaseAgent(MessageSession messageSession) {
this(messageSession, "schema/schemaBaseReAct.json");
}
public ReActBaseAgent(MessageSession messageSession, String toolSchemaPath) {
this.messageSession = messageSession;
this.toolSchema = ResourceUtil.readStr(toolSchemaPath, StandardCharsets.UTF_8);
this.refreshToolDefinition();
}
@Override
protected String solve(String task) {
return this.reActSolve(task);
}
public String reActSolve(String task) {
// 初始化当前步骤
this.currentStep = 1;
// 初始化动作列表
this.initActionMap();
// 定义输入消息列表
List<Message> inputList = new LinkedList<>();
// 将当前任务加入到列表中
this.addInitInput(inputList, task);
// 定义 token 计数器
var tokenCount = new AtomicLong(0L);
// 如果没有标记为停止, 并且当前的步骤小于等于最大步骤,则进行循环
while (!this.agentStop && this.currentStep <= this.reActConfig.getMaxStep()) {
this.buildCurrentParse(inputList);
this.callingLLM(inputList, tokenCount);
log.info("step:{},tokenCount:{}", this.currentStep, tokenCount.get());
log.info("[{}👆]------------------", getClass().getSimpleName());
this.currentStep++;
}
// 最后一步检查
if (this.currentStep >= this.reActConfig.getMaxStep()) {
String text = """
注意,你的执行已达到最大步数限制,请立即使用done操作返回截至到目前的工作成果,
需要包含具体的内容,不允许任何省略,最后总结尚未完成的任务并对遇到的问题加以说明
""";
inputList.add(new UserMessage(text));
this.callingLLM(inputList, tokenCount);
}
return finalResult;
}
protected void refreshToolDefinition() {
JSONObject jsonObject = JSONUtil.parseObj(this.toolSchema);
this.toolDefinition = new DefaultToolDefinition(
jsonObject.getStr("name"),
jsonObject.getStr("description"),
jsonObject.getStr("inputSchema"));
}
/**
* 调用 LLM
*/
private void callingLLM(List<Message> inputList, AtomicLong tokenCount) {
ChatResponse chatResponse;
try {
// 构造请求参数
OpenAiChatOptions chatOptions = OpenAiChatOptions.builder()
.toolCallbacks(this.getToolCallbacks()) // 添加工具回调
.internalToolExecutionEnabled(false) // 设置为false,自行控制对tool的调用过程
// 强制设置工具名
.toolChoice(OpenAiApi.ChatCompletionRequest.ToolChoiceBuilder.FUNCTION(AGENT_OUTPUT_METHOD))
.build();
// 定义 Prompt
Prompt req = new Prompt(inputList, chatOptions);
// 调用大模型
if (modelConfig.isStream()) {
// 流式调用
chatResponse = this.chatModel()
.stream(req)
.collectList()
.block()
.stream()
.findFirst()
.orElseThrow();
} else {
// 非流式调用
chatResponse = this.chatModel().call(req);
}
} catch (Exception e) {
log.error("调用AI大模型时出错!", e);
throw new RuntimeException(e);
}
// 从响应中提取token的使用数,累加到tokenCount中
Optional.of(chatResponse)
.map(ChatResponse::getMetadata)
.map(ChatResponseMetadata::getUsage)
.map(Usage::getTotalTokens)
.ifPresent(tokenCount::addAndGet);
// 提取出大模型的响应内容,进行后续的处理
Optional.of(chatResponse)
.map(ChatResponse::getResult)
.map(Generation::getOutput)
.ifPresent(assistantMessage -> this.processResponse(assistantMessage, inputList));
}
private void processResponse(AssistantMessage assistantMessage, List<Message> inputList) {
var text = assistantMessage.getText();
log.info("开始处理大模型的响应,内容:{}", text);
if (assistantMessage.hasToolCalls()) {
if (assistantMessage.getToolCalls().size() > 1) {
log.warn("找到1个以上的工具执行请求列表:{}", assistantMessage.getToolCalls());
}
var tool = CollUtil.getFirst(assistantMessage.getToolCalls());
if (StrUtil.equalsIgnoreCase(tool.name(), AGENT_OUTPUT_METHOD)) {
var reActInput = JSONUtil.toBean(tool.arguments(), ReActInput.class);
this.agentOutput(reActInput, inputList, assistantMessage);
} else {
log.error("未知工具名称:{}", tool.name());
}
return;
}
// 如果没有工具调用,则尝试从响应中解析出ReActInput
JsonFinder.findFirstJson(text)
.map(json -> JSONUtil.toBean(json, ReActInput.class))
.ifPresent(reActInput -> this.agentOutput(reActInput, inputList, assistantMessage));
}
private void agentOutput(ReActInput reActInput, List<Message> inputList, AssistantMessage assistantMessage) {
var status = reActInput.getStatus();
log.info("🏹 previous goal:{}", status.getEvaluationPreviousGoal());
log.info("💾 memory:{}", status.getMemory());
log.info("🧠 thinking:{}", status.getThinking());
// 向客户端发送消息
this.messageSession.sendMessage(String.format("[%s]%s", this.name(), status.getThinking()));
// 获取动作列表
var srcActionList = reActInput.getAction();
log.info("🛠 action:{}", reActInput.getAction());
var processedActionList = new LinkedList<Map<String, Object>>();
var resultBuilder = new StringBuilder();
// 1.run all action
for (var action : srcActionList) {
processedActionList.add(action);
var actName = action.keySet().stream().findFirst().orElseThrow();
try {
var actionResult = actionRegistry.get(actName).apply(BeanUtil.beanToMap(action.get(actName)));
resultBuilder.append(action).append("->").append(actionResult).append('\n');
} catch (Exception e) {
log.error("error in running action:{}", action, e);
// break loop
resultBuilder.append(action).append("->").append("Exception:").append(StrUtil.truncateUtf8(e.getMessage(), 32)).append('\n');
break;
}
if (srcActionList.indexOf(action) != srcActionList.size() - 1 && this.isStatusSignificantChanged()) {
// 如果当前动作不是最后一个动作,并且状态发生了显著变化,则通过 break 提前终止循环。
break;
}
}
if (processedActionList.size() < srcActionList.size()) {
// 如果已经执行的动作数量小于总动作数量,则说明状态发生了显著变化,需要忽略已经执行的动作。
// 注意:前面的操作会导致上下文发生重大变化,忽略以下操作:
resultBuilder.append("NOTICE:previous action leads a significant change on context,ignore following action:");
var ignore = new ArrayList<>(srcActionList);
ignore.removeAll(processedActionList);
ignore.forEach(act -> {
resultBuilder.append(act);
resultBuilder.append('\n');
});
// 您应该根据最新状态继续操作
resultBuilder.append("You should continue you step based on the newest status");
}
// 2.得到结果
var actionResult = resultBuilder.toString();
log.info("📃 obs:{}", actionResult);
// 3.按当前结果重建消息列表(历史部分)
// 删除最新的聊天消息(UserMessage)
inputList.remove(inputList.size() - 1);
// 把响应加入到输入列表中
inputList.add(assistantMessage);
if (assistantMessage.hasToolCalls()) {
var toolCall = CollUtil.getFirst(assistantMessage.getToolCalls());
// 添加工具消息响应
var toolResponseMessage = new ToolResponseMessage(
List.of(new ToolResponseMessage.ToolResponse(toolCall.id(), toolCall.name(), "success")));
inputList.add(toolResponseMessage);
}
inputList.add(new UserMessage(String.format("思路'%s'的行动结果如下:\n%s", status.getThinking(), actionResult)));
}
protected boolean isStatusSignificantChanged() {
return false;
}
private List<ToolCallback> getToolCallbacks() {
return List.of(new ToolCallback() {
@Override
public ToolDefinition getToolDefinition() {
return toolDefinition;
}
@Override
public String call(String toolInput) {
throw new UnsupportedOperationException("toolCalling should controlled by native program.");
}
});
}
private void buildCurrentParse(List<Message> inputList) {
inputList.add(new UserMessage(this.getCurrentStatus()));
}
/**
* 获取任务当前的状态数据,使用L2M规则每次放入上下文的最末端
*
* @return
*/
protected abstract String getCurrentStatus();
/**
* 1.增加SystemMessage
* 2.增加主任务对应的UserMessage
* 3.增加One-Shot Example(可选)
*/
protected abstract void addInitInput(List<Message> inputList, String task);
/**
* 初始action化方法绑定,应与构造参数中传入的schema匹配
*/
protected void initActionMap() {
actionRegistry.put("done", paramMap -> {
var result = MapUtil.getStr(paramMap, "text");
this.done(result);
return result;
});
toolCallbackProvider().forEach(this::mergeByToolCallbackProvider);
}
protected List<ToolCallbackProvider> toolCallbackProvider() {
return List.of();
}
/**
* 将已有Tool的定义并入ReAct的Schema中,例如MCP服务提供的能力
*/
private void mergeByToolCallbackProvider(ToolCallbackProvider toolCallback) {
// 读取原始的工具定义
JSONObject jsonObject = JSONUtil.parseObj(this.toolSchema);
String expression = "inputSchema.properties.action.items.properties";
// 读取具体工具的定义
JSONObject actionEntry = jsonObject.getByPath(expression, JSONObject.class);
for (ToolCallback tool : toolCallback.getToolCallbacks()) {
var def = tool.getToolDefinition();
// 将子类中定义的工具加入到工具定义中
actionEntry.set(def.name(), JSONUtil.parseObj(def.inputSchema()));
actionRegistry.put(def.name(), mp -> tool.call(JSONUtil.toJsonStr(mp)));
}
// 回写到原始对象中
jsonObject.putByPath(expression, actionEntry);
this.toolSchema = jsonObject.toString();
this.refreshToolDefinition();
}
/**
* 完成动作
*/
protected void done(String result) {
this.agentStop = true;
this.finalResult = result;
}
@Override
public ChatModel chatModel() {
return this.chatModel;
}
}
package cn.itcast.manus.agent.bean;
import cn.hutool.core.annotation.Alias;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Map;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ReActInput {
private List<Map<String, Object>> action;
@Alias("current_state")
private Status status;
@Data
public static class Status {
@Alias("evaluation_previous_goal")
private String evaluationPreviousGoal;
private String thinking;
private String memory;
}
}
prompt管理
项目中定义了很多的提示词文件,如下:

为了方便后续程序中的使用,我们将编写PromptManagement,对这些文件进行管理,主要实现的内容有:
- 读取
prompt目录下的所有文件,并且将其写到Map集合中,方便后续的调用 - 通过
name从上述的Map集合中查询prompt内容
代码实现:
package cn.itcast.manus.agent.prompt;
import cn.hutool.core.io.IoUtil;
import cn.hutool.core.util.StrUtil;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Slf4j
@Component
public class PromptManagement {
@Value("${prompt.locale:/prompt}")
private String locale;
private static final Map<String, String> PROMPT_MAP = new ConcurrentHashMap<>();
/**
* 初始化操作,读取prompt目录下的所有文件,并将文件内容保存到PROMPT_MAP中
*
* @throws IOException 读取文件异常
*/
@PostConstruct
void init() throws IOException {
String pattern = StrUtil.format("classpath:{}/*.txt", this.locale);
PathMatchingResourcePatternResolver loader = new PathMatchingResourcePatternResolver();
Resource[] resources = loader.getResources(pattern);
for (Resource resource : resources) {
PROMPT_MAP.put(resource.getFilename(), IoUtil.readUtf8(resource.getInputStream()));
log.info("加载prompt({})文件成功!", resource.getFilename());
}
}
/**
* 根据名称获取prompt内容
*
* @param name prompt名称
* @return prompt内容
*/
public String getPrompt(String name) {
String key = StrUtil.endWith(name, ".txt") ? name : name + ".txt";
return PROMPT_MAP.get(key);
}
}
ReActPlanningAgent
ReActPlanningAgent是用来计划任务的,也是MyManus项目的入口智能体。它的主要任务就是接收用户发来的任务,对任务进行进行拆解,再调用其他的Agent进行执行,获取结果,最终给用户返回。
初始化输入Message
package cn.itcast.manus.agent.planning;
import cn.hutool.core.util.StrUtil;
import cn.itcast.manus.agent.ReActBaseAgent;
import cn.itcast.manus.agent.prompt.PromptManagement;
import cn.itcast.manus.constants.Constant;
import cn.itcast.manus.message.MessageSession;
import jakarta.annotation.Resource;
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 java.util.HashMap;
import java.util.List;
import java.util.Map;
@Slf4j
public class ReActPlanningAgent extends ReActBaseAgent {
@Resource
private PromptManagement promptManagement;
// 存放agent,key:agentName,value:功能描述
private final Map<String, String> agentInfo = new HashMap<>();
public ReActPlanningAgent(MessageSession messageSession) {
super(messageSession);
}
@Override
protected String getCurrentStatus() {
return "";
}
@Override
protected boolean isStatusSignificantChanged() {
// 计划Agent每次的action只需要执行一个,后面的action不再执行
return true;
}
@Override
protected void addInitInput(List<Message> inputList, String task) {
// 添加系统消息
inputList.add(SystemMessage.builder()
.text(promptManagement.getPrompt(Constant.Prompts.PLANNING_SYSTEM))
.build());
// 添加用户消息
inputList.add(UserMessage.builder()
.text(this.getPromptPlanningUserTask(task))
.build());
// 添加说明
inputList.add(UserMessage.builder()
.text("[你的历史记忆从此处开始]")
.build());
}
private String getPromptPlanningUserTask(String task) {
var promptPlanningSystem = this.promptManagement.getPrompt(Constant.Prompts.PLANNING_USER_TASK);
var param = Map.of(
Constant.TASK, task,
Constant.AGENT_DATA, String.valueOf(agentInfo));
return StrUtil.format(promptPlanningSystem, param);
}
}
AgentFactory
接下来,我们需要定义AgentFactory进行查找ReActPlanningAgent,然后进行执行。
package cn.itcast.manus.agent;
import cn.itcast.manus.agent.planning.ReActPlanningAgent;
import cn.itcast.manus.enums.AgentTypeEnum;
import cn.itcast.manus.message.MessageSession;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Scope;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
@Configuration
public class AgentFactory {
public static final Map<AgentTypeEnum, Function<MessageSession, Agent>> AGENT_FUNC_MAP = new HashMap<>();
/**
* 初始化方法,完成Agent的注册
*/
@PostConstruct
public void init() {
// 注册任务规划智能体
AGENT_FUNC_MAP.put(AgentTypeEnum.RE_ACT_PLANNING_AGENT, this::reActPlanningAgent);
}
/**
* 根据agentTypeEnum获取对应的Agent
*/
public static Function<MessageSession, Agent> getAgent(AgentTypeEnum agentTypeEnum) {
Function<MessageSession, Agent> fun = AGENT_FUNC_MAP.get(agentTypeEnum);
if (null == fun) {
throw new IllegalArgumentException("找不到对应的智能体: " + agentTypeEnum);
}
return fun;
}
/**
* 任务规划智能体,交由Spring管理
*
* @param messageSession 会话对象
* @return 查找到的智能体实例
*/
@Bean
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public Agent reActPlanningAgent(MessageSession messageSession) {
return new ReActPlanningAgent(messageSession);
}
}
在WebSocketService中,查找任务规划Agent,进行执行:
package cn.itcast.manus.service.impl;
import cn.hutool.core.util.StrUtil;
import cn.itcast.manus.agent.AgentFactory;
import cn.itcast.manus.dto.DialogMessageDTO;
import cn.itcast.manus.enums.AgentTypeEnum;
import cn.itcast.manus.message.WSSessionManagement;
import cn.itcast.manus.service.WebSocketService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.stereotype.Service;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@Slf4j
@Service
@RequiredArgsConstructor
public class WebSocketServiceImpl implements WebSocketService {
private final WSSessionManagement wsSessionManagement;
private final ExecutorService executor = Executors.newCachedThreadPool();
@Override
public void enhancedDialog(DialogMessageDTO message, SimpMessageHeaderAccessor headerAccessor) {
if(StrUtil.isEmpty(message.getText())){
log.info("receive empty message!!!");
return;
}
// 获取会话id
var sessionId = headerAccessor.getSessionId();
// 根据会话id获取对话对象
var wsSession = this.wsSessionManagement.sessionDialog(sessionId);
// 将消息对象写入到会话中
wsSession.receiveMessages(message);
// 通过智能体进行处理业务
var agent = AgentFactory.getAgent(AgentTypeEnum.RE_ACT_PLANNING_AGENT).apply(wsSession);
Runnable src = () -> {
try {
// 获取客户端发来的消息
var rawInput = wsSession.readMessage();
// 通过规划agent进行执行
var finalResult = agent.solveTask(rawInput);
log.info("complete with final result:{}", finalResult);
wsSession.sendMessage(finalResult);
wsSession.sendMessage("任务流程结束");
} finally {
wsSessionManagement.clean(sessionId);
}
};
executor.execute(src);
}
}
测试
重启服务进行测试,测试的问题是:列举出中国的省会城市,按照名称的首字符进行分组,制作成表格输出







返回的内容如下:
{
"current_state": {
"evaluation_previous_goal": "Unknown - 这是任务的开始,尚未执行任何子任务。",
"memory": "任务刚刚开始,尚未执行任何操作。当前子任务完成度 0/3。",
"thinking": "这是一个新的任务,目标是列举中国的省会城市并按名称首字符分组制作表格。首先需要收集中国的省会城市列表,然后按照首字符进行分组,最后制作成表格。虽然可选Agent列表为空,但任务本身可以通过规划来完成。"
},
"action": [{
"generateNext": {
"agent": "BrowserAgent",
"subTask": "从中国行政区划网站获取中国的省会城市列表",
"maxStep": 10
}
}]
}


测试到这里,虽然没有完全测试成功,但是基本的流程可以动起来了,后面只需要注册Tool,就可以进行执行了。
定义规划子任务Tool
代码实现:
package cn.itcast.manus.agent.planning;
import cn.hutool.core.util.StrUtil;
import cn.itcast.manus.agent.ReActBaseAgent;
import cn.itcast.manus.agent.prompt.PromptManagement;
import cn.itcast.manus.constants.Constant;
import cn.itcast.manus.message.MessageSession;
import jakarta.annotation.Resource;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
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.tool.ToolCallbackProvider;
import org.springframework.ai.support.ToolCallbacks;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
@Slf4j
public class ReActPlanningAgent extends ReActBaseAgent {
@Resource
private PromptManagement promptManagement;
@Resource(name = ModelConfig.PLAN_AGENT)
private ChatModel chatModel;
// 存放agent,key:agentName,value:功能描述
private final Map<String, String> agentInfo = new HashMap<>();
// 线程安全的动态数组,用来存储子任务链
private final CopyOnWriteArrayList<SubTaskNode> subTaskChain = new CopyOnWriteArrayList<>();
public ReActPlanningAgent(MessageSession messageSession) {
super(messageSession);
}
@Override
public String reActSolve(String task) {
this.subTaskChain.clear();
return super.reActSolve(task);
}
@Override
protected String getCurrentStatus() {
return "";
}
@Override
protected boolean isStatusSignificantChanged() {
// 计划Agent每次的action只需要执行一个,后面的action不再执行
return true;
}
@Override
protected void addInitInput(List<Message> inputList, String task) {
// 添加系统消息
inputList.add(SystemMessage.builder()
.text(promptManagement.getPrompt(Constant.Prompts.PLANNING_SYSTEM))
.build());
// 添加用户消息
inputList.add(UserMessage.builder()
.text(this.getPromptPlanningUserTask(task))
.build());
// 添加说明
inputList.add(UserMessage.builder()
.text("[你的历史记忆从此处开始]")
.build());
}
private String getPromptPlanningUserTask(String task) {
var promptPlanningSystem = this.promptManagement.getPrompt(Constant.Prompts.PLANNING_USER_TASK);
var param = Map.of(
Constant.TASK, task,
Constant.AGENT_DATA, String.valueOf(agentInfo));
return StrUtil.format(promptPlanningSystem, param);
}
@Tool(description = "规划下一个子任务节点")
public String generateNext(@ToolParam(description = "agent名称") String agent,
@ToolParam(description = "子任务内容") String subTask,
@ToolParam(description = "完成任务的最大步数") int maxStep) {
var res = subTaskChain.stream()
.reduce((x, y) -> y) // 获取最后一个元素
.map(p -> { // 对最后一个任务进行描述,生成了新的子任务
var result = "子任务{}的执行结果:\n{}\n基于此结果成功规划下一个子任务节点:{}";
return StrUtil.format(result, p.getSubTask(), p.getResult(), subTask);
})
// 没有子任务,就生成新的任务
.orElseGet(() -> StrUtil.format("成功规划下一个子任务节点:{}", subTask));
// 生成新的子任务节点
SubTaskNode subTaskNode = SubTaskNode.builder()
.agent(agent)
.subTask(subTask)
.maxStep(maxStep)
.build();
// 将子任务加入到任务链中
this.subTaskChain.add(subTaskNode);
return res;
}
@Override
protected List<ToolCallbackProvider> toolCallbackProvider() {
// 将当前对象中标记为@Tool解析出来,作为工具回调
return List.of(() -> ToolCallbacks.from(ReActPlanningAgent.this));
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class SubTaskNode {
private String agent;
private String subTask;
private int maxStep;
private String result;
private int resultStep = -1;
public String strInStatus() {
return StrUtil.format("[{},Task:{},Result:{},resultStep:{}]",
agent,
subTask,
StrUtil.truncateUtf8(result, 20),
resultStep);
}
}
@Override
public ChatModel chatModel() {
return this.chatModel;
}
}
测试
还是之前的测试方法:


合并完成后的工具定义:
{
"name": "AgentOutput",
"description": "main output method",
"inputSchema": {
"type": "object",
"properties": {
"current_state": {
"type": "object",
"properties": {
"evaluation_previous_goal": {
"type": "string"
},
"memory": {
"type": "string"
},
"thinking": {
"type": "string"
}
},
"required": [
"evaluation_previous_goal",
"memory",
"thinking"
]
},
"action": {
"type": "array",
"items": {
"type": "object",
"properties": {
"done": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"text": {
"type": "string"
}
},
"required": [
"success",
"text"
]
},
"generateNext": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"agent": {
"type": "string",
"description": "agent名称"
},
"subTask": {
"type": "string",
"description": "子任务内容"
},
"maxStep": {
"type": "integer",
"format": "int32",
"description": "完成任务的最大步数"
}
},
"required": [
"agent",
"subTask",
"maxStep"
],
"additionalProperties": false
}
}
}
}
},
"required": [
"current_state",
"action"
]
}
}




由于代码还没有完全编写完成,所以功能还不能完全测试成功,先测试到这里。
查询当前状态
查询当前的状态,就是需要将子任务链中的最后一个任务执行的结果,拼接到主任务中,进行后续的执行。但是,由于现在还没有其他的Agent,所以我们先不去查找Agent,先完成结果的拼接。
@Override
protected String getCurrentStatus() {
//TODO 获取任务链中的最后一个任务,查找到对应的Agent进行执行
// 将子任务链中的结果以字符串拼接,拼接格式为:子任务1->子任务2->子任务3
var chainStr = subTaskChain.stream()
.map(SubTaskNode::strInStatus)
.reduce((x, y) -> x + "->" + y)
.orElse("[Empty]");
// 将子任务链中最后一个任务的结果拼接
var latestStr = subTaskChain.stream()
.reduce((x, y) -> y)
.map(SubTaskNode::getResult)
.orElse("[Empty]");
// 将结果拼接到模板中,作为下次调用大模型的历史对话
var params = Map.of(
"stepData", StrUtil.format("{}/{}", super.currentStep, super.reActConfig.getMaxStep()),
"dateTime", DateUtil.now(),
"subTaskChain", chainStr,
"latestResult", latestStr);
return StrUtil.format(this.promptManagement.getPrompt(Constant.Prompts.PLANNING_STATUS), params);
}
控制浏览器与页面标注
前面基本完成了任务规划Agent,下面需要实现BrowserAgent,来控制浏览器已经进行数据的获取,所以,就需要使用到Playwright技术了。
Playwright简介
Playwright是微软开源的现代化浏览器自动化工具,支持Chromium、Firefox、WebKit等多浏览器,提供跨平台(Windows/Linux/macOS)和跨语言(Java/Python/.NET等)的API。
其核心特性包括:
- 无头模式/有头模式:可隐藏或显示浏览器界面,便于调试或观察操作过程****。
- 元素定位与交互:支持CSS、XPath、文本等多种定位方式,模拟点击、输入等用户行为****。
- 自动化测试:常用于端到端测试、爬虫、网页交互自动化等场景。
📚 Chromium由 Google 主导开发的开源浏览器,是众多浏览器(如 Chrome、Edge、Opera 等)的技术基础。Chrome 是 Chromium 的商业化版本,添加了自动更新、Flash 支持等专有功能。
导入依赖
在pom.xml中添加依赖:
<!-- Playwright 浏览器自动化工具 -->
<dependency>
<groupId>com.microsoft.playwright</groupId>
<artifactId>playwright</artifactId>
<version>1.51.0</version>
</dependency>
打开浏览器
package cn.itcast.playwright;
import com.microsoft.playwright.Browser;
import com.microsoft.playwright.BrowserType;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.Playwright;
import org.junit.jupiter.api.Test;
public class TestPlaywright {
// 测试网站导航功能
@Test
public void testLaunch() {
// 创建Playwright实例并管理资源
try (Playwright playwright = Playwright.create()) {
// 获取Chromium浏览器类型
BrowserType chromium = playwright.chromium();
// 启动Chromium浏览器,设置非无头模式以便观察
Browser browser = chromium.launch(new BrowserType.LaunchOptions().setHeadless(false));
// 新建一个页面
Page page = browser.newPage();
// 设置页面操作的默认超时时间为60秒
page.setDefaultTimeout(60_000);
// 导航到指定URL
page.navigate("https://www.baidu.com/");
// 关闭浏览器
browser.close();
}
}
}
浏览器控制
使用playwright可以控制浏览器打开页面,并且可以对页面中的元素进行操作,比如:填写表单、点击按钮等操作,下面我们通过代码来演示下:
@Test
public void testDomOperation() {
// 创建Playwright实例并管理资源
try (Playwright playwright = Playwright.create()) {
// 获取Chromium浏览器类型
BrowserType chromium = playwright.chromium();
// 启动Chromium浏览器,设置非无头模式以便观察
Browser browser = chromium.launch(new BrowserType.LaunchOptions().setHeadless(false));
// 新建一个页面
Page page = browser.newPage();
// 设置页面操作的默认超时时间为60秒
page.setDefaultTimeout(60_000);
// 导航到指定URL
page.navigate("https://www.baidu.com/");
// 获取输入框对象,进行填充数据
var input = page.locator("#kw");
input.clear();
input.fill("北京今天的天气");
// 找到提交按钮,触发点击事件
Locator submit = page.locator("#su");
submit.click();
// 获取页面的html的内容
String html = page.content();
System.out.println(html);
// 获取页面的纯文本内容
String bodyText = page.innerText("body");
System.out.println(bodyText);
// 关闭浏览器
browser.close();
}
}
页面标注
为什么要用页面标注?
在上面的演示中,对输入框的填充、按钮的点击,是我们人为的分析了页面之后得到的,如果是不同的网站,按钮的id是不一样的,所以id是不能写死的,在结合了AI大模型之后,就可以交给大模型来分析,决定怎么操作,但是,如果把整个页面发送给大模型的话,消耗的token太多了,也可能会超出token的限制。
所以,发送给AI大模型之前,就需要对页面中的元素内容进行提取,提取有效的元素内容即可,一些无效的内容就不提取了,比如:不可见的元素、css、js等,这些内容就没有必要发送给AI大模型了。这样的技术就是被称之为:页面标注。
实现原理
实现页面标注的核心原理:在原页面中执行一段写好的js脚本,对页面进行标注,并且获取到标记的内容。

下面我们手动的来测试下:
第一步,在浏览器中打开百度:

第二步,打开开发者工具,在控制台中粘贴buildDomTree.js文件中的内容,并且按下回车键,格式如下:

第三步,执行**tt()**方法:

第四步,查看页面中的变化:

可以看到,页面已经标注了。
接下来,看一下tt()方法的返回值,内容如下:
看资源
可以看到,返回的内容是一个json数据,包含了2个属性内容,分别是rootId和map,其中rootId是根节点id,map是具体的节点内容。
节点关系如下:
在下面的节点中,有一些节点,有highlightIndex属性,这个就是页面中看到的数字标号,也就是标记出来认为有用的内容:


每一个标注的元素,都包含了data-testid属性,后续可以根据这个属性来定位元素进行操作:
代码实现
上面是手动测试的方式,下面我们通过代码的方式来运行这一段js脚本:
@Test
public void testPageAnnotation() {
// 获取js文件内容
String buildDomTree = ResourceUtil.readUtf8Str("js/buildDomTree.js");
// 创建Playwright实例并管理资源
try (Playwright playwright = Playwright.create()) {
// 获取Chromium浏览器类型
BrowserType chromium = playwright.chromium();
// 启动Chromium浏览器,设置非无头模式以便观察
Browser browser = chromium.launch(new BrowserType.LaunchOptions().setHeadless(false));
// 新建一个页面
Page page = browser.newPage();
// 设置页面操作的默认超时时间为60秒
page.setDefaultTimeout(60_000);
// 导航到指定URL
page.navigate("https://www.baidu.com/");
// 页面执行标注js
Object evaluate = page.evaluate(buildDomTree);
JSONObject jsonObject = JSONUtil.parseObj(evaluate);
// 获取根节点的indexId
var rootId = jsonObject.getInt("rootId");
// 将map中所有的节点元素都放到allElement中
Map<Integer, JSONObject> allElement = new TreeMap<>();
JSONObject map = jsonObject.getJSONObject("map");
map.forEach((k, v) -> allElement.put(Convert.toInt(k), (JSONObject) v));
// 获取页面中所有【有价值】的内容,这部分内容就是可以发给大模型进行分析的
String body = this.fromAllElementByTree(rootId, allElement);
System.out.println(body);
// TODO 把 body 内容发给AI大模型,进行分析,即可知道要执行下一步的index下标,进行执行
// 关闭浏览器
browser.close();
}
}
/**
* 递归生成页面元素树的字符串
*/
private String fromAllElementByTree(Integer indexId, Map<Integer, JSONObject> allElement) {
JSONObject node = allElement.get(indexId);
var childList = childIdxList(node);
String childContent = childList.stream()
.map(id -> this.fromAllElementByTree(id, allElement))
.filter(StrUtil::isNotBlank)
.reduce((x, y) -> x + "\n" + y)
.orElse(StrUtil.EMPTY);
if (!node.getBool("isVisible", false)) {
return childContent;
}
if ("TEXT_NODE".equals(node.getStr("type"))) {
return node.getStr("text") + "\n" + childContent;
}
if (node.containsKey("highlightIndex")) {
String highlightIndex = node.getStr("highlightIndex");
String tag = Optional.ofNullable(node.getStr("tagName")).orElse("?");
String attr = Optional.ofNullable(node.getJSONObject("attributes"))
.map(this::collectAttribute)
.filter(s -> !s.equals(childContent))
.orElse(StrUtil.EMPTY);
boolean childHighLighted = childList.stream().anyMatch(p -> this.containsHighlighted(allElement, p));
return childHighLighted ? childContent : String.format("[%s]<%s %s;%s/>", highlightIndex, tag, attr, StrUtil.removeAllSuffix(childContent, "\n"));
}
return childContent;
}
private List<Integer> childIdxList(JSONObject node) {
return Optional.of(node)
.filter(j -> j.containsKey("children"))
.map(j -> j.getJSONArray("children")
.stream()
.map(Convert::toInt)
.toList())
.orElse(Collections.emptyList());
}
/**
* 收集节点的属性信息
*/
private String collectAttribute(JSONObject node) {
Set<String> attributeKey = Set.of("title", "type", "name", "role", "tabindex", "aria-label", "placeholder", "value", "alt", "aria-expanded", "class");
return node.entrySet().stream()
.filter(e -> attributeKey.contains(e.getKey()))
.map(Map.Entry::getValue)
.map(String::valueOf)
.reduce((x, y) -> x + ";" + y)
.orElse(StrUtil.EMPTY);
}
private boolean containsHighlighted(Map<Integer, JSONObject> allElement, Integer indexId) {
JSONObject node = allElement.get(indexId);
if (node.containsKey("highlightIndex")) {
return true;
}
return childIdxList(node).stream().anyMatch(p -> this.containsHighlighted(allElement, p));
}
获取到的body内容如下:
[0]<a mnav c-font-normal c-color-t;新闻/>
[1]<a mnav c-font-normal c-color-t;hao123/>
[2]<a mnav c-font-normal c-color-t;地图/>
[3]<a mnav c-font-normal c-color-t;贴吧/>
[4]<a mnav c-font-normal c-color-t;视频/>
[5]<a mnav c-font-normal c-color-t;图片/>
[6]<a mnav c-font-normal c-color-t;网盘/>
[7]<a mnav c-font-normal c-color-t;文库/>
[9]<img ;/>
[10]<a tj_briicon;s-bri c-font-normal c-color-t;更多/>
[11]<a operate-wrapper;/>
设置
[12]<a s-top-login-btn c-btn c-btn-primary c-btn-mini lb
;tj_login;登录/>
[13]<input wd;s_ipt;;新冠阳性检出率连续16周上升;/>
[14]<input submit;百度一下;bg s_btn;/>
[16]<img ;/>
今日已解决
0
1
2
3
4
5
个问题
[17]<a hot-refresh c-font-normal c-color-gray2;
换一换/>
[18]<a title-content c-link c-font-medium c-line-clamp1;
特朗普怒批普京疯了 还点名泽连斯基/>
新
[20]<a title-content tag-width c-link c-font-medium c-line-clamp1;1
夫妻俩在加沙救人9个孩子却被炸死/>
热
[21]<a title-content tag-width c-link c-font-medium c-line-clamp1;6
优衣库大搞辣妹风 抛弃普通人/>
热
[22]<a title-content c-link c-font-medium c-line-clamp1;2
王健林再卖48座万达广场/>
[23]<a title-content c-link c-font-medium c-line-clamp1;7
孙颖莎躺下的汗水印出China字样/>
[24]<a title-content c-link c-font-medium c-line-clamp1;3
美线货运订舱就像“抢票”/>
[25]<a title-content tag-width c-link c-font-medium c-line-clamp1;8
美女机器人击倒对手后拍屁股挑衅/>
热
[26]<a title-content tag-width c-link c-font-medium c-line-clamp1;4
中使馆提醒:消除买外国媳妇错误思想/>
新
[27]<a title-content c-link c-font-medium c-line-clamp1;9
知名主持人程成生病住院/>
[28]<img ;/>
可以看出,一个页面中的有价值的内容已经精简出来了,并且包含了标注id。
📚 需要注意的是,最后程序给大模型提交的标签id比页面中标记的要少一些,因为程序里判断了父子关系,如果一个可点击区域有多个div并且是嵌套的父子关系,那么只取一个标签id。
ReActBrowserAgent
接下来,我们来编写ReActBrowserAgent智能体,通过控制浏览器进行网页的访问,获取数据,配合PlanningAgent完成相关的任务。
PlaywrightManagement
package cn.itcast.manus.playwright;
import com.microsoft.playwright.BrowserContext;
import com.microsoft.playwright.BrowserType.LaunchPersistentContextOptions;
import com.microsoft.playwright.Playwright;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.Getter;
import org.springframework.stereotype.Component;
import java.io.File;
import java.nio.file.Path;
import java.util.function.Function;
/**
* 封装了 Playwright 管理
*/
@Getter
@Component
public class PlaywrightManagement {
// Playwright 实例,用于创建和管理浏览器
private Playwright playwright;
public <T> T browserContextOperation(Function<BrowserContext, T> func) {
// 默认使用路径 /tmp/playwrightContext 和非无头模式启动 Chromium
LaunchPersistentContextOptions op = new LaunchPersistentContextOptions();
op.setHeadless(false);
Path path = new File("/tmp/playwrightContext").toPath();
// 封装了 try-with-resources 模式自动关闭浏览器资源
try (var ctx = this.playwright.chromium().launchPersistentContext(path, op)) {
return func.apply(ctx);
}
}
/**
* 在 Bean 初始化时创建 Playwright 实例
*/
@PostConstruct
void init() {
this.playwright = Playwright.create();
}
/**
* 在 Bean 销毁前关闭 Playwright,释放资源
*/
@PreDestroy
void destroy() {
this.playwright.close();
}
}
PageSession
我们需要对于页面的操作进行封装。
package cn.itcast.manus.agent.browser;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.io.resource.ResourceUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import cn.itcast.manus.agent.prompt.PromptManagement;
import cn.itcast.manus.constants.Constant;
import cn.itcast.manus.service.PageContentExtractService;
import com.microsoft.playwright.BrowserContext;
import com.microsoft.playwright.BrowserContext.WaitForPageOptions;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.Page.ScreenshotOptions;
import com.microsoft.playwright.Page.WaitForLoadStateOptions;
import com.microsoft.playwright.Page.WaitForSelectorOptions;
import com.microsoft.playwright.Page.WaitForURLOptions;
import com.microsoft.playwright.TimeoutError;
import com.microsoft.playwright.options.LoadState;
import jakarta.annotation.Resource;
import lombok.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import java.util.*;
import java.util.Map.Entry;
import java.util.function.Predicate;
import java.util.stream.Stream;
/**
* 对于 Playwright 的页面会话,用于管理页面的导航、动作和元素分析等操作。
*/
@Slf4j
public class PageSession {
private static final String PLAYWRIGHT_CONTAINER_REMOVE = "document.getElementById(\"playwright-highlight-container\")?.remove()";
private static final String PLAYWRIGHT_DATATESTID_REMOVE = """
document.querySelectorAll('*').forEach(element => {
if (element.hasAttribute('data-testid')) {
element.removeAttribute('data-testid');
}
});
""";
public static final String AMOUNT = "amount";
private static final String HIGHLIGHT_INDEX = "highlightIndex";
// 等待超时时间,单位为毫秒
private static final long WAIT_TIMEOUT = 5000L;
@Resource
private PromptManagement promptManagement;
@Resource
private PageContentExtractService pageContentExtractService;
private final Map<Integer, JSONObject> allElement = new TreeMap<>();
private Integer rootId;
private final String buildDomJS;
private final Set<String> attributeKey;
private final BrowserContext browserContext;
private final List<Page> openInOperation = new LinkedList<>();
@Setter
@Getter
private int pageIndex;
@Getter
private String pageListInfo;
private int flagNavi = 0;
private int flagNewPage = 0;
private int flagTabChanged = 0;
private String originalContent;
public PageSession(BrowserContext browserContext) {
this.browserContext = browserContext;
this.pageIndex = 0;
this.attributeKey = Set.of("title", "type", "name", "role", "tabindex", "aria-label", "placeholder", "value", "alt", "aria-expanded", "class");
this.buildDomJS = ResourceUtil.readUtf8Str("js/buildDomTree.js");
}
/**
* 清除页面状态标志
*/
public void clearFlag() {
this.flagNavi = 0;
this.flagNewPage = 0;
this.flagTabChanged = 0;
}
/**
* 刷新页面信息,包括当前页面的标题、URL
*/
private void refreshPageInfo() {
var pageListInfo = new StringBuilder();
var page = listPageWithoutExtension();
var pageArr = page.toArray(Page[]::new);
var lst = Stream.iterate(0, i -> i + 1).limit(pageArr.length)
.map(i -> new BrowserTabInfo(i, pageArr[i].title(), pageArr[i].url()))
.toList();
pageListInfo.append(JSONUtil.toJsonStr(lst)).append("\n");
pageListInfo.append("current at index:").append(this.pageIndex).append("\n");
pageListInfo.append("using 'switchTab' action to change browser tab\n");
this.pageListInfo = pageListInfo.toString();
this.originalContent = currentPage().content();
}
/**
* 是否发生异常情况
*/
public boolean isStatusSignificantChanged() {
if (flagNavi != 0 || flagNewPage != 0 || flagTabChanged != 0) {
return true;
}
try {
var page = currentPage();
var opt = new WaitForSelectorOptions();
opt.setTimeout(WAIT_TIMEOUT);
var handle = page.waitForSelector("#playwright-highlight-container", opt);
return handle == null;
} catch (Exception e) {
log.trace("significantChanged by exception", e);
return true;
}
}
private void reAnalyze() {
this.refreshPageInfo();
this.removeAnalyzeContainer();
var page = currentPage();
var result = page.evaluate(buildDomJS);
var jo = JSONUtil.parseObj(result);
var map = jo.getJSONObject("map");
// 根节点id
this.rootId = jo.getInt("rootId");
// 将所有的节点信息写入到allElement中
map.forEach((k, v) -> allElement.put(Convert.toInt(k), (JSONObject) v));
}
/**
* 等待新页面的出现,超时时间为 3 秒。
*/
private Optional<Page> waitForNewPage() {
// waiting for new page
try {
var opt = new WaitForPageOptions();
opt.setTimeout(WAIT_TIMEOUT);
var newPage = this.browserContext.waitForPage(opt, () -> {});
this.waitPageIdle(newPage);
this.flagNewPage = 1;
return Optional.of(newPage);
} catch (TimeoutError e) {
log.trace("no page found after waiting 5 s");
return Optional.empty();
}
}
/**
* 等待页面加载完成,超时时间为 5 秒
*/
private void waitPageIdle(Page p) {
try {
p.waitForLoadState();
var opt = new WaitForLoadStateOptions();
opt.setTimeout(WAIT_TIMEOUT);
p.waitForLoadState(LoadState.NETWORKIDLE, opt);
} catch (TimeoutError e) {
log.trace("ignore waiting loadstatus timeout.", e);
}
}
/**
* 获取当前页面对象
*/
private Page currentPage() {
return this.listPageWithoutExtension().get(pageIndex);
}
/**
* 列出所有非扩展页面
*/
private List<Page> listPageWithoutExtension() {
return browserContext.pages().stream()
.filter(p -> !p.url().toLowerCase().startsWith("chrome-extension"))
.toList();
}
/**
* 移除页面中的分析容器
*/
public void removeAnalyzeContainer() {
var page = this.listPageWithoutExtension();
page.forEach(p -> {
p.evaluate(PLAYWRIGHT_CONTAINER_REMOVE);
p.evaluate(PLAYWRIGHT_DATATESTID_REMOVE);
});
}
/**
* 清理页面资源,关闭打开的页面
*/
public void cleanUp() {
this.removeAnalyzeContainer();
this.openInOperation.forEach(Page::close);
}
/**
* 等待页面导航完成
*/
private boolean waitNavi(Page page, String oldUrl) {
try {
var waitForURLOptions = new WaitForURLOptions();
waitForURLOptions.setTimeout(WAIT_TIMEOUT);
Predicate<String> urlChanged = s -> !s.equalsIgnoreCase(oldUrl);
page.waitForURL(urlChanged, waitForURLOptions);
this.flagNavi = 1;
return true;
} catch (TimeoutError e) {
log.trace("ignore waiting loadstatus timeout.", e);
return false;
}
}
/**
* 定位到指定页面,并更新页面索引
*/
private void locateOn(Page page) {
this.pageIndex = listPageWithoutExtension().indexOf(page);
this.openInOperation.add(page);
}
/**
* 向上滚动页面
*/
public String scrollUp(Map<String, Object> arg) {
var page = currentPage();
int amount = Convert.toInt(arg.get(AMOUNT));
page.evaluate(String.format("window.scrollBy(0, -%s);", amount));
return "SUCCESS";
}
/**
* 向下滚动页面
*/
public String scrollDown(Map<String, Object> arg) {
var page = currentPage();
int amount = Convert.toInt(arg.get(AMOUNT));
page.evaluate(String.format("window.scrollBy(0, %s);", amount));
return "SUCCESS";
}
/**
* 获取当前页面的状态信息,包括 URL、页面数据、滚动信息等
*/
public String getCurrentPageStatus(int currentStep, int maxStep) {
this.reAnalyze();
var page = this.currentPage();
var params = new HashMap<>();
params.put("url", page.url());
params.put("pageListInfo", getPageListInfo());
var pageData = fromAllElementByTree(rootId);
params.put("pageData", pageData);
params.put("stepData", String.format("%s/%s", currentStep, maxStep));
params.put("dateTime", String.valueOf(new Date()));
var sc = this.getScrollInfo();
params.put("scrollData",
String.format(
"...%s pixels above,%s pixels below,using extract_content action to see more information...",
sc.getPixelsAbove(), sc.getPixelsBelow()));
return StrUtil.format(promptManagement.getPrompt(Constant.Prompts.PAGE_STATUS), params);
}
private boolean containsHighlighted(Integer indexId) {
var node = this.allElement.get(indexId);
if (node.containsKey(HIGHLIGHT_INDEX)) {
return true;
}
return childIdxList(node).stream().anyMatch(this::containsHighlighted);
}
private List<Integer> childIdxList(JSONObject node) {
return Optional.of(node)
.filter(j -> j.containsKey("children"))
.map(j -> j.getJSONArray("children")
.stream()
.map(Convert::toInt)
.toList())
.orElse(Collections.emptyList());
}
/**
* 递归生成页面元素树的字符串
*/
private String fromAllElementByTree(Integer indexId) {
var node = this.allElement.get(indexId);
var childList = this.childIdxList(node);
var childContent = childList.stream()
.map(this::fromAllElementByTree)
.filter(StrUtil::isNotBlank)
.reduce((x, y) -> x + "\n" + y)
.orElse(StrUtil.EMPTY);
if (!node.getBool("isVisible", false)) {
return childContent;
}
if ("TEXT_NODE".equals(node.getStr("type"))) {
return node.getStr("text") + "\n" + childContent;
}
if (node.containsKey(HIGHLIGHT_INDEX)) {
var highlightIndex = node.getStr(HIGHLIGHT_INDEX);
var tag = Optional.ofNullable(node.getStr("tagName")).orElse("?");
var attr = Optional.ofNullable(node.getJSONObject("attributes")).map(this::collectAttribute)
.filter(s -> !s.equals(childContent)).orElse(StrUtil.EMPTY);
var childHighLighted = childList.stream().anyMatch(this::containsHighlighted);
return childHighLighted ? childContent : String.format("[%s]<%s %s;%s/>", highlightIndex, tag, attr, StrUtil.removeAllSuffix(childContent, "\n"));
}
return childContent;
}
/**
* 收集节点的属性信息
*/
private String collectAttribute(JSONObject node) {
return node.entrySet().stream()
.filter(e -> attributeKey.contains(e.getKey()))
.map(Entry::getValue)
.map(String::valueOf)
.reduce((x, y) -> x + ";" + y)
.orElse(StrUtil.EMPTY);
}
public ScrollInfo getScrollInfo() {
var page = currentPage();
var scrollY = Convert.toInt(page.evaluate("window.scrollY"));
var viewportHeight = Convert.toInt(page.evaluate("window.innerHeight"));
var totalHeight = Convert.toInt(page.evaluate("document.documentElement.scrollHeight"));
return ScrollInfo.builder()
.pixelsAbove(scrollY)
.pixelsBelow(totalHeight - (scrollY + viewportHeight))
.build();
}
@Tool(description = "根据目标,提取相关的内容数据")
public String extractContent(@ToolParam(description = "要提取的内容描述") String goal) {
return this.pageContentExtractService.extractContent(originalContent, this.fromAllElementByTree(rootId), goal);
}
@Tool(description = "切换到指定的页面标签页")
public String switchTab(@ToolParam(description = "页面id") int pageId) {
this.pageIndex = pageId;
this.reAnalyze();
this.flagTabChanged = 1;
return "Action Result:page index change to " + pageId;
}
/**
* 点击指定索引的元素,并处理可能的新页面打开或导航
*/
@Tool(description = "点击指定索引的元素")
public String clickElement(@ToolParam(description = "下标") int index) {
Page page = currentPage();
String oldUrl = page.url();
String testId = String.format("zp-%s", index);
page.getByTestId(testId).click();
String postfix = waitForNewPage().map(p -> {
locateOn(p);
return "new page found after click action:" + p.title();
}).orElseGet(() -> {
if (waitNavi(page, oldUrl)) {
return "navigated to:" + page.title();
}
return StrUtil.EMPTY;
});
return "Action result: Element clicked!" + postfix;
}
/**
* 导航到指定 URL
*/
@Tool(description = "导航到指定的url")
public String goToUrl(@ToolParam(description = "url地址") String url) {
Page page = browserContext.newPage();
page.navigate(url);
waitNavi(page, StrUtil.EMPTY);
locateOn(page);
return "Action result: Navigated to " + url;
}
/**
* 在指定索引的输入框中输入文本
*/
@Tool(description = "输入框中输入文本")
public String inputText(@ToolParam(description = "下标") int index,@ToolParam(description = "文本内容") String text) {
Page page = currentPage();
String testId = String.format("zp-%s", index);
var locator = page.getByTestId(testId);
locator.clear();
locator.fill(text);
return "Action result: input text success";
}
@Tool(description = "等待指定时间")
public String wait(@ToolParam(description = "等待时间(秒)") int seconds) {
try {
Thread.sleep(seconds * 1_000L);
return "Action result: you have wait " + seconds + " second(s)";
} catch (InterruptedException e) {
return "Action result: InterruptedException(" + e.getMessage() + ")";
}
}
/**
* 用于存储页面滚动信息
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class ScrollInfo {
private int pixelsAbove;
private int pixelsBelow;
}
/**
* 用于存储浏览器标签页的信息
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class BrowserTabInfo {
private int index;
private String title;
private String url;
}
}
页面内容提取
通过标注出来的内容进行有效内容的进一步提出,这部分功能是基于AI大模型实现的。
interface
package cn.itcast.manus.service;
/**
* 提取页面内容服务
*/
public interface PageContentExtractService {
/**
* 提取页面中的内容
*
* @param originalContent 原始内容(包含所有内容,冗余信息较多,容易超token限制)
* @param pageInStatus 当前页面的状态(可见的基础标签及文本)
* @param goal 提取的目标内容
* @return 提取到的内容
*/
String extractContent(String originalContent, String pageInStatus, String goal);
}
impl
package cn.itcast.manus.service.impl;
import cn.hutool.core.util.StrUtil;
import cn.itcast.manus.agent.prompt.PromptManagement;
import cn.itcast.manus.config.ModelConfig;
import cn.itcast.manus.constants.Constant;
import cn.itcast.manus.service.PageContentExtractService;
import com.vladsch.flexmark.html2md.converter.FlexmarkHtmlConverter;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.jsoup.Jsoup;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.tokenizer.JTokkitTokenCountEstimator;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Stream;
@Slf4j
@Service
public class PageContentExtractServiceImpl implements PageContentExtractService {
@Resource(name = ModelConfig.MAIN_AGENT)
private ChatModel chatModel;
@Resource
private PromptManagement promptManagement;
/**
* 设置最大的token数量,默认为:64k
*/
@Value("${extraction.max-token:64000}")
private int maxToken;
@Override
public String extractContent(String originalContent, String pageInStatus, String goal) {
var result = new StringBuilder();
result.append("下面的数据包括文本提取结果,你需要结合两部分数据进行综合考量\n");
result.append("文本提取结果如下:\n");
// token数量评估
var tokenCountEstimator = new JTokkitTokenCountEstimator();
var doc = Jsoup.parse(originalContent);
var markDown = FlexmarkHtmlConverter.builder().build().convert(originalContent);
// 优先级顺序
// 1.原始html(包含所有内容,冗余信息较多,容易超token限制)
// 2.markdown保留连接、图片地址等信息、
// 3.text纯文本内容
// 4.可见的基础标签及文本(同pageStatus)
var op = Stream.of(doc.body().html(), markDown, doc.body().text(), pageInStatus)
.filter(s -> tokenCountEstimator.estimate(s) < maxToken)
.findFirst();
if (op.isPresent()) {
var targetContent = op.get();
var textBased = this.textExtraction(goal, targetContent);
result.append(textBased);
} else {
result.append("文本Token数量超过最大值,放弃文本分析\n");
}
return result.toString();
}
public String textExtraction(String goal, String targetContent) {
var params = Map.of(
"page", targetContent,
"goal", Optional.ofNullable(goal).orElse(StrUtil.EMPTY));
String message = StrUtil.format(this.promptManagement.getPrompt(Constant.Prompts.EXTRA_PAGE_CONTENT), params);
return this.chatModel.call(message);
}
}
测试
编写测试用例:
package cn.itcast.manus.service;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.io.resource.ResourceUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.microsoft.playwright.*;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
class PageContentExtractServiceTest {
@Resource
private PageContentExtractService pageContentExtractService;
@Test# ReActBrowserAgent
接下来,我们来编写`ReActBrowserAgent`智能体,通过控制浏览器进行网页的访问,获取数据,配合`PlanningAgent`完成相关的任务。
## PlaywrightManagement
```java
package cn.itcast.manus.playwright;
import com.microsoft.playwright.BrowserContext;
import com.microsoft.playwright.BrowserType.LaunchPersistentContextOptions;
import com.microsoft.playwright.Playwright;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.Getter;
import org.springframework.stereotype.Component;
import java.io.File;
import java.nio.file.Path;
import java.util.function.Function;
/**
* 封装了 Playwright 管理
*/
@Getter
@Component
public class PlaywrightManagement {
// Playwright 实例,用于创建和管理浏览器
private Playwright playwright;
public <T> T browserContextOperation(Function<BrowserContext, T> func) {
// 默认使用路径 /tmp/playwrightContext 和非无头模式启动 Chromium
LaunchPersistentContextOptions op = new LaunchPersistentContextOptions();
op.setHeadless(false);
Path path = new File("/tmp/playwrightContext").toPath();
// 封装了 try-with-resources 模式自动关闭浏览器资源
try (var ctx = this.playwright.chromium().launchPersistentContext(path, op)) {
return func.apply(ctx);
}
}
/**
* 在 Bean 初始化时创建 Playwright 实例
*/
@PostConstruct
void init() {
this.playwright = Playwright.create();
}
/**
* 在 Bean 销毁前关闭 Playwright,释放资源
*/
@PreDestroy
void destroy() {
this.playwright.close();
}
}
PageSession
我们需要对于页面的操作进行封装。
package cn.itcast.manus.agent.browser;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.io.resource.ResourceUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import cn.itcast.manus.agent.prompt.PromptManagement;
import cn.itcast.manus.constants.Constant;
import cn.itcast.manus.service.PageContentExtractService;
import com.microsoft.playwright.BrowserContext;
import com.microsoft.playwright.BrowserContext.WaitForPageOptions;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.Page.ScreenshotOptions;
import com.microsoft.playwright.Page.WaitForLoadStateOptions;
import com.microsoft.playwright.Page.WaitForSelectorOptions;
import com.microsoft.playwright.Page.WaitForURLOptions;
import com.microsoft.playwright.TimeoutError;
import com.microsoft.playwright.options.LoadState;
import jakarta.annotation.Resource;
import lombok.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import java.util.*;
import java.util.Map.Entry;
import java.util.function.Predicate;
import java.util.stream.Stream;
/**
* 对于 Playwright 的页面会话,用于管理页面的导航、动作和元素分析等操作。
*/
@Slf4j
public class PageSession {
private static final String PLAYWRIGHT_CONTAINER_REMOVE = "document.getElementById(\"playwright-highlight-container\")?.remove()";
private static final String PLAYWRIGHT_DATATESTID_REMOVE = """
document.querySelectorAll('*').forEach(element => {
if (element.hasAttribute('data-testid')) {
element.removeAttribute('data-testid');
}
});
""";
public static final String AMOUNT = "amount";
private static final String HIGHLIGHT_INDEX = "highlightIndex";
// 等待超时时间,单位为毫秒
private static final long WAIT_TIMEOUT = 5000L;
@Resource
private PromptManagement promptManagement;
@Resource
private PageContentExtractService pageContentExtractService;
private final Map<Integer, JSONObject> allElement = new TreeMap<>();
private Integer rootId;
private final String buildDomJS;
private final Set<String> attributeKey;
private final BrowserContext browserContext;
private final List<Page> openInOperation = new LinkedList<>();
@Setter
@Getter
private int pageIndex;
@Getter
private String pageListInfo;
private int flagNavi = 0;
private int flagNewPage = 0;
private int flagTabChanged = 0;
private String originalContent;
public PageSession(BrowserContext browserContext) {
this.browserContext = browserContext;
this.pageIndex = 0;
this.attributeKey = Set.of("title", "type", "name", "role", "tabindex", "aria-label", "placeholder", "value", "alt", "aria-expanded", "class");
this.buildDomJS = ResourceUtil.readUtf8Str("js/buildDomTree.js");
}
/**
* 清除页面状态标志
*/
public void clearFlag() {
this.flagNavi = 0;
this.flagNewPage = 0;
this.flagTabChanged = 0;
}
/**
* 刷新页面信息,包括当前页面的标题、URL
*/
private void refreshPageInfo() {
var pageListInfo = new StringBuilder();
var page = listPageWithoutExtension();
var pageArr = page.toArray(Page[]::new);
var lst = Stream.iterate(0, i -> i + 1).limit(pageArr.length)
.map(i -> new BrowserTabInfo(i, pageArr[i].title(), pageArr[i].url()))
.toList();
pageListInfo.append(JSONUtil.toJsonStr(lst)).append("\n");
pageListInfo.append("current at index:").append(this.pageIndex).append("\n");
pageListInfo.append("using 'switchTab' action to change browser tab\n");
this.pageListInfo = pageListInfo.toString();
this.originalContent = currentPage().content();
}
/**
* 是否发生异常情况
*/
public boolean isStatusSignificantChanged() {
if (flagNavi != 0 || flagNewPage != 0 || flagTabChanged != 0) {
return true;
}
try {
var page = currentPage();
var opt = new WaitForSelectorOptions();
opt.setTimeout(WAIT_TIMEOUT);
var handle = page.waitForSelector("#playwright-highlight-container", opt);
return handle == null;
} catch (Exception e) {
log.trace("significantChanged by exception", e);
return true;
}
}
private void reAnalyze() {
this.refreshPageInfo();
this.removeAnalyzeContainer();
var page = currentPage();
var result = page.evaluate(buildDomJS);
var jo = JSONUtil.parseObj(result);
var map = jo.getJSONObject("map");
// 根节点id
this.rootId = jo.getInt("rootId");
// 将所有的节点信息写入到allElement中
map.forEach((k, v) -> allElement.put(Convert.toInt(k), (JSONObject) v));
}
/**
* 等待新页面的出现,超时时间为 3 秒。
*/
private Optional<Page> waitForNewPage() {
// waiting for new page
try {
var opt = new WaitForPageOptions();
opt.setTimeout(WAIT_TIMEOUT);
var newPage = this.browserContext.waitForPage(opt, () -> {});
this.waitPageIdle(newPage);
this.flagNewPage = 1;
return Optional.of(newPage);
} catch (TimeoutError e) {
log.trace("no page found after waiting 5 s");
return Optional.empty();
}
}
/**
* 等待页面加载完成,超时时间为 5 秒
*/
private void waitPageIdle(Page p) {
try {
p.waitForLoadState();
var opt = new WaitForLoadStateOptions();
opt.setTimeout(WAIT_TIMEOUT);
p.waitForLoadState(LoadState.NETWORKIDLE, opt);
} catch (TimeoutError e) {
log.trace("ignore waiting loadstatus timeout.", e);
}
}
/**
* 获取当前页面对象
*/
private Page currentPage() {
return this.listPageWithoutExtension().get(pageIndex);
}
/**
* 列出所有非扩展页面
*/
private List<Page> listPageWithoutExtension() {
return browserContext.pages().stream()
.filter(p -> !p.url().toLowerCase().startsWith("chrome-extension"))
.toList();
}
/**
* 移除页面中的分析容器
*/
public void removeAnalyzeContainer() {
var page = this.listPageWithoutExtension();
page.forEach(p -> {
p.evaluate(PLAYWRIGHT_CONTAINER_REMOVE);
p.evaluate(PLAYWRIGHT_DATATESTID_REMOVE);
});
}
/**
* 清理页面资源,关闭打开的页面
*/
public void cleanUp() {
this.removeAnalyzeContainer();
this.openInOperation.forEach(Page::close);
}
/**
* 等待页面导航完成
*/
private boolean waitNavi(Page page, String oldUrl) {
try {
var waitForURLOptions = new WaitForURLOptions();
waitForURLOptions.setTimeout(WAIT_TIMEOUT);
Predicate<String> urlChanged = s -> !s.equalsIgnoreCase(oldUrl);
page.waitForURL(urlChanged, waitForURLOptions);
this.flagNavi = 1;
return true;
} catch (TimeoutError e) {
log.trace("ignore waiting loadstatus timeout.", e);
return false;
}
}
/**
* 定位到指定页面,并更新页面索引
*/
private void locateOn(Page page) {
this.pageIndex = listPageWithoutExtension().indexOf(page);
this.openInOperation.add(page);
}
/**
* 向上滚动页面
*/
public String scrollUp(Map<String, Object> arg) {
var page = currentPage();
int amount = Convert.toInt(arg.get(AMOUNT));
page.evaluate(String.format("window.scrollBy(0, -%s);", amount));
return "SUCCESS";
}
/**
* 向下滚动页面
*/
public String scrollDown(Map<String, Object> arg) {
var page = currentPage();
int amount = Convert.toInt(arg.get(AMOUNT));
page.evaluate(String.format("window.scrollBy(0, %s);", amount));
return "SUCCESS";
}
/**
* 获取当前页面的状态信息,包括 URL、页面数据、滚动信息等
*/
public String getCurrentPageStatus(int currentStep, int maxStep) {
this.reAnalyze();
var page = this.currentPage();
var params = new HashMap<>();
params.put("url", page.url());
params.put("pageListInfo", getPageListInfo());
var pageData = fromAllElementByTree(rootId);
params.put("pageData", pageData);
params.put("stepData", String.format("%s/%s", currentStep, maxStep));
params.put("dateTime", String.valueOf(new Date()));
var sc = this.getScrollInfo();
params.put("scrollData",
String.format(
"...%s pixels above,%s pixels below,using extract_content action to see more information...",
sc.getPixelsAbove(), sc.getPixelsBelow()));
return StrUtil.format(promptManagement.getPrompt(Constant.Prompts.PAGE_STATUS), params);
}
private boolean containsHighlighted(Integer indexId) {
var node = this.allElement.get(indexId);
if (node.containsKey(HIGHLIGHT_INDEX)) {
return true;
}
return childIdxList(node).stream().anyMatch(this::containsHighlighted);
}
private List<Integer> childIdxList(JSONObject node) {
return Optional.of(node)
.filter(j -> j.containsKey("children"))
.map(j -> j.getJSONArray("children")
.stream()
.map(Convert::toInt)
.toList())
.orElse(Collections.emptyList());
}
/**
* 递归生成页面元素树的字符串
*/
private String fromAllElementByTree(Integer indexId) {
var node = this.allElement.get(indexId);
var childList = this.childIdxList(node);
var childContent = childList.stream()
.map(this::fromAllElementByTree)
.filter(StrUtil::isNotBlank)
.reduce((x, y) -> x + "\n" + y)
.orElse(StrUtil.EMPTY);
if (!node.getBool("isVisible", false)) {
return childContent;
}
if ("TEXT_NODE".equals(node.getStr("type"))) {
return node.getStr("text") + "\n" + childContent;
}
if (node.containsKey(HIGHLIGHT_INDEX)) {
var highlightIndex = node.getStr(HIGHLIGHT_INDEX);
var tag = Optional.ofNullable(node.getStr("tagName")).orElse("?");
var attr = Optional.ofNullable(node.getJSONObject("attributes")).map(this::collectAttribute)
.filter(s -> !s.equals(childContent)).orElse(StrUtil.EMPTY);
var childHighLighted = childList.stream().anyMatch(this::containsHighlighted);
return childHighLighted ? childContent : String.format("[%s]<%s %s;%s/>", highlightIndex, tag, attr, StrUtil.removeAllSuffix(childContent, "\n"));
}
return childContent;
}
/**
* 收集节点的属性信息
*/
private String collectAttribute(JSONObject node) {
return node.entrySet().stream()
.filter(e -> attributeKey.contains(e.getKey()))
.map(Entry::getValue)
.map(String::valueOf)
.reduce((x, y) -> x + ";" + y)
.orElse(StrUtil.EMPTY);
}
public ScrollInfo getScrollInfo() {
var page = currentPage();
var scrollY = Convert.toInt(page.evaluate("window.scrollY"));
var viewportHeight = Convert.toInt(page.evaluate("window.innerHeight"));
var totalHeight = Convert.toInt(page.evaluate("document.documentElement.scrollHeight"));
return ScrollInfo.builder()
.pixelsAbove(scrollY)
.pixelsBelow(totalHeight - (scrollY + viewportHeight))
.build();
}
@Tool(description = "根据目标,提取相关的内容数据")
public String extractContent(@ToolParam(description = "要提取的内容描述") String goal) {
return this.pageContentExtractService.extractContent(originalContent, this.fromAllElementByTree(rootId), goal);
}
@Tool(description = "切换到指定的页面标签页")
public String switchTab(@ToolParam(description = "页面id") int pageId) {
this.pageIndex = pageId;
this.reAnalyze();
this.flagTabChanged = 1;
return "Action Result:page index change to " + pageId;
}
/**
* 点击指定索引的元素,并处理可能的新页面打开或导航
*/
@Tool(description = "点击指定索引的元素")
public String clickElement(@ToolParam(description = "下标") int index) {
Page page = currentPage();
String oldUrl = page.url();
String testId = String.format("zp-%s", index);
page.getByTestId(testId).click();
String postfix = waitForNewPage().map(p -> {
locateOn(p);
return "new page found after click action:" + p.title();
}).orElseGet(() -> {
if (waitNavi(page, oldUrl)) {
return "navigated to:" + page.title();
}
return StrUtil.EMPTY;
});
return "Action result: Element clicked!" + postfix;
}
/**
* 导航到指定 URL
*/
@Tool(description = "导航到指定的url")
public String goToUrl(@ToolParam(description = "url地址") String url) {
Page page = browserContext.newPage();
page.navigate(url);
waitNavi(page, StrUtil.EMPTY);
locateOn(page);
return "Action result: Navigated to " + url;
}
/**
* 在指定索引的输入框中输入文本
*/
@Tool(description = "输入框中输入文本")
public String inputText(@ToolParam(description = "下标") int index,@ToolParam(description = "文本内容") String text) {
Page page = currentPage();
String testId = String.format("zp-%s", index);
var locator = page.getByTestId(testId);
locator.clear();
locator.fill(text);
return "Action result: input text success";
}
@Tool(description = "等待指定时间")
public String wait(@ToolParam(description = "等待时间(秒)") int seconds) {
try {
Thread.sleep(seconds * 1_000L);
return "Action result: you have wait " + seconds + " second(s)";
} catch (InterruptedException e) {
return "Action result: InterruptedException(" + e.getMessage() + ")";
}
}
/**
* 用于存储页面滚动信息
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class ScrollInfo {
private int pixelsAbove;
private int pixelsBelow;
}
/**
* 用于存储浏览器标签页的信息
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class BrowserTabInfo {
private int index;
private String title;
private String url;
}
}
页面内容提取
通过标注出来的内容进行有效内容的进一步提出,这部分功能是基于AI大模型实现的。
interface
package cn.itcast.manus.service;
/**
* 提取页面内容服务
*/
public interface PageContentExtractService {
/**
* 提取页面中的内容
*
* @param originalContent 原始内容(包含所有内容,冗余信息较多,容易超token限制)
* @param pageInStatus 当前页面的状态(可见的基础标签及文本)
* @param goal 提取的目标内容
* @return 提取到的内容
*/
String extractContent(String originalContent, String pageInStatus, String goal);
}
impl
package cn.itcast.manus.service.impl;
import cn.hutool.core.util.StrUtil;
import cn.itcast.manus.agent.prompt.PromptManagement;
import cn.itcast.manus.config.ModelConfig;
import cn.itcast.manus.constants.Constant;
import cn.itcast.manus.service.PageContentExtractService;
import com.vladsch.flexmark.html2md.converter.FlexmarkHtmlConverter;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.jsoup.Jsoup;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.tokenizer.JTokkitTokenCountEstimator;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Stream;
@Slf4j
@Service
public class PageContentExtractServiceImpl implements PageContentExtractService {
@Resource(name = ModelConfig.MAIN_AGENT)
private ChatModel chatModel;
@Resource
private PromptManagement promptManagement;
/**
* 设置最大的token数量,默认为:64k
*/
@Value("${extraction.max-token:64000}")
private int maxToken;
@Override
public String extractContent(String originalContent, String pageInStatus, String goal) {
var result = new StringBuilder();
result.append("下面的数据包括文本提取结果,你需要结合两部分数据进行综合考量\n");
result.append("文本提取结果如下:\n");
// token数量评估
var tokenCountEstimator = new JTokkitTokenCountEstimator();
var doc = Jsoup.parse(originalContent);
var markDown = FlexmarkHtmlConverter.builder().build().convert(originalContent);
// 优先级顺序
// 1.原始html(包含所有内容,冗余信息较多,容易超token限制)
// 2.markdown保留连接、图片地址等信息、
// 3.text纯文本内容
// 4.可见的基础标签及文本(同pageStatus)
var op = Stream.of(doc.body().html(), markDown, doc.body().text(), pageInStatus)
.filter(s -> tokenCountEstimator.estimate(s) < maxToken)
.findFirst();
if (op.isPresent()) {
var targetContent = op.get();
var textBased = this.textExtraction(goal, targetContent);
result.append(textBased);
} else {
result.append("文本Token数量超过最大值,放弃文本分析\n");
}
return result.toString();
}
public String textExtraction(String goal, String targetContent) {
var params = Map.of(
"page", targetContent,
"goal", Optional.ofNullable(goal).orElse(StrUtil.EMPTY));
String message = StrUtil.format(this.promptManagement.getPrompt(Constant.Prompts.EXTRA_PAGE_CONTENT), params);
return this.chatModel.call(message);
}
}
测试
编写测试用例:
package cn.itcast.manus.service;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.io.resource.ResourceUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.microsoft.playwright.*;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
class PageContentExtractServiceTest {
@Resource
private PageContentExtractService pageContentExtractService;
@Test
void extractContent() {
// 创建Playwright实例并管理资源
try (Playwright playwright = Playwright.create()) {
// 获取Chromium浏览器类型
BrowserType chromium = playwright.chromium();
// 启动Chromium浏览器,设置非无头模式以便观察
Browser browser = chromium.launch(new BrowserType.LaunchOptions().setHeadless(false));
// 新建一个页面
Page page = browser.newPage();
// 设置页面操作的默认超时时间为60秒
page.setDefaultTimeout(60_000);
// 导航到指定URL
page.navigate("https://www.baidu.com/");
// 获取输入框对象,进行填充数据
var input = page.locator("#kw");
input.clear();
input.fill("北京近7天的天气");
// 找到提交按钮,触发点击事件
Locator submit = page.locator("#su");
submit.click();
// 等待3秒
page.waitForTimeout(3000);
// 获取页面的html的内容
String html = page.content();
// 获取js文件内容
String buildDomTree = ResourceUtil.readUtf8Str("js/buildDomTree.js");
// 页面执行标注js
Object evaluate = page.evaluate(buildDomTree);
JSONObject jsonObject = JSONUtil.parseObj(evaluate);
// 获取根节点的indexId
var rootId = jsonObject.getInt("rootId");
// 将map中所有的节点元素都放到allElement中
Map<Integer, JSONObject> allElement = new TreeMap<>();
JSONObject map = jsonObject.getJSONObject("map");
map.forEach((k, v) -> allElement.put(Convert.toInt(k), (JSONObject) v));
// 获取页面中所有【有价值】的内容,这部分内容就是可以发给大模型进行分析的
String body = this.fromAllElementByTree(rootId, allElement);
// 测试内容提取
String data = this.pageContentExtractService.extractContent(html, body, "北京近7天的天气数据");
System.out.println("提取到的内容如下:" + data);
// 关闭浏览器
browser.close();
}
}
/**
* 递归生成页面元素树的字符串
*/
private String fromAllElementByTree(Integer indexId, Map<Integer, JSONObject> allElement) {
JSONObject node = allElement.get(indexId);
var childList = childIdxList(node);
String childContent = childList.stream()
.map(id -> this.fromAllElementByTree(id, allElement))
.filter(StrUtil::isNotBlank)
.reduce((x, y) -> x + "\n" + y)
.orElse(StrUtil.EMPTY);
if (!node.getBool("isVisible", false)) {
return childContent;
}
if ("TEXT_NODE".equals(node.getStr("type"))) {
return node.getStr("text") + "\n" + childContent;
}
if (node.containsKey("highlightIndex")) {
String highlightIndex = node.getStr("highlightIndex");
String tag = Optional.ofNullable(node.getStr("tagName")).orElse("?");
String attr = Optional.ofNullable(node.getJSONObject("attributes"))
.map(this::collectAttribute)
.filter(s -> !s.equals(childContent))
.orElse(StrUtil.EMPTY);
boolean childHighLighted = childList.stream().anyMatch(p -> this.containsHighlighted(allElement, p));
return childHighLighted ? childContent : String.format("[%s]<%s %s;%s/>", highlightIndex, tag, attr, StrUtil.removeAllSuffix(childContent, "\n"));
}
return childContent;
}
private List<Integer> childIdxList(JSONObject node) {
return Optional.of(node)
.filter(j -> j.containsKey("children"))
.map(j -> j.getJSONArray("children")
.stream()
.map(Convert::toInt)
.toList())
.orElse(Collections.emptyList());
}
/**
* 收集节点的属性信息
*/
private String collectAttribute(JSONObject node) {
Set<String> attributeKey = Set.of("title", "type", "name", "role", "tabindex", "aria-label", "placeholder", "value", "alt", "aria-expanded", "class");
return node.entrySet().stream()
.filter(e -> attributeKey.contains(e.getKey()))
.map(Map.Entry::getValue)
.map(String::valueOf)
.reduce((x, y) -> x + ";" + y)
.orElse(StrUtil.EMPTY);
}
private boolean containsHighlighted(Map<Integer, JSONObject> allElement, Integer indexId) {
JSONObject node = allElement.get(indexId);
if (node.containsKey("highlightIndex")) {
return true;
}
return childIdxList(node).stream().anyMatch(p -> this.containsHighlighted(allElement, p));
}
}
测试结果:
提取到的内容如下:下面的数据包括文本提取结果,你需要结合两部分数据进行综合考量
文本提取结果如下:
根据提供的页面内容,提取北京近7天的天气数据如下:
1. **今天 (05/26)**
- 天气:多云
- 风向/风力:西南风4级
- 温度:17~29°C
- 空气质量:良
2. **明天 (05/27)**
- 天气:未明确标注(推测为晴或多云)
- 风向/风力:西南风1级
- 温度:28°C(最高)
- 空气质量:轻度
3. **周三 (05/28)**
- 天气:未明确标注
- 风向/风力:西南风1级
- 温度:32°C(最高)
- 空气质量:中度
4. **周四 (05/29)**
- 天气:未明确标注
- 风向/风力:西南风1级
- 温度:33°C(最高)
- 空气质量:中度
5. **周五 (05/30)**
- 天气:未明确标注
- 风向/风力:南风3级
- 温度:32°C(最高)
- 空气质量:轻度
6. **周六 (05/31)**
- 天气:未明确标注
- 风向/风力:南风1级
- 温度:28°C(最高)
- 空气质量:轻度
7. **周日 (06/01)**
- 天气:未明确标注
- 风向/风力:东南风1级
- 温度:29°C(最高)
- 空气质量:良
**补充说明**:
- 部分日期缺少具体天气描述(如晴/雨),仅能根据温度、风向和空气质量推测。
- 页面中另有15天预报的链接(如[61]处“40天预报”),但未展开详细数据。
若需更完整的7天数据(如每日最低温、降水概率等),建议直接访问专业天气网站(如页面中提到的“中国天气网”)。
ReActBrowserAgent
系统提示词
你是一个用于自动化浏览器任务的AI代理。你遵循‘思考-行动-观察’循环来完成任务,在完成任务的过程中请遵循如下规则:
# 输入格式
[任务]
[先前步骤]
[当前URL]
[打开的标签页]
[交互元素]格式为:[索引]<类型>文本</类型>
- 索引:交互元素的数字标识符,每次对页面的分析都会导致索引变化,因此你不能依赖历史对话中的索引
- 类型:HTML元素类型(按钮、输入框等)
- 文本:元素描述
交互元素示例:
[33]<button>提交表单</button>
- 只有带[]数字索引的元素可交互
- 无[]的元素仅提供上下文
# 响应规则
1.响应格式:你必须使用预定义的AgentOutput工具返回具体内容:
{
"current_state": {
"evaluation_previous_goal": "这里需要总结上一步结果",
"memory": "你的记忆:记录已执行操作和需记忆内容",
"thinking": "你的思考过程:尽量详细的针对行动清单中的每个执行动作,填充导致该操作的输入数据来源并简要说明"
},
"action":[{"动作名称": {参数}}, ...] // 按顺序执行
}
evaluation_previous_goal字段:
- 使用固定格式:"上一步中的思路为:<参考上一步的thinking字段>;为此我在上一步的行动是:<参考上一步的action字段>;基于XX结果我判断上一步执行失败|成功了"
- 初始状态时使用固定值"未知"
memory字段:
- 记录任务中哪些步骤执行完毕,哪些步骤尚待执行
- 记录任务的整体执行次数以及其他任何需要记录次数的场景(必须包含具体计数,例如:0/10网站已分析,还有XX待分析等等尽量详细)
- 若evaluation_previous_goal的结果为失败,你必须在此处记录失败的次数,当连续失败超过一定值时,变更你的action
thinking字段:
- 此处很重要!你需要在thinking字段中模拟人类思考的行为,例如:我觉得应该XXX、YYY执行成功了,我又完成了一步等,着重说明你为何要执行下面的action,需要包含符合逻辑的详细解释
action字段:
- 虽然声明成了list,但某些动作仅能独立执行,请参考下面的动作限制部分内容
注意,上述字段的值尽量使用中文进行输出
2.动作限制:
- 某些动作可以批量执行,但单次最多使用{max_actions}个动作,例如:表单填写:[{"inputText": {"index": 1, "text": "username"}}, {"inputText": {"index": 2, "text": "password"}}, {"clickElement": {"index": 3}}]
- 某些动作会引起页面显著变化,故仅能独立执行,例如导航goToUrl、切换tab页面switchTab,此时action里仅能有此为一的动作,需要单独运行后结合最新页面状态判断下一步操作
- 动作列表中的动作将按顺序执行,若页面在某个动作后引起了显著变化,则会立刻中断动作列表的执行,后续的动作都将被忽略
- 确保动作的高效性(如填写表单可以依次返回多个动作将表单填写完整)
- 不要假设动作必然成功,即使其返回了"成功"字样,你需要依据当前页面信息做判断
3.元素交互:
- 只能使用带数字索引的元素
4.导航与错误处理:
- 无可用元素时尝试其他方法(如打开新标签页)
- 如果遇到要求登录的页面,请使用wait动作来等待人工登录,长时间无人工操作时尝试其他方法完成任务
- 请关注你的thinking与memory构成的思维链,如果多次陷入循环,请尝试用其他方法
5.任务的完成:
- 注意!这里非常关键:当任务完成后,必须通过"done"动作来结束,在done操作的text参数中需要完整的保留所有数据,不能做任何摘要或省略任何数据
- 未完成最终任务前禁止使用"done"动作
- 涉及到计数的任务(做xx次、对每一个数据操作等)必须在"memory"中精确计数(例如:已完成3/10次)
- 任务将在达到最大步骤数时强制结束,此时你需要在任务未能完成的前提下作出必要总结,以及汇总截至到目前步骤的全部数据
6.内容提取:
- extractContent提取内容仅限当前HTML页面(PDF/XLS等格式禁用extractContent)
- extractPDFContent专用于提取指定的pdf文件内容
初始化输入
package cn.itcast.manus.agent.browser;
import cn.hutool.core.util.StrUtil;
import cn.itcast.manus.agent.ReActBaseAgent;
import cn.itcast.manus.agent.prompt.PromptManagement;
import cn.itcast.manus.constants.Constant;
import cn.itcast.manus.message.MessageSession;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Slf4j
public class ReActBrowserAgent extends ReActBaseAgent {
@Resource
private PromptManagement promptManagement;
public ReActBrowserAgent(MessageSession messageSession) {
super(messageSession);
}
@Override
protected String getCurrentStatus() {
return "";
}
@Override
protected void addInitInput(LinkedList<Message> inputList, String task) {
// 添加系统消息
inputList.add(SystemMessage.builder()
.text(this.getPrompt(Constant.Prompts.BROWSER_SYSTEM, Map.of(Constant.MAX_ACTIONS, super.reActConfig.getMaxActionPerCall())))
.build());
// 添加用户消息
inputList.add(UserMessage.builder()
.text(this.getPrompt(Constant.Prompts.BROWSER_USER_TASK, Map.of(Constant.TASK, task)))
.build());
// 添加一个用户示例
inputList.add(new UserMessage("下面是一个例子:"));
AssistantMessage.ToolCall toolCall = new AssistantMessage.ToolCall(
"1",
"function",
AGENT_OUTPUT_METHOD,
"{'action': [{'clickElement': {'index': 0}}], 'current_state': {'evaluation_previous_goal': 'Success - 基于页面信息,我判断成功打开了某个页面', 'memory': '现在1/10的任务已完成', 'thinking': '接下来需要点击索引为0的元素'}}");
inputList.add(new AssistantMessage("", Map.of(), List.of(toolCall)));
inputList.add(new ToolResponseMessage(List.of(new ToolResponseMessage.ToolResponse("1", AGENT_OUTPUT_METHOD, "SUCCESS"))));
inputList.add(new UserMessage("[以下是任务历史记录]"));
}
private String getPrompt(String name, Map<String, Object> params) {
String prompt = this.promptManagement.getPrompt(name);
return StrUtil.format(prompt, params);
}
}
任务处理
package cn.itcast.manus.agent.browser;
import cn.hutool.core.exceptions.ExceptionUtil;
import cn.hutool.core.util.StrUtil;
import cn.itcast.manus.agent.ReActBaseAgent;
import cn.itcast.manus.agent.prompt.PromptManagement;
import cn.itcast.manus.constants.Constant;
import cn.itcast.manus.message.MessageSession;
import cn.itcast.manus.playwright.PlaywrightComponentFactory;
import cn.itcast.manus.playwright.PlaywrightManagement;
import com.microsoft.playwright.BrowserContext;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Slf4j
public class ReActBrowserAgent extends ReActBaseAgent {
@Resource
private PromptManagement promptManagement;
@Resource
private PlaywrightManagement playwrightManagement;
@Resource
private PlaywrightComponentFactory playwrightComponentFactory;
private PageSession pageSession;
public ReActBrowserAgent(MessageSession messageSession) {
super(messageSession);
}
@Override
public String reActSolve(String task) {
return this.playwrightManagement
.browserContextOperation(browserContext -> this.runWithContext(task, browserContext));
}
private String runWithContext(String task, BrowserContext ctx) {
try {
this.pageSession = this.playwrightComponentFactory.pageSession(ctx);
var result = super.reActSolve(task);
this.pageSession.cleanUp();
return result;
} catch (Exception e) {
log.error("ReActBrowserAgent runWithContext error", e);
return ExceptionUtil.getMessage(e);
}
}
@Override
protected String getCurrentStatus() {
return "";
}
@Override
protected void addInitInput(LinkedList<Message> inputList, String task) {
// 添加系统消息
inputList.add(SystemMessage.builder()
.text(this.getPrompt(Constant.Prompts.BROWSER_SYSTEM, Map.of(Constant.MAX_ACTIONS, super.reActConfig.getMaxActionPerCall())))
.build());
// 添加用户消息
inputList.add(UserMessage.builder()
.text(this.getPrompt(Constant.Prompts.BROWSER_USER_TASK, Map.of(Constant.TASK, task)))
.build());
// 添加一个用户示例
inputList.add(new UserMessage("下面是一个例子:"));
AssistantMessage.ToolCall toolCall = new AssistantMessage.ToolCall(
"1",
"function",
AGENT_OUTPUT_METHOD,
"{'action': [{'clickElement': {'index': 0}}], 'current_state': {'evaluation_previous_goal': 'Success - 基于页面信息,我判断成功打开了某个页面', 'memory': '现在1/10的任务已完成', 'thinking': '接下来需要点击索引为0的元素'}}");
inputList.add(new AssistantMessage("", Map.of(), List.of(toolCall)));
inputList.add(new ToolResponseMessage(List.of(new ToolResponseMessage.ToolResponse("1", AGENT_OUTPUT_METHOD, "SUCCESS"))));
inputList.add(new UserMessage("[以下是任务历史记录]"));
}
private String getPrompt(String name, Map<String, Object> params) {
String prompt = this.promptManagement.getPrompt(name);
return StrUtil.format(prompt, params);
}
@Override
protected boolean isStatusSignificantChanged() {
return this.pageSession.isStatusSignificantChanged();
}
}
package cn.itcast.manus.playwright;
import cn.itcast.manus.agent.browser.PageSession;
import com.microsoft.playwright.BrowserContext;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Scope;
@Slf4j
@Configuration
public class PlaywrightComponentFactory {
@Bean
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public PageSession pageSession(BrowserContext ctx) {
return new PageSession(ctx);
}
}
工具与当前状态
package cn.itcast.manus.agent.browser;
import cn.hutool.core.exceptions.ExceptionUtil;
import cn.hutool.core.util.StrUtil;
import cn.itcast.manus.agent.ReActBaseAgent;
import cn.itcast.manus.agent.prompt.PromptManagement;
import cn.itcast.manus.constants.Constant;
import cn.itcast.manus.message.MessageSession;
import cn.itcast.manus.playwright.PlaywrightComponentFactory;
import cn.itcast.manus.playwright.PlaywrightManagement;
import com.microsoft.playwright.BrowserContext;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.*;
import org.springframework.ai.support.ToolCallbacks;
import org.springframework.ai.tool.ToolCallbackProvider;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Slf4j
public class ReActBrowserAgent extends ReActBaseAgent {
@Resource
private PromptManagement promptManagement;
@Resource
private PlaywrightManagement playwrightManagement;
@Resource
private PlaywrightComponentFactory playwrightComponentFactory;
private PageSession pageSession;
public ReActBrowserAgent(MessageSession messageSession) {
super(messageSession);
}
@Override
public String reActSolve(String task) {
return this.playwrightManagement
.browserContextOperation(browserContext -> this.runWithContext(task, browserContext));
}
private String runWithContext(String task, BrowserContext ctx) {
try {
this.pageSession = this.playwrightComponentFactory.pageSession(ctx);
var result = super.reActSolve(task);
this.pageSession.cleanUp();
return result;
} catch (Exception e) {
log.error("ReActBrowserAgent runWithContext error", e);
return ExceptionUtil.getMessage(e);
}
}
@Override
protected List<ToolCallbackProvider> toolCallbackProvider() {
return List.of(() -> ToolCallbacks.from(this.pageSession));
}
@Override
protected String getCurrentStatus() {
try {
// 清空页面标志
this.pageSession.clearFlag();
// 获取当前页面状态
return this.pageSession.getCurrentPageStatus(currentStep, super.reActConfig.getMaxStep());
} catch (Exception e) {
return "Exception in getCurrent page status:" + ExceptionUtil.getMessage(e);
}
}
@Override
protected void addInitInput(LinkedList<Message> inputList, String task) {
// 添加系统消息
inputList.add(SystemMessage.builder()
.text(this.getPrompt(Constant.Prompts.BROWSER_SYSTEM, Map.of(Constant.MAX_ACTIONS, super.reActConfig.getMaxActionPerCall())))
.build());
// 添加用户消息
inputList.add(UserMessage.builder()
.text(this.getPrompt(Constant.Prompts.BROWSER_USER_TASK, Map.of(Constant.TASK, task)))
.build());
// 添加一个用户示例
inputList.add(new UserMessage("下面是一个例子:"));
AssistantMessage.ToolCall toolCall = new AssistantMessage.ToolCall(
"1",
"function",
AGENT_OUTPUT_METHOD,
"{'action': [{'clickElement': {'index': 0}}], 'current_state': {'evaluation_previous_goal': 'Success - 基于页面信息,我判断成功打开了某个页面', 'memory': '现在1/10的任务已完成', 'thinking': '接下来需要点击索引为0的元素'}}");
inputList.add(new AssistantMessage("", Map.of(), List.of(toolCall)));
inputList.add(new ToolResponseMessage(List.of(new ToolResponseMessage.ToolResponse("1", AGENT_OUTPUT_METHOD, "SUCCESS"))));
inputList.add(new UserMessage("[以下是任务历史记录]"));
}
private String getPrompt(String name, Map<String, Object> params) {
String prompt = this.promptManagement.getPrompt(name);
return StrUtil.format(prompt, params);
}
@Override
protected boolean isStatusSignificantChanged() {
return this.pageSession.isStatusSignificantChanged();
}
}
完善PlanningAgent
在PlanningAgent中执行子任务:
@PostConstruct
void init() {
// 将需要的智能体名称和描述添加到智能体信息中
agentInfo.put(AgentTypeEnum.BROWSER_AGENT.getAgentName(), AgentTypeEnum.BROWSER_AGENT.getDesc());
}
@Override
public String reActSolve(String task) {
this.subTaskChain.clear();
this.subTaskMidResult.clear();
this.targetTask = task;
return super.reActSolve(task);
}
// 目标任务
private String targetTask;
// 子任务中间结果
private final Map<String, String> subTaskMidResult = new ConcurrentHashMap<>();
@Override
protected String getCurrentStatus() {
// 获取任务链中的最后一个任务,查找到对应的Agent进行执行
this.subTaskChain.stream()
.reduce((x, y) -> y)
.ifPresent(last -> {
// 获取当前任务
String originalTask = last.getSubTask();
// 将原任务与目标任务合并
String subTaskWithCtx = this.taskMerging(targetTask, originalTask, subTaskMidResult);
// 查找对应的智能体,修改最大步数为当前任务的最大步数
Function<MessageSession, Agent> agentFunction = AgentFactory.getAgent(AgentTypeEnum.agentNameOf(last.getAgent()));
if (ObjectUtil.isEmpty(agentFunction)) {
return;
}
var agent = agentFunction.apply(super.messageSession);
if (agent instanceof ReActBaseAgent base) {
// 修改任务的最大步数为最后一个任务的最大步数
base.resetReActConfig(c -> c.setMaxStep(last.getMaxStep()));
}
// 调用智能体,获取结果
String result = agent.solveTask(subTaskWithCtx);
if (agent instanceof ReActBaseAgent base) {
// 设置结果的步数,就是当前的任务的步数
last.setResultStep(base.getCurrentStep());
}
// 将结果保存到中间结果中
subTaskMidResult.put(originalTask, result);
last.setResult(result);
});
// 将子任务链中的结果以字符串拼接,拼接格式为:子任务1->子任务2->子任务3
var chainStr = subTaskChain.stream()
.map(SubTaskNode::strInStatus)
.reduce((x, y) -> x + "->" + y)
.orElse("[Empty]");
// 将子任务链中最后一个任务的结果拼接
var latestStr = subTaskChain.stream()
.reduce((x, y) -> y)
.map(SubTaskNode::getResult)
.orElse("[Empty]");
// 将结果拼接到模板中,作为下次调用大模型的历史对话
var params = Map.of(
"stepData", StrUtil.format("{}/{}", super.currentStep, super.reActConfig.getMaxStep()),
"dateTime", DateUtil.now(),
"subTaskChain", chainStr,
"latestResult", latestStr);
return StrUtil.format(this.promptManagement.getPrompt(Constant.Prompts.PLANNING_STATUS), params);
}
private String taskMerging(String targetTask, String subTask, Object context) {
String prompt = this.promptManagement.getPrompt(Constant.Prompts.PLANNING_TASK_MERGING);
var params = Map.of(
"targetTask", targetTask,
"subTask", subTask,
"context", Convert.toStr(context));
return StrUtil.format(prompt, params);
}
在ReActBaseAgent中增加resetReActConfig方法:
public void resetReActConfig(Consumer<ReActConfig> cons) {
cons.accept(reActConfig);
}
在AgentFactory中注册浏览器智能体:
package cn.itcast.manus.agent;
import cn.itcast.manus.agent.browser.ReActBrowserAgent;
import cn.itcast.manus.agent.planning.ReActPlanningAgent;
import cn.itcast.manus.enums.AgentTypeEnum;
import cn.itcast.manus.message.MessageSession;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Scope;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
@Configuration
public class AgentFactory {
public static final Map<AgentTypeEnum, Function<MessageSession, Agent>> AGENT_FUNC_MAP = new HashMap<>();
/**
* 初始化方法,完成Agent的注册
*/
@PostConstruct
public void init() {
// 注册任务规划智能体
AGENT_FUNC_MAP.put(AgentTypeEnum.RE_ACT_PLANNING_AGENT, this::reActPlanningAgent);
// 注册浏览器智能体
AGENT_FUNC_MAP.put(AgentTypeEnum.BROWSER_AGENT, this::reActBrowserAgent);
}
@Bean
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public Agent reActBrowserAgent(MessageSession messageSession) {
return new ReActBrowserAgent(messageSession);
}
/**
* 根据agentTypeEnum获取对应的Agent
*/
public static Function<MessageSession, Agent> getAgent(AgentTypeEnum agentTypeEnum) {
Function<MessageSession, Agent> fun = AGENT_FUNC_MAP.get(agentTypeEnum);
if (null == fun) {
throw new IllegalArgumentException("找不到对应的智能体: " + agentTypeEnum);
}
return fun;
}
/**
* 任务规划智能体,交由Spring管理
*
* @param messageSession 会话对象
* @return 查找到的智能体实例
*/
@Bean
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public Agent reActPlanningAgent(MessageSession messageSession) {
return new ReActPlanningAgent(messageSession);
}
}
测试
测试内容:打开百度,搜索百度热搜,找出前10条热搜内容


文件存储管理
对于表格、图表智能体生成的内容,需要保存到磁盘,并且可以在线预览文件内容。
interface
package cn.itcast.manus.service;
import org.springframework.http.MediaType;
import java.io.InputStream;
/**
* 文件存储服务接口,提供文件存储、下载链接生成及文件内容获取等功能。
*/
public interface FileStorageService {
/**
* 下载路径模板,用于构建基于 UUID 的文件下载 URL。
*/
String DOWNLOAD_PATH = "/content/download/{uuid}";
/**
* 预览路径模板,用于构建基于 UUID 的文件在线打开 URL。
*/
String OPEN_PATH = "/content/open/{uuid}";
/**
* 保存文件数据,并返回对应的唯一标识符(UUID)。
*
* @param data 文件字节数据
* @return 文件的唯一标识符(UUID)
*/
String saveFile(byte[] data);
/**
* 生成文件下载链接。
*
* @param name 文件原始名称
* @param uuid 文件唯一标识符
* @return 下载链接字符串
*/
String generateDownloadUrl(String name, String uuid);
/**
* 生成文件预览或打开链接。
*
* @param uuid 文件唯一标识符
* @return 预览链接字符串
*/
String generateOpenUrl(String uuid);
/**
* 根据文件 UUID 获取可下载的内容对象,包含媒体类型和输入流。
*
* @param uuid 文件唯一标识符
* @return 可下载内容对象,包含媒体类型和输入流
*/
DownloadTableContent generateDownloadableContent(String uuid);
/**
* 可下载内容的数据结构,包含媒体类型和输入流。
*
* @param type 媒体类型(如 application/pdf、image/jpeg)
* @param src 输入流,用于读取文件内容
*/
record DownloadTableContent(MediaType type, InputStream src) {
}
}
impl
package cn.itcast.manus.service.impl;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import cn.itcast.manus.service.FileStorageService;
import lombok.extern.slf4j.Slf4j;
import org.apache.tika.Tika;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.util.Map;
@Slf4j
@Service
public class FileStorageServiceImpl implements FileStorageService {
private final Tika tika = new Tika();
@Value("${file.base:/tmp/}")
private String baseFolder;
@Value("${file.domain:http://localhost:18081}")
private String domain;
@Override
public String saveFile(byte[] data) {
var uuid = IdUtil.fastSimpleUUID();
FileUtil.writeBytes(data, this.getFilePath(uuid));
return uuid;
}
private String getFilePath(String uuid) {
return baseFolder + uuid;
}
@Override
public String generateDownloadUrl(String name, String uuid) {
return StrUtil.format("{}{}?name={}",
domain,
StrUtil.format(DOWNLOAD_PATH, Map.of("uuid", uuid)),
name);
}
@Override
public String generateOpenUrl(String uuid) {
return StrUtil.format("{}{}",
domain,
StrUtil.format(OPEN_PATH, Map.of("uuid", uuid)));
}
@Override
public DownloadTableContent generateDownloadableContent(String uuid) {
// 获取文件输入流
var is = FileUtil.getInputStream(this.getFilePath(uuid));
try {
// 通过tika获取文件类型
String mimeType = tika.detect(is);
// 创建下载内容对象返回
return new DownloadTableContent(new MediaType(MediaType.valueOf(mimeType), StandardCharsets.UTF_8), is);
} catch (Exception e) {
log.error("IOException in generateDownloadableContent", e);
return new DownloadTableContent(MediaType.APPLICATION_OCTET_STREAM, is);
}
}
}
Controller
package cn.itcast.manus.controller;
import cn.itcast.manus.service.FileStorageService;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 内容控制器,用于处理与文件内容相关的请求,如下载和预览。
*/
@RestController
@RequiredArgsConstructor
public class ContentController {
/**
* 文件存储服务,提供文件的存储和检索功能。
*/
private final FileStorageService fileStorageService;
/**
* 下载文件接口。
*
* @param uuid 文件唯一标识符。
* @param name 下载时使用的文件名。
* @return 包含 InputStreamResource 的 ResponseEntity 对象,表示可下载的文件资源。
*/
@GetMapping(FileStorageService.DOWNLOAD_PATH)
public ResponseEntity<InputStreamResource> download(@PathVariable("uuid") String uuid, @RequestParam("name") String name) {
var dw = this.fileStorageService.generateDownloadableContent(uuid);
return ResponseEntity.ok()
.contentType(dw.type())
.header("Content-disposition", "attachment; filename=" + name)
.body(new InputStreamResource(dw.src()));
}
/**
* 预览文件接口。
*
* @param uuid 文件唯一标识符。
* @return 包含 InputStreamResource 的 ResponseEntity 对象,表示可预览的文件资源。
*/
@GetMapping(FileStorageService.OPEN_PATH)
public ResponseEntity<InputStreamResource> open(@PathVariable("uuid") String uuid) {
var dw = this.fileStorageService.generateDownloadableContent(uuid);
return ResponseEntity.ok()
.contentType(dw.type())
.body(new InputStreamResource(dw.src()));
}
}
TableAgent
TableAgent是基于AI大模型,将内容生成表格页面。
prompt
{task},现在你需要生成一个html文件,把提供的数据绘制成恰当的表格,并遵循下列规则
- 生成包含表格数据的html主体
- 如果使用到js、css的cdn,请使用中国大陆境内可访问的cdn地址,例如bootCDN
- 数据中可能存在冗余的部分,某些数据可能缺少部分种类,绘图时请关注所有数据共有的部分
- 包含一个导出为excel的功能
- 返回格式要求如下:
你的返回值必须使用HTML格式
不要包含任何解释性的内容,仅严格按照此格式提供HTML回应,不得有任何偏差。
不要包含任何Markdown代码块。
从输出中移除```html标记。
代码实现
package cn.itcast.manus.agent.table;
import cn.hutool.core.util.StrUtil;
import cn.itcast.manus.agent.BaseAgent;
import cn.itcast.manus.agent.prompt.PromptManagement;
import cn.itcast.manus.config.ModelConfig;
import cn.itcast.manus.constants.Constant;
import cn.itcast.manus.dto.DialogMessageDTO;
import cn.itcast.manus.message.MessageSession;
import cn.itcast.manus.service.FileStorageService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ChatModel;
import java.util.Map;
@Slf4j
public class TableAgent extends BaseAgent {
@Resource(name = ModelConfig.MAIN_AGENT)
private ChatModel chatModel;
@Resource
private PromptManagement promptManagement;
@Resource
private FileStorageService fileStorageService;
private final MessageSession messageSession;
public TableAgent(MessageSession messageSession) {
this.messageSession = messageSession;
}
@Override
protected String solve(String task) {
//1. 调用大模型生成html内容
var params = Map.of(Constant.TASK, task);
var prompt = StrUtil.format(this.promptManagement.getPrompt(Constant.Prompts.TABLE), params);
var html = this.chatModel.call(prompt);
//2. 存储文件,生成下载地址
var uuid = this.fileStorageService.saveFile(StrUtil.utf8Bytes(html));
var url = this.fileStorageService.generateDownloadUrl("table.html", uuid);
//3. 通过session发送信息给客户端
var ms = DialogMessageDTO.builder()
.text("[TableAgent]文件生成")
.fileUrl(url)
.build();
this.messageSession.sendMessage(ms);
//4. 返回内容给大模型
var openurl = this.fileStorageService.generateOpenUrl(uuid);
return StrUtil.format("""
[TableAgent]
生成可打开的url:{}
生成的可下载url:{}
""", openurl, url);
}
@Override
public ChatModel chatModel() {
return this.chatModel;
}
}
注册Agent
package cn.itcast.manus.agent;
import cn.itcast.manus.agent.browser.ReActBrowserAgent;
import cn.itcast.manus.agent.planning.ReActPlanningAgent;
import cn.itcast.manus.agent.table.TableAgent;
import cn.itcast.manus.enums.AgentTypeEnum;
import cn.itcast.manus.message.MessageSession;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Scope;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
@Configuration
public class AgentFactory {
public static final Map<AgentTypeEnum, Function<MessageSession, Agent>> AGENT_FUNC_MAP = new HashMap<>();
/**
* 初始化方法,完成Agent的注册
*/
@PostConstruct
public void init() {
// 注册任务规划智能体
AGENT_FUNC_MAP.put(AgentTypeEnum.RE_ACT_PLANNING_AGENT, this::reActPlanningAgent);
// 注册浏览器智能体
AGENT_FUNC_MAP.put(AgentTypeEnum.BROWSER_AGENT, this::reActBrowserAgent);
// 注册表格智能体
AGENT_FUNC_MAP.put(AgentTypeEnum.TABLE_AGENT, this::tableAgent);
}
@Bean
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public Agent tableAgent(MessageSession messageSession) {
return new TableAgent(messageSession);
}
@Bean
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public Agent reActBrowserAgent(MessageSession messageSession) {
return new ReActBrowserAgent(messageSession);
}
/**
* 根据agentTypeEnum获取对应的Agent
*/
public static Function<MessageSession, Agent> getAgent(AgentTypeEnum agentTypeEnum) {
Function<MessageSession, Agent> fun = AGENT_FUNC_MAP.get(agentTypeEnum);
if (null == fun) {
throw new IllegalArgumentException("找不到对应的智能体: " + agentTypeEnum);
}
return fun;
}
/**
* 任务规划智能体,交由Spring管理
*
* @param messageSession 会话对象
* @return 查找到的智能体实例
*/
@Bean
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public Agent reActPlanningAgent(MessageSession messageSession) {
return new ReActPlanningAgent(messageSession);
}
}
在PlanningAgent中,注册智能体:
@PostConstruct
void init() {
// 将需要的智能体名称和描述添加到智能体信息中
agentInfo.put(AgentTypeEnum.BROWSER_AGENT.getAgentName(), AgentTypeEnum.BROWSER_AGENT.getDesc());
agentInfo.put(AgentTypeEnum.TABLE_AGENT.getAgentName(), AgentTypeEnum.TABLE_AGENT.getDesc());
}
测试
用户提问:使用百度搜索北京最近7天的天气,使用表格进行总结


生成的表格效果:

ChartAgent
ChartAgent是基于AI大模型,将内容生成图表页面。
prompt
{task},现在你需要生成一个html文件,把提供的数据绘制成恰当的图表,并遵循下列规则
- 生成html主体,并使用javascript绘制图表
- 如果使用到js、css的cdn,请使用中国大陆境内可访问的cdn地址,例如bootCDN
- 数据中可能存在冗余的部分,某些数据可能缺少部分种类,绘图时请关注所有数据共有的部分
- 注意数据的单位是否一致,如果不一致请尝试转换
- 返回格式要求如下:
你的返回值必须使用HTML格式
不要包含任何解释性的内容,仅严格按照此格式提供HTML回应,不得有任何偏差。
不要包含任何Markdown代码块。
从输出中移除```html标记。
代码实现
package cn.itcast.manus.agent.chart;
import cn.hutool.core.util.StrUtil;
import cn.itcast.manus.agent.BaseAgent;
import cn.itcast.manus.agent.prompt.PromptManagement;
import cn.itcast.manus.config.ModelConfig;
import cn.itcast.manus.constants.Constant;
import cn.itcast.manus.dto.DialogMessageDTO;
import cn.itcast.manus.message.MessageSession;
import cn.itcast.manus.service.FileStorageService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ChatModel;
import java.util.Map;
@Slf4j
public class ChartAgent extends BaseAgent {
private final MessageSession messageSession;
@Resource(name = ModelConfig.MAIN_AGENT)
private ChatModel chatModel;
@Resource
private PromptManagement promptManagement;
@Resource
private FileStorageService fileStorageService;
public ChartAgent(MessageSession messageSession) {
this.messageSession = messageSession;
}
@Override
protected String solve(String task) {
// 1.LLM生成 html
var params = Map.of(Constant.TASK, task);
var prompt = StrUtil.format(this.promptManagement.getPrompt(Constant.Prompts.CHART), params);
var html = this.chatModel.call(prompt);
// 2.存储文件,生成下载地址
var uuid = this.fileStorageService.saveFile(StrUtil.utf8Bytes(html));
var url = this.fileStorageService.generateDownloadUrl("chart.html", uuid);
// 3.走session返回到message里
var ms = DialogMessageDTO.builder()
.text("[ChartAgent]文件生成")
.fileUrl(url)
.build();
this.messageSession.sendMessage(ms);
return StrUtil.format("""
[ChartAgent]
生成可打开的url:{}
生成的可下载url:{}
""", this.fileStorageService.generateOpenUrl(uuid), url);
}
@Override
public ChatModel chatModel() {
return this.chatModel;
}
}
注册Agent
package cn.itcast.manus.agent;
import cn.itcast.manus.agent.browser.ReActBrowserAgent;
import cn.itcast.manus.agent.chart.ChartAgent;
import cn.itcast.manus.agent.planning.ReActPlanningAgent;
import cn.itcast.manus.agent.table.TableAgent;
import cn.itcast.manus.enums.AgentTypeEnum;
import cn.itcast.manus.message.MessageSession;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Scope;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
@Configuration
public class AgentFactory {
public static final Map<AgentTypeEnum, Function<MessageSession, Agent>> AGENT_FUNC_MAP = new HashMap<>();
/**
* 初始化方法,完成Agent的注册
*/
@PostConstruct
public void init() {
// 注册任务规划智能体
AGENT_FUNC_MAP.put(AgentTypeEnum.RE_ACT_PLANNING_AGENT, this::reActPlanningAgent);
// 注册浏览器智能体
AGENT_FUNC_MAP.put(AgentTypeEnum.BROWSER_AGENT, this::reActBrowserAgent);
// 注册表格智能体
AGENT_FUNC_MAP.put(AgentTypeEnum.TABLE_AGENT, this::tableAgent);
// 注册图表智能体
AGENT_FUNC_MAP.put(AgentTypeEnum.CHART_AGENT, this::chartAgent);
}
@Bean
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public Agent chartAgent(MessageSession messageSession) {
return new ChartAgent(messageSession);
}
@Bean
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public Agent tableAgent(MessageSession messageSession) {
return new TableAgent(messageSession);
}
@Bean
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public Agent reActBrowserAgent(MessageSession messageSession) {
return new ReActBrowserAgent(messageSession);
}
/**
* 根据agentTypeEnum获取对应的Agent
*/
public static Function<MessageSession, Agent> getAgent(AgentTypeEnum agentTypeEnum) {
Function<MessageSession, Agent> fun = AGENT_FUNC_MAP.get(agentTypeEnum);
if (null == fun) {
throw new IllegalArgumentException("找不到对应的智能体: " + agentTypeEnum);
}
return fun;
}
/**
* 任务规划智能体,交由Spring管理
*
* @param messageSession 会话对象
* @return 查找到的智能体实例
*/
@Bean
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public Agent reActPlanningAgent(MessageSession messageSession) {
return new ReActPlanningAgent(messageSession);
}
}
在PlanningAgent中,注册智能体:
@PostConstruct
void init() {
// 将需要的智能体名称和描述添加到智能体信息中
agentInfo.put(AgentTypeEnum.BROWSER_AGENT.getAgentName(), AgentTypeEnum.BROWSER_AGENT.getDesc());
agentInfo.put(AgentTypeEnum.TABLE_AGENT.getAgentName(), AgentTypeEnum.TABLE_AGENT.getDesc());
agentInfo.put(AgentTypeEnum.CHART_AGENT.getAgentName(), AgentTypeEnum.CHART_AGENT.getDesc());
}
测试
用户提问:使用百度搜索北京最近7天的天气,使用图表进行总结

生成的图表效果:

HtmlDocAgent
HtmlDocAgent是基于AI大模型,此Agent用于生成各类网页内容,只能基于上下文中已有的数据进行生成,无法查询额外信息;可作为生成一般内容时的默认Agent。
prompt
{task},现在你需要生成一个html多媒体文档并遵循下列规则
- 根据要求以及提供的数据返回html主体
- 如果使用到js、css的cdn,请使用中国大陆境内可访问的cdn地址,例如bootCDN
- 若信息中包含特定app才能打开的特殊协议链接,请将其转换为二维码
- 返回格式要求如下:
你的返回值必须使用HTML格式
不要包含任何解释性的内容,仅严格按照此格式提供HTML回应,不得有任何偏差。
不要包含任何Markdown代码块。
从输出中移除```html标记。
代码实现
package cn.itcast.manus.agent.html;
import cn.hutool.core.util.StrUtil;
import cn.itcast.manus.agent.BaseAgent;
import cn.itcast.manus.agent.prompt.PromptManagement;
import cn.itcast.manus.config.ModelConfig;
import cn.itcast.manus.constants.Constant;
import cn.itcast.manus.dto.DialogMessageDTO;
import cn.itcast.manus.message.MessageSession;
import cn.itcast.manus.service.FileStorageService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ChatModel;
import java.util.Map;
@Slf4j
public class HtmlDocAgent extends BaseAgent {
@Resource(name = ModelConfig.MAIN_AGENT)
private ChatModel chatModel;
@Resource
private PromptManagement promptManagement;
@Resource
private FileStorageService fileStorageService;
private final MessageSession messageSession;
public HtmlDocAgent(MessageSession messageSession) {
this.messageSession = messageSession;
}
@Override
protected String solve(String task) {
// 1.LLM生成 html
var params = Map.of(Constant.TASK, task);
var prompt = StrUtil.format(this.promptManagement.getPrompt(Constant.Prompts.HTML_DOC), params);
var html = this.chatModel.call(prompt);
// 2.存储文件,生成下载地址
var uuid = this.fileStorageService.saveFile(StrUtil.utf8Bytes(html));
var url = this.fileStorageService.generateDownloadUrl("htmldoc.html", uuid);
// 3.走session返回到message里
var ms = DialogMessageDTO.builder()
.text("[HtmlDocAgent]文件生成")
.fileUrl(url)
.build();
this.messageSession.sendMessage(ms);
return StrUtil.format("""
[HtmlDocAgent]
生成可打开的url:{}
生成的可下载url:{}
""", this.fileStorageService.generateOpenUrl(uuid), url);
}
@Override
public ChatModel chatModel() {
return this.chatModel;
}
}
注册Agent
package cn.itcast.manus.agent;
import cn.itcast.manus.agent.browser.ReActBrowserAgent;
import cn.itcast.manus.agent.chart.ChartAgent;
import cn.itcast.manus.agent.html.HtmlDocAgent;
import cn.itcast.manus.agent.planning.ReActPlanningAgent;
import cn.itcast.manus.agent.table.TableAgent;
import cn.itcast.manus.enums.AgentTypeEnum;
import cn.itcast.manus.message.MessageSession;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Scope;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
@Configuration
public class AgentFactory {
public static final Map<AgentTypeEnum, Function<MessageSession, Agent>> AGENT_FUNC_MAP = new HashMap<>();
/**
* 初始化方法,完成Agent的注册
*/
@PostConstruct
public void init() {
// 注册任务规划智能体
AGENT_FUNC_MAP.put(AgentTypeEnum.RE_ACT_PLANNING_AGENT, this::reActPlanningAgent);
// 注册浏览器智能体
AGENT_FUNC_MAP.put(AgentTypeEnum.BROWSER_AGENT, this::reActBrowserAgent);
// 注册表格智能体
AGENT_FUNC_MAP.put(AgentTypeEnum.TABLE_AGENT, this::tableAgent);
// 注册图表智能体
AGENT_FUNC_MAP.put(AgentTypeEnum.CHART_AGENT, this::chartAgent);
// 注册网页文档智能体
AGENT_FUNC_MAP.put(AgentTypeEnum.HTML_DOC_AGENT, this::htmlDocAgent);
}
@Bean
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public Agent htmlDocAgent(MessageSession messageSession) {
return new HtmlDocAgent(messageSession);
}
@Bean
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public Agent chartAgent(MessageSession messageSession) {
return new ChartAgent(messageSession);
}
@Bean
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public Agent tableAgent(MessageSession messageSession) {
return new TableAgent(messageSession);
}
@Bean
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public Agent reActBrowserAgent(MessageSession messageSession) {
return new ReActBrowserAgent(messageSession);
}
/**
* 根据agentTypeEnum获取对应的Agent
*/
public static Function<MessageSession, Agent> getAgent(AgentTypeEnum agentTypeEnum) {
Function<MessageSession, Agent> fun = AGENT_FUNC_MAP.get(agentTypeEnum);
if (null == fun) {
throw new IllegalArgumentException("找不到对应的智能体: " + agentTypeEnum);
}
return fun;
}
/**
* 任务规划智能体,交由Spring管理
*
* @param messageSession 会话对象
* @return 查找到的智能体实例
*/
@Bean
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public Agent reActPlanningAgent(MessageSession messageSession) {
return new ReActPlanningAgent(messageSession);
}
}
在PlanningAgent中,注册智能体:
@PostConstruct
void init() {
// 将需要的智能体名称和描述添加到智能体信息中
agentInfo.put(AgentTypeEnum.BROWSER_AGENT.getAgentName(), AgentTypeEnum.BROWSER_AGENT.getDesc());
agentInfo.put(AgentTypeEnum.TABLE_AGENT.getAgentName(), AgentTypeEnum.TABLE_AGENT.getDesc());
agentInfo.put(AgentTypeEnum.CHART_AGENT.getAgentName(), AgentTypeEnum.CHART_AGENT.getDesc());
agentInfo.put(AgentTypeEnum.HTML_DOC_AGENT.getAgentName(), AgentTypeEnum.HTML_DOC_AGENT.getDesc());
}
测试
用户提问:使用百度搜索北京最近7天的天气,使用html进行总结

生成的html页面效果:

AMAPAgent
AMAPAgent是基于AI大模型,此Agent包含完整的地图工具集,可用于路线规划、结构化地址转换为经纬度坐标等地理信息操作,返回文字或多媒体链接的结果。
基于高德提供的MCP服务实现:
prompt
你是一个地图导航员,负责根据输入查询精确地点信息及交通线路规划,你的任务是:{task},你需要按照如下要求完成任务:
- 你需要通过提供的工具链完成你的任务
- 当涉及到经纬度坐标数据时,注意参数的精确度
- 返回信息时需要尽量详细,要包括通过工具链调用的完整数据,不要自行省略
高德SSE自动断开问题
package cn.itcast.manus.mcp;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.client.McpAsyncClient;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpClient.AsyncSpec;
import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport;
import io.modelcontextprotocol.spec.McpClientTransport;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.mcp.McpToolUtils;
import org.springframework.ai.model.ModelOptionsUtils;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import java.util.function.Function;
/**
* 由于amap的server在sse端链接2min后自动断链,springai不支持短sse方案,故使用此方式每次做初始化,使用完后关闭
*
* @author zepu
*
*/
@Component
@Slf4j
public class SseShortConnectionSupport {
@Value("${amap.key:0c4e4fccb076468f694bdd243523feaf}")
private String amapKey;
public <T> T requestAmapSse(Function<ToolCallbackProvider, T> func) {
var provider = amapToolCallbackProvider();
return func.apply(provider);
}
public <T> T retrieveFromShortConnectionClient(Function<McpAsyncClient, T> func) {
var cli = amapMCPClient();
cli.initialize().doOnError(e -> log.error("error on sse client init", e))
.doOnSuccess(v -> log.trace("sse client inited.")).block();
try {
return func.apply(cli);
} finally {
cli.closeGracefully().doOnError(e -> log.error("error on sse client close", e))
.doOnSuccess(v -> log.trace("sse client closed.")).block();
}
}
public void useShortConnectionClient(Consumer<McpAsyncClient> cons) {
Function<McpAsyncClient, Void> func = c -> {
cons.accept(c);
return null;
};
retrieveFromShortConnectionClient(func);
}
private McpClientTransport amapMCPTransport() {
return HttpClientSseClientTransport.builder("https://mcp.amap.com")
.sseEndpoint(String.format("/sse?key=%s", amapKey))
.objectMapper(new ObjectMapper())
.build();
}
private McpAsyncClient amapMCPClient() {
var namedTransport = amapMCPTransport();
McpSchema.Implementation clientInfo = new McpSchema.Implementation("spring-ai-amap-mcp", "1.0.0");
AsyncSpec spec = McpClient.async(namedTransport)
.clientInfo(clientInfo)
.requestTimeout(Duration.ofSeconds(12));
return spec.build();
}
private ToolCallbackProvider amapToolCallbackProvider() {
return new LazyAsyncMcpToolCallbackProvider();
}
class LazyAsyncMcpToolCallbackProvider implements ToolCallbackProvider {
@Override
public ToolCallback @NotNull [] getToolCallbacks() {
List<ToolCallback> toolCallBack = new LinkedList<>();
useShortConnectionClient(cli -> {
cli.listTools().map(response -> response.tools()
.stream()
.map(LazyToolCallback::new)
.toList())
.block().forEach(toolCallBack::add);
});
return toolCallBack.toArray(ToolCallback[]::new);
}
}
class LazyToolCallback implements ToolCallback {
private final Tool tool;
public LazyToolCallback(Tool tool) {
this.tool = tool;
}
@Override
public @NotNull ToolDefinition getToolDefinition() {
String name = retrieveFromShortConnectionClient(cli -> cli.getClientInfo().name());
return ToolDefinition.builder().name(McpToolUtils.prefixedToolName(name, this.tool.name()))
.description(this.tool.description())
.inputSchema(ModelOptionsUtils.toJsonString(this.tool.inputSchema())).build();
}
@Override
public @NotNull String call(@NotNull String functionInput) {
log.info("tool call - [{}]input:{}", tool.name(), functionInput);
var res = retrieveFromShortConnectionClient(cli -> {
Map<String, Object> arguments = ModelOptionsUtils.jsonToMap(functionInput);
// Note that we use the original tool name here, not the adapted one from
// getToolDefinition
return cli.callTool(new CallToolRequest(this.tool.name(), arguments)).map(response -> {
if (response.isError() != null && response.isError()) {
throw new IllegalStateException("Error calling tool: " + response.content());
}
return ModelOptionsUtils.toJsonString(response.content());
}).block();
});
log.info("tool call - [{}]output:{}", tool.name(), res);
return res;
}
@Override
public @NotNull String call(@NotNull String toolArguments, @NotNull ToolContext toolContext) {
return call(toolArguments);
}
}
}
代码实现
package cn.itcast.manus.agent.amap;
import cn.hutool.core.util.StrUtil;
import cn.itcast.manus.agent.BaseAgent;
import cn.itcast.manus.agent.prompt.PromptManagement;
import cn.itcast.manus.config.ModelConfig;
import cn.itcast.manus.constants.Constant;
import cn.itcast.manus.mcp.SseShortConnectionSupport;
import jakarta.annotation.Resource;
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.model.Generation;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.openai.OpenAiChatOptions;
import java.util.Map;
import java.util.Optional;
@Slf4j
public class AMAPAgent extends BaseAgent {
@Resource(name = ModelConfig.MAIN_AGENT)
private ChatModel chatModel;
@Resource
private PromptManagement promptManagement;
@Resource
private SseShortConnectionSupport sseShortConnectionSupport;
@Override
protected String solve(String task) {
var params = Map.of(Constant.TASK, task);
var prompt = StrUtil.format(this.promptManagement.getPrompt(Constant.Prompts.AMAP_SYSTEM), params);
return this.sseShortConnectionSupport.requestAmapSse(toolCallbackProvider -> {
//调用大模型
ChatOptions opt = OpenAiChatOptions.builder()
.toolCallbacks(toolCallbackProvider.getToolCallbacks())
.build();
Prompt req = new Prompt(prompt, opt);
var resp = this.chatModel().call(req);
return Optional.of(resp)
.map(ChatResponse::getResult)
.map(Generation::getOutput)
.map(AssistantMessage::getText)
.orElseThrow();
});
}
@Override
public ChatModel chatModel() {
return this.chatModel;
}
}
注册Agent
package cn.itcast.manus.agent;
import cn.itcast.manus.agent.amap.AMAPAgent;
import cn.itcast.manus.agent.browser.ReActBrowserAgent;
import cn.itcast.manus.agent.chart.ChartAgent;
import cn.itcast.manus.agent.html.HtmlDocAgent;
import cn.itcast.manus.agent.planning.ReActPlanningAgent;
import cn.itcast.manus.agent.table.TableAgent;
import cn.itcast.manus.enums.AgentTypeEnum;
import cn.itcast.manus.message.MessageSession;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Scope;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
@Configuration
public class AgentFactory {
public static final Map<AgentTypeEnum, Function<MessageSession, Agent>> AGENT_FUNC_MAP = new HashMap<>();
/**
* 初始化方法,完成Agent的注册
*/
@PostConstruct
public void init() {
// 注册任务规划智能体
AGENT_FUNC_MAP.put(AgentTypeEnum.RE_ACT_PLANNING_AGENT, this::reActPlanningAgent);
// 注册浏览器智能体
AGENT_FUNC_MAP.put(AgentTypeEnum.BROWSER_AGENT, this::reActBrowserAgent);
// 注册表格智能体
AGENT_FUNC_MAP.put(AgentTypeEnum.TABLE_AGENT, this::tableAgent);
// 注册图表智能体
AGENT_FUNC_MAP.put(AgentTypeEnum.CHART_AGENT, this::chartAgent);
// 注册网页文档智能体
AGENT_FUNC_MAP.put(AgentTypeEnum.HTML_DOC_AGENT, this::htmlDocAgent);
// 注册高德地图智能体
AGENT_FUNC_MAP.put(AgentTypeEnum.AMAP_AGENT, this::amapAgent);
}
@Bean
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public Agent amapAgent(MessageSession messageSession) {
return new AMAPAgent();
}
@Bean
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public Agent htmlDocAgent(MessageSession messageSession) {
return new HtmlDocAgent(messageSession);
}
@Bean
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public Agent chartAgent(MessageSession messageSession) {
return new ChartAgent(messageSession);
}
@Bean
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public Agent tableAgent(MessageSession messageSession) {
return new TableAgent(messageSession);
}
@Bean
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public Agent reActBrowserAgent(MessageSession messageSession) {
return new ReActBrowserAgent(messageSession);
}
/**
* 根据agentTypeEnum获取对应的Agent
*/
public static Function<MessageSession, Agent> getAgent(AgentTypeEnum agentTypeEnum) {
Function<MessageSession, Agent> fun = AGENT_FUNC_MAP.get(agentTypeEnum);
if (null == fun) {
throw new IllegalArgumentException("找不到对应的智能体: " + agentTypeEnum);
}
return fun;
}
/**
* 任务规划智能体,交由Spring管理
*
* @param messageSession 会话对象
* @return 查找到的智能体实例
*/
@Bean
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public Agent reActPlanningAgent(MessageSession messageSession) {
return new ReActPlanningAgent(messageSession);
}
}
在PlanningAgent中,注册智能体:
@PostConstruct
void init() {
// 将需要的智能体名称和描述添加到智能体信息中
agentInfo.put(AgentTypeEnum.BROWSER_AGENT.getAgentName(), AgentTypeEnum.BROWSER_AGENT.getDesc());
agentInfo.put(AgentTypeEnum.TABLE_AGENT.getAgentName(), AgentTypeEnum.TABLE_AGENT.getDesc());
agentInfo.put(AgentTypeEnum.CHART_AGENT.getAgentName(), AgentTypeEnum.CHART_AGENT.getDesc());
agentInfo.put(AgentTypeEnum.HTML_DOC_AGENT.getAgentName(), AgentTypeEnum.HTML_DOC_AGENT.getDesc());
agentInfo.put(AgentTypeEnum.AMAP_AGENT.getAgentName(), AgentTypeEnum.AMAP_AGENT.getDesc());
}
测试
用户提问:查询北京天安门到颐和园的驾车导航路线,生成路线html页面

生成的html页面效果:
更多推荐




所有评论(0)