cc hai 2 semanas
pai
achega
6e1430e7d5
Modificáronse 22 ficheiros con 1955 adicións e 773 borrados
  1. 61 0
      .zcode/plans/plan-sess_85020676-f10d-4fa4-a9b9-0e3b1a12e973.md
  2. 67 0
      ai-electron/frontend/src/ai/api/capabilityApi.ts
  3. 79 0
      ai-electron/frontend/src/ai/api/types.ts
  4. 0 10
      ai-electron/frontend/src/ai/composables/useAiChatPage.ts
  5. 257 698
      ai-electron/frontend/src/ai/views/aiPlugin/index.vue
  6. 24 53
      ai-electron/frontend/src/ai/views/aiWorkbench/index.vue
  7. 21 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/WorkspaceManagerFactory.java
  8. 94 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/controller/AgentCapabilityController.java
  9. 31 4
      ai-server/src/main/java/com/zsjz/ai/module/agent/mcp/AgentScopeMcpToolProvider.java
  10. 64 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/service/AgentCapabilityService.java
  11. 26 4
      ai-server/src/main/java/com/zsjz/ai/module/agent/service/AgentService.java
  12. 335 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/service/impl/AgentCapabilityServiceImpl.java
  13. 12 4
      ai-server/src/main/java/com/zsjz/ai/module/agent/service/impl/AgentChatServiceImpl.java
  14. 461 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/skill/SkillStore.java
  15. 34 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/tools/AgentToolRegistry.java
  16. 47 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/vo/McpServerInfoVO.java
  17. 47 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/vo/SkillDetailVO.java
  18. 28 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/vo/SkillVO.java
  19. 30 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/vo/ToolGroupVO.java
  20. 24 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/vo/ToolVO.java
  21. 165 0
      ai-server/src/test/java/com/zsjz/ai/module/agent/skill/SkillStoreTest.java
  22. 48 0
      ai-server/src/test/java/com/zsjz/ai/module/agent/tools/AgentToolRegistryTest.java

+ 61 - 0
.zcode/plans/plan-sess_85020676-f10d-4fa4-a9b9-0e3b1a12e973.md

@@ -0,0 +1,61 @@
+# 「插件与技能」改造为服务端驱动(技能存 .agentscope 用户级目录)
+
+## 一、存储与隔离口径(按您的两条纠正)
+
+- **技能装在用户级目录、与案件无关、不维护数据库**:
+  `{user.dir}/.agentscope/users/{当前登录用户ID}/skills/<技能名>/SKILL.md`
+  —— 与 harness 的工作区同根(工作区是 `{user.dir}/.agentscope/users/{userId}/workspaces/{案件}/agents/{行}`),**一律不碰 `PathConst.SKILLS_PATH`**,agent 相关文件全部留在 `.agentscope` 下。
+  - 定位口径收在 `WorkspaceManagerFactory`(它本来就是 `.agentscope` 布局的所有者):新增 `resolveUserSkillsPath(Path cwd, Long userId)`,能力服务与 `AgentService` 都调它,避免两处各拼一次路径。
+  - 同一用户跨案件共享技能;用户之间互不可见。
+- **启停状态**:技能目录下的 `.disabled` 标记文件(存在=停用)。用标记文件而不是集中名单,避免「手工往目录里放技能」时名单与实际目录漂移;harness 的技能扫描只认 `SKILL.md`,多一个点文件不影响加载。
+- **对话 agent 怎么用上这些技能**(两处改动):
+  1. `AgentService.buildAgent`:把该用户的技能目录作为技能仓库挂给 agent(`Files.isDirectory` 守卫,与现有 `skill_repositories_json` 处理并列)。
+  2. `AgentChatServiceImpl.doStream`:该处**已经**每轮对话构造 `RuntimeContext`(:348),加一行 `.put(SkillFilter.class, SkillFilter.disable(已停用技能名…))` → harness 侧 `effectiveFilter = builderFilter.overlay(ctx.get(SkillFilter.class))`(源码已确认),overlay 语义 = 「只停列出的,其余照旧」,**不需要重建 agent 实例**。
+
+## 二、后端改动(ai-server / module/agent)
+
+**新增 `controller/AgentCapabilityController.java`(`@RequestMapping("/capability")`,`Result<T>` 包装;技能接口与案件无关,不传 caseId)**
+
+| 接口 | 作用 |
+|---|---|
+| `GET /capability/tools` | 6 个业务工具组(组名/组描述/是否默认装备/工具列表=名称+描述+参数 JSON Schema)+ 1 个「基础工具」组(execute_sql、检索表结构、执行 Python、图谱渲染、工作区信息等) |
+| `GET /capability/mcp` | MCP 接入信息(只读):是否启用/协议/服务名与版本/端点地址/已暴露工具数 |
+| `GET /capability/skills` | 技能清单:用户目录里的技能(可管理)+ `skill_repositories_json` 里 filesystem 仓库的技能(只读),带来源与启停状态 |
+| `GET /capability/skills/{name}` | 技能详情:SKILL.md 正文 + 附属文件清单 |
+| `POST /capability/skills/install` | 上传 zip 安装:路径穿越与技能名合法性校验 → 解压到用户技能目录(同名需显式 `overwrite`) |
+| `GET /capability/skills/{name}/delete` | 卸载(删目录;沿用本项目「删除用 GET」的既有风格,同 `AgentModelController`) |
+| `GET /capability/skills/{name}/toggle?enabled=` | 启停:写/删 `.disabled` 标记 |
+
+**新增 `service/AgentCapabilityService` + `impl`**(接口/实现分离、`@RequiredArgsConstructor`、`ServerException(404/400, …)`,照 `AgentModelServiceImpl` 的写法):
+- 工具枚举:`new Toolkit()` + `agentToolRegistry.registerBusinessTools(tk, 全部组)` → `getToolGroup(name)`(组描述/组内工具)+ `getToolSchemas()`(描述+参数);基础工具取自 MCP provider 已缓存的工具定义。
+- 技能枚举:用户技能目录(一层一技能,要求 `SKILL.md`;启停名以 frontmatter 的 `name` 为准,缺失时退回目录名)+ 配置仓库(**跳过 git 条目**:会 clone/sync 有副作用,依赖也不在 pom 里)。
+- 安装/卸载/启停:纯 `Files` 操作;zip 用项目已在用的 hutool `ZipUtil`(`SystemService.java:265` 有先例)+ 路径穿越校验。
+
+**改动既有文件(小改)**
+1. `WorkspaceManagerFactory.java`:新增 `resolveUserSkillsPath(cwd, userId)`。
+2. `service/AgentService.java`:挂载用户技能目录 + **实例池 key 加 userId**(现 key 是 `w{案件}-a{行}-m{模型}`,不含用户 → 用户 A 建的 agent 会被 B 复用,技能与工作区就串了,与「用户隔离」直接冲突,必须修)。
+3. `service/impl/AgentChatServiceImpl.java`:`RuntimeContext` 上挂 `SkillFilter`(约 5 行)。
+4. `mcp/AgentScopeMcpToolProvider.java`:加只读访问器(暴露已缓存 toolkit 的 `ToolSchema`),避免能力服务重抄基础工具注册逻辑。
+5. `tools/AgentToolRegistry.java`:加 `public static List<String> allGroupNames()`(`Toolkit` 没有列举组的 API)。
+
+## 三、前端改动(ai-electron/frontend)
+
+1. **新增 `ai/api/capabilityApi.ts`**(照 `modelApi.ts` 风格,multipart 上传走 `aiHttp.request`)+ `ai/api/types.ts` 补类型。
+2. **重写 `ai/views/aiPlugin/index.vue`**,三个页签:
+   - **工具能力**(默认):按组卡片(组名/描述/工具数/「默认装备」徽标)→ 抽屉看工具清单(名称/描述/参数表),带关键字过滤。
+   - **技能**:卡片列表(名称/描述/来源/启停开关)+「上传 zip 安装」「卸载」;抽屉看 SKILL.md 与附属文件;顶部标明技能所在目录(`.agentscope/users/{用户}/skills`)。
+   - **MCP 接入**:端点/协议/服务名/版本/暴露工具数 + 外部模型接入说明。
+   - 删除页面里全部 `codexApi`(IPC)用法与「仅桌面客户端可用」提示(这套现在浏览器里也能用)。
+3. 工作台 `aiWorkbench/index.vue` 无需改动;旧工作台 `codex/views/workbench/WbPluginPage.vue` 保持原样。
+
+## 四、验证
+
+1. 后端:编译通过;给技能「安装/卸载/启停」的文件层与「工具枚举」补 1~2 个单测(临时目录,不依赖 Spring/DB)并跑通。
+2. 前端:`vue-tsc` 无新增错误 + `vite build` 通过。
+3. 浏览器实测(沿用独立端口 + mock,不碰您的 dev server 与 8980 真实后端):工作台 → 插件与技能 → 三页签渲染;上传一个最小 zip 安装出技能;启停后 `.disabled` 标记与页面状态一致;卸载后消失。
+4. **需要真实对话才能确认的一条我会如实说明**:停用后技能是否真的从 agent 可用列表消失(不再进 prompt)——这要靠一次真实对话观察,或您允许时我连真实后端跑一次。
+
+## 五、明确不做 / 待确认
+
+- **不做**:目录安装(Electron 选目录 + 绝对路径信任问题)、技能市场(Git/Nacos/MySQL 仓库)、MCP 可配置化(服务端目前只「对外暴露 MCP」,没有「消费外部 MCP」的能力)。
+- 顺带发现、本轮不修(可另开):会话表没有属主列(同案其他用户能看到此前会话);`agent` 表缺建表脚本。

+ 67 - 0
ai-electron/frontend/src/ai/api/capabilityApi.ts

@@ -0,0 +1,67 @@
+/**
+ * 服务端 agent 能力接口(`/capability/**`,后端统一 `Result<T>` 包装)
+ *
+ * 「插件与技能」页的数据源,全部走服务端 agent 模块:
+ *  - 工具能力:agent 会话里能调用哪些工具(业务工具组 + 基础工具)
+ *  - 技能:磁盘上的 SKILL.md 包,装在服务端本机的**用户级目录**
+ *    ({user.dir}/.agentscope/users/{用户ID}/skills),与案件无关,不落库
+ *  - MCP 接入:本服务对外的 MCP 端点与已暴露工具数(只读)
+ */
+import { defHttp } from '@/core/utils/http/axios';
+import { AI_ADMIN_PATH } from '../utils/constants';
+import { aiHttp } from './http';
+import type { McpServerInfo, Skill, SkillDetail, ToolGroup } from './types';
+
+/** 工具能力清单(业务工具组 + 基础工具组) */
+export function listToolGroups(): Promise<ToolGroup[]> {
+  return aiHttp.get<ToolGroup[]>('/capability/tools');
+}
+
+/** MCP 接入信息(端点 / 协议 / 已暴露工具数) */
+export function mcpServerInfo(): Promise<McpServerInfo> {
+  return aiHttp.get<McpServerInfo>('/capability/mcp');
+}
+
+/**
+ * 技能清单。
+ *
+ * @param caseId 案件ID,可选;传了才会额外带上「该案件 agent 配置的技能仓库」里的只读技能
+ */
+export function listSkills(caseId?: string | number | null): Promise<Skill[]> {
+  return aiHttp.get<Skill[]>('/capability/skills', caseId ? { caseId: String(caseId) } : undefined);
+}
+
+/** 技能详情(SKILL.md 正文 + 附属文件清单) */
+export function readSkill(name: string, caseId?: string | number | null): Promise<SkillDetail> {
+  return aiHttp.get<SkillDetail>(
+    `/capability/skills/${encodeURIComponent(name)}`,
+    caseId ? { caseId: String(caseId) } : undefined,
+  );
+}
+
+/**
+ * 上传 zip 安装技能(服务端解压到当前登录用户的技能目录)。
+ *
+ * <p>走 `defHttp.uploadFile`:它负责把 FormData 的 content-type 设成 multipart,
+ * 直接传 FormData 会被实例默认的 form-urlencoded 头覆盖。`overwrite` 作为表单字段下发,
+ * 后端用 `@RequestParam` 接收(同名技能默认不覆盖,避免误删用户改过的技能)。
+ */
+export async function installSkillZip(file: File, overwrite = false): Promise<Skill> {
+  const res: any = await defHttp.uploadFile<Skill>(
+    { url: `${AI_ADMIN_PATH}/capability/skills/install` },
+    { name: 'file', file, filename: file.name, ...(overwrite ? { overwrite: 'true' } : {}) },
+  );
+  // uploadFile 的返回类型标的是 AxiosResponse,实际经响应拦截器已按 isTransformResponse 拆包。
+  // 两种形态都兼容,避免拿到 { data: Skill } 却被当成技能对象用(那样页面只会显示文件名)。
+  return (res && typeof res === 'object' && 'name' in res ? res : res?.data) as Skill;
+}
+
+/** 卸载技能(删除用户技能目录;配置仓库里的技能不可卸载) */
+export function deleteSkill(name: string): Promise<void> {
+  return aiHttp.get<void>(`/capability/skills/${encodeURIComponent(name)}/delete`);
+}
+
+/** 启停技能(停用后下一轮对话即不生效,不需要重建会话) */
+export function toggleSkill(name: string, enabled: boolean): Promise<void> {
+  return aiHttp.get<void>(`/capability/skills/${encodeURIComponent(name)}/toggle`, { enabled });
+}

