cc 3 週間 前
親
コミット
6a49157ace
26 ファイル変更、1540 行追加、6 行削除
  1. 133 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/controller/AgentChatController.java
  2. 79 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/controller/AgentModelController.java
  3. 37 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/converter/AgentModelConverter.java
  4. 22 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/converter/AgentModelProviderConverter.java
  5. 15 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/dto/ChatRequestDTO.java
  6. 27 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/dto/CreateChatSessionDTO.java
  7. 40 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/dto/CreateModelDTO.java
  8. 24 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/dto/RagSearchDTO.java
  9. 24 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/dto/SendMessageDTO.java
  10. 40 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/dto/UpdateModelDTO.java
  11. 1 1
      ai-server/src/main/java/com/zsjz/ai/module/agent/entity/AgentChatSession.java
  12. 2 1
      ai-server/src/main/java/com/zsjz/ai/module/agent/followup/FollowupService.java
  13. 2 1
      ai-server/src/main/java/com/zsjz/ai/module/agent/intent/IntentService.java
  14. 73 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/service/AgentChatService.java
  15. 17 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/service/AgentModelProviderService.java
  16. 47 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/service/AgentModelService.java
  17. 7 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/service/LlmService.java
  18. 621 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/service/impl/AgentChatServiceImpl.java
  19. 42 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/service/impl/AgentModelProviderServiceImpl.java
  20. 132 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/service/impl/AgentModelServiceImpl.java
  21. 3 3
      ai-server/src/main/java/com/zsjz/ai/module/agent/tools/WorkspaceInfoTool.java
  22. 35 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/vo/ChatSessionVO.java
  23. 29 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/vo/MessageVO.java
  24. 21 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/vo/ModelProviderVO.java
  25. 30 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/vo/ModelVO.java
  26. 37 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/vo/SqlResultPageVO.java

+ 133 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/controller/AgentChatController.java

@@ -0,0 +1,133 @@
+package com.zsjz.ai.module.agent.controller;
+
+import cn.dev33.satoken.stp.StpUtil;
+
+import com.zsjz.ai.module.agent.dto.ChatRequestDTO;
+import com.zsjz.ai.module.agent.dto.CreateChatSessionDTO;
+import com.zsjz.ai.module.agent.dto.SendMessageDTO;
+import com.zsjz.ai.module.agent.service.AgentChatService;
+import com.zsjz.ai.module.agent.vo.ChatSessionVO;
+import com.zsjz.ai.module.agent.vo.MessageVO;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.MediaType;
+import org.springframework.http.codec.ServerSentEvent;
+import org.springframework.web.bind.annotation.*;
+import reactor.core.publisher.Flux;
+
+import java.util.List;
+
+/**
+ * 聊天控制器(会话归属当前登录用户)
+ */
+@Slf4j
+@RestController
+@RequestMapping("/chat")
+@RequiredArgsConstructor
+public class AgentChatController {
+
+    private final AgentChatService agentChatService;
+
+    /**
+     * 创建会话
+     */
+    @PostMapping("/sessions")
+    public ChatSessionVO createSession(@Valid @RequestBody CreateChatSessionDTO dto) {
+        return agentChatService.createSession(dto, StpUtil.getLoginIdAsLong());
+    }
+
+    /**
+     * 查询当前用户的会话列表
+     */
+    @GetMapping("/sessions")
+    public List<ChatSessionVO> listSessions(@RequestParam Long workspaceId) {
+        return agentChatService.listSessions(workspaceId, StpUtil.getLoginIdAsLong());
+    }
+
+    /**
+     * 获取会话详情
+     */
+    @GetMapping("/sessions/{sessionId}")
+    public ChatSessionVO getSession(@PathVariable Long sessionId) {
+        return agentChatService.getSession(sessionId, StpUtil.getLoginIdAsLong());
+    }
+
+    /**
+     * 更新会话标题
+     */
+    @PutMapping("/sessions/{sessionId}/title")
+    public void updateSessionTitle(@PathVariable Long sessionId, @RequestParam String title) {
+        agentChatService.updateSessionTitle(sessionId, title, StpUtil.getLoginIdAsLong());
+    }
+
+    /**
+     * 删除会话
+     */
+    @DeleteMapping("/sessions/{sessionId}")
+    public void deleteSession(@PathVariable Long sessionId) {
+        agentChatService.deleteSession(sessionId, StpUtil.getLoginIdAsLong());
+    }
+
+    /**
+     * 置顶/取消置顶会话
+     */
+    @PutMapping("/sessions/{sessionId}/pin")
+    public void togglePinSession(@PathVariable Long sessionId) {
+        agentChatService.togglePinSession(sessionId, StpUtil.getLoginIdAsLong());
+    }
+
+    /**
+     * 获取会话消息列表
+     */
+    @GetMapping("/sessions/{sessionId}/messages")
+    public List<MessageVO> listMessages(@PathVariable Long sessionId) {
+        return agentChatService.listMessages(sessionId, StpUtil.getLoginIdAsLong());
+    }
+
+    /**
+     * 发送消息
+     */
+    @PostMapping("/messages")
+    public MessageVO sendMessage(@Valid @RequestBody SendMessageDTO dto) {
+        return agentChatService.sendMessage(dto, StpUtil.getLoginIdAsLong());
+    }
+
+    /**
+     * 删除消息
+     */
+    @DeleteMapping("/messages/{messageId}")
+    public void deleteMessage(@PathVariable Long messageId) {
+        agentChatService.deleteMessage(messageId, StpUtil.getLoginIdAsLong());
+    }
+
+    /**
+     * 收藏/取消收藏消息
+     */
+    @PutMapping("/messages/{messageId}/star")
+    public void toggleStarMessage(@PathVariable Long messageId) {
+        agentChatService.toggleStarMessage(messageId, StpUtil.getLoginIdAsLong());
+    }
+
+
+    /**
+     * SSE streaming endpoint. Emits, in order:
+     *
+     * <ul>
+     *   <li>{@code tool_call} — a tool invocation starts (carries {@code toolCallId}, {@code toolName})
+     *   <li>{@code tool_input} — the accumulated input arguments of the tool call (once complete)
+     *   <li>{@code tool_result} — the accumulated result of the tool call (once complete)
+     *   <li>{@code token} — incremental reply text deltas (multiple frames, real streaming)
+     *   <li>{@code done} — end of run, optionally carrying the resolved {@code sessionKey};
+     *       the full assistant reply is persisted at this point
+     *   <li>{@code error} — terminates the run on failure
+     * </ul>
+     *
+     * <p>The frontend consumes these via {@code chat.ts}, parsing the {@code data:} payload as JSON
+     * with shape {@code { type, data?, toolCallId?, toolName?, toolInput?, toolResult?, error?, sessionKey? }}.
+     */
+    @PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
+    public Flux<ServerSentEvent<String>> stream(@RequestBody ChatRequestDTO req) {
+        return agentChatService.stream(req, StpUtil.getLoginIdAsLong());
+    }
+}

+ 79 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/controller/AgentModelController.java

