前言

在企业级AI应用中,完全自动化的流程往往难以满足复杂业务需求。例如请假审批系统,AI可以帮我们校验信息、计算余额,但最终的决策权必须交给人。这就需要 人工介入(Human-in-the-Loop) 机制。

本文将带你从零开始,使用 Spring AI Alibaba Graph 框架,构建一个完整的请假审批工作流,深入讲解人工介入的实现原理。

代码仓库

后端代码

前端代码

实现效果
Alt

一、基础概念:先搞懂 人工介入 在AI工作流中的定位

1.1 什么是AI工作流中的人工介入?

人工介入,本质是在AI自动化流程中设置 中断点 ,当流程执行到该节点时,暂停AI的自动执行,将关键信息推送至人工端,由人工完成判断、确认、审批等操作后,再将人工操作结果反馈给工作流,恢复流程继续执行。

简单来说:AI负责“重复性、规则明确”的操作(如信息校验、数据计算),人工负责“不确定性、高风险、需主观判断”的操作(如信息确认、审批决策),二者协同完成完整业务流程。

1.2 人工介入的核心场景

以本文的员工请假流程为例,核心人工介入场景有2个,也是实际业务中最常见的场景:

  • 场景1:AI校验后的 信息确认 — AI完成请假信息(天数、类型、原因)的基础校验后,需要人工确认信息是否准确、是否存在遗漏,避免AI误判。
  • 场景2:流程中的 主管审批 — AI计算完员工假期余额后,需要主管人工审批是否同意请假,审批结果直接决定流程继续执行还是终止。

1.3 核心技术框架说明

本文案例基于Spring Cloud AI Graph 框架开发,该框架是Spring生态中用于构建AI工作流的核心框架,支持节点定义、流程编排、中断控制、状态管理,非常适合实现“AI+人工”混合工作流。

核心优势:无需手动编写复杂的流程控制逻辑,通过注解和API即可完成节点配置、分支判断、中断与恢复,大幅降低人工介入流程的开发成本。

二、核心组件

在开始解析代码前,我们先梳理案例中涉及的核心组件,明确每个组件的作用,避免后续看代码时“一头雾水”。所有组件均对应本文提供的代码,一一对应,方便大家对照查看。

2.1 核心组件分类及作用

