cc 3 周之前
父節點
當前提交
27c84a3722
共有 46 個文件被更改,包括 4201 次插入 和 0 次删除
  1. 38 0
      ai-server/src/main/java/com/zsjz/ai/common/enums/StatusEnum.java
  2. 6 0
      ai-server/src/main/java/com/zsjz/ai/common/exception/ServerException.java
  3. 58 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/WorkspaceManagerFactory.java
  4. 32 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/config/EtlProperties.java
  5. 137 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/config/SkillRepositoryConfigEntry.java
  6. 142 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/config/SkillRepositorySupport.java
  7. 58 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/entity/AgentChatSession.java
  8. 110 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/entity/AgentEntity.java
  9. 55 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/entity/AgentMessage.java
  10. 63 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/entity/AgentModel.java
  11. 43 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/entity/AgentModelProvider.java
  12. 59 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/entity/RagIndexStatus.java
  13. 118 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/followup/FollowupMiddleware.java
  14. 24 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/followup/FollowupProperties.java
  15. 156 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/followup/FollowupService.java
  16. 38 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/intent/IntentEnum.java
  17. 132 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/intent/IntentMiddleware.java
  18. 24 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/intent/IntentProperties.java
  19. 14 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/intent/IntentResult.java
  20. 212 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/intent/IntentService.java
  21. 12 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/mapper/AgentChatSessionMapper.java
  22. 9 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/mapper/AgentMapper.java
  23. 12 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/mapper/AgentMessageMapper.java
  24. 12 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/mapper/AgentModelMapper.java
  25. 12 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/mapper/AgentModelProviderMapper.java
  26. 12 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/mapper/RagIndexStatusMapper.java
  27. 57 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/mapper/RagVectorMapper.java
  28. 27 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/mapper/SqlQueryMapper.java
  29. 284 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/python/PythonExecutor.java
  30. 30 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/python/PythonProperties.java
  31. 183 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/rag/EmbeddingModelFactory.java
  32. 24 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/rag/RagProperties.java
  33. 477 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/rag/RagSchemaService.java
  34. 10 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/rag/WorkspaceSwitchedEvent.java
  35. 134 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/scaffold/WorkspaceScaffolder.java
  36. 79 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/service/AgentModelFactory.java
  37. 245 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/service/AgentService.java
  38. 76 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/sql/SqlResultStore.java
  39. 113 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/tools/GraphRenderTool.java
  40. 63 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/tools/PythonAnalysisTool.java
  41. 46 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/tools/RagSchemaSearchTool.java
  42. 314 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/tools/SqlAnalysisTool.java
  43. 129 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/tools/WorkspaceInfoTool.java
  44. 261 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/usage/UsageStore.java
  45. 24 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/vo/RagIndexStatusVO.java
  46. 37 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/vo/RagSearchResultVO.java

+ 38 - 0
ai-server/src/main/java/com/zsjz/ai/common/enums/StatusEnum.java

@@ -0,0 +1,38 @@
+package com.zsjz.ai.common.enums;
+
+import com.baomidou.mybatisplus.annotation.EnumValue;
+import lombok.Getter;
+
+/**
+ * 工作空间状态枚举
+ */
+@Getter
+public enum StatusEnum {
+
+    ACTIVE("ACTIVE", "活跃"),
+    SUSPENDED("SUSPENDED", "暂停"),
+    ARCHIVED("ARCHIVED", "归档"),
+    DISABLED("DISABLED", "禁用"),
+    DELETED("DELETED", "已删除");
+
+    @EnumValue
+    private final String code;
+    private final String desc;
+
+    StatusEnum(String code, String desc) {
+        this.code = code;
+        this.desc = desc;
+    }
+
+    /**
+     * 根据code获取枚举
+     */
+    public static StatusEnum fromCode(String code) {
+        for (StatusEnum status : values()) {
+            if (status.getCode().equals(code)) {
+                return status;
+            }
+        }
+        throw new IllegalArgumentException("未知的工作空间状态: " + code);
+    }
+}

+ 6 - 0
ai-server/src/main/java/com/zsjz/ai/common/exception/ServerException.java

@@ -48,6 +48,12 @@ public class ServerException extends RuntimeException {
         this.msg = errorCode.getMessage();
     }
 
+    public ServerException(int code, String msg) {
+        super(msg);
+        this.code = code;
+        this.msg = msg;
+    }
+
     /**
      * 服务异常静态工厂方法
      *

+ 58 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/WorkspaceManagerFactory.java

@@ -0,0 +1,58 @@
+package com.zsjz.ai.module.agent;
+
+import org.springframework.stereotype.Component;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+/**
+ * agent 运行时工作目录解析器(三维数据隔离:用户 × 工作空间 × Agent)。
+ *
+ * <p>目录结构固定为:
+ * <pre>{cwd}/.agentscope/users/{userId}/workspaces/{workspaceId}/agents/{agentRowId}/</pre>
+ *
+ * <p>用户间、工作空间间的 agent 文件空间(skills / 记忆 / 产出文件)相互独立,
+ * 模型切换不改变数据归属。
+ *
+ * <p>注意:该目录同时作为 harness 工作区根目录(IsolationScope.GLOBAL,不再追加
+ * userId 命名空间),harness 会在其中自动创建运行时子目录:
+ * <pre>
+ * {agentDir}/agents/main/sessions/   主 agent 会话数据(agentId 固定为 "main")
+ * {agentDir}/default/main/{sid}/events/  事件转录
+ * {agentDir}/memory/ skills/ subagents/  脚手架标准槽位
+ * </pre>
+ */
+@Component
+public final class WorkspaceManagerFactory {
+
+    /**
+     * 解析 agent 的隔离工作目录。
+     *
+     * @param userId      用户标识(业务用户ID字符串)
+     * @param workspaceId 业务工作空间ID
+     * @param agentRowId  dataagent_agent.row_id
+     * @return 隔离目录的绝对路径(调用方负责按需创建)
+     */
+    public Path resolveAgentDataPath(String userId, Long workspaceId, Long agentRowId) {
+        validateSegment("userId", userId);
+        validateSegment("workspaceId", workspaceId == null ? null : String.valueOf(workspaceId));
+        validateSegment("agentRowId", agentRowId == null ? null : String.valueOf(agentRowId));
+
+        Path cwd = Paths.get(System.getProperty("user.dir")).toAbsolutePath().normalize();
+        return cwd.resolve(".agentscope")
+                .resolve("users").resolve(userId.trim())
+                .resolve("workspaces").resolve(String.valueOf(workspaceId))
+                .resolve("agents").resolve(String.valueOf(agentRowId))
+                .normalize();
+    }
+
+    private static void validateSegment(String label, String value) {
+        if (value == null || value.isBlank()) {
+            throw new IllegalArgumentException(label + " must not be null or blank");
+        }
+        if (value.contains("/") || value.contains("\\") || value.contains("..")) {
+            throw new IllegalArgumentException(
+                    label + " must not contain path separators or '..': " + value);
+        }
+    }
+}

+ 32 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/config/EtlProperties.java

@@ -0,0 +1,32 @@
+package com.zsjz.ai.module.agent.config;
+
+import com.zsjz.ai.module.agent.followup.FollowupProperties;
+import com.zsjz.ai.module.agent.intent.IntentProperties;
+import com.zsjz.ai.module.agent.python.PythonProperties;
+import com.zsjz.ai.module.agent.rag.RagProperties;
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * ETL Agent 统一配置
+ *
+ * <p>对应 application.yaml 中 {@code etl} 配置节,包含 RAG、意图识别、引导建议、Python 执行四个子配置。
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "etl")
+public class EtlProperties {
+
+    /** RAG 表结构向量检索配置 */
+    private RagProperties rag = new RagProperties();
+
+    /** 意图识别与语义增强配置 */
+    private IntentProperties intent = new IntentProperties();
+
+    /** 对话结束后的下一步引导建议配置 */
+    private FollowupProperties followup = new FollowupProperties();
+
+    /** Python 数据分析执行配置 */
+    private PythonProperties python = new PythonProperties();
+}

+ 137 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/config/SkillRepositoryConfigEntry.java

@@ -0,0 +1,137 @@
+/*
+ * Copyright 2024-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.zsjz.ai.module.agent.config;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Declarative skill repository settings for {@code agentscope.json} (and similar) configs.
+ *
+ * <p>Use {@code type: "filesystem"} with {@link #path} or {@code type: "git"} with {@link
+ * #remoteUrl}. Git support requires {@code io.agentscope:agentscope-extensions-skill-git-repository}
+ * on the classpath.
+ */
+@JsonInclude(JsonInclude.Include.NON_EMPTY)
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class SkillRepositoryConfigEntry {
+
+    /**
+     * {@code filesystem} — load from a directory ({@link #path}, relative to bootstrap {@code cwd}).
+     *
+     * <p>{@code git} — clone / sync a remote repository ({@link #remoteUrl}, optional {@link
+     * #branch}, {@link #localPath}, etc.).
+     */
+    @JsonProperty("type")
+    private String type;
+
+    /** Directory containing skill folders (each with {@code SKILL.md}). Used when {@code type} is {@code filesystem}. */
+    @JsonProperty("path")
+    private String path;
+
+    @JsonProperty("remoteUrl")
+    private String remoteUrl;
+
+    @JsonProperty("branch")
+    private String branch;
+
+    /**
+     * Optional in-repo subdirectory containing skill folders. Used when {@code type} is {@code
+     * git}; ignored otherwise. When blank, defaults to {@code skills/} if present, else the repo
+     * root. Must be a path relative to the repo root; absolute paths or {@code ..} segments are
+     * rejected by the underlying repository implementation.
+     */
+    @JsonProperty("skillsRoot")
+    private String skillsRoot;
+
+    /**
+     * Local clone directory; when set, resolved relative to bootstrap {@code cwd}. Optional for
+     * {@code git} (otherwise a temp directory is used by {@code GitSkillRepository}).
+     */
+    @JsonProperty("localPath")
+    private String localPath;
+
+    @JsonProperty("source")
+    private String source;
+
+    @JsonProperty("autoSync")
+    private Boolean autoSync;
+
+    public String getType() {
+        return type;
+    }
+
+    public void setType(String type) {
+        this.type = type;
+    }
+
+    public String getPath() {
+        return path;
+    }
+
+    public void setPath(String path) {
+        this.path = path;
+    }
+
+    public String getRemoteUrl() {
+        return remoteUrl;
+    }
+
+    public void setRemoteUrl(String remoteUrl) {
+        this.remoteUrl = remoteUrl;
+    }
+
+    public String getBranch() {
+        return branch;
+    }
+
+    public void setBranch(String branch) {
+        this.branch = branch;
+    }
+
+    public String getSkillsRoot() {
+        return skillsRoot;
+    }
+
+    public void setSkillsRoot(String skillsRoot) {
+        this.skillsRoot = skillsRoot;
+    }
+
+    public String getLocalPath() {
+        return localPath;
+    }
+
+    public void setLocalPath(String localPath) {
+        this.localPath = localPath;
+    }
+
+    public String getSource() {
+        return source;
+    }
+
+    public void setSource(String source) {
+        this.source = source;
+    }
+
+    public Boolean getAutoSync() {
+        return autoSync;
+    }
+
+    public void setAutoSync(Boolean autoSync) {
+        this.autoSync = autoSync;
+    }
+}

+ 142 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/config/SkillRepositorySupport.java

@@ -0,0 +1,142 @@
+/*
+ * Copyright 2024-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.zsjz.ai.module.agent.config;
+
+import io.agentscope.core.skill.repository.AgentSkillRepository;
+import io.agentscope.core.skill.repository.FileSystemSkillRepository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+/** Builds an {@link AgentSkillRepository} from {@link SkillRepositoryConfigEntry}. */
+public final class SkillRepositorySupport {
+
+    private static final Logger log = LoggerFactory.getLogger(SkillRepositorySupport.class);
+
+    private static final String TYPE_FILESYSTEM = "filesystem";
+    private static final String TYPE_GIT = "git";
+
+    private SkillRepositorySupport() {}
+
+
+
+    /**
+     * Materialises every non-null entry in {@code entries} via {@link #create(Path,
+     * SkillRepositoryConfigEntry)} and returns the resulting list, preserving order. Entries that
+     * fail to instantiate (unknown type, missing optional Git dependency, …) are filtered out and
+     * logged at WARN. Never null; may be empty.
+     */
+    public static List<AgentSkillRepository> createAll(
+            Path cwd, List<SkillRepositoryConfigEntry> entries) {
+        if (entries == null || entries.isEmpty()) return List.of();
+        List<AgentSkillRepository> out = new ArrayList<>(entries.size());
+        for (SkillRepositoryConfigEntry entry : entries) {
+            AgentSkillRepository repo = create(cwd, entry);
+            if (repo != null) {
+                out.add(repo);
+            }
+        }
+        return out;
+    }
+
+    /**
+     * @param cwd   bootstrap working directory (used to resolve relative paths)
+     * @param entry non-null config entry
+     * @return repository instance, or {@code null} if configuration is invalid or optional Git
+     *     types are not on the classpath
+     */
+    public static AgentSkillRepository create(Path cwd, SkillRepositoryConfigEntry entry) {
+        if (entry == null || entry.getType() == null || entry.getType().isBlank()) {
+            return null;
+        }
+        String kind = entry.getType().trim().toLowerCase();
+        return switch (kind) {
+            case TYPE_FILESYSTEM -> createFilesystem(cwd, entry);
+            case TYPE_GIT -> createGit(cwd, entry);
+            default -> {
+                log.warn(
+                        "Unknown skillRepository type '{}'; expected '{}' or '{}'",
+                        entry.getType(),
+                        TYPE_FILESYSTEM,
+                        TYPE_GIT);
+                yield null;
+            }
+        };
+    }
+
+    private static AgentSkillRepository createFilesystem(
+            Path cwd, SkillRepositoryConfigEntry entry) {
+        String pathStr = entry.getPath();
+        if (pathStr == null || pathStr.isBlank()) {
+            log.warn("skillRepository type filesystem requires non-blank 'path'");
+            return null;
+        }
+        Path dir = cwd.resolve(pathStr).normalize();
+        if (!Files.isDirectory(dir)) {
+            log.warn(
+                    "skillRepository path '{}' resolved to '{}' which is not a directory",
+                    pathStr,
+                    dir);
+            return null;
+        }
+        return new FileSystemSkillRepository(dir);
+    }
+
+    private static AgentSkillRepository createGit(Path cwd, SkillRepositoryConfigEntry entry) {
+        String remote = entry.getRemoteUrl();
+        if (remote == null || remote.isBlank()) {
+            log.warn("skillRepository type git requires non-blank 'remoteUrl'");
+            return null;
+        }
+        Path local =
+                entry.getLocalPath() != null && !entry.getLocalPath().isBlank()
+                        ? cwd.resolve(entry.getLocalPath()).normalize()
+                        : null;
+        boolean auto = entry.getAutoSync() == null || Boolean.TRUE.equals(entry.getAutoSync());
+        try {
+            Class<?> gitRepo =
+                    Class.forName("io.agentscope.core.skill.repository.GitSkillRepository");
+            var ctor =
+                    gitRepo.getConstructor(
+                            String.class,
+                            String.class,
+                            Path.class,
+                            String.class,
+                            boolean.class,
+                            String.class);
+            return (AgentSkillRepository)
+                    ctor.newInstance(
+                            remote,
+                            entry.getBranch(),
+                            local,
+                            entry.getSource(),
+                            auto,
+                            entry.getSkillsRoot());
+        } catch (ClassNotFoundException e) {
+            log.warn(
+                    "GitSkillRepository not on classpath; add dependency"
+                            + " agentscope-extensions-skill-git-repository");
+            return null;
+        } catch (ReflectiveOperationException e) {
+            log.warn("Failed to construct GitSkillRepository: {}", e.getMessage());
+            return null;
+        }
+    }
+}

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