@@ -0,0 +1,79 @@
+package com.zsjz.ai.module.agent.controller;
+
+import com.zsjz.ai.common.config.Result;
+import com.zsjz.ai.module.agent.dto.CreateModelDTO;
+import com.zsjz.ai.module.agent.dto.UpdateModelDTO;
+import com.zsjz.ai.module.agent.service.AgentModelProviderService;
+import com.zsjz.ai.module.agent.service.AgentModelService;
+import com.zsjz.ai.module.agent.vo.ModelProviderVO;
+import com.zsjz.ai.module.agent.vo.ModelVO;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 模型管理控制器
+ */
+@Slf4j
+@RestController
+@RequestMapping("/models")
+@RequiredArgsConstructor
+public class AgentModelController {
+
+    private final AgentModelService agentModelService;
+    private final AgentModelProviderService agentModelProviderService;
+
+    /**
+     * 查询模型厂商列表
+     */
+    @GetMapping("/providers")
+    public Result<List<ModelProviderVO>> listProviders() {
+        return Result.succeed(agentModelProviderService.listActiveProviders());
+    }
+
+    /**
+     * 查询模型列表
+     */
+    @GetMapping
+    public Result<List<ModelVO>> listModels() {
+        return Result.succeed(agentModelService.listActiveModels());
+    }
+
+    /**
+     * 创建模型
+     */
+    @PostMapping
+    public Result<ModelVO> createModel(@Valid @RequestBody CreateModelDTO dto) {
+        return Result.succeed(agentModelService.createModel(dto));
+    }
+
+    /**
+     * 更新模型
+     */
+    @PutMapping("/{id}")
+    public Result<Void> updateModel(@PathVariable Long id, @Valid @RequestBody UpdateModelDTO dto) {
+        agentModelService.updateModel(id, dto);
+        return Result.succeed();
+    }
+
+    /**
+     * 删除模型
+     */
+    @DeleteMapping("/{id}")
+    public Result<Void> deleteModel(@PathVariable Long id) {
+        agentModelService.deleteModel(id);
+        return Result.succeed();
+    }
+
+    /**
+     * 设置默认模型
+     */
+    @PutMapping("/{id}/default")
+    public Result<Void> setDefaultModel(@PathVariable Long id) {
+        agentModelService.setDefaultModel(id);
+        return Result.succeed();
+    }
+}

+ 37 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/converter/AgentModelConverter.java

@@ -0,0 +1,37 @@
+package com.zsjz.ai.module.agent.converter;
+
+import com.zsjz.ai.common.enums.StatusEnum;
+import com.zsjz.ai.module.agent.dto.UpdateModelDTO;
+import com.zsjz.ai.module.agent.entity.AgentModel;
+import com.zsjz.ai.module.agent.vo.ModelVO;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.mapstruct.factory.Mappers;
+
+/**
+ * 模型配置对象转换器
+ */
+@Mapper(componentModel = "spring")
+public interface AgentModelConverter {
+
+    AgentModelConverter INSTANCE = Mappers.getMapper(AgentModelConverter.class);
+
+    /**
+     * 实体转VO
+     */
+    @Mapping(source = "status", target = "status", qualifiedByName = "statusToCode")
+    ModelVO toVO(AgentModel entity);
+
+    /**
+     * UpdateModelDTO转实体
+     */
+    AgentModel toEntity(UpdateModelDTO dto);
+
+    /**
+     * 状态枚举转字符串
+     */
+    @org.mapstruct.Named("statusToCode")
+    default String statusToCode(StatusEnum status) {
+        return status != null ? status.getCode() : null;
+    }
+}

+ 22 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/converter/AgentModelProviderConverter.java

@@ -0,0 +1,22 @@
+package com.zsjz.ai.module.agent.converter;
+
+
+import com.zsjz.ai.module.agent.entity.AgentModelProvider;
+import com.zsjz.ai.module.agent.vo.ModelProviderVO;
+import org.mapstruct.Mapper;
+import org.mapstruct.factory.Mappers;
+
+/**
+ * 模型厂商对象转换器
+ */
+@Mapper(componentModel = "spring")
+public interface AgentModelProviderConverter {
+
+    AgentModelProviderConverter INSTANCE = Mappers.getMapper(AgentModelProviderConverter.class);
+
+    /**
+     * 实体转VO
+     */
+    ModelProviderVO toVO(AgentModelProvider entity);
+
+}

+ 15 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/dto/ChatRequestDTO.java

@@ -0,0 +1,15 @@
+package com.zsjz.ai.module.agent.dto;
+
+import lombok.Data;
+
+@Data
+public class ChatRequestDTO {
+    private String message;
+    private String sessionKey;
+    private Long agentId;
+
+    /**
+     * 模型配置ID(可选;与当前会话绑定的模型不同时触发动态切换)
+     */
+    private Long modelId;
+}

+ 27 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/dto/CreateChatSessionDTO.java

@@ -0,0 +1,27 @@
+package com.zsjz.ai.module.agent.dto;
+
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+/**
+ * 创建会话请求DTO
+ */
+@Data
+public class CreateChatSessionDTO {
+
+    @NotNull(message = "工作空间ID不能为空")
+    private Long workspaceId;
+
+    private Long agentId;
+
+    /**
+     * 绑定的模型配置ID(可选,空则使用默认模型)
+     */
+    private Long modelId;
+
+    @Size(max = 100, message = "标题长度不能超过100")
+    private String title;
+
+    private String context;
+}

+ 40 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/dto/CreateModelDTO.java

@@ -0,0 +1,40 @@
+package com.zsjz.ai.module.agent.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+/**
+ * 创建模型请求DTO
+ */
+@Data
+public class CreateModelDTO {
+
+    @NotBlank(message = "供应商不能为空")
+    @Size(max = 64, message = "供应商名称长度不能超过64")
+    private String provider;
+
+    @Size(max = 128, message = "模型ID长度不能超过128")
+    private String modelId;
+
+    @NotBlank(message = "模型名称不能为空")
+    @Size(max = 128, message = "模型名称长度不能超过128")
+    private String name;
+
+    @Size(max = 20, message = "类型长度不能超过20")
+    private String type;
+
+    @Size(max = 128, message = "API Key长度不能超过128")
+    private String apiKey;
+
+    @Size(max = 256, message = "Base URL长度不能超过256")
+    private String baseUrl;
+
+    private Boolean defaultModel;
+
+    private String config;
+
+    private String headersJson;
+
+    private String extraJson;
+}

+ 24 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/dto/RagSearchDTO.java

@@ -0,0 +1,24 @@
+package com.zsjz.ai.module.agent.dto;
+
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+/**
+ * RAG 表结构检索请求DTO
+ */
+@Data
+public class RagSearchDTO {
+
+    @NotNull(message = "工作空间ID不能为空")
+    private Long workspaceId;
+
+    @NotBlank(message = "查询内容不能为空")
+    private String query;
+
+    @Min(value = 1, message = "返回条数必须大于0")
+    @Max(value = 20, message = "返回条数不能超过20")
+    private Integer topK;
+}

+ 24 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/dto/SendMessageDTO.java

@@ -0,0 +1,24 @@
+package com.zsjz.ai.module.agent.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+/**
+ * 发送消息请求DTO
+ */
+@Data
+public class SendMessageDTO {
+
+    @NotNull(message = "会话ID不能为空")
+    private Long sessionId;
+
+    @NotBlank(message = "消息内容不能为空")
+    private String content;
+
+    private String contentType;
+
+    private String messageType;
+
+    private Long replyToMessageId;
+}

+ 40 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/dto/UpdateModelDTO.java

@@ -0,0 +1,40 @@
+package com.zsjz.ai.module.agent.dto;
+
+import com.zsjz.ai.common.enums.StatusEnum;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+/**
+ * 更新模型请求DTO
+ */
+@Data
+public class UpdateModelDTO {
+
+    @Size(max = 64, message = "供应商名称长度不能超过64")
+    private String provider;
+
+    @Size(max = 128, message = "模型ID长度不能超过128")
+    private String modelId;
+
+    @Size(max = 128, message = "模型名称长度不能超过128")
+    private String name;
+
+    @Size(max = 20, message = "类型长度不能超过20")
+    private String type;
+
+    @Size(max = 128, message = "API Key长度不能超过128")
+    private String apiKey;
+
+    @Size(max = 256, message = "Base URL长度不能超过256")
+    private String baseUrl;
+
+    private Boolean defaultModel;
+
+    private String config;
+
+    private String headersJson;
+
+    private String extraJson;
+
+    private StatusEnum status;
+}