+ 79 - 0
ai-electron/frontend/src/ai/api/types.ts

@@ -358,3 +358,82 @@ export interface SseHandlers {
   /** 网络层异常(非后端 error 帧) */
   onTransportError?: (err: unknown) => void;
 }
+
+/* ------------------------------------------------------------------ *
+ * 服务端 agent 能力(工具 / 技能 / MCP 接入)
+ * ------------------------------------------------------------------ */
+
+/**
+ * 工具入参 JSON Schema(由 agent 框架生成,原样下发)。
+ *
+ * 页面只读 `properties`(字段名/类型/说明)与 `required`,因此这里按用到的部分声明;
+ * `$defs`/嵌套结构一律当透传对象用,不做二次建模。
+ */
+export interface ToolJsonSchema {
+  type?: string;
+  properties?: Record<string, { type?: string; description?: string; $ref?: string } & Record<string, any>>;
+  required?: string[];
+  $defs?: Record<string, any>;
+}
+
+/** agent 可调用的一个工具 */
+export interface CapabilityTool {
+  name: string;
+  description?: string;
+  parameters?: ToolJsonSchema;
+}
+
+/** 工具组(业务域或基础工具) */
+export interface ToolGroup {
+  code: string;
+  label: string;
+  /** 是否新会话默认装备 */
+  defaultActive?: boolean;
+  description?: string;
+  toolCount?: number;
+  tools?: CapabilityTool[];
+}
+
+/** 技能(磁盘上一个含 SKILL.md 的目录) */
+export interface Skill {
+  name: string;
+  description?: string;
+  /** user = 用户技能目录(可管理);repo = agent 配置的技能仓库(只读) */
+  source?: string;
+  managed?: boolean;
+  enabled?: boolean;
+  /** 技能所在目录(服务端本机路径) */
+  path?: string;
+}
+
+/** 技能附属文件 */
+export interface SkillFile {
+  /** 相对技能目录的路径 */
+  path: string;
+  size?: number;
+}
+
+/** 技能详情 */
+export interface SkillDetail extends Skill {
+  /** SKILL.md 正文 */
+  content?: string;
+  files?: SkillFile[];
+}
+
+/** MCP 接入信息(服务端对外暴露工具,只读) */
+export interface McpServerInfo {
+  enabled?: boolean;
+  name?: string;
+  version?: string;
+  /** STREAMABLE / SSE */
+  protocol?: string;
+  /** 当前协议生效的端点路径 */
+  endpoint?: string;
+  sseEndpoint?: string;
+  contextPath?: string;
+  port?: number;
+  toolCount?: number;
+  /** MCP 专用案件管理工具(外部客户端先调它们绑定案件) */
+  caseTools?: string[];
+  note?: string;
+}

+ 0 - 10
ai-electron/frontend/src/ai/composables/useAiChatPage.ts

