Administrator
2025-09-02 3d762c09e6b25a87c8f4c5e026ed6de255b43377
feat(ai): 实现 AI 流式回答功能

- 新增 AiPromptJsonReq、AiTalkAnswerStreamDto、AiTalkOutputEnum等数据模型
- 在 ApiMemberTalkStreamController 中添加流式回答和保存答案的接口
- 在 ApiMemberTalkStreamService 中定义相关服务方法
- 实现流式回答的逻辑,包括构建提示、调用 LLM 策略等
- 实现保存答案的逻辑,支持新增和更新答案
4 files modified
4 files added
304 ■■■■■ changed files
src/main/java/cc/mrbird/febs/ai/controller/memberTalk/ApiMemberTalkStreamController.java 30 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/ai/enumerates/AiPromptEnum.java 2 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/ai/enumerates/AiTalkOutputEnum.java 49 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/ai/req/memberTalkStream/AiPromptJsonReq.java 19 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/ai/req/memberTalkStream/AiTalkAnswerStreamDto.java 27 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/ai/req/memberTalkStream/ApiMemberTalkAnswerSavaDto.java 35 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/ai/service/ApiMemberTalkStreamService.java 7 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/ai/service/impl/ApiMemberTalkStreamServiceImpl.java 135 ●●●●● patch | view | raw | blame | history
src/main/java/cc/mrbird/febs/ai/controller/memberTalk/ApiMemberTalkStreamController.java
@@ -1,18 +1,29 @@
package cc.mrbird.febs.ai.controller.memberTalk;
import cc.mrbird.febs.ai.req.memberTalk.ApiMemberTalkItemPageDto;
import cc.mrbird.febs.ai.req.memberTalkStream.AiTalkAnswerStreamDto;
import cc.mrbird.febs.ai.req.memberTalkStream.ApiMemberTalkAnswerSavaDto;
import cc.mrbird.febs.ai.req.memberTalkStream.ApiMemberTalkReloadStreamDto;
import cc.mrbird.febs.ai.req.memberTalkStream.ApiMemberTalkStreamDto;
import cc.mrbird.febs.ai.req.talk.AiTalkAnswerStream;
import cc.mrbird.febs.ai.res.memberTalk.ApiMemberTalkItemVo;
import cc.mrbird.febs.ai.res.memberTalkStream.ApiMemberTalkReloadStreamVo;
import cc.mrbird.febs.ai.res.memberTalkStream.ApiMemberTalkStreamVo;
import cc.mrbird.febs.ai.service.ApiMemberTalkStreamService;
import cc.mrbird.febs.ai.strategy.enumerates.LlmStrategyEnum;
import cc.mrbird.febs.ai.strategy.param.LlmStrategyDto;
import cc.mrbird.febs.common.entity.FebsResponse;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.alibaba.dashscope.common.Role;
import io.swagger.annotations.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import java.util.ArrayList;
/**
 * @author Administrator
@@ -56,4 +67,23 @@
        return apiMemberTalkStreamService.historyPage(dto);
    }
    @ApiOperation("回答(流式)")
    @ApiResponses({
            @ApiResponse(code = 200, message = "流式响应", response = cc.mrbird.febs.ai.res.memberTalk.ApiMemberTalkStreamVo.class),
    })
    @PostMapping("/answer")
    public Flux<FebsResponse> answer(@RequestBody @Validated AiTalkAnswerStreamDto dto) {
        if (StrUtil.isEmpty(dto.getId()) || StrUtil.isEmpty(dto.getReqContext())|| StrUtil.isEmpty(dto.getReqContext())){
            return Flux.just(new FebsResponse().fail().message("参数异常"));
        }
        return apiMemberTalkStreamService.answer(dto);
    }
    @ApiOperation(value = "保存答案", notes = "保存答案")
    @PostMapping(value = "/saveAnswer")
    public FebsResponse saveAnswer(@RequestBody @Validated ApiMemberTalkAnswerSavaDto dto) {
        return apiMemberTalkStreamService.saveAnswer(dto);
    }
}
src/main/java/cc/mrbird/febs/ai/enumerates/AiPromptEnum.java
@@ -9,6 +9,8 @@
@Getter
public enum AiPromptEnum {
    HIGH_LIGHT(2,"请输出亮点"),
    STREAM_NORMAL(1,"你是人工智能,请回答我提出的问题");
    private final int code;
src/main/java/cc/mrbird/febs/ai/enumerates/AiTalkOutputEnum.java
New file
@@ -0,0 +1,49 @@
package cc.mrbird.febs.ai.enumerates;
import lombok.Getter;
/**
 * @author Administrator
 */