+ 1 - 1
ai-server/src/main/java/com/zsjz/ai/module/agent/entity/AgentChatSession.java

@@ -27,7 +27,7 @@ public class AgentChatSession {
     private Long modelId;
 
     @TableField("case_id")
-    private Integer caseId;
+    private Long caseId;
 
     @TableField("title")
     private String title;

+ 2 - 1
ai-server/src/main/java/com/zsjz/ai/module/agent/followup/FollowupService.java

@@ -9,6 +9,7 @@ import io.agentscope.core.message.Msg;
 import io.agentscope.core.message.MsgRole;
 import io.agentscope.core.message.TextBlock;
 import io.agentscope.core.model.GenerateOptions;
+import io.agentscope.core.model.Model;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.util.StringUtils;
@@ -61,7 +62,7 @@ public class FollowupService {
             if (config == null) {
                 return null;
             }
-            io.agentscope.core.model.Model model = agentModelFactory.create(config);
+            Model model = agentModelFactory.create(config);
 
             List<Msg> msgs = List.of(
                     Msg.builder().role(MsgRole.SYSTEM).textContent(buildSystemPrompt()).build(),

+ 2 - 1
ai-server/src/main/java/com/zsjz/ai/module/agent/intent/IntentService.java

@@ -13,6 +13,7 @@ import io.agentscope.core.message.Msg;
 import io.agentscope.core.message.MsgRole;
 import io.agentscope.core.message.TextBlock;
 import io.agentscope.core.model.GenerateOptions;
+import io.agentscope.core.model.Model;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.util.StringUtils;
@@ -69,7 +70,7 @@ public class IntentService {
             if (config == null) {
                 return null;
             }
-            io.agentscope.core.model.Model model = agentModelFactory.create(config);
+            Model model = agentModelFactory.create(config);
 
             List<Msg> msgs = List.of(
                     Msg.builder().role(MsgRole.SYSTEM).textContent(buildSystemPrompt()).build(),

+ 73 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/service/AgentChatService.java

@@ -0,0 +1,73 @@
+package com.zsjz.ai.module.agent.service;
+
+
+import com.zsjz.ai.module.agent.dto.ChatRequestDTO;
+import com.zsjz.ai.module.agent.dto.CreateChatSessionDTO;
+import com.zsjz.ai.module.agent.dto.SendMessageDTO;
+import com.zsjz.ai.module.agent.vo.ChatSessionVO;
+import com.zsjz.ai.module.agent.vo.MessageVO;
+import org.springframework.http.codec.ServerSentEvent;
+import reactor.core.publisher.Flux;
+
+import java.util.List;
+
+/**
+ * 聊天服务接口(userId 由 Controller 从登录态取并传入,Service 不依赖认证框架)
+ */
+public interface AgentChatService {
+
+    /**
+     * 创建会话
+     */
+    ChatSessionVO createSession(CreateChatSessionDTO dto, Long userId);
+
+    /**
+     * 查询当前用户的会话列表
+     */
+    List<ChatSessionVO> listSessions(Long workspaceId, Long userId);
+
+    /**
+     * 获取会话详情(校验归属)
+     */
+    ChatSessionVO getSession(Long sessionId, Long userId);
+
+    /**
+     * 更新会话标题(校验归属)
+     */
+    void updateSessionTitle(Long sessionId, String title, Long userId);
+
+    /**
+     * 删除会话(校验归属)
+     */
+    void deleteSession(Long sessionId, Long userId);
+
+    /**
+     * 置顶/取消置顶会话(校验归属)
+     */
+    void togglePinSession(Long sessionId, Long userId);
+
+    /**
+     * 获取会话消息列表(校验归属)
+     */
+    List<MessageVO> listMessages(Long sessionId, Long userId);
+
+    /**
+     * 发送消息(校验归属)
+     */
+    MessageVO sendMessage(SendMessageDTO dto, Long userId);
+
+    /**
+     * 删除消息(校验归属)
+     */
+    void deleteMessage(Long messageId, Long userId);
+
+    /**
+     * 收藏/取消收藏消息(校验归属)
+     */
+    void toggleStarMessage(Long messageId, Long userId);
+
+    /**
+     * 流式对话(校验会话归属)
+     */
+    Flux<ServerSentEvent<String>> stream(ChatRequestDTO req, Long userId);
+}

+ 17 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/service/AgentModelProviderService.java

@@ -0,0 +1,17 @@
+package com.zsjz.ai.module.agent.service;
+
+
+import com.zsjz.ai.module.agent.vo.ModelProviderVO;
+
+import java.util.List;
+
+/**
+ * 模型厂商服务接口
+ */
+public interface AgentModelProviderService {
+
+    /**
+     * 查询所有启用的厂商列表
+     */
+    List<ModelProviderVO> listActiveProviders();
+}

+ 47 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/service/AgentModelService.java

@@ -0,0 +1,47 @@
+package com.zsjz.ai.module.agent.service;
+
+
+
+import com.zsjz.ai.module.agent.dto.CreateModelDTO;
+import com.zsjz.ai.module.agent.dto.UpdateModelDTO;
+import com.zsjz.ai.module.agent.vo.ModelVO;
+
+import java.util.List;
+
+/**
+ * 模型配置服务接口
+ */
+public interface AgentModelService {
+
+    /**
+     * 查询所有启用的模型列表
+     */
+    List<ModelVO> listActiveModels();
+
+    /**
+     * 创建模型
+     */
+    ModelVO createModel(CreateModelDTO dto);
+
+    /**
+     * 更新模型
+     */
+    void updateModel(Long id, UpdateModelDTO dto);
+
+    /**
+     * 删除模型
+     */
+    void deleteModel(Long id);
+
+    /**
+     * 设置默认模型
+     */
+    void setDefaultModel(Long id);
+
+    /**
+     * 获取默认模型ID(default_model=true 且状态为启用的第一条)
+     *
+     * @return 模型ID,无默认模型时返回 null
+     */
+    Long getDefaultModelId();
+}

+ 7 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/service/LlmService.java

@@ -0,0 +1,7 @@
+package com.zsjz.ai.module.agent.service;
+
+import org.springframework.stereotype.Service;
+
+@Service
+public class LlmService {
+}

+ 621 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/service/impl/AgentChatServiceImpl.java

@@ -0,0 +1,621 @@
+package com.zsjz.ai.module.agent.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.baomidou.mybatisplus.spring.service.impl.ServiceImpl;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.zsjz.ai.common.exception.ServerException;
+import com.zsjz.ai.module.agent.dto.ChatRequestDTO;
+import com.zsjz.ai.module.agent.dto.CreateChatSessionDTO;
+import com.zsjz.ai.module.agent.dto.SendMessageDTO;
+import com.zsjz.ai.module.agent.entity.AgentChatSession;
+import com.zsjz.ai.module.agent.entity.AgentMessage;
+import com.zsjz.ai.module.agent.mapper.AgentChatSessionMapper;
+import com.zsjz.ai.module.agent.mapper.AgentMessageMapper;
+import com.zsjz.ai.module.agent.service.AgentChatService;
+import com.zsjz.ai.module.agent.service.AgentModelService;
+import com.zsjz.ai.module.agent.service.AgentService;
+import com.zsjz.ai.module.agent.usage.UsageStore;
+import com.zsjz.ai.module.agent.vo.ChatSessionVO;
+import com.zsjz.ai.module.agent.vo.MessageVO;
+import io.agentscope.core.agent.RuntimeContext;
+import io.agentscope.core.event.*;
+import io.agentscope.core.message.Msg;
+import io.agentscope.core.message.MsgRole;
+import io.agentscope.harness.agent.HarnessAgent;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.codec.ServerSentEvent;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
+
+import java.time.LocalDateTime;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
+
+/**
+ * 聊天服务实现
+ *
+ * <p>流式对话走官方 API 直连:{@link HarnessAgent#streamEvents(List, RuntimeContext)}。
+ * 会话状态(对话历史)由官方 {@code AgentStateStore} 按 {@code (userId, sessionId)}
+ * 自动加载/保存;会话转录与 sessions.json 索引由官方 {@code TranscriptMiddleware}
+ * 在每轮对话结束后自动写入 agent 工作目录。
+ */
+@Slf4j
+@Service
+public class AgentChatServiceImpl extends ServiceImpl<AgentChatSessionMapper, AgentChatSession> implements AgentChatService {
+
+    private static final Long DEFAULT_AGENT_ROW_ID = 1L;
+
+    private final AgentChatSessionMapper chatSessionMapper;
+    private final AgentMessageMapper messageMapper;
+    private final AgentService agentService;
+    private final AgentModelService agentModelService;
+    private final UsageStore usageStore;
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    public AgentChatServiceImpl(UsageStore usageStore,
+                                AgentService agentService,
+                                AgentModelService agentModelService,
+                                AgentMessageMapper messageMapper,
+                                AgentChatSessionMapper chatSessionMapper) {
+        this.usageStore = usageStore;
+        this.agentService = agentService;
+        this.agentModelService = agentModelService;
+        this.messageMapper = messageMapper;
+        this.chatSessionMapper = chatSessionMapper;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public ChatSessionVO createSession(CreateChatSessionDTO dto, Long userId) {
+        AgentChatSession entity = new AgentChatSession();
+        entity.setId(generateId());
+        entity.setCaseId(dto.getWorkspaceId());
+        entity.setAgentId(dto.getAgentId() != null ? dto.getAgentId() : DEFAULT_AGENT_ROW_ID);
+     
+        entity.setTitle(dto.getTitle() != null ? dto.getTitle() : "新对话");
+        entity.setContext(dto.getContext() != null ? dto.getContext() : "{}");
+        // 模型绑定:请求指定 > 默认模型
+        entity.setModelId(dto.getModelId() != null ? dto.getModelId() : agentModelService.getDefaultModelId());
+        entity.setMessageCount(0);
+        entity.setTotalTokens(0L);
+        entity.setPinned(false);
+        entity.setCreateAt(LocalDateTime.now());
+        entity.setUpdateAt(LocalDateTime.now());
+
+        chatSessionMapper.insert(entity);
+        log.info("创建会话成功: {}, 标题: {}, 模型: {}", entity.getId(), entity.getTitle(), entity.getModelId());
+
+        return convertSessionToVO(entity);
+    }
+
+    @Override
+    public List<ChatSessionVO> listSessions(Long workspaceId, Long userId) {
+        List<AgentChatSession> list = chatSessionMapper.selectList(
+                new LambdaQueryWrapper<AgentChatSession>()
+                        .eq(AgentChatSession::getCaseId, workspaceId)
+                        .orderByDesc(AgentChatSession::getPinned)
+                        .orderByDesc(AgentChatSession::getLastMessageAt)
+                        .orderByDesc(AgentChatSession::getCreateAt)
+        );
+        return list.stream()
+                .map(this::convertSessionToVO)
+                .collect(Collectors.toList());
+    }
+
+    @Override
+    public ChatSessionVO getSession(Long sessionId, Long userId) {
+        AgentChatSession entity = assertOwner(sessionId, userId);
+        return convertSessionToVO(entity);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void updateSessionTitle(Long sessionId, String title, Long userId) {
+        AgentChatSession entity = assertOwner(sessionId, userId);
+
+        chatSessionMapper.update(null,
+                new LambdaUpdateWrapper<AgentChatSession>()
+                        .eq(AgentChatSession::getId, sessionId)
+                        .set(AgentChatSession::getTitle, title)
+                        .set(AgentChatSession::getUpdateAt, LocalDateTime.now())
+        );
+        log.info("更新会话标题成功: {}, 标题: {}", sessionId, title);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void deleteSession(Long sessionId, Long userId) {
+        assertOwner(sessionId, userId);
+
+        // 删除会话下的所有消息
+        messageMapper.delete(
+                new LambdaQueryWrapper<AgentMessage>()
+                        .eq(AgentMessage::getSessionId, sessionId)
+        );
+
+        // 删除会话
+        chatSessionMapper.deleteById(sessionId);
+        log.info("删除会话成功: {}", sessionId);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void togglePinSession(Long sessionId, Long userId) {
+        AgentChatSession entity = assertOwner(sessionId, userId);
+
+        chatSessionMapper.update(null,
+                new LambdaUpdateWrapper<AgentChatSession>()
+                        .eq(AgentChatSession::getId, sessionId)
+                        .set(AgentChatSession::getPinned, !entity.getPinned())
+                        .set(AgentChatSession::getUpdateAt, LocalDateTime.now())
+        );
+        log.info("切换会话置顶状态成功: {}, 置顶: {}", sessionId, !entity.getPinned());
+    }
+
+    @Override
+    public List<MessageVO> listMessages(Long sessionId, Long userId) {
+        assertOwner(sessionId, userId);
+        List<AgentMessage> list = messageMapper.selectList(
+                new LambdaQueryWrapper<AgentMessage>()
+                        .eq(AgentMessage::getSessionId, sessionId)
+                        .orderByAsc(AgentMessage::getCreateAt)
+        );
+        return list.stream()
+                .map(this::convertMessageToVO)
+                .collect(Collectors.toList());
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public MessageVO sendMessage(SendMessageDTO dto, Long userId) {
+        AgentChatSession session = assertOwner(dto.getSessionId(), userId);
+
+        // 保存用户消息
+        AgentMessage userMessage = new AgentMessage();
+        userMessage.setId(generateId());
+        userMessage.setSessionId(dto.getSessionId());
+        userMessage.setRole("user");
+        userMessage.setContent(dto.getContent());
+        userMessage.setContentType(dto.getContentType() != null ? dto.getContentType() : "text");
+        userMessage.setMessageType(dto.getMessageType() != null ? dto.getMessageType() : "text");
+        userMessage.setReplyToMessageId(dto.getReplyToMessageId());
+        userMessage.setStarred(false);
+        userMessage.setCreateAt(LocalDateTime.now());
+
+        messageMapper.insert(userMessage);
+
+        // 更新会话信息
+        chatSessionMapper.update(null,
+                new LambdaUpdateWrapper<AgentChatSession>()
+                        .eq(AgentChatSession::getId, dto.getSessionId())
+                        .set(AgentChatSession::getLastMessageAt, LocalDateTime.now())
+                        .set(AgentChatSession::getMessageCount, session.getMessageCount() + 1)
+                        .set(AgentChatSession::getUpdateAt, LocalDateTime.now())
+        );
+
+        // 如果是第一条消息,更新会话标题
+        if (session.getMessageCount() == 0) {
+            String title = dto.getContent().length() > 50
+                    ? dto.getContent().substring(0, 50) + "..."
+                    : dto.getContent();
+            chatSessionMapper.update(null,
+                    new LambdaUpdateWrapper<AgentChatSession>()
+                            .eq(AgentChatSession::getId, dto.getSessionId())
+                            .set(AgentChatSession::getTitle, title)
+            );
+        }
+
+        log.info("发送消息成功: {}, 会话: {}", userMessage.getId(), dto.getSessionId());
+        return convertMessageToVO(userMessage);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void deleteMessage(Long messageId, Long userId) {
+        AgentMessage entity = messageMapper.selectById(messageId);
+        if (entity == null) {
+            throw new ServerException(404, "消息不存在: " + messageId);
+        }
+        assertOwner(entity.getSessionId(), userId);
+
+        messageMapper.deleteById(messageId);
+
+        // 更新会话消息数量
+        AgentChatSession session = chatSessionMapper.selectById(entity.getSessionId());
+        if (session != null) {
+            chatSessionMapper.update(null,
+                    new LambdaUpdateWrapper<AgentChatSession>()
+                            .eq(AgentChatSession::getId, entity.getSessionId())
+                            .set(AgentChatSession::getMessageCount, Math.max(0, session.getMessageCount() - 1))
+                            .set(AgentChatSession::getUpdateAt, LocalDateTime.now())
+            );
+        }
+
+        log.info("删除消息成功: {}", messageId);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void toggleStarMessage(Long messageId, Long userId) {
+        AgentMessage entity = messageMapper.selectById(messageId);
+        if (entity == null) {
+            throw new ServerException(404, "消息不存在: " + messageId);
+        }
+        assertOwner(entity.getSessionId(), userId);
+
+        messageMapper.update(null,
+                new LambdaUpdateWrapper<AgentMessage>()
+                        .eq(AgentMessage::getId, messageId)
+                        .set(AgentMessage::getStarred, !entity.getStarred())
+        );
+        log.info("切换消息收藏状态成功: {}, 收藏: {}", messageId, !entity.getStarred());
+    }
+
+    @Override
+    public Flux<ServerSentEvent<String>> stream(ChatRequestDTO req, Long userId) {
+        if (!StringUtils.hasText(req.getMessage())) {
+            return Flux.just(sse("error", Map.of("type", "error", "error", "消息内容不能为空")));
+        }
+        // defer:订阅时才解析会话/构建 agent,异常统一转 error 帧
+        return Flux.defer(() -> doStream(req, userId))
+                .onErrorResume(ex -> {
+                    String errorMsg = ex.getMessage() != null ? ex.getMessage() : "未知错误";
+                    log.warn("Chat stream error: sessionKey={}, error={}", req.getSessionKey(), errorMsg);
+                    // 错误信息持久化:确保刷新后仍可见
+                    Long sessionId = parseSessionId(req.getSessionKey());
+                    if (sessionId != null) {
+                        Mono.fromRunnable(() -> persistErrorMessage(sessionId, errorMsg))
+                                .subscribeOn(Schedulers.boundedElastic())
+                                .subscribe();
+                    }
+                    return Flux.just(sse("error",
+                            Map.of("type", "error", "error", errorMsg)));
+                });
+    }
+
+    /**
+     * 流式对话主链路(官方 API 直连)。
+     *
+     * <p>从会话记录解析三维隔离参数(userId / workspaceId / agentRowId)与模型;
+     * 请求体携带 modelId 且与会话绑定不同时,视为动态切换模型并回写会话。
+     * 记忆槽位 = (userId, sessionId),与 agent 实例/模型无关,切换模型后记忆自动延续。
+     */
+    private Flux<ServerSentEvent<String>> doStream(ChatRequestDTO req, Long userId) {
+        Long sessionId = parseSessionId(req.getSessionKey());
+        if (sessionId == null) {
+            throw new ServerException(400, "缺少有效会话ID(sessionKey)");
+        }
+        AgentChatSession session = assertOwner(sessionId, userId);
+
+        String userIdStr = String.valueOf(userId);
+        Long workspaceId = session.getCaseId();
+        Long agentRowId = session.getAgentId() != null ? session.getAgentId() : DEFAULT_AGENT_ROW_ID;
+
+        // 模型优先级:请求体 modelId(动态切换)> session.modelId > 默认模型
+        Long modelId = req.getModelId() != null ? req.getModelId() : session.getModelId();
+        if (modelId == null) {
+            modelId = agentModelService.getDefaultModelId();
+        }
+        if (modelId == null) {
+            throw new ServerException(400, "未指定模型且无默认模型,请先选择模型");
+        }
+        // 动态切换:回写会话,下一轮默认使用新模型
+        if (!Objects.equals(modelId, session.getModelId())) {
+            chatSessionMapper.update(null,
+                    new LambdaUpdateWrapper<AgentChatSession>()
+                            .eq(AgentChatSession::getId, sessionId)
+                            .set(AgentChatSession::getModelId, modelId)
+                            .set(AgentChatSession::getUpdateAt, LocalDateTime.now()));
+            session.setModelId(modelId);
+            log.info("会话动态切换模型: sessionId={}, modelId={}", sessionId, modelId);
+        }
+
+        HarnessAgent agent = agentService.getOrCreateAgent(userIdStr, workspaceId, agentRowId, modelId);
+        RuntimeContext ctx = RuntimeContext.builder()
+                .userId(userIdStr)
+                .sessionId(String.valueOf(sessionId))
+                .build();
+
+        // 意图识别由 IntentMiddleware 在 onAgent 钩子内完成(消息增强 + intent 事件),
+        // 这里原文直发,ChatServiceImpl 只负责事件转换
+        List<Msg> msgs = List.of(
+                Msg.builder().role(MsgRole.USER).textContent(req.getMessage()).build());
+        Flux<AgentEvent> events = agent.streamEvents(msgs, ctx);
+
+        long startMs = System.currentTimeMillis();
+        String sessionKey = String.valueOf(sessionId);
+
+        // 工具调用状态聚合(流内累积,随流结束释放)
+        Map<String, StringBuilder> toolInputs = new ConcurrentHashMap<>();
+        Map<String, StringBuilder> toolResults = new ConcurrentHashMap<>();
+        Map<String, String> toolNames = new ConcurrentHashMap<>();
+
+        // 流中断追踪:用户主动中止时,若 AgentResultEvent 尚未到达,将已累积的文本保存
+        AtomicReference<StringBuilder> accumulatedText = new AtomicReference<>(new StringBuilder());
+        AtomicBoolean resultPersisted = new AtomicBoolean(false);
+
+        return events.concatMap(event -> {
+            List<ServerSentEvent<String>> frames = new ArrayList<>();
+            if (event instanceof CustomEvent e && "intent".equals(e.getName())) {
+                // IntentMiddleware 发出的意图识别结果 → SSE intent 帧
+                frames.add(sse("intent", e.getValue()));
+            } else if (event instanceof CustomEvent e && "suggestions".equals(e.getName())) {
+                // FollowupMiddleware 在对话结束后追加的下一步引导建议 → SSE suggestions 帧
+                frames.add(sse("suggestions", e.getValue()));
+            } else if (event instanceof ToolCallStartEvent e) {
+                Map<String, Object> data = new LinkedHashMap<>();
+                data.put("type", "tool_call");
+                data.put("toolCallId", e.getToolCallId());
+                data.put("toolName", e.getToolCallName());
+                toolNames.put(e.getToolCallId(), e.getToolCallName());
+                frames.add(sse("tool_call", data));
+            } else if (event instanceof ToolCallDeltaEvent e) {
+                toolInputs
+                        .computeIfAbsent(e.getToolCallId(), k -> new StringBuilder())
+                        .append(e.getDelta());
+            } else if (event instanceof ToolCallEndEvent e) {
+                Map<String, Object> data = new LinkedHashMap<>();
+                data.put("type", "tool_input");
+                data.put("toolCallId", e.getToolCallId());
+                StringBuilder input = toolInputs.get(e.getToolCallId());
+                data.put("toolInput", input != null ? input.toString() : "");
+                frames.add(sse("tool_input", data));
+            } else if (event instanceof ToolResultTextDeltaEvent e) {
+                toolResults
+                        .computeIfAbsent(e.getToolCallId(), k -> new StringBuilder())
+                        .append(e.getDelta());
+            } else if (event instanceof ToolResultEndEvent e) {
+                Map<String, Object> data = new LinkedHashMap<>();
+                data.put("type", "tool_result");
+                data.put("toolCallId", e.getToolCallId());
+                data.put("toolName", e.getToolCallName());
+                StringBuilder result = toolResults.get(e.getToolCallId());
+                if (!result.isEmpty()) {
+                    String text = result.toString();
+                    // 截断超长结果,避免单帧 SSE 过大(表格首页 JSON 可达数十 k)
+                    data.put("toolResult", text.length() > 100000 ? text.substring(0, 100000) : text);
+                }
+                frames.add(sse("tool_result", data));
+            } else if (event instanceof TextBlockDeltaEvent e) {
+                accumulatedText.get().append(e.getDelta());
+                frames.add(sse("token", Map.of("type", "token", "data", e.getDelta())));
+            } else if (event instanceof AgentResultEvent e) {
+                // AI 回复落库(异步,不阻塞事件流);工具调用事件一并持久化成 tool_events
+                resultPersisted.set(true);
+                String toolEventsJson = buildToolEventsJson(toolInputs, toolResults, toolNames);
+                Mono.fromRunnable(() -> persistAssistantMessage(sessionId, e.getResult(), toolEventsJson))
+                        .subscribeOn(Schedulers.boundedElastic())
+                        .subscribe();
+            } else if (event instanceof AgentEndEvent) {
+                usageStore.record(userIdStr, String.valueOf(agentRowId), System.currentTimeMillis() - startMs);
+                frames.add(sse("done", Map.of("type", "done", "sessionKey", sessionKey)));
+            }
+            return Flux.fromIterable(frames);
+        }).doOnCancel(() -> {
+            // 用户主动中止:若 AgentResultEvent 尚未到达,保存已流式输出的部分内容
+            if (!resultPersisted.get()) {
+                String partialText = accumulatedText.get().toString().trim();
+                String toolEventsJson = buildToolEventsJson(toolInputs, toolResults, toolNames);
+                if (!partialText.isEmpty() || !toolEventsJson.isEmpty()) {
+                    Mono.fromRunnable(() -> persistPartialMessage(sessionId, partialText, toolEventsJson))
+                            .subscribeOn(Schedulers.boundedElastic())
+                            .subscribe();
+                }
+            }
+        });
+    }
+
+    /**
+     * 校验会话归属:会话不存在返回 404,非本人会话返回 403。
+     *
+     * @return 归属校验通过的会话实体
+     */
+    private AgentChatSession assertOwner(Long sessionId, Long caseId) {
+        AgentChatSession session = chatSessionMapper.selectById(sessionId);
+        if (session == null) {
+            throw new ServerException(404, "会话不存在: " + sessionId);
+        }
+        if (!Objects.equals(session.getCaseId(), caseId)) {
+            throw new ServerException(403, "无权访问该会话");
+        }
+        return session;
+    }
+
+    /**
+     * 将 AI 回复持久化为 assistant 消息。失败不影响流式输出。
+     *
+     * @param toolEventsJson 工具调用事件 JSON(execute_sql 入参/结果等),可为 null
+     */
+    private void persistAssistantMessage(Long sessionId, Msg result, String toolEventsJson) {
+        try {
+            AgentChatSession session = chatSessionMapper.selectById(sessionId);
+            if (session == null) {
+                return;
+            }
+            String text = result.getTextContent() != null ? result.getTextContent() : "";
+            if (text.isBlank()) {
+                return;
+            }
+            AgentMessage assistantMessage = new AgentMessage();
+            assistantMessage.setSessionId(sessionId);
+            assistantMessage.setRole("assistant");
+            assistantMessage.setContent(text);
+            assistantMessage.setContentType("text");
+            assistantMessage.setMessageType("text");
+            assistantMessage.setStarred(false);
+            assistantMessage.setToolEvents(toolEventsJson);
+            assistantMessage.setCreateAt(LocalDateTime.now());
+            messageMapper.insert(assistantMessage);
+            chatSessionMapper.update(
+                    null,
+                    new LambdaUpdateWrapper<AgentChatSession>()
+                            .eq(AgentChatSession::getId, sessionId)
+                            .set(AgentChatSession::getLastMessageAt, LocalDateTime.now())
+                            .set(AgentChatSession::getMessageCount, session.getMessageCount() + 1)
+                            .set(AgentChatSession::getUpdateAt, LocalDateTime.now()));
+            log.info("AI回复落库成功: sessionId={}, messageId={}", sessionId, assistantMessage.getId());
+        } catch (Exception e) {
+            log.warn("AI回复落库失败: sessionId={}, error={}", sessionId, e.getMessage());
+        }
+    }
+
+    /**
+     * 持久化错误信息为 assistant 消息(messageType=error),确保刷新后仍可见。
+     */
+    private void persistErrorMessage(Long sessionId, String errorMsg) {
+        try {
+            AgentMessage msg = new AgentMessage();
+            msg.setSessionId(sessionId);
+            msg.setRole("assistant");
+            msg.setContent("⚠️ " + errorMsg);
+            msg.setContentType("text");
+            msg.setMessageType("error");
+            msg.setMetadata("{\"error\": true}");
+            msg.setStarred(false);
+            msg.setCreateAt(LocalDateTime.now());
+            messageMapper.insert(msg);
+            chatSessionMapper.update(null,
+                    new LambdaUpdateWrapper<AgentChatSession>()
+                            .eq(AgentChatSession::getId, sessionId)
+                            .setSql("message_count = message_count + 1")
+                            .set(AgentChatSession::getLastMessageAt, LocalDateTime.now())
+                            .set(AgentChatSession::getUpdateAt, LocalDateTime.now()));
+            log.info("错误消息落库成功: sessionId={}, messageId={}", sessionId, msg.getId());
+        } catch (Exception e) {
+            log.warn("错误消息落库失败: sessionId={}, error={}", sessionId, e.getMessage());
+        }
+    }
+
+    /**
+     * 持久化用户中断时的部分内容(messageType=interrupted),标记为不完整回复。
+     */
+    private void persistPartialMessage(Long sessionId, String partialText, String toolEventsJson) {
+        try {
+            AgentMessage msg = new AgentMessage();
+            msg.setSessionId(sessionId);
+            msg.setRole("assistant");
+            msg.setContent(partialText.isEmpty() ? "(回复已中断)" : partialText + "\n\n*(回复已中断)*");
+            msg.setContentType("text");
+            msg.setMessageType("interrupted");
+            msg.setMetadata("{\"interrupted\": true}");
+            msg.setToolEvents(toolEventsJson);
+            msg.setStarred(false);
+            msg.setCreateAt(LocalDateTime.now());
+            messageMapper.insert(msg);
+            chatSessionMapper.update(null,
+                    new LambdaUpdateWrapper<AgentChatSession>()
+                            .eq(AgentChatSession::getId, sessionId)
+                            .setSql("message_count = message_count + 1")
+                            .set(AgentChatSession::getLastMessageAt, LocalDateTime.now())
+                            .set(AgentChatSession::getUpdateAt, LocalDateTime.now()));
+            log.info("中断回复落库成功: sessionId={}, messageId={}, textLen={}", sessionId, msg.getId(), partialText.length());
+        } catch (Exception e) {
+            log.warn("中断回复落库失败: sessionId={}, error={}", sessionId, e.getMessage());
+        }
+    }
+
+    /**
+     * sessionKey → 会话ID(当前约定:sessionKey 即业务会话 ID 字符串)
+     */
+    private static Long parseSessionId(String sessionKey) {
+        if (!StringUtils.hasText(sessionKey)) {
+            return null;
+        }
+        try {
+            return Long.parseLong(sessionKey.trim());
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    private Long generateId() {
+        return UUID.randomUUID().getMostSignificantBits() & Long.MAX_VALUE;
+    }
+
+    /**
+     * 将流内累积的工具调用聚合为 JSON 字符串(tool_events 列存储形态,与前端 ToolStep 对齐)。
+     *
+     * <p>每条工具调用产出 {@code {id, toolName, input, result, status}}。input/result 为字符串原样保留:
+     * execute_sql 的 result 是完整 JSON,前端据此渲染分页表格。
+     */
+    private static String buildToolEventsJson(Map<String, StringBuilder> toolInputs,
+                                              Map<String, StringBuilder> toolResults,
+                                              Map<String, String> toolNames) {
+        if (toolNames.isEmpty()) {
+            return null;
+        }
+        List<Map<String, Object>> events = new ArrayList<>();
+        for (String toolCallId : toolNames.keySet()) {
+            Map<String, Object> ev = new LinkedHashMap<>();
+            ev.put("id", toolCallId);
+            ev.put("toolName", toolNames.get(toolCallId));
+            StringBuilder input = toolInputs.get(toolCallId);
+            ev.put("input", input != null ? input.toString() : "");
+            StringBuilder result = toolResults.get(toolCallId);
+            ev.put("result", result != null ? result.toString() : "");
+            ev.put("status", "done");
+            events.add(ev);
+        }
+        try {
+            return MAPPER.writeValueAsString(events);
+        } catch (JsonProcessingException e) {
+            return null;
+        }
+    }
+
+    private ChatSessionVO convertSessionToVO(AgentChatSession entity) {
+        ChatSessionVO vo = new ChatSessionVO();
+        vo.setId(entity.getId());
+        vo.setAgentId(entity.getAgentId());
+        vo.setModelId(entity.getModelId());
+        vo.setCaseId(entity.getCaseId());
+        vo.setTitle(entity.getTitle());
+        vo.setContext(entity.getContext());
+        vo.setMessageCount(entity.getMessageCount());
+        vo.setTotalTokens(entity.getTotalTokens());
+        vo.setPinned(entity.getPinned());
+        vo.setAttachments(entity.getAttachments());
+        vo.setLastMessageAt(entity.getLastMessageAt());
+        vo.setCreateAt(entity.getCreateAt());
+        vo.setUpdateAt(entity.getUpdateAt());
+        return vo;
+    }
+
+    private MessageVO convertMessageToVO(AgentMessage entity) {
+        MessageVO vo = new MessageVO();
+        vo.setId(entity.getId());
+        vo.setSessionId(entity.getSessionId());
+        vo.setRole(entity.getRole());
+        vo.setContent(entity.getContent());
+        vo.setContentType(entity.getContentType());
+        vo.setTokenCount(entity.getTokenCount());
+        vo.setMetadata(entity.getMetadata());
+        vo.setToolEvents(entity.getToolEvents());
+        vo.setParentId(entity.getParentId());
+        vo.setMessageType(entity.getMessageType());
+        vo.setReplyToMessageId(entity.getReplyToMessageId());
+        vo.setStarred(entity.getStarred());
+        vo.setCreateAt(entity.getCreateAt());
+        return vo;
+    }
+
+    private ServerSentEvent<String> sse(String eventType, Object data) {
+        String json;
+        try {
+            json = MAPPER.writeValueAsString(data);
+        } catch (JsonProcessingException e) {
+            json = "{\"type\":\"" + eventType + "\"}";
+        }
+        return ServerSentEvent.<String>builder().event(eventType).data(json).build();
+    }
+
+}

+ 42 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/service/impl/AgentModelProviderServiceImpl.java

@@ -0,0 +1,42 @@
+package com.zsjz.ai.module.agent.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.spring.service.impl.ServiceImpl;
+import com.zsjz.ai.module.agent.converter.AgentModelProviderConverter;
+import com.zsjz.ai.module.agent.entity.AgentModelProvider;
+import com.zsjz.ai.module.agent.mapper.AgentModelProviderMapper;
+import com.zsjz.ai.module.agent.service.AgentModelProviderService;
+import com.zsjz.ai.module.agent.vo.ModelProviderVO;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * 模型厂商服务实现
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AgentModelProviderServiceImpl extends ServiceImpl<AgentModelProviderMapper, AgentModelProvider> implements AgentModelProviderService {
+
+    private final AgentModelProviderConverter agentModelProviderConverter;
+
+    @Override
+    public List<ModelProviderVO> listActiveProviders() {
+        List<AgentModelProvider> list = list(
+                new LambdaQueryWrapper<AgentModelProvider>()
+                        .eq(AgentModelProvider::getStatus, "active")
+                        .orderByAsc(AgentModelProvider::getName)
+        );
+        return list.stream()
+                .map(this::convertToVO)
+                .collect(Collectors.toList());
+    }
+
+    private ModelProviderVO convertToVO(AgentModelProvider entity) {
+        return agentModelProviderConverter.toVO(entity);
+    }
+}

+ 132 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/service/impl/AgentModelServiceImpl.java

@@ -0,0 +1,132 @@
+package com.zsjz.ai.module.agent.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.baomidou.mybatisplus.spring.service.impl.ServiceImpl;
+import com.zsjz.ai.common.enums.StatusEnum;
+import com.zsjz.ai.common.exception.ServerException;
+import com.zsjz.ai.module.agent.converter.AgentModelConverter;
+import com.zsjz.ai.module.agent.dto.CreateModelDTO;
+import com.zsjz.ai.module.agent.dto.UpdateModelDTO;
+import com.zsjz.ai.module.agent.entity.AgentModel;
+import com.zsjz.ai.module.agent.mapper.AgentModelMapper;
+import com.zsjz.ai.module.agent.service.AgentModelService;
+import com.zsjz.ai.module.agent.vo.ModelVO;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import java.util.List;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+/**
+ * 模型配置服务实现
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AgentModelServiceImpl extends ServiceImpl<AgentModelMapper, AgentModel> implements AgentModelService {
+
+    private final AgentModelConverter agentModelConverter;
+
+    @Override
+    public List<ModelVO> listActiveModels() {
+        List<AgentModel> list = list(
+                new LambdaQueryWrapper<AgentModel>()
+                        .ne(AgentModel::getStatus, StatusEnum.DELETED)
+                        .orderByDesc(AgentModel::getDefaultModel)
+                        .orderByDesc(AgentModel::getCreateBy)
+        );
+        return list.stream()
+                .map(this::convertToVO)
+                .collect(Collectors.toList());
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public ModelVO createModel(CreateModelDTO dto) {
+        AgentModel entity = new AgentModel();
+        entity.setId(UUID.randomUUID().getMostSignificantBits() & Long.MAX_VALUE);
+        entity.setProvider(dto.getProvider());
+        entity.setModelId(dto.getModelId());
+        entity.setName(dto.getName());
+        entity.setType(dto.getType() != null ? dto.getType() : "llm");
+        entity.setApiKey(dto.getApiKey());
+        entity.setBaseUrl(dto.getBaseUrl());
+        entity.setDefaultModel(dto.getDefaultModel() != null && dto.getDefaultModel());
+        entity.setConfig(dto.getConfig() != null ? dto.getConfig() : "{}");
+        entity.setHeadersJson(dto.getHeadersJson() != null ? dto.getHeadersJson() : "{}");
+        entity.setExtraJson(dto.getExtraJson() != null ? dto.getExtraJson() : "{}");
+        entity.setStatus(StatusEnum.ACTIVE);
+
+        save(entity);
+        log.info("创建模型成功: {}, 名称: {}", entity.getId(), entity.getName());
+
+        return convertToVO(entity);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void updateModel(Long id, UpdateModelDTO dto) {
+        AgentModel entity = getById(id);
+        if (entity == null) {
+            throw new ServerException(404, "模型不存在: " + id);
+        }
+        AgentModel agentModel = agentModelConverter.toEntity(dto);
+        agentModel.setId(id);
+        updateById(agentModel);
+        log.info("更新模型成功: {}", id);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void deleteModel(Long id) {
+        AgentModel entity = getById(id);
+        if (entity == null) {
+            throw new ServerException(404, "模型不存在: " + id);
+        }
+
+        update(new LambdaUpdateWrapper<AgentModel>()
+                .eq(AgentModel::getId, id)
+                .set(AgentModel::getStatus, StatusEnum.DELETED)
+        );
+        log.info("删除模型成功: {}", id);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void setDefaultModel(Long id) {
+        AgentModel entity = getById(id);
+        if (entity == null) {
+            throw new ServerException(404, "模型不存在: " + id);
+        }
+
+        // 先取消所有默认
+        update(new LambdaUpdateWrapper<AgentModel>()
+                .eq(AgentModel::getDefaultModel, true)
+                .set(AgentModel::getDefaultModel, false)
+        );
+
+        // 设置当前模型为默认
+        update(new LambdaUpdateWrapper<AgentModel>()
+                .eq(AgentModel::getId, id)
+                .set(AgentModel::getDefaultModel, true)
+        );
+        log.info("设置默认模型成功: {}", id);
+    }
+
+    @Override
+    public Long getDefaultModelId() {
+        AgentModel agentModel = getOne(
+                new LambdaQueryWrapper<AgentModel>()
+                        .eq(AgentModel::getDefaultModel, true)
+                        .eq(AgentModel::getStatus, StatusEnum.ACTIVE)
+                        .last("LIMIT 1"));
+        return agentModel != null ? agentModel.getId() : null;
+    }
+
+    private ModelVO convertToVO(AgentModel entity) {
+        return agentModelConverter.toVO(entity);
+    }
+}

+ 3 - 3
ai-server/src/main/java/com/zsjz/ai/module/agent/tools/WorkspaceInfoTool.java

@@ -44,7 +44,7 @@ public class WorkspaceInfoTool {
             concurrencySafe = true)
     public String getCurrentWorkspace(RuntimeContext ctx) {
         try {
-            Integer workspaceId = resolveSessionWorkspaceId(ctx);
+            Long workspaceId = resolveSessionWorkspaceId(ctx);
             if (workspaceId == null) {
                 return error("当前会话未绑定工作空间,无法查询");
             }
@@ -88,7 +88,7 @@ public class WorkspaceInfoTool {
     /**
      * 工作空间ID → 标准信息 JSON
      */
-    private String workspaceJson(Integer workspaceId) throws Exception {
+    private String workspaceJson(Long workspaceId) throws Exception {
         CaseInfo workspace = caseInfoMapper.selectById(workspaceId);
         if (workspace == null) {
             return error("工作空间不存在: " + workspaceId);
@@ -103,7 +103,7 @@ public class WorkspaceInfoTool {
     /**
      * sessionKey(业务会话ID字符串)→ 会话 → 工作空间ID
      */
-    private Integer resolveSessionWorkspaceId(RuntimeContext ctx) {
+    private Long resolveSessionWorkspaceId(RuntimeContext ctx) {
         Long sessionId = parseLong(ctx.getSessionId());
         if (sessionId == null) {
             return null;

+ 35 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/vo/ChatSessionVO.java

@@ -0,0 +1,35 @@
+package com.zsjz.ai.module.agent.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 会话记录响应VO
+ */
+@Data
+public class ChatSessionVO {
+
+    private Long id;
+    private Long agentId;
+    private Long modelId;
+    private Long caseId;
+    private Long userId;
+    private Long datasourceId;
+    private String title;
+    private String context;
+    private Integer messageCount;
+    private Long totalTokens;
+    private Boolean pinned;
+    private String attachments;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private LocalDateTime lastMessageAt;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private LocalDateTime createAt;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private LocalDateTime updateAt;
+}

+ 29 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/vo/MessageVO.java

@@ -0,0 +1,29 @@
+package com.zsjz.ai.module.agent.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 消息响应VO
+ */
+@Data
+public class MessageVO {
+
+    private Long id;
+    private Long sessionId;
+    private String role;
+    private String content;
+    private String contentType;
+    private Integer tokenCount;
+    private String metadata;
+    private String toolEvents;
+    private Long parentId;
+    private String messageType;
+    private Long replyToMessageId;
+    private Boolean starred;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private LocalDateTime createAt;
+}

+ 21 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/vo/ModelProviderVO.java

@@ -0,0 +1,21 @@
+package com.zsjz.ai.module.agent.vo;
+
+import lombok.Data;
+
+/**
+ * 模型厂商响应VO
+ */
+@Data
+public class ModelProviderVO {
+
+    private Long id;
+    private String name;
+    private String baseUrl;
+    private String config;
+    private String status;
+    private String modelsEndpoint;
+    private String headersJson;
+    private String extraJson;
+    private String type;
+
+}

+ 30 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/vo/ModelVO.java

@@ -0,0 +1,30 @@
+package com.zsjz.ai.module.agent.vo;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 模型配置响应VO
+ */
+@Data
+public class ModelVO {
+
+    private Long id;
+    private String provider;
+    private String modelId;
+    private String name;
+    private String type;
+    private String apiKey;
+    private String baseUrl;
+    private Boolean defaultModel;
+    private String config;
+    private String headersJson;
+    private String extraJson;
+    private String status;
+    private Long createBy;
+
+    private LocalDateTime createdAt;
+
+    private LocalDateTime updatedAt;
+}

+ 37 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/vo/SqlResultPageVO.java

@@ -0,0 +1,37 @@
+package com.zsjz.ai.module.agent.vo;
+
+import lombok.Data;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * execute_sql 查询结果分页 VO(与工具返回结构同形状)
+ */
+@Data
+public class SqlResultPageVO {
+
+    /** 结果缓存 ID(前端翻页凭据) */
+    private String resultId;
+
+    /** 原始 SQL */
+    private String sql;
+
+    /** 列定义 [{key, label}] */
+    private List<Map<String, Object>> columns;
+
+    /** 当前页行数据 */
+    private List<Map<String, Object>> rows;
+
+    /** 当前页码(1 起) */
+    private Integer page;
+
+    /** 每页行数 */
+    private Integer pageSize;
+
+    /** 总行数 */
+    private Integer totalRows;
+
+    /** 总页数 */
+    private Integer totalPages;
+}