@@ -0,0 +1,58 @@
+package com.zsjz.ai.module.agent.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import com.zsjz.ai.common.pgsql.JsonbTypeHandler;
+import lombok.Data;
+import org.apache.ibatis.type.JdbcType;
+
+import java.time.LocalDateTime;
+
+/**
+ * 会话记录实体
+ */
+@Data
+@TableName("agent_chat_session")
+public class AgentChatSession {
+
+    @TableId(value = "id", type = IdType.ASSIGN_ID)
+    private Long id;
+
+    @TableField("agent_id")
+    private Long agentId;
+
+    /**
+     * 绑定的模型配置ID(model.id),空则使用默认模型
+     */
+    @TableField("model_id")
+    private Long modelId;
+
+    @TableField("case_id")
+    private Integer caseId;
+
+    @TableField("title")
+    private String title;
+
+    @TableField(value = "context", typeHandler = JsonbTypeHandler.class, jdbcType = JdbcType.OTHER)
+    private String context;
+
+    @TableField("message_count")
+    private Integer messageCount;
+
+    @TableField("total_tokens")
+    private Long totalTokens;
+
+    @TableField("last_message_at")
+    private LocalDateTime lastMessageAt;
+
+    @TableField("pinned")
+    private Boolean pinned;
+
+    @TableField(value = "attachments", typeHandler = JsonbTypeHandler.class, jdbcType = JdbcType.OTHER)
+    private String attachments;
+
+    @TableField(value = "create_at", fill = FieldFill.INSERT)
+    private LocalDateTime createAt;
+
+    @TableField(value = "update_at", fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateAt;
+}

+ 110 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/entity/AgentEntity.java

@@ -0,0 +1,110 @@
+
+package com.zsjz.ai.module.agent.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+/**
+ * MyBatis Plus 持久化实体 —— 对应用户自定义的 Agent 定义。
+ * 主键由 {@code row_id} 标识,唯一约束由 {(owner_id, agent_id)} 对保证。
+
+ */
+@Data
+@TableName(value = "agent")
+public class AgentEntity {
+
+    /**
+     * 主键 ID,自增策略。
+     */
+    @TableId(value = "row_id", type = IdType.AUTO)
+    private Long rowId;
+
+    @TableField(value = "owner_id")
+    private String ownerId;
+
+    @TableField(value = "agent_id")
+    private String agentId;
+
+    /**
+     * 用户提供的 Agent 工作区路径(原样存储,可能为 {@code null})。
+     * 非空时使用绝对路径;相对路径在 {@code ${cwd}/.agentscope/} 下解析。
+     * 为 {@code null} 时使用 agentId 作为路径。
+     */
+    @TableField(value = "workspace_path")
+    private String workspacePath;
+
+    @TableField(value = "name")
+    private String name;
+
+    /**
+     * Agent 描述信息,对应数据库 LONGTEXT / TEXT 列。
+     */
+    @TableField(value = "description")
+    private String description;
+
+    /**
+     * 系统提示词(System Prompt),对应数据库 LONGTEXT / TEXT 列。
+     */
+    @TableField(value = "sys_prompt")
+    private String sysPrompt;
+
+    @TableField(value = "model")
+    private String model;
+
+    @TableField(value = "max_iters")
+    private Integer maxIters;
+
+    /**
+     * 允许使用的工具列表(JSON 数组字符串)。
+     */
+    @TableField(value = "tools_allow_json")
+    private String toolsAllowJson;
+
+    /**
+     * 禁止使用的工具列表(JSON 数组字符串)。
+     */
+    @TableField(value = "tools_deny_json")
+    private String toolsDenyJson;
+
+    /**
+     * 允许使用的技能列表(JSON 数组字符串)。
+     */
+    @TableField(value = "skills_allow_json")
+    private String skillsAllowJson;
+
+    /**
+     * 禁止使用的技能列表(JSON 数组字符串)。
+     */
+    @TableField(value = "skills_deny_json")
+    private String skillsDenyJson;
+
+    @TableField(value = "run_as")
+    private String runAs;
+
+    @TableField(value = "fork_of")
+    private String forkOf;
+
+    /**
+     * 技能仓库配置列表(JSON 数组字符串),与 agentscope.json 中的 skillRepositories 节格式一致。
+     * 为 {@code null} 时表示仅使用隐式工作区覆盖。
+     */
+    @TableField(value = "skill_repositories_json")
+    private String skillRepositoriesJson;
+
+    /**
+     * 沙箱执行模式({@code local} / {@code sandbox});{@code null} 时回退到平台默认值。
+     */
+    @TableField(value = "sandbox_mode")
+    private String sandboxMode;
+
+    /**
+     * 沙箱隔离范围({@code SESSION} / {@code USER} / {@code AGENT} / {@code GLOBAL});
+     * 仅在 {@link #sandboxMode} 为 {@code sandbox} 时有意义。
+     */
+    @TableField(value = "sandbox_scope")
+    private String sandboxScope;
+
+}

+ 55 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/entity/AgentMessage.java

@@ -0,0 +1,55 @@
+package com.zsjz.ai.module.agent.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import com.zsjz.ai.common.pgsql.JsonbTypeHandler;
+import lombok.Data;
+import org.apache.ibatis.type.JdbcType;
+
+import java.time.LocalDateTime;
+
+/**
+ * 消息实体
+ */
+@Data
+@TableName("agent_message")
+public class AgentMessage {
+
+    @TableId(value = "id", type = IdType.ASSIGN_ID)
+    private Long id;
+
+    @TableField("session_id")
+    private Long sessionId;
+
+    @TableField("role")
+    private String role;
+
+    @TableField("content")
+    private String content;
+
+    @TableField("content_type")
+    private String contentType;
+
+    @TableField("token_count")
+    private Integer tokenCount;
+
+    @TableField(value = "metadata", typeHandler = JsonbTypeHandler.class, jdbcType = JdbcType.OTHER)
+    private String metadata;
+
+    @TableField(value = "tool_events", typeHandler = JsonbTypeHandler.class, jdbcType = JdbcType.OTHER)
+    private String toolEvents;
+
+    @TableField("parent_id")
+    private Long parentId;
+
+    @TableField(value = "create_at", fill = FieldFill.INSERT)
+    private LocalDateTime createAt;
+
+    @TableField("message_type")
+    private String messageType;
+
+    @TableField("reply_to_message_id")
+    private Long replyToMessageId;
+
+    @TableField("starred")
+    private Boolean starred;
+}

+ 63 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/entity/AgentModel.java

@@ -0,0 +1,63 @@
+package com.zsjz.ai.module.agent.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+
+import com.zsjz.ai.common.enums.StatusEnum;
+import com.zsjz.ai.common.pgsql.JsonbTypeHandler;
+import lombok.Data;
+import org.apache.ibatis.type.JdbcType;
+
+import java.time.LocalDateTime;
+
+/**
+ * 模型配置实体
+ */
+@Data
+@TableName("agent_model")
+public class AgentModel {
+
+    @TableId(value = "id", type = IdType.ASSIGN_ID)
+    private Long id;
+
+    @TableField("provider")
+    private String provider;
+
+    @TableField("model_id")
+    private String modelId;
+
+    @TableField("name")
+    private String name;
+
+    @TableField("type")
+    private String type;
+
+    @TableField("api_key")
+    private String apiKey;
+
+    @TableField("base_url")
+    private String baseUrl;
+
+    @TableField("default_model")
+    private Boolean defaultModel;
+
+    @TableField(value="config", typeHandler = JsonbTypeHandler.class, jdbcType = JdbcType.OTHER)
+    private String config;
+
+    @TableField(value="headers_json", typeHandler = JsonbTypeHandler.class, jdbcType = JdbcType.OTHER)
+    private String headersJson;
+
+    @TableField(value="extra_json", typeHandler = JsonbTypeHandler.class, jdbcType = JdbcType.OTHER)
+    private String extraJson;
+
+    @TableField("status")
+    private StatusEnum status;
+
+    @TableField("create_by")
+    private Long createBy;
+
+    @TableField(value = "create_at", fill = FieldFill.INSERT)
+    private LocalDateTime createdAt;
+
+    @TableField(value = "update_at", fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updatedAt;
+}

+ 43 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/entity/AgentModelProvider.java

@@ -0,0 +1,43 @@
+package com.zsjz.ai.module.agent.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+/**
+ * 模型厂商实体
+ */
+@Data
+@TableName("agent_model_provider")
+public class AgentModelProvider {
+
+    @TableId(value = "id", type = IdType.ASSIGN_ID)
+    private Long id;
+
+    @TableField("name")
+    private String name;
+
+    @TableField("base_url")
+    private String baseUrl;
+
+    @TableField("config")
+    private String config;
+
+    @TableField("status")
+    private String status;
+
+    @TableField("models_endpoint")
+    private String modelsEndpoint;
+
+    @TableField("headers_json")
+    private String headersJson;
+
+    @TableField("extra_json")
+    private String extraJson;
+
+    @TableField("type")
+    private String type;
+
+}

+ 59 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/entity/RagIndexStatus.java

@@ -0,0 +1,59 @@
+package com.zsjz.ai.module.agent.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * RAG 表结构向量索引状态实体(每工作空间一行)
+ */
+@Data
+@TableName("rag_index_status")
+public class RagIndexStatus {
+
+    /** 状态常量:未索引 */
+    public static final String STATUS_IDLE = "idle";
+    /** 状态常量:索引中 */
+    public static final String STATUS_INDEXING = "indexing";
+    /** 状态常量:成功 */
+    public static final String STATUS_SUCCESS = "success";
+    /** 状态常量:失败 */
+    public static final String STATUS_FAILED = "failed";
+
+    @TableId(value = "workspace_id", type = IdType.INPUT)
+    private Long workspaceId;
+
+    @TableField("embedding_model_id")
+    private Long embeddingModelId;
+
+    @TableField("embedding_model_name")
+    private String embeddingModelName;
+
+    @TableField("dimensions")
+    private Integer dimensions;
+
+    @TableField("vector_table")
+    private String vectorTable;
+
+    @TableField("schema_fingerprint")
+    private String schemaFingerprint;
+
+    @TableField("table_count")
+    private Integer tableCount;
+
+    @TableField("status")
+    private String status;
+
+    @TableField("last_error")
+    private String lastError;
+
+    @TableField("indexed_at")
+    private LocalDateTime indexedAt;
+
+    @TableField("update_at")
+    private LocalDateTime updateAt;
+}

+ 118 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/followup/FollowupMiddleware.java

@@ -0,0 +1,118 @@
+package com.zsjz.ai.module.agent.followup;
+
+import io.agentscope.core.agent.Agent;
+import io.agentscope.core.agent.RuntimeContext;
+import io.agentscope.core.event.AgentEvent;
+import io.agentscope.core.event.AgentResultEvent;
+import io.agentscope.core.event.CustomEvent;
+import io.agentscope.core.message.Msg;
+import io.agentscope.core.message.MsgRole;
+import io.agentscope.core.middleware.AgentInput;
+import io.agentscope.core.middleware.MiddlewareBase;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.util.StringUtils;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+
+/**
+ * 下一步引导建议中间件(onAgent 钩子)
+ *
+ * <p>对话结束后根据本轮问答内容生成引导问题(官方 Agentic 管线的横切关注点模式):
+ * <ul>
+ *   <li>包裹整个推理流,透传期间累积最终回答文本({@link AgentResultEvent} 携带完整结果)</li>
+ *   <li>流完成(对话结束)后在 {@code boundedElastic} 上调用 {@link FollowupService}
+ *       做轻量 LLM 生成(fail-open),将建议以 {@code CustomEvent("suggestions", ...)}
+ *       追加到事件流尾部,由 ChatServiceImpl 转为 SSE suggestions 帧推给前端</li>
+ * </ul>
+ *
+ * <p>选择 {@code onAgent} 而非 {@code onReasoning}:建议生成必须在整个对话(含全部工具调用轮次)
+ * 结束后执行一次,且 {@code concatWith} 保证建议事件晚于 done 帧、连接关闭前送达。
+ */
+@Slf4j
+public class FollowupMiddleware implements MiddlewareBase {
+
+    private final FollowupService followupService;
+    private final FollowupProperties followupProperties;
+    private final Long modelId;
+
+    public FollowupMiddleware(FollowupService followupService,
+                              FollowupProperties followupProperties,
+                              Long modelId) {
+        this.followupService = followupService;
+        this.followupProperties = followupProperties;
+        this.modelId = modelId;
+    }
+
+    @Override
+    public Flux<AgentEvent> onAgent(Agent agent, RuntimeContext ctx, AgentInput input,
+                                    Function<AgentInput, Flux<AgentEvent>> next) {
+        if (!followupProperties.isEnabled()) {
+            return next.apply(input);
+        }
+        String question = lastUserText(input.msgs());
+        if (!StringUtils.hasText(question)) {
+            return next.apply(input);
+        }
+
+        // 透传事件流,累积本轮最终回答
+        StringBuilder answer = new StringBuilder();
+        return next.apply(input)
+                .doOnNext(event -> {
+                    if (event instanceof AgentResultEvent e
+                            && e.getResult() != null && e.getResult().getTextContent() != null) {
+                        answer.append(e.getResult().getTextContent());
+                    }
+                })
+                // 对话结束后追加建议事件(阻塞 LLM 调用移到 boundedElastic,避免占用事件循环线程)
+                .concatWith(Mono.fromCallable(() -> buildSuggestionEvent(question, answer.toString()))
+                        .subscribeOn(Schedulers.boundedElastic())
+                        .flatMapMany(Flux::just)
+                        .onErrorResume(e -> {
+                            log.warn("下一步引导建议事件发射失败(fail-open): error={}", e.getMessage());
+                            return Flux.empty();
+                        }));
+    }
+
+    /**
+     * 生成建议事件;本轮无有效回答(异常/中止/空回复)或生成失败时返回 null(不追加事件)
+     */
+    private AgentEvent buildSuggestionEvent(String question, String answer) {
+        if (!StringUtils.hasText(answer)) {
+            return null;
+        }
+        List<String> questions = followupService.safeSuggest(modelId, question, answer);
+        if (questions == null || questions.isEmpty()) {
+            return null;
+        }
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("type", "suggestions");
+        data.put("questions", questions);
+        return new CustomEvent("suggestions", data);
+    }
+
+    /**
+     * 定位本轮最后一条真实用户消息(跳过中间件注入的 {@code METADATA_SYNTHETIC} 分析块)
+     */
+    private static String lastUserText(List<Msg> msgs) {
+        for (int i = msgs.size() - 1; i >= 0; i--) {
+            Msg m = msgs.get(i);
+            if (m.getRole() != MsgRole.USER) {
+                continue;
+            }
+            if (m.getMetadata() != null && Boolean.TRUE.equals(m.getMetadata().get(Msg.METADATA_SYNTHETIC))) {
+                continue;
+            }
+            String text = m.getTextContent();
+            if (StringUtils.hasText(text)) {
+                return text;
+            }
+        }
+        return null;
+    }
+}

+ 24 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/followup/FollowupProperties.java

@@ -0,0 +1,24 @@
+package com.zsjz.ai.module.agent.followup;
+
+import lombok.Data;
+
+/**
+ * 下一步引导建议配置项
+ *
+ * <p>对应 application.yaml 中 {@code etl.followup} 配置节。
+ */
+@Data
+public class FollowupProperties {
+
+    /** 是否启用对话结束后的下一步引导建议 */
+    private boolean enabled = true;
+
+    /** LLM 调用超时秒数(超时即静默跳过,不影响主链路) */
+    private int timeoutSeconds = 8;
+
+    /** 生成建议条数 */
+    private int questionCount = 3;
+
+    /** LLM 输出 token 上限 */
+    private int maxTokens = 256;
+}

+ 156 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/followup/FollowupService.java

@@ -0,0 +1,156 @@
+package com.zsjz.ai.module.agent.followup;
+
+import com.zsjz.ai.common.utils.Json;
+import com.zsjz.ai.module.agent.config.EtlProperties;
+import com.zsjz.ai.module.agent.entity.AgentModel;
+import com.zsjz.ai.module.agent.mapper.AgentModelMapper;
+import com.zsjz.ai.module.agent.service.AgentModelFactory;
+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 lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 下一步引导建议生成服务
+ *
+ * <p>对话结束后,复用会话模型做一次轻量 LLM 调用:根据本轮用户问题与助手回答,
+ * 推测用户接下来最想问的引导问题。全程 fail-open——任何异常/超时/解析失败均返回 null,
+ * 由调用方静默跳过,绝不影响主链路。
+ */
+@Slf4j
+@Service
+public class FollowupService {
+
+    /** 助手回答参与上下文的截断长度(建议只关心结论部分,超长截断) */
+    private static final int ANSWER_LIMIT = 2000;
+
+    /** 单条建议问题长度上限(防 LLM 输出整段话) */
+    private static final int QUESTION_MAX_LEN = 50;
+
+    private final AgentModelMapper agentModelMapper;
+    private final AgentModelFactory agentModelFactory;
+    private final FollowupProperties followupProperties;
+
+    public FollowupService(AgentModelMapper agentModelMapper,
+                           AgentModelFactory agentModelFactory,
+                           EtlProperties etlProperties) {
+        this.agentModelMapper = agentModelMapper;
+        this.agentModelFactory = agentModelFactory;
+        this.followupProperties = etlProperties.getFollowup();
+    }
+
+    /**
+     * 生成下一步引导建议(fail-open:任何异常/超时返回 null)
+     *
+     * @param modelId  会话当前模型 ID
+     * @param question 本轮用户问题
+     * @param answer   本轮助手回答
+     * @return 建议问题列表(1~questionCount 条);模型配置缺失/调用失败/超时/解析失败时返回 null
+     */
+    public List<String> safeSuggest(Long modelId, String question, String answer) {
+        try {
+            AgentModel config = agentModelMapper.selectById(modelId);
+            if (config == null) {
+                return null;
+            }
+            io.agentscope.core.model.Model model = agentModelFactory.create(config);
+
+            List<Msg> msgs = List.of(
+                    Msg.builder().role(MsgRole.SYSTEM).textContent(buildSystemPrompt()).build(),
+                    Msg.builder().role(MsgRole.USER).textContent(buildUserPrompt(question, answer)).build());
+
+            StringBuilder sb = new StringBuilder();
+            model.stream(msgs, List.of(), GenerateOptions.builder()
+                            .temperature(0.7)
+                            .maxTokens(followupProperties.getMaxTokens())
+                            .build())
+                    .doOnNext(resp -> {
+                        if (resp.getContent() != null) {
+                            resp.getContent().forEach(block -> {
+                                if (block instanceof TextBlock t && t.getText() != null) {
+                                    sb.append(t.getText());
+                                }
+                            });
+                        }
+                    })
+                    .blockLast(Duration.ofSeconds(followupProperties.getTimeoutSeconds()));
+
+            return parse(sb.toString().trim());
+        } catch (Exception e) {
+            log.warn("下一步引导建议降级(fail-open): modelId={}, error={}", modelId, e.getMessage());
+            return null;
+        }
+    }
+
+    /**
+     * 建议生成系统提示词:聚焦数据分析场景的可继续深入方向
+     */
+    private String buildSystemPrompt() {
+        int count = followupProperties.getQuestionCount();
+        return "你是数据分析助手的\"下一步引导\"模块。根据用户问题和助手的最新回答,"
+                + "推测用户接下来最可能想问的 " + count + " 个引导问题。\n"
+                + "要求:\n"
+                + "- 问题必须与刚才的分析结果直接相关、可继续深入(如:换维度统计、下钻明细、TopN/排序、趋势、可视化、异常值、对比验证)\n"
+                + "- 用中文口语化表述,每条不超过 30 字,不带编号、引号和标点前缀\n"
+                + "- 禁止重复刚才已经回答过的内容,禁止泛泛的寒暄类问题\n"
+                + "输出严格 JSON(不要输出任何其他内容,不要用 markdown 代码块包裹):\n"
+                + "{\"questions\":[" + "\"问题1\",\"问题2\",\"问题3\"" + "]}\n"
+                + "实际条数必须等于 " + count + "。";
+    }
+
+    /**
+     * 用户提示词:本轮问答原文(回答超长截断)
+     */
+    private static String buildUserPrompt(String question, String answer) {
+        String ans = answer.length() > ANSWER_LIMIT
+                ? answer.substring(0, ANSWER_LIMIT) + "..." : answer;
+        return "[用户问题]\n" + question + "\n\n[助手回答]\n" + ans;
+    }
+
+    /**
+     * 宽松解析 LLM 输出:剥围栏 → 截取首尾大括号 → Jackson 解析 → 过滤/截断
+     */
+    private List<String> parse(String raw) {
+        if (!StringUtils.hasText(raw)) {
+            return null;
+        }
+        int start = raw.indexOf('{');
+        int end = raw.lastIndexOf('}');
+        if (start < 0 || end <= start) {
+            log.warn("引导建议输出非 JSON(降级): {}", abbreviate(raw));
+            return null;
+        }
+        try {
+            Map<String, Object> map = Json.objectMapper().readValue(raw.substring(start, end + 1), Map.class);
+            List<String> questions = new ArrayList<>();
+            if (map.get("questions") instanceof List<?> list) {
+                for (Object item : list) {
+                    if (item instanceof String s && StringUtils.hasText(s)
+                            && questions.size() < followupProperties.getQuestionCount()) {
+                        String q = s.trim();
+                        if (q.length() > QUESTION_MAX_LEN) {
+                            q = q.substring(0, QUESTION_MAX_LEN);
+                        }
+                        questions.add(q);
+                    }
+                }
+            }
+            return questions.isEmpty() ? null : questions;
+        } catch (Exception e) {
+            log.warn("引导建议结果解析失败(降级): error={}", e.getMessage());
+            return null;
+        }
+    }
+
+    private static String abbreviate(String s) {
+        return s.length() > 120 ? s.substring(0, 120) + "..." : s;
+    }
+}

+ 38 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/intent/IntentEnum.java

@@ -0,0 +1,38 @@
+package com.zsjz.ai.module.agent.intent;
+
+import lombok.Getter;
+
+/**
+ * 用户问题意图枚举
+ *
+ * <p>供意图识别 LLM prompt 与解析校验复用,未知意图回落 {@link #GENERAL_CHAT}。
+ */
+@Getter
+public enum IntentEnum {
+
+    DATA_QUERY("data_query", "数据查询"),
+    SCHEMA_EXPLORE("schema_explore", "表结构探索"),
+    ETL_CLEAN("etl_clean", "数据处理"),
+    VISUALIZATION("visualization", "图表可视化"),
+    GENERAL_CHAT("general_chat", "通用问答");
+
+    private final String code;
+    private final String label;
+
+    IntentEnum(String code, String label) {
+        this.code = code;
+        this.label = label;
+    }
+
+    /**
+     * 根据 code 获取枚举,未知 code 回落通用问答
+     */
+    public static IntentEnum fromCode(String code) {
+        for (IntentEnum e : values()) {
+            if (e.getCode().equals(code)) {
+                return e;
+            }
+        }
+        return GENERAL_CHAT;
+    }
+}

+ 132 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/intent/IntentMiddleware.java

@@ -0,0 +1,132 @@
+package com.zsjz.ai.module.agent.intent;
+
+import io.agentscope.core.agent.Agent;
+import io.agentscope.core.agent.RuntimeContext;
+import io.agentscope.core.event.AgentEvent;
+import io.agentscope.core.event.CustomEvent;
+import io.agentscope.core.message.Msg;
+import io.agentscope.core.message.MsgRole;
+import io.agentscope.core.message.TextBlock;
+import io.agentscope.core.middleware.AgentInput;
+import io.agentscope.core.middleware.MiddlewareBase;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.util.StringUtils;
+import reactor.core.publisher.Flux;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+
+/**
+ * 意图识别中间件(onAgent 钩子)
+ *
+ * <p>在用户消息进入 Agent 推理前做意图分类 + 语义增强(官方 Agentic 管线的横切关注点模式):
+ * <ul>
+ *   <li>取本轮最后一条用户消息,经 {@link IntentService} 做轻量 LLM 分析(fail-open)</li>
+ *   <li>有识别结果时,向输入追加一条带 {@code METADATA_SYNTHETIC} 标记的系统分析消息
+ *       (沿用官方 {@code TaskReminderMiddleware} 的注入约定,不修改用户原始消息)</li>
+ *   <li>在事件流头部发射 {@code CustomEvent("intent", ...)},由 ChatServiceImpl 转为 SSE intent 帧</li>
+ * </ul>
+ *
+ * <p>选择 {@code onAgent} 而非 {@code onReasoning}:后者在多轮工具调用循环中会重复触发,
+ * 意图分析只需在调用入口执行一次。
+ */
+@Slf4j
+public class IntentMiddleware implements MiddlewareBase {
+
+    private final IntentService intentService;
+    private final IntentProperties intentProperties;
+    private final Long modelId;
+
+    public IntentMiddleware(IntentService intentService, IntentProperties intentProperties, Long modelId) {
+        this.intentService = intentService;
+        this.intentProperties = intentProperties;
+        this.modelId = modelId;
+    }
+
+    @Override
+    public Flux<AgentEvent> onAgent(Agent agent, RuntimeContext ctx, AgentInput input,
+                                    Function<AgentInput, Flux<AgentEvent>> next) {
+        if (!intentProperties.isEnabled()) {
+            return next.apply(input);
+        }
+        List<Msg> msgs = input.msgs();
+        int lastUserIdx = lastUserIndex(msgs);
+        if (lastUserIdx < 0) {
+            return next.apply(input);
+        }
+        String question = msgs.get(lastUserIdx).getTextContent();
+        if (!StringUtils.hasText(question)) {
+            return next.apply(input);
+        }
+
+        IntentResult intent = intentService.safeAnalyze(modelId, parseSessionId(ctx), question);
+        if (intent == null) {
+            return next.apply(input);
+        }
+
+        // 官方 synthetic 注入约定:不修改用户原始消息,追加带元数据标记的分析块
+        Msg analysis = Msg.builder()
+                .role(MsgRole.USER)
+                .name("system")
+                .content(TextBlock.builder().text(render(intent)).build())
+                .metadata(Map.of(Msg.METADATA_SYNTHETIC, true))
+                .build();
+        List<Msg> newMsgs = new ArrayList<>(msgs);
+        newMsgs.add(analysis);
+
+        // 事件流头部发射 intent 事件(ChatServiceImpl 转 SSE intent 帧)
+        return Flux.just((AgentEvent) new CustomEvent("intent", frame(intent)))
+                .concatWith(next.apply(new AgentInput(newMsgs)));
+    }
+
+    /**
+     * 定位本轮最后一条用户消息(多工具消息场景下跳过 tool_result 等)
+     */
+    private static int lastUserIndex(List<Msg> msgs) {
+        for (int i = msgs.size() - 1; i >= 0; i--) {
+            if (msgs.get(i).getRole() == MsgRole.USER) {
+                return i;
+            }
+        }
+        return -1;
+    }
+
+    /**
+     * sessionKey 即业务会话 ID 字符串(与 ChatServiceImpl 约定一致)
+     */
+    private static Long parseSessionId(RuntimeContext ctx) {
+        try {
+            return StringUtils.hasText(ctx.getSessionId())
+                    ? Long.parseLong(ctx.getSessionId().trim()) : null;
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    /**
+     * 注入给 Agent 的意图分析块文本
+     */
+    private static String render(IntentResult intent) {
+        return "<intent-analysis>\n"
+                + "意图: " + intent.intent() + "(" + intent.intentLabel() + ")\n"
+                + "增强问题: " + intent.enhanced() + "\n"
+                + (intent.entities().isEmpty() ? "" : "关键实体: " + String.join(", ", intent.entities()) + "\n")
+                + "</intent-analysis>";
+    }
+
+    /**
+     * CustomEvent 携带的意图数据(前端渲染意图标签用)
+     */
+    private static Map<String, Object> frame(IntentResult intent) {
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("type", "intent");
+        data.put("intent", intent.intent());
+        data.put("intentLabel", intent.intentLabel());
+        data.put("enhanced", intent.enhanced());
+        data.put("entities", intent.entities());
+        return data;
+    }
+}

+ 24 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/intent/IntentProperties.java

@@ -0,0 +1,24 @@
+package com.zsjz.ai.module.agent.intent;
+
+import lombok.Data;
+
+/**
+ * 意图识别配置项
+ *
+ * <p>对应 application.yaml 中 {@code etl.intent} 配置节。
+ */
+@Data
+public class IntentProperties {
+
+    /** 是否启用前置意图识别管道 */
+    private boolean enabled = true;
+
+    /** LLM 调用超时秒数(超时即降级为原始问题直发) */
+    private int timeoutSeconds = 5;
+
+    /** 参与上下文增强的最近消息条数 */
+    private int historyTurns = 5;
+
+    /** LLM 输出 token 上限 */
+    private int maxTokens = 512;
+}

+ 14 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/intent/IntentResult.java

@@ -0,0 +1,14 @@
+package com.zsjz.ai.module.agent.intent;
+
+import java.util.List;
+
+/**
+ * 意图识别结果
+ *
+ * @param intent      意图 code({@link IntentEnum})
+ * @param intentLabel 中文标签(SSE 展示用)
+ * @param enhanced    增强后的问题(结合会话历史补全指代/省略)
+ * @param entities    关键实体(问题涉及的数据实体/业务概念)
+ */
+public record IntentResult(String intent, String intentLabel, String enhanced, List<String> entities) {
+}

+ 212 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/intent/IntentService.java

@@ -0,0 +1,212 @@
+package com.zsjz.ai.module.agent.intent;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.zsjz.ai.common.utils.Json;
+import com.zsjz.ai.module.agent.config.EtlProperties;
+import com.zsjz.ai.module.agent.entity.AgentMessage;
+import com.zsjz.ai.module.agent.entity.AgentModel;
+import com.zsjz.ai.module.agent.mapper.AgentMessageMapper;
+import com.zsjz.ai.module.agent.mapper.AgentModelMapper;
+import com.zsjz.ai.module.agent.service.AgentModelFactory;
+
+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 lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 用户问题意图识别与语义增强服务
+ *
+ * <p>在问题发给 Agent 之前,复用会话模型做一次轻量 LLM 调用:意图分类 + 结合会话历史
+ * 补全指代/省略的语义改写。全程 fail-open——任何异常/超时/解析失败均返回 null,
+ * 由调用方降级为原始问题直发,绝不阻塞主链路。
+ */
+@Slf4j
+@Service
+public class IntentService {
+
+    /** 单条历史消息参与上下文的截断长度 */
+    private static final int HISTORY_CONTENT_LIMIT = 300;
+
+    /** 实体列表上限(防止 LLM 输出超长) */
+    private static final int MAX_ENTITIES = 10;
+
+    private final AgentModelMapper agentModelMapper;
+    private final AgentMessageMapper agentMessageMapper;
+    private final AgentModelFactory agentModelFactory;
+    private final EtlProperties etlProperties;
+
+    public IntentService(AgentModelMapper agentModelMapper,
+                         AgentMessageMapper agentMessageMapper,
+                         AgentModelFactory agentModelFactory,
+                         EtlProperties etlProperties) {
+        this.agentModelMapper = agentModelMapper;
+        this.agentMessageMapper = agentMessageMapper;
+        this.agentModelFactory = agentModelFactory;
+        this.etlProperties = etlProperties;
+    }
+
+    /**
+     * 意图识别 + 语义增强(fail-open:任何异常/超时返回 null,不阻塞主链路)
+     *
+     * @param modelId   会话当前模型 ID
+     * @param sessionId 会话 ID(用于加载最近对话历史补全指代/省略)
+     * @param question 用户原始问题
+     * @return 识别结果;模型配置缺失/调用失败/超时/解析失败时返回 null
+     */
+    public IntentResult safeAnalyze(Long modelId, Long sessionId, String question) {
+        try {
+            AgentModel config = agentModelMapper.selectById(modelId);
+            if (config == null) {
+                return null;
+            }
+            io.agentscope.core.model.Model model = agentModelFactory.create(config);
+
+            List<Msg> msgs = List.of(
+                    Msg.builder().role(MsgRole.SYSTEM).textContent(buildSystemPrompt()).build(),
+                    Msg.builder().role(MsgRole.USER).textContent(buildUserPrompt(sessionId, question)).build());
+
+            StringBuilder sb = new StringBuilder();
+            model.stream(msgs, List.of(), GenerateOptions.builder()
+                            .temperature(0.1)
+                            .maxTokens(etlProperties.getIntent().getMaxTokens())
+                            .build())
+                    .doOnNext(resp -> {
+                        if (resp.getContent() != null) {
+                            resp.getContent().forEach(block -> {
+                                if (block instanceof TextBlock t && t.getText() != null) {
+                                    sb.append(t.getText());
+                                }
+                            });
+                        }
+                    })
+                    .blockLast(Duration.ofSeconds(etlProperties.getIntent().getTimeoutSeconds()));
+
+            return parse(sb.toString().trim(), question);
+        } catch (Exception e) {
+            log.warn("意图识别降级(fail-open): modelId={}, sessionId={}, error={}",
+                    modelId, sessionId, e.getMessage());
+            return null;
+        }
+    }
+
+    /**
+     * 意图识别系统提示词:5 类标签 + 严格 JSON 输出约束
+     */
+    private static String buildSystemPrompt() {
+        return "你是 Excel ETL 数据分析助手的问题理解模块,负责分析用户当前问题。\n"
+                + "将问题意图分类为以下标签之一:\n"
+                + "- data_query:数据查询/统计/聚合(需写 SQL 分析数据)\n"
+                + "- schema_explore:表结构探索(了解有哪些表/字段/含义)\n"
+                + "- etl_clean:数据清洗/转换/ETL 处理任务\n"
+                + "- visualization:图表/可视化需求\n"
+                + "- general_chat:通用问答/闲聊/与数据无关\n\n"
+                + "输出严格 JSON(不要输出任何其他内容,不要用 markdown 代码块包裹):\n"
+                + "{\"intent\":\"<上述标签之一>\",\"enhanced\":\"<语义增强后的问题>\",\"entities\":[\"<关键数据实体/业务概念>\"]}\n\n"
+                + "enhanced 规则:结合对话历史补全当前问题中的指代和省略(如\"那个表\"\"上次的结果\"具体指什么),"
+                + "保留用户原意,输出一句完整清晰的中文问题;无历史可参考时,将口语化表述改写为更完整清晰的表述。\n"
+                + "entities 规则:提取问题涉及的数据实体与业务概念(如:客户、手机号、订单金额),最多 10 个,没有则为空数组。";
+    }
+
+    /**
+     * 用户提示词:最近对话历史 + 当前问题
+     */
+    private String buildUserPrompt(Long sessionId, String question) {
+        StringBuilder sb = new StringBuilder();
+        List<AgentMessage> history = loadHistory(sessionId);
+        if (!history.isEmpty()) {
+            sb.append("[对话历史]\n");
+            for (AgentMessage m : history) {
+                String content = m.getContent();
+                if (content != null && content.length() > HISTORY_CONTENT_LIMIT) {
+                    content = content.substring(0, HISTORY_CONTENT_LIMIT) + "...";
+                }
+                sb.append("user".equals(m.getRole()) ? "user: " : "assistant: ")
+                        .append(content).append('\n');
+            }
+            sb.append('\n');
+        }
+        sb.append("[当前问题]\n").append(question);
+        return sb.toString();
+    }
+
+    /**
+     * 加载最近 N 条会话消息(时间正序返回;排除当前正在提问的消息——历史均在本次落库前查询)
+     */
+    private List<AgentMessage> loadHistory(Long sessionId) {
+        if (sessionId == null || etlProperties.getIntent().getHistoryTurns() <= 0) {
+            return Collections.emptyList();
+        }
+        try {
+            List<AgentMessage> list = agentMessageMapper.selectList(
+                    new LambdaQueryWrapper<AgentMessage>()
+                            .eq(AgentMessage::getSessionId, sessionId)
+                            .orderByDesc(AgentMessage::getCreateAt)
+                            .last("LIMIT " + etlProperties.getIntent().getHistoryTurns()));
+            Collections.reverse(list);
+            return list;
+        } catch (Exception e) {
+            log.warn("加载会话历史失败(跳过上下文增强): sessionId={}, error={}", sessionId, e.getMessage());
+            return Collections.emptyList();
+        }
+    }
+
+    /**
+     * 宽松解析 LLM 输出:剥 markdown 围栏 → 截取首尾大括号 → Jackson 解析 → 标签校验回落
+     */
+    private IntentResult parse(String raw, String question) {
+        if (!StringUtils.hasText(raw)) {
+            return null;
+        }
+        String json = extractJson(raw);
+        if (json == null) {
+            log.warn("意图识别输出非 JSON(降级): {}", abbreviate(raw));
+            return null;
+        }
+        try {
+            Map<String, Object> map = Json.objectMapper().readValue(json, Map.class);
+            IntentEnum intent = IntentEnum.fromCode((String) map.get("intent"));
+
+            String enhanced = map.get("enhanced") instanceof String s && StringUtils.hasText(s)
+                    ? s.trim() : question;
+
+            List<String> entities = new ArrayList<>();
+            if (map.get("entities") instanceof List<?> list) {
+                for (Object item : list) {
+                    if (item instanceof String s && StringUtils.hasText(s) && entities.size() < MAX_ENTITIES) {
+                        entities.add(s.trim());
+                    }
+                }
+            }
+            return new IntentResult(intent.getCode(), intent.getLabel(), enhanced, entities);
+        } catch (Exception e) {
+            log.warn("意图识别结果解析失败(降级): error={}", e.getMessage());
+            return null;
+        }
+    }
+
+    /**
+     * 提取 JSON 主体:兼容 ```json 围栏与前后多余文本
+     */
+    private static String extractJson(String raw) {
+        int start = raw.indexOf('{');
+        int end = raw.lastIndexOf('}');
+        if (start < 0 || end <= start) {
+            return null;
+        }
+        return raw.substring(start, end + 1);
+    }
+
+    private static String abbreviate(String s) {
+        return s.length() > 120 ? s.substring(0, 120) + "..." : s;
+    }
+}

+ 12 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/mapper/AgentChatSessionMapper.java

@@ -0,0 +1,12 @@
+package com.zsjz.ai.module.agent.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.zsjz.ai.module.agent.entity.AgentChatSession;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 会话记录 Mapper
+ */
+@Mapper
+public interface AgentChatSessionMapper extends BaseMapper<AgentChatSession> {
+}

+ 9 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/mapper/AgentMapper.java

@@ -0,0 +1,9 @@
+package com.zsjz.ai.module.agent.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.zsjz.ai.module.agent.entity.AgentEntity;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface AgentMapper extends BaseMapper<AgentEntity> {
+}

+ 12 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/mapper/AgentMessageMapper.java

@@ -0,0 +1,12 @@
+package com.zsjz.ai.module.agent.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.zsjz.ai.module.agent.entity.AgentMessage;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 消息 Mapper
+ */
+@Mapper
+public interface AgentMessageMapper extends BaseMapper<AgentMessage> {
+}

+ 12 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/mapper/AgentModelMapper.java

@@ -0,0 +1,12 @@
+package com.zsjz.ai.module.agent.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.zsjz.ai.module.agent.entity.AgentModel;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 模型配置 Mapper
+ */
+@Mapper
+public interface AgentModelMapper extends BaseMapper<AgentModel> {
+}

+ 12 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/mapper/AgentModelProviderMapper.java

@@ -0,0 +1,12 @@
+package com.zsjz.ai.module.agent.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.zsjz.ai.module.agent.entity.AgentModelProvider;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 模型厂商 Mapper
+ */
+@Mapper
+public interface AgentModelProviderMapper extends BaseMapper<AgentModelProvider> {
+}

+ 12 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/mapper/RagIndexStatusMapper.java

@@ -0,0 +1,12 @@
+package com.zsjz.ai.module.agent.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.zsjz.ai.module.agent.entity.RagIndexStatus;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * RAG 索引状态 Mapper
+ */
+@Mapper
+public interface RagIndexStatusMapper extends BaseMapper<RagIndexStatus> {
+}

+ 57 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/mapper/RagVectorMapper.java

@@ -0,0 +1,57 @@
+package com.zsjz.ai.module.agent.mapper;
+
+import org.apache.ibatis.annotations.*;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * RAG pgvector 向量表 Mapper(原生 SQL:动态表名 + pgvector/jsonb 类型转换)
+ *
+ * <p>表名为服务层生成的 {@code rag_ws{workspaceId}_d{dims}},向量字面量为
+ * {@code [0.1,0.2,...]} 字符串,由 PG 端 {@code ::vector}/{@code ::jsonb} 转换。
+ */
+@Mapper
+public interface RagVectorMapper {
+
+    /**
+     * 创建向量表:doc_id 主键 + 原文 + 结构化 payload + embedding 向量列
+     */
+    @Update("CREATE TABLE public.${tableName} ("
+            + "doc_id VARCHAR(128) PRIMARY KEY, "
+            + "content TEXT, "
+            + "payload JSONB, "
+            + "embedding vector(${dims}))")
+    void createVectorTable(@Param("tableName") String tableName, @Param("dims") int dims);
+
+    /**
+     * 写入单条表结构文档向量
+     */
+    @Insert("INSERT INTO public.${tableName} (doc_id, content, payload, embedding) "
+            + "VALUES (#{docId}, #{content}, #{payloadJson}::jsonb, #{embedding}::vector)")
+    void insertVectorDoc(@Param("tableName") String tableName,
+                         @Param("docId") String docId,
+                         @Param("content") String content,
+                         @Param("payloadJson") String payloadJson,
+                         @Param("embedding") String embedding);
+
+    /**
+     * cosine 相似度检索:score = 1 - 距离,阈值过滤 + top-k
+     *
+     * <p>payload 用 {@code ::text} 输出避免 jsonb 类型映射问题。
+     */
+    @Select("SELECT payload::text AS payload, 1 - (embedding <=> #{embedding}::vector) AS score "
+            + "FROM public.${tableName} "
+            + "WHERE 1 - (embedding <=> #{embedding}::vector) >= #{threshold} "
+            + "ORDER BY score DESC LIMIT #{limit}")
+    List<Map<String, Object>> searchVector(@Param("tableName") String tableName,
+                                           @Param("embedding") String embedding,
+                                           @Param("threshold") double threshold,
+                                           @Param("limit") int limit);
+
+    /**
+     * 删除向量表
+     */
+    @Update("DROP TABLE IF EXISTS public.${tableName}")
+    void dropVectorTable(@Param("tableName") String tableName);
+}

+ 27 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/mapper/SqlQueryMapper.java

@@ -0,0 +1,27 @@
+package com.zsjz.ai.module.agent.mapper;
+
+import com.baomidou.dynamic.datasource.annotation.DS;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+
+/**
+ * DuckDB 只读查询 Mapper(SQL 数据分析工具用)
+ *
+ * <p>动态 SQL 直接拼接({@code ${sql}}),语句安全性由
+ * {@code SqlAnalysisTool} 的只读白名单校验保证。
+ * 返回 {@link LinkedHashMap} 保证列顺序稳定。
+ */
+@DS("slave")
+@Mapper
+public interface SqlQueryMapper {
+
+    /**
+     * 执行只读查询语句(白名单校验后放行)
+     */
+    @Select("${sql}")
+    List<LinkedHashMap<String, Object>> executeQuery(@Param("sql") String sql);
+}

+ 284 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/python/PythonExecutor.java

@@ -0,0 +1,284 @@
+package com.zsjz.ai.module.agent.python;
+
+import com.zsjz.ai.common.constants.PathConst;
+import com.zsjz.ai.common.model.plat.entity.CaseInfo;
+import com.zsjz.ai.common.utils.StateManager;
+import com.zsjz.ai.module.agent.config.EtlProperties;
+
+import com.zsjz.ai.module.plat.mapper.CaseInfoMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Stream;
+
+/**
+ * Python 子进程沙箱执行器
+ *
+ * <p>每次执行使用独立临时目录({@code data/workspace/{wsId}/tmp/py-{execId}}),
+ * 通过环境变量 {@code ETL_DB_PATH} 注入工作空间 DuckDB 文件路径(Python 内只读连接),
+ * 超时强制终止、输出截断;执行后将沙箱内生成的图片移动到
+ * {@code data/workspace/{wsId}/py-out/{execId}} 供文件服务接口输出。
+ */
+@Slf4j
+@Service
+public class PythonExecutor {
+
+    /**
+     * 依赖检测 import 语句
+     */
+    private static final String CHECK_IMPORTS = "import pandas, duckdb, matplotlib";
+
+    /**
+     * 环境注入的 DuckDB 文件路径变量名
+     */
+    public static final String ENV_DB_PATH = "ETL_DB_PATH";
+
+    /**
+     * Python 图片 URL 前缀(PythonFileController 匹配)
+     */
+    public static final String IMAGE_URL_PREFIX = "/api/v1/py/files/";
+
+    private final EtlProperties etlProperties;
+    private final CaseInfoMapper caseInfoMapper;
+
+    public PythonExecutor(EtlProperties etlProperties, CaseInfoMapper caseInfoMapper) {
+        this.etlProperties = etlProperties;
+        this.caseInfoMapper = caseInfoMapper;
+    }
+
+    /**
+     * 依赖检测缓存(null = 未检测;true/false = 检测结果)
+     */
+    private volatile Boolean available;
+
+    /**
+     * 环境就绪检测:python 可用且 pandas/duckdb/matplotlib 可导入(结果缓存)
+     */
+    public boolean isAvailable() {
+        if (!etlProperties.getPython().isEnabled()) {
+            return false;
+        }
+        if (available == null) {
+            synchronized (this) {
+                if (available == null) {
+                    available = detect();
+                }
+            }
+        }
+        return available;
+    }
+
+    /**
+     * 执行 Python 代码(子进程沙箱)
+     *
+     * @param code Python 3 源码
+     * @return 执行结果(exitCode/stdout/stderr/imageUrls/durationMs/error)
+     */
+    public PythonResult execute(String code) {
+        long start = System.currentTimeMillis();
+        if (!isAvailable()) {
+            return PythonResult.error("Python 执行环境不可用(未安装或缺少依赖:pip install pandas duckdb matplotlib)");
+        }
+        Integer workspaceId = StateManager.instance().getCaseId();
+        CaseInfo caseInfo = caseInfoMapper.selectById(workspaceId);
+        String dbFile = caseInfo.getDbPath();
+        if (dbFile == null) {
+            return PythonResult.error("工作空间数据库不存在或未初始化: workspaceId=" + workspaceId);
+        }
+
+        String execId = UUID.randomUUID().toString();
+        Path sandbox = PathConst.WORKSPACE.resolve(String.valueOf(workspaceId))
+                .resolve("tmp").resolve("py-" + execId);
+        try {
+            Files.createDirectories(sandbox);
+            Path mainPy = sandbox.resolve("main.py");
+            Files.writeString(mainPy, code);
+
+            ProcessBuilder pb = new ProcessBuilder(etlProperties.getPython().getCommand(), "main.py")
+                    .directory(sandbox.toFile());
+            pb.environment().put(ENV_DB_PATH, dbFile);
+            pb.environment().put("MPLBACKEND", "Agg"); // matplotlib 无头渲染
+
+            Process process = pb.start();
+            OutputCollector stdout = new OutputCollector(process.getInputStream(),
+                    etlProperties.getPython().getMaxOutputChars());
+            OutputCollector stderr = new OutputCollector(process.getErrorStream(),
+                    etlProperties.getPython().getMaxOutputChars());
+            stdout.start();
+            stderr.start();
+
+            boolean finished = process.waitFor(etlProperties.getPython().getTimeoutSeconds(), TimeUnit.SECONDS);
+            if (!finished) {
+                process.destroyForcibly();
+                process.waitFor(5, TimeUnit.SECONDS);
+                return PythonResult.error("执行超时(" + etlProperties.getPython().getTimeoutSeconds()
+                        + " 秒)已终止,请优化代码或减少数据量");
+            }
+            stdout.join(5000);
+            stderr.join(5000);
+
+            List<String> imageUrls = collectImages(workspaceId, execId, sandbox);
+            long durationMs = System.currentTimeMillis() - start;
+            log.info("Python 执行完成: workspaceId={}, execId={}, exitCode={}, images={}, costMs={}",
+                    workspaceId, execId, process.exitValue(), imageUrls.size(), durationMs);
+            return new PythonResult(process.exitValue(), stdout.getContent(), stderr.getContent(),
+                    imageUrls, durationMs, null);
+        } catch (IOException e) {
+            return PythonResult.error("Python 进程启动失败: " + e.getMessage());
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            return PythonResult.error("执行被中断: " + e.getMessage());
+        } finally {
+            deleteRecursively(sandbox);
+        }
+    }
+
+    /**
+     * 执行结果 record(error 非空表示未正常执行)
+     */
+    public record PythonResult(int exitCode, String stdout, String stderr,
+                               List<String> imageUrls, long durationMs, String error) {
+
+        static PythonResult error(String message) {
+            return new PythonResult(-1, "", "", List.of(), 0, message);
+        }
+    }
+
+    // ==================== 内部实现 ====================
+
+    /**
+     * 检测 Python 与三方库可用性(结果缓存;失败打印安装提示)
+     */
+    private boolean detect() {
+        try {
+            Process process = new ProcessBuilder(etlProperties.getPython().getCommand(), "-c", CHECK_IMPORTS).start();
+            if (!process.waitFor(30, TimeUnit.SECONDS)) {
+                process.destroyForcibly();
+                process.waitFor(5, TimeUnit.SECONDS);
+                log.warn("Python 依赖检测超时(execute_python 工具将不注册): command={}",
+                        etlProperties.getPython().getCommand());
+                return false;
+            }
+            if (process.exitValue() == 0) {
+                log.info("Python 执行环境就绪: command={}", etlProperties.getPython().getCommand());
+                return true;
+            }
+            log.warn("Python 执行环境不可用(command={},需安装依赖: pip install pandas duckdb matplotlib),"
+                    + "execute_python 工具将不注册", etlProperties.getPython().getCommand());
+            return false;
+        } catch (Exception e) {
+            log.warn("Python 检测失败(execute_python 工具将不注册): command={}, error={}",
+                    etlProperties.getPython().getCommand(), e.getMessage());
+            return false;
+        }
+    }
+
+    /**
+     * 捕获沙箱内图片:移动到 py-out/{execId}/ 并生成访问 URL,LRU 清理超额执行目录
+     */
+    private List<String> collectImages(int workspaceId, String execId, Path sandbox) {
+        List<String> urls = new ArrayList<>();
+        try (Stream<Path> files = Files.list(sandbox)) {
+            List<Path> images = files.filter(p -> p.getFileName().toString().toLowerCase().endsWith(".png"))
+                    .sorted(Comparator.comparing(p -> p.getFileName().toString()))
+                    .limit(etlProperties.getPython().getMaxImages())
+                    .toList();
+            if (images.isEmpty()) {
+                return urls;
+            }
+            Path outDir = PathConst.WORKSPACE.resolve(String.valueOf(workspaceId))
+                    .resolve("py-out").resolve(execId);
+            Files.createDirectories(outDir);
+            for (Path img : images) {
+                Files.move(img, outDir.resolve(img.getFileName()));
+                urls.add(IMAGE_URL_PREFIX + workspaceId + "/" + execId + "/"
+                        + img.getFileName());
+            }
+            evictOldExecs(PathConst.WORKSPACE.resolve(String.valueOf(workspaceId)).resolve("py-out"));
+        } catch (IOException e) {
+            log.warn("Python 图片捕获失败: workspaceId={}, execId={}, error={}",
+                    workspaceId, execId, e.getMessage());
+        }
+        return urls;
+    }
+
+    /**
+     * LRU 清理 py-out 超额执行目录
+     */
+    private void evictOldExecs(Path pyOutRoot) {
+        try (Stream<Path> dirs = Files.list(pyOutRoot)) {
+            List<Path> execDirs = dirs.sorted(Comparator.comparingLong(
+                            p -> p.toFile().lastModified()))
+                    .toList();
+            int excess = execDirs.size() - etlProperties.getPython().getMaxHistoryExecs();
+            for (int i = 0; i < excess; i++) {
+                deleteRecursively(execDirs.get(i));
+            }
+        } catch (IOException e) {
+            log.debug("py-out 清理失败: {}, error={}", pyOutRoot, e.getMessage());
+        }
+    }
+
+    /**
+     * 递归删除目录(沙箱清理,失败仅告警)
+     */
+    private static void deleteRecursively(Path dir) {
+        if (!Files.exists(dir)) {
+            return;
+        }
+        try (Stream<Path> walk = Files.walk(dir)) {
+            walk.sorted(Comparator.reverseOrder()).forEach(p -> {
+                try {
+                    Files.delete(p);
+                } catch (IOException ignored) {
+                    // 清理失败不影响主流程
+                }
+            });
+        } catch (IOException e) {
+            log.debug("目录清理失败: {}, error={}", dir, e.getMessage());
+        }
+    }
+
+    /**
+     * 进程输出流采集线程(截断到 maxChars)
+     */
+    private static class OutputCollector extends Thread {
+
+        private final java.io.InputStream in;
+        private final int maxChars;
+        private final StringBuilder sb = new StringBuilder();
+
+        OutputCollector(java.io.InputStream in, int maxChars) {
+            this.in = in;
+            this.maxChars = maxChars;
+            setDaemon(true);
+        }
+
+        @Override
+        public void run() {
+            try (java.io.BufferedReader reader = new java.io.BufferedReader(
+                    new java.io.InputStreamReader(in, java.nio.charset.StandardCharsets.UTF_8))) {
+                int c;
+                while ((c = reader.read()) != -1) {
+                    if (sb.length() < maxChars) {
+                        sb.append((char) c);
+                    }
+                }
+            } catch (IOException ignored) {
+                // 进程终止导致的流关闭,忽略
+            }
+        }
+
+        String getContent() {
+            return sb.toString();
+        }
+    }
+}

+ 30 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/python/PythonProperties.java

@@ -0,0 +1,30 @@
+package com.zsjz.ai.module.agent.python;
+
+import lombok.Data;
+
+/**
+ * Python 执行配置项
+ *
+ * <p>对应 application.yaml 中 {@code etl.python} 配置节。
+ */
+@Data
+public class PythonProperties {
+
+    /** 是否启用 Python 工具 */
+    private boolean enabled = true;
+
+    /** Python 解释器命令(可配置绝对路径,如 python3 / D:\envs\etl\python.exe) */
+    private String command = "python";
+
+    /** 执行超时秒数(超时强制终止进程) */
+    private int timeoutSeconds = 60;
+
+    /** stdout/stderr 截断字符上限 */
+    private int maxOutputChars = 20000;
+
+    /** 单次执行最多捕获图片数 */
+    private int maxImages = 4;
+
+    /** 图片输出目录保留最近执行数(LRU 清理) */
+    private int maxHistoryExecs = 20;
+}

+ 183 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/rag/EmbeddingModelFactory.java

@@ -0,0 +1,183 @@
+package com.zsjz.ai.module.agent.rag;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.zsjz.ai.common.enums.StatusEnum;
+import com.zsjz.ai.common.exception.ServerException;
+import com.zsjz.ai.common.utils.Json;
+import com.zsjz.ai.module.agent.entity.AgentModel;
+import com.zsjz.ai.module.agent.mapper.AgentModelMapper;
+
+import io.agentscope.core.embedding.EmbeddingModel;
+import io.agentscope.core.embedding.dashscope.DashScopeTextEmbedding;
+import io.agentscope.core.embedding.ollama.OllamaTextEmbedding;
+import io.agentscope.core.embedding.openai.OpenAITextEmbedding;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+import java.util.Map;
+
+/**
+ * 嵌入模型工厂:DB 模型配置(model 表 type='embedding')→ agentscope EmbeddingModel 实例
+ *
+ * <p>支持三种 provider(归一化规则沿用 {@link com.zsjz.ai.module.agent.service.AgentModelFactory}):
+ * <ul>
+ *   <li>含 "ollama" → {@link OllamaTextEmbedding}(本地端点,无需 apiKey)</li>
+ *   <li>含 "dashscope"/"阿里"/"通义"/"qwen" → {@link DashScopeTextEmbedding}(原生协议)</li>
+ *   <li>其余 → {@link OpenAITextEmbedding}(OpenAI 兼容协议,靠 baseUrl 指向各兼容端点)</li>
+ * </ul>
+ *
+ * <p>维度约定:从 {@code model.config} JSON 的 {@code dimensions} 字段读取,缺省 1024。
+ * Ollama 模型维度因模型而异,强烈建议显式配置。
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class EmbeddingModelFactory {
+
+    private static final int DEFAULT_DIMENSIONS = 1024;
+
+    private final AgentModelMapper agentModelMapper;
+
+    /**
+     * 嵌入模型规格:模型配置 + 构建好的 Embedding 实例 + 向量维度
+     */
+    public record EmbeddingSpec(Long modelId, String modelName, EmbeddingModel model, int dimensions) {
+    }
+
+    /**
+     * 解析默认嵌入模型:type='embedding' 且 default_model=true 优先,否则任取一个活跃记录。
+     *
+     * @return 嵌入模型规格;未配置任何 embedding 模型时返回 null(不抛异常)
+     */
+    public EmbeddingSpec resolveDefault() {
+        try {
+            AgentModel config = agentModelMapper.selectOne(
+                    new LambdaQueryWrapper<AgentModel>()
+                            .eq(AgentModel::getType, "embedding")
+                            .eq(AgentModel::getStatus, StatusEnum.ACTIVE)
+                            .orderByDesc(AgentModel::getDefaultModel)
+                            .last("LIMIT 1"));
+            if (config == null) {
+                return null;
+            }
+            return create(config);
+        } catch (Exception e) {
+            log.warn("解析默认嵌入模型失败: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    /**
+     * 按模型ID构建嵌入实例(用于按索引状态记录恢复检索所用模型)
+     *
+     * @param modelId model.id
+     * @return 嵌入模型规格;模型不存在时抛出 BusinessException
+     */
+    public EmbeddingSpec createById(Long modelId) {
+        AgentModel config = agentModelMapper.selectById(modelId);
+        if (config == null) {
+            throw new ServerException(404, "嵌入模型不存在: " + modelId);
+        }
+        return create(config);
+    }
+
+    /**
+     * 根据模型配置构建 Embedding 实例
+     *
+     * @param config 模型配置(model 表记录,type 应为 'embedding')
+     * @return 嵌入模型规格
+     * @throws ServerException 配置不完整或构建失败时
+     */
+    public EmbeddingSpec create(AgentModel config) {
+        if (config == null) {
+            throw new ServerException(400, "嵌入模型配置不存在");
+        }
+        if (!StringUtils.hasText(config.getModelId())) {
+            throw new ServerException(400, "嵌入模型配置不完整(缺少模型ID): " + config.getName());
+        }
+        int dimensions = parseDimensions(config);
+        String provider = normalizeProvider(config.getProvider());
+
+        EmbeddingModel model = switch (provider) {
+            case "ollama" -> buildOllama(config, dimensions);
+            case "dashscope" -> buildDashScope(config, dimensions);
+            default -> buildOpenAiCompatible(config, dimensions);
+        };
+        log.info("构建嵌入模型: provider={}, modelId={}, dimensions={}", provider, config.getModelId(), dimensions);
+        return new EmbeddingSpec(config.getId(), config.getName(), model, dimensions);
+    }
+
+    private EmbeddingModel buildOllama(AgentModel config, int dimensions) {
+        OllamaTextEmbedding.Builder b = OllamaTextEmbedding.builder()
+                .modelName(config.getModelId().trim())
+                .dimensions(dimensions);
+        if (StringUtils.hasText(config.getBaseUrl())) {
+            b.baseUrl(config.getBaseUrl().trim());
+        }
+        return b.build();
+    }
+
+    private EmbeddingModel buildDashScope(AgentModel config, int dimensions) {
+        if (!StringUtils.hasText(config.getApiKey())) {
+            throw new ServerException(400, "嵌入模型配置不完整(缺少API密钥): " + config.getName());
+        }
+        DashScopeTextEmbedding.Builder b = DashScopeTextEmbedding.builder()
+                .apiKey(config.getApiKey().trim())
+                .modelName(config.getModelId().trim())
+                .dimensions(dimensions);
+        if (StringUtils.hasText(config.getBaseUrl())) {
+            b.baseUrl(config.getBaseUrl().trim());
+        }
+        return b.build();
+    }
+
+    private EmbeddingModel buildOpenAiCompatible(AgentModel config, int dimensions) {
+        if (!StringUtils.hasText(config.getApiKey())) {
+            throw new ServerException(400, "嵌入模型配置不完整(缺少API密钥): " + config.getName());
+        }
+        OpenAITextEmbedding.Builder b = OpenAITextEmbedding.builder()
+                .apiKey(config.getApiKey().trim())
+                .modelName(config.getModelId().trim())
+                .dimensions(dimensions);
+        if (StringUtils.hasText(config.getBaseUrl())) {
+            b.baseUrl(config.getBaseUrl().trim());
+        }
+        return b.build();
+    }
+
+    /**
+     * 从 model.config JSON 解析 dimensions 字段
+     */
+    private int parseDimensions(AgentModel config) {
+        if (StringUtils.hasText(config.getConfig())) {
+            try {
+                Map<String, Object> cfg = Json.objectMapper().readValue(config.getConfig(), Map.class);
+                Object dims = cfg != null ? cfg.get("dimensions") : null;
+                if (dims instanceof Number n && n.intValue() > 0) {
+                    return n.intValue();
+                }
+            } catch (Exception e) {
+                log.warn("解析嵌入模型 dimensions 失败,使用默认值 {}: {}", DEFAULT_DIMENSIONS, e.getMessage());
+            }
+        }
+        return DEFAULT_DIMENSIONS;
+    }
+
+    /**
+     * provider 归一化:ollama / dashscope / openai
+     */
+    private String normalizeProvider(String provider) {
+        if (StringUtils.hasText(provider)) {
+            String p = provider.trim().toLowerCase();
+            if (p.contains("ollama")) {
+                return "ollama";
+            }
+            if (p.contains("dashscope") || p.contains("阿里") || p.contains("通义") || p.contains("qwen")) {
+                return "dashscope";
+            }
+        }
+        return "openai";
+    }
+}

+ 24 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/rag/RagProperties.java

@@ -0,0 +1,24 @@
+package com.zsjz.ai.module.agent.rag;
+
+import lombok.Data;
+
+/**
+ * RAG 配置项
+ *
+ * <p>对应 application.yaml 中 {@code etl.rag} 配置节。
+ */
+@Data
+public class RagProperties {
+
+    /** 是否为 Agent 注册表结构检索工具 */
+    private boolean enabled = true;
+
+    /** 工作空间切换后是否自动后台重建索引 */
+    private boolean autoIndexOnSwitch = true;
+
+    /** 检索默认返回条数 */
+    private int topK = 5;
+
+    /** 相似度阈值(cosine 转换后的 score,低于该值的结果被过滤) */
+    private double scoreThreshold = 0.3;
+}

+ 477 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/rag/RagSchemaService.java

@@ -0,0 +1,477 @@
+package com.zsjz.ai.module.agent.rag;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.zsjz.ai.common.enums.HasInnerEnum;
+import com.zsjz.ai.common.exception.ServerException;
+import com.zsjz.ai.common.model.plat.entity.TableField;
+import com.zsjz.ai.common.model.plat.entity.TableInfo;
+import com.zsjz.ai.common.utils.Json;
+import com.zsjz.ai.module.agent.config.EtlProperties;
+import com.zsjz.ai.module.agent.entity.RagIndexStatus;
+import com.zsjz.ai.module.agent.mapper.RagIndexStatusMapper;
+import com.zsjz.ai.module.agent.mapper.RagVectorMapper;
+import com.zsjz.ai.module.agent.rag.EmbeddingModelFactory.EmbeddingSpec;
+
+import com.zsjz.ai.module.agent.vo.RagIndexStatusVO;
+import com.zsjz.ai.module.agent.vo.RagSearchResultVO;
+import com.zsjz.ai.module.plat.mapper.TableFieldMapper;
+import com.zsjz.ai.module.plat.mapper.TableInfoMapper;
+import io.agentscope.core.message.TextBlock;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.stream.Collectors;
+
+/**
+ * RAG 表结构向量服务:模板元数据 → 嵌入 → 主数据源 pgvector 存储与检索
+ *
+ * <p>元数据来源(全部 master PG,不依赖 DuckDB 系统表):
+ * <ul>
+ *   <li>{@code meta_raw_sheet}:按 workspace_id 过滤,得到工作空间内已匹配模板的表
+ *       (template_id 非空),行数按同模板多 sheet 求和</li>
+ *   <li>{@code table_info}:表英文名(DuckDB 物理表名)+ 表中文名</li>
+ *   <li>{@code table_field}:字段英文名 + 字段中文名(注释)+ 类型 + 是否必填</li>
+ * </ul>
+ *
+ * <p>嵌入仍使用未弃用的 {@code io.agentscope.core.embedding.*},向量存取由本服务通过
+ * JDBC 直接管理 pgvector 表(表名 rag_ws{workspaceId}_d{dims},列为
+ * doc_id/content/payload jsonb/embedding vector(dims))。
+ *
+ * <p>索引策略:结构指纹(SHA-256,不含行数)相同且嵌入模型未变时跳过重建;
+ * 重建采用 DROP + 全量写入。
+ */
+@Slf4j
+@Service
+public class RagSchemaService {
+
+    private final EmbeddingModelFactory embeddingModelFactory;
+    private final RagIndexStatusMapper ragIndexStatusMapper;
+    private final TableInfoMapper tableInfoMapper;
+    private final TableFieldMapper tableFieldMapper;
+    private final RagVectorMapper ragVectorMapper;
+    private final EtlProperties etlProperties;
+
+    /**
+     * 索引重建防重入标记
+     */
+    private final Set<Long> indexing = ConcurrentHashMap.newKeySet();
+
+    /**
+     * 后台索引单线程池(守护线程,不阻塞应用关闭)
+     */
+    private final ExecutorService indexExecutor = Executors.newSingleThreadExecutor(r -> {
+        Thread t = new Thread(r, "rag-index");
+        t.setDaemon(true);
+        return t;
+    });
+
+    public RagSchemaService(EmbeddingModelFactory embeddingModelFactory,
+                            RagIndexStatusMapper ragIndexStatusMapper,
+                            TableInfoMapper tableInfoMapper,
+                            TableFieldMapper tableFieldMapper,
+                            RagVectorMapper ragVectorMapper,
+                            EtlProperties etlProperties) {
+        this.embeddingModelFactory = embeddingModelFactory;
+        this.ragIndexStatusMapper = ragIndexStatusMapper;
+        this.tableInfoMapper = tableInfoMapper;
+        this.tableFieldMapper = tableFieldMapper;
+        this.ragVectorMapper = ragVectorMapper;
+        this.etlProperties = etlProperties;
+    }
+
+    /**
+     * 工作空间切换事件:后台异步重建索引(指纹相同自动跳过)
+     */
+    @EventListener
+    public void onWorkspaceSwitched(WorkspaceSwitchedEvent event) {
+        if (!etlProperties.getRag().isEnabled() || !etlProperties.getRag().isAutoIndexOnSwitch()) {
+            return;
+        }
+        Long workspaceId = event.workspaceId();
+        log.info("工作空间切换,触发后台 RAG 索引: workspaceId={}", workspaceId);
+        indexExecutor.submit(() -> {
+            try {
+                reindexWorkspace(workspaceId);
+            } catch (Exception e) {
+                log.warn("后台 RAG 索引失败: workspaceId={}, error={}", workspaceId, e.getMessage());
+            }
+        });
+    }
+
+    /**
+     * RAG 能力是否可用(配置开关开启且存在 embedding 模型)
+     */
+    public boolean isAvailable() {
+        return etlProperties.getRag().isEnabled() && embeddingModelFactory.resolveDefault() != null;
+    }
+
+    /**
+     * 查询工作空间索引状态
+     */
+    public RagIndexStatusVO getStatus(Long workspaceId) {
+        RagIndexStatus status = ragIndexStatusMapper.selectById(workspaceId);
+        if (status == null) {
+            RagIndexStatusVO vo = new RagIndexStatusVO();
+            vo.setWorkspaceId(workspaceId);
+            vo.setStatus(RagIndexStatus.STATUS_IDLE);
+            vo.setTableCount(0);
+            return vo;
+        }
+        return convertStatusToVO(status);
+    }
+
+    /**
+     * 重建工作空间表结构索引(同步,全量)
+     *
+     * <p>结构指纹与嵌入模型均未变化时直接返回;否则 DROP 向量表后全量重建。
+     * 同一工作空间重建互斥。只读 master 元数据表,不影响当前 slave(DuckDB)指向。
+     */
+    public RagIndexStatusVO reindexWorkspace(Long workspaceId) {
+        EmbeddingSpec spec = embeddingModelFactory.resolveDefault();
+        if (spec == null) {
+            throw new ServerException(400, "未配置嵌入模型(model 表需存在 type='embedding' 的活跃记录)");
+        }
+        if (!indexing.add(workspaceId)) {
+            throw new ServerException(409, "该工作空间正在构建索引,请稍后再试");
+        }
+        try {
+            return doReindex(workspaceId, spec);
+        } finally {
+            indexing.remove(workspaceId);
+        }
+    }
+
+    private RagIndexStatusVO doReindex(Long workspaceId, EmbeddingSpec spec) {
+        List<WorkspaceTable> tables = tableInfoMapper.selectList(Wrappers.<TableInfo>lambdaQuery().eq(TableInfo::getHasTable, HasInnerEnum.YES))
+                .stream()
+                .map(t -> new WorkspaceTable(t.getId(), t.getTableNameEn(), t.getTableNameCn()))
+                .toList();
+
+        List<TableField> fields = tableFieldMapper.selectList(new LambdaQueryWrapper<TableField>()
+                .in(TableField::getTableId, tables.stream().map(WorkspaceTable::id).toList())
+                .orderByAsc(TableField::getFieldSort));
+        String fingerprint = fingerprint(tables, fields);
+
+        upsertStatus(workspaceId, RagIndexStatus.STATUS_INDEXING, spec, fingerprint, tables.size(), null);
+        try {
+            String vectorTable = vectorTableName(workspaceId, spec.dimensions());
+            dropVectorTable(vectorTable);
+            List<SchemaDoc> docs = buildDocuments(workspaceId, tables, fields);
+            writeToVectorTable(vectorTable, spec, docs);
+            RagIndexStatus done = upsertStatus(workspaceId, RagIndexStatus.STATUS_SUCCESS, spec,
+                    fingerprint, tables.size(), null);
+            log.info("RAG 表结构索引完成: workspaceId={}, tables={}, dims={}, vectorTable={}",
+                    workspaceId, tables.size(), spec.dimensions(), vectorTable);
+            return convertStatusToVO(done);
+        } catch (Exception e) {
+            log.error("RAG 表结构索引失败: workspaceId={}, error={}", workspaceId, e.getMessage(), e);
+            upsertStatus(workspaceId, RagIndexStatus.STATUS_FAILED, spec, fingerprint, tables.size(),
+                    e.getMessage());
+            throw new ServerException(500, "RAG 索引构建失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 语义检索工作空间表结构:嵌入查询文本后按 cosine 相似度取 top-k
+     *
+     * <p>嵌入模型取索引状态记录的模型(保证与已建索引一致);索引未就绪时返回 409。
+     */
+    public List<RagSearchResultVO> search( String query, Integer topK) {
+        int limit = (topK != null && topK > 0) ? topK : etlProperties.getRag().getTopK();
+
+        RagIndexStatus status = ragIndexStatusMapper.selectOne(Wrappers.emptyWrapper(),  false);
+        if (status == null || !RagIndexStatus.STATUS_SUCCESS.equals(status.getStatus())) {
+            throw new ServerException(409, "该工作空间表结构索引不可用(状态: "
+                    + (status == null ? "未构建" : status.getStatus()) + "),请先重建或等待构建完成");
+        }
+        EmbeddingSpec spec = embeddingModelFactory.createById(status.getEmbeddingModelId());
+
+        double[] queryVector = embed(spec, query);
+        String literal = toVectorLiteral(queryVector);
+        try {
+            List<Map<String, Object>> rows = ragVectorMapper.searchVector(status.getVectorTable(),
+                    literal, etlProperties.getRag().getScoreThreshold(), limit);
+            List<RagSearchResultVO> results = new ArrayList<>(rows.size());
+            for (Map<String, Object> row : rows) {
+                Object payload = row.get("payload");
+                Object score = row.get("score");
+                results.add(convertSearchResult(payload == null ? null : payload.toString(),
+                        score instanceof Number n ? n.doubleValue() : 0.0));
+            }
+            return results;
+        } catch (Exception e) {
+            throw new ServerException(500, "表结构向量检索失败: " + e.getMessage());
+        }
+    }
+
+    // ==================== 元数据读取(master:meta_raw_sheet / table_info / table_field) ====================
+
+    /**
+     * 工作空间表元信息:物理表名 + 中英文表名 + 行数合计
+     */
+    public record WorkspaceTable(Integer id, String tableNameEn, String tableNameCn) {
+    }
+
+
+
+    // ==================== 文档构建 ====================
+
+    /**
+     * 待向量的表结构文档:docId + 嵌入文本 + 结构化 payload JSON
+     */
+    record SchemaDoc(String docId, String content, String payloadJson) {
+    }
+
+    private List<SchemaDoc> buildDocuments(Long workspaceId, List<WorkspaceTable> tables,
+                                           List<TableField> fields) {
+        Map<String, List<TableField>> fieldsByTable = fields.stream()
+                .filter(f -> StringUtils.hasText(f.getTableNameEn()))
+                .collect(Collectors.groupingBy(TableField::getTableNameEn, LinkedHashMap::new,
+                        Collectors.toList()));
+
+        List<SchemaDoc> documents = new ArrayList<>(tables.size());
+        for (WorkspaceTable table : tables) {
+            documents.add(buildDocument(workspaceId, table,
+                    fieldsByTable.getOrDefault(table.tableNameEn(), List.of())));
+        }
+        return documents;
+    }
+
+    private SchemaDoc buildDocument(Long workspaceId, WorkspaceTable table, List<TableField> fields) {
+        StringBuilder sb = new StringBuilder();
+        sb.append("表名: ").append(table.tableNameEn());
+        if (StringUtils.hasText(table.tableNameCn())) {
+            sb.append("(中文名: ").append(table.tableNameCn()).append(")");
+        }
+        sb.append("\n字段:");
+        for (TableField field : fields) {
+            sb.append("\n- ").append(field.getFieldNameEn()).append(" (").append(field.getFieldType());
+            if (StringUtils.hasText(field.getFieldNameCn())) {
+                sb.append(", 注释: ").append(field.getFieldNameCn());
+            }
+            if (field.getRequired() != null && field.getRequired() == 1) {
+                sb.append(", 必填");
+            }
+            sb.append(")");
+        }
+
+        List<Map<String, Object>> columnPayload = new ArrayList<>(fields.size());
+        for (TableField field : fields) {
+            Map<String, Object> cm = new LinkedHashMap<>();
+            cm.put("name", field.getFieldNameEn());
+            cm.put("type", field.getFieldType());
+            if (StringUtils.hasText(field.getFieldNameCn())) {
+                cm.put("comment", field.getFieldNameCn());
+            }
+            if (field.getRequired() != null && field.getRequired() == 1) {
+                cm.put("required", true);
+            }
+            columnPayload.add(cm);
+        }
+        Map<String, Object> payload = new LinkedHashMap<>();
+        payload.put("workspaceId", workspaceId);
+        payload.put("tableName", table.tableNameEn());
+        if (StringUtils.hasText(table.tableNameCn())) {
+            payload.put("tableComment", table.tableNameCn());
+        }
+        payload.put("columns", columnPayload);
+
+        return new SchemaDoc("ws" + workspaceId + ":" + table.tableNameEn(),
+                sb.toString(), Json.toStr(payload));
+    }
+
+    /**
+     * 结构指纹:对排序后的(表/字段/类型/注释/必填)元组做 SHA-256(不含行数,避免数据量变化触发重建)
+     */
+    private String fingerprint(List<WorkspaceTable> tables, List<TableField> fields) {
+        StringBuilder sb = new StringBuilder();
+        tables.stream()
+                .sorted(Comparator.comparing(WorkspaceTable::tableNameEn))
+                .forEach(t -> sb.append("T|").append(t.tableNameEn()).append('|')
+                        .append(nullToEmpty(t.tableNameCn())).append('\n'));
+        fields.stream()
+                .sorted(Comparator.comparing(TableField::getTableNameEn,
+                                Comparator.nullsLast(Comparator.naturalOrder()))
+                        .thenComparing(TableField::getFieldNameEn,
+                                Comparator.nullsLast(Comparator.naturalOrder())))
+                .forEach(f -> sb.append("C|").append(nullToEmpty(f.getTableNameEn()))
+                        .append('|').append(nullToEmpty(f.getFieldNameEn()))
+                        .append('|').append(nullToEmpty(f.getFieldType().getValue()))
+                        .append('|').append(nullToEmpty(f.getFieldNameCn()))
+                        .append('|').append(f.getRequired() == null ? "" : f.getRequired())
+                        .append('\n'));
+        try {
+            MessageDigest digest = MessageDigest.getInstance("SHA-256");
+            return HexFormat.of().formatHex(digest.digest(sb.toString().getBytes(StandardCharsets.UTF_8)));
+        } catch (Exception e) {
+            throw new ServerException(500, "计算结构指纹失败: " + e.getMessage());
+        }
+    }
+
+    private static String nullToEmpty(String s) {
+        return s == null ? "" : s;
+    }
+
+    // ==================== 向量存储管理(应用层 JDBC 直连 pgvector) ====================
+
+    /**
+     * 向量表名:rag_ws{workspaceId}_d{dims}
+     */
+    private String vectorTableName(Long workspaceId, int dims) {
+        return "rag_ws" + workspaceId + "_d" + dims;
+    }
+
+    /**
+     * 建表并逐条写入文档向量(每文档一次嵌入调用)
+     */
+    private void writeToVectorTable(String tableName, EmbeddingSpec spec, List<SchemaDoc> docs) {
+        try {
+            ragVectorMapper.createVectorTable(tableName, spec.dimensions());
+        } catch (Exception e) {
+            throw new ServerException(500, "创建向量表失败(请检查 PG 是否已安装 pgvector 扩展): " + e.getMessage());
+        }
+        try {
+            for (SchemaDoc doc : docs) {
+                double[] vector = embed(spec, doc.content());
+                ragVectorMapper.insertVectorDoc(tableName, doc.docId(), doc.content(),
+                        doc.payloadJson(), toVectorLiteral(vector));
+            }
+        } catch (Exception e) {
+            throw new ServerException(500, "写入向量数据失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 嵌入文本并校验维度一致性
+     */
+    private double[] embed(EmbeddingSpec spec, String content) {
+        double[] vector = spec.model()
+                .embed(TextBlock.builder().text(content).build())
+                .block(Duration.ofSeconds(60));
+        if (vector == null || vector.length != spec.dimensions()) {
+            throw new ServerException(500, "嵌入向量维度异常(期望 " + spec.dimensions()
+                    + ",实际 " + (vector == null ? 0 : vector.length)
+                    + "),请检查 model.config 中 dimensions 配置");
+        }
+        return vector;
+    }
+
+    /**
+     * double[] → pgvector 字面量(如 [0.1,0.2,...])
+     */
+    private static String toVectorLiteral(double[] vector) {
+        StringBuilder sb = new StringBuilder(vector.length * 10).append('[');
+        for (int i = 0; i < vector.length; i++) {
+            if (i > 0) {
+                sb.append(',');
+            }
+            sb.append(vector[i]);
+        }
+        return sb.append(']').toString();
+    }
+
+    /**
+     * 删除向量表
+     */
+    private void dropVectorTable(String tableName) {
+        try {
+            ragVectorMapper.dropVectorTable(tableName);
+            log.info("已删除向量表: {}", tableName);
+        } catch (Exception e) {
+            log.warn("删除向量表失败: {}, error={}", tableName, e.getMessage());
+        }
+    }
+
+    // ==================== 状态与转换 ====================
+
+    private RagIndexStatus upsertStatus(Long workspaceId, String status, EmbeddingSpec spec,
+                                        String fingerprint, int tableCount, String error) {
+        RagIndexStatus entity = ragIndexStatusMapper.selectById(workspaceId);
+        boolean exists = entity != null;
+        if (!exists) {
+            entity = new RagIndexStatus();
+            entity.setWorkspaceId(workspaceId);
+        }
+        entity.setEmbeddingModelId(spec.modelId());
+        entity.setEmbeddingModelName(spec.modelName());
+        entity.setDimensions(spec.dimensions());
+        entity.setVectorTable(vectorTableName(workspaceId, spec.dimensions()));
+        entity.setSchemaFingerprint(fingerprint);
+        entity.setTableCount(tableCount);
+        entity.setStatus(status);
+        entity.setLastError(error);
+        entity.setUpdateAt(LocalDateTime.now());
+        if (RagIndexStatus.STATUS_SUCCESS.equals(status)) {
+            entity.setIndexedAt(LocalDateTime.now());
+        }
+        if (exists) {
+            ragIndexStatusMapper.updateById(entity);
+        } else {
+            ragIndexStatusMapper.insert(entity);
+        }
+        return entity;
+    }
+
+    private RagIndexStatusVO convertStatusToVO(RagIndexStatus entity) {
+        RagIndexStatusVO vo = new RagIndexStatusVO();
+        vo.setWorkspaceId(entity.getWorkspaceId());
+        vo.setEmbeddingModelId(entity.getEmbeddingModelId());
+        vo.setEmbeddingModelName(entity.getEmbeddingModelName());
+        vo.setDimensions(entity.getDimensions());
+        vo.setVectorTable(entity.getVectorTable());
+        vo.setSchemaFingerprint(entity.getSchemaFingerprint());
+        vo.setTableCount(entity.getTableCount());
+        vo.setStatus(entity.getStatus());
+        vo.setLastError(entity.getLastError());
+        vo.setIndexedAt(entity.getIndexedAt());
+        vo.setUpdateAt(entity.getUpdateAt());
+        return vo;
+    }
+
+    @SuppressWarnings("unchecked")
+    private RagSearchResultVO convertSearchResult(String payloadJson, double score) {
+        RagSearchResultVO vo = new RagSearchResultVO();
+        vo.setScore(score);
+        if (!StringUtils.hasText(payloadJson)) {
+            return vo;
+        }
+        try {
+            Map<String, Object> payload = Json.objectMapper().readValue(payloadJson, Map.class);
+            vo.setTableName((String) payload.get("tableName"));
+            vo.setTableComment((String) payload.get("tableComment"));
+            Object rowCount = payload.get("rowCount");
+            if (rowCount instanceof Number n) {
+                vo.setRowCount(n.longValue());
+            }
+            Object columns = payload.get("columns");
+            if (columns instanceof List<?> list) {
+                List<RagSearchResultVO.ColumnMeta> metas = new ArrayList<>(list.size());
+                for (Object item : list) {
+                    if (item instanceof Map<?, ?> m) {
+                        RagSearchResultVO.ColumnMeta meta = new RagSearchResultVO.ColumnMeta();
+                        meta.setName((String) m.get("name"));
+                        meta.setType((String) m.get("type"));
+                        meta.setComment((String) m.get("comment"));
+                        metas.add(meta);
+                    }
+                }
+                vo.setColumns(metas);
+            }
+        } catch (Exception e) {
+            log.warn("解析向量 payload 失败: {}", e.getMessage());
+        }
+        return vo;
+    }
+
+}

+ 10 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/rag/WorkspaceSwitchedEvent.java

@@ -0,0 +1,10 @@
+package com.zsjz.ai.module.agent.rag;
+
+/**
+ * 工作空间切换完成事件
+ *
+ * <p>由 {@code DynamicDatasourceServiceImpl.switchWorkspace} 在 slave 数据源
+ * 成功指向新工作空间后发布,RAG 模块监听该事件后台重建表结构索引。
+ */
+public record WorkspaceSwitchedEvent(Long workspaceId) {
+}

+ 134 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/scaffold/WorkspaceScaffolder.java

@@ -0,0 +1,134 @@
+/*
+ * Copyright 2024-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.zsjz.ai.module.agent.scaffold;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+/**
+ * Materializes an agent's workspace folder by copying classpath resources from
+ * {@code scaffold/default/} into the target directory with write-if-missing semantics.
+ *
+ * <p>Layout produced (all under the supplied {@code workspace} root):
+ *
+ * <ul>
+ *   <li>{@code AGENTS.md} — generated from {@code scaffold/default/AGENTS.md.template} with the
+ *       supplied {@code displayName} / {@code sysPrompt} substituted into the {@code {{NAME}}} and
+ *       {@code {{SYSPROMPT}}} placeholders.
+ *   <li>{@code tools.json}, {@code skills/example-skill/SKILL.md}, {@code subagents/README.md} —
+ *       copied verbatim from the corresponding resource under {@code scaffold/default/}.
+ *   <li>{@code skills/}, {@code subagents/}, {@code memory/} — empty directories (so the UI's file
+ *       tree always shows the standard slots even before a user adds anything).
+ *   <li>{@code memory/.gitkeep} — empty marker file so the {@code memory/} folder survives a git
+ *       clone of the workspace.
+ * </ul>
+ *
+ * <p>Everything beyond the AGENTS.md placeholders lives as on-disk resources, not Java string
+ * literals — operators who want to customise the default starter content edit the resource files
+ * under {@code src/main/resources/scaffold/default/} rather than touching Java code. Existing
+ * files in the destination workspace are never overwritten, so calling this on a populated
+ * workspace is a no-op for whatever is already there.
+ */
+public final class WorkspaceScaffolder {
+
+    private static final String RESOURCE_ROOT = "scaffold/default";
+
+    /**
+     * Files copied verbatim from {@code scaffold/default/<rel>} into {@code workspace/<rel>}.
+     * Keep this list narrow — anything you add here ships in every fresh workspace.
+     */
+    private static final List<String> VERBATIM_RESOURCES =
+            List.of("tools.json", "subagents/README.md");
+
+    private WorkspaceScaffolder() {
+    }
+
+    /**
+     * Materializes the workspace folder for an agent. Safe to call repeatedly: only files that do
+     * not already exist are created.
+     *
+     * @param workspace   target workspace directory (will be created if missing)
+     * @param displayName human-readable agent name substituted into the AGENTS.md heading
+     * @param sysPrompt   optional system-prompt body substituted into AGENTS.md (may be {@code null})
+     */
+    public static void scaffold(Path workspace, String displayName, String sysPrompt)
+            throws IOException {
+        Files.createDirectories(workspace);
+        Files.createDirectories(workspace.resolve("skills"));
+        Files.createDirectories(workspace.resolve("subagents"));
+        Files.createDirectories(workspace.resolve("memory"));
+        String prompt = sysPrompt.isBlank()
+                ? "You are a helpful assistant."
+                : sysPrompt.trim();
+        writeIfMissing(workspace.resolve("AGENTS.md"), prompt);
+        for (String rel : VERBATIM_RESOURCES) {
+            writeIfMissing(workspace.resolve(rel), readResource(RESOURCE_ROOT + "/" + rel));
+        }
+        // Empty file; carries no content so we don't bother adding it to the resource tree —
+        // a {@code .gitkeep} only needs to exist for {@code git} to track the otherwise-empty
+        // {@code memory/} directory.
+        writeIfMissing(workspace.resolve("memory").resolve(".gitkeep"), "");
+    }
+
+    /**
+     * Loads the AGENTS.md template resource and substitutes the supplied agent identity into the
+     * {@code {{NAME}}} / {@code {{SYSPROMPT}}} placeholders. Falls back to {@code "agent"} and
+     * {@code "You are a helpful assistant."} when either input is blank, so the generated file is
+     * always well-formed even for callers that don't supply customisation.
+     */
+    private static String renderAgentsMd(String displayName, String sysPrompt) throws IOException {
+        String name = (displayName == null || displayName.isBlank()) ? "agent" : displayName;
+        String prompt =
+                (sysPrompt == null || sysPrompt.isBlank())
+                        ? "You are a helpful assistant."
+                        : sysPrompt.trim();
+        String template = readResource(RESOURCE_ROOT + "/AGENTS.md.template");
+        return template.replace("{{NAME}}", name).replace("{{SYSPROMPT}}", prompt);
+    }
+
+    /**
+     * Loads a UTF-8 classpath resource as a string. Throws {@link IOException} if the resource is
+     * missing so a packaging mistake (renamed or excluded resource) surfaces loudly at startup
+     * instead of silently producing an empty workspace.
+     */
+    private static String readResource(String classpathPath) throws IOException {
+        ClassLoader cl = Thread.currentThread().getContextClassLoader();
+        if (cl == null) {
+            cl = WorkspaceScaffolder.class.getClassLoader();
+        }
+        try (InputStream in = cl.getResourceAsStream(classpathPath)) {
+            if (in == null) {
+                throw new IOException(
+                        "Missing scaffold resource on classpath: '"
+                                + classpathPath
+                                + "' (expected under src/main/resources/"
+                                + classpathPath
+                                + ")");
+            }
+            return new String(in.readAllBytes(), StandardCharsets.UTF_8);
+        }
+    }
+
+    private static void writeIfMissing(Path file, String content) throws IOException {
+        if (Files.exists(file)) return;
+        Files.createDirectories(file.getParent());
+        Files.writeString(file, content, StandardCharsets.UTF_8);
+    }
+}

+ 79 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/service/AgentModelFactory.java

@@ -0,0 +1,79 @@
+package com.zsjz.ai.module.agent.service;
+
+import com.zsjz.ai.common.exception.ServerException;
+import com.zsjz.ai.module.agent.entity.AgentModel;
+import io.agentscope.core.model.Model;
+import io.agentscope.core.model.ModelCreationContext;
+import io.agentscope.core.model.ModelRegistry;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+/**
+ * 模型工厂:DB 模型配置 → agentscope Model 实例
+ *
+ * <p>使用官方 SPI 机制:{@link ModelRegistry#resolve(String, ModelCreationContext)}
+ * 按模型引用前缀路由到对应 provider 扩展,{@link ModelCreationContext} 携带
+ * apiKey / baseUrl / stream 等连接参数。
+ *
+ * <p>provider 归一化规则(DB 的 model.provider 列为用户自由填写):
+ * <ul>
+ *   <li>包含 "dashscope" 或 "阿里"(忽略大小写)→ {@code dashscope:} 前缀(原生协议)</li>
+ *   <li>其余一律 {@code openai:} 前缀(OpenAI 兼容协议,各厂商端点靠 baseUrl 指向,
+ *       覆盖 openai / deepseek / glm / kimi / ollama 兼容服务等)</li>
+ * </ul>
+ */
+@Slf4j
+@Component
+public class AgentModelFactory {
+
+    private static final String PREFIX_DASHSCOPE = "dashscope:";
+    private static final String PREFIX_OPENAI = "openai:";
+
+    /**
+     * 根据模型配置构造 agentscope Model 实例
+     *
+     * @param config 模型配置(model 表记录)
+     * @return agentscope Model 实例(每次新建轻量配置对象,不做缓存)
+     * @throws com.zsjz.ai.common.exception.ServerException 模型配置不完整或构造失败时
+     */
+    public Model create(AgentModel config) {
+        if (config == null) {
+            throw new ServerException(400, "模型配置不存在");
+        }
+        if (!StringUtils.hasText(config.getModelId())) {
+            throw new ServerException(400, "模型配置不完整(缺少模型ID): " + config.getName());
+        }
+        if (!StringUtils.hasText(config.getApiKey())) {
+            throw new ServerException(400, "模型配置不完整(缺少API密钥): " + config.getName());
+        }
+
+        String modelRef = resolvePrefix(config.getProvider()) + config.getModelId().trim();
+        ModelCreationContext ctx = ModelCreationContext.builder()
+                .apiKey(config.getApiKey().trim())
+                .baseUrl(StringUtils.hasText(config.getBaseUrl()) ? config.getBaseUrl().trim() : null)
+                .stream(true)
+                .build();
+        try {
+            Model model = ModelRegistry.resolve(modelRef, ctx);
+            log.info("构造模型实例: ref={}, name={}", modelRef, config.getName());
+            return model;
+        } catch (RuntimeException e) {
+            log.error("模型实例构造失败: ref={}, error={}", modelRef, e.getMessage());
+            throw new ServerException(500, "模型创建失败[" + config.getName() + "]: " + e.getMessage());
+        }
+    }
+
+    /**
+     * provider 归一化:DB provider 值 → agentscope SPI 前缀
+     */
+    private String resolvePrefix(String provider) {
+        if (StringUtils.hasText(provider)) {
+            String p = provider.trim().toLowerCase();
+            if (p.contains("dashscope") || p.contains("阿里")) {
+                return PREFIX_DASHSCOPE;
+            }
+        }
+        return PREFIX_OPENAI;
+    }
+}

+ 245 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/service/AgentService.java

@@ -0,0 +1,245 @@
+package com.zsjz.ai.module.agent.service;
+
+import com.baomidou.mybatisplus.spring.service.impl.ServiceImpl;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.zsjz.ai.common.constants.PathConst;
+import com.zsjz.ai.common.exception.ServerException;
+import com.zsjz.ai.common.utils.Json;
+import com.zsjz.ai.module.agent.config.EtlProperties;
+import com.zsjz.ai.module.agent.config.SkillRepositoryConfigEntry;
+import com.zsjz.ai.module.agent.config.SkillRepositorySupport;
+import com.zsjz.ai.module.agent.entity.AgentEntity;
+import com.zsjz.ai.module.agent.entity.AgentModel;
+import com.zsjz.ai.module.agent.followup.FollowupMiddleware;
+import com.zsjz.ai.module.agent.followup.FollowupService;
+import com.zsjz.ai.module.agent.intent.IntentMiddleware;
+import com.zsjz.ai.module.agent.intent.IntentService;
+import com.zsjz.ai.module.agent.mapper.AgentChatSessionMapper;
+import com.zsjz.ai.module.agent.mapper.AgentMapper;
+import com.zsjz.ai.module.agent.mapper.AgentModelMapper;
+import com.zsjz.ai.module.agent.mapper.SqlQueryMapper;
+import com.zsjz.ai.module.agent.python.PythonExecutor;
+import com.zsjz.ai.module.agent.rag.RagSchemaService;
+import com.zsjz.ai.module.agent.scaffold.WorkspaceScaffolder;
+import com.zsjz.ai.module.agent.sql.SqlResultStore;
+import com.zsjz.ai.module.agent.tools.*;
+import com.zsjz.ai.module.plat.mapper.CaseInfoMapper;
+import com.zsjz.ai.module.plat.mapper.TableFieldMapper;
+import com.zsjz.ai.module.plat.mapper.TableInfoMapper;
+import io.agentscope.core.model.Model;
+import io.agentscope.core.state.AgentStateStore;
+import io.agentscope.core.state.InMemoryAgentStateStore;
+import io.agentscope.core.tool.Toolkit;
+import io.agentscope.harness.agent.HarnessAgent;
+import io.agentscope.harness.agent.IsolationScope;
+import io.agentscope.harness.agent.filesystem.spec.LocalFilesystemSpec;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Agent 实例管理服务(官方"无状态引擎单例"模式)。
+ *
+ * <p>HarnessAgent 实例仅绑定不可变配置(sysPrompt/model/skillRepositories 等),
+ * 会话状态由 {@code RuntimeContext(userId, sessionId)} 在 {@link AgentStateStore}
+ * 中隔离,与实例无关——因此按「用户×工作空间×Agent×模型」组合池化实例即可,
+ * 无需会话级缓存;同一会话切换模型时记忆自动延续(状态槽位不变)。
+ */
+@Slf4j
+@Service
+public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
+
+    private final AgentModelFactory agentModelFactory;
+    private final AgentModelMapper modelMapper;
+    private final AgentStateStore agentStateStore;
+    private final RagSchemaService ragSchemaService;
+    private final EtlProperties etlProperties;
+    private final IntentService intentService;
+    private final FollowupService followupService;
+    private final SqlQueryMapper sqlQueryMapper;
+    private final SqlResultStore sqlResultStore;
+    private final TableInfoMapper tableInfoMapper;
+    private final TableFieldMapper tableFieldMapper;
+    private final PythonExecutor pythonExecutor;
+    private final AgentChatSessionMapper chatSessionMapper;
+    private final CaseInfoMapper caseInfoMapper;
+
+    /**
+     * agent 实例池:key = u{userId}-w{workspaceId}-a{agentRowId}-m{modelId}
+     */
+    private final ConcurrentHashMap<String, HarnessAgent> agentPool = new ConcurrentHashMap<>();
+
+    public AgentService(
+                        AgentModelFactory agentModelFactory,
+                        AgentModelMapper modelMapper,
+                        Optional<AgentStateStore> stateStoreOpt,
+                        RagSchemaService ragSchemaService,
+                        EtlProperties etlProperties,
+                        IntentService intentService,
+                        FollowupService followupService,
+                        SqlQueryMapper sqlQueryMapper,
+                        SqlResultStore sqlResultStore,
+                        TableInfoMapper tableInfoMapper,
+                        TableFieldMapper tableFieldMapper,
+                        PythonExecutor pythonExecutor,
+                        AgentChatSessionMapper agentChatSessionMapper,
+                        CaseInfoMapper caseInfoMapper) {
+        this.agentModelFactory = agentModelFactory;
+        this.modelMapper = modelMapper;
+        this.agentStateStore = stateStoreOpt.orElseGet(InMemoryAgentStateStore::new);
+        this.ragSchemaService = ragSchemaService;
+        this.etlProperties = etlProperties;
+        this.intentService = intentService;
+        this.followupService = followupService;
+        this.sqlQueryMapper = sqlQueryMapper;
+        this.sqlResultStore = sqlResultStore;
+        this.tableInfoMapper = tableInfoMapper;
+        this.tableFieldMapper = tableFieldMapper;
+        this.pythonExecutor = pythonExecutor;
+        this.chatSessionMapper = agentChatSessionMapper;
+        this.caseInfoMapper = caseInfoMapper;
+    }
+
+    /**
+     * 取或建 agent 实例(池化,按 用户×workspace×agent×模型 组合去重)。
+     *
+     * @param userId      用户标识
+     * @param workspaceId 业务工作空间ID(agent 文件空间隔离维度)
+     * @param agentRowId  dataagent_agent.row_id
+     * @param modelId     model.id(动态模型绑定)
+     * @return 可复用的 HarnessAgent 实例
+     */
+    public HarnessAgent getOrCreateAgent(String userId, Long workspaceId, Long agentRowId, Long modelId) {
+        String key = "u" + userId + "-m" + modelId;
+        return agentPool.computeIfAbsent(key, k -> {
+            AgentEntity entry = getById(agentRowId);
+            if (entry == null) {
+                throw new ServerException(404, "Agent 配置不存在: " + agentRowId);
+            }
+            AgentModel agentModelConfig = modelMapper.selectById(modelId);
+            if (agentModelConfig == null) {
+                throw new ServerException(404, "模型配置不存在: " + modelId);
+            }
+            Model model = agentModelFactory.create(agentModelConfig);
+            log.info("构建 agent 实例: userId={}, workspaceId={}, agentRowId={}, modelId={}, model={}",
+                    userId, workspaceId, agentRowId, modelId, agentModelConfig.getName());
+            return buildAgent(entry, modelId, model);
+        });
+    }
+
+    /**
+     * 构建 HarnessAgent(官方 builder;工作目录按 用户/workspace/agent 三维隔离)。
+     */
+    private HarnessAgent buildAgent(AgentEntity entry, Long modelId, Model model) {
+        HarnessAgent.Builder b = HarnessAgent.builder();
+
+        String name = entry.getName() != null ? entry.getName() : entry.getRowId().toString();
+        b.name(name);
+        if (entry.getDescription() != null) {
+            b.description(entry.getDescription());
+        }
+        String sysPrompt = entry.getSysPrompt();
+        sysPrompt = appendRenderPrompt(sysPrompt);
+        if (ragSchemaService.isAvailable()) {
+            sysPrompt = appendRagPrompt(sysPrompt);
+        }
+        b.sysPrompt(sysPrompt);
+        if (entry.getMaxIters() != null) {
+            b.maxIters(entry.getMaxIters());
+        }
+        b.model(model).agentId(entry.getRowId().toString());
+        // 工具注册:RAG 检索(可用时)+ SQL 分析三件套(总是)+ Python 分析(环境就绪时)+ 工作空间查询(总是)+ 关系图谱渲染(总是)
+        Toolkit toolkit = new Toolkit();
+        if (ragSchemaService.isAvailable()) {
+            toolkit.registerTool(new RagSchemaSearchTool(ragSchemaService));
+        }
+        toolkit.registerTool(new SqlAnalysisTool(sqlQueryMapper, sqlResultStore, tableInfoMapper, tableFieldMapper));
+        if (pythonExecutor.isAvailable()) {
+            toolkit.registerTool(new PythonAnalysisTool(pythonExecutor));
+        }
+        toolkit.registerTool(new WorkspaceInfoTool(chatSessionMapper, caseInfoMapper));
+        toolkit.registerTool(new GraphRenderTool());
+        b.toolkit(toolkit);
+
+        // 意图识别:onAgent 钩子内做意图分类 + 语义增强 + intent 事件发射(内部自行判断开关)
+        b.middleware(new IntentMiddleware(intentService, etlProperties.getIntent(), modelId));
+
+        // 下一步引导建议:onAgent 钩子包裹整个推理流,对话结束后生成建议并追加 suggestions 事件
+        b.middleware(new FollowupMiddleware(followupService, etlProperties.getFollowup(), modelId));
+
+        //Path workspace = workspaceManagerFactory.resolveAgentDataPath(userId, workspaceId, entry.getRowId());
+        Path workspace = PathConst.ROOT_PATH.resolve(".agentscope");
+        //scaffoldWorkspace(workspace, entry.getSysPrompt());
+        b.workspace(workspace);
+
+        var skillRepositories = readSkillRepositories(entry.getSkillRepositoriesJson());
+        if (skillRepositories != null && !skillRepositories.isEmpty()) {
+            var repos = SkillRepositorySupport.createAll(workspace, skillRepositories);
+            if (!repos.isEmpty()) {
+                b.skillRepositories(repos);
+            }
+        }
+
+        b.stateStore(agentStateStore);
+        b.filesystem(new LocalFilesystemSpec().isolationScope(IsolationScope.USER));
+        return b.build();
+    }
+
+    /**
+     * 首次使用时为隔离工作目录补脚手架(AGENTS.md / skills / memory 等标准结构)。
+     * 幂等:已存在的文件不覆盖。
+     */
+    private static void scaffoldWorkspace(Path workspace, String sysPrompt) {
+        try {
+            WorkspaceScaffolder.scaffold(workspace, null, sysPrompt);
+        } catch (java.io.IOException e) {
+            log.warn("工作空间脚手架失败: {}, error={}", workspace, e.getMessage());
+        }
+    }
+
+    private static List<SkillRepositoryConfigEntry> readSkillRepositories(String json) {
+        if (json == null || json.isBlank()) return null;
+        try {
+            return Json.objectMapper().readValue(json, new TypeReference<>() {
+            });
+        } catch (JsonProcessingException ex) {
+            return null;
+        }
+    }
+
+    /**
+     * 在用户配置的 sysPrompt 后追加 RAG 检索指引(Agentic RAG:Agent 自行决定何时检索)
+     */
+    private String appendRagPrompt(String sysPrompt) {
+        String ragPrompt = """
+                ## 数据表结构检索(重要)
+                你拥有 search_table_schema 工具,可按语义检索当前工作空间数据库的表结构(表名、字段、类型、注释、行数)。
+                - 在编写任何 SQL 或回答"有哪些表/字段"类问题之前,必须先调用该工具检索,禁止凭空猜测表名和字段名。
+                - 检索 query 用自然语言描述目标(如"包含手机号和身份证号的表")。
+                - 检索结果已按相似度排序,结合字段注释理解表用途后再编写 SQL。""";
+        return sysPrompt.isBlank() ? ragPrompt.strip() : sysPrompt + ragPrompt;
+    }
+
+    /**
+     * 在用户配置的 sysPrompt 后追加前端渲染契约(表格/图表必须用特殊围栏块输出,否则前端只能显示 JSON 文本)
+     */
+    private String appendRenderPrompt(String sysPrompt) {
+        String renderPrompt = """
+                
+                ## 回复输出格式契约(前端渲染依赖,必须遵守)
+                聊天前端只把以下三种围栏代码块渲染为交互组件,其余代码块按普通文本展示:
+                1. 向用户展示查询/分析结果数据时,必须调用 execute_sql 查询,并把工具返回的 JSON 原样返回,禁止增删改字段、禁止截断行。示例格式:
+                   {"resultId":"...","columns":[...],"rows":[...],"page":1,"pageSize":50,"totalRows":100}
+                   前端会据此渲染为可翻页交互表格。禁止把结果 JSON 放入 ```json 或 ```sql 块,禁止用文字逐行罗列数据。
+                2. 展示常规图表(柱状/折线/饼/散点等)时,必须先调用 render_chart 工具校验 option,再把工具返回的 ECharts option JSON 原样放入 ```echarts 围栏块中,前端会渲染为图表。
+                3. 展示关系图谱/网络图/关联图时,禁止用 render_chart 手写 graph 类型 series(会被拒绝),必须先调用 render_graph 工具校验,再把工具返回的图谱 JSON 原样放入 ```graph 围栏块中(结构:title/nodes[id,name,category,value,desc]/edges[source,target,label,value]/categories),前端会渲染为可交互力导向图。禁止用表格或文字罗列代替。
+                4. 结论、摘要、分析说明正常用 markdown 书写;凡是需要用户查看的数据明细,一律用上述围栏块呈现。""";
+        return sysPrompt == null || sysPrompt.isBlank() ? renderPrompt.strip() : sysPrompt + renderPrompt;
+    }
+
+}

+ 76 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/sql/SqlResultStore.java

@@ -0,0 +1,76 @@
+package com.zsjz.ai.module.agent.sql;
+
+import org.springframework.stereotype.Service;
+
+import java.time.Duration;
+import java.util.*;
+
+/**
+ * execute_sql 查询结果内存缓存:resultId → 全量行数据。
+ *
+ * <p>前端表格翻页时按 resultId 从本缓存按页取数(REST),
+ * 避免大结果集经 SSE 单帧下发。容量 LRU 淘汰 + TTL 惰性过期。
+ */
+@Service
+public class SqlResultStore {
+
+    /** 最多缓存查询结果数 */
+    private static final int MAX_ENTRIES = 50;
+
+    /** 结果保留时长 */
+    private static final Duration TTL = Duration.ofMinutes(30);
+
+    /** 缓存条目:列定义 + 全量行 + 过期时间戳(按插入序淘汰最旧) */
+    private record CachedResult(List<Map<String, Object>> columns, List<Map<String, Object>> rows, long expireAt) {
+    }
+
+    /** 按页取数结果 */
+    public record PageData(List<Map<String, Object>> columns, List<Map<String, Object>> rows,
+                          int page, int pageSize, int totalRows, int totalPages, String sql) {
+    }
+
+    private final Map<String, CachedResult> cache = new LinkedHashMap<>(16, 0.75f, true);
+
+    /**
+     * 存入全量查询结果,返回 resultId(UUID);超容量淘汰最旧条目
+     */
+    public synchronized String put(String sql, List<Map<String, Object>> columns,
+                                   List<Map<String, Object>> rows) {
+        String resultId = UUID.randomUUID().toString();
+        cache.put(resultId, new CachedResult(columns, rows,
+                System.currentTimeMillis() + TTL.toMillis()));
+        evictOverCapacity();
+        return resultId;
+    }
+
+    /**
+     * 按页取数(page 从 1 起);resultId 不存在或已过期返回 null
+     */
+    public synchronized PageData get(String resultId, int page, int pageSize) {
+        CachedResult cached = cache.get(resultId);
+        if (cached == null || cached.expireAt() < System.currentTimeMillis()) {
+            if (cached != null) {
+                cache.remove(resultId);
+            }
+            return null;
+        }
+        int totalRows = cached.rows().size();
+        int totalPages = Math.max(1, (totalRows + pageSize - 1) / pageSize);
+        int safePage = Math.min(Math.max(1, page), totalPages);
+        int from = Math.min((safePage - 1) * pageSize, totalRows);
+        int to = Math.min(from + pageSize, totalRows);
+        return new PageData(cached.columns(), new ArrayList<>(cached.rows().subList(from, to)),
+                safePage, pageSize, totalRows, totalPages, null);
+    }
+
+    /**
+     * 超容量淘汰最旧条目(LinkedHashMap accessOrder=true 下为最久未访问)
+     */
+    private void evictOverCapacity() {
+        Iterator<String> it = cache.keySet().iterator();
+        while (cache.size() > MAX_ENTRIES && it.hasNext()) {
+            it.next();
+            it.remove();
+        }
+    }
+}

+ 113 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/tools/GraphRenderTool.java

@@ -0,0 +1,113 @@
+package com.zsjz.ai.module.agent.tools;
+
+import com.zsjz.ai.common.utils.Json;
+import io.agentscope.core.tool.Tool;
+import io.agentscope.core.tool.ToolParam;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.util.StringUtils;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Agent 关系图谱渲染工具(render_graph)
+ *
+ * <p>注册到 HarnessAgent 的 Toolkit,由 Agent(LLM)自行决定何时调用。
+ * 与 render_chart(ECharts 常规图表)分离:关系图谱使用结构化
+ * nodes/edges JSON,前端以 relation-graph 力导向图渲染。
+ */
+@Slf4j
+public class GraphRenderTool {
+
+    /** 关系图谱节点数上限(防图过密不可读) */
+    private static final int MAX_GRAPH_NODES = 200;
+
+    /** 关系图谱边数上限 */
+    private static final int MAX_GRAPH_EDGES = 500;
+
+    /**
+     * 将关系数据渲染为力导向关系图谱(节点/边结构化 JSON)
+     */
+    @Tool(name = "render_graph",
+          description = "将关系数据渲染为可交互的关系图谱(力导向图)展示给用户。传入结构化图谱 JSON:"
+                  + "{\"title\":\"图谱标题\",\"nodes\":[{\"id\":\"唯一标识\",\"name\":\"显示名称\",\"category\":\"分类名\",\"value\":度数或权重,\"desc\":\"节点说明\"}],"
+                  + "\"edges\":[{\"source\":\"起点节点id\",\"target\":\"终点节点id\",\"label\":\"关系名称\",\"value\":关系权重}],"
+                  + "\"categories\":[{\"name\":\"分类名\"}]}。"
+                  + "节点/边数据基于已通过 execute_sql 查询到的关系数据组装;nodes.id 必须唯一且被 edges 的 source/target 引用;"
+                  + "category 用于分区着色(categories 可省略,自动从节点收集)。"
+                  + "用户需要『关系图谱』『人-人/人-案关联』『网络图』类可视化时必须调用本工具。",
+          readOnly = true)
+    public String renderGraph(
+            @ToolParam(name = "graph", description = "结构化图谱 JSON 对象(nodes/edges/categories)") String graph) {
+        if (!StringUtils.hasText(graph)) {
+            return error("graph 不能为空");
+        }
+        try {
+            com.fasterxml.jackson.databind.JsonNode root = Json.objectMapper().readTree(graph);
+            List<String> problems = validateGraph(root);
+            if (!problems.isEmpty()) {
+                return error("图谱结构不合法: " + String.join("; ", problems));
+            }
+            return graph.trim();
+        } catch (Exception e) {
+            return error("graph 不是合法的 JSON 对象,请修正后重试: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 图谱结构只读校验:nodes 非空、必填字段、引用完整性、规模上限
+     *
+     * @return 问题列表(空列表表示通过)
+     */
+    private static List<String> validateGraph(com.fasterxml.jackson.databind.JsonNode root) {
+        List<String> problems = new ArrayList<>();
+        if (!root.isObject()) {
+            return List.of("顶层必须是 JSON 对象");
+        }
+        com.fasterxml.jackson.databind.JsonNode nodes = root.get("nodes");
+        if (nodes == null || !nodes.isArray() || nodes.isEmpty()) {
+            return List.of("nodes 必须是非空数组");
+        }
+        if (nodes.size() > MAX_GRAPH_NODES) {
+            problems.add("节点数超过上限 " + MAX_GRAPH_NODES + ",请筛选重点节点");
+        }
+        Set<String> nodeIds = new HashSet<>();
+        for (com.fasterxml.jackson.databind.JsonNode node : nodes) {
+            String id = node.path("id").asText(null);
+            if (!StringUtils.hasText(id)) {
+                problems.add("存在缺失 id 的节点");
+                continue;
+            }
+            if (!nodeIds.add(id)) {
+                problems.add("节点 id 重复: " + id);
+            }
+            if (!node.hasNonNull("name")) {
+                problems.add("节点 " + id + " 缺失 name");
+            }
+        }
+        com.fasterxml.jackson.databind.JsonNode edges = root.get("edges");
+        if (edges == null || !edges.isArray() || edges.isEmpty()) {
+            problems.add("edges 必须是非空数组(没有边就不是关系图谱)");
+        } else if (edges.size() > MAX_GRAPH_EDGES) {
+            problems.add("边数超过上限 " + MAX_GRAPH_EDGES + ",请筛选重要关系");
+        } else {
+            for (com.fasterxml.jackson.databind.JsonNode edge : edges) {
+                String source = edge.path("source").asText(null);
+                String target = edge.path("target").asText(null);
+                if (!StringUtils.hasText(source) || !nodeIds.contains(source)) {
+                    problems.add("边的 source 引用了不存在的节点: " + source);
+                }
+                if (!StringUtils.hasText(target) || !nodeIds.contains(target)) {
+                    problems.add("边的 target 引用了不存在的节点: " + target);
+                }
+            }
+        }
+        return problems;
+    }
+
+    private static String error(String message) {
+        return "{\"error\": \"" + message.replace("\"", "'") + "\"}";
+    }
+}

+ 63 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/tools/PythonAnalysisTool.java

@@ -0,0 +1,63 @@
+package com.zsjz.ai.module.agent.tools;
+
+import com.zsjz.ai.common.utils.Json;
+import com.zsjz.ai.module.agent.python.PythonExecutor;
+import io.agentscope.core.tool.Tool;
+import io.agentscope.core.tool.ToolParam;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Agent Python 数据分析工具(子进程沙箱执行)
+ *
+ * <p>注册到 HarnessAgent 的 Toolkit,由 Agent(LLM)自行决定何时调用。
+ * 构造时绑定工作空间 ID:沙箱目录 / DuckDB 文件(ETL_DB_PATH 环境变量)
+ * / 图片输出目录均按工作空间隔离。
+ */
+@Slf4j
+public class PythonAnalysisTool {
+
+    private final PythonExecutor pythonExecutor;
+
+    public PythonAnalysisTool(PythonExecutor pythonExecutor) {
+        this.pythonExecutor = pythonExecutor;
+    }
+
+    /**
+     * 在隔离沙箱中执行 Python 数据分析代码
+     */
+    @Tool(name = "execute_python",
+          description = "在隔离沙箱中执行 Python 数据分析代码(pandas/numpy/duckdb/matplotlib 可用)。"
+                  + "通过环境变量 ETL_DB_PATH 获得当前工作空间 DuckDB 文件路径,"
+                  + "用 duckdb.connect(ETL_DB_PATH, read_only=True) 只读访问数据(示例:"
+                  + "import os, duckdb; con = duckdb.connect(os.environ['ETL_DB_PATH'], read_only=True))。"
+                  + "用 print() 输出分析结论文本;matplotlib 图表用 plt.savefig('chart.png') 保存"
+                  + "(系统自动捕获并展示给用户)。"
+                  + "适合 execute_sql 无法完成的复杂分析:统计建模、多步数据变换、专业可视化。",
+          readOnly = true)
+    public String executePython(
+            @ToolParam(name = "code", description = "Python 3 代码,可用库:pandas/numpy/duckdb/matplotlib;"
+                    + "超时 60 秒,注意控制数据量") String code) {
+        try {
+            PythonExecutor.PythonResult result = pythonExecutor.execute(code);
+
+            Map<String, Object> data = new LinkedHashMap<>();
+            data.put("exitCode", result.exitCode());
+            data.put("stdout", result.stdout());
+            if (result.stderr() != null && !result.stderr().isBlank()) {
+                data.put("stderr", result.stderr());
+            }
+            data.put("imageUrls", result.imageUrls());
+            data.put("durationMs", result.durationMs());
+            if (result.error() != null) {
+                data.put("error", result.error());
+            }
+            return Json.toStr(data);
+        } catch (Exception e) {
+            log.warn("Python 执行失败:  error={}", e.getMessage());
+            return "{\"error\": \"Python 执行失败: " + e.getMessage() + "\"}";
+        }
+    }
+}

+ 46 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/tools/RagSchemaSearchTool.java

@@ -0,0 +1,46 @@
+package com.zsjz.ai.module.agent.tools;
+
+import com.zsjz.ai.common.utils.Json;
+import com.zsjz.ai.module.agent.rag.RagSchemaService;
+
+import com.zsjz.ai.module.agent.vo.RagSearchResultVO;
+import io.agentscope.core.tool.Tool;
+import io.agentscope.core.tool.ToolParam;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.List;
+
+/**
+ * Agent 表结构语义检索工具
+ *
+ * <p>注册到 HarnessAgent 的 Toolkit,由 Agent(LLM)自行决定何时调用,
+ * 实现 Agentic RAG。构造时绑定工作空间ID,检索该工作空间的 DuckDB 表结构向量索引。
+ */
+@Slf4j
+public class RagSchemaSearchTool {
+
+    private final RagSchemaService ragSchemaService;
+
+    public RagSchemaSearchTool(RagSchemaService ragSchemaService) {
+        this.ragSchemaService = ragSchemaService;
+    }
+
+    /**
+     * 按语义检索当前工作空间 DuckDB 数据库的表结构
+     */
+    @Tool(name = "search_table_schema",
+          description = "按语义检索当前工作空间 DuckDB 数据库的表结构,返回最相关的表及其字段名、类型、注释、行数。"
+                  + "当需要了解有哪些数据表、字段含义,或为编写 SQL 挑选合适的表时,必须先调用本工具,而不是凭空猜测表名。",
+          readOnly = true)
+    public String searchTableSchema(
+            @ToolParam(name = "query", description = "自然语言查询,例如:包含手机号和身份证号的表") String query,
+            @ToolParam(name = "top_k", description = "返回最相似的前 K 张表,默认 5", required = false) Integer topK) {
+        try {
+            List<RagSearchResultVO> results = ragSchemaService.search(query, topK);
+            return Json.toStr(results);
+        } catch (Exception e) {
+            log.warn("表结构检索失败: query={}, error={}", query, e.getMessage());
+            return "{\"error\": \"表结构检索失败: " + e.getMessage() + "\"}";
+        }
+    }
+}

+ 314 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/tools/SqlAnalysisTool.java

@@ -0,0 +1,314 @@
+package com.zsjz.ai.module.agent.tools;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.zsjz.ai.common.enums.HasInnerEnum;
+import com.zsjz.ai.common.model.plat.entity.TableField;
+import com.zsjz.ai.common.model.plat.entity.TableInfo;
+import com.zsjz.ai.common.utils.Json;
+import com.zsjz.ai.module.agent.mapper.SqlQueryMapper;
+import com.zsjz.ai.module.agent.sql.SqlResultStore;
+
+import com.zsjz.ai.module.plat.mapper.TableFieldMapper;
+import com.zsjz.ai.module.plat.mapper.TableInfoMapper;
+import io.agentscope.core.tool.Tool;
+import io.agentscope.core.tool.ToolParam;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.util.StringUtils;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * Agent SQL 数据分析工具集(execute_sql / list_tables / render_chart)
+ *
+ * <p>注册到 HarnessAgent 的 Toolkit,由 Agent(LLM)自行决定何时调用。
+ * 构造时绑定工作空间 ID:execute_sql 走 slave(DuckDB),
+ * list_tables 走 master 元数据三表(meta_raw_sheet / table_info / table_field)。
+ */
+@Slf4j
+public class SqlAnalysisTool {
+
+    /** 结果集总行数硬上限(防内存溢出,超出让模型加 LIMIT/WHERE) */
+    private static final int MAX_TOTAL_ROWS = 100_000;
+
+    /** 每页行数默认值 */
+    private static final int DEFAULT_PAGE_SIZE = 50;
+
+    /** 每页行数上限 */
+    private static final int MAX_PAGE_SIZE = 500;
+
+    /** 单元格值转字符串截断长度 */
+    private static final int CELL_LIMIT = 500;
+
+    /** 只读语句首词白名单 */
+    private static final List<String> READ_ONLY_PREFIXES = List.of(
+            "select", "with", "pragma", "describe", "show", "explain");
+
+    private final SqlQueryMapper sqlQueryMapper;
+    private final SqlResultStore sqlResultStore;
+    private final TableInfoMapper tableInfoMapper;
+    private final TableFieldMapper tableFieldMapper;
+
+    public SqlAnalysisTool(SqlQueryMapper sqlQueryMapper,
+                           SqlResultStore sqlResultStore,
+                           TableInfoMapper tableInfoMapper,
+                           TableFieldMapper tableFieldMapper ) {
+        this.sqlQueryMapper = sqlQueryMapper;
+        this.sqlResultStore = sqlResultStore;
+        this.tableInfoMapper = tableInfoMapper;
+        this.tableFieldMapper = tableFieldMapper;
+    }
+
+    /**
+     * 在当前工作空间的 DuckDB 数据库上执行只读查询 SQL
+     */
+    @Tool(name = "execute_sql",
+          description = "在当前工作空间的 DuckDB 数据库上执行只读查询 SQL(仅允许 SELECT/WITH/PRAGMA/DESCRIBE/SHOW/EXPLAIN 开头的单条语句)。"
+                  + "返回 JSON 含 resultId/列定义/当前页数据/总行数/总页数,前端表格按 resultId 分页展示全部数据。"
+                  + "结果集不能超过 100000 行(大表务必加 LIMIT 或 WHERE 过滤)。",
+          readOnly = true)
+    public String executeSql(
+            @ToolParam(name = "sql", description = "DuckDB 方言的只读 SQL 查询语句") String sql,
+            @ToolParam(name = "page", description = "页码,从 1 开始,默认 1", required = false) Integer page,
+            @ToolParam(name = "page_size", description = "每页行数,默认 50,上限 500", required = false) Integer pageSize) {
+        try {
+            String error = validateReadOnly(sql);
+            if (error != null) {
+                return error(error);
+            }
+            int safePageSize = normalizePageSize(pageSize);
+            int safePage = page != null && page > 0 ? page : 1;
+
+            List<LinkedHashMap<String, Object>> allRows = sqlQueryMapper.executeQuery(sql.trim());
+            if (allRows.size() > MAX_TOTAL_ROWS) {
+                return error("结果集过大(" + allRows.size() + " 行,上限 " + MAX_TOTAL_ROWS
+                        + "),请加 LIMIT 或 WHERE 过滤后重试");
+            }
+
+            List<Map<String, Object>> columns = buildColumns(allRows);
+            String resultId = sqlResultStore.put(sql.trim(), columns, sanitizeRows(allRows));
+
+            return page(resultId, sql.trim(), columns, allRows, safePage, safePageSize);
+        } catch (Exception e) {
+            log.warn("SQL 执行失败: sql={}, error={}",  sql, e.getMessage());
+            return error("SQL 执行失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 列出当前工作空间所有数据表的完整清单
+     */
+    @Tool(name = "list_tables",
+          description = "列出当前工作空间所有数据表的完整清单(表英文名/中文名/行数/字段名、类型、中文注释)。"
+                  + "当 search_table_schema 语义检索未命中合适表,或需要精确确认表名和字段名时调用。",
+          readOnly = true)
+    public String listTables() {
+        try {
+            List<Map<String, Object>> tables = loadWorkspaceTableMeta();
+            return Json.toStr(tables);
+        } catch (Exception e) {
+            log.warn("表清单查询失败:  error={}",  e.getMessage());
+            return error("表清单查询失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 将查询结果渲染为 ECharts 图表
+     */
+    @Tool(name = "render_chart",
+          description = "将查询结果渲染为 ECharts 图表展示给用户(柱状图/折线图/饼图/散点图等常规图表)。"
+                  + "传入完整 ECharts option JSON 对象(基于已通过 execute_sql 查询到的数据组装,title/text 等文案用中文)。"
+                  + "用户需要常规图表可视化时必须调用本工具。注意:关系图谱/网络图/力导向图禁止使用本工具,必须用 render_graph。",
+          readOnly = true)
+    public String renderChart(
+            @ToolParam(name = "option", description = "完整的 ECharts option JSON 对象,例如柱状图含 title/xAxis/yAxis/series") String option) {
+        if (!StringUtils.hasText(option)) {
+            return error("option 不能为空");
+        }
+        try {
+            com.fasterxml.jackson.databind.JsonNode root = Json.objectMapper().readTree(option);
+            // 硬性拦截:关系图谱必须走 render_graph(手写 graph series 布局易失效,前端无法渲染节点)
+            if (containsGraphSeries(root)) {
+                return error("关系图谱/网络图禁止使用 render_chart 手写 graph 类型 series,"
+                        + "请改用 render_graph 工具传入结构化图谱 JSON(title/nodes/edges/categories),前端会渲染为力导向关系图");
+            }
+            return option.trim();
+        } catch (Exception e) {
+            return error("option 不是合法的 JSON 对象,请修正后重试: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 检测 option 中是否包含 graph 类型 series(兼容 {"option":{...}} 包裹形态)
+     */
+    private static boolean containsGraphSeries(com.fasterxml.jackson.databind.JsonNode root) {
+        if (!root.isObject()) {
+            return false;
+        }
+        com.fasterxml.jackson.databind.JsonNode target = root;
+        if (root.has("option") && root.get("option").isObject()) {
+            target = root.get("option");
+        }
+        com.fasterxml.jackson.databind.JsonNode series = target.get("series");
+        if (series == null) {
+            return false;
+        }
+        if (series.isArray()) {
+            for (com.fasterxml.jackson.databind.JsonNode s : series) {
+                if ("graph".equalsIgnoreCase(s.path("type").asText(""))) {
+                    return true;
+                }
+            }
+        } else if (series.isObject()) {
+            return "graph".equalsIgnoreCase(series.path("type").asText(""));
+        }
+        return false;
+    }
+
+    // ==================== 内部实现 ====================
+
+    /**
+     * 只读校验:trim → 去尾分号 → 禁多语句 → 首词白名单
+     *
+     * @return 违规时返回 error JSON,合规返回 null
+     */
+    private static String validateReadOnly(String sql) {
+        if (!StringUtils.hasText(sql)) {
+            return error("SQL 不能为空");
+        }
+        String trimmed = sql.trim();
+        while (trimmed.endsWith(";")) {
+            trimmed = trimmed.substring(0, trimmed.length() - 1).trim();
+        }
+        if (trimmed.contains(";")) {
+            return error("一次只能执行一条 SQL 语句(禁止多语句)");
+        }
+        String firstWord = trimmed.split("\\s+", 2)[0].toLowerCase();
+        if (!READ_ONLY_PREFIXES.contains(firstWord)) {
+            return error("仅允许只读查询语句(SELECT/WITH/PRAGMA/DESCRIBE/SHOW/EXPLAIN),"
+                    + "禁止 INSERT/UPDATE/DELETE/CREATE/DROP/ALTER 等");
+        }
+        return null;
+    }
+
+    private static int normalizePageSize(Integer pageSize) {
+        if (pageSize == null || pageSize <= 0) {
+            return DEFAULT_PAGE_SIZE;
+        }
+        return Math.min(pageSize, MAX_PAGE_SIZE);
+    }
+
+    /**
+     * 从首行推导列定义(LinkedHashMap 保序)
+     */
+    private static List<Map<String, Object>> buildColumns(List<LinkedHashMap<String, Object>> rows) {
+        List<Map<String, Object>> columns = new ArrayList<>();
+        if (!rows.isEmpty()) {
+            for (String key : rows.get(0).keySet()) {
+                Map<String, Object> col = new LinkedHashMap<>();
+                col.put("key", key);
+                col.put("label", key);
+                columns.add(col);
+            }
+        }
+        return columns;
+    }
+
+    /**
+     * 全量行清洗:单元格转字符串并截断(缓存存储形态)
+     */
+    private static List<Map<String, Object>> sanitizeRows(List<LinkedHashMap<String, Object>> rows) {
+        List<Map<String, Object>> cleaned = new ArrayList<>(rows.size());
+        for (Map<String, Object> row : rows) {
+            Map<String, Object> safe = new LinkedHashMap<>(row.size());
+            row.forEach((k, v) -> safe.put(k, cellToString(v)));
+            cleaned.add(safe);
+        }
+        return cleaned;
+    }
+
+    private static String cellToString(Object value) {
+        if (value == null) {
+            return null;
+        }
+        String s = String.valueOf(value);
+        return s.length() > CELL_LIMIT ? s.substring(0, CELL_LIMIT) + "..." : s;
+    }
+
+    /**
+     * 组装首页(当前页)返回 JSON(LLM 与前端 SSE 看到同一结构)
+     */
+    private static String page(String resultId, String sql, List<Map<String, Object>> columns,
+                               List<LinkedHashMap<String, Object>> allRows, int page, int pageSize) {
+        int totalRows = allRows.size();
+        int totalPages = Math.max(1, (totalRows + pageSize - 1) / pageSize);
+        int safePage = Math.min(Math.max(1, page), totalPages);
+        int from = Math.min((safePage - 1) * pageSize, totalRows);
+        int to = Math.min(from + pageSize, totalRows);
+
+        List<Map<String, Object>> pageRows = new ArrayList<>(to - from);
+        for (int i = from; i < to; i++) {
+            Map<String, Object> row = new LinkedHashMap<>();
+            allRows.get(i).forEach((k, v) -> row.put(k, cellToString(v)));
+            pageRows.add(row);
+        }
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("resultId", resultId);
+        result.put("sql", sql);
+        result.put("columns", columns);
+        result.put("rows", pageRows);
+        result.put("page", safePage);
+        result.put("pageSize", pageSize);
+        result.put("totalRows", totalRows);
+        result.put("totalPages", totalPages);
+        return Json.toStr(result);
+    }
+
+    /**
+     * 元数据三表链路:meta_raw_sheet → table_info → table_field
+     */
+    private List<Map<String, Object>> loadWorkspaceTableMeta() {
+        Map<Integer, TableInfo> infoById = tableInfoMapper.selectList(Wrappers.<TableInfo>lambdaQuery().eq(TableInfo::getHasTable, HasInnerEnum.YES)).stream()
+                .collect(Collectors.toMap(TableInfo::getId, t -> t, (a, b) -> a));
+
+        List<String> tableNames = infoById.values().stream()
+                .map(TableInfo::getTableNameEn).filter(StringUtils::hasText).toList();
+        Map<String, List<TableField>> fieldsByTable = tableNames.isEmpty() ? Map.of()
+                : tableFieldMapper.selectList(new LambdaQueryWrapper<TableField>()
+                        .in(TableField::getTableNameEn, tableNames)
+                        .orderByAsc(TableField::getFieldSort)).stream()
+                .collect(Collectors.groupingBy(TableField::getTableNameEn, LinkedHashMap::new,
+                        Collectors.toList()));
+
+        List<Map<String, Object>> tables = new ArrayList<>(infoById.size());
+        for (Map.Entry<Integer, TableInfo> e : infoById.entrySet()) {
+            TableInfo info = e.getValue();
+            if (!StringUtils.hasText(info.getTableNameEn())) {
+                continue;
+            }
+            Map<String, Object> table = new LinkedHashMap<>();
+            table.put("tableName", info.getTableNameEn());
+            table.put("tableComment", info.getTableNameCn());
+            List<Map<String, Object>> columns = new ArrayList<>();
+            for (TableField f : fieldsByTable.getOrDefault(info.getTableNameEn(), List.of())) {
+                Map<String, Object> col = new LinkedHashMap<>();
+                col.put("name", f.getFieldNameEn());
+                col.put("type", f.getFieldType());
+                col.put("comment", f.getFieldNameCn());
+                columns.add(col);
+            }
+            table.put("columns", columns);
+            tables.add(table);
+        }
+        return tables;
+    }
+
+    private static String error(String message) {
+        return "{\"error\": \"" + message.replace("\"", "'") + "\"}";
+    }
+}

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

@@ -0,0 +1,129 @@
+package com.zsjz.ai.module.agent.tools;
+
+import com.zsjz.ai.common.model.plat.entity.CaseInfo;
+import com.zsjz.ai.common.utils.Json;
+import com.zsjz.ai.module.agent.entity.AgentChatSession;
+import com.zsjz.ai.module.agent.mapper.AgentChatSessionMapper;
+import com.zsjz.ai.module.plat.mapper.CaseInfoMapper;
+import io.agentscope.core.agent.RuntimeContext;
+import io.agentscope.core.tool.Tool;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.util.StringUtils;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Agent 工作空间信息工具集(get_current_workspace / get_user_selected_workspace)
+ *
+ * <p>注册到 HarnessAgent 的 Toolkit,由 Agent(LLM)自行决定何时调用:
+ * <ul>
+ *   <li>{@code get_current_workspace}:当前会话绑定的工作空间(sessionKey → 会话 → workspaceId)</li>
+ *   <li>{@code get_user_selected_workspace}:当前登录用户选择的工作空间(userId → sys_user.last_workspace_id)</li>
+ * </ul>
+ */
+@Slf4j
+public class WorkspaceInfoTool {
+
+    private final AgentChatSessionMapper agentChatSessionMapper;
+    private final CaseInfoMapper caseInfoMapper;
+
+    public WorkspaceInfoTool(AgentChatSessionMapper agentChatSessionMapper,
+                             CaseInfoMapper caseInfoMapper) {
+        this.agentChatSessionMapper = agentChatSessionMapper;
+        this.caseInfoMapper = caseInfoMapper;
+    }
+
+    /**
+     * 查询当前会话所在的工作空间信息
+     */
+    @Tool(name = "get_current_workspace",
+            description = "查询当前会话使用的工作空间信息(工作空间ID、名称、编码、描述)。"
+                    + "当用户询问『当前工作空间』『现在在哪个工作空间』『这是什么项目环境』时调用,禁止凭空猜测。",
+            readOnly = true,
+            concurrencySafe = true)
+    public String getCurrentWorkspace(RuntimeContext ctx) {
+        try {
+            Integer workspaceId = resolveSessionWorkspaceId(ctx);
+            if (workspaceId == null) {
+                return error("当前会话未绑定工作空间,无法查询");
+            }
+            return workspaceJson(workspaceId);
+        } catch (Exception e) {
+            log.warn("查询当前会话工作空间失败: error={}", e.getMessage());
+            return error("查询当前会话工作空间失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 查询当前登录用户选择(上次使用)的工作空间信息
+
+     @Tool(name = "get_user_selected_workspace",
+     description = "查询当前登录用户选择的工作空间信息(工作空间ID、名称、编码、描述),"
+     + "即用户在界面上当前选用的工作空间。当用户询问『我选的是哪个工作空间』『我在用哪个工作空间』时调用,"
+     + "与 get_current_workspace(会话绑定)不同,此工具返回用户级选择。",
+     readOnly = true,
+     concurrencySafe = true)
+     public String getUserSelectedWorkspace(RuntimeContext ctx) {
+     try {
+     Long userId = parseLong(ctx.getUserId());
+     if (userId == null) {
+     return error("当前调用未携带用户信息,无法查询");
+     }
+     UserEntity user = userMapper.selectById(userId);
+     if (user == null) {
+     return error("用户不存在: " + userId);
+     }
+     Long workspaceId = user.getLastWorkspaceId();
+     if (workspaceId == null) {
+     return error("用户尚未选择过工作空间(首次使用默认工作空间)");
+     }
+     return workspaceJson(workspaceId);
+     } catch (Exception e) {
+     log.warn("查询用户所选工作空间失败: userId={}, error={}", ctx.getUserId(), e.getMessage());
+     return error("查询用户所选工作空间失败: " + e.getMessage());
+     }
+     } */
+
+    /**
+     * 工作空间ID → 标准信息 JSON
+     */
+    private String workspaceJson(Integer workspaceId) throws Exception {
+        CaseInfo workspace = caseInfoMapper.selectById(workspaceId);
+        if (workspace == null) {
+            return error("工作空间不存在: " + workspaceId);
+        }
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("workspaceId", String.valueOf(workspace.getId()));
+        data.put("workspaceName", workspace.getName());
+        data.put("description", workspace.getRemark());
+        return Json.objectMapper().writeValueAsString(data);
+    }
+
+    /**
+     * sessionKey(业务会话ID字符串)→ 会话 → 工作空间ID
+     */
+    private Integer resolveSessionWorkspaceId(RuntimeContext ctx) {
+        Long sessionId = parseLong(ctx.getSessionId());
+        if (sessionId == null) {
+            return null;
+        }
+        AgentChatSession session = agentChatSessionMapper.selectById(sessionId);
+        return session != null ? session.getCaseId() : null;
+    }
+
+    private static Long parseLong(String value) {
+        if (!StringUtils.hasText(value)) {
+            return null;
+        }
+        try {
+            return Long.parseLong(value.trim());
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    private static String error(String message) {
+        return "{\"error\":\"" + message.replace("\"", "'") + "\"}";
+    }
+}

+ 261 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/usage/UsageStore.java

@@ -0,0 +1,261 @@
+/*
+ * Copyright 2024-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.zsjz.ai.module.agent.usage;
+
+import org.springframework.stereotype.Component;
+
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.*;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+/**
+ * In-memory usage event store. Records individual turn events (one per user message) and provides
+ * hourly/daily aggregations for trend charts.
+ *
+ * <p>Data is <em>not</em> persisted across restarts. This is intentional for the current phase;
+ * the store is designed to be replaceable with a durable implementation later.
+ */
+@Component
+public class UsageStore {
+
+    /** Maximum number of raw events to retain in memory (rolling window). */
+    private static final int MAX_EVENTS = 50_000;
+
+    private final CopyOnWriteArrayList<UsageEvent> events = new CopyOnWriteArrayList<>();
+
+    /** Records a single turn completion. */
+    public void record(String userId, String agentId, long durationMs) {
+        events.add(new UsageEvent(System.currentTimeMillis(), userId, agentId, durationMs));
+        if (events.size() > MAX_EVENTS) {
+            events.removeFirst();
+        }
+    }
+
+    /** Returns all raw events (newest-first) up to {@code limit}. */
+    public List<UsageEvent> recentEvents(int limit) {
+        List<UsageEvent> copy = new ArrayList<>(events);
+        Collections.reverse(copy);
+        return copy.stream().limit(limit).toList();
+    }
+
+    /**
+     * Returns hourly turn counts for the past {@code hours} hours.
+     *
+     * @param hours number of hours of history to include (max 168 = 7 days)
+     */
+    public List<BucketCount> hourlyTurns(int hours) {
+        int h = Math.clamp(hours, 1, 168);
+        long nowMs = System.currentTimeMillis();
+        long startMs = nowMs - (long) h * 3_600_000L;
+
+        Map<Long, Integer> buckets = new TreeMap<>();
+        // Pre-fill all hours with 0
+        for (int i = 0; i < h; i++) {
+            long bucketMs = startMs + (long) i * 3_600_000L;
+            buckets.put(truncateHour(bucketMs), 0);
+        }
+        for (UsageEvent e : events) {
+            if (e.timestampMs() < startMs) continue;
+            long bucket = truncateHour(e.timestampMs());
+            buckets.merge(bucket, 1, Integer::sum);
+        }
+
+        return buckets.entrySet().stream()
+                .map(en -> new BucketCount(en.getKey(), labelHour(en.getKey()), en.getValue()))
+                .toList();
+    }
+
+    /**
+     * Returns daily turn counts for the past {@code days} days.
+     *
+     * @param days number of days of history to include (max 90)
+     */
+    public List<BucketCount> dailyTurns(int days) {
+        int d = Math.clamp(days, 1, 90);
+        long nowMs = System.currentTimeMillis();
+        long startMs = nowMs - (long) d * 86_400_000L;
+
+        Map<Long, Integer> buckets = new TreeMap<>();
+        for (int i = 0; i < d; i++) {
+            long bucketMs = startMs + (long) i * 86_400_000L;
+            buckets.put(truncateDay(bucketMs), 0);
+        }
+        for (UsageEvent e : events) {
+            if (e.timestampMs() < startMs) continue;
+            long bucket = truncateDay(e.timestampMs());
+            buckets.merge(bucket, 1, Integer::sum);
+        }
+
+        return buckets.entrySet().stream()
+                .map(en -> new BucketCount(en.getKey(), labelDay(en.getKey()), en.getValue()))
+                .toList();
+    }
+
+    /** Returns aggregate totals for a specific user only. */
+    public UsageSummary summaryForUser(String userId) {
+        List<UsageEvent> mine = events.stream().filter(e -> userId.equals(e.userId())).toList();
+        long totalTurns = mine.size();
+        long today = truncateDay(System.currentTimeMillis());
+        long todayTurns = mine.stream().filter(e -> truncateDay(e.timestampMs()) == today).count();
+        long avgDurationMs =
+                mine.isEmpty()
+                        ? 0L
+                        : (long)
+                                mine.stream().mapToLong(UsageEvent::durationMs).average().orElse(0);
+        return new UsageSummary(totalTurns, todayTurns, avgDurationMs, 1L);
+    }
+
+    /** Returns hourly turn counts for the past {@code hours} hours, scoped to one user. */
+    public List<BucketCount> hourlyTurnsForUser(String userId, int hours) {
+        int h = Math.clamp(hours, 1, 168);
+        long nowMs = System.currentTimeMillis();
+        long startMs = nowMs - (long) h * 3_600_000L;
+        Map<Long, Integer> buckets = new TreeMap<>();
+        for (int i = 0; i < h; i++) {
+            buckets.put(truncateHour(startMs + (long) i * 3_600_000L), 0);
+        }
+        for (UsageEvent e : events) {
+            if (!userId.equals(e.userId()) || e.timestampMs() < startMs) continue;
+            buckets.merge(truncateHour(e.timestampMs()), 1, Integer::sum);
+        }
+        return buckets.entrySet().stream()
+                .map(en -> new BucketCount(en.getKey(), labelHour(en.getKey()), en.getValue()))
+                .toList();
+    }
+
+    /** Returns daily turn counts for the past {@code days} days, scoped to one user. */
+    public List<BucketCount> dailyTurnsForUser(String userId, int days) {
+        int d = Math.clamp(days, 1, 90);
+        long nowMs = System.currentTimeMillis();
+        long startMs = nowMs - (long) d * 86_400_000L;
+        Map<Long, Integer> buckets = new TreeMap<>();
+        for (int i = 0; i < d; i++) {
+            buckets.put(truncateDay(startMs + (long) i * 86_400_000L), 0);
+        }
+        for (UsageEvent e : events) {
+            if (!userId.equals(e.userId()) || e.timestampMs() < startMs) continue;
+            buckets.merge(truncateDay(e.timestampMs()), 1, Integer::sum);
+        }
+        return buckets.entrySet().stream()
+                .map(en -> new BucketCount(en.getKey(), labelDay(en.getKey()), en.getValue()))
+                .toList();
+    }
+
+    /**
+     * Returns top-N users by turn count over the past {@code days} days.
+     * Used by admin usage dashboard.
+     */
+    public List<GroupCount> topUsersByTurns(int days, int topN) {
+        long startMs = System.currentTimeMillis() - (long) days * 86_400_000L;
+        Map<String, Integer> counts = new LinkedHashMap<>();
+        for (UsageEvent e : events) {
+            if (e.timestampMs() < startMs || e.userId() == null) continue;
+            counts.merge(e.userId(), 1, Integer::sum);
+        }
+        return counts.entrySet().stream()
+                .sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
+                .limit(topN)
+                .map(en -> new GroupCount(en.getKey(), en.getValue()))
+                .toList();
+    }
+
+    /**
+     * Returns top-N agents by turn count over the past {@code days} days.
+     * Used by admin usage dashboard.
+     */
+    public List<GroupCount> topAgentsByTurns(int days, int topN) {
+        long startMs = System.currentTimeMillis() - (long) days * 86_400_000L;
+        Map<String, Integer> counts = new LinkedHashMap<>();
+        for (UsageEvent e : events) {
+            if (e.timestampMs() < startMs || e.agentId() == null) continue;
+            counts.merge(e.agentId(), 1, Integer::sum);
+        }
+        return counts.entrySet().stream()
+                .sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
+                .limit(topN)
+                .map(en -> new GroupCount(en.getKey(), en.getValue()))
+                .toList();
+    }
+
+    /** Returns aggregate totals. */
+    public UsageSummary summary() {
+        long totalTurns = events.size();
+        long today = truncateDay(System.currentTimeMillis());
+        long todayTurns =
+                events.stream().filter(e -> truncateDay(e.timestampMs()) == today).count();
+        long avgDurationMs =
+                events.isEmpty()
+                        ? 0L
+                        : (long)
+                                events.stream()
+                                        .mapToLong(UsageEvent::durationMs)
+                                        .average()
+                                        .orElse(0);
+        long uniqueUsers =
+                events.stream()
+                        .map(UsageEvent::userId)
+                        .filter(u -> u != null && !u.isBlank())
+                        .distinct()
+                        .count();
+        return new UsageSummary(totalTurns, todayTurns, avgDurationMs, uniqueUsers);
+    }
+
+    // -----------------------------------------------------------------
+    //  Internal helpers
+    // -----------------------------------------------------------------
+
+    private static long truncateHour(long epochMs) {
+        return Instant.ofEpochMilli(epochMs)
+                .atZone(ZoneId.systemDefault())
+                .truncatedTo(ChronoUnit.HOURS)
+                .toInstant()
+                .toEpochMilli();
+    }
+
+    private static long truncateDay(long epochMs) {
+        return Instant.ofEpochMilli(epochMs)
+                .atZone(ZoneId.systemDefault())
+                .truncatedTo(ChronoUnit.DAYS)
+                .toInstant()
+                .toEpochMilli();
+    }
+
+    private static String labelHour(long epochMs) {
+        ZonedDateTime zdt = Instant.ofEpochMilli(epochMs).atZone(ZoneId.systemDefault());
+        return String.format("%02d:%02d", zdt.getHour(), 0);
+    }
+
+    private static String labelDay(long epochMs) {
+        ZonedDateTime zdt = Instant.ofEpochMilli(epochMs).atZone(ZoneId.systemDefault());
+        return String.format("%02d-%02d", zdt.getMonthValue(), zdt.getDayOfMonth());
+    }
+
+    // -----------------------------------------------------------------
+    //  DTO types
+    // -----------------------------------------------------------------
+
+    public record UsageEvent(long timestampMs, String userId, String agentId, long durationMs) {}
+
+    public record BucketCount(long epochMs, String label, int count) {}
+
+    public record GroupCount(String key, int count) {}
+
+    public record UsageSummary(
+            long totalTurns, long todayTurns, long avgDurationMs, long uniqueUsers) {}
+}

+ 24 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/vo/RagIndexStatusVO.java

@@ -0,0 +1,24 @@
+package com.zsjz.ai.module.agent.vo;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * RAG 索引状态响应VO
+ */
+@Data
+public class RagIndexStatusVO {
+
+    private Long workspaceId;
+    private Long embeddingModelId;
+    private String embeddingModelName;
+    private Integer dimensions;
+    private String vectorTable;
+    private String schemaFingerprint;
+    private Integer tableCount;
+    private String status;
+    private String lastError;
+    private LocalDateTime indexedAt;
+    private LocalDateTime updateAt;
+}

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

@@ -0,0 +1,37 @@
+package com.zsjz.ai.module.agent.vo;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * RAG 表结构检索结果VO
+ */
+@Data
+public class RagSearchResultVO {
+
+    /** 表名 */
+    private String tableName;
+
+    /** 表注释 */
+    private String tableComment;
+
+    /** 行数(DuckDB 估算) */
+    private Long rowCount;
+
+    /** 相似度得分(越高越相关) */
+    private Double score;
+
+    /** 字段列表 */
+    private List<ColumnMeta> columns;
+
+    /**
+     * 字段元信息
+     */
+    @Data
+    public static class ColumnMeta {
+        private String name;
+        private String type;
+        private String comment;
+    }
+}