@Getter
public enum AiTalkOutputEnum {
    KEY_KNOWLEDGE(4,"只输出知识点总结","KEY_KNOWLEDGE"),
    REFERENCE_ANSWER(3,"只输出参考答案","REFERENCE_ANSWER"),
    SUGGESTION(2,"只输出建议","SUGGESTION"),
    HIGH_LIGHT(1,"只输出亮点","HIGH_LIGHT");
    private final int type;
    private final String content;
    private final String code;
    AiTalkOutputEnum(int type, String content, String code) {
        this.type = type;
        this.content = content;
        this.code = code;
    }
    public static String getContentByType(int type) {
        for (AiTalkOutputEnum value : AiTalkOutputEnum.values()) {
            if (value.getType() ==  type) {
                return value.getContent();
            }
        }
        return null;
    }
    public static String getCodeByType(int type) {
        for (AiTalkOutputEnum value : AiTalkOutputEnum.values()) {
            if (value.getType() ==  type) {
                return value.getCode();
            }
        }
        return null;
    }
}
src/main/java/cc/mrbird/febs/ai/req/memberTalkStream/AiPromptJsonReq.java
New file
@@ -0,0 +1,19 @@
package cc.mrbird.febs.ai.req.memberTalkStream;
import io.swagger.annotations.ApiModel;
import lombok.Data;
/**
 * @author Administrator
 */
@Data
@ApiModel(value = "AiPromptJsonReq", description = "参数")
public class AiPromptJsonReq {
    private String task;
    private String rule;
    private String outputFormat;
}
src/main/java/cc/mrbird/febs/ai/req/memberTalkStream/AiTalkAnswerStreamDto.java
New file
@@ -0,0 +1,27 @@
package cc.mrbird.febs.ai.req.memberTalkStream;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
/**
 * @author Administrator
 */
@Data
@ApiModel(value = "AiTalkAnswerStreamDto", description = "参数")
public class AiTalkAnswerStreamDto {
    @ApiModelProperty(value = "类型 1:亮点 2:建议 3:参考答案 4:知识点总结", example = "10")
    private Integer type;
    @ApiModelProperty(value = "会话ID", example = "10")
    private String id;
    @ApiModelProperty(value = "回答", example = "10")
    private String reqContext;
}
src/main/java/cc/mrbird/febs/ai/req/memberTalkStream/ApiMemberTalkAnswerSavaDto.java
New file
@@ -0,0 +1,35 @@
package cc.mrbird.febs.ai.req.memberTalkStream;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
/**
 * @author Administrator
 */