@@ -44,15 +44,6 @@ export function useAiChatPage() {
    */
   const chatModels = computed(() => models.value.filter((m) => !m.type || m.type === 'llm'));
 
-  /**
-   * 当前模型的展示名(顶栏 chip 用)。
-   * 取不到选中项时退化为「默认模型」:后端默认模型由列表接口的 defaultModel 标记,不在这里兜底查询。
-   */
-  const modelLabel = computed(() => {
-    const hit = chatModels.value.find((m) => String(m.id) === String(modelId.value ?? ''));
-    return hit?.name || hit?.modelId || '默认模型';
-  });
-
   onMounted(async () => {
     if (workspaceId.value) {
       await store.init(workspaceId.value);
@@ -169,7 +160,6 @@ export function useAiChatPage() {
     models,
     modelId,
     chatModels,
-    modelLabel,
     workspaceId,
     caseName,
     activeTitle,

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 257 - 698
ai-electron/frontend/src/ai/views/aiPlugin/index.vue


+ 24 - 53
ai-electron/frontend/src/ai/views/aiWorkbench/index.vue

@@ -7,23 +7,11 @@
       show-case-exit
       @exit-case="onExitCase"
     >
-      <!-- 工作台特有状态 chip:当前会话 / 当前模型 -->
+      <!-- 工作台特有状态 chip:当前会话 -->
       <template #info>
-        <span v-if="activeTitle" class="awb-chip awb-chip--muted" :title="activeTitle">
+        <span v-if="activeTitle" class="awb-chip" :title="activeTitle">
           {{ activeTitle }}
         </span>
-        <span class="awb-chip awb-chip--model">
-          <Icon icon="mdi:robot-outline" :size="13" />
-          {{ modelLabel }}
-        </span>
-      </template>
-
-      <template #actions>
-        <Tooltip title="重新加载会话列表" placement="bottom">
-          <button class="awb-iconbtn" type="button" @click="onReload">
-            <Icon icon="mdi:refresh" :size="15" />
-          </button>
-        </Tooltip>
       </template>
     </AppTopBar>
 
@@ -48,8 +36,8 @@
           :sessions="store.sessions"
           :active-id="store.activeSessionId"
           :loading="store.loadingSessions"
-          @create="onCreate"
-          @select="onSelect"
+          @create="onCreateSession"
+          @select="onSelectSession"
           @rename="onRename"
           @pin="onPin"
           @remove="onRemove"
@@ -97,8 +85,7 @@
    * 组件级复用:会话侧栏 / 对话面板 / 模型页 / 插件页全部来自 ai 模块现有实现,
    * 本文件只做装配与工作台外壳,不含协议逻辑。
    */
-  import { computed, ref } from 'vue';
-  import { Tooltip } from 'ant-design-vue';
+  import { computed, nextTick, ref } from 'vue';
   import Icon from '@/core/components/Icon/src/Icon.vue';
   import { AppTopBar } from '@/core/components/Application';
   import { useMessage } from '@/core/hooks/web/useMessage';
@@ -127,7 +114,6 @@
     panelRef,
     modelId,
     chatModels,
-    modelLabel,
     workspaceId,
     caseName,
     activeTitle,
@@ -138,10 +124,28 @@
     onPin,
     onRemove,
     onRemoveMessage,
-    onReload,
     onSend,
   } = useAiChatPage();
 
+  /**
+   * 左栏会话列表在三个视图下都常驻,所以「选会话 / 新建会话」必须先把主区切回对话视图:
+   * 否则在模型管理或插件页点会话,选中的会话在别处看不见,用户会以为点击没生效。
+   *
+   * 先切视图再 await nextTick() 的原因:对话面板是 v-if 渲染的,切换后要等它挂载出来,
+   * 接下来的「回到底部 / 聚焦输入框」才有目标(面板未挂载时 panelRef 还是空)。
+   */
+  async function onSelectSession(id: string) {
+    view.value = 'chat';
+    await nextTick();
+    await onSelect(id);
+  }
+
+  async function onCreateSession() {
+    view.value = 'chat';
+    await nextTick();
+    await onCreate();
+  }
+
   /**
    * 退出案件:回案件管理页重新选择(工作台不允许直接切案件)。
    * 破坏性动作(当前会话上下文、工作台状态都会离开)先弹确认框。
@@ -182,39 +186,6 @@
     text-overflow: ellipsis;
     background: var(--ai-bg-subtle);
     border-radius: 99px;
-
-    &.awb-chip--muted {
-      color: var(--ai-text-2);
-    }
-
-    &.awb-chip--model {
-      font-weight: 600;
-      color: var(--ai-primary);
-      background: var(--ai-primary-soft);
-    }
-  }
-
-  /* 顶栏图标按钮:尺寸对齐传统顶栏的图标按钮(36px 方块 + 悬停底色) */
-  .awb-iconbtn {
-    display: inline-flex;
-    align-items: center;
-    justify-content: center;
-    width: 36px;
-    min-width: 36px;
-    height: 36px;
-    padding: 0;
-    font-size: 18px;
-    color: var(--cb-text-primary);
-    background: transparent;
-    border: none;
-    border-radius: 10px;
-    cursor: pointer;
-    transition: background-color 0.2s ease;
-
-    &:hover {
-      color: var(--ai-primary);
-      background: var(--ai-bg-hover);
-    }
   }
 
   .awb-body {

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

@@ -11,6 +11,8 @@ import java.nio.file.Paths;
  * <p>目录结构固定为:
  * <pre>{cwd}/.agentscope/users/{userId}/workspaces/{workspaceId}/agents/{agentRowId}/</pre>
  *
+ * <p>另有一处<b>用户级</b>目录(技能仓库,与工作空间无关):{@link #resolveUserSkillsPath}</p>
+ *
  * <p>用户间、工作空间间的 agent 文件空间(skills / 记忆 / 产出文件)相互独立,
  * 模型切换不改变数据归属。
  *
@@ -46,6 +48,25 @@ public final class WorkspaceManagerFactory {
                 .normalize();
     }
 
+    /**
+     * 解析用户的<b>技能仓库</b>目录。
+     *
+     * <p>目录结构:{@code {cwd}/.agentscope/users/{userId}/skills} —— 与
+     * {@link #resolveAgentDataPath} 里用户根目录平级的兄弟目录,agent 相关文件一律留在
+     * {@code .agentscope} 下。技能按<b>用户</b>归属、与案件无关:同一用户在任意案件里看到的
+     * 是同一批技能,用户之间互不可见。
+     *
+     * @param userId 用户标识(业务用户ID字符串)
+     * @return 用户技能目录的绝对路径(调用方负责按需创建)
+     */
+    public Path resolveUserSkillsPath(Path cwd, Long userId) {
+        validateSegment("userId", String.valueOf(userId));
+        return cwd.resolve(".agentscope")
+                .resolve("users").resolve(userId.toString())
+                .resolve("skills")
+                .normalize();
+    }
+
     private static void validateSegment(String label, String value) {
         if (value == null || value.isBlank()) {
             throw new IllegalArgumentException(label + " must not be null or blank");

+ 94 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/controller/AgentCapabilityController.java

@@ -0,0 +1,94 @@
+package com.zsjz.ai.module.agent.controller;
+
+import com.zsjz.ai.common.config.Result;
+import com.zsjz.ai.module.agent.service.AgentCapabilityService;
+import com.zsjz.ai.module.agent.vo.McpServerInfoVO;
+import com.zsjz.ai.module.agent.vo.SkillDetailVO;
+import com.zsjz.ai.module.agent.vo.SkillVO;
+import com.zsjz.ai.module.agent.vo.ToolGroupVO;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.util.List;
+
+/**
+ * 服务端 agent 能力清单与技能管理(「插件与技能」页的数据源)。
+ *
+ * <p>与 {@code /models} 一样走 {@code Result<T>} 包装;技能按<b>登录用户</b>隔离,
+ * 装在 {@code {cwd}/.agentscope/users/{userId}/skills},与案件无关,因此除「展示该案件配置的
+ * 只读技能仓库」外都不需要 caseId。
+ */
+@Slf4j
+@RestController
+@RequestMapping("/capability")
+@RequiredArgsConstructor
+public class AgentCapabilityController {
+
+    private final AgentCapabilityService agentCapabilityService;
+
+    /**
+     * 工具能力清单:业务工具组(人员画像/通话/交易/轨迹/图谱/非表格材料)+ 基础工具组。
+     */
+    @GetMapping("/tools")
+    public Result<List<ToolGroupVO>> listTools() {
+        return Result.succeed(agentCapabilityService.listToolGroups());
+    }
+
+    /**
+     * MCP 接入信息(只读):端点、协议、以及本服务已暴露的工具数。
+     */
+    @GetMapping("/mcp")
+    public Result<McpServerInfoVO> mcpInfo() {
+        return Result.succeed(agentCapabilityService.mcpServerInfo());
+    }
+
+    /**
+     * 技能清单:当前用户可管理的技能 + 该案件下 agent 配置的技能仓库(只读)。
+     *
+     * @param caseId 案件ID,可选;为空时只返回用户技能
+     */
+    @GetMapping("/skills")
+    public Result<List<SkillVO>> listSkills(@RequestParam(required = false) Long caseId) {
+        return Result.succeed(agentCapabilityService.listSkills(caseId));
+    }
+
+    /**
+     * 技能详情:SKILL.md 正文 + 附属文件清单。
+     */
+    @GetMapping("/skills/{name}")
+    public Result<SkillDetailVO> readSkill(@PathVariable String name,
+                                           @RequestParam(required = false) Long caseId) {
+        return Result.succeed(agentCapabilityService.readSkill(name, caseId));
+    }
+
+    /**
+     * 上传 zip 安装技能到当前用户的技能目录。
+     *
+     * @param overwrite 同名技能是否覆盖(默认 false:已存在则报错,避免误覆盖)
+     */
+    @PostMapping("/skills/install")
+    public Result<SkillVO> installSkill(@RequestParam("file") MultipartFile file,
+                                        @RequestParam(defaultValue = "false") boolean overwrite) {
+        return Result.succeed(agentCapabilityService.installSkillZip(file, overwrite));
+    }
+
+    /**
+     * 卸载技能(删除技能目录)。沿用本项目「删除用 GET」的既有风格,同 {@code AgentModelController}。
+     */
+    @GetMapping("/skills/{name}/delete")
+    public Result<Void> deleteSkill(@PathVariable String name) {
+        agentCapabilityService.deleteSkill(name);
+        return Result.succeed();
+    }
+
+    /**
+     * 启停技能:停用 = 写 {@code .disabled} 标记(下一轮对话即不生效),启用 = 删除标记。
+     */
+    @GetMapping("/skills/{name}/toggle")
+    public Result<Void> toggleSkill(@PathVariable String name, @RequestParam boolean enabled) {
+        agentCapabilityService.toggleSkill(name, enabled);
+        return Result.succeed();
+    }
+}

+ 31 - 4
ai-server/src/main/java/com/zsjz/ai/module/agent/mcp/AgentScopeMcpToolProvider.java

@@ -84,6 +84,19 @@ public class AgentScopeMcpToolProvider implements ToolCallbackProvider {
      */
     private static final Duration TOOL_TIMEOUT = Duration.ofMinutes(5);
 
+    /** 案件管理工具名:外部客户端必须先调它们绑定案件(页面「MCP 接入」会展示这份清单) */
+    public static final String TOOL_LIST_CASES = "list_cases";
+
+    public static final String TOOL_OPEN_CASE = "open_case";
+
+    public static final String TOOL_CURRENT_CASE = "current_case";
+
+    public static final String TOOL_CLOSE_CASE = "close_case";
+
+    /** 全部案件管理工具名(顺序即页面展示顺序) */
+    public static final List<String> CASE_TOOL_NAMES =
+            List.of(TOOL_LIST_CASES, TOOL_OPEN_CASE, TOOL_CURRENT_CASE, TOOL_CLOSE_CASE);
+
     // ==================== 依赖(与 AgentService#buildAgent 保持一致) ====================
 
     private final AgentToolRegistry agentToolRegistry;
@@ -146,6 +159,20 @@ public class AgentScopeMcpToolProvider implements ToolCallbackProvider {
 
     // ==================== 构建 ====================
 
+    /**
+     * 只读访问器:已缓存的工具容器(供「插件与技能」页枚举<b>基础工具</b>用)。
+     *
+     * <p>基础工具(SQL / 检索表结构 / Python / 图谱渲染 / 工作区信息)的注册条件与依赖都在本类,
+     * 与其在能力服务里再抄一遍,不如复用这份实例。首次调用会触发构建,
+     * 与 MCP 客户端首次连接时拿到的是同一份,没有运行时副作用。
+     */
+    public Toolkit toolkit() {
+        if (toolkit == null) {
+            getToolCallbacks();
+        }
+        return toolkit;
+    }
+
     private ToolCallback[] build() {
         Toolkit tk = buildToolkit();
         this.toolkit = tk;
@@ -295,7 +322,7 @@ public class AgentScopeMcpToolProvider implements ToolCallbackProvider {
     private List<ToolCallback> caseManagementTools() {
         List<ToolCallback> tools = new ArrayList<>();
 
-        tools.add(simple("list_cases",
+        tools.add(simple(TOOL_LIST_CASES,
                 "列出本机全部案件(ID、名称、是否已初始化数据库)。"
                         + "★ 外部客户端接入后必须先调用本工具拿到案件 ID,再调用 open_case 打开案件,"
                         + "否则所有通话/交易/轨迹/画像类工具都会报「缺少案件上下文」。",
@@ -317,7 +344,7 @@ public class AgentScopeMcpToolProvider implements ToolCallbackProvider {
                     return Json.toStr(Map.of("cases", items));
                 }));
 
-        tools.add(simple("open_case",
+        tools.add(simple(TOOL_OPEN_CASE,
                 "打开指定案件并绑定到当前 MCP 会话。绑定后所有业务工具(通话/交易/轨迹/画像/图谱/SQL)"
                         + "都作用于该案件的数据。换案件重复调用本工具即可。"
                         + "参数 case_id 来自 list_cases。",
@@ -333,7 +360,7 @@ public class AgentScopeMcpToolProvider implements ToolCallbackProvider {
                             "opened", true));
                 }));
 
-        tools.add(simple("current_case",
+        tools.add(simple(TOOL_CURRENT_CASE,
                 "查看当前 MCP 会话已绑定的案件;未绑定时提示先调用 open_case。",
                 emptySchema(),
                 (input, ctx) -> {
@@ -344,7 +371,7 @@ public class AgentScopeMcpToolProvider implements ToolCallbackProvider {
                     return Json.toStr(Map.of("caseId", ci.getId(), "name", ci.getName() == null ? "" : ci.getName()));
                 }));
 
-        tools.add(simple("close_case",
+        tools.add(simple(TOOL_CLOSE_CASE,
                 "关闭当前 MCP 会话绑定的案件数据源(释放 DuckDB 内存)。"
                         + "仅影响本 MCP 会话,不影响 Web 端已打开的案件。",
                 emptySchema(),

+ 64 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/service/AgentCapabilityService.java

@@ -0,0 +1,64 @@
+package com.zsjz.ai.module.agent.service;
+
+import com.zsjz.ai.module.agent.vo.McpServerInfoVO;
+import com.zsjz.ai.module.agent.vo.SkillDetailVO;
+import com.zsjz.ai.module.agent.vo.SkillVO;
+import com.zsjz.ai.module.agent.vo.ToolGroupVO;
+import io.agentscope.core.skill.SkillFilter;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.util.List;
+
+/**
+ * 服务端 agent 的能力清单与技能管理。
+ *
+ * <p>「插件与技能」页的数据源:工具(agent 能调用什么)、技能(磁盘上的 SKILL.md 包)、
+ * MCP 接入信息(本服务怎么对外暴露工具)。技能存本地磁盘、按用户隔离、不落库。
+ */
+public interface AgentCapabilityService {
+
+    /**
+     * 工具清单:业务工具组(按域分组)+ 基础工具组。
+     */
+    List<ToolGroupVO> listToolGroups();
+
+    /**
+     * MCP 接入信息(只读)。
+     */
+    McpServerInfoVO mcpServerInfo();
+
+    /**
+     * 技能清单:用户技能目录(可管理)+ 该案件下 agent 配置的技能仓库(只读)。
+     *
+     * @param caseId 案件ID,可为空(为空时只返回用户技能)
+     */
+    List<SkillVO> listSkills(Long caseId);
+
+    /**
+     * 技能详情(SKILL.md 正文 + 附属文件)。
+     */
+    SkillDetailVO readSkill(String name, Long caseId);
+
+    /**
+     * 上传 zip 安装技能到<b>当前登录用户</b>的技能目录。
+     *
+     * @param overwrite 同名技能是否覆盖
+     * @return 安装后的技能
+     */
+    SkillVO installSkillZip(MultipartFile file, boolean overwrite);
+
+    /**
+     * 卸载技能(删目录)。
+     */
+    void deleteSkill(String name);
+
+    /**
+     * 启停技能(写/删 {@code .disabled} 标记)。
+     */
+    void toggleSkill(String name, boolean enabled);
+
+    /**
+     * 该用户当前「已停用」的技能过滤器,供对话链路挂到 RuntimeContext 上;无停用项时返回 null。
+     */
+    SkillFilter disabledSkillFilter(Long userId);
+}

+ 26 - 4
ai-server/src/main/java/com/zsjz/ai/module/agent/service/AgentService.java

@@ -30,6 +30,7 @@ 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.skill.repository.FileSystemSkillRepository;
 import io.agentscope.core.state.AgentStateStore;
 import io.agentscope.core.state.InMemoryAgentStateStore;
 import io.agentscope.core.tool.Toolkit;
@@ -39,6 +40,7 @@ import io.agentscope.harness.agent.filesystem.spec.LocalFilesystemSpec;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 
+import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.List;
@@ -57,6 +59,11 @@ import java.util.concurrent.ConcurrentHashMap;
 @Service
 public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
 
+    /**
+     * 默认 agent 行:会话表里没记 agent_id 时用它(对话链路与能力接口共用同一口径)。
+     */
+    public static final Long DEFAULT_AGENT_ROW_ID = 1L;
+
     private final AgentModelFactory agentModelFactory;
     private final AgentModelMapper modelMapper;
     private final AgentStateStore agentStateStore;
@@ -78,7 +85,7 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
     private final WorkspaceManagerFactory workspaceManagerFactory;
 
     /**
-     * agent 实例池:key = w{workspaceId}-a{agentRowId}-m{modelId}
+     * agent 实例池:key = w{workspaceId}-a{agentRowId}-m{modelId}-u{userId}
      */
     private final ConcurrentHashMap<String, HarnessAgent> agentPool = new ConcurrentHashMap<>();
 
@@ -129,7 +136,10 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
      * @return 可复用的 HarnessAgent 实例
      */
     public HarnessAgent getOrCreateAgent(Long workspaceId, Long agentRowId, Long modelId) {
-        String key = "w" + workspaceId + "-a" + agentRowId + "-m" + modelId;
+        // 池 key 必须带 userId:工作区与用户级技能目录都按登录用户解析,
+        // 少了这一维就会把 A 用户建的 agent(含其技能与工作区上下文)复用给 B 用户。
+        Long userId = StpUtil.getLoginIdAsLong();
+        String key = "w" + workspaceId + "-a" + agentRowId + "-m" + modelId + "-u" + userId;
         return agentPool.computeIfAbsent(key, k -> {
             AgentEntity entry = getById(agentRowId);
             if (entry == null) {
@@ -201,12 +211,20 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
         //scaffoldWorkspace(workspace, entry.getSysPrompt());
         b.workspace(workspace);
 
-        var skillRepositories = readSkillRepositories(entry.getSkillRepositoriesJson());
+       /* var skillRepositories = readSkillRepositories(entry.getSkillRepositoriesJson());
         if (skillRepositories != null && !skillRepositories.isEmpty()) {
             var repos = SkillRepositorySupport.createAll(workspace, skillRepositories);
             if (!repos.isEmpty()) {
                 b.skillRepositories(repos);
             }
+        }*/
+
+        // 用户级技能目录(「插件与技能」页安装的技能):
+        // {cwd}/.agentscope/users/{userId}/skills,与案件无关、跨案件共享。
+        // 目录不存在时不挂载(FileSystemSkillRepository 构造会抛异常),安装第一个技能时自动创建。
+        Path userSkills = workspaceManagerFactory.resolveUserSkillsPath(cwd, userId);
+        if (Files.isDirectory(userSkills)) {
+            b.skillRepository(new FileSystemSkillRepository(userSkills));
         }
 
         b.stateStore(agentStateStore);
@@ -226,7 +244,11 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
         }
     }
 
-    private static List<SkillRepositoryConfigEntry> readSkillRepositories(String json) {
+    /**
+     * 解析 {@code agent.skill_repositories_json}(与 {@code agentscope.json} 的 skillRepositories 同构)。
+     * 坏 JSON 静默降级为 null(等同「没配技能仓库」)。
+     */
+    public static List<SkillRepositoryConfigEntry> readSkillRepositories(String json) {
         if (json == null || json.isBlank()) return null;
         try {
             return Json.objectMapper().readValue(json, new TypeReference<>() {

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

@@ -0,0 +1,335 @@
+package com.zsjz.ai.module.agent.service.impl;
+
+import cn.dev33.satoken.stp.StpUtil;
+import cn.hutool.core.util.StrUtil;
+import com.zsjz.ai.common.exception.ServerException;
+import com.zsjz.ai.module.agent.WorkspaceManagerFactory;
+import com.zsjz.ai.module.agent.config.SkillRepositoryConfigEntry;
+import com.zsjz.ai.module.agent.entity.AgentEntity;
+import com.zsjz.ai.module.agent.mcp.AgentScopeMcpToolProvider;
+import com.zsjz.ai.module.agent.service.AgentCapabilityService;
+import com.zsjz.ai.module.agent.service.AgentService;
+import com.zsjz.ai.module.agent.skill.SkillStore;
+import com.zsjz.ai.module.agent.tools.AgentToolRegistry;
+import com.zsjz.ai.module.agent.vo.McpServerInfoVO;
+import com.zsjz.ai.module.agent.vo.SkillDetailVO;
+import com.zsjz.ai.module.agent.vo.SkillVO;
+import com.zsjz.ai.module.agent.vo.ToolGroupVO;
+import com.zsjz.ai.module.agent.vo.ToolVO;
+import io.agentscope.core.model.ToolSchema;
+import io.agentscope.core.skill.SkillFilter;
+import io.agentscope.core.tool.ToolGroup;
+import io.agentscope.core.tool.Toolkit;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 服务端 agent 能力清单与技能管理实现。
+ *
+ * <h3>技能存哪</h3>
+ * {@code {user.dir}/.agentscope/users/{登录用户ID}/skills/} —— 用户级、与案件无关、纯磁盘,
+ * 路径口径见 {@link WorkspaceManagerFactory#resolveUserSkillsPath}。与 harness 的工作区同根,
+ * 因此写进去即被 agent 加载,不需要注册、不需要落库。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AgentCapabilityServiceImpl implements AgentCapabilityService {
+
+    /** 基础工具组的伪组名(不是 AgentScope 的工具组,页面展示用) */
+    private static final String BASE_GROUP_CODE = "base";
+
+    /**
+     * 基础工具组说明。
+     *
+     * <p>这组工具不属于任何业务域、也不会被 {@code reset_equipped_tools} 收起,会话里始终可用。
+     */
+    private static final String BASE_GROUP_DESCRIPTION =
+            "不属于任何业务域、会话中始终可用的工具:SQL 取数与结果翻页、表结构检索(RAG)、"
+                    + "Python 分析、关系图谱渲染、当前工作空间信息。它们不会被工具组切换收起。";
+
+    private final AgentToolRegistry agentToolRegistry;
+
+    private final AgentScopeMcpToolProvider mcpToolProvider;
+
+    private final SkillStore skillStore;
+
+    private final AgentService agentService;
+
+    private final WorkspaceManagerFactory workspaceManagerFactory;
+
+    // ==================== MCP Server 配置(只读展示) ====================
+
+    @Value("${spring.ai.mcp.server.enabled:false}")
+    private boolean mcpEnabled;
+
+    @Value("${spring.ai.mcp.server.name:}")
+    private String mcpName;
+
+    @Value("${spring.ai.mcp.server.version:}")
+    private String mcpVersion;
+
+    @Value("${spring.ai.mcp.server.protocol:}")
+    private String mcpProtocol;
+
+    @Value("${spring.ai.mcp.server.base-url:}")
+    private String mcpBaseUrl;
+
+    @Value("${spring.ai.mcp.server.sse-endpoint:}")
+    private String mcpSseEndpoint;
+
+    @Value("${spring.ai.mcp.server.streamable-http.mcp-endpoint:}")
+    private String mcpStreamableEndpoint;
+
+    @Value("${server.port:}")
+    private Integer serverPort;
+
+    @Value("${server.servlet.context-path:}")
+    private String contextPath;
+
+    /* ------------------------------------------------------------------ *
+     * 工具
+     * ------------------------------------------------------------------ */
+
+    @Override
+    public List<ToolGroupVO> listToolGroups() {
+        // 业务工具:独立 Toolkit 仅用于枚举;必须把全部组都激活,否则未激活组的工具不进 schema
+        Toolkit tk = new Toolkit();
+        agentToolRegistry.registerBusinessTools(tk, AgentToolRegistry.registeredGroupNames());
+        Map<String, ToolVO> schemaByName = new LinkedHashMap<>();
+        for (ToolSchema schema : tk.getToolSchemas()) {
+            schemaByName.put(schema.getName(), toToolVO(schema));
+        }
+
+        List<ToolGroupVO> groups = new ArrayList<>();
+        for (String code : AgentToolRegistry.registeredGroupNames()) {
+            ToolGroup group = tk.getToolGroup(code);
+            if (group == null) {
+                continue;
+            }
+            List<ToolVO> tools = new ArrayList<>();
+            for (String toolName : group.getTools()) {
+                ToolVO tool = schemaByName.get(toolName);
+                if (tool == null) {
+                    // 组里登记了名字但没注册工具(配置不一致):仍然列出,避免页面「少了一个」
+                    tool = new ToolVO();
+                    tool.setName(toolName);
+                }
+                tools.add(tool);
+            }
+            tools.sort(Comparator.comparing(ToolVO::getName, Comparator.nullsLast(String::compareTo)));
+
+            ToolGroupVO vo = new ToolGroupVO();
+            vo.setCode(code);
+            vo.setLabel(AgentToolRegistry.groupLabel(code));
+            vo.setDefaultActive(AgentToolRegistry.DEFAULT_ACTIVE_GROUPS.contains(code));
+            vo.setDescription(group.getDescription());
+            vo.setToolCount(tools.size());
+            vo.setTools(tools);
+            groups.add(vo);
+        }
+
+        // 基础工具:MCP 侧那份 Toolkit 已按可用性注册好(RAG/Python 不可用时不会注册),直接复用
+        List<ToolVO> base = new ArrayList<>();
+        for (ToolSchema schema : mcpToolProvider.toolkit().getToolSchemas()) {
+            if (!schemaByName.containsKey(schema.getName())) {
+                base.add(toToolVO(schema));
+            }
+        }
+        if (!base.isEmpty()) {
+            base.sort(Comparator.comparing(ToolVO::getName, Comparator.nullsLast(String::compareTo)));
+            ToolGroupVO vo = new ToolGroupVO();
+            vo.setCode(BASE_GROUP_CODE);
+            vo.setLabel("基础工具");
+            // 不是可装备的业务组:不给「默认装备」标记,避免页面上出现一个不能切换的开关暗示
+            vo.setDefaultActive(false);
+            vo.setDescription(BASE_GROUP_DESCRIPTION);
+            vo.setToolCount(base.size());
+            vo.setTools(base);
+            groups.add(vo);
+        }
+        return groups;
+    }
+
+    private static ToolVO toToolVO(ToolSchema schema) {
+        ToolVO vo = new ToolVO();
+        vo.setName(schema.getName());
+        vo.setDescription(schema.getDescription());
+        vo.setParameters(schema.getParameters());
+        return vo;
+    }
+
+    /* ------------------------------------------------------------------ *
+     * MCP 接入信息
+     * ------------------------------------------------------------------ */
+
+    @Override
+    public McpServerInfoVO mcpServerInfo() {
+        boolean streamable = "STREAMABLE".equalsIgnoreCase(StrUtil.trimToEmpty(mcpProtocol));
+        String prefix = StrUtil.isNotBlank(mcpBaseUrl) ? mcpBaseUrl : contextPath;
+
+        McpServerInfoVO vo = new McpServerInfoVO();
+        vo.setEnabled(mcpEnabled);
+        vo.setName(StrUtil.trimToEmpty(mcpName));
+        vo.setVersion(StrUtil.trimToEmpty(mcpVersion));
+        vo.setProtocol(StrUtil.trimToEmpty(mcpProtocol));
+        vo.setContextPath(StrUtil.trimToEmpty(contextPath));
+        vo.setPort(serverPort);
+        vo.setEndpoint(joinPath(prefix, streamable ? mcpStreamableEndpoint : mcpSseEndpoint));
+        vo.setSseEndpoint(joinPath(prefix, mcpSseEndpoint));
+        vo.setToolCount(mcpToolProvider.getToolCallbacks().length);
+        vo.setCaseTools(AgentScopeMcpToolProvider.CASE_TOOL_NAMES);
+        vo.setNote("外部客户端接入后:先调 list_cases 取案件 ID,再调 open_case 绑定案件,"
+                + "之后所有业务工具都作用于该案件;换案件重复 open_case 即可。"
+                + "当前端点未做鉴权(见 SaTokenWebConfig 白名单),只应在本机或内网使用。");
+        return vo;
+    }
+
+    private static String joinPath(String prefix, String path) {
+        if (StrUtil.isBlank(path)) {
+            return null;
+        }
+        String left = StrUtil.trimToEmpty(prefix);
+        while (left.endsWith("/")) {
+            left = left.substring(0, left.length() - 1);
+        }
+        String right = path.trim();
+        if (!right.startsWith("/")) {
+            right = "/" + right;
+        }
+        return left + right;
+    }
+
+    /* ------------------------------------------------------------------ *
+     * 技能
+     * ------------------------------------------------------------------ */
+
+    @Override
+    public List<SkillVO> listSkills(Long caseId) {
+        List<SkillVO> skills = new ArrayList<>(
+                skillStore.list(userSkillsPath(), SkillStore.SOURCE_USER, true, true));
+        for (Path repoDir : configuredRepoPaths(caseId)) {
+            skills.addAll(skillStore.list(repoDir, SkillStore.SOURCE_REPO, false, false));
+        }
+        return skills;
+    }
+
+    @Override
+    public SkillDetailVO readSkill(String name, Long caseId) {
+        Path userRoot = userSkillsPath();
+        if (skillStore.resolveDir(userRoot, name).isPresent()) {
+            return skillStore.read(userRoot, name, SkillStore.SOURCE_USER, true, true);
+        }
+        for (Path repoDir : configuredRepoPaths(caseId)) {
+            if (skillStore.resolveDir(repoDir, name).isPresent()) {
+                return skillStore.read(repoDir, name, SkillStore.SOURCE_REPO, false, false);
+            }
+        }
+        throw new ServerException(404, "技能不存在: " + name);
+    }
+
+    @Override
+    public SkillVO installSkillZip(MultipartFile file, boolean overwrite) {
+        if (file == null || file.isEmpty()) {
+            throw new ServerException(400, "请选择要上传的技能包(zip)");
+        }
+        byte[] bytes;
+        try {
+            bytes = file.getBytes();
+        } catch (IOException e) {
+            throw new ServerException(500, "读取上传文件失败: " + e.getMessage());
+        }
+        Path userRoot = userSkillsPath();
+        String name = skillStore.install(bytes, userRoot, overwrite);
+        return skillStore.list(userRoot, SkillStore.SOURCE_USER, true, true).stream()
+                .filter(s -> name.equals(s.getName()))
+                .findFirst()
+                .orElseThrow(() -> new ServerException(500, "技能已写入但读取失败: " + name));
+    }
+
+    @Override
+    public void deleteSkill(String name) {
+        Path userRoot = userSkillsPath();
+        // 只允许删用户技能:配置仓库里的技能由仓库各自维护,页面也不提供卸载入口
+        if (skillStore.resolveDir(userRoot, name).isEmpty()) {
+            throw new ServerException(404, "用户技能目录里没有这个技能: " + name);
+        }
+        skillStore.delete(userRoot, name);
+    }
+
+    @Override
+    public void toggleSkill(String name, boolean enabled) {
+        Path userRoot = userSkillsPath();
+        if (skillStore.resolveDir(userRoot, name).isEmpty()) {
+            throw new ServerException(404, "用户技能目录里没有这个技能: " + name);
+        }
+        skillStore.toggle(userRoot, name, enabled);
+    }
+
+    @Override
+    public SkillFilter disabledSkillFilter(Long userId) {
+        List<String> disabled = skillStore.disabledNames(
+                workspaceManagerFactory.resolveUserSkillsPath(cwd(), userId));
+        if (disabled.isEmpty()) {
+            return null;
+        }
+        return SkillFilter.disable(disabled.toArray(new String[0]));
+    }
+
+    /* ------------------------------------------------------------------ *
+     * 路径与配置读取
+     * ------------------------------------------------------------------ */
+
+    /** 当前登录用户的技能目录(按需创建由写入方负责) */
+    private Path userSkillsPath() {
+        return workspaceManagerFactory.resolveUserSkillsPath(cwd(), StpUtil.getLoginIdAsLong());
+    }
+
+    /**
+     * 该案件下 agent 配置的技能仓库目录(只读展示用)。
+     *
+     * <p>只处理 {@code filesystem} 类型:{@code git} 类型会触发 clone/sync(有网络与落盘副作用),
+     * 且 {@code agentscope-extensions-skill-git-repository} 并不在本项目 pom 里,必然失败。
+     */
+    private List<Path> configuredRepoPaths(Long caseId) {
+        if (caseId == null) {
+            return List.of();
+        }
+        AgentEntity entry = agentService.getById(AgentService.DEFAULT_AGENT_ROW_ID);
+        if (entry == null) {
+            return List.of();
+        }
+        List<SkillRepositoryConfigEntry> entries =
+                AgentService.readSkillRepositories(entry.getSkillRepositoriesJson());
+        if (entries == null || entries.isEmpty()) {
+            return List.of();
+        }
+        Path workspace = workspaceManagerFactory.resolveAgentDataPath(
+                cwd(), StpUtil.getLoginIdAsLong(), caseId, AgentService.DEFAULT_AGENT_ROW_ID);
+        List<Path> dirs = new ArrayList<>();
+        for (SkillRepositoryConfigEntry e : entries) {
+            if (e == null || !"filesystem".equalsIgnoreCase(StrUtil.trimToEmpty(e.getType()))
+                    || StrUtil.isBlank(e.getPath())) {
+                continue;
+            }
+            dirs.add(workspace.resolve(e.getPath()).normalize());
+        }
+        return dirs;
+    }
+
+    private static Path cwd() {
+        return Paths.get(System.getProperty("user.dir")).toAbsolutePath().normalize();
+    }
+}

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

@@ -1,5 +1,6 @@
 package com.zsjz.ai.module.agent.service.impl;
 
+import cn.dev33.satoken.stp.StpUtil;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.spring.service.impl.ServiceImpl;
@@ -15,6 +16,7 @@ import com.zsjz.ai.module.agent.entity.AgentChatSession;
 import com.zsjz.ai.module.agent.entity.AgentMessage;
 import com.zsjz.ai.module.agent.mapper.AgentChatSessionMapper;
 import com.zsjz.ai.module.agent.mapper.AgentMessageMapper;
+import com.zsjz.ai.module.agent.service.AgentCapabilityService;
 import com.zsjz.ai.module.agent.service.AgentChatService;
 import com.zsjz.ai.module.agent.service.AgentModelService;
 import com.zsjz.ai.module.agent.service.AgentService;
@@ -25,6 +27,7 @@ import io.agentscope.core.agent.RuntimeContext;
 import io.agentscope.core.event.*;
 import io.agentscope.core.message.Msg;
 import io.agentscope.core.message.MsgRole;
+import io.agentscope.core.skill.SkillFilter;
 import io.agentscope.harness.agent.HarnessAgent;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.http.codec.ServerSentEvent;
@@ -58,8 +61,6 @@ import java.util.stream.Collectors;
 @Service
 public class AgentChatServiceImpl extends ServiceImpl<AgentChatSessionMapper, AgentChatSession> implements AgentChatService {
 
-    private static final Long DEFAULT_AGENT_ROW_ID = 1L;
-
     /** 固定运行时用户标识(项目无用户体系,仅供 AgentScope 记忆/文件隔离维度使用) */
     private static final String RUNTIME_USER_ID = "default";
 
@@ -67,17 +68,20 @@ public class AgentChatServiceImpl extends ServiceImpl<AgentChatSessionMapper, Ag
     private final AgentMessageMapper messageMapper;
     private final AgentService agentService;
     private final AgentModelService agentModelService;
+    private final AgentCapabilityService agentCapabilityService;
     private final UsageStore usageStore;
     private static final ObjectMapper MAPPER = new ObjectMapper();
 
     public AgentChatServiceImpl(UsageStore usageStore,
                                 AgentService agentService,
                                 AgentModelService agentModelService,
+                                AgentCapabilityService agentCapabilityService,
                                 AgentMessageMapper messageMapper,
                                 AgentChatSessionMapper chatSessionMapper) {
         this.usageStore = usageStore;
         this.agentService = agentService;
         this.agentModelService = agentModelService;
+        this.agentCapabilityService = agentCapabilityService;
         this.messageMapper = messageMapper;
         this.chatSessionMapper = chatSessionMapper;
     }
@@ -88,7 +92,7 @@ public class AgentChatServiceImpl extends ServiceImpl<AgentChatSessionMapper, Ag
         AgentChatSession entity = new AgentChatSession();
         entity.setId(generateId());
         entity.setCaseId(dto.getWorkspaceId());
-        entity.setAgentId(dto.getAgentId() != null ? dto.getAgentId() : DEFAULT_AGENT_ROW_ID);
+        entity.setAgentId(dto.getAgentId() != null ? dto.getAgentId() : AgentService.DEFAULT_AGENT_ROW_ID);
      
         entity.setTitle(dto.getTitle() != null ? dto.getTitle() : "新对话");
         entity.setContext(dto.getContext() != null ? dto.getContext() : "{}");
@@ -318,7 +322,7 @@ public class AgentChatServiceImpl extends ServiceImpl<AgentChatSessionMapper, Ag
 
         String runtimeUser = RUNTIME_USER_ID;
         Long workspaceId = session.getCaseId();
-        Long agentRowId = session.getAgentId() != null ? session.getAgentId() : DEFAULT_AGENT_ROW_ID;
+        Long agentRowId = session.getAgentId() != null ? session.getAgentId() : AgentService.DEFAULT_AGENT_ROW_ID;
 
         // 模型优先级:请求体 modelId(动态切换)> session.modelId > 默认模型
         Long modelId = req.getModelId() != null ? req.getModelId() : session.getModelId();
@@ -348,6 +352,10 @@ public class AgentChatServiceImpl extends ServiceImpl<AgentChatSessionMapper, Ag
         RuntimeContext ctx = RuntimeContext.builder()
                 .userId(runtimeUser)
                 .sessionId(String.valueOf(sessionId))
+                // 技能启停:「插件与技能」页把技能标成停用后,这里每轮现算一个过滤器塞进调用上下文。
+                // harness 侧 effectiveFilter = builderFilter.overlay(ctx.get(SkillFilter.class)),
+                // overlay 只影响列出的技能,其余照旧 —— 因此不需要重建 agent 实例。
+                .put(SkillFilter.class, agentCapabilityService.disabledSkillFilter(StpUtil.getLoginIdAsLong()))
                 .build();
 
         // 意图识别由 IntentMiddleware 在 onAgent 钩子内完成(消息增强 + intent 事件),

+ 461 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/skill/SkillStore.java

@@ -0,0 +1,461 @@
+package com.zsjz.ai.module.agent.skill;
+
+import com.zsjz.ai.common.exception.ServerException;
+import com.zsjz.ai.module.agent.vo.SkillDetailVO;
+import com.zsjz.ai.module.agent.vo.SkillVO;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.io.ByteArrayInputStream;
+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.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Stream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+/**
+ * 技能文件仓库:技能在磁盘上的读写实现(<b>纯文件系统操作,不碰数据库</b>)。
+ *
+ * <h3>目录约定</h3>
+ * <pre>
+ * {skillsRoot}/
+ * ├── 技能名/SKILL.md          必需:YAML frontmatter(name + description)+ 正文
+ * ├── 技能名/references/…      可选:附属文件
+ * ├── 技能名/.disabled         存在 = 该技能已停用(由本类维护)
+ * └── 其他目录/                没有 SKILL.md 的目录一律不算技能
+ * </pre>
+ *
+ * <p>与 AgentScope 的 {@code FileSystemSkillRepository} 同构(同样是「一层子目录 + SKILL.md」),
+ * 因此把技能直接写进目录即可被 agent 加载,无需注册、无需落库。
+ *
+ * <h3>为什么单独成类</h3>
+ * 这些方法只依赖一个 {@code skillsRoot} 路径,不依赖登录态、数据库与 Spring 上下文,
+ * 因此可以直接用临时目录做单元测试(见 {@code SkillStoreTest})。
+ */
+@Slf4j
+@Component
+public class SkillStore {
+
+    /** 技能定义文件名(与 AgentScope 一致) */
+    public static final String SKILL_FILE = "SKILL.md";
+
+    /** 停用标记文件名:存在即停用 */
+    public static final String DISABLED_MARKER = ".disabled";
+
+    /** 技能名允许的字符:中英文、数字、下划线、短横线、点(不允许路径分隔符,避免越界) */
+    private static final String NAME_PATTERN = "[\\p{IsHan}A-Za-z0-9._-]{1,64}";
+
+    /** 单个技能包解压后的体积上限(防 zip 炸弹) */
+    private static final long MAX_UNZIP_BYTES = 50L * 1024 * 1024;
+
+    /** 单个技能包的条目数上限 */
+    private static final int MAX_UNZIP_ENTRIES = 2000;
+
+    /** frontmatter 解析的读取上限:技能定义是说明文档,超过这个体积的多半不是技能 */
+    private static final long MAX_SKILL_MD_BYTES = 1024 * 1024;
+
+    /** 技能来源:用户技能目录(页面可管理) */
+    public static final String SOURCE_USER = "user";
+
+    /** 技能来源:agent 配置的技能仓库(只读) */
+    public static final String SOURCE_REPO = "repo";
+
+    /**
+     * 列出目录下的全部技能。
+     *
+     * @param skillsRoot  技能根目录(不存在时返回空列表,不抛异常)
+     * @param source      来源标签,写入返回的 VO
+     * @param managed     是否可管理(可卸载 / 可启停)
+     * @param markerIsOff 停用标记是否生效(配置仓库只读,标记不由我们维护)
+     */
+    public List<SkillVO> list(Path skillsRoot, String source, boolean managed, boolean markerIsOff) {
+        List<SkillVO> result = new ArrayList<>();
+        if (skillsRoot == null || !Files.isDirectory(skillsRoot)) {
+            return result;
+        }
+        try (Stream<Path> dirs = Files.list(skillsRoot)) {
+            for (Path dir : (Iterable<Path>) dirs.sorted()::iterator) {
+                Path skillMd = dir.resolve(SKILL_FILE);
+                if (!Files.isDirectory(dir) || !Files.isRegularFile(skillMd)) {
+                    continue;
+                }
+                Meta meta = readMeta(skillMd);
+                SkillVO vo = new SkillVO();
+                vo.setName(meta.name != null ? meta.name : dir.getFileName().toString());
+                vo.setDescription(meta.description);
+                vo.setSource(source);
+                vo.setManaged(managed);
+                vo.setEnabled(!(markerIsOff && Files.exists(dir.resolve(DISABLED_MARKER))));
+                vo.setPath(dir.toAbsolutePath().toString());
+                result.add(vo);
+            }
+        } catch (IOException e) {
+            throw new ServerException(500, "读取技能目录失败: " + e.getMessage());
+        }
+        result.sort(Comparator.comparing(SkillVO::getName, Comparator.nullsLast(String::compareTo)));
+        return result;
+    }
+
+    /**
+     * 读取技能详情(SKILL.md 正文 + 附属文件清单)。
+     */
+    public SkillDetailVO read(Path skillsRoot, String name, String source, boolean managed, boolean markerIsOff) {
+        Path dir = resolveDir(skillsRoot, name)
+                .orElseThrow(() -> new ServerException(404, "技能不存在: " + name));
+        Path skillMd = dir.resolve(SKILL_FILE);
+        Meta meta = readMeta(skillMd);
+
+        SkillDetailVO vo = new SkillDetailVO();
+        vo.setName(meta.name != null ? meta.name : dir.getFileName().toString());
+        vo.setDescription(meta.description);
+        vo.setSource(source);
+        vo.setManaged(managed);
+        vo.setEnabled(!(markerIsOff && Files.exists(dir.resolve(DISABLED_MARKER))));
+        vo.setPath(dir.toAbsolutePath().toString());
+        vo.setContent(readText(skillMd));
+        vo.setFiles(listFiles(dir));
+        return vo;
+    }
+
+    /**
+     * 安装一个技能包(zip 字节);返回安装后的技能名。
+     *
+     * <p>校验顺序:解压到临时目录 → 定位唯一技能根(含 SKILL.md 的目录)→ 校验技能名
+     * → 落到 {@code {skillsRoot}/{技能名}}。同名技能存在时,只有 {@code overwrite=true} 才覆盖。
+     *
+     * @param zipBytes  zip 包内容(由调用方从上传流读出,避免本类依赖 web 层)
+     * @param skillsRoot 用户技能根目录(按需创建)
+     * @param overwrite  同名技能是否覆盖
+     */
+    public String install(byte[] zipBytes, Path skillsRoot, boolean overwrite) {
+        if (zipBytes == null || zipBytes.length == 0) {
+            throw new ServerException(400, "上传的技能包为空");
+        }
+        Path tempDir;
+        try {
+            tempDir = Files.createTempDirectory("skill-install-");
+        } catch (IOException e) {
+            throw new ServerException(500, "创建临时目录失败: " + e.getMessage());
+        }
+        try {
+            unzip(zipBytes, tempDir);
+            List<Path> roots = findSkillRoots(tempDir);
+            if (roots.isEmpty()) {
+                throw new ServerException(400, "压缩包里没有 " + SKILL_FILE + ",不是有效的技能包");
+            }
+            if (roots.size() > 1) {
+                throw new ServerException(400,
+                        "一个压缩包只能包含一个技能(发现 " + roots.size() + " 个 " + SKILL_FILE + ")");
+            }
+            Path skillRoot = roots.get(0);
+            Path skillMd = skillRoot.resolve(SKILL_FILE);
+            Meta meta = readMeta(skillMd);
+            String name = meta.name != null ? meta.name : skillRoot.getFileName().toString();
+            validateName(name);
+
+            Files.createDirectories(skillsRoot);
+            Path target = skillsRoot.resolve(name).normalize();
+            if (!target.startsWith(skillsRoot.normalize())) {
+                throw new ServerException(400, "技能名非法: " + name);
+            }
+            if (Files.exists(target)) {
+                if (!overwrite) {
+                    throw new ServerException(400, "同名技能已存在: " + name + "(如需替换请勾选覆盖)");
+                }
+                deleteDirectory(target);
+            }
+            copyDirectory(skillRoot, target);
+            log.info("技能已安装: {} -> {}", name, target);
+            return name;
+        } catch (IOException e) {
+            throw new ServerException(500, "安装技能失败: " + e.getMessage());
+        } finally {
+            deleteQuietly(tempDir);
+        }
+    }
+
+    /**
+     * 卸载技能(删除技能目录)。
+     */
+    public void delete(Path skillsRoot, String name) {
+        Path dir = resolveDir(skillsRoot, name)
+                .orElseThrow(() -> new ServerException(404, "技能不存在: " + name));
+        try {
+            deleteDirectory(dir);
+            log.info("技能已卸载: {} ({})", name, dir);
+        } catch (IOException e) {
+            throw new ServerException(500, "卸载技能失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 启停技能:启用 = 删除 {@value #DISABLED_MARKER} 标记,停用 = 创建标记。
+     */
+    public void toggle(Path skillsRoot, String name, boolean enabled) {
+        Path dir = resolveDir(skillsRoot, name)
+                .orElseThrow(() -> new ServerException(404, "技能不存在: " + name));
+        Path marker = dir.resolve(DISABLED_MARKER);
+        try {
+            if (enabled) {
+                Files.deleteIfExists(marker);
+            } else if (!Files.exists(marker)) {
+                Files.createFile(marker);
+            }
+        } catch (IOException e) {
+            throw new ServerException(500, "更新技能状态失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 已停用的技能名(取 frontmatter 的 name,缺失时退回目录名)——供对话链路挂技能过滤器。
+     */
+    public List<String> disabledNames(Path skillsRoot) {
+        List<String> names = new ArrayList<>();
+        if (skillsRoot == null || !Files.isDirectory(skillsRoot)) {
+            return names;
+        }
+        try (Stream<Path> dirs = Files.list(skillsRoot)) {
+            for (Path dir : (Iterable<Path>) dirs::iterator) {
+                Path skillMd = dir.resolve(SKILL_FILE);
+                if (!Files.isDirectory(dir) || !Files.isRegularFile(skillMd)) {
+                    continue;
+                }
+                if (!Files.exists(dir.resolve(DISABLED_MARKER))) {
+                    continue;
+                }
+                Meta meta = readMeta(skillMd);
+                names.add(meta.name != null ? meta.name : dir.getFileName().toString());
+            }
+        } catch (IOException e) {
+            log.warn("读取停用技能失败: {}", e.getMessage());
+        }
+        return names;
+    }
+
+    /**
+     * 按技能名定位技能目录:先比目录名,再比 SKILL.md frontmatter 里的 name。
+     */
+    public Optional<Path> resolveDir(Path skillsRoot, String name) {
+        if (skillsRoot == null || name == null || name.isBlank() || !Files.isDirectory(skillsRoot)) {
+            return Optional.empty();
+        }
+        Path direct = skillsRoot.resolve(name).normalize();
+        if (direct.startsWith(skillsRoot.normalize())
+                && Files.isRegularFile(direct.resolve(SKILL_FILE))) {
+            return Optional.of(direct);
+        }
+        try (Stream<Path> dirs = Files.list(skillsRoot)) {
+            return dirs.filter(Files::isDirectory)
+                    .filter(dir -> Files.isRegularFile(dir.resolve(SKILL_FILE)))
+                    .filter(dir -> {
+                        Meta meta = readMeta(dir.resolve(SKILL_FILE));
+                        return name.equals(meta.name);
+                    })
+                    .findFirst();
+        } catch (IOException e) {
+            return Optional.empty();
+        }
+    }
+
+    /* ------------------------------------------------------------------ *
+     * 内部实现
+     * ------------------------------------------------------------------ */
+
+    /**
+     * 解压 zip 到目标目录。
+     *
+     * <p>自己实现而不是用工具类,是因为**路径穿越必须在这里挡住**:条目名里的 {@code ../}
+     * 或绝对路径会让文件落到技能目录之外。另外顺手挡一下 zip 炸弹(体积 / 条目数上限)。
+     */
+    private void unzip(byte[] zipBytes, Path targetDir) throws IOException {
+        long total = 0;
+        int entries = 0;
+        byte[] buffer = new byte[8192];
+        try (InputStream in = new ByteArrayInputStream(zipBytes);
+             ZipInputStream zis = new ZipInputStream(in, StandardCharsets.UTF_8)) {
+            ZipEntry entry;
+            while ((entry = zis.getNextEntry()) != null) {
+                if (entry.isDirectory()) {
+                    continue;
+                }
+                entries++;
+                if (entries > MAX_UNZIP_ENTRIES) {
+                    throw new ServerException(400, "压缩包条目过多,已中止");
+                }
+                Path out = targetDir.resolve(entry.getName()).normalize();
+                if (!out.startsWith(targetDir.normalize())) {
+                    throw new ServerException(400, "压缩包包含非法路径: " + entry.getName());
+                }
+                Files.createDirectories(out.getParent());
+                long written = 0;
+                try (var os = Files.newOutputStream(out)) {
+                    int len;
+                    while ((len = zis.read(buffer)) > 0) {
+                        written += len;
+                        total += len;
+                        if (total > MAX_UNZIP_BYTES) {
+                            throw new ServerException(400, "压缩包解压后体积过大,已中止");
+                        }
+                        os.write(buffer, 0, len);
+                    }
+                }
+                if (written == 0) {
+                    Files.write(out, new byte[0]);
+                }
+            }
+        }
+    }
+
+    /**
+     * 找出压缩包里含 SKILL.md 的目录(最多留意两层:包内直接是技能目录,或多套了一层包装目录)。
+     */
+    private List<Path> findSkillRoots(Path tempDir) throws IOException {
+        List<Path> roots = new ArrayList<>();
+        try (Stream<Path> walk = Files.walk(tempDir, 3)) {
+            for (Path p : (Iterable<Path>) walk::iterator) {
+                if (Files.isDirectory(p) && Files.isRegularFile(p.resolve(SKILL_FILE))) {
+                    roots.add(p);
+                }
+            }
+        }
+        // 嵌套情况(技能目录里还有技能)只保留最外层,避免被当成多个技能
+        List<Path> outer = new ArrayList<>();
+        for (Path p : roots) {
+            boolean nested = outer.stream().anyMatch(parent -> p.startsWith(parent));
+            if (!nested) {
+                outer.add(p);
+            }
+        }
+        return outer;
+    }
+
+    private void validateName(String name) {
+        if (name == null || name.isBlank() || !name.matches(NAME_PATTERN)) {
+            throw new ServerException(400,
+                    "技能名非法: " + name + "(仅允许中英文、数字、下划线、短横线与点,长度 1~64)");
+        }
+    }
+
+    /**
+     * 读 SKILL.md 的 frontmatter(只取 name / description)。
+     *
+     * <p>技能定义的 frontmatter 就这两个必需字段(AgentScope 也是这么约定),
+     * 因此这里手写解析,不引入 YAML 依赖去做通用解析。
+     */
+    private Meta readMeta(Path skillMd) {
+        Meta meta = new Meta();
+        String text = readText(skillMd);
+        if (text == null || !text.startsWith("---")) {
+            return meta;
+        }
+        int end = text.indexOf("\n---", 3);
+        String frontmatter = end > 0 ? text.substring(3, end) : "";
+        for (String rawLine : frontmatter.split("\n")) {
+            String line = rawLine.strip();
+            if (line.startsWith("name:")) {
+                meta.name = unquote(line.substring("name:".length()).strip());
+            } else if (line.startsWith("description:")) {
+                meta.description = unquote(line.substring("description:".length()).strip());
+            }
+        }
+        return meta;
+    }
+
+    private static String unquote(String value) {
+        if (value == null || value.isEmpty()) {
+            return value;
+        }
+        if ((value.startsWith("\"") && value.endsWith("\"") && value.length() > 1)
+                || (value.startsWith("'") && value.endsWith("'") && value.length() > 1)) {
+            return value.substring(1, value.length() - 1);
+        }
+        return value;
+    }
+
+    private String readText(Path file) {
+        try {
+            if (!Files.isRegularFile(file) || Files.size(file) > MAX_SKILL_MD_BYTES) {
+                return null;
+            }
+            return Files.readString(file, StandardCharsets.UTF_8);
+        } catch (IOException e) {
+            log.warn("读取技能定义失败: {} ({})", file, e.getMessage());
+            return null;
+        }
+    }
+
+    private List<SkillDetailVO.FileItem> listFiles(Path skillDir) {
+        List<SkillDetailVO.FileItem> files = new ArrayList<>();
+        try (Stream<Path> walk = Files.walk(skillDir)) {
+            for (Path p : (Iterable<Path>) walk::iterator) {
+                if (!Files.isRegularFile(p)) {
+                    continue;
+                }
+                String relative = skillDir.relativize(p).toString().replace('\\', '/');
+                if (SKILL_FILE.equals(relative) || relative.startsWith(".") || relative.contains("/.")) {
+                    continue;
+                }
+                SkillDetailVO.FileItem item = new SkillDetailVO.FileItem();
+                item.setPath(relative);
+                try {
+                    item.setSize(Files.size(p));
+                } catch (IOException ignored) {
+                    item.setSize(null);
+                }
+                files.add(item);
+            }
+        } catch (IOException e) {
+            log.warn("读取技能附属文件失败: {} ({})", skillDir, e.getMessage());
+        }
+        files.sort(Comparator.comparing(SkillDetailVO.FileItem::getPath,
+                Comparator.nullsLast(String::compareTo)));
+        return files;
+    }
+
+    private void copyDirectory(Path source, Path target) throws IOException {
+        try (Stream<Path> walk = Files.walk(source)) {
+            for (Path p : (Iterable<Path>) walk::iterator) {
+                Path dest = target.resolve(source.relativize(p).toString());
+                if (Files.isDirectory(p)) {
+                    Files.createDirectories(dest);
+                } else {
+                    Files.createDirectories(dest.getParent());
+                    Files.copy(p, dest, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
+                }
+            }
+        }
+    }
+
+    private void deleteDirectory(Path dir) throws IOException {
+        if (!Files.exists(dir)) {
+            return;
+        }
+        try (Stream<Path> walk = Files.walk(dir)) {
+            List<Path> paths = walk.sorted(Comparator.reverseOrder()).toList();
+            for (Path p : paths) {
+                Files.deleteIfExists(p);
+            }
+        }
+    }
+
+    private void deleteQuietly(Path dir) {
+        try {
+            deleteDirectory(dir);
+        } catch (IOException e) {
+            log.warn("清理临时目录失败: {} ({})", dir, e.getMessage());
+        }
+    }
+
+    /** SKILL.md frontmatter 里的关键字段 */
+    private static final class Meta {
+        private String name;
+        private String description;
+    }
+}

+ 34 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/tools/AgentToolRegistry.java

@@ -117,6 +117,40 @@ public class AgentToolRegistry {
      */
     public static final List<String> DEFAULT_ACTIVE_GROUPS = List.of(GROUP_PERSON);
 
+    /**
+     * 实际注册成组的业务工具组(顺序与 {@code AgentScopeMcpToolProvider#buildToolkit} 保持一致)。
+     *
+     * <p>不含 {@link #GROUP_OTG}:全文检索组目前 {@code OTG_TOOLS} 为空、{@code createGroup}
+     * 整段被注释,{@code Toolkit#getToolGroup("otg")} 会返回 null,列到页面上只会是个空壳。
+     *
+     * <p>为什么需要这个方法:{@code Toolkit} 没有「列举所有组」的公开 API(只有按名取组的
+     * {@code getToolGroup(String)}),所以只能由本类给出权威的组名清单。
+     */
+    public static List<String> registeredGroupNames() {
+        return List.of(GROUP_PERSON, GROUP_CALL, GROUP_TRANS, GROUP_TRACK, GROUP_GRAPH, GROUP_FILE);
+    }
+
+    /**
+     * 组名 → 中文名(页面标题用)。
+     *
+     * <p>组描述是写给模型看的指令(「什么时候该激活我」),不适合直接当标题,因此另给一套短标签。
+     */
+    public static String groupLabel(String groupName) {
+        if (groupName == null) {
+            return "";
+        }
+        return switch (groupName) {
+            case GROUP_PERSON -> "人员画像与亲密度";
+            case GROUP_CALL -> "通话分析";
+            case GROUP_TRANS -> "交易与资金分析";
+            case GROUP_TRACK -> "轨迹、出行与快递";
+            case GROUP_GRAPH -> "关系图谱";
+            case GROUP_FILE -> "非表格材料档案";
+            case GROUP_OTG -> "全文检索(暂未启用)";
+            default -> groupName;
+        };
+    }
+
     // ==================== 各组工具名清单 ====================
 
     private static final List<String> CALL_TOOLS = List.of(

+ 47 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/vo/McpServerInfoVO.java

@@ -0,0 +1,47 @@
+package com.zsjz.ai.module.agent.vo;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * MCP 接入信息响应VO(只读)。
+ *
+ * <p>服务端目前是<b>纯 MCP Server</b>:把 agent 的工具暴露给外部客户端(Claude Desktop / Cursor 等),
+ * 不消费外部 MCP 服务,因此这里只有「怎么连进来」的信息,没有可配置项。
+ */
+@Data
+public class McpServerInfoVO {
+
+    /** 是否启用 MCP Server */
+    private Boolean enabled;
+
+    /** 服务名(客户端 tools/list 里能看到) */
+    private String name;
+
+    private String version;
+
+    /** 传输协议:{@code STREAMABLE} 或 {@code SSE} */
+    private String protocol;
+
+    /** 当前协议实际生效的端点路径 */
+    private String endpoint;
+
+    /** SSE 端点路径(配了但当前协议下未注册时为 null) */
+    private String sseEndpoint;
+
+    /** 服务上下文路径(后端 context-path) */
+    private String contextPath;
+
+    /** 服务端口 */
+    private Integer port;
+
+    /** 已暴露的工具总数(业务工具 + 基础工具 + 案件管理工具) */
+    private Integer toolCount;
+
+    /** MCP 专用的案件管理工具名(外部客户端要先调它们绑定案件) */
+    private List<String> caseTools;
+
+    /** 接入说明:调用顺序、无鉴权等注意事项 */
+    private String note;
+}

+ 47 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/vo/SkillDetailVO.java

@@ -0,0 +1,47 @@
+package com.zsjz.ai.module.agent.vo;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 技能详情响应VO:SKILL.md 正文 + 附属文件清单。
+ */
+@Data
+public class SkillDetailVO {
+
+    private String name;
+
+    private String description;
+
+    /** 来源:{@code user} / {@code repo} */
+    private String source;
+
+    /** 是否可管理(可卸载 / 可启停) */
+    private Boolean managed;
+
+    /** 是否启用(未停用) */
+    private Boolean enabled;
+
+    /** 技能所在目录的绝对路径 */
+    private String path;
+
+    /** SKILL.md 原文 */
+    private String content;
+
+    /** 附属文件(相对技能目录的路径 + 字节数) */
+    private List<FileItem> files;
+
+    /**
+     * 附属文件项
+     */
+    @Data
+    public static class FileItem {
+
+        /** 相对技能目录的路径(正斜杠,如 {@code references/style.md}) */
+        private String path;
+
+        /** 字节数 */
+        private Long size;
+    }
+}

+ 28 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/vo/SkillVO.java

@@ -0,0 +1,28 @@
+package com.zsjz.ai.module.agent.vo;
+
+import lombok.Data;
+
+/**
+ * 技能响应VO:一条技能(磁盘上一个含 SKILL.md 的目录)。
+ */
+@Data
+public class SkillVO {
+
+    /** 技能名(SKILL.md frontmatter 的 name,缺失时退回目录名) */
+    private String name;
+
+    /** 技能描述(frontmatter 的 description) */
+    private String description;
+
+    /** 来源:{@code user}=用户技能目录(可管理);{@code repo}=agent 配置的技能仓库(只读) */
+    private String source;
+
+    /** 是否可管理(可卸载 / 可启停) */
+    private Boolean managed;
+
+    /** 是否启用(未停用) */
+    private Boolean enabled;
+
+    /** 技能所在目录的绝对路径(页面展示,方便运维对照磁盘) */
+    private String path;
+}

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

@@ -0,0 +1,30 @@
+package com.zsjz.ai.module.agent.vo;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 工具组响应VO:一组同类工具(通话/交易/轨迹/画像/图谱/非表格材料)。
+ */
+@Data
+public class ToolGroupVO {
+
+    /** 组名(agent 内部标识,也是模型切换装备时的取值,如 {@code person}) */
+    private String code;
+
+    /** 中文名(页面展示用) */
+    private String label;
+
+    /** 是否新会话默认装备 */
+    private Boolean defaultActive;
+
+    /** 组描述原文:写给模型看的「什么时候该激活我」,页面里折叠展示 */
+    private String description;
+
+    /** 工具数量 */
+    private Integer toolCount;
+
+    /** 工具清单 */
+    private List<ToolVO> tools;
+}

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

@@ -0,0 +1,24 @@
+package com.zsjz.ai.module.agent.vo;
+
+import lombok.Data;
+
+import java.util.Map;
+
+/**
+ * 工具响应VO:agent 可调用的一个工具。
+ */
+@Data
+public class ToolVO {
+
+    /** 工具名(模型调用时用的标识,如 {@code stat_call_night_summary}) */
+    private String name;
+
+    /** 工具描述原文(写给模型看的说明) */
+    private String description;
+
+    /**
+     * 入参 JSON Schema({@code {type,properties,required,$defs}})。
+     * 页面按 {@code properties} + {@code required} 渲染参数表;原样下发,避免前后端两套字段定义。
+     */
+    private Map<String, Object> parameters;
+}

+ 165 - 0
ai-server/src/test/java/com/zsjz/ai/module/agent/skill/SkillStoreTest.java

@@ -0,0 +1,165 @@
+package com.zsjz.ai.module.agent.skill;
+
+import com.zsjz.ai.common.exception.ServerException;
+import com.zsjz.ai.module.agent.vo.SkillDetailVO;
+import com.zsjz.ai.module.agent.vo.SkillVO;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 技能文件仓库测试:安装 / 卸载 / 启停 / 列表。
+ *
+ * <p>全部跑在临时目录上,不依赖 Spring 上下文、数据库与登录态。
+ */
+class SkillStoreTest {
+
+    private final SkillStore skillStore = new SkillStore();
+
+    private static final String SKILL_MD = """
+            ---
+            name: night-call
+            description: 夜间通话分析套路
+            ---
+
+            # 夜间通话分析
+
+            1. 先按人员筛出夜间通话
+            2. 再看对方号码分布
+            """;
+
+    private static byte[] zip(String entryName, String content) throws IOException {
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        try (ZipOutputStream zos = new ZipOutputStream(out, StandardCharsets.UTF_8)) {
+            zos.putNextEntry(new ZipEntry(entryName));
+            zos.write(content.getBytes(StandardCharsets.UTF_8));
+            zos.closeEntry();
+        }
+        return out.toByteArray();
+    }
+
+    @Test
+    @DisplayName("安装 zip:解压到技能目录,名称取 frontmatter 的 name")
+    void installFromZip() throws IOException {
+        Path root = Files.createTempDirectory("skills-root-");
+        String name = skillStore.install(zip("night-call/SKILL.md", SKILL_MD), root, false);
+
+        assertEquals("night-call", name);
+        assertTrue(Files.isRegularFile(root.resolve("night-call/SKILL.md")));
+
+        List<SkillVO> skills = skillStore.list(root, SkillStore.SOURCE_USER, true, true);
+        assertEquals(1, skills.size());
+        assertEquals("night-call", skills.get(0).getName());
+        assertEquals("夜间通话分析套路", skills.get(0).getDescription());
+        assertTrue(skills.get(0).getEnabled());
+    }
+
+    @Test
+    @DisplayName("安装 zip:包内多套一层包装目录也能识别到技能根")
+    void installFromZipWithWrapperDir() throws IOException {
+        Path root = Files.createTempDirectory("skills-root-");
+        String name = skillStore.install(zip("night-call-v2/night-call/SKILL.md", SKILL_MD), root, false);
+
+        assertEquals("night-call", name);
+        assertTrue(Files.isRegularFile(root.resolve("night-call/SKILL.md")));
+    }
+
+    @Test
+    @DisplayName("安装 zip:没有 SKILL.md 或路径穿越都要被拒")
+    void installRejectsInvalidPackage() throws IOException {
+        Path root = Files.createTempDirectory("skills-root-");
+        byte[] noSkill = zip("readme.txt", "hello");
+        assertEquals(400, assertThrows(ServerException.class,
+                () -> skillStore.install(noSkill, root, false)).getCode());
+
+        byte[] traversal = zip("../escaped/SKILL.md", SKILL_MD);
+        assertEquals(400, assertThrows(ServerException.class,
+                () -> skillStore.install(traversal, root, false)).getCode());
+        assertFalse(Files.exists(root.getParent().resolve("escaped")), "越界文件不应被写出技能目录");
+    }
+
+    @Test
+    @DisplayName("安装 zip:同名技能默认不覆盖,勾选覆盖后才替换")
+    void installOverwriteSemantics() throws IOException {
+        Path root = Files.createTempDirectory("skills-root-");
+        skillStore.install(zip("night-call/SKILL.md", SKILL_MD), root, false);
+
+        String changed = SKILL_MD.replace("夜间通话分析套路", "改过的描述");
+        assertEquals(400, assertThrows(ServerException.class,
+                () -> skillStore.install(zip("night-call/SKILL.md", changed), root, false)).getCode());
+
+        skillStore.install(zip("night-call/SKILL.md", changed), root, true);
+        List<SkillVO> skills = skillStore.list(root, SkillStore.SOURCE_USER, true, true);
+        assertEquals(1, skills.size());
+        assertEquals("改过的描述", skills.get(0).getDescription());
+    }
+
+    @Test
+    @DisplayName("启停:停用写 .disabled 标记,启用删掉;停用清单供过滤器使用")
+    void toggleAndDisabledNames() throws IOException {
+        Path root = Files.createTempDirectory("skills-root-");
+        skillStore.install(zip("night-call/SKILL.md", SKILL_MD), root, false);
+
+        skillStore.toggle(root, "night-call", false);
+        assertTrue(Files.exists(root.resolve("night-call/" + SkillStore.DISABLED_MARKER)));
+        assertEquals(List.of("night-call"), skillStore.disabledNames(root));
+        assertFalse(skillStore.list(root, SkillStore.SOURCE_USER, true, true).get(0).getEnabled());
+
+        skillStore.toggle(root, "night-call", true);
+        assertFalse(Files.exists(root.resolve("night-call/" + SkillStore.DISABLED_MARKER)));
+        assertTrue(skillStore.disabledNames(root).isEmpty());
+        assertTrue(skillStore.list(root, SkillStore.SOURCE_USER, true, true).get(0).getEnabled());
+    }
+
+    @Test
+    @DisplayName("详情:返回 SKILL.md 正文与附属文件(不含停用标记)")
+    void readDetail() throws IOException {
+        Path root = Files.createTempDirectory("skills-root-");
+        skillStore.install(zip("night-call/SKILL.md", SKILL_MD), root, false);
+        Files.createDirectories(root.resolve("night-call/references"));
+        Files.writeString(root.resolve("night-call/references/tips.md"), "tips");
+        skillStore.toggle(root, "night-call", false);
+
+        SkillDetailVO detail = skillStore.read(root, "night-call", SkillStore.SOURCE_USER, true, true);
+        assertEquals("night-call", detail.getName());
+        assertTrue(detail.getContent().contains("夜间通话分析"));
+        assertFalse(detail.getEnabled());
+        assertEquals(List.of("references/tips.md"),
+                detail.getFiles().stream().map(SkillDetailVO.FileItem::getPath).toList());
+    }
+
+    @Test
+    @DisplayName("卸载:删除技能目录;不存在的技能报 404")
+    void deleteSkill() throws IOException {
+        Path root = Files.createTempDirectory("skills-root-");
+        skillStore.install(zip("night-call/SKILL.md", SKILL_MD), root, false);
+
+        skillStore.delete(root, "night-call");
+        assertFalse(Files.exists(root.resolve("night-call")));
+        assertTrue(skillStore.list(root, SkillStore.SOURCE_USER, true, true).isEmpty());
+
+        assertEquals(404, assertThrows(ServerException.class,
+                () -> skillStore.delete(root, "night-call")).getCode());
+    }
+
+    @Test
+    @DisplayName("目录不存在时列表为空而不是报错(新用户还没装过技能)")
+    void listOnMissingRoot() {
+        Path missing = Path.of(System.getProperty("java.io.tmpdir"), "not-exists-" + System.nanoTime());
+        assertTrue(skillStore.list(missing, SkillStore.SOURCE_USER, true, true).isEmpty());
+        assertTrue(skillStore.disabledNames(missing).isEmpty());
+    }
+}

+ 48 - 0
ai-server/src/test/java/com/zsjz/ai/module/agent/tools/AgentToolRegistryTest.java

@@ -32,6 +32,7 @@ import com.zsjz.ai.module.trans.service.TransFundFlowService;
 import com.zsjz.ai.module.trans.service.TransRecordService;
 import com.zsjz.ai.module.trans.service.TransSensitiveService;
 import io.agentscope.core.model.ToolSchema;
+import io.agentscope.core.tool.ToolGroup;
 import io.agentscope.core.tool.Toolkit;
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Test;
@@ -274,4 +275,51 @@ class AgentToolRegistryTest {
         // 它守的是「有人把 Spec 的 pageSize 批量改成 String」,不是精确计数。
         assertTrue(checked >= 45, "应至少检查到 45 个工具的 pageSize,实际: " + checked);
     }
+
+    @Test
+    @DisplayName("能力清单枚举:组按清单可取到、组描述与工具 schema 齐全(「插件与技能」页的契约)")
+    void capabilityListingContract() {
+        // 与 AgentCapabilityServiceImpl#listToolGroups 完全一致的枚举姿势:全部组激活,再用按名取组
+        Toolkit toolkit = new Toolkit();
+        AgentToolRegistry registry = new AgentToolRegistry(
+                mock(SqlResultStore.class),
+                mock(CallRecordService.class), mock(CallNightService.class),
+                mock(CallContinuousService.class), mock(CallSensitiveService.class),
+                mock(TransBigService.class), mock(TransCardHoldService.class),
+                mock(TransCashFlowService.class), mock(TransContinuousService.class),
+                mock(TransFastFundFlowService.class), mock(TransFinancialService.class),
+                mock(TransFixedDepositService.class), mock(TransFrequencyService.class),
+                mock(TransFundFlowService.class), mock(TransRecordService.class),
+                mock(TransSensitiveService.class),
+                mock(TrackCellTowerService.class), mock(TrackEnLocalService.class),
+                mock(TrackExpressInfoService.class), mock(TrackMeetService.class),
+                mock(TrackTogetherLiveService.class), mock(TrackTogetherTravelService.class),
+                mock(GraphService.class),
+                mock(DataProfileService.class), mock(IntimacyService.class),
+                mock(PersonBasicInfoService.class), mock(PersonGroupService.class),
+                mock(FileAiProfileService.class));
+        registry.registerBusinessTools(toolkit, AgentToolRegistry.registeredGroupNames());
+
+        Map<String, ToolSchema> schemaByName = toolkit.getToolSchemas().stream()
+                .collect(Collectors.toMap(ToolSchema::getName, schema -> schema));
+
+        int total = 0;
+        for (String group : AgentToolRegistry.registeredGroupNames()) {
+            ToolGroup g = toolkit.getToolGroup(group);
+            assertNotNull(g, "组取不到,页面上会少一组: " + group);
+            assertTrue(StringUtils.hasText(g.getDescription()), "组描述为空: " + group);
+            assertFalse(g.getTools().isEmpty(), "空的工具组不该出现在清单里: " + group);
+            assertTrue(StringUtils.hasText(AgentToolRegistry.groupLabel(group)), "缺少中文名: " + group);
+            for (String toolName : g.getTools()) {
+                ToolSchema schema = schemaByName.get(toolName);
+                assertNotNull(schema, "组内工具有名字但没有 schema(注册漏了): " + toolName);
+                assertTrue(StringUtils.hasText(schema.getDescription()), "工具描述为空: " + toolName);
+                assertNotNull(schema.getParameters(), "工具参数 schema 为空: " + toolName);
+                total++;
+            }
+        }
+        assertEquals(TOTAL_BUSINESS_TOOLS, total, "枚举出的业务工具总数应与会话侧一致");
+        // otg 组未注册(OTG_TOOLS 为空),不能出现在清单里,否则页面会多一个空组
+        assertFalse(AgentToolRegistry.registeredGroupNames().contains(AgentToolRegistry.GROUP_OTG));
+    }
 }

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio