课程安排

  • 了解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从“文本生成器”升级为“问题解决者”。

核心组成

  1. 推理(Reasoning)
    模型通过思维链(Chain-of-Thought)分解任务需求,规划解决路径。例如,回答“如何分析销售数据下滑原因?”时,模型会先推理出需要调取数据、筛选关键指标、对比历史趋势等步骤
  2. 行动(Acting)
    根据推理结果调用外部工具(如API、数据库)执行具体操作。例如:
    • 调用 query_by_product_name 工具检索产品信息****;
    • 使用 calculate 工具计算优惠价格****。
  3. 观察(Observation)
    接收工具返回的结果或环境反馈,更新模型对任务的理解,并触发下一轮推理或行动。例如,若调用促销查询工具后发现无优惠活动,模型会调整策略直接返回原价

📚 以电商客服场景为例:

  1. 用户提问:“足球现在有优惠吗?买一个多少钱?”
  2. 推理:模型判断需先查询产品库存和促销信息。
  3. 行动:调用 read_store_promotions 工具获取促销数据。
  4. 观察:工具返回“足球当前享8折优惠”。
  5. 推理:计算折后价格需结合原价。
  6. 行动:调用 calculate 工具计算最终价格。
  7. 观察:确认价格无误后生成回复****。

ReAct的优势

  1. 减少幻觉(Hallucination)
    通过工具验证信息,避免模型虚构答案。例如,调用搜索引擎或数据库可确保促销信息的准确性****。
  2. 增强可解释性
    推理步骤和工具调用过程可追溯,提升决策透明度****。
  3. 动态适应性
    根据环境反馈实时调整策略,适用于多变场景(如库存变动、促销更新)****。
  4. 高效处理复杂任务
    多轮循环机制支持拆解多步骤任务(如数据分析、行程规划)。

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服务实现:

概述-MCP Server | 高德地图API

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页面效果:在这里插入图片描述

Logo

小龙虾开发者社区是 CSDN 旗下专注 OpenClaw 生态的官方阵地,聚焦技能开发、插件实践与部署教程,为开发者提供可直接落地的方案、工具与交流平台,助力高效构建与落地 AI 应用

更多推荐