@Data
@ApiModel(value = "ApiMemberTalkAnswerSavaDto", description = "参数")
public class ApiMemberTalkAnswerSavaDto {
    /**
     * 用户对话ID (UUID)
     */
    @NotBlank(message = "会话ID不能为空")
    @ApiModelProperty(value = "会话ID", example = "10")
    private String memberTalkId;
    @ApiModelProperty(value = "会话ItemID", example = "10")
    private String memberTalkItemId;
    @NotBlank(message = "回复内容不能为空")
    @ApiModelProperty(value = "回复内容", example = "10")
    private String content;
    @NotNull(message = "类型ID不能为空")
    @ApiModelProperty(value = "类型 1:亮点 2:建议 3:参考答案 4:知识点总结", example = "10")
    private Integer type;
}
src/main/java/cc/mrbird/febs/ai/service/ApiMemberTalkStreamService.java
@@ -2,10 +2,13 @@
import cc.mrbird.febs.ai.entity.AiMemberTalk;
import cc.mrbird.febs.ai.req.memberTalk.ApiMemberTalkItemPageDto;
import cc.mrbird.febs.ai.req.memberTalkStream.AiTalkAnswerStreamDto;
import cc.mrbird.febs.ai.req.memberTalkStream.ApiMemberTalkAnswerSavaDto;
import cc.mrbird.febs.ai.req.memberTalkStream.ApiMemberTalkReloadStreamDto;
import cc.mrbird.febs.ai.req.memberTalkStream.ApiMemberTalkStreamDto;
import cc.mrbird.febs.common.entity.FebsResponse;
import com.baomidou.mybatisplus.extension.service.IService;
import reactor.core.publisher.Flux;
public interface ApiMemberTalkStreamService extends IService<AiMemberTalk> {
    /**
@@ -18,4 +21,8 @@
    FebsResponse reload(ApiMemberTalkReloadStreamDto dto);
    FebsResponse historyPage(ApiMemberTalkItemPageDto dto);
    Flux<FebsResponse> answer(AiTalkAnswerStreamDto dto);
    FebsResponse saveAnswer(ApiMemberTalkAnswerSavaDto dto);
}
src/main/java/cc/mrbird/febs/ai/service/impl/ApiMemberTalkStreamServiceImpl.java
@@ -1,29 +1,36 @@
package cc.mrbird.febs.ai.service.impl;
import cc.mrbird.febs.ai.entity.AiMemberTalk;
import cc.mrbird.febs.ai.entity.AiMemberTalkItem;
import cc.mrbird.febs.ai.entity.AiProductQuestion;
import cc.mrbird.febs.ai.entity.AiProductRoleLink;
import cc.mrbird.febs.ai.entity.*;
import cc.mrbird.febs.ai.enumerates.AiTalkOutputEnum;
import cc.mrbird.febs.ai.enumerates.AiTypeEnum;
import cc.mrbird.febs.ai.mapper.AiMemberTalkMapper;
import cc.mrbird.febs.ai.req.memberTalk.ApiMemberTalkItemPageDto;
import cc.mrbird.febs.ai.req.memberTalkStream.ApiMemberTalkReloadStreamDto;
import cc.mrbird.febs.ai.req.memberTalkStream.ApiMemberTalkStreamDto;
import cc.mrbird.febs.ai.req.memberTalkStream.*;
import cc.mrbird.febs.ai.res.memberTalkStream.ApiMemberTalkReloadStreamVo;
import cc.mrbird.febs.ai.res.memberTalkStream.ApiMemberTalkStreamVo;
import cc.mrbird.febs.ai.service.*;
import cc.mrbird.febs.ai.strategy.LlmStrategyFactory;
import cc.mrbird.febs.ai.strategy.enumerates.LlmStrategyEnum;
import cc.mrbird.febs.ai.strategy.param.LlmStrategyDto;
import cc.mrbird.febs.common.entity.FebsResponse;
import cc.mrbird.febs.common.exception.FebsException;
import cc.mrbird.febs.common.utils.LoginUserUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.alibaba.dashscope.common.Role;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
/**
 * AI用户对话训练记录 Service实现类
@@ -38,9 +45,11 @@
    private final AiMemberTalkMapper aiMemberTalkMapper;
    private final AiProductRoleLinkService aiProductRoleLinkService;
    private final AiProductRoleService aiProductRoleService;
    private final AiMemberTalkService aiMemberTalkService;
    private final AiMemberTalkItemService aiMemberTalkItemService;
    private final AiProductQuestionService aiProductQuestionService;
    private final LlmStrategyFactory llmStrategyFactory;
    private final AiService aiService;
@@ -108,4 +117,118 @@
        dto.setMemberUuid(memberUuid);
        return new FebsResponse().success().data(aiMemberTalkItemService.historyPage(dto));
    }
    @Override
    public Flux<FebsResponse> answer(AiTalkAnswerStreamDto dto) {
        String memberUuid = LoginUserUtil.getLoginUser().getMemberUuid();
        Integer type = dto.getType();
        String memberTalkId = dto.getId();
        AiMemberTalk aiMemberTalk = this.getById(memberTalkId);
        if (ObjectUtil.isNull(aiMemberTalk)){
            throw new FebsException("对话不存在");
        }
        LambdaQueryWrapper<AiProductRoleLink> productLinkQuery = Wrappers.lambdaQuery(AiProductRoleLink.class);
        productLinkQuery.eq(AiProductRoleLink::getProductId,aiMemberTalk.getProductId());
        productLinkQuery.last("limit 1");
        AiProductRoleLink aiProductRoleLink = aiProductRoleLinkService.getByQuery(productLinkQuery);
        if(ObjectUtil.isNull(aiProductRoleLink)){
            throw new FebsException("产品没有关联AI陪练");
        }
        String productRoleId = aiProductRoleLink.getProductRoleId();
        AiProductRole aiProductRole = aiProductRoleService.getById(productRoleId);
        if (ObjectUtil.isNull(aiProductRole)){
            throw new FebsException("产品AI陪练不存在");
        }
        String reqContext = dto.getReqContext();
        LambdaQueryWrapper<AiMemberTalkItem> memberTalkItemQuery = Wrappers.lambdaQuery(AiMemberTalkItem.class);
        memberTalkItemQuery.eq(AiMemberTalkItem::getMemberId,memberUuid);
        memberTalkItemQuery.eq(AiMemberTalkItem::getMemberTalkId,memberTalkId);
        memberTalkItemQuery.eq(AiMemberTalkItem::getType,1);
        memberTalkItemQuery.orderByDesc(AiMemberTalkItem::getCreatedTime);
        memberTalkItemQuery.last("limit 1");
        AiMemberTalkItem aiMemberTalkItem = aiMemberTalkItemService.getByQuery(memberTalkItemQuery);
        aiMemberTalkItemService.add(memberUuid,aiMemberTalk.getId(),2,reqContext,new Date());
        String prompt = this.buildPrompt(aiProductRole.getPromptHead(), aiProductRole.getPromptTemplate(), type);
        List<LlmStrategyDto> llmStrategyDtoList = new ArrayList<>();
        LlmStrategyDto llmStrategyDto = this.buildLlmStrategyDtoList(prompt, 1);
        llmStrategyDtoList.add(llmStrategyDto);
        llmStrategyDto = this.buildLlmStrategyDtoList(reqContext, 2);
        llmStrategyDtoList.add(llmStrategyDto);
        String modelName = LlmStrategyEnum.getName(aiService.getSystemSetAiType());
        return llmStrategyFactory.getCalculationStrategyMap().get(modelName).llmInvokeStreamingNoThink(llmStrategyDtoList);
    }
    private String buildPrompt(String promptHead, String promptTemplate,Integer type){
        AiPromptJsonReq aiPromptJsonReq = new AiPromptJsonReq();
        aiPromptJsonReq.setTask(promptHead);
        aiPromptJsonReq.setRule(promptTemplate);
        String contentByCode = AiTalkOutputEnum.HIGH_LIGHT.getContentByType(type);
        aiPromptJsonReq.setOutputFormat(contentByCode);
        return JSONUtil.toJsonStr(aiPromptJsonReq);
    }
    private LlmStrategyDto buildLlmStrategyDtoList(String Str, Integer type){
        LlmStrategyDto llmStrategyDto = new LlmStrategyDto();
        if (type == 1){
            llmStrategyDto.setRole(Role.SYSTEM.getValue());
        }
        if (type == 2){
            llmStrategyDto.setRole(Role.USER.getValue());
        }
        if (type == 3){
            llmStrategyDto.setRole(Role.ASSISTANT.getValue());
        }
        llmStrategyDto.setContent(Str);
        return llmStrategyDto;
    }
    @Override
    public FebsResponse saveAnswer(ApiMemberTalkAnswerSavaDto dto) {
        String memberUuid = LoginUserUtil.getLoginUser().getMemberUuid();
        String memberTalkId = dto.getMemberTalkId();
        String content = dto.getContent();
        AiMemberTalk aiMemberTalk = this.getById(memberTalkId);
        if (ObjectUtil.isNull(aiMemberTalk)){
            throw new FebsException("对话不存在");
        }
        Integer type = dto.getType();
        String memberTalkItemId;
        String contentByCode = AiTalkOutputEnum.HIGH_LIGHT.getCodeByType(type);
        if(StrUtil.isEmpty(dto.getMemberTalkItemId())){
            HashMap<String, String> stringStringHashMap = new HashMap<>();
            stringStringHashMap.put(contentByCode,content);
            AiMemberTalkItem add = aiMemberTalkItemService.add(memberUuid, memberTalkId, 3, JSONUtil.toJsonStr(stringStringHashMap), new Date());
            memberTalkItemId = add.getId();
        }else{
            memberTalkItemId = dto.getMemberTalkItemId();
            AiMemberTalkItem aiMemberTalkItem = aiMemberTalkItemService.getById(memberTalkItemId);
            String context = aiMemberTalkItem.getContext();
            HashMap<String, String> stringStringHashMap = JSONUtil.toBean(context, HashMap.class);
            stringStringHashMap.put(contentByCode,content);
            aiMemberTalkItemService.update(null,
                    Wrappers.lambdaUpdate(AiMemberTalkItem.class)
                            .set(AiMemberTalkItem::getContext,JSONUtil.toJsonStr(stringStringHashMap))
                            .set(AiMemberTalkItem::getUpdatedTime,new Date())
                            .set(AiMemberTalkItem::getRevision,aiMemberTalkItem.getRevision()+1)
                            .eq(AiMemberTalkItem::getId,memberTalkItemId)
            );
        }
        HashMap<String, String> stringStringHashMap = new HashMap<>();
        stringStringHashMap.put("memberTalkItemId",memberTalkItemId);
        return new FebsResponse().success().data(stringStringHashMap);
    }
}