组件类型 具体类名 核心作用
工作流配置类 GraphHumanConfiguration 定义工作流的节点、分支、状态字段,是整个工作流的“总配置”
节点类(Node LeaveInfoCheckNode、LeaveInfoConfirmNode等 工作流的最小执行单元,负责具体业务逻辑(AI校验、人工确认、余额计算等)
调度器类(Dispatcher) LeaveInfoDispatcher、ManagerApproveDispatcher 负责分支判断,根据节点执行结果,决定流程下一步走向
流程处理类 GraphProcess 处理工作流的输出流,将节点结果、中断信息推送至前端(供人工操作)
控制器类 GraphHumanController 提供接口供前端调用(提交申请、人工确认、主管审批、查询中断状态)
状态相关 KeyStrategyFactory、StateGraph 管理工作流的状态字段(如user_id、leave_days),支持状态持久化与更新

2.2 核心概念补充

  • StateGraph(状态图):整个工作流的载体,包含所有节点、分支、状态信息,负责流程的启动、执行、中断与恢复。
  • 中断点(Interrupt):人工介入的核心触发点,通过 interruptAfter(节点执行后中断)、interruptBefore(节点执行前中断)定义。
  • StateSnapshot(状态快照):保存工作流中断时的所有状态信息,用于人工操作后恢复流程时,获取中断前的上下文。
  • Stream(流):工作流的输出方式,通过SSE(Server-Sent Events)推送给前端,实现实时反馈(如节点执行状态、中断提示)。

三、业务流程拆解

结合本文案例的员工请假流程,我们先梳理完整的业务流程,明确AI操作和人工操作的边界、流程走向,再通过流程图和时序图直观呈现,最后对应代码解析。

3.1 完整业务流程

整个请假流程分为 AI自动执行 和 人工介入 两个阶段,共7个核心步骤,其中2个人工介入步骤,具体如下:

  1. 员工提交请假申请:前端调用接口,传入user_id、user_name、leave_type(请假类型)、leave_days(请假天数)、leave_reason(请假原因)等参数。
  2. AI智能校验(自动):工作流启动,执行AI校验节点(LeaveInfoCheckNode),校验请假信息是否完整(天数>0、类型和原因非空),返回校验结果(ok/error)。
  3. 人工信息确认(介入):AI校验完成后,工作流中断(interruptAfter("ai_check")),将校验后的请假信息推送给人工,人工确认信息是否准确(确认/取消)。
  4. 分支判断(自动):根据人工确认结果,调度器(LeaveInfoDispatcher)决定流程走向 — 确认通过则进入下一步,取消则流程终止。
  5. AI计算假期余额(自动):确认通过后,执行余额计算节点(LeaveCalcBalanceNode),计算员工的年假、病假、事假余额。
  6. 主管审批(介入):余额计算完成后,工作流再次中断(interruptBefore("manager_approve")),将员工请假信息、假期余额推送给主管,主管审批(同意/拒绝)。
  7. 流程收尾(自动):根据主管审批结果,调度器(ManagerApproveDispatcher)决定流程走向 — 同意则发送通知(LeaveNotifyNode),流程结束;拒绝则流程终止。

3.2 工作流流程图

以下流程图直观呈现整个请假流程的节点、分支和人工介入点,清晰区分AI自动节点和人工节点:

人工确认通过

人工确认取消

审批通过

审批拒绝

流程开始

AI智能校验请假信息(LeaveInfoCheckNode)

人工信息确认(LeaveInfoConfirmNode,介入点)

AI计算假期余额(LeaveCalcBalanceNode)

流程结束

主管审批(ManagerApproveNode,介入点)

执行通知(LeaveNotifyNode)

3.3 人工介入时序图

时序图主要呈现 前端-后端-工作流-人工 的交互过程,重点体现人工介入时的中断、操作、恢复逻辑:

人工操作(员工/主管) 各业务节点 StateGraph/CompiledGraph GraphHumanController 前端(员工/主管) 人工操作(员工/主管) 各业务节点 StateGraph/CompiledGraph GraphHumanController 前端(员工/主管) 调用/submit接口,传入请假参数 启动工作流,传入参数 执行AI校验节点(LeaveInfoCheckNode) 返回AI校验结果(ok/error) 触发中断(interruptAfter("ai_check")) 通过SSE推送中断信息(需人工确认) 展示请假信息+AI校验结果 确认/取消操作 调用/confirm-info接口,传入确认结果 更新状态,恢复流程执行 执行人工确认节点(LeaveInfoConfirmNode) 返回下一步走向(calc_balance/END) 执行余额计算节点(LeaveCalcBalanceNode) 返回假期余额 触发中断(interruptBefore("manager_approve")) 通过SSE推送中断信息(需主管审批) 展示请假信息+假期余额 审批通过/拒绝 调用/approve接口,传入审批结果 更新状态,恢复流程执行 执行主管审批节点(ManagerApproveNode) 返回下一步走向(notify/END) 执行通知节点(LeaveNotifyNode) 返回通知结果 流程执行完成 通过SSE推送流程结束信息

四、代码逐模块解析

接下来,我们对照业务流程,逐模块解析代码,重点关注 人工介入相关逻辑 (中断配置、中断信息推送、人工操作接口、流程恢复),其他基础代码会简要说明,确保大家能看懂每一行代码的作用。

4.1 核心依赖

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>com.elvis</groupId>
  <artifactId>human-in-the-loop-leave</artifactId>
  <version>1.0</version>
  <packaging>jar</packaging>

  <name>human-in-the-loop-leave</name>
  <url>http://maven.apache.org</url>

  <properties>
    <java.version>17</java.version>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    <spring-boot.version>3.5.4</spring-boot.version>
    <spring-ai-alibaba.version>1.1.2.0</spring-ai-alibaba.version>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
      <groupId>org.projectlombok</groupId>
      <artifactId>lombok</artifactId>
      <optional>true</optional>
    </dependency>

    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
    </dependency>

    <dependency>
      <groupId>org.springframework.ai</groupId>
      <artifactId>spring-ai-autoconfigure-model-chat-client</artifactId>
      <version>1.1.2</version>
      <scope>compile</scope>
    </dependency>

    <!-- Spring AI Alibaba Agent Framework -->
    <dependency>
      <groupId>com.alibaba.cloud.ai</groupId>
      <artifactId>spring-ai-alibaba-agent-framework</artifactId>
      <version>${spring-ai-alibaba.version}</version>
    </dependency>

    <!-- DashScope ChatModel -->
    <dependency>
      <groupId>com.alibaba.cloud.ai</groupId>
      <artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
      <version>${spring-ai-alibaba.version}</version>
    </dependency>

    <!--graph 核心-->
    <dependency>
      <groupId>com.alibaba.cloud.ai</groupId>
      <artifactId>spring-ai-alibaba-graph-core</artifactId>
      <version>${spring-ai-alibaba.version}</version>
    </dependency>

  </dependencies>
  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-dependencies</artifactId>
        <version>${spring-boot.version}</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>

  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.8.1</version>
        <configuration>
          <source>17</source>
          <target>17</target>
          <encoding>UTF-8</encoding>
          <compilerArgs>
            <arg>-parameters</arg>
          </compilerArgs>
        </configuration>
      </plugin>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
        <version>${spring-boot.version}</version>
        <configuration>
          <mainClass>com.elvis.HumanInTheLoopLeaveApplication</mainClass>
          <skip>true</skip>
        </configuration>
        <executions>
          <execution>
            <id>repackage</id>
            <goals>
              <goal>repackage</goal>
            </goals>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>
</project>

4.2 主配置文件

server:
  port: 8081
spring:
  ai:
    dashscope:
      api-key: ${ALI_API_KEY}
      chat:
        options:
          model: qwen-plus   # 模型名称
          temperature: 0.5   # 温度
          max-tokens: 2048   # 最大令牌数

4.3 节点类:人工介入节点的核心逻辑

Graph中的节点类负责执行具体的业务逻辑,接收当前流程状态并返回要更新的数据,是工作流中实际完成AI计算、数据校验、人工交互等核心操作的执行单元。

4.3.1 人工信息确认节点:LeaveInfoConfirmNode
package com.elvis.node;

import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.StateGraph;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import lombok.extern.slf4j.Slf4j;
import java.util.Map;

@Slf4j
public class LeaveInfoConfirmNode implements NodeAction {
    @Override
    public Map<String, Object> apply(OverAllState state) {
        log.info("=== 人工信息确认节点 ===");

        // 1. 获取人工确认结果(从状态中获取,人工操作后由接口更新)
        boolean infoConfirm = state.value("info_confirm", false);
        // 2. 根据确认结果,决定下一步走向:确认通过→计算余额,取消→结束
        String nextStep = infoConfirm ? "calc_balance" : StateGraph.END;

        log.info("信息确认结果:{},下一步:{}", infoConfirm, nextStep);
        // 3. 返回下一步走向,供调度器使用
        return Map.of("next_after_info", nextStep);
    }
}
4.4.2 主管审批节点:ManagerApproveNode
package com.elvis.node;

import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.StateGraph;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import lombok.extern.slf4j.Slf4j;
import java.util.Map;

@Slf4j
public class ManagerApproveNode implements NodeAction {
    @Override
    public Map<String, Object> apply(OverAllState state) {
        log.info("=== 主管审批节点 ===");
        // 1. 获取主管审批结果(人工操作后由接口更新)
        boolean approve = state.value("manager_approve", false);
        // 2. 根据审批结果,决定下一步走向:同意→通知,拒绝→结束
        String next = approve ? "notify" : StateGraph.END;
        // 3. 返回下一步走向,供调度器使用
        return Map.of("next_after_manager", next);
    }
}
4.3.3 假期余额计算节点:LeaveCalcBalanceNode

从流程状态中获取用户信息,模拟计算并返回固定的年假、病假、事假余额数据。

package com.elvis.node;

import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import lombok.extern.slf4j.Slf4j;
import java.util.Map;

@Slf4j
public class LeaveCalcBalanceNode implements NodeAction {
    @Override
    public Map<String, Object> apply(OverAllState state) {
        log.info("=== AI 计算假期余额 ===");

        // 从 state 中获取用户信息(用于日志)
        String userId = state.value("user_id", "");
        String userName = state.value("user_name", "");
        log.info("为用户 {} ({}) 计算假期余额", userId, userName);

        // 只返回余额计算结果,用户信息会从原有 state 中保留
        return Map.of(
                "balance_annual", 5,
                "balance_sick", 10,
                "balance_personal", 3
        );
    }
}
4.3.4 请假信息 AI 校验节点:LeaveInfoCheckNode

从流程状态中读取请假天数、类型、原因,判断信息是否完整,并返回校验结果与提示信息。

package com.elvis.node;

import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.StateGraph;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import java.util.Map;

@Slf4j
public class LeaveInfoCheckNode implements NodeAction {

    public LeaveInfoCheckNode(ChatClient.Builder builder) {}

    @Override
    public Map<String, Object> apply(OverAllState state) {
        log.info("=== AI 正在校验请假信息 ===");

        int days = state.value("leave_days", 0);
        String reason = state.value("leave_reason", "");
        String type = state.value("leave_type", "");

        boolean valid = days > 0 && !reason.isBlank() && !type.isBlank();

        return Map.of(
                "ai_check_result", valid ? "ok" : "error",
                "ai_check_msg", valid ? "信息完整" : "信息不完整:天数/类型/原因不能为空"
        );
    }
}
4.3.5 请假流程通知节点:LeaveNotifyNode

在审批通过后执行,输出通知日志并返回包含员工、主管、HR 三方的通知结果。

package com.elvis.node;

import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import lombok.extern.slf4j.Slf4j;
import java.util.Map;

@Slf4j
public class LeaveNotifyNode implements NodeAction {
    @Override
    public Map<String, Object> apply(OverAllState state) {
        log.info("=== 审批通过 → 已发送请假成功通知 ===");
        return Map.of("notify_result", "通知成功:员工+主管+HR");
    }
}

关键解析:

人工介入节点本身不直接和人工交互,而是通过 状态(OverAllState) 获取人工操作结果 — 人工操作(确认/审批)会通过接口更新状态中的对应字段(info_confirm、manager_approve),节点执行时读取该字段,决定下一步走向。

4.4 工作流配置类:GraphHumanConfiguration(核心配置)

该类是整个工作流的核心配置,负责定义 状态字段、节点、流程分支 ,也是人工介入节点的“注册中心”。

package com.elvis.config;

import com.alibaba.cloud.ai.graph.*;
import com.alibaba.cloud.ai.graph.action.AsyncEdgeAction;
import com.alibaba.cloud.ai.graph.action.AsyncNodeAction;
import com.alibaba.cloud.ai.graph.exception.GraphStateException;
import com.alibaba.cloud.ai.graph.state.strategy.ReplaceStrategy;
import com.elvis.dispatcher.LeaveInfoDispatcher;
import com.elvis.dispatcher.ManagerApproveDispatcher;
import com.elvis.node.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.Map;

@Configuration
@Slf4j
public class GraphHumanConfiguration {

    @Bean
    public StateGraph leaveGraph(ChatClient.Builder chatClientBuilder) throws GraphStateException {

        // 1. 定义工作流的状态字段(所有需要传递的参数,如请假信息、校验结果、人工操作结果)
        KeyStrategyFactory keyStrategyFactory = new KeyStrategyFactoryBuilder()
                .addPatternStrategy("thread_id", new ReplaceStrategy()) // 会话ID,用于关联流程实例
                .addPatternStrategy("user_id", new ReplaceStrategy())   // 员工ID
                .addPatternStrategy("user_name", new ReplaceStrategy()) // 员工姓名
                .addPatternStrategy("leave_type", new ReplaceStrategy())// 请假类型
                .addPatternStrategy("leave_days", new ReplaceStrategy())// 请假天数
                .addPatternStrategy("leave_reason", new ReplaceStrategy())// 请假原因
                .addPatternStrategy("ai_check_result", new ReplaceStrategy())// AI校验结果
                .addPatternStrategy("ai_check_msg", new ReplaceStrategy())// AI校验提示
                .addPatternStrategy("info_confirm", new ReplaceStrategy())// 人工信息确认结果(true/false)
                .addPatternStrategy("next_after_info", new ReplaceStrategy())// 信息确认后的下一步
                .addPatternStrategy("balance_annual", new ReplaceStrategy())// 年假余额
                .addPatternStrategy("balance_sick", new ReplaceStrategy())// 病假余额
                .addPatternStrategy("balance_personal", new ReplaceStrategy())// 事假余额
                .addPatternStrategy("manager_approve", new ReplaceStrategy())// 主管审批结果(true/false)
                .addPatternStrategy("next_after_manager", new ReplaceStrategy())// 审批后的下一步
                .build();

        // 2. 构建状态图(工作流载体),配置节点和分支
        StateGraph stateGraph = new StateGraph(keyStrategyFactory)

                // 节点1:AI智能校验请假信息(自动节点)
                .addNode("ai_check", AsyncNodeAction.node_async(new LeaveInfoCheckNode(chatClientBuilder)))

                // 节点2:人工信息确认节点(人工介入节点,核心)
                .addNode("info_check", AsyncNodeAction.node_async(new LeaveInfoConfirmNode()))

                // 节点3:AI计算假期余额(自动节点)
                .addNode("calc_balance", AsyncNodeAction.node_async(new LeaveCalcBalanceNode()))

                // 节点4:主管审批节点(人工介入节点,核心)
                .addNode("manager_approve", AsyncNodeAction.node_async(new ManagerApproveNode()))

                // 节点5:执行通知(自动节点)
                .addNode("notify", AsyncNodeAction.node_async(new LeaveNotifyNode()))

                // 流程走向:开始 → AI校验
                .addEdge(StateGraph.START, "ai_check")

                // 流程走向:AI校验 → 人工信息确认
                .addEdge("ai_check", "info_check")

                // 条件分支:人工信息确认后的走向(核心分支)
                .addConditionalEdges(
                        "info_check", // 分支节点:人工信息确认节点
                        AsyncEdgeAction.edge_async(new LeaveInfoDispatcher()), // 调度器:判断下一步
                        Map.of(
                                "calc_balance", "calc_balance", // 确认通过 → 计算余额
                                StateGraph.END, StateGraph.END  // 确认取消 → 流程结束
                        )
                )

                // 流程走向:计算余额 → 主管审批
                .addEdge("calc_balance", "manager_approve")

                // 条件分支:主管审批后的走向(核心分支)
                .addConditionalEdges(
                        "manager_approve", // 分支节点:主管审批节点
                        AsyncEdgeAction.edge_async(new ManagerApproveDispatcher()), // 调度器:判断下一步
                        Map.of(
                                "notify", "notify", // 审批通过 → 发送通知
                                StateGraph.END, StateGraph.END // 审批拒绝 → 流程结束
                        )
                )

                // 流程走向:通知完成 → 流程结束
                .addEdge("notify", StateGraph.END);

        // 打印流程图(方便调试,可在日志中查看)
        GraphRepresentation representation = stateGraph.getGraph(GraphRepresentation.Type.PLANTUML, "员工请假流程");
        log.info("\n==================================");
        log.info("请假流程 UML:\n" + representation.content());
        log.info("==================================\n");

        return stateGraph;
    }
}

关键解析:

  • 状态字段配置:所有和人工介入相关的字段(如info_confirm、manager_approve)都需要注册到KeyStrategyFactory,确保流程中断后,人工操作结果能被正确保存和传递。
  • 人工节点定义:info_check(人工信息确认)和manager_approve(主管审批)是两个核心人工介入节点,通过AsyncNodeAction.node_async定义为异步节点,支持流程中断。
  • 分支条件:通过addConditionalEdges定义人工操作后的流程走向,由调度器(Dispatcher)根据人工操作结果(如info_confirm为true/false)决定下一步。

4.5 流程处理类:GraphProcess(中断信息推送)

该类负责处理工作流的输出流,当流程执行到中断点时,将中断信息(需要人工操作的内容)通过SSE推送给前端,供人工查看和操作,是 人工介入的交互桥梁 。

package com.elvis.controller.GraphProcess;

import com.alibaba.cloud.ai.graph.CompiledGraph;
import com.alibaba.cloud.ai.graph.NodeOutput;
import com.alibaba.cloud.ai.graph.streaming.StreamingOutput;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.codec.ServerSentEvent;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Sinks;

import java.util.Map;

@Slf4j
public class GraphProcess {
    private CompiledGraph compiledGraph;

    public GraphProcess(CompiledGraph compiledGraph) {
        this.compiledGraph = compiledGraph;
    }

    // 处理工作流输出流,推送中断信息和节点执行结果
    public void processStream(Flux<NodeOutput> nodeOutputFlux, Sinks.Many<ServerSentEvent<ChatMessage>> sink) {
        nodeOutputFlux
                .doOnNext(output -> {
                    log.info("output = {}", output);

                    String nodeName = output.node();
                    ChatMessage chatMessage = null;

                    // 处理流式输出(本文暂不涉及,可忽略)
                    if (output instanceof StreamingOutput<?> streamingOutput) {
                        String chunk = streamingOutput.chunk();
                        if (chunk != null && !chunk.isEmpty()) {
                            chatMessage = new ChatMessage(nodeName, chunk, null, null);
                        }
                    }
                    else {
                        // 获取当前工作流的所有状态数据
                        Map<String, Object> stateData = output.state().data();

                        String interruptType = null; // 中断类型(info_confirm/manager_approve)
                        Map<String, Object> interruptInfo = null; // 中断信息(需人工操作的内容)

                        // 1. 当AI校验节点执行完成后,触发人工信息确认中断
                        if ("ai_check".equals(nodeName)) {
                            interruptType = "info_confirm";
                            interruptInfo = Map.of(
                                    "message", "请确认请假信息",
                                    "fields", Map.of(
                                            "user_id", stateData.get("user_id"),
                                            "user_name", stateData.get("user_name"),
                                            "leave_type", stateData.get("leave_type"),
                                            "leave_days", stateData.get("leave_days"),
                                            "leave_reason", stateData.get("leave_reason"),
                                            "ai_check_result", stateData.get("ai_check_result"),
                                            "ai_check_msg", stateData.get("ai_check_msg")
                                    )
                            );
                        }
                        // 2. 当余额计算节点执行完成后,触发主管审批中断(关键修复:传递完整用户信息)
                        else if ("calc_balance".equals(nodeName)) {
                            interruptType = "manager_approve";
                            interruptInfo = Map.of(
                                    "message", "请主管审批",
                                    "fields", Map.of(
                                            "user_id", stateData.get("user_id"),
                                            "user_name", stateData.get("user_name"),
                                            "leave_type", stateData.get("leave_type"),
                                            "leave_days", stateData.get("leave_days"),
                                            "leave_reason", stateData.get("leave_reason"),
                                            "balance_annual", stateData.get("balance_annual"),
                                            "balance_sick", stateData.get("balance_sick"),
                                            "balance_personal", stateData.get("balance_personal")
                                    )
                            );
                        }

                        // 构建聊天消息(中断信息),推送给前端
                        chatMessage = new ChatMessage(nodeName, stateData, interruptType, interruptInfo);
                    }

                    // 将消息推送给前端(SSE方式,实时反馈)
                    if (chatMessage != null) {
                        sink.tryEmitNext(ServerSentEvent.builder(chatMessage).build());
                    }
                })
                .doOnComplete(() -> {
                    log.info("工作流执行完成");
                    sink.tryEmitComplete();
                })
                .doOnError(e -> {
                    log.error("在传输过程中出现错误", e);
                    sink.tryEmitError(e);
                })
                .subscribe();
    }

    // 定义推送的消息格式(前端可根据该格式解析中断信息)
    public record ChatMessage(
            @JsonProperty("node_name") String nodeName,
            @JsonProperty("type") Object data,
            @JsonProperty("interrupt_type") String interruptType,
            @JsonProperty("interrupt_info") Object interruptInfo
    ) {
    }
}

关键解析:

  • 中断信息构建:当流程执行到ai_check(AI校验)和calc_balance(余额计算)节点后,会构建对应的中断信息(interruptType和interruptInfo),包含人工需要查看的所有内容(请假信息、校验结果、余额等)。
  • SSE推送:通过Sinks和ServerSentEvent实现实时推送,前端接收后,可展示中断提示和相关信息,供人工操作。
  • 消息格式:ChatMessage类定义了推送消息的格式,前端可根据interrupt_type判断是“信息确认”还是“主管审批”,根据interrupt_info展示具体内容。

4.6 控制器类:GraphHumanController

该类提供前端可调用的接口,负责 提交请假申请、人工确认、主管审批、查询中断状态 ,是人工操作与工作流交互的入口,核心接口对应人工介入的两个场景。

package com.elvis.controller;

import com.alibaba.cloud.ai.graph.*;
import com.alibaba.cloud.ai.graph.checkpoint.config.SaverConfig;
import com.alibaba.cloud.ai.graph.checkpoint.savers.MemorySaver;
import com.alibaba.cloud.ai.graph.exception.GraphRunnerException;
import com.alibaba.cloud.ai.graph.exception.GraphStateException;
import com.alibaba.cloud.ai.graph.state.StateSnapshot;
import com.elvis.controller.GraphProcess.GraphProcess;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Sinks;

import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

@Slf4j
@RestController
@RequestMapping("/leave")
@CrossOrigin
public class GraphHumanController {

    private final CompiledGraph compiledGraph;

    // 初始化工作流,配置中断点(核心:人工介入的触发配置)
    @Autowired
    public GraphHumanController(@Qualifier("leaveGraph") StateGraph stateGraph) throws GraphStateException {
        SaverConfig saverConfig = SaverConfig.builder()
                .register(new MemorySaver()) // 内存存储状态快照(生产环境可替换为Redis等)
                .build();

        this.compiledGraph = stateGraph.compile(
                CompileConfig.builder()
                        .saverConfig(saverConfig)
                        .interruptAfter("ai_check")      // 中断点1:AI校验完成后暂停,触发人工信息确认
                        .interruptBefore("manager_approve")// 中断点2:主管审批之前暂停,触发主管审批
                        .build()
        );
    }

    // 接口1:提交请假申请(触发工作流启动)
    @GetMapping(value = "/submit", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ServerSentEvent<GraphProcess.ChatMessage>> submit(
            @RequestParam String user_id,
            @RequestParam String user_name,
            @RequestParam String leave_type,
            @RequestParam Integer leave_days,
            @RequestParam String leave_reason,
            @RequestParam(defaultValue = "leave_001") String thread_id
    ) {
        RunnableConfig runnableConfig = RunnableConfig.builder().threadId(thread_id).build();

        // 封装请假参数
        Map<String, Object> params = new HashMap<>();
        params.put("user_id", user_id);
        params.put("user_name", user_name);
        params.put("leave_type", leave_type);
        params.put("leave_days", leave_days);
        params.put("leave_reason", leave_reason);

        // 处理工作流输出,推送中断信息
        GraphProcess process = new GraphProcess(compiledGraph);
        Sinks.Many<ServerSentEvent<GraphProcess.ChatMessage>> sink = Sinks.many().unicast().onBackpressureBuffer();

        Flux<NodeOutput> flux = compiledGraph.stream(params, runnableConfig);
        process.processStream(flux, sink);

        return sink.asFlux()
                .doOnCancel(() -> log.info("客户端断开连接"))
                .doOnError(e -> log.error("流错误", e));
    }

    // 接口2:获取当前中断状态(前端可调用该接口,查看当前是否处于中断状态、中断类型)
    @GetMapping("/interrupt-state")
    public Map<String, Object> getInterruptState(@RequestParam String thread_id) throws GraphRunnerException {
        RunnableConfig config = RunnableConfig.builder().threadId(thread_id).build();
        Optional<StateSnapshot> snapshot = compiledGraph.stateOf(config);

        if (snapshot.isEmpty()) {
            return Map.of("error", "未找到会话:" + thread_id);
        }

        StateSnapshot state = snapshot.get();
        Map<String, Object> currentState = state.state().data();

        // 判断当前处于哪个中断点
        String interruptPoint = null;
        Map<String, Object> interruptData = new HashMap<>();

        // 中断点1:AI校验后,等待人工信息确认
        if (currentState.containsKey("ai_check_result") && !currentState.containsKey("info_confirm")) {
            interruptPoint = "info_confirm";
            interruptData.put("message", "请确认以下请假信息");
            interruptData.put("leave_type", currentState.get("leave_type"));
            interruptData.put("leave_days", currentState.get("leave_days"));
            interruptData.put("leave_reason", currentState.get("leave_reason"));
            interruptData.put("ai_check_result", currentState.get("ai_check_result"));
            interruptData.put("ai_check_msg", currentState.get("ai_check_msg"));
        }
        // 中断点2:余额计算后,等待主管审批
        else if (currentState.containsKey("balance_annual") && !currentState.containsKey("manager_approve")) {
            interruptPoint = "manager_approve";
            interruptData.put("message", "请主管审批");
            interruptData.put("balance_annual", currentState.get("balance_annual"));
            interruptData.put("balance_sick", currentState.get("balance_sick"));
            interruptData.put("balance_personal", currentState.get("balance_personal"));
            interruptData.put("user_name", currentState.get("user_name"));
            interruptData.put("leave_type", currentState.get("leave_type"));
            interruptData.put("leave_days", currentState.get("leave_days"));
            interruptData.put("leave_reason", currentState.get("leave_reason"));
        }

        return Map.of(
                "thread_id", thread_id,
                "interrupt_point", interruptPoint,
                "interrupt_data", interruptData,
                "current_state", currentState
        );
    }

    // 接口3:人工信息确认(人工操作后,调用该接口更新状态,恢复流程)
    @GetMapping(value = "/confirm-info", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ServerSentEvent<GraphProcess.ChatMessage>> confirmInfo(
            @RequestParam String thread_id,
            @RequestParam boolean info_confirm
    ) throws GraphRunnerException {
        return resume(thread_id, Map.of("info_confirm", info_confirm));
    }

    // 接口4:主管审批(人工操作后,调用该接口更新状态,恢复流程)
    @GetMapping(value = "/approve", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ServerSentEvent<GraphProcess.ChatMessage>> approve(
            @RequestParam String thread_id,
            @RequestParam boolean manager_approve
    ) throws GraphRunnerException {
        return resume(thread_id, Map.of("manager_approve", manager_approve));
    }

    // 通用恢复方法:更新人工操作结果,恢复工作流执行
    private Flux<ServerSentEvent<GraphProcess.ChatMessage>> resume(String threadId, Map<String, Object> data) throws GraphRunnerException {
        RunnableConfig config = RunnableConfig.builder().threadId(threadId).build();
        Optional<StateSnapshot> snapshot = compiledGraph.stateOf(config);

        return snapshot.map(state -> {
            try {
                // 1. 更新人工操作的状态(如info_confirm、manager_approve)
                RunnableConfig newConfig = compiledGraph.updateState(config, data, null);
                // 2. 恢复工作流执行
                GraphProcess process = new GraphProcess(compiledGraph);
                Sinks.Many<ServerSentEvent<GraphProcess.ChatMessage>> sink = Sinks.many().unicast().onBackpressureBuffer();
                Flux<NodeOutput> flux = compiledGraph.stream(null, newConfig);
                process.processStream(flux, sink);
                return sink.asFlux();
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }).orElseThrow(() -> new GraphRunnerException("未找到会话:" + threadId));
    }
}

关键解析:

  1. 中断点配置:在构造方法中,通过interruptAfter(&#34;ai_check&#34;)和interruptBefore(&#34;manager_approve&#34;)定义两个中断点,这是人工介入的“触发开关”。
  2. 人工操作接口:/confirm-info(信息确认)和/approve(主管审批)接收人工操作结果,调用resume方法更新状态、恢复流程。
  3. 状态恢复:resume方法是核心,通过compiledGraph.updateState更新人工操作结果(如info_confirm=true),再通过compiledGraph.stream恢复流程执行。
  4. 中断状态查询:/interrupt-state接口供前端查询当前流程是否处于中断状态、中断类型和相关信息,方便前端展示对应的人工操作界面。

4.7 调度器类:Dispatcher(分支判断)

调度器负责根据人工操作结果,决定流程的下一步走向,本质是“读取状态中的下一步标识,返回对应的节点名称”。

4.7.1 人工信息确认调度器:LeaveInfoDispatcher
package com.elvis.dispatcher;

import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.StateGraph;
import com.alibaba.cloud.ai.graph.action.EdgeAction;

public class LeaveInfoDispatcher implements EdgeAction {
    @Override
    public String apply(OverAllState state) {
        // 读取人工信息确认节点返回的下一步标识,决定流程走向
        return state.value("next_after_info", StateGraph.END);
    }
}
4.7.2 主管审批调度器:ManagerApproveDispatcher
package com.elvis.dispatcher;

import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.StateGraph;
import com.alibaba.cloud.ai.graph.action.EdgeAction;

public class ManagerApproveDispatcher implements EdgeAction {
    @Override
    public String apply(OverAllState state) {
        // 读取主管审批节点返回的下一步标识,决定流程走向
        return state.value("next_after_manager", StateGraph.END);
    }
}

## 五、前端代码

该页面基于 Vue3+Element Plus 开发,采用左侧配置表单、右侧聊天窗口的布局,集成 SSE 流式通信对接后端请假审批工作流;前端封装消息渲染、自动滚动、节点文案转换通用逻辑,通过表单录入请假基础信息发起流程,监听后端 SSE 推送实时展示 AI 校验、余额计算等流程节点消息,遇到信息确认、主管审批等人工介入节点时自动弹出模态弹窗等待操作,确认后继续调用接口推进流程,完整实现 AI 自动审核 + 人工节点干预的请假审批可视化交互。
```vue
<template>
  <div class="ai-chat-page">
    <div class="sidebar">
      <div class="sidebar-header">请假申请配置</div>
      <el-card class="config-card" shadow="hover">
        <el-form :model="form" label-width="100px" size="small">
          <el-form-item label="会话ID">
            <el-input v-model="form.threadId" style="width: 100%" />
          </el-form-item>
          <el-form-item label="员工ID">
            <el-input v-model="form.userId" style="width: 100%" />
          </el-form-item>
          <el-form-item label="员工姓名">
            <el-input v-model="form.userName" style="width: 100%" />
          </el-form-item>
          <el-form-item label="请假类型">
            <el-select v-model="form.leaveType" style="width: 100%">
              <el-option label="年假" value="年假" />
              <el-option label="病假" value="病假" />
              <el-option label="事假" value="事假" />
            </el-select>
          </el-form-item>
          <el-form-item label="请假天数">
            <el-input-number v-model="form.leaveDays" :min="1" :max="30" style="width: 100%" />
          </el-form-item>
          <el-form-item label="请假原因">
            <el-input v-model="form.leaveReason" type="textarea" rows="3" style="width: 100%" />
          </el-form-item>
          <el-form-item>
            <el-button type="primary" :loading="loading" @click="startLeave" style="width: 100%" round>
              提交请假申请
            </el-button>
          </el-form-item>
        </el-form>
      </el-card>
    </div>

    <div class="chat-container">
      <div class="chat-header">
        <span>🤖 员工请假审批助手</span>
        <el-button v-if="messages.length" text type="primary" size="small" @click="clearMessages">清空对话</el-button>
      </div>
      
      <div class="chat-content-box" v-loading="loading" ref="chatContainer">
        <div v-if="messages.length === 0" class="empty-chat">
          <div class="empty-icon">💬</div>
          <div>填写左侧信息,点击「提交请假申请」开始</div>
        </div>
        
        <div v-for="(msg, idx) in messages" :key="idx" class="chat-message" :class="msg.role">
          <div class="avatar">{{ msg.role === 'user' ? '👤' : '🤖' }}</div>
          <div class="message-bubble">
            <div class="message-time">{{ msg.time }}</div>
            <div class="message-text" v-html="msg.content"></div>
          </div>
        </div>
      </div>

      <!-- 信息确认对话框 -->
      <el-dialog v-model="showInfoConfirmDialog" title="📋 请假信息确认" width="500px" :close-on-click-modal="false">
        <div class="confirm-info-content">
          <el-descriptions :column="1" border>
            <el-descriptions-item label="申请人">{{ interruptInfo?.fields?.user_name || form.userName }}</el-descriptions-item>
            <el-descriptions-item label="请假类型">
              <el-tag size="small">{{ interruptInfo?.fields?.leave_type || form.leaveType }}</el-tag>
            </el-descriptions-item>
            <el-descriptions-item label="请假天数">{{ interruptInfo?.fields?.leave_days || form.leaveDays }}</el-descriptions-item>
            <el-descriptions-item label="请假原因">{{ interruptInfo?.fields?.leave_reason || form.leaveReason }}</el-descriptions-item>
            <el-descriptions-item label="AI校验">
              <el-tag :type="interruptInfo?.fields?.ai_check_result === 'ok' ? 'success' : 'danger'">
                {{ interruptInfo?.fields?.ai_check_msg }}
              </el-tag>
            </el-descriptions-item>
          </el-descriptions>
        </div>
        <template #footer>
          <el-button @click="confirmInfo(false)">❌ 信息有误,结束流程</el-button>
          <el-button type="primary" @click="confirmInfo(true)">✅ 信息无误,继续审批</el-button>
        </template>
      </el-dialog>

      <!-- 主管审批对话框 -->
      <el-dialog v-model="showApproveDialog" title="👔 主管审批" width="550px" :close-on-click-modal="false">
        <div class="approve-content">
          <el-descriptions :column="2" border>
            <el-descriptions-item label="申请人" :span="2">
              <strong>{{ interruptInfo?.fields?.user_name || form.userName }}</strong>
            </el-descriptions-item>
            <el-descriptions-item label="请假类型">{{ interruptInfo?.fields?.leave_type || form.leaveType }}</el-descriptions-item>
            <el-descriptions-item label="请假天数">{{ interruptInfo?.fields?.leave_days || form.leaveDays }}</el-descriptions-item>
            <el-descriptions-item label="请假原因" :span="2">{{ interruptInfo?.fields?.leave_reason || form.leaveReason }}</el-descriptions-item>
          </el-descriptions>
          
          <div class="balance-info">
            <div class="balance-title">📊 剩余假期余额</div>
            <div class="balance-items">
              <div class="balance-item">
                <span class="label">年假剩余</span>
                <span class="value">{{ interruptInfo?.fields?.balance_annual ?? '--' }}</span>
              </div>
              <div class="balance-item">
                <span class="label">病假剩余</span>
                <span class="value">{{ interruptInfo?.fields?.balance_sick ?? '--' }}</span>
              </div>
              <div class="balance-item">
                <span class="label">事假剩余</span>
                <span class="value">{{ interruptInfo?.fields?.balance_personal ?? '--' }}</span>
              </div>
            </div>
          </div>
        </div>
        <template #footer>
          <el-button type="danger" @click="managerApprove(false)">❌ 拒绝请假</el-button>
          <el-button type="success" @click="managerApprove(true)">✅ 同意请假</el-button>
        </template>
      </el-dialog>
    </div>
  </div>
</template>

<script setup>
import { ref, reactive, nextTick } from 'vue'
import { ElMessage } from 'element-plus'

const baseUrl = 'http://localhost:8081'

const form = reactive({
  threadId: 'leave_001',
  userId: '1001',
  userName: '张三',
  leaveType: '年假',
  leaveDays: 2,
  leaveReason: '回家处理私事'
})

const loading = ref(false)
const messages = ref([])
const showInfoConfirmDialog = ref(false)
const showApproveDialog = ref(false)
const interruptInfo = ref(null)
const chatContainer = ref(null)

// 格式化时间
const getTime = () => {
  const now = new Date()
  return `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`
}

// 添加消息
const addMessage = (role, content, isHtml = false) => {
  messages.value.push({
    role,  // 'user' 或 'assistant'
    content: isHtml ? content : content.replace(/\n/g, '<br>'),
    time: getTime()
  })
  nextTick(() => {
    if (chatContainer.value) {
      chatContainer.value.scrollTop = chatContainer.value.scrollHeight
    }
  })
}

// 清空消息
const clearMessages = () => {
  messages.value = []
}

// 根据节点名称和状态数据生成友好消息
const generateFriendlyMessage = (nodeName, stateData) => {
  switch (nodeName) {
    case '__START__':
      return `📝 收到请假申请:${stateData.user_name} 申请 ${stateData.leave_days} 天${stateData.leave_type},原因:${stateData.leave_reason}`
    
    case 'ai_check':
      if (stateData.ai_check_result === 'ok') {
        return `🔍 AI校验通过:${stateData.ai_check_msg}<br>✅ 请假信息完整,等待您确认`
      } else {
        return `⚠️ AI校验异常:${stateData.ai_check_msg}<br>请确认请假信息是否正确`
      }
    
    case 'info_check':
      if (stateData.next_after_info === 'calc_balance') {
        return `✅ 信息确认完成,继续计算假期余额...`
      } else {
        return `❌ 信息确认失败,流程结束`
      }
    
    case 'calc_balance':
      return `💰 假期余额计算完成<br>📊 您的剩余假期:年假 ${stateData.balance_annual} 天,病假 ${stateData.balance_sick} 天,事假 ${stateData.balance_personal} 天`
    
    case 'manager_approve':
      if (stateData.next_after_manager === 'notify') {
        return `👔 主管已同意请假申请,正在发送通知...`
      } else {
        return `❌ 主管已拒绝请假申请`
      }
    
    case 'notify':
      return `📧 ${stateData.notify_result || '通知发送成功'}`
    
    case '__END__':
      return `🏁 请假流程结束,感谢使用!`
    
    default:
      return null
  }
}

// 处理SSE流
const processSSEStream = async (url, onComplete) => {
  const res = await fetch(url, { headers: { Accept: 'text/event-stream' } })
  const reader = res.body.getReader()
  const decoder = new TextDecoder()
  let buffer = ''

  while (true) {
    const { done, value } = await reader.read()
    if (done) {
      if (onComplete) onComplete()
      break
    }
    
    buffer += decoder.decode(value, { stream: true })
    const lines = buffer.split('\n')
    buffer = lines.pop() || ''

    for (const line of lines) {
      if (line.startsWith('data:')) {
        try {
          const jsonStr = line.replace('data:', '').trim()
          if (!jsonStr) continue
          
          const data = JSON.parse(jsonStr)
          console.log('收到SSE数据:', data)
          
          // 处理节点数据 - 生成友好消息
          if (data.node_name && data.type && typeof data.type === 'object') {
            const friendlyMsg = generateFriendlyMessage(data.node_name, data.type)
            if (friendlyMsg) {
              addMessage('assistant', friendlyMsg, true)
            }
          }
          
          // 处理人工介入
          if (data.interrupt_type) {
            interruptInfo.value = data.interrupt_info
            
            if (data.interrupt_type === 'info_confirm') {
              showInfoConfirmDialog.value = true
              addMessage('assistant', '⏸️ 流程已暂停,请确认以下请假信息', true)
            } 
            else if (data.interrupt_type === 'manager_approve') {
              showApproveDialog.value = true
              addMessage('assistant', '⏸️ 流程已暂停,等待主管审批', true)
            }
          }
        } catch (e) {
          console.error('解析SSE数据失败:', e, line)
        }
      }
    }
  }
}

// 提交请假申请
const startLeave = async () => {
  loading.value = true
  messages.value = []
  showInfoConfirmDialog.value = false
  showApproveDialog.value = false
  interruptInfo.value = null

  // 先添加用户消息
  addMessage('user', `提交请假申请:${form.leaveDays}天${form.leaveType},原因:${form.leaveReason}`)

  const url = `${baseUrl}/leave/submit?` + new URLSearchParams({
    thread_id: form.threadId,
    user_id: form.userId,
    user_name: form.userName,
    leave_type: form.leaveType,
    leave_days: form.leaveDays,
    leave_reason: form.leaveReason
  })
  
  await processSSEStream(url, () => {
    loading.value = false
  })
}

// 信息确认
const confirmInfo = async (ok) => {
  showInfoConfirmDialog.value = false
  loading.value = true
  
  addMessage('user', ok ? '确认信息无误,继续审批' : '信息有误,取消申请')
  
  const url = `${baseUrl}/leave/confirm-info?thread_id=${form.threadId}&info_confirm=${ok}`
  
  await processSSEStream(url, () => {
    loading.value = false
  })
}

// 主管审批
const managerApprove = async (ok) => {
  showApproveDialog.value = false
  loading.value = true
  
  addMessage('user', ok ? '同意请假申请' : '拒绝请假申请')
  
  const url = `${baseUrl}/leave/approve?thread_id=${form.threadId}&manager_approve=${ok}`
  
  await processSSEStream(url, () => {
    loading.value = false
  })
}
</script>

<style scoped>
.ai-chat-page {
  display: flex;
  height: 95vh;
  gap: 20px;
  padding: 20px;
  background: #f0f2f5;
}
.sidebar { 
  width: 330px; 
  flex-shrink: 0;
}
.sidebar-header {
  font-size: 16px;
  font-weight: bold;
  margin-bottom: 12px;
  padding-left: 8px;
  color: #303133;
}
.config-card {
  border-radius: 12px;
}
.chat-container { 
  flex: 1; 
  background: #fff; 
  border-radius: 16px; 
  overflow: hidden; 
  display: flex;
  flex-direction: column;
  box-shadow: 0 2px 12px rgba(0,0,0,0.08);
}
.chat-header { 
  padding: 16px 20px; 
  border-bottom: 1px solid #e4e7ed; 
  font-weight: bold; 
  background: #fff;
  display: flex;
  justify-content: space-between;
  align-items: center;
}
.chat-content-box { 
  flex: 1; 
  padding: 20px; 
  overflow-y: auto;
  min-height: 0;
  background: #fafafa;
}
.empty-chat {
  text-align: center;
  padding: 60px 20px;
  color: #909399;
}
.empty-icon {
  font-size: 48px;
  margin-bottom: 16px;
}
.chat-message {
  display: flex;
  gap: 12px;
  margin-bottom: 20px;
  animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
  from { opacity: 0; transform: translateY(10px); }
  to { opacity: 1; transform: translateY(0); }
}
.chat-message.user {
  flex-direction: row-reverse;
}
.chat-message.user .avatar {
  background: #67c23a;
}
.chat-message.assistant .avatar {
  background: #409eff;
}
.avatar {
  width: 40px;
  height: 40px;
  background: #409eff;
  color: #fff;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 20px;
  flex-shrink: 0;
}
.message-bubble {
  max-width: 75%;
}
.chat-message.user .message-bubble {
  text-align: right;
}
.message-time {
  font-size: 11px;
  color: #909399;
  margin-bottom: 4px;
}
.chat-message.user .message-time {
  text-align: right;
}
.message-text {
  background: #fff;
  padding: 10px 16px;
  border-radius: 12px;
  line-height: 1.6;
  font-size: 14px;
  box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
.chat-message.user .message-text {
  background: #67c23a;
  color: #fff;
}
.chat-message.assistant .message-text {
  background: #fff;
  border: 1px solid #e4e7ed;
}
.confirm-info-content,
.approve-content {
  padding: 8px 0;
}
.balance-info {
  margin-top: 20px;
  background: #f5f7fa;
  border-radius: 12px;
  padding: 12px 16px;
}
.balance-title {
  font-weight: bold;
  margin-bottom: 12px;
  font-size: 14px;
}
.balance-items {
  display: flex;
  gap: 16px;
}
.balance-item {
  flex: 1;
  text-align: center;
}
.balance-item .label {
  font-size: 12px;
  color: #909399;
  display: block;
}
.balance-item .value {
  font-size: 18px;
  font-weight: bold;
  color: #409eff;
}
:deep(.el-dialog) {
  border-radius: 16px;
}
:deep(.el-descriptions) {
  font-size: 14px;
}
</style>

六、总结

本文案例代码完整可运行,大家可直接复制到项目中,结合Spring Cloud AI依赖配置,快速搭建人工介入工作流。如果在开发过程中遇到问题,可在评论区留言,一起交流探讨!

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