Browse Source

feat(knowledge): 知识库模块——用户隔离、文档RAG、方案绑定与检索工具

- 数据库:knowledge_base / knowledge_doc / knowledge_doc_download_log 三表 + 内置库初始化 + agent.knowledge_base_id(sql/knowledge_base.sql,手工执行)
- 后端 module/knowledge:知识库 CRUD(内置全局可见只读、自建按 user_id 隔离、删除级联清理+解绑方案);文档上传(Tika 提取 sidecar)→ 事件驱动异步分块向量化(pgvector kb_vec_{kbId}_d{dims},复用 EmbeddingProvider 双模)→ 状态机 PENDING/INDEXING/READY/FAILED 可重试
- 文档层隔离:列表/下载/删除/重试仅对上传者本人开放(内置库清单对普通用户保密,检索不受限);下载写审计记录 + 冗余计数
- 方案绑定:SaveExpertDTO.knowledgeBaseId 保存校验可见性;buildAgent 默认注册 search_knowledge 基础设施工具 + 追加检索指引(fail-open),库删除自动解绑并驱逐实例池
- 修复:文档索引状态更新改显式 set(MP NOT_NULL 策略清不掉旧 fail_reason);CorsConfig allowCredentials=true(fetch credentials:include 的 SSE/下载被 preflight 拦截)
- 前端:知识库管理页(版式对齐研判方案页)+ 我的上传文档弹窗(上传/下载/重试/轮询)+ 工作台入口 + 方案表单绑定下拉 + uno safelist 补 5 个动态图标
- 测试:KnowledgeIsolationTest 两层隔离/级联/审计 10 例全绿
cc 6 days ago
parent
commit
87a9dd987a
38 changed files with 3487 additions and 19 deletions
  1. 1 0
      ai-electron/frontend/src/ai/api/index.ts
  2. 97 0
      ai-electron/frontend/src/ai/api/knowledgeApi.ts
  3. 46 0
      ai-electron/frontend/src/ai/api/types.ts
  4. 44 3
      ai-electron/frontend/src/ai/components/AgentFormModal.vue
  5. 409 0
      ai-electron/frontend/src/ai/components/KnowledgeDocModal.vue
  6. 785 0
      ai-electron/frontend/src/ai/views/aiKnowledge/index.vue
  7. 8 3
      ai-electron/frontend/src/ai/views/aiWorkbench/index.vue
  8. 6 0
      ai-electron/frontend/uno.config.ts
  9. 7 3
      ai-server/src/main/java/com/zsjz/ai/common/config/CorsConfig.java
  10. 7 4
      ai-server/src/main/java/com/zsjz/ai/module/agent/attachment/AttachmentDocIndexer.java
  11. 6 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/dto/SaveExpertDTO.java
  12. 7 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/entity/AgentEntity.java
  13. 92 3
      ai-server/src/main/java/com/zsjz/ai/module/agent/service/AgentService.java
  14. 66 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/tools/KnowledgeSearchTool.java
  15. 3 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/vo/ExpertVO.java
  16. 359 0
      ai-server/src/main/java/com/zsjz/ai/module/knowledge/KnowledgeDocIndexer.java
  17. 55 0
      ai-server/src/main/java/com/zsjz/ai/module/knowledge/KnowledgeDocParser.java
  18. 65 0
      ai-server/src/main/java/com/zsjz/ai/module/knowledge/KnowledgeProperties.java
  19. 122 0
      ai-server/src/main/java/com/zsjz/ai/module/knowledge/controller/KnowledgeBaseController.java
  20. 22 0
      ai-server/src/main/java/com/zsjz/ai/module/knowledge/dto/SaveKnowledgeBaseDTO.java
  21. 57 0
      ai-server/src/main/java/com/zsjz/ai/module/knowledge/entity/KnowledgeBase.java
  22. 81 0
      ai-server/src/main/java/com/zsjz/ai/module/knowledge/entity/KnowledgeDoc.java
  23. 45 0
      ai-server/src/main/java/com/zsjz/ai/module/knowledge/entity/KnowledgeDocDownloadLog.java
  24. 59 0
      ai-server/src/main/java/com/zsjz/ai/module/knowledge/event/KnowledgeDocIndexEvent.java
  25. 9 0
      ai-server/src/main/java/com/zsjz/ai/module/knowledge/mapper/KnowledgeBaseMapper.java
  26. 19 0
      ai-server/src/main/java/com/zsjz/ai/module/knowledge/mapper/KnowledgeDocDownloadLogMapper.java
  27. 28 0
      ai-server/src/main/java/com/zsjz/ai/module/knowledge/mapper/KnowledgeDocMapper.java
  28. 64 0
      ai-server/src/main/java/com/zsjz/ai/module/knowledge/mapper/KnowledgeVectorMapper.java
  29. 209 0
      ai-server/src/main/java/com/zsjz/ai/module/knowledge/service/KnowledgeBaseService.java
  30. 247 0
      ai-server/src/main/java/com/zsjz/ai/module/knowledge/service/KnowledgeDocService.java
  31. 31 0
      ai-server/src/main/java/com/zsjz/ai/module/knowledge/vo/KnowledgeBaseVO.java
  32. 38 0
      ai-server/src/main/java/com/zsjz/ai/module/knowledge/vo/KnowledgeDocVO.java
  33. 10 0
      ai-server/src/main/resources/application-dev.yaml
  34. 4 1
      ai-server/src/test/java/com/zsjz/ai/module/agent/service/AgentCapabilityToolkitTest.java
  35. 4 1
      ai-server/src/test/java/com/zsjz/ai/module/agent/service/AgentExpertTest.java
  36. 4 1
      ai-server/src/test/java/com/zsjz/ai/module/agent/service/impl/AgentToolCatalogServiceImplTest.java
  37. 301 0
      ai-server/src/test/java/com/zsjz/ai/module/knowledge/service/KnowledgeIsolationTest.java
  38. 70 0
      sql/knowledge_base.sql

+ 1 - 0
ai-electron/frontend/src/ai/api/index.ts

@@ -3,5 +3,6 @@
  */
 export * as chatApi from './chatApi';
 export * as modelApi from './modelApi';
+export * as knowledgeApi from './knowledgeApi';
 export { aiHttp } from './http';
 export * from './types';

+ 97 - 0
ai-electron/frontend/src/ai/api/knowledgeApi.ts

@@ -0,0 +1,97 @@
+/**
+ * 知识库管理接口 —— 后端 KnowledgeBaseController(/knowledge-bases/**,Result 包装)。
+ *
+ * <p>隔离口径在后端:知识库列表 = 内置 + 自己的(内置不可编辑/删除);
+ * 文档列表/下载/删除/重试只对上传者本人开放(「我的上传文档」);
+ * 上传权限:自建库=库主人,内置库=仅管理员。ID 一律按字符串传递(前端 ID 约定)。
+ */
+import { getToken } from '@/core/utils/auth';
+import { downloadByData } from '@/core/utils/file/download';
+import { resolveApiUrl } from '../utils/constants';
+import { aiHttp } from './http';
+import type { KnowledgeBase, KnowledgeDoc, SaveKnowledgeBaseParams } from './types';
+
+/** 知识库列表(内置 + 我的,后端按登录用户过滤) */
+export function listKnowledgeBases(): Promise<KnowledgeBase[]> {
+  return aiHttp.get<KnowledgeBase[]>('/knowledge-bases');
+}
+
+/** 新建自建知识库 */
+export function createKnowledgeBase(params: SaveKnowledgeBaseParams): Promise<KnowledgeBase> {
+  return aiHttp.postJson<KnowledgeBase>('/knowledge-bases', params as unknown as Record<string, any>);
+}
+
+/** 编辑知识库(内置会被后端拒绝) */
+export function updateKnowledgeBase(id: string, params: SaveKnowledgeBaseParams): Promise<KnowledgeBase> {
+  return aiHttp.putJson<KnowledgeBase>(`/knowledge-bases/${id}`, params as unknown as Record<string, any>);
+}
+
+/** 删除知识库(内置会被后端拒绝;级联清理文档与向量,并解绑引用它的研判方案) */
+export function deleteKnowledgeBase(id: string): Promise<void> {
+  return aiHttp.delete<void>(`/knowledge-bases/${id}`);
+}
+
+/** 「我的上传文档」列表(后端只返回当前用户上传的) */
+export function listMyDocs(kbId: string): Promise<KnowledgeDoc[]> {
+  return aiHttp.get<KnowledgeDoc[]>(`/knowledge-bases/${kbId}/docs`);
+}
+
+/**
+ * 上传文档(multipart)。提取在请求内完成,向量化异步(返回 PENDING,
+ * 列表轮询看 indexStatus)。
+ */
+export async function uploadDoc(kbId: string, file: File): Promise<KnowledgeDoc> {
+  const form = new FormData();
+  form.append('file', file);
+  const res: any = await aiHttp.request<any>(
+    {
+      url: `/knowledge-bases/${kbId}/docs`,
+      method: 'POST',
+      data: form,
+      // multipart 让 axios 自设 boundary,不手动写 content-type
+      timeout: 10 * 60 * 1000,
+    },
+  );
+  return res as KnowledgeDoc;
+}
+
+/** 删除文档(仅上传者) */
+export function deleteDoc(kbId: string, docId: string): Promise<void> {
+  return aiHttp.delete<void>(`/knowledge-bases/${kbId}/docs/${docId}`);
+}
+
+/** 重试索引(仅上传者;提取失败/嵌入服务临时不可用后可重放) */
+export function reindexDoc(kbId: string, docId: string): Promise<void> {
+  return aiHttp.postJson<void>(`/knowledge-bases/${kbId}/docs/${docId}/reindex`);
+}
+
+/**
+ * 下载文档(仅上传者;后端记下载审计)。
+ *
+ * <p><b>为什么必须走 fetch</b>:sa-token 只从请求头 x-token 取登录态,
+ * {@code <a href> 直链}不会带头,会被拦成未登录 —— 与产物下载(artifactApi)同一处理。
+ */
+export async function downloadDoc(meta: { kbId: string; docId: string; fileName: string }): Promise<void> {
+  const token = getToken();
+  const url = resolveApiUrl(`/knowledge-bases/${meta.kbId}/docs/${meta.docId}/download`);
+  const res = await fetch(url, {
+    headers: token ? { 'x-token': String(token) } : undefined,
+    credentials: 'include',
+  });
+
+  const contentType = res.headers.get('content-type') || '';
+  // 未登录/token 失效时后端回 JSON;正常下载是文件流 —— 不能把 JSON 存成文件
+  if (!res.ok || contentType.includes('application/json')) {
+    let message = `下载失败(HTTP ${res.status})`;
+    try {
+      const body = await res.json();
+      const text = body?.message || body?.msg;
+      if (text) message = String(text);
+    } catch {
+      /* 非 JSON 响应:保留默认文案 */
+    }
+    throw new Error(message);
+  }
+
+  downloadByData(await res.blob(), meta.fileName);
+}

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

@@ -58,6 +58,8 @@ export interface AiExpert {
   skillNames?: string[] | null;
   /** 勾选的工具包 id 列表;构成该方案的业务工具白名单 */
   toolPackIds?: string[] | null;
+  /** 绑定的知识库 id;null/缺省 = 未绑定(绑定后对话自动获得 search_knowledge 工具) */
+  knowledgeBaseId?: string | null;
 }
 
 /** 新建/编辑研判方案入参(builtin/userId 由服务端按登录态决定,前端不可传) */
@@ -69,6 +71,50 @@ export interface SaveExpertParams {
   toolGroups?: string[];
   skillNames?: string[];
   toolPackIds?: string[];
+  /** 绑定的知识库 id;不传/null = 不绑定 */
+  knowledgeBaseId?: string | null;
+}
+
+/** 知识库:内置全局可见只读(管理员维护文档);自建按用户隔离 */
+export interface KnowledgeBase {
+  /** 雪花/自增主键,一律字符串 */
+  id: string;
+  name: string;
+  description?: string;
+  /** 内置知识库:全局可见可绑定,只有管理员可维护文档 */
+  builtin: boolean;
+  /** 是否当前用户的自建知识库 */
+  mine: boolean;
+  /** 当前用户在该库下上传的文档数(「我的文档 N 篇」) */
+  myDocCount: number;
+  /** 当前用户在该库下待索引/失败的文档数 */
+  myPendingCount: number;
+}
+
+/** 知识库文档:列表/下载/删除只对上传者本人开放(后端过滤) */
+export interface KnowledgeDoc {
+  id: string;
+  kbId: string;
+  fileName: string;
+  mime?: string;
+  /** 文件大小(字节) */
+  sizeBytes: number;
+  /** 提取文本字符数 */
+  charCount: number;
+  /** 已向量化块数 */
+  chunkCount: number;
+  /** PENDING / INDEXING / READY / FAILED */
+  indexStatus: string;
+  failReason?: string;
+  /** 下载次数 */
+  downloadCount: number;
+  createTime?: string;
+}
+
+/** 新建/编辑知识库入参(builtin/userId 由服务端按登录态决定,前端不可传) */
+export interface SaveKnowledgeBaseParams {
+  name: string;
+  description?: string;
 }
 
 /** 工具包:用户自定义的任意工具组合(业务工具),在「插件与技能 → 工具包」维护 */

+ 44 - 3
ai-electron/frontend/src/ai/components/AgentFormModal.vue

@@ -44,6 +44,23 @@
             </div>
           </div>
 
+          <div class="ae-form__row">
+            <label class="ae-form__label">绑定知识库</label>
+            <div class="ae-form__control">
+              <a-select
+                v-model:value="kbId"
+                :options="kbOptions"
+                placeholder="不绑定(知识库在「知识库」页管理)"
+                allow-clear
+                show-search
+                option-filter-prop="label"
+              />
+              <div class="ae-form__kb-hint">
+                绑定后,本方案对话时模型可用 search_knowledge 工具按需检索库内知识(含来源文档名)。
+              </div>
+            </div>
+          </div>
+
           <div class="ae-form__row">
             <label class="ae-form__label"><i class="ae-form__req">*</i>系统提示词</label>
             <div class="ae-form__control ae-form__control--full">
@@ -173,14 +190,15 @@
    * 新增的纯前端能力(不影响保存语义):技能名称过滤、已选摘要条与「清空」。
    */
   import { computed, reactive, ref, watch } from 'vue';
-  import { Form, FormItem, Input, Spin, Textarea } from 'ant-design-vue';
+  import { Form, FormItem, Input, Select, Spin, Textarea } from 'ant-design-vue';
   import { BasicModal } from '@/core/components/Modal';
   import Icon from '@/core/components/Icon/src/Icon.vue';
   import { useMessage } from '@/core/hooks/web/useMessage';
   import * as agentApi from '../api/agentApi';
   import * as capabilityApi from '../api/capabilityApi';
   import * as toolPackApi from '../api/toolPackApi';
-  import type { AiExpert, SaveExpertParams, Skill, ToolPack } from '../api/types';
+  import * as knowledgeApi from '../api/knowledgeApi';
+  import type { AiExpert, KnowledgeBase, SaveExpertParams, Skill, ToolPack } from '../api/types';
   import { toolGroupLabel, toolLabel } from '../utils/constants';
 
   const AForm = Form;
@@ -188,6 +206,7 @@
   const AInput = Input;
   const ATextarea = Textarea;
   const ASpin = Spin;
+  const ASelect = Select;
 
   const props = defineProps<{
     open: boolean;
@@ -229,6 +248,15 @@
   const skills = ref<Skill[]>([]);
   const packs = ref<ToolPack[]>([]);
   const packsLoading = ref(false);
+  /** 知识库绑定(下拉单选 + 可清空;null = 不绑定) */
+  const knowledgeBases = ref<KnowledgeBase[]>([]);
+  const kbId = ref<string | null>(null);
+  const kbOptions = computed(() =>
+    knowledgeBases.value.map((kb) => ({
+      label: kb.builtin ? `${kb.name}(内置)` : kb.name,
+      value: kb.id,
+    })),
+  );
   /** 勾选态(key → checked);未出现的 key 视为不勾 */
   const checkedSkills = reactive<Record<string, boolean>>({});
   const checkedPacks = reactive<Record<string, boolean>>({});
@@ -280,13 +308,15 @@
     skillsLoading.value = true;
     packsLoading.value = true;
     try {
-      const [skillList, packList] = await Promise.all([
+      const [skillList, packList, kbList] = await Promise.all([
         capabilityApi.listSkills().catch(() => [] as Skill[]),
         toolPackApi.listToolPacks().catch(() => [] as ToolPack[]),
+        knowledgeApi.listKnowledgeBases().catch(() => [] as KnowledgeBase[]),
       ]);
       // 白名单只对用户技能目录生效:repo 来源是只读技能,不参与勾选
       skills.value = (skillList || []).filter((s) => s.source === 'user');
       packs.value = packList || [];
+      knowledgeBases.value = kbList || [];
       // 工具包清单就位后,把旧工具组映射成同名内置包勾选
       mapLegacyGroups();
     } finally {
@@ -304,6 +334,7 @@
         description: props.record?.description || '',
         sysPrompt: props.record?.sysPrompt || '',
       });
+      kbId.value = props.record?.knowledgeBaseId || null;
       resetChecks();
       skillKeyword.value = '';
       formRef.value?.clearValidate?.();
@@ -346,6 +377,8 @@
       toolGroups: legacyGroups.value,
       skillNames: pickedSkills.value,
       toolPackIds: pickedPackIds.value,
+      // 清空下拉 = 解绑(null 照写,后端显式 set 清列)
+      knowledgeBaseId: kbId.value || null,
     };
     submitting.value = true;
     try {
@@ -377,6 +410,14 @@
       color: var(--ai-text-2);
     }
 
+    /* 绑定知识库下拉下的说明小字 */
+    &__kb-hint {
+      margin-top: 4px;
+      font-size: 11.5px;
+      line-height: 1.5;
+      color: #98a2b3;
+    }
+
     &__form {
       display: flex;
       flex-direction: column;

+ 409 - 0
ai-electron/frontend/src/ai/components/KnowledgeDocModal.vue

@@ -0,0 +1,409 @@
+<template>
+  <!--
+    「我的上传文档」弹窗:知识库卡片上的「管理文档」打开。
+    文档按上传者隔离(后端过滤):列表/下载/删除/重试只对上传者本人开放;
+    内置库普通用户打开也是空列表(管理员维护的文档清单不外露,检索不受影响)。
+  -->
+  <BasicModal
+    :open="open"
+    :title="kb ? `我的上传文档:${kb.name}` : '我的上传文档'"
+    :width="880"
+    wrap-class-name="kd-doc-modal"
+    :show-ok-btn="false"
+    cancel-text="关闭"
+    :body-style="{ maxHeight: 'calc(100vh - 240px)', overflowY: 'auto' }"
+    @cancel="close"
+  >
+    <div class="kd-modal">
+      <div class="kd-modal__bar">
+        <span class="kd-modal__hint">文档按上传者隔离:这里只显示我上传的文件;方案绑定知识库后,对话会检索库内全部文档。</span>
+        <span class="kd-modal__sp"></span>
+        <a-upload :show-upload-list="false" :before-upload="onPickFile" :accept="acceptStr" :disabled="uploading">
+          <a-button type="primary" :loading="uploading">
+            <template #icon><Icon icon="mdi:upload-outline" :size="14" /></template>
+            上传文档
+          </a-button>
+        </a-upload>
+        <a-button :loading="loading" @click="load">
+          <template #icon><Icon icon="mdi:refresh" :size="14" /></template>
+          刷新
+        </a-button>
+      </div>
+
+      <a-table
+        :columns="columns"
+        :data-source="docs"
+        :loading="loading"
+        :pagination="false"
+        size="small"
+        row-key="id"
+        class="kd-modal__table"
+      >
+        <template #bodyCell="{ column, record }">
+          <template v-if="column.key === 'fileName'">
+            <span class="kd-modal__file" :title="record.fileName">{{ record.fileName }}</span>
+          </template>
+          <template v-else-if="column.key === 'sizeBytes'">
+            {{ formatSize(record.sizeBytes) }}
+          </template>
+          <template v-else-if="column.key === 'chunks'">
+            <span v-if="record.indexStatus === 'READY'">{{ record.chunkCount }} 块</span>
+            <span v-else class="kd-modal__dim">—</span>
+          </template>
+          <template v-else-if="column.key === 'indexStatus'">
+            <a-tag :color="statusColor(record.indexStatus)">{{ statusLabel(record.indexStatus) }}</a-tag>
+            <div v-if="record.failReason" class="kd-modal__fail" :title="record.failReason">
+              {{ record.failReason }}
+            </div>
+          </template>
+          <template v-else-if="column.key === 'ops'">
+            <a-tooltip title="下载">
+              <button class="kd-modal__iconbtn" type="button" aria-label="下载文档" @click="onDownload(record)">
+                <Icon icon="mdi:download-outline" :size="15" />
+              </button>
+            </a-tooltip>
+            <a-tooltip v-if="record.indexStatus === 'FAILED'" title="重试索引">
+              <button class="kd-modal__iconbtn" type="button" aria-label="重试索引" @click="onReindex(record)">
+                <Icon icon="mdi:refresh" :size="15" />
+              </button>
+            </a-tooltip>
+            <a-tooltip title="删除">
+              <button
+                class="kd-modal__iconbtn is-danger"
+                type="button"
+                aria-label="删除文档"
+                @click="onDelete(record)"
+              >
+                <Icon icon="mdi:trash-can-outline" :size="15" />
+              </button>
+            </a-tooltip>
+          </template>
+        </template>
+        <template #emptyText>
+          <div class="kd-modal__empty">
+            <EmptyState
+              title="还没有上传过文档"
+              description="点击右上角「上传文档」添加第一个文件(支持 PDF / Word / 表格 / 文本等)。上传后自动解析并向量化。"
+            />
+          </div>
+        </template>
+      </a-table>
+    </div>
+  </BasicModal>
+</template>
+
+<script lang="ts" setup name="KnowledgeDocModal">
+  /**
+   * 知识库「我的上传文档」弹窗:列表(后端按上传者过滤)/ 上传 / 下载 / 重试 / 删除。
+   *
+   *  - 上传:选择即传(before-upload 返回 false 阻止 antd 自动上传),返回 PENDING;
+   *  - 轮询:列表存在 PENDING/INDEXING 时每 3s 自动刷新,全部落定即停(索引是后台异步的);
+   *  - 下载:fetch 带 x-token 拿 blob 触发保存(直链不带登录态),后端记下载审计;
+   *  - 重试:仅 FAILED 出现(提取失败/嵌入服务临时不可用),重新走 PENDING → 索引。
+   */
+  import { onBeforeUnmount, ref, watch } from 'vue';
+  import { Button, Modal, Table, Tag, Tooltip, Upload } from 'ant-design-vue';
+  import { BasicModal } from '@/core/components/Modal';
+  import Icon from '@/core/components/Icon/src/Icon.vue';
+  import { EmptyState } from '@/core/components/EmptyState';
+  import { useMessage } from '@/core/hooks/web/useMessage';
+  import * as knowledgeApi from '../api/knowledgeApi';
+  import type { KnowledgeBase, KnowledgeDoc } from '../api/types';
+
+  const AButton = Button;
+  const ATable = Table;
+  const ATag = Tag;
+  const ATooltip = Tooltip;
+  const AUpload = Upload;
+  const AM = Modal;
+
+  const props = defineProps<{
+    open: boolean;
+    /** 目标知识库(标题与上传接口参数) */
+    kb: KnowledgeBase | null;
+  }>();
+  const emit = defineEmits<{ 'update:open': [value: boolean] }>();
+
+  const { showMessage } = useMessage();
+
+  const docs = ref<KnowledgeDoc[]>([]);
+  const loading = ref(false);
+  const uploading = ref(false);
+  let pollTimer: ReturnType<typeof setTimeout> | null = null;
+
+  const acceptStr = '.txt,.md,.log,.json,.xml,.yaml,.yml,.ini,.sql,.tsv,.pdf,.doc,.docx,.rtf,.odt,.wps,.html,.htm,.eml,.csv,.xls,.xlsx,.ppt,.pptx';
+
+  const columns = [
+    { title: '文件名', key: 'fileName', ellipsis: true },
+    { title: '大小', key: 'sizeBytes', width: 90 },
+    { title: '向量块', key: 'chunks', width: 80 },
+    { title: '索引状态', key: 'indexStatus', width: 150 },
+    { title: '下载次数', key: 'downloadCount', width: 80 },
+    { title: '操作', key: 'ops', width: 110 },
+  ];
+
+  watch(
+    () => props.open,
+    (open) => {
+      if (open) {
+        void load();
+      } else {
+        stopPoll();
+      }
+    },
+  );
+
+  onBeforeUnmount(stopPoll);
+
+  function close() {
+    emit('update:open', false);
+  }
+
+  function stopPoll() {
+    if (pollTimer) {
+      clearTimeout(pollTimer);
+      pollTimer = null;
+    }
+  }
+
+  async function load() {
+    if (!props.kb) return;
+    loading.value = true;
+    try {
+      docs.value = (await knowledgeApi.listMyDocs(props.kb.id)) || [];
+      schedulePoll();
+    } catch (e: any) {
+      showMessage(e?.message || '加载文档列表失败', 'error');
+    } finally {
+      loading.value = false;
+    }
+  }
+
+  /** 有未落定的索引状态时自动轮询,全部 READY/FAILED 即停 */
+  function schedulePoll() {
+    stopPoll();
+    if (!props.open) return;
+    const busy = docs.value.some((d) => d.indexStatus === 'PENDING' || d.indexStatus === 'INDEXING');
+    if (busy) {
+      pollTimer = setTimeout(() => {
+        void load();
+      }, 3000);
+    }
+  }
+
+  /** antd before-upload 返回 false 阻止自动上传,这里手动接管 */
+  function onPickFile(file: File) {
+    void doUpload(file);
+    return false;
+  }
+
+  async function doUpload(file: File) {
+    if (!props.kb) return;
+    uploading.value = true;
+    try {
+      await knowledgeApi.uploadDoc(props.kb.id, file);
+      showMessage('已上传,正在解析并向量化', 'success');
+      await load();
+    } catch (e: any) {
+      showMessage(e?.message || '上传失败', 'error');
+    } finally {
+      uploading.value = false;
+    }
+  }
+
+  async function onDownload(doc: KnowledgeDoc) {
+    try {
+      await knowledgeApi.downloadDoc({ kbId: doc.kbId, docId: doc.id, fileName: doc.fileName });
+    } catch (e: any) {
+      showMessage(e?.message || '下载失败', 'error');
+    }
+  }
+
+  async function onReindex(doc: KnowledgeDoc) {
+    try {
+      await knowledgeApi.reindexDoc(doc.kbId, doc.id);
+      showMessage('已加入索引队列', 'success');
+      await load();
+    } catch (e: any) {
+      showMessage(e?.message || '重试失败', 'error');
+    }
+  }
+
+  function onDelete(doc: KnowledgeDoc) {
+    Modal.confirm({
+      title: '删除文档',
+      content: `确认删除「${doc.fileName}」?其向量索引与下载记录会一并清理。`,
+      okText: '删除',
+      okType: 'primary',
+      okButtonProps: { danger: true },
+      cancelText: '取消',
+      onOk: async () => {
+        try {
+          await knowledgeApi.deleteDoc(doc.kbId, doc.id);
+          showMessage('已删除', 'success');
+          await load();
+        } catch (err: any) {
+          showMessage(err?.message || '删除失败', 'error');
+        }
+      },
+    });
+  }
+
+  function formatSize(bytes: number): string {
+    if (!bytes) return '—';
+    if (bytes < 1024) return `${bytes} B`;
+    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+    return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
+  }
+
+  function statusColor(status: string): string {
+    switch (status) {
+      case 'READY':
+        return 'success';
+      case 'INDEXING':
+        return 'processing';
+      case 'FAILED':
+        return 'error';
+      default:
+        return 'default';
+    }
+  }
+
+  function statusLabel(status: string): string {
+    switch (status) {
+      case 'READY':
+        return '已索引';
+      case 'INDEXING':
+        return '索引中';
+      case 'FAILED':
+        return '失败';
+      case 'PENDING':
+        return '排队中';
+      default:
+        return status;
+    }
+  }
+</script>
+
+<style lang="less" scoped>
+  .kd-modal {
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+
+    &__bar {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+
+      :deep(.ant-btn) {
+        height: 30px;
+        padding: 0 14px;
+        color: #5b6577;
+        font-size: 12px;
+        font-weight: 500;
+        background: #fff;
+        border: 1px solid #e7ebf3;
+        border-radius: 8px;
+        box-shadow: none;
+      }
+
+      :deep(.ant-btn-primary) {
+        color: #fff;
+        background: #2a50ec;
+        border-color: #2a50ec;
+
+        &:hover,
+        &:focus {
+          color: #fff;
+          background: #4b6bf0;
+          border-color: #4b6bf0;
+        }
+      }
+    }
+
+    &__hint {
+      flex: 1 1 auto;
+      color: #98a2b3;
+      font-size: 11.5px;
+      line-height: 1.6;
+    }
+
+    &__sp {
+      flex: 0 0 auto;
+    }
+
+    &__file {
+      display: inline-block;
+      max-width: 320px;
+      overflow: hidden;
+      color: #111827;
+      font-size: 12.5px;
+      font-weight: 500;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+      vertical-align: bottom;
+    }
+
+    &__dim {
+      color: #98a2b3;
+    }
+
+    &__fail {
+      max-width: 150px;
+      overflow: hidden;
+      margin-top: 4px;
+      color: #e5484d;
+      font-size: 11px;
+      line-height: 1.4;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+    }
+
+    &__iconbtn {
+      display: inline-flex;
+      align-items: center;
+      justify-content: center;
+      width: 26px;
+      height: 26px;
+      padding: 0;
+      margin-right: 4px;
+      color: #98a2b3;
+      background: #fff;
+      border: 1px solid #e7ebf3;
+      border-radius: 6px;
+      cursor: pointer;
+      transition:
+        color 0.15s ease,
+        background-color 0.15s ease,
+        border-color 0.15s ease;
+
+      &:hover {
+        color: #2a50ec;
+        background: #f8faff;
+        border-color: #bfcdfb;
+      }
+
+      &.is-danger:hover {
+        color: #e5484d;
+        background: #fff5f5;
+        border-color: rgb(229 72 77 / 35%);
+      }
+    }
+
+    &__empty {
+      padding: 12px 0;
+    }
+
+    :deep(.ant-table-thead > tr > th) {
+      color: #5b6577;
+      font-size: 12px;
+      font-weight: 500;
+      background: #f8fafc;
+    }
+
+    :deep(.ant-table-tbody > tr > td) {
+      font-size: 12.5px;
+    }
+  }
+</style>

+ 785 - 0
ai-electron/frontend/src/ai/views/aiKnowledge/index.vue

@@ -0,0 +1,785 @@
+<template>
+  <div class="ai-page ai-kb">
+    <!-- 内容卡:标题 + 摘要 + 搜索 + 操作 + 知识库卡片网格(工具栏合并进卡头,版式对齐研判方案页) -->
+    <section class="ai-kb__panel">
+      <div class="ai-kb__panel-head">
+        <span class="ai-kb__card-title">知识库</span>
+        <span class="ai-kb__summary">{{ summary }}</span>
+        <label class="ai-kb__search">
+          <Icon icon="mdi:magnify" :size="14" />
+          <input v-model="keyword" type="text" placeholder="输入知识库名称搜索" />
+        </label>
+        <span class="ai-kb__spacer"></span>
+        <span class="ai-kb__panel-count">共 {{ filteredBases.length }} 个</span>
+        <div class="ai-kb__actions">
+          <a-button :loading="loading" @click="load">
+            <template #icon><Icon icon="mdi:refresh" :size="14" /></template>
+            刷新
+          </a-button>
+          <a-button type="primary" @click="openCreate">
+            <template #icon><Icon icon="mdi:plus" :size="14" /></template>
+            新建知识库
+          </a-button>
+        </div>
+      </div>
+
+      <div class="ai-kb__panel-body">
+        <a-spin :spinning="loading">
+          <!-- 卡片混合展示(内置在前),卡片头标签区分内置 / 自定义 -->
+          <div v-if="filteredBases.length" class="ai-kb__grid">
+            <article v-for="kb in filteredBases" :key="kb.id" class="ai-kb__card">
+              <div class="ai-kb__card-head">
+                <span class="ai-kb__card-icon">
+                  <Icon icon="mdi:bookshelf" :size="17" />
+                </span>
+                <div class="ai-kb__card-heading">
+                  <div class="ai-kb__name-row">
+                    <span class="ai-kb__name">{{ kb.name }}</span>
+                    <span class="ai-kb__chip" :class="{ 'is-primary': !kb.builtin }">
+                      {{ kb.builtin ? '内置' : '自定义' }}
+                    </span>
+                  </div>
+                  <span class="ai-kb__card-sub">{{ kb.builtin ? '系统内置知识库(管理员维护)' : '我的知识库' }}</span>
+                </div>
+              </div>
+
+              <p class="ai-kb__desc">{{ kb.description || '暂无描述' }}</p>
+
+              <div class="ai-kb__chips">
+                <span class="ai-kb__chip">
+                  <i class="ai-kb__chip-dot" style="background: #0969da"></i>
+                  我的文档 {{ kb.myDocCount }} 篇
+                </span>
+                <span v-if="kb.myPendingCount > 0" class="ai-kb__chip">
+                  <i class="ai-kb__chip-dot" style="background: #d97706"></i>
+                  {{ kb.myPendingCount }} 篇待索引
+                </span>
+              </div>
+
+              <div class="ai-kb__card-ops">
+                <!-- 「管理文档」= 主操作:打开我的上传文档弹窗(列表/上传/下载按上传者隔离) -->
+                <button class="ai-kb__run" type="button" @click="openDocs(kb)">
+                  管理文档
+                  <Icon icon="mdi:arrow-right" :size="13" />
+                </button>
+                <span class="ai-kb__card-ops-sp"></span>
+                <!-- 查看详情:内置/自定义都可看(只读弹框) -->
+                <a-tooltip title="查看详情">
+                  <button class="ai-kb__iconbtn" type="button" aria-label="查看知识库详情" @click="openDetail(kb)">
+                    <Icon icon="mdi:eye-outline" :size="15" />
+                  </button>
+                </a-tooltip>
+                <!-- 内置只可查看/绑定(编辑/删除入口根本不渲染,后端另有双重校验) -->
+                <template v-if="!kb.builtin">
+                  <a-tooltip title="编辑知识库">
+                    <button class="ai-kb__iconbtn" type="button" aria-label="编辑知识库" @click="openEdit(kb)">
+                      <Icon icon="mdi:pencil-outline" :size="15" />
+                    </button>
+                  </a-tooltip>
+                  <a-tooltip title="删除知识库">
+                    <button class="ai-kb__iconbtn is-danger" type="button" aria-label="删除知识库" @click="onDelete(kb)">
+                      <Icon icon="mdi:trash-can-outline" :size="15" />
+                    </button>
+                  </a-tooltip>
+                </template>
+              </div>
+            </article>
+          </div>
+
+          <EmptyState
+            v-else-if="!loading"
+            :title="bases.length ? '没有匹配的知识库' : '还没有知识库'"
+            :description="
+              bases.length
+                ? '换个知识库名称试试,或清空搜索条件查看全部。'
+                : '点击右上角「新建知识库」创建第一个;内置知识库对所有用户可见。'
+            "
+            :min-height="300"
+            description-width="min(100%, 520px)"
+          />
+        </a-spin>
+      </div>
+    </section>
+
+    <!-- 新建/编辑:小弹框(名称 + 描述) -->
+    <BasicModal
+      :open="formOpen"
+      :title="editing ? '编辑知识库' : '新建知识库'"
+      :width="520"
+      :confirm-loading="submitting"
+      ok-text="保存"
+      cancel-text="取消"
+      @ok="submit"
+      @cancel="formOpen = false"
+    >
+      <a-form layout="vertical">
+        <a-form-item label="知识库名称" required>
+          <a-input v-model:value="form.name" placeholder="如:法规与量刑指引" :maxlength="100" />
+        </a-form-item>
+        <a-form-item label="描述">
+          <a-textarea
+            v-model:value="form.description"
+            :rows="3"
+            :maxlength="500"
+            placeholder="一句话说明这个知识库收录什么内容(可选)"
+          />
+        </a-form-item>
+      </a-form>
+    </BasicModal>
+
+    <!-- 知识库详情(只读):卡片眼睛图标打开 -->
+    <BasicModal
+      :open="detailOpen"
+      :title="detail ? `知识库详情:${detail.name}` : '知识库详情'"
+      :width="640"
+      :show-ok-btn="false"
+      cancel-text="关闭"
+      @cancel="detailOpen = false"
+    >
+      <div v-if="detail" class="akb-detail">
+        <div class="akb-detail__head">
+          <span class="akb-detail__badge" :class="detail.builtin ? 'is-builtin' : 'is-custom'">
+            {{ detail.builtin ? '内置' : '自定义' }}
+          </span>
+          <span class="akb-detail__name">{{ detail.name }}</span>
+        </div>
+        <p class="akb-detail__desc">{{ detail.description || '暂无描述' }}</p>
+
+        <section class="akb-detail__panel">
+          <div class="akb-detail__label">使用方式</div>
+          <p class="akb-detail__text">
+            在「研判方案」新建/编辑时绑定本知识库,对话中模型即可用 search_knowledge
+            工具按需检索库内知识(含来源文档名)。文档按上传者隔离:管理文档只显示自己上传的文件。
+          </p>
+        </section>
+
+        <section class="akb-detail__panel">
+          <div class="akb-detail__label">我的文档</div>
+          <p class="akb-detail__text">
+            {{ detail.myDocCount }} 篇<template v-if="detail.myPendingCount > 0">
+              ,其中 {{ detail.myPendingCount }} 篇待索引</template
+            >。{{ detail.builtin ? '内置库的文档由管理员维护,普通用户清单不外露(检索可用)。' : '' }}
+          </p>
+        </section>
+      </div>
+    </BasicModal>
+
+    <!-- 我的上传文档(列表/上传/下载按上传者隔离) -->
+    <KnowledgeDocModal v-model:open="docsOpen" :kb="docsKb" />
+  </div>
+</template>
+
+<script lang="ts" setup name="AiKnowledgeIndex">
+  /**
+   * 知识库管理页:卡片混合列表,卡片头标签区分「内置 / 自定义」—— 版式对齐研判方案页(aiExpert)。
+   *
+   * 对接 KnowledgeBaseController(/knowledge-bases/**,Result 包装):列表本身就是「内置 + 自己的」
+   * (后端过滤,内置在前),页面不再分组,只靠卡片角标区分来源。
+   *
+   * 操作语义:
+   *  - 管理文档(主操作):打开「我的上传文档」弹窗 —— 文档按上传者隔离,
+   *    内置库普通用户打开也是空列表(管理员维护的文档清单不外露,检索不受影响);
+   *  - 编辑/删除:仅自建卡片出现,内置的按钮根本不渲染(后端另有双重校验)。
+   */
+  import { computed, onMounted, reactive, ref } from 'vue';
+  import { Button, Form, FormItem, Input, Modal, Spin, Textarea, Tooltip } from 'ant-design-vue';
+  import Icon from '@/core/components/Icon/src/Icon.vue';
+  import { useMessage } from '@/core/hooks/web/useMessage';
+  import { BasicModal } from '@/core/components/Modal';
+  import { EmptyState } from '@/core/components/EmptyState';
+  import * as knowledgeApi from '../../api/knowledgeApi';
+  import type { KnowledgeBase, SaveKnowledgeBaseParams } from '../../api/types';
+  import KnowledgeDocModal from '../../components/KnowledgeDocModal.vue';
+
+  const AButton = Button;
+  const ASpin = Spin;
+  const ATooltip = Tooltip;
+  const AForm = Form;
+  const AFormItem = FormItem;
+  const AInput = Input;
+  const ATextarea = Textarea;
+
+  const { showMessage } = useMessage();
+
+  const bases = ref<KnowledgeBase[]>([]);
+  const loading = ref(false);
+
+  /** 新建/编辑(名称 + 描述) */
+  const formOpen = ref(false);
+  const submitting = ref(false);
+  const editing = ref<KnowledgeBase | null>(null);
+  const form = reactive({ name: '', description: '' });
+
+  /** 知识库详情(只读弹框) */
+  const detailOpen = ref(false);
+  const detail = ref<KnowledgeBase | null>(null);
+
+  /** 我的上传文档弹窗 */
+  const docsOpen = ref(false);
+  const docsKb = ref<KnowledgeBase | null>(null);
+
+  /** 知识库名称搜索(纯前端过滤,不改接口与加载逻辑) */
+  const keyword = ref('');
+
+  const filteredBases = computed(() => {
+    const k = keyword.value.trim().toLowerCase();
+    if (!k) return bases.value;
+    return bases.value.filter((kb) => (kb.name || '').toLowerCase().includes(k));
+  });
+
+  const builtinBases = computed(() => bases.value.filter((kb) => kb.builtin));
+  const mineBases = computed(() => bases.value.filter((kb) => !kb.builtin));
+
+  const summary = computed(() => {
+    const parts: string[] = [];
+    if (builtinBases.value.length) parts.push(`内置 ${builtinBases.value.length}`);
+    if (mineBases.value.length) parts.push(`我的 ${mineBases.value.length}`);
+    return parts.length ? parts.join(' · ') : '尚未创建知识库';
+  });
+
+  onMounted(load);
+
+  async function load() {
+    loading.value = true;
+    try {
+      bases.value = (await knowledgeApi.listKnowledgeBases()) || [];
+    } catch (e: any) {
+      showMessage(e?.message || '加载知识库列表失败', 'error');
+    } finally {
+      loading.value = false;
+    }
+  }
+
+  function openCreate() {
+    editing.value = null;
+    form.name = '';
+    form.description = '';
+    formOpen.value = true;
+  }
+
+  function openEdit(kb: KnowledgeBase) {
+    editing.value = kb;
+    form.name = kb.name;
+    form.description = kb.description || '';
+    formOpen.value = true;
+  }
+
+  async function submit() {
+    const name = form.name.trim();
+    if (!name) {
+      showMessage('请输入知识库名称', 'error');
+      return;
+    }
+    const payload: SaveKnowledgeBaseParams = { name, description: form.description?.trim() || '' };
+    submitting.value = true;
+    try {
+      if (editing.value) {
+        await knowledgeApi.updateKnowledgeBase(editing.value.id, payload);
+      } else {
+        await knowledgeApi.createKnowledgeBase(payload);
+      }
+      showMessage('已保存', 'success');
+      formOpen.value = false;
+      await load();
+    } catch (e: any) {
+      showMessage(e?.message || '保存失败', 'error');
+    } finally {
+      submitting.value = false;
+    }
+  }
+
+  function openDetail(kb: KnowledgeBase) {
+    detail.value = kb;
+    detailOpen.value = true;
+  }
+
+  function openDocs(kb: KnowledgeBase) {
+    docsKb.value = kb;
+    docsOpen.value = true;
+  }
+
+  function onDelete(kb: KnowledgeBase) {
+    Modal.confirm({
+      title: '删除知识库',
+      content: `确认删除「${kb.name}」?库内全部文档(含文件与向量索引)、下载记录会一并清理,绑定它的研判方案将自动解绑。`,
+      okText: '删除',
+      okType: 'primary',
+      okButtonProps: { danger: true },
+      cancelText: '取消',
+      onOk: async () => {
+        try {
+          await knowledgeApi.deleteKnowledgeBase(kb.id);
+          showMessage('已删除', 'success');
+          await load();
+        } catch (err: any) {
+          showMessage(err?.message || '删除失败', 'error');
+        }
+      },
+    });
+  }
+</script>
+
+<style lang="less" scoped>
+  /* ======================================================================
+   * 版式对齐研判方案页(aiExpert):页底 #f4f6fa · 白卡圆角 14 · 描边 #e7ebf3
+   * 标题蓝条 · 主按钮 #2a50ec · 卡片网格 auto-fill 320px
+   * ==================================================================== */
+  .ai-page.ai-kb {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+    width: 100%;
+    height: 100%;
+    min-height: 0;
+    margin: 0;
+    padding: 8px 10px;
+    background: #f4f6fa;
+    border: 0;
+    border-radius: 0;
+    box-sizing: border-box;
+    overflow: hidden;
+  }
+
+  .ai-kb {
+    &__card-title {
+      position: relative;
+      flex: 0 0 auto;
+      padding-left: 11px;
+      color: #111827;
+      font-size: 15px;
+      font-weight: 600;
+      line-height: 22px;
+      white-space: nowrap;
+
+      &::before {
+        content: '';
+        position: absolute;
+        top: 4px;
+        bottom: 4px;
+        left: 0;
+        width: 3px;
+        background: #2a50ec;
+        border-radius: 2px;
+      }
+    }
+
+    &__summary {
+      max-width: 200px;
+      overflow: hidden;
+      color: #98a2b3;
+      font-size: 11.5px;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+    }
+
+    &__search {
+      display: flex;
+      flex: 0 1 auto;
+      align-items: center;
+      gap: 8px;
+      width: 240px;
+      min-width: 160px;
+      height: 32px;
+      padding: 0 11px;
+      color: #98a2b3;
+      background: #fff;
+      border: 1px solid #e7ebf3;
+      border-radius: 8px;
+      cursor: text;
+      transition:
+        border-color 0.15s ease,
+        box-shadow 0.15s ease;
+
+      &:focus-within {
+        border-color: #2a50ec;
+        box-shadow: 0 0 0 2px rgb(42 80 236 / 12%);
+      }
+
+      input {
+        flex: 1;
+        min-width: 0;
+        color: #111827;
+        font-size: 12px;
+        font-family: inherit;
+        background: transparent;
+        border: 0;
+        outline: none;
+
+        &::placeholder {
+          color: #98a2b3;
+        }
+      }
+    }
+
+    &__spacer {
+      flex: 1 1 auto;
+    }
+
+    &__actions {
+      display: flex;
+      flex: 0 0 auto;
+      align-items: center;
+      gap: 8px;
+
+      :deep(.ant-btn) {
+        height: 30px;
+        padding: 0 14px;
+        color: #5b6577;
+        font-size: 12px;
+        font-weight: 500;
+        background: #fff;
+        border: 1px solid #e7ebf3;
+        border-radius: 8px;
+        box-shadow: none;
+
+        &:hover,
+        &:focus {
+          color: #2a50ec;
+          background: #f8faff;
+          border-color: #bfcdfb;
+        }
+      }
+
+      :deep(.ant-btn-primary) {
+        color: #fff;
+        background: #2a50ec;
+        border-color: #2a50ec;
+
+        &:hover,
+        &:focus {
+          color: #fff;
+          background: #4b6bf0;
+          border-color: #4b6bf0;
+        }
+
+        &:active {
+          background: #1e3ed0;
+          border-color: #1e3ed0;
+        }
+      }
+
+      :deep(.ant-btn[disabled]) {
+        color: rgb(0 0 0 / 25%);
+        background: #f5f5f5;
+        border-color: #e7ebf3;
+      }
+    }
+
+    &__panel {
+      display: flex;
+      flex: 1 1 auto;
+      flex-direction: column;
+      gap: 0;
+      min-height: 0;
+      padding: 0;
+      background: #fff;
+      border: 1px solid #e7ebf3;
+      border-radius: 14px;
+      box-shadow: 0 1px 2px rgb(16 24 40 / 4%);
+      overflow: hidden;
+    }
+
+    &__panel-head {
+      display: flex;
+      flex: 0 0 auto;
+      align-items: center;
+      gap: 10px;
+      min-height: 52px;
+      padding: 10px 14px;
+      border-bottom: 1px solid #eff2f7;
+      box-sizing: border-box;
+    }
+
+    &__panel-count {
+      color: #98a2b3;
+      font-size: 11.5px;
+      white-space: nowrap;
+      font-variant-numeric: tabular-nums;
+    }
+
+    &__panel-body {
+      flex: 1 1 auto;
+      min-height: 0;
+      padding: 14px;
+      overflow-y: auto;
+      overscroll-behavior: contain;
+      box-sizing: border-box;
+    }
+
+    &__grid {
+      display: grid;
+      grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+      gap: 12px;
+    }
+
+    &__card {
+      position: relative;
+      display: flex;
+      flex-direction: column;
+      gap: 10px;
+      padding: 14px;
+      background: #fff;
+      border: 1px solid #e7ebf3;
+      border-radius: 12px;
+      box-sizing: border-box;
+      transition:
+        border-color 0.15s ease,
+        box-shadow 0.15s ease;
+
+      &:hover {
+        border-color: #bfcdfb;
+        box-shadow: 0 4px 14px rgb(16 24 40 / 8%);
+        transform: none;
+      }
+    }
+
+    &__card-head {
+      display: flex;
+      align-items: flex-start;
+      gap: 10px;
+    }
+
+    &__card-icon {
+      display: flex;
+      flex: 0 0 auto;
+      align-items: center;
+      justify-content: center;
+      width: 34px;
+      height: 34px;
+      color: #2a50ec;
+      background: rgb(42 80 236 / 8%);
+      border-radius: 9px;
+    }
+
+    &__card-heading {
+      display: flex;
+      flex: 1 1 auto;
+      flex-direction: column;
+      gap: 4px;
+      min-width: 0;
+    }
+
+    &__name-row {
+      display: flex;
+      align-items: center;
+      gap: 6px;
+      min-width: 0;
+    }
+
+    &__name {
+      flex: 1 1 auto;
+      min-width: 0;
+      overflow: hidden;
+      color: #111827;
+      font-size: 14px;
+      font-weight: 600;
+      line-height: 1.5;
+      letter-spacing: 0;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+    }
+
+    &__card-sub {
+      overflow: hidden;
+      color: #98a2b3;
+      font-size: 11.5px;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+    }
+
+    &__chip {
+      display: inline-flex;
+      flex: 0 0 auto;
+      align-items: center;
+      gap: 5px;
+      max-width: 100%;
+      height: 20px;
+      padding: 0 7px;
+      overflow: hidden;
+      color: #5b6577;
+      font-size: 11px;
+      font-weight: 500;
+      line-height: 18px;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+      background: #f4f6fa;
+      border: 1px solid #e7ebf3;
+      border-radius: 6px;
+
+      &.is-primary {
+        color: #2a50ec;
+        background: rgb(42 80 236 / 7%);
+        border-color: rgb(42 80 236 / 18%);
+      }
+    }
+
+    &__chip-dot {
+      flex: 0 0 auto;
+      width: 5px;
+      height: 5px;
+      border-radius: 50%;
+    }
+
+    &__desc {
+      display: -webkit-box;
+      min-height: calc(12.5px * 1.7 * 2);
+      margin: 0;
+      overflow: hidden;
+      color: #5b6577;
+      font-size: 12.5px;
+      line-height: 1.7;
+      -webkit-box-orient: vertical;
+      -webkit-line-clamp: 3;
+    }
+
+    &__chips {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 6px;
+    }
+
+    &__card-ops {
+      display: flex;
+      align-items: center;
+      gap: 6px;
+      margin-top: auto;
+      padding-top: 10px;
+      border-top: 1px solid #eff2f7;
+    }
+
+    &__card-ops-sp {
+      flex: 1 1 auto;
+    }
+
+    &__run {
+      display: inline-flex;
+      align-items: center;
+      gap: 5px;
+      height: 26px;
+      padding: 0 10px;
+      color: #fff;
+      font-size: 11.5px;
+      font-weight: 500;
+      font-family: inherit;
+      white-space: nowrap;
+      background: #2a50ec;
+      border: 1px solid #2a50ec;
+      border-radius: 6px;
+      cursor: pointer;
+      transition:
+        background-color 0.15s ease,
+        border-color 0.15s ease;
+
+      &:hover {
+        background: #4b6bf0;
+        border-color: #4b6bf0;
+      }
+    }
+
+    &__iconbtn {
+      display: inline-flex;
+      align-items: center;
+      justify-content: center;
+      width: 26px;
+      height: 26px;
+      padding: 0;
+      color: #98a2b3;
+      background: #fff;
+      border: 1px solid #e7ebf3;
+      border-radius: 6px;
+      cursor: pointer;
+      transition:
+        color 0.15s ease,
+        background-color 0.15s ease,
+        border-color 0.15s ease;
+
+      &:hover {
+        color: #2a50ec;
+        background: #f8faff;
+        border-color: #bfcdfb;
+      }
+
+      &.is-danger:hover {
+        color: #e5484d;
+        background: #fff5f5;
+        border-color: rgb(229 72 77 / 35%);
+      }
+    }
+  }
+
+  /* ── 知识库详情弹框(只读):头 + 描述 + 使用方式 ── */
+  .akb-detail {
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+
+    &__head {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+    }
+
+    &__badge {
+      flex: 0 0 auto;
+      padding: 0 7px;
+      font-size: 11px;
+      line-height: 18px;
+      border-radius: 99px;
+
+      &.is-builtin {
+        color: #5b6577;
+        background: #f4f6fa;
+      }
+
+      &.is-custom {
+        color: #2a50ec;
+        background: rgb(42 80 236 / 8%);
+      }
+    }
+
+    &__name {
+      overflow: hidden;
+      color: #111827;
+      font-size: 15px;
+      font-weight: 700;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+    }
+
+    &__desc {
+      margin: 0;
+      color: #5b6577;
+      font-size: 13px;
+      line-height: 1.7;
+    }
+
+    &__panel {
+      display: flex;
+      flex-direction: column;
+      gap: 8px;
+      padding: 12px 14px;
+      background: #fff;
+      border: 1px solid #e7ebf3;
+      border-radius: 10px;
+    }
+
+    &__label {
+      color: #111827;
+      font-size: 12.5px;
+      font-weight: 700;
+    }
+
+    &__text {
+      margin: 0;
+      color: #5b6577;
+      font-size: 12.5px;
+      line-height: 1.7;
+    }
+  }
+
+  @media (max-width: 1180px) {
+    .ai-kb__summary {
+      display: none;
+    }
+  }
+</style>

+ 8 - 3
ai-electron/frontend/src/ai/views/aiWorkbench/index.vue

@@ -82,7 +82,10 @@
         <!-- ③ 研判方案(智能体) -->
         <AiExpertPage v-else-if="view === 'agent'" @chat="onChatWithExpert" />
 
-        <!-- ④ 插件与技能 -->
+        <!-- ④ 知识库 -->
+        <AiKnowledgePage v-else-if="view === 'knowledge'" />
+
+        <!-- ⑤ 插件与技能 -->
         <AiPluginPage v-else-if="view === 'plugin'" />
       </main>
 
@@ -118,13 +121,14 @@
   import ArtifactPreviewModal from '../../components/ArtifactPreviewModal.vue';
   import AiModelPage from '../aiModel/index.vue';
   import AiExpertPage from '../aiExpert/index.vue';
+  import AiKnowledgePage from '../aiKnowledge/index.vue';
   import AiPluginPage from '../aiPlugin/index.vue';
   import { useAiChatPage } from '../../composables/useAiChatPage';
   import { extractArtifacts } from '../../utils/artifacts';
   import type { AiExpert } from '../../api/types';
 
-  type WorkbenchView = 'chat' | 'model' | 'agent' | 'plugin';
-  const WORKBENCH_VIEWS: WorkbenchView[] = ['chat', 'model', 'agent', 'plugin'];
+  type WorkbenchView = 'chat' | 'model' | 'agent' | 'knowledge' | 'plugin';
+  const WORKBENCH_VIEWS: WorkbenchView[] = ['chat', 'model', 'agent', 'knowledge', 'plugin'];
 
   /**
    * 视图恢复,两个来源,前者优先:
@@ -153,6 +157,7 @@
   const RAIL_ITEMS: Array<{ key: WorkbenchView; icon: string; label: string }> = [
     // 研判方案沿用原「模型管理」的 brain 图标;模型管理改用芯片图标(与输入框的模型 chip 同形)
     { key: 'agent', icon: 'mdi:brain', label: '研判方案' },
+    { key: 'knowledge', icon: 'mdi:bookshelf', label: '知识库' },
     { key: 'plugin', icon: 'mdi:puzzle-outline', label: '插件与技能' },
     { key: 'model', icon: 'mdi:chip', label: '模型管理' },
   ];

+ 6 - 0
ai-electron/frontend/uno.config.ts

@@ -161,6 +161,12 @@ export default defineConfig({
 
     // Codex 工作台新增页面/组件用到的图标
     'i-mdi:dots-horizontal',
+    // 知识库页/文档弹窗/工作台入口(Icon 组件动态拼接,静态扫描不到)
+    'i-mdi:bookshelf',
+    'i-mdi:arrow-right',
+    'i-mdi:eye-outline',
+    'i-mdi:upload-outline',
+    'i-mdi:download-outline',
   ],
   presets: [
     presetWind3(),

+ 7 - 3
ai-server/src/main/java/com/zsjz/ai/common/config/CorsConfig.java

@@ -24,8 +24,12 @@ import java.util.List;
  * <p><b>为什么排在最前</b>:预检请求(OPTIONS)不携带登录态,
  * 必须早于 sa-token 的 Filter(order = -104)被应答,否则会被当成未登录拒绝。
  *
- * <p><b>为什么不带凭据</b>:前端用 {@code x-token} 请求头鉴权,不依赖 cookie,
- * 因此 {@code allowCredentials=false},配合 {@code allowedOriginPatterns=*} 是安全的;
+ * <p><b>凭据模式</b>:鉴权靠 {@code x-token} 请求头,不依赖 cookie;但前端的 SSE 流与
+ * 文件下载用原生 fetch 且带 {@code credentials: 'include'}(桌面端历史写法),浏览器在
+ * include 模式下强制要求响应头 {@code Access-Control-Allow-Credentials: true},否则
+ * preflight 直接失败(表现为对话「网络异常:Failed to fetch」)。因此这里开
+ * {@code allowCredentials=true};Spring 对 {@code allowedOriginPatterns=*} + credentials
+ * 的组合会回显请求 Origin 而不是输出字面 {@code *},不会放大暴露面。
  * 若有固定域名,用 {@code app.cors.allowed-origin-patterns} 收敛。
  *
  * <p><b>暴露 Content-Disposition</b>:跨域下 JS 只能读到安全列表内的响应头,
@@ -56,7 +60,7 @@ public class CorsConfig {
         config.addAllowedHeader(CorsConfiguration.ALL);
         config.addAllowedMethod(CorsConfiguration.ALL);
         config.setExposedHeaders(List.of("Content-Disposition"));
-        config.setAllowCredentials(false);
+        config.setAllowCredentials(true);
         config.setMaxAge(3600L);
 
         UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();

+ 7 - 4
ai-server/src/main/java/com/zsjz/ai/module/agent/attachment/AttachmentDocIndexer.java

@@ -229,8 +229,11 @@ public class AttachmentDocIndexer {
         }
     }
 
-    /** 分块:固定窗口 + 重叠;段落边界优先(在窗口末尾附近找换行切齐) */
-    static List<String> chunk(String text, int chunkSize, int chunkOverlap) {
+    /**
+     * 分块:固定窗口 + 重叠;段落边界优先(在窗口末尾附近找换行切齐)。
+     * 知识库文档索引(module/knowledge)复用同一算法,保证切分口径一致。
+     */
+    public static List<String> chunk(String text, int chunkSize, int chunkOverlap) {
         List<String> out = new ArrayList<>();
         int size = Math.max(200, chunkSize);
         int overlap = Math.min(Math.max(0, chunkOverlap), size / 4);
@@ -286,8 +289,8 @@ public class AttachmentDocIndexer {
         return "chat_att_vec_" + caseId + (tag == null ? "" : tag) + "_d" + dims;
     }
 
-    /** double[] → pgvector 字面量 */
-    private static String toVectorLiteral(double[] vector) {
+    /** double[] → pgvector 字面量(知识库文档索引复用) */
+    public static String toVectorLiteral(double[] vector) {
         StringBuilder sb = new StringBuilder(vector.length * 10).append('[');
         for (int i = 0; i < vector.length; i++) {
             if (i > 0) {

+ 6 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/dto/SaveExpertDTO.java

@@ -45,4 +45,10 @@ public class SaveExpertDTO {
      * 服务端校验每个工具包存在且属于当前用户。
      */
     private List<String> toolPackIds;
+
+    /**
+     * 绑定的知识库 id(knowledge_base.id);null/不传 = 不绑定。
+     * 服务端校验存在性 + 可见性(内置 or 自己的);绑定后对话自动获得 search_knowledge 工具。
+     */
+    private Long knowledgeBaseId;
 }

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

@@ -89,6 +89,13 @@ public class AgentEntity {
     @TableField(value = "tool_packs_json")
     private String toolPacksJson;
 
+    /**
+     * 方案绑定的知识库 id(knowledge_base.id);null = 未绑定。
+     * 绑定后 buildAgent 自动注册 search_knowledge 工具(基础设施工具,不受工具白名单约束)。
+     */
+    @TableField(value = "knowledge_base_id")
+    private Long knowledgeBaseId;
+
     /**
      * 允许使用的技能列表(JSON 数组字符串)。
      */

+ 92 - 3
ai-server/src/main/java/com/zsjz/ai/module/agent/service/AgentService.java

@@ -24,9 +24,13 @@ import com.zsjz.ai.module.agent.mapper.AgentModelMapper;
 import com.zsjz.ai.module.agent.mapper.SqlQueryMapper;
 import com.zsjz.ai.module.agent.middleware.CaseScopeMiddleware;
 import com.zsjz.ai.module.agent.python.PythonExecutor;
+import com.zsjz.ai.module.agent.rag.EmbeddingProvider;
 import com.zsjz.ai.module.agent.rag.RagSchemaService;
 import com.zsjz.ai.module.agent.rag.WorkspaceSchemaCatalog;
 import com.zsjz.ai.module.agent.tools.*;
+import com.zsjz.ai.module.knowledge.KnowledgeDocIndexer;
+import com.zsjz.ai.module.knowledge.entity.KnowledgeBase;
+import com.zsjz.ai.module.knowledge.mapper.KnowledgeBaseMapper;
 import com.zsjz.ai.module.agent.vo.ExpertVO;
 import com.zsjz.ai.module.govern.serivce.GovernTreeService;
 import com.zsjz.ai.module.plat.mapper.CaseInfoMapper;
@@ -91,6 +95,10 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
     private final WorkspaceManagerFactory workspaceManagerFactory;
     private final ArtifactService artifactService;
     private final GovernTreeService governTreeService;
+    /** 知识库绑定:加载绑定库(校验存在性/可见性)+ 检索工具装配(只依赖 mapper/索引器,避免与 KnowledgeBaseService 循环依赖) */
+    private final KnowledgeBaseMapper knowledgeBaseMapper;
+    private final KnowledgeDocIndexer knowledgeDocIndexer;
+    private final EmbeddingProvider embeddingProvider;
 
     /**
      * agent 实例池:key = w{workspaceId}-a{agentRowId}-m{modelId}-u{userId}
@@ -132,7 +140,10 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
             ArtifactService artifactService,
             GovernTreeService governTreeService,
             ToolPackService toolPackService,
-            WorkspaceSchemaCatalog schemaCatalog) {
+            WorkspaceSchemaCatalog schemaCatalog,
+            KnowledgeBaseMapper knowledgeBaseMapper,
+            KnowledgeDocIndexer knowledgeDocIndexer,
+            EmbeddingProvider embeddingProvider) {
         this.agentModelFactory = agentModelFactory;
         this.modelMapper = modelMapper;
         this.agentStateStore = stateStoreOpt.orElseGet(InMemoryAgentStateStore::new);
@@ -152,6 +163,9 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
         this.governTreeService = governTreeService;
         this.toolPackService = toolPackService;
         this.schemaCatalog = schemaCatalog;
+        this.knowledgeBaseMapper = knowledgeBaseMapper;
+        this.knowledgeDocIndexer = knowledgeDocIndexer;
+        this.embeddingProvider = embeddingProvider;
     }
 
     /**
@@ -247,7 +261,8 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
         if (ragSchemaService.isAvailable()) {
             sysPrompt = appendRagPrompt(sysPrompt);
         }
-        b.sysPrompt(sysPrompt);
+        // sysPrompt 的最终值要等知识库工具注册后才知道(追加使用指引),
+        // 统一在 toolkit 装配完成后 b.sysPrompt(...) 一次写定
         if (entry.getMaxIters() != null) {
             b.maxIters(entry.getMaxIters());
         }
@@ -279,7 +294,11 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
             agentToolRegistry.registerBusinessToolsForWhitelist(
                     toolkit, expertGroups == null ? List.of() : expertGroups, packTools);
         }
+        // 知识库:绑定了知识库的方案默认注册 search_knowledge(基础设施工具,不受白名单约束),
+        // 并在 sysPrompt 追加使用指引;未绑定/嵌入模型不可用时跳过(fail-open)
+        sysPrompt = registerKnowledgeTool(toolkit, entry, sysPrompt);
         b.toolkit(toolkit);
+        b.sysPrompt(sysPrompt);
         // 注册 reset_equipped_tools 元工具(内部会调 toolkit.registerMetaTool()),
         // 模型据此在对话中自行装备/卸载业务工具组;必须放在 toolkit(...) 之后、build() 之前。
         b.enableMetaTool(true);
@@ -409,7 +428,8 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
                 .set(AgentEntity::getSysPrompt, entity.getSysPrompt())
                 .set(AgentEntity::getToolsAllowJson, entity.getToolsAllowJson())
                 .set(AgentEntity::getSkillsAllowJson, entity.getSkillsAllowJson())
-                .set(AgentEntity::getToolPacksJson, entity.getToolPacksJson()));
+                .set(AgentEntity::getToolPacksJson, entity.getToolPacksJson())
+                .set(AgentEntity::getKnowledgeBaseId, entity.getKnowledgeBaseId()));
     }
 
     /**
@@ -457,6 +477,64 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
         }
     }
 
+    /**
+     * 知识库被删除后:解绑所有绑定它的方案(knowledge_base_id 置空)并驱逐池化实例。
+     *
+     * <p>表关系口径:方案 ↔ 知识库是松引用(knowledge_base_id),库删除时由本方法做**引用清理**,
+     * 不留悬空绑定(与 {@link #removePackBinding} 同口径)。
+     */
+    public void detachKnowledgeBase(Long kbId) {
+        for (AgentEntity agent : lambdaQuery().eq(AgentEntity::getKnowledgeBaseId, kbId).list()) {
+            update(new LambdaUpdateWrapper<AgentEntity>()
+                    .eq(AgentEntity::getRowId, agent.getRowId())
+                    .set(AgentEntity::getKnowledgeBaseId, null));
+            evictAgentPool(agent.getRowId());
+            log.info("知识库解绑方案: kbId={}, agentRowId={}", kbId, agent.getRowId());
+        }
+    }
+
+    /**
+     * 绑定了知识库的方案默认注册 search_knowledge 工具(基础设施工具:
+     * 不进任何工具组、不受 tools_allow_json/工具包白名单约束、reset_equipped_tools 不收起),
+     * 并在系统提示词追加使用指引。
+     *
+     * <p>fail-open:库已删除 / 嵌入模型不可用时跳过注册并 log.warn,不阻断对话
+     * (绑定校验在保存时已做,运行期异常只降级为「没有该工具」)。
+     *
+     * @return 追加知识库指引后的 sysPrompt(未注册时原样返回)
+     */
+    private String registerKnowledgeTool(Toolkit toolkit, AgentEntity entry, String sysPrompt) {
+        Long kbId = entry.getKnowledgeBaseId();
+        if (kbId == null) {
+            return sysPrompt;
+        }
+        KnowledgeBase kb = knowledgeBaseMapper.selectById(kbId);
+        if (kb == null) {
+            log.warn("方案绑定的知识库不存在,跳过知识库工具注册: agentRowId={}, kbId={}", entry.getRowId(), kbId);
+            return sysPrompt;
+        }
+        if (!embeddingProvider.isAvailable()) {
+            log.warn("嵌入模型不可用,跳过知识库工具注册: agentRowId={}, kbId={}", entry.getRowId(), kbId);
+            return sysPrompt;
+        }
+        toolkit.registerTool(new KnowledgeSearchTool(knowledgeDocIndexer, kbId));
+        return appendKbPrompt(sysPrompt, kb.getName());
+    }
+
+    /**
+     * 在 sysPrompt 后追加知识库检索指引(Agentic RAG:Agent 自行决定何时检索)。
+     */
+    private String appendKbPrompt(String sysPrompt, String kbName) {
+        String kbPrompt = """
+
+                ## 知识库检索(重要)
+                你拥有 search_knowledge 工具,可按语义检索本方案绑定的知识库「%s」(返回知识片段与来源文档名)。
+                - 用户问题涉及领域知识、法规条文、业务规范、背景资料时,必须先检索再回答;引用片段时标注来源文档名。
+                - 检索不到相关内容时明确告知用户,禁止凭空编造知识内容。
+                - 检索 query 用自然语言描述知识点,一次检索不理想可换角度再次检索。""".formatted(kbName);
+        return sysPrompt.isBlank() ? kbPrompt.strip() : sysPrompt + kbPrompt;
+    }
+
     /** 内置专家不可编辑/删除;自建必须属于当前用户 */
     private AgentEntity requireOwnedExpert(Long userId, Long rowId, String action) {
         AgentEntity entity = getById(rowId);
@@ -485,6 +563,15 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
         if (dto.getToolPackIds() != null && !dto.getToolPackIds().isEmpty()) {
             toolPackService.requireBindable(userId, dto.getToolPackIds());
         }
+        if (dto.getKnowledgeBaseId() != null) {
+            KnowledgeBase kb = knowledgeBaseMapper.selectById(dto.getKnowledgeBaseId());
+            if (kb == null) {
+                throw new ServerException(400, "绑定的知识库不存在");
+            }
+            if (!kb.isBuiltin() && (userId == null || !userId.equals(kb.getUserId()))) {
+                throw new ServerException(400, "只能绑定自己可见的知识库(内置或自建)");
+            }
+        }
         List<String> skills = dto.getSkillNames();
         if (skills == null || skills.isEmpty()) {
             return;
@@ -510,6 +597,7 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
         entity.setToolsAllowJson(writeStringList(dto.getToolGroups()));
         entity.setSkillsAllowJson(writeStringList(dto.getSkillNames()));
         entity.setToolPacksJson(writeStringList(dto.getToolPackIds()));
+        entity.setKnowledgeBaseId(dto.getKnowledgeBaseId());
     }
 
     private ExpertVO toExpertVO(AgentEntity e, Long userId) {
@@ -523,6 +611,7 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
         vo.setToolGroups(readStringList(e.getToolsAllowJson()));
         vo.setSkillNames(readStringList(e.getSkillsAllowJson()));
         vo.setToolPackIds(readStringList(e.getToolPacksJson()));
+        vo.setKnowledgeBaseId(e.getKnowledgeBaseId() == null ? null : String.valueOf(e.getKnowledgeBaseId()));
         return vo;
     }
 

+ 66 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/tools/KnowledgeSearchTool.java

@@ -0,0 +1,66 @@
+package com.zsjz.ai.module.agent.tools;
+
+import com.zsjz.ai.common.utils.Json;
+import com.zsjz.ai.module.knowledge.KnowledgeDocIndexer;
+import io.agentscope.core.message.ToolResultBlock;
+import io.agentscope.core.tool.Tool;
+import io.agentscope.core.tool.ToolParam;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 知识库语义检索工具(Agentic RAG)。
+ *
+ * <p>研判方案绑定了知识库时由 {@code AgentService.buildAgent} 默认注册为<b>基础设施工具</b>
+ * (不进任何业务工具组、不受工具白名单约束),由 Agent(LLM)自行决定何时调用。
+ * 检索范围固定为绑定的那个知识库的全量文档 —— 文档列表层的「只看自己上传」隔离
+ * 不适用于检索(知识可用、清单保密);绑定的库名通过系统提示词(appendKbPrompt)告知模型。
+ *
+ * <p>返回值统一用 {@link ToolResults} 包装:直接返回 String 会被 AgentScope 再 JSON 序列化一次。
+ */
+@Slf4j
+public class KnowledgeSearchTool {
+
+    private final KnowledgeDocIndexer docIndexer;
+    private final Long kbId;
+
+    public KnowledgeSearchTool(KnowledgeDocIndexer docIndexer, Long kbId) {
+        this.docIndexer = docIndexer;
+        this.kbId = kbId;
+    }
+
+    /**
+     * 按语义检索研判方案绑定的知识库(全量文档,不区分上传者)
+     */
+    @Tool(name = "search_knowledge",
+          description = "按语义检索当前研判方案绑定的知识库,返回最相关的知识片段(含来源文档名与相似度)。"
+                  + "当用户问题涉及领域知识、法规条文、业务规范、背景资料等,而系统提示词指明本方案已绑定知识库时,"
+                  + "应先调用本工具检索再回答,禁止凭空编造知识内容。检索 query 用自然语言描述要查找的知识点。",
+          readOnly = true)
+    public ToolResultBlock searchKnowledge(
+            @ToolParam(name = "query", description = "自然语言查询,必填,例如:电信网络诈骗案件的量刑标准") String query,
+            @ToolParam(name = "top_k", description = "返回最相似的前 K 个片段,默认 8", required = false) Integer topK) {
+        try {
+            List<KnowledgeDocIndexer.SearchHit> hits = docIndexer.search(query, kbId);
+            List<Map<String, Object>> out = new ArrayList<>(hits.size());
+            for (KnowledgeDocIndexer.SearchHit hit : hits) {
+                Map<String, Object> item = new LinkedHashMap<>();
+                item.put("content", hit.content());
+                item.put("source", hit.docName());
+                item.put("score", hit.score());
+                out.add(item);
+            }
+            if (out.isEmpty()) {
+                return ToolResults.ok("知识库中没有与查询相关的内容。");
+            }
+            return ToolResults.ok(Json.toStr(out));
+        } catch (Exception e) {
+            log.error("知识库检索失败: kbId={}, query={}, error={}", kbId, query, e.getMessage());
+            return ToolResults.error("知识库检索失败: " + e.getMessage());
+        }
+    }
+}

+ 3 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/vo/ExpertVO.java

@@ -36,4 +36,7 @@ public class ExpertVO {
 
     /** 勾选的工具包 id 列表(字符串);与 toolGroups 并集构成业务工具白名单 */
     private List<String> toolPackIds;
+
+    /** 绑定的知识库 id(字符串);null = 未绑定 */
+    private String knowledgeBaseId;
 }

+ 359 - 0
ai-server/src/main/java/com/zsjz/ai/module/knowledge/KnowledgeDocIndexer.java

@@ -0,0 +1,359 @@
+package com.zsjz.ai.module.knowledge;
+
+import com.zsjz.ai.common.utils.Json;
+import com.zsjz.ai.common.utils.LocalFileStorageUtil;
+import com.zsjz.ai.module.agent.attachment.AttachmentDocIndexer;
+import com.zsjz.ai.module.agent.rag.EmbeddingProvider;
+import com.zsjz.ai.module.knowledge.entity.KnowledgeDoc;
+import com.zsjz.ai.module.knowledge.event.KnowledgeDocIndexEvent;
+import com.zsjz.ai.module.knowledge.mapper.KnowledgeDocMapper;
+import com.zsjz.ai.module.knowledge.mapper.KnowledgeVectorMapper;
+import jakarta.annotation.PreDestroy;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * 知识库文档的 RAG 索引器:事件驱动异步执行 分块 → embedding → pgvector。
+ *
+ * <p>嵌入模型与附件/表结构索引共用 {@link EmbeddingProvider}(本地 ONNX / 在线模型双模)。
+ * 向量表按知识库分表 {@code kb_vec_{kbId}{tag}_d{dims}}(tag 区分本地/在线模型),
+ * chunk 的 payload 强制带 {@code kbId + docId + docName} —— 检索侧按 kbId 过滤(隔离双保险),
+ * 返回片段时可标注来源文档名。
+ *
+ * <p>消费 {@link KnowledgeDocIndexEvent}(上传/重试时发布),单线程守护队列顺序执行:
+ * 嵌入(本地 ONNX 是 CPU 密集型)本身不该并发铺开,全局并发闸门
+ * {@code index-concurrency} 限制的是单个文档内 chunk 的嵌入并发。
+ * 失败置 FAILED + fail_reason(可重试),绝不让异常逃出队列线程。
+ *
+ * @author cc
+ * @since 2026/10/25
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class KnowledgeDocIndexer {
+
+    private final EmbeddingProvider embeddingProvider;
+    private final KnowledgeVectorMapper vectorMapper;
+    private final KnowledgeDocMapper docMapper;
+    private final KnowledgeDocParser docParser;
+    private final LocalFileStorageUtil storageUtil;
+    private final KnowledgeProperties props;
+
+    /** 单线程守护队列:文档逐个索引,避免多文档同时打满嵌入模型 */
+    private final ExecutorService indexExecutor = Executors.newSingleThreadExecutor(r -> {
+        Thread t = new Thread(r, "kb-doc-index");
+        t.setDaemon(true);
+        return t;
+    });
+
+    /** 单文档内 chunk 嵌入的并发闸门(懒初始化,permits 取 index-concurrency 配置) */
+    private volatile Semaphore globalEmbedGate;
+
+    private Semaphore embedGate() {
+        Semaphore gate = globalEmbedGate;
+        if (gate == null) {
+            synchronized (this) {
+                if (globalEmbedGate == null) {
+                    globalEmbedGate = new Semaphore(Math.max(1, props.getIndexConcurrency()), true);
+                }
+                gate = globalEmbedGate;
+            }
+        }
+        return gate;
+    }
+
+    /** 检索命中片段(带来源文档名与相似度,供 search_knowledge 工具组装结果) */
+    public record SearchHit(String content, String docName, double score) {
+    }
+
+    @PreDestroy
+    void shutdown() {
+        indexExecutor.shutdownNow();
+    }
+
+    /**
+     * 事件消费:排入单线程队列。队列里做幂等(先删旧向量行再重插),重复事件无害。
+     */
+    @EventListener
+    public void onDocIndexEvent(KnowledgeDocIndexEvent event) {
+        if (!props.isEnabled()) {
+            return;
+        }
+        Long docId = event.docId();
+        try {
+            indexExecutor.submit(() -> indexDoc(docId));
+        } catch (Exception e) {
+            log.error("知识库文档索引任务提交失败: docId={}, err={}", docId, e.getMessage());
+        }
+    }
+
+    /**
+     * 索引一个文档:PENDING/FAILED → INDEXING → READY / FAILED。
+     * 异常全部就地消化(状态落 FAILED),不让异常逃出队列线程。
+     */
+    void indexDoc(Long docId) {
+        KnowledgeDoc doc = docMapper.selectById(docId);
+        if (doc == null) {
+            return;
+        }
+        markStatus(doc, "INDEXING", null);
+        try {
+            String fullText = loadFullText(doc);
+            doIndex(doc, fullText);
+        } catch (Exception e) {
+            String reason = StringUtils.truncate(e.getMessage(), 900);
+            markStatus(doc, "FAILED", reason);
+            log.error("知识库文档索引失败: docId={}, file={}, err={}", docId, doc.getFileName(), e.getMessage());
+        }
+    }
+
+    /** 全文来源:sidecar 优先;缺失(上次提取失败)时从原件兜底重抽并补写 sidecar */
+    private String loadFullText(KnowledgeDoc doc) throws Exception {
+        int maxChars = props.getMaxChunks() * props.getChunkSize();
+        if (StringUtils.hasText(doc.getSidecarPath())) {
+            try (InputStream in = storageUtil.download(doc.getSidecarPath())) {
+                return new String(in.readAllBytes(), StandardCharsets.UTF_8);
+            }
+        }
+        String fullText;
+        try (InputStream in = storageUtil.download(doc.getFilePath())) {
+            fullText = docParser.extract(in, maxChars);
+        }
+        String sidecarPath = KnowledgeDocParser.sidecarPathOf(doc.getFilePath());
+        storageUtil.uploadString(fullText, sidecarPath);
+        doc.setSidecarPath(sidecarPath);
+        doc.setCharCount(fullText.length());
+        doc.setUpdateTime(LocalDateTime.now());
+        docMapper.updateById(doc);
+        return fullText;
+    }
+
+    private void doIndex(KnowledgeDoc doc, String fullText) {
+        if (!embeddingProvider.isAvailable()) {
+            throw new IllegalStateException(embeddingProvider.unavailableMessage() + ",无法索引文档");
+        }
+        int dimensions = embeddingProvider.dimensions();
+        if (dimensions <= 0) {
+            throw new IllegalStateException("嵌入模型维度无效: " + dimensions);
+        }
+        String table = vectorTableName(doc.getKbId(), embeddingProvider.tableTag(), dimensions);
+        // 并发首建兜底:CREATE TABLE IF NOT EXISTS 并发时会撞 pg_class 唯一索引,确认表已存在即视为成功
+        try {
+            vectorMapper.createVectorTable(table, dimensions);
+        } catch (Exception e) {
+            if (vectorMapper.tableExists(table) == 0) {
+                throw new IllegalStateException("知识库向量表创建失败: " + e.getMessage());
+            }
+        }
+
+        List<String> chunks = AttachmentDocIndexer.chunk(fullText, props.getChunkSize(), props.getChunkOverlap());
+        boolean truncated = chunks.size() > props.getMaxChunks();
+        if (truncated) {
+            chunks = chunks.subList(0, props.getMaxChunks());
+        }
+        // 重放幂等:重索引前清掉该文档旧行
+        vectorMapper.deleteByDoc(table, String.valueOf(doc.getId()));
+
+        long deadlineMs = System.currentTimeMillis() + props.getIndexBudgetSeconds() * 1000L;
+        Semaphore gate = embedGate();
+        AtomicInteger ok = new AtomicInteger();
+        AtomicInteger skipped = new AtomicInteger();
+        List<Runnable> jobs = new ArrayList<>(chunks.size());
+        for (int i = 0; i < chunks.size(); i++) {
+            int chunkNo = i;
+            String content = chunks.get(i);
+            jobs.add(() -> {
+                if (System.currentTimeMillis() > deadlineMs) {
+                    skipped.incrementAndGet();
+                    return;
+                }
+                try {
+                    gate.acquire();
+                } catch (InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                    return;
+                }
+                try {
+                    if (System.currentTimeMillis() > deadlineMs) {
+                        skipped.incrementAndGet();
+                        return;
+                    }
+                    double[] vec = embeddingProvider.embed(content);
+                    Map<String, Object> payload = new LinkedHashMap<>();
+                    payload.put("kbId", doc.getKbId());
+                    payload.put("docId", doc.getId());
+                    payload.put("docName", doc.getFileName());
+                    payload.put("chunkNo", chunkNo);
+                    vectorMapper.insertVectorDoc(table, doc.getId() + ":" + chunkNo, content,
+                            Json.toStr(payload), AttachmentDocIndexer.toVectorLiteral(vec));
+                    ok.incrementAndGet();
+                } catch (Exception e) {
+                    log.error("知识库 chunk 向量化失败: docId={}, chunk={}, err={}",
+                            doc.getId(), chunkNo, e.getMessage());
+                } finally {
+                    gate.release();
+                }
+            });
+        }
+        // 虚拟线程并发执行,join 全部(块数有上限,内存无虞)
+        List<Thread> threads = jobs.stream()
+                .map(j -> Thread.ofVirtual().name("kb-embed-", 0).start(j))
+                .toList();
+        for (Thread t : threads) {
+            try {
+                t.join();
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                break;
+            }
+        }
+        if (skipped.get() > 0) {
+            log.info("知识库文档索引预算用尽({}s),跳过 {}/{} 个 chunk: docId={}, 已索引 {} 块",
+                    props.getIndexBudgetSeconds(), skipped.get(), chunks.size(), doc.getId(), ok.get());
+        }
+        if (ok.get() == 0) {
+            throw new IllegalStateException(skipped.get() > 0
+                    ? "索引预算用尽(index-budget-seconds=" + props.getIndexBudgetSeconds() + "s)且无一 chunk 完成索引"
+                    : "全部 chunk 向量化失败,请检查嵌入服务");
+        }
+
+        doc.setChunkCount(ok.get());
+        doc.setIndexStatus("READY");
+        doc.setFailReason(truncated ? "文档过长,仅索引前 " + ok.get() + " 块" : null);
+        doc.setUpdateTime(LocalDateTime.now());
+        // MP 默认 NOT_NULL 策略会跳过 null 字段:重试成功时旧 fail_reason 必须显式清掉(updateById 清不掉)
+        docMapper.update(new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<KnowledgeDoc>()
+                .eq(KnowledgeDoc::getId, doc.getId())
+                .set(KnowledgeDoc::getChunkCount, ok.get())
+                .set(KnowledgeDoc::getIndexStatus, "READY")
+                .set(KnowledgeDoc::getFailReason, doc.getFailReason())
+                .set(KnowledgeDoc::getUpdateTime, doc.getUpdateTime()));
+        log.info("知识库文档索引完成: docId={}, file={}, chunks={}{}",
+                doc.getId(), doc.getFileName(), ok.get(), truncated ? "(截断)" : "");
+    }
+
+    private void markStatus(KnowledgeDoc doc, String status, String failReason) {
+        doc.setIndexStatus(status);
+        doc.setFailReason(failReason);
+        doc.setUpdateTime(LocalDateTime.now());
+        // 同上:显式 set(INDEXING 置位要清旧 fail_reason;FAILED 落原因)
+        docMapper.update(new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<KnowledgeDoc>()
+                .eq(KnowledgeDoc::getId, doc.getId())
+                .set(KnowledgeDoc::getIndexStatus, status)
+                .set(KnowledgeDoc::getFailReason, failReason)
+                .set(KnowledgeDoc::getUpdateTime, doc.getUpdateTime()));
+    }
+
+    /**
+     * 按知识库检索 top-k 片段(cosine,强制 kbId 过滤),供 search_knowledge 工具调用。
+     */
+    public List<SearchHit> search(String question, Long kbId) {
+        if (!embeddingProvider.isAvailable() || kbId == null) {
+            return List.of();
+        }
+        int dimensions = embeddingProvider.dimensions();
+        if (dimensions <= 0) {
+            return List.of();
+        }
+        double[] vec = embeddingProvider.embed(question);
+        String table = vectorTableName(kbId, embeddingProvider.tableTag(), dimensions);
+        if (vectorMapper.tableExists(table) == 0) {
+            // 本地/在线开关切换后旧表名下一片空白:按「无召回」降级,不把 relation does not exist 抛出去
+            return List.of();
+        }
+        List<Map<String, Object>> rows = vectorMapper.searchInKb(table, AttachmentDocIndexer.toVectorLiteral(vec),
+                String.valueOf(kbId), props.getScoreThreshold(), props.getTopK());
+        List<SearchHit> out = new ArrayList<>(rows.size());
+        for (Map<String, Object> row : rows) {
+            Map<String, Object> payload = parsePayload(row.get("payload"));
+            Object content = row.get("content");
+            if (content == null) {
+                continue;
+            }
+            Object docName = payload.get("docName");
+            double score = row.get("score") instanceof Number n ? n.doubleValue() : 0;
+            out.add(new SearchHit(content.toString(), docName == null ? null : docName.toString(), score));
+        }
+        return out;
+    }
+
+    /**
+     * 删除某文档的全部向量行(删文档/重索引时调用)。
+     *
+     * <p>本地/在线开关可能已切换,带标签与不带标签的表名、常规维度各试一遍;
+     * 表不存在是常态,静默忽略。
+     */
+    public void deleteDocVectors(Long kbId, Long docId) {
+        String tag = embeddingProvider.tableTag();
+        for (int dims : new int[]{1024, 1536}) {
+            deleteQuietly(vectorTableName(kbId, tag, dims), String.valueOf(docId));
+            if (StringUtils.hasText(tag)) {
+                deleteQuietly(vectorTableName(kbId, "", dims), String.valueOf(docId));
+            }
+        }
+    }
+
+    /** 删除某知识库的全部向量行(级联删库时调用),表名遍历口径同上 */
+    public void deleteKbVectors(Long kbId) {
+        String tag = embeddingProvider.tableTag();
+        for (int dims : new int[]{1024, 1536}) {
+            deleteQuietlyKb(vectorTableName(kbId, tag, dims), String.valueOf(kbId));
+            if (StringUtils.hasText(tag)) {
+                deleteQuietlyKb(vectorTableName(kbId, "", dims), String.valueOf(kbId));
+            }
+        }
+    }
+
+    /**
+     * 向量表名(按知识库分表)。
+     *
+     * @param tag 模型标签:本地模式为 {@code _bge_m3},在线模式为空串
+     */
+    public static String vectorTableName(Long kbId, String tag, int dims) {
+        return "kb_vec_" + kbId + (tag == null ? "" : tag) + "_d" + dims;
+    }
+
+    private void deleteQuietly(String table, String docId) {
+        try {
+            vectorMapper.deleteByDoc(table, docId);
+        } catch (Exception ignore) {
+            // 表不存在是常态(该库还没建过这个维度的向量表),静默
+        }
+    }
+
+    private void deleteQuietlyKb(String table, String kbId) {
+        try {
+            vectorMapper.deleteByKb(table, kbId);
+        } catch (Exception ignore) {
+            // 表不存在是常态,静默
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private static Map<String, Object> parsePayload(Object payload) {
+        if (payload == null) {
+            return Map.of();
+        }
+        try {
+            return Json.objectMapper().readValue(payload.toString(), Map.class);
+        } catch (Exception e) {
+            return Map.of();
+        }
+    }
+}

+ 55 - 0
ai-server/src/main/java/com/zsjz/ai/module/knowledge/KnowledgeDocParser.java

@@ -0,0 +1,55 @@
+package com.zsjz.ai.module.knowledge;
+
+import lombok.extern.slf4j.Slf4j;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.parser.AutoDetectParser;
+import org.apache.tika.sax.BodyContentHandler;
+import org.springframework.stereotype.Component;
+
+import java.io.InputStream;
+
+/**
+ * 知识库文档文本抽取(Tika AutoDetect)。
+ *
+ * <p>上传链路(KnowledgeDocService)用它提取全文落 sidecar .txt;
+ * 索引链路(KnowledgeDocIndexer)在 sidecar 缺失(提取失败后重试)时兜底重抽。
+ * writeLimit 超限时 Tika 中断解析,已提取内容仍在 handler 里 —— 诚实截断,不报错丢弃
+ * (与对话附件的文档提取同一套处理)。
+ */
+@Slf4j
+@Component
+public class KnowledgeDocParser {
+
+    /**
+     * 从原件输入流提取全文。
+     *
+     * @param in       原件输入流(调用方负责关闭)
+     * @param maxChars 提取字符上限(超限截断)
+     * @return 提取的全文
+     * @throws IllegalStateException 内容为空/解析彻底失败
+     */
+    public String extract(InputStream in, int maxChars) {
+        int limit = (int) Math.min((long) maxChars + 1, Integer.MAX_VALUE);
+        BodyContentHandler handler = new BodyContentHandler(limit);
+        try {
+            new AutoDetectParser().parse(in, handler, new Metadata());
+        } catch (Exception e) {
+            // 超限中断时已提取的内容仍在 handler 里:非空则视为成功(截断),为空才是真失败
+            if (handler.toString().isEmpty()) {
+                throw new IllegalStateException("文档文本提取失败: " + e.getMessage());
+            }
+            log.warn("文档提取超限截断: err={}", e.getMessage());
+        }
+        String full = handler.toString();
+        if (full.isBlank()) {
+            throw new IllegalStateException("文档无可提取文本(可能是扫描件/图片型 PDF)");
+        }
+        return full;
+    }
+
+    /** sidecar 存储相对路径:与原件同目录同名,扩展名替换为 .txt */
+    public static String sidecarPathOf(String originalPath) {
+        int dot = originalPath.lastIndexOf('.');
+        return (dot > 0 ? originalPath.substring(0, dot) : originalPath) + ".txt";
+    }
+}

+ 65 - 0
ai-server/src/main/java/com/zsjz/ai/module/knowledge/KnowledgeProperties.java

@@ -0,0 +1,65 @@
+package com.zsjz.ai.module.knowledge;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+/**
+ * 知识库配置({@code zsjz.knowledge})。
+ *
+ * <p>文档上传限制与 RAG 索引参数。分块/检索参数与对话附件({@code zsjz.chat-attachment})
+ * 各自独立,互不影响。
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "zsjz.knowledge")
+public class KnowledgeProperties {
+
+    /** 是否启用知识库功能(关闭后列表页与工具注册一并降级) */
+    private boolean enabled = true;
+
+    /** 单文件大小上限(MB) */
+    private long maxFileMb = 50;
+
+    /** 单用户在单个知识库下的文档数上限 */
+    private int maxDocsPerUser = 200;
+
+    /** RAG 分块大小(字符) */
+    private int chunkSize = 1000;
+
+    /** RAG 分块重叠(字符) */
+    private int chunkOverlap = 100;
+
+    /** RAG 分块数量上限:超出截断索引并标注 */
+    private int maxChunks = 2000;
+
+    /** 单文档索引总预算(秒):超时后剩余 chunk 跳过(truncated 提示),防嵌入端点变慢占住队列 */
+    private int indexBudgetSeconds = 900;
+
+    /** 嵌入并发度(全局闸门,所有文档共享) */
+    private int indexConcurrency = 8;
+
+    /** search_knowledge 检索片段数 */
+    private int topK = 8;
+
+    /** 检索相似度阈值(cosine score = 1 - 距离) */
+    private double scoreThreshold = 0.3;
+
+    /** 接受的后缀白名单(小写;空 = 用默认清单) */
+    private List<String> acceptSuffixes;
+
+    /** 默认文档后缀(Tika 可解析的文本类文档与常见表格) */
+    public static final List<String> DEFAULT_SUFFIXES = List.of(
+            "txt", "md", "log", "json", "xml", "yaml", "yml", "ini", "sql", "tsv",
+            "pdf", "doc", "docx", "rtf", "odt", "wps", "html", "htm", "eml",
+            "csv", "xls", "xlsx", "ppt", "pptx");
+
+    /** 生效的后缀白名单 */
+    public List<String> effectiveSuffixes() {
+        return acceptSuffixes == null || acceptSuffixes.isEmpty()
+                ? DEFAULT_SUFFIXES
+                : acceptSuffixes.stream().map(s -> s.toLowerCase().trim()).filter(s -> !s.isEmpty()).toList();
+    }
+}

+ 122 - 0
ai-server/src/main/java/com/zsjz/ai/module/knowledge/controller/KnowledgeBaseController.java

@@ -0,0 +1,122 @@
+package com.zsjz.ai.module.knowledge.controller;
+
+import cn.dev33.satoken.stp.StpUtil;
+import com.zsjz.ai.common.config.Result;
+import com.zsjz.ai.module.knowledge.dto.SaveKnowledgeBaseDTO;
+import com.zsjz.ai.module.knowledge.service.KnowledgeDocService;
+import com.zsjz.ai.module.knowledge.service.KnowledgeBaseService;
+import com.zsjz.ai.module.knowledge.vo.KnowledgeBaseVO;
+import com.zsjz.ai.module.knowledge.vo.KnowledgeDocVO;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.core.io.InputStreamResource;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+/**
+ * 知识库管理控制器。
+ *
+ * <p>口径(详见 {@link KnowledgeBaseService} / {@link KnowledgeDocService}):
+ * <ul>
+ *   <li>知识库列表 = 内置(全局可见)+ 当前用户自建,<b>后端</b>按登录用户过滤;</li>
+ *   <li>内置库只可查看/绑定:编辑/删除接口按 is_builtin 双重拒绝(不靠前端隐藏);</li>
+ *   <li>文档列表/下载/删除/重试只对<b>上传者本人</b>开放(比知识库本体更细的隔离层);
+ *       上传权限:自建库=库主人,内置库=仅管理员(userId=1);</li>
+ *   <li>下载响应为裸文件流(无 Result 包装),前端 fetch 带 x-token 拿 blob 触发保存。</li>
+ * </ul>
+ */
+@Slf4j
+@RestController
+@RequestMapping("/knowledge-bases")
+@RequiredArgsConstructor
+public class KnowledgeBaseController {
+
+    private final KnowledgeBaseService kbService;
+    private final KnowledgeDocService docService;
+
+    /** 知识库列表:内置 + 我的(后端按登录用户过滤) */
+    @GetMapping
+    public Result<List<KnowledgeBaseVO>> list() {
+        return Result.succeed(kbService.list(currentUserId()));
+    }
+
+    /** 新建自建知识库 */
+    @PostMapping
+    public Result<KnowledgeBaseVO> create(@Valid @RequestBody SaveKnowledgeBaseDTO dto) {
+        return Result.succeed(kbService.create(currentUserId(), dto));
+    }
+
+    /** 编辑知识库(内置拒绝) */
+    @PutMapping("/{id}")
+    public Result<KnowledgeBaseVO> update(@PathVariable Long id, @Valid @RequestBody SaveKnowledgeBaseDTO dto) {
+        return Result.succeed(kbService.update(currentUserId(), id, dto));
+    }
+
+    /** 删除知识库(内置拒绝;级联清理文档与向量,并解绑引用它的方案) */
+    @DeleteMapping("/{id}")
+    public Result<Void> delete(@PathVariable Long id) {
+        kbService.delete(currentUserId(), id);
+        return Result.succeed();
+    }
+
+    /** 「我的上传文档」列表(只返回当前用户上传的) */
+    @GetMapping("/{id}/docs")
+    public Result<List<KnowledgeDocVO>> listDocs(@PathVariable Long id) {
+        return Result.succeed(docService.listMine(currentUserId(), id));
+    }
+
+    /** 上传文档(multipart;提取与向量化异步,返回登记结果与索引状态) */
+    @PostMapping("/{id}/docs")
+    public Result<KnowledgeDocVO> uploadDoc(@PathVariable Long id, @RequestParam("file") MultipartFile file) {
+        return Result.succeed(docService.upload(currentUserId(), id, file));
+    }
+
+    /** 删除文档(仅上传者;级联清理文件与向量) */
+    @DeleteMapping("/{id}/docs/{docId}")
+    public Result<Void> deleteDoc(@PathVariable Long id, @PathVariable Long docId) {
+        docService.delete(currentUserId(), docId);
+        return Result.succeed();
+    }
+
+    /** 重试索引(仅上传者;提取失败/嵌入服务临时不可用后可重放) */
+    @PostMapping("/{id}/docs/{docId}/reindex")
+    public Result<Void> reindexDoc(@PathVariable Long id, @PathVariable Long docId) {
+        docService.reindex(currentUserId(), docId);
+        return Result.succeed();
+    }
+
+    /**
+     * 下载文档(仅上传者;写下载记录)。
+     *
+     * <p>响应为裸文件流(无 Result 包装):浏览器直发不带请求头会拿到未登录响应,
+     * 前端必须 fetch 带 {@code x-token} 拿 blob 后触发保存。
+     */
+    @GetMapping("/{id}/docs/{docId}/download")
+    public ResponseEntity<InputStreamResource> downloadDoc(@PathVariable Long id, @PathVariable Long docId) {
+        KnowledgeDocService.DownloadResult result = docService.download(currentUserId(), docId);
+        String encoded = URLEncoder.encode(result.fileName(), StandardCharsets.UTF_8).replace("+", "%20");
+        return ResponseEntity.ok()
+                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encoded)
+                .body(new InputStreamResource(result.in()));
+    }
+
+    private static Long currentUserId() {
+        return StpUtil.getLoginIdAsLong();
+    }
+}

+ 22 - 0
ai-server/src/main/java/com/zsjz/ai/module/knowledge/dto/SaveKnowledgeBaseDTO.java

@@ -0,0 +1,22 @@
+package com.zsjz.ai.module.knowledge.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+/**
+ * 新建/编辑知识库请求 DTO。
+ *
+ * <p>创建与编辑共用:编辑时由服务端校验归属(非内置 + 属于当前用户),
+ * 不接受 builtin/userId 字段 —— 这两个维度只能由服务端按登录态决定。
+ */
+@Data
+public class SaveKnowledgeBaseDTO {
+
+    @NotBlank(message = "知识库名称不能为空")
+    @Size(max = 100, message = "名称长度不能超过100")
+    private String name;
+
+    @Size(max = 500, message = "描述长度不能超过500")
+    private String description;
+}

+ 57 - 0
ai-server/src/main/java/com/zsjz/ai/module/knowledge/entity/KnowledgeBase.java

@@ -0,0 +1,57 @@
+package com.zsjz.ai.module.knowledge.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.io.Serial;
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 知识库({@code knowledge_base})。
+ *
+ * <h3>隔离口径</h3>
+ * {@code is_builtin=1} 内置库:全局可见、可绑定到研判方案,仅平台管理员(user_id=1)可维护文档;
+ * {@code is_builtin=0} 自建库:仅创建者可见(user_id 过滤)、可编辑可删除。
+ *
+ * <p>库内文档的可见性是<b>另一层隔离</b>(见 {@link KnowledgeDoc}):文档列表/下载只对
+ * 上传者本人开放,与「知识库可见」解耦。
+ */
+@Data
+@TableName("knowledge_base")
+public class KnowledgeBase implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /** 雪花 ID;内置库固定 id=1 */
+    @TableId(value = "id", type = IdType.ASSIGN_ID)
+    private Long id;
+
+    /** 创建者(sa-token 登录用户 ID);内置库固定 1(平台管理员) */
+    @TableField("user_id")
+    private Long userId;
+
+    /** 是否内置:1=内置(全局可见只读),0=用户自建 */
+    @TableField("is_builtin")
+    private Integer isBuiltin;
+
+    @TableField("name")
+    private String name;
+
+    @TableField("description")
+    private String description;
+
+    @TableField("create_time")
+    private LocalDateTime createTime;
+
+    @TableField("update_time")
+    private LocalDateTime updateTime;
+
+    public boolean isBuiltin() {
+        return isBuiltin != null && isBuiltin == 1;
+    }
+}

+ 81 - 0
ai-server/src/main/java/com/zsjz/ai/module/knowledge/entity/KnowledgeDoc.java

@@ -0,0 +1,81 @@
+package com.zsjz.ai.module.knowledge.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.io.Serial;
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 知识库文档({@code knowledge_doc}):一次上传的文件登记。
+ *
+ * <h3>隔离</h3>
+ * {@code user_id} 即上传者,是文档列表/下载/删除/重试的<b>唯一归属依据</b> ——
+ * 即便知识库本身对所有人可见(内置库),文档清单也只对上传者本人开放。
+ *
+ * <h3>索引状态机</h3>
+ * PENDING(上传登记)→ INDEXING(异步索引中)→ READY / FAILED(失败带 fail_reason,可重试)。
+ * 检索链路只消费 READY 文档的向量(其余状态的自然搜不到)。
+ */
+@Data
+@TableName("knowledge_doc")
+public class KnowledgeDoc implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    @TableId(value = "id", type = IdType.ASSIGN_ID)
+    private Long id;
+
+    @TableField("kb_id")
+    private Long kbId;
+
+    /** 上传者(列表/下载隔离依据) */
+    @TableField("user_id")
+    private Long userId;
+
+    @TableField("file_name")
+    private String fileName;
+
+    /** 原件存储相对路径(LocalFileStorageUtil 根下;下载用) */
+    @TableField("file_path")
+    private String filePath;
+
+    /** Tika 全文 .txt 存储相对路径(向量化用;提取失败时为 null,重试时补提取) */
+    @TableField("sidecar_path")
+    private String sidecarPath;
+
+    @TableField("mime")
+    private String mime;
+
+    @TableField("size_bytes")
+    private Long sizeBytes;
+
+    /** 提取文本字符数 */
+    @TableField("char_count")
+    private Integer charCount;
+
+    /** 已向量化块数 */
+    @TableField("chunk_count")
+    private Integer chunkCount;
+
+    @TableField("index_status")
+    private String indexStatus;
+
+    @TableField("fail_reason")
+    private String failReason;
+
+    /** 下载次数(冗余计数,我的文档列表展示) */
+    @TableField("download_count")
+    private Integer downloadCount;
+
+    @TableField("create_time")
+    private LocalDateTime createTime;
+
+    @TableField("update_time")
+    private LocalDateTime updateTime;
+}

+ 45 - 0
ai-server/src/main/java/com/zsjz/ai/module/knowledge/entity/KnowledgeDocDownloadLog.java

@@ -0,0 +1,45 @@
+package com.zsjz.ai.module.knowledge.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.io.Serial;
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 知识库文档下载记录({@code knowledge_doc_download_log}):审计用途,只增不改。
+ *
+ * <p>{@code fileName} 是下载时的文件名快照 —— 文档行随后被删除时,记录仍能说明
+ * 「谁在何时下载过什么名字的文件」。
+ */
+@Data
+@TableName("knowledge_doc_download_log")
+public class KnowledgeDocDownloadLog implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    @TableId(value = "id", type = IdType.ASSIGN_ID)
+    private Long id;
+
+    @TableField("kb_id")
+    private Long kbId;
+
+    @TableField("doc_id")
+    private Long docId;
+
+    /** 下载人 */
+    @TableField("user_id")
+    private Long userId;
+
+    /** 下载时文件名快照 */
+    @TableField("file_name")
+    private String fileName;
+
+    @TableField("download_time")
+    private LocalDateTime downloadTime;
+}

+ 59 - 0
ai-server/src/main/java/com/zsjz/ai/module/knowledge/event/KnowledgeDocIndexEvent.java

@@ -0,0 +1,59 @@
+package com.zsjz.ai.module.knowledge.event;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+
+/**
+ * 知识库文档待索引事件:文档上传 / 重试索引时发布,由 {@code KnowledgeDocIndexer}
+ * 异步消费(分块 → embedding → pgvector)。
+ *
+ * <h3>为什么用事件而不是直接调索引器</h3>
+ * 上传链路(KnowledgeDocService)与索引链路(KnowledgeDocIndexer)解耦:
+ * 发布方只声明事实,索引快慢不影响上传请求返回;与模板元数据变更
+ * ({@code TemplateMetaChangedEvent})同一套范式。
+ *
+ * <h3>为什么必须 afterCommit</h3>
+ * 文档行落库若在事务里,事务未提交时异步线程读不到新行。所以有事务时挂到
+ * afterCommit,没事务时立即发。发布语义 fail-open:索引是召回增强,不是数据正确性的
+ * 必要条件,发布失败只记日志(文档停留在 PENDING,可手动重试)。
+ */
+@Slf4j
+public record KnowledgeDocIndexEvent(Long docId) {
+
+    /**
+     * 发布事件(有事务时延到 afterCommit)。
+     *
+     * @param publisher Spring 事件发布器
+     * @param docId     待索引文档 id(null 直接跳过)
+     */
+    public static void publish(ApplicationEventPublisher publisher, Long docId) {
+        if (publisher == null || docId == null) {
+            return;
+        }
+        final KnowledgeDocIndexEvent event = new KnowledgeDocIndexEvent(docId);
+        try {
+            if (TransactionSynchronizationManager.isSynchronizationActive()) {
+                TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+                    @Override
+                    public void afterCommit() {
+                        safePublish(publisher, event);
+                    }
+                });
+            } else {
+                safePublish(publisher, event);
+            }
+        } catch (Exception e) {
+            log.error("发布知识库文档索引事件失败(不影响主流程): docId={}, err={}", docId, e.getMessage());
+        }
+    }
+
+    private static void safePublish(ApplicationEventPublisher publisher, KnowledgeDocIndexEvent event) {
+        try {
+            publisher.publishEvent(event);
+        } catch (Exception e) {
+            log.error("发布知识库文档索引事件失败(不影响主流程): docId={}, err={}", event.docId(), e.getMessage());
+        }
+    }
+}

+ 9 - 0
ai-server/src/main/java/com/zsjz/ai/module/knowledge/mapper/KnowledgeBaseMapper.java

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

+ 19 - 0
ai-server/src/main/java/com/zsjz/ai/module/knowledge/mapper/KnowledgeDocDownloadLogMapper.java

@@ -0,0 +1,19 @@
+package com.zsjz.ai.module.knowledge.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.zsjz.ai.module.knowledge.entity.KnowledgeDocDownloadLog;
+import org.apache.ibatis.annotations.Delete;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+@Mapper
+public interface KnowledgeDocDownloadLogMapper extends BaseMapper<KnowledgeDocDownloadLog> {
+
+    /** 删除某文档的全部下载记录(删文档/级联删库时调用) */
+    @Delete("DELETE FROM knowledge_doc_download_log WHERE doc_id = #{docId}")
+    int deleteByDocId(@Param("docId") Long docId);
+
+    /** 删除某知识库的全部下载记录(级联删库时调用) */
+    @Delete("DELETE FROM knowledge_doc_download_log WHERE kb_id = #{kbId}")
+    int deleteByKbId(@Param("kbId") Long kbId);
+}

+ 28 - 0
ai-server/src/main/java/com/zsjz/ai/module/knowledge/mapper/KnowledgeDocMapper.java

@@ -0,0 +1,28 @@
+package com.zsjz.ai.module.knowledge.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.zsjz.ai.module.knowledge.entity.KnowledgeDoc;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+import java.util.Map;
+
+@Mapper
+public interface KnowledgeDocMapper extends BaseMapper<KnowledgeDoc> {
+
+    /**
+     * 统计某用户在各知识库下上传的文档数(列表页「我的文档 N 篇」),
+     * 一次 group by 拿全量,避免按知识库逐个 count。
+     * 别名加引号:PG 会把未加引号的标识符折叠成小写。
+     */
+    @Select("SELECT kb_id AS \"kbId\", count(*) AS \"cnt\" FROM knowledge_doc "
+            + "WHERE user_id = #{userId} GROUP BY kb_id")
+    List<Map<String, Object>> countByUserGroupByKb(@Param("userId") Long userId);
+
+    /** 统计某用户在各知识库下尚未索引完成的文档数(PENDING/INDEXING/FAILED) */
+    @Select("SELECT kb_id AS \"kbId\", count(*) AS \"cnt\" FROM knowledge_doc "
+            + "WHERE user_id = #{userId} AND index_status <> 'READY' GROUP BY kb_id")
+    List<Map<String, Object>> countPendingGroupByKb(@Param("userId") Long userId);
+}

+ 64 - 0
ai-server/src/main/java/com/zsjz/ai/module/knowledge/mapper/KnowledgeVectorMapper.java

@@ -0,0 +1,64 @@
+package com.zsjz.ai.module.knowledge.mapper;
+
+import org.apache.ibatis.annotations.Delete;
+import org.apache.ibatis.annotations.Insert;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Update;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 知识库 RAG 向量表 Mapper(原生 SQL,动态表名 {@code kb_vec_{kbId}{tag}_d{dims}})。
+ *
+ * <p>与 {@code AttachmentVectorMapper} 同款形态,区别只在隔离键:
+ * 一个知识库一张向量表,检索再强制按 {@code payload->>'kbId'} 过滤(双保险,
+ * 表名拼写/维度切换的错位也不至于跨库召回)。
+ */
+@Mapper
+public interface KnowledgeVectorMapper {
+
+    @Update("CREATE TABLE IF NOT EXISTS 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);
+
+    /** 表是否已存在(并发建表兜底用) */
+    @Select("SELECT count(*) FROM information_schema.tables WHERE table_name = #{tableName}")
+    int tableExists(@Param("tableName") String tableName);
+
+    @Insert("INSERT INTO public.${tableName} (doc_id, content, payload, embedding) "
+            + "VALUES (#{docId}, #{content}, #{payloadJson}::jsonb, #{embedding}::vector) "
+            + "ON CONFLICT (doc_id) DO NOTHING")
+    void insertVectorDoc(@Param("tableName") String tableName,
+                         @Param("docId") String docId,
+                         @Param("content") String content,
+                         @Param("payloadJson") String payloadJson,
+                         @Param("embedding") String embedding);
+
+    /**
+     * cosine 检索 + 知识库隔离过滤(payload->>'kbId' 必须命中)。
+     */
+    @Select("SELECT content, payload::text AS payload, 1 - (embedding <=> #{embedding}::vector) AS score "
+            + "FROM public.${tableName} "
+            + "WHERE payload->>'kbId' = #{kbId} "
+            + "  AND 1 - (embedding <=> #{embedding}::vector) >= #{threshold} "
+            + "ORDER BY score DESC LIMIT #{limit}")
+    List<Map<String, Object>> searchInKb(@Param("tableName") String tableName,
+                                         @Param("embedding") String embedding,
+                                         @Param("kbId") String kbId,
+                                         @Param("threshold") double threshold,
+                                         @Param("limit") int limit);
+
+    /** 删除某文档的全部向量行(删文档/重索引时调用) */
+    @Delete("DELETE FROM public.${tableName} WHERE payload->>'docId' = #{docId}")
+    int deleteByDoc(@Param("tableName") String tableName, @Param("docId") String docId);
+
+    /** 删除某知识库的全部向量行(级联删库时调用) */
+    @Delete("DELETE FROM public.${tableName} WHERE payload->>'kbId' = #{kbId}")
+    int deleteByKb(@Param("tableName") String tableName, @Param("kbId") String kbId);
+}

+ 209 - 0
ai-server/src/main/java/com/zsjz/ai/module/knowledge/service/KnowledgeBaseService.java

@@ -0,0 +1,209 @@
+package com.zsjz.ai.module.knowledge.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.spring.service.impl.ServiceImpl;
+import com.zsjz.ai.common.exception.ServerException;
+import com.zsjz.ai.common.utils.LocalFileStorageUtil;
+import com.zsjz.ai.module.agent.service.AgentService;
+import com.zsjz.ai.module.knowledge.KnowledgeDocIndexer;
+import com.zsjz.ai.module.knowledge.KnowledgeProperties;
+import com.zsjz.ai.module.knowledge.dto.SaveKnowledgeBaseDTO;
+import com.zsjz.ai.module.knowledge.entity.KnowledgeBase;
+import com.zsjz.ai.module.knowledge.entity.KnowledgeDoc;
+import com.zsjz.ai.module.knowledge.mapper.KnowledgeBaseMapper;
+import com.zsjz.ai.module.knowledge.mapper.KnowledgeDocDownloadLogMapper;
+import com.zsjz.ai.module.knowledge.mapper.KnowledgeDocMapper;
+import com.zsjz.ai.module.knowledge.vo.KnowledgeBaseVO;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.time.LocalDateTime;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 知识库管理服务:用户隔离 / 内置不可改删 / 删除级联。
+ *
+ * <p>口径(对齐 {@link AgentService} 的专家管理区):
+ * <ul>
+ *   <li>列表 = 内置(全局可见)+ 当前用户自建,<b>后端</b>按登录用户过滤,内置排前;</li>
+ *   <li>内置库只可查看/绑定:编辑/删除按 is_builtin 双重拒绝(不靠前端隐藏);</li>
+ *   <li>删除自建库级联清理:文档原件/sidecar、向量行、下载记录、全部文档行,
+ *       并解绑所有绑定它的研判方案(agent.knowledge_base_id 置空 + 驱逐实例池);</li>
+ *   <li>userId 显式传参(Controller 取自登录态),隔离/归属校验可脱离 Web 层单测。</li>
+ * </ul>
+ *
+ * <p>文档层还有一层更细的隔离(列表/下载只对上传者开放),见 {@link KnowledgeDocService}。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class KnowledgeBaseService extends ServiceImpl<KnowledgeBaseMapper, KnowledgeBase> {
+
+    /** 平台管理员(内置资源固定归属该用户,与 agent/tool_pack 的内置口径一致) */
+    public static final long ADMIN_USER_ID = 1L;
+
+    private final KnowledgeDocMapper docMapper;
+    private final KnowledgeDocDownloadLogMapper downloadLogMapper;
+    private final KnowledgeDocIndexer docIndexer;
+    private final LocalFileStorageUtil storageUtil;
+    private final KnowledgeProperties props;
+    private final AgentService agentService;
+
+    /**
+     * 知识库列表:内置(全局可见)+ 当前用户自建,内置排前;VO 带我的文档数/待索引数。
+     */
+    public List<KnowledgeBaseVO> list(Long userId) {
+        Map<Long, long[]> myStats = myDocStats(userId);
+        return loadAllBases().stream()
+                .filter(kb -> kb.isBuiltin() || (userId != null && userId.equals(kb.getUserId())))
+                .map(kb -> toVO(kb, userId, myStats))
+                .toList();
+    }
+
+    /** 查询全部知识库(内置在前、自建按创建顺序);包级可见,单测可 stub */
+    List<KnowledgeBase> loadAllBases() {
+        return lambdaQuery()
+                .orderByDesc(KnowledgeBase::getIsBuiltin)
+                .orderByAsc(KnowledgeBase::getId)
+                .list();
+    }
+
+    /** 当前用户在各库的 [文档数, 待索引数](一次 group by,避免逐库 count) */
+    private Map<Long, long[]> myDocStats(Long userId) {
+        Map<Long, long[]> out = new HashMap<>();
+        if (userId == null) {
+            return out;
+        }
+        for (Map<String, Object> row : docMapper.countByUserGroupByKb(userId)) {
+            accumulate(out, row, 0);
+        }
+        for (Map<String, Object> row : docMapper.countPendingGroupByKb(userId)) {
+            accumulate(out, row, 1);
+        }
+        return out;
+    }
+
+    private static void accumulate(Map<Long, long[]> out, Map<String, Object> row, int slot) {
+        Object kbId = row.get("kbId");
+        if (kbId == null) {
+            return;
+        }
+        Object cnt = row.get("cnt");
+        long[] slots = out.computeIfAbsent(Long.valueOf(kbId.toString()), k -> new long[2]);
+        slots[slot] = cnt instanceof Number n ? n.longValue() : 0;
+    }
+
+    private KnowledgeBaseVO toVO(KnowledgeBase kb, Long userId, Map<Long, long[]> myStats) {
+        KnowledgeBaseVO vo = new KnowledgeBaseVO();
+        vo.setId(String.valueOf(kb.getId()));
+        vo.setName(kb.getName());
+        vo.setDescription(kb.getDescription());
+        vo.setBuiltin(kb.isBuiltin());
+        vo.setMine(!kb.isBuiltin() && userId != null && userId.equals(kb.getUserId()));
+        long[] stats = myStats.get(kb.getId());
+        vo.setMyDocCount(stats == null ? 0 : stats[0]);
+        vo.setMyPendingCount(stats == null ? 0 : stats[1]);
+        return vo;
+    }
+
+    /** 新建自建知识库(user_id=当前用户,is_builtin=0) */
+    public KnowledgeBaseVO create(Long userId, SaveKnowledgeBaseDTO dto) {
+        KnowledgeBase entity = new KnowledgeBase();
+        entity.setUserId(userId);
+        entity.setIsBuiltin(0);
+        applyConfig(entity, dto);
+        save(entity);
+        return toVO(getById(entity.getId()), userId, Map.of());
+    }
+
+    /** 编辑知识库:内置拒绝;自建必须属于当前用户 */
+    public KnowledgeBaseVO update(Long userId, Long id, SaveKnowledgeBaseDTO dto) {
+        KnowledgeBase entity = requireOwned(userId, id, "编辑");
+        applyConfig(entity, dto);
+        entity.setUpdateTime(LocalDateTime.now());
+        updateById(entity);
+        return toVO(getById(id), userId, Map.of());
+    }
+
+    /**
+     * 删除知识库:内置拒绝;级联清理文档文件/sidecar/向量行/下载记录/文档行,
+     * 并解绑所有绑定它的研判方案。
+     */
+    public void delete(Long userId, Long id) {
+        KnowledgeBase entity = requireOwned(userId, id, "删除");
+        List<KnowledgeDoc> docs = docMapper.selectList(new LambdaQueryWrapper<KnowledgeDoc>()
+                .eq(KnowledgeDoc::getKbId, id));
+        for (KnowledgeDoc doc : docs) {
+            deleteFileQuietly(doc.getFilePath());
+            deleteFileQuietly(doc.getSidecarPath());
+        }
+        docIndexer.deleteKbVectors(id);
+        downloadLogMapper.deleteByKbId(id);
+        docMapper.delete(new LambdaQueryWrapper<KnowledgeDoc>().eq(KnowledgeDoc::getKbId, id));
+        removeById(id);
+        agentService.detachKnowledgeBase(id);
+        log.info("删除知识库: id={}, name={}, userId={}, 级联清理文档 {} 篇",
+                id, entity.getName(), userId, docs.size());
+    }
+
+    /**
+     * 归属校验(内置 or 自己的),供 agent 绑定校验与文档操作复用。
+     *
+     * @throws ServerException 404 不存在;400 无权访问
+     */
+    public KnowledgeBase requireVisible(Long userId, Long kbId) {
+        KnowledgeBase entity = getById(kbId);
+        if (entity == null) {
+            throw new ServerException(404, "知识库不存在或已删除");
+        }
+        if (!entity.isBuiltin() && (userId == null || !userId.equals(entity.getUserId()))) {
+            throw new ServerException(400, "无权访问该知识库");
+        }
+        return entity;
+    }
+
+    /** 可上传校验:自建库=库主人(requireVisible 已保证);内置库=仅管理员可维护 */
+    public void requireUploadable(Long userId, KnowledgeBase kb) {
+        if (!kb.isBuiltin()) {
+            return;
+        }
+        if (userId == null || userId != ADMIN_USER_ID) {
+            throw new ServerException(400, "内置知识库仅管理员可维护");
+        }
+    }
+
+    /** 内置不可编辑/删除;自建必须属于当前用户 */
+    private KnowledgeBase requireOwned(Long userId, Long id, String action) {
+        KnowledgeBase entity = getById(id);
+        if (entity == null) {
+            throw new ServerException(404, "知识库不存在或已删除");
+        }
+        if (entity.isBuiltin()) {
+            throw new ServerException(400, "内置知识库不可" + action);
+        }
+        if (userId == null || !userId.equals(entity.getUserId())) {
+            throw new ServerException(400, "无权" + action + "该知识库");
+        }
+        return entity;
+    }
+
+    private void applyConfig(KnowledgeBase entity, SaveKnowledgeBaseDTO dto) {
+        entity.setName(dto.getName().trim());
+        entity.setDescription(dto.getDescription());
+    }
+
+    private void deleteFileQuietly(String path) {
+        if (!StringUtils.hasText(path)) {
+            return;
+        }
+        try {
+            storageUtil.delete(path);
+        } catch (Exception e) {
+            log.warn("知识库文件清理失败(忽略): path={}, err={}", path, e.getMessage());
+        }
+    }
+}

+ 247 - 0
ai-server/src/main/java/com/zsjz/ai/module/knowledge/service/KnowledgeDocService.java

@@ -0,0 +1,247 @@
+package com.zsjz.ai.module.knowledge.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.baomidou.mybatisplus.spring.service.impl.ServiceImpl;
+import com.zsjz.ai.common.exception.ServerException;
+import com.zsjz.ai.common.utils.LocalFileStorageUtil;
+import com.zsjz.ai.module.knowledge.KnowledgeDocParser;
+import com.zsjz.ai.module.knowledge.KnowledgeDocIndexer;
+import com.zsjz.ai.module.knowledge.KnowledgeProperties;
+import com.zsjz.ai.module.knowledge.entity.KnowledgeBase;
+import com.zsjz.ai.module.knowledge.entity.KnowledgeDoc;
+import com.zsjz.ai.module.knowledge.entity.KnowledgeDocDownloadLog;
+import com.zsjz.ai.module.knowledge.event.KnowledgeDocIndexEvent;
+import com.zsjz.ai.module.knowledge.mapper.KnowledgeDocDownloadLogMapper;
+import com.zsjz.ai.module.knowledge.mapper.KnowledgeDocMapper;
+import com.zsjz.ai.module.knowledge.vo.KnowledgeDocVO;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.InputStream;
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 知识库文档服务:上传 / 我的文档列表 / 下载(记审计) / 删除 / 重试索引。
+ *
+ * <h3>文档层隔离(比知识库本体更细)</h3>
+ * 列表、下载、删除、重试只对<b>上传者本人</b>开放({@code knowledge_doc.user_id} 过滤)——
+ * 即便知识库是内置共享库,也「只能看到自己的上传文件列表数据」;
+ * RAG 检索(search_knowledge)不受此限制,检索绑定知识库的全量文档。
+ *
+ * <h3>上传链路</h3>
+ * 校验(可见 + 可维护 + 类型/大小/数量上限)→ 原件落盘 → Tika 提取全文落 sidecar →
+ * 登记 doc 行(PENDING,提取失败为 FAILED)→ 发布 {@link KnowledgeDocIndexEvent}
+ * 异步向量化。索引快慢不影响上传请求返回。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class KnowledgeDocService extends ServiceImpl<KnowledgeDocMapper, KnowledgeDoc> {
+
+    private final KnowledgeBaseService kbService;
+    private final KnowledgeDocDownloadLogMapper downloadLogMapper;
+    private final KnowledgeDocIndexer docIndexer;
+    private final KnowledgeDocParser docParser;
+    private final LocalFileStorageUtil storageUtil;
+    private final KnowledgeProperties props;
+    private final ApplicationEventPublisher eventPublisher;
+
+    /** 下载结果:原件流 + 展示文件名(Controller 写响应流后关闭) */
+    public record DownloadResult(String fileName, long sizeBytes, InputStream in) {
+    }
+
+    /** 「我的上传文档」列表:知识库可见性校验后,只返回 user_id=当前用户 的文档 */
+    public List<KnowledgeDocVO> listMine(Long userId, Long kbId) {
+        kbService.requireVisible(userId, kbId);
+        return loadMineDocs(userId, kbId).stream().map(this::toVO).toList();
+    }
+
+    /** 查询某用户在某库下的文档;包级可见,单测可 stub */
+    List<KnowledgeDoc> loadMineDocs(Long userId, Long kbId) {
+        return lambdaQuery()
+                .eq(KnowledgeDoc::getKbId, kbId)
+                .eq(KnowledgeDoc::getUserId, userId)
+                .orderByDesc(KnowledgeDoc::getCreateTime)
+                .list();
+    }
+
+    /**
+     * 上传文档:自建库=库主人可传;内置库=仅管理员可传。
+     * 提取失败不阻断登记(落 FAILED 可重试),向量化异步执行。
+     */
+    public KnowledgeDocVO upload(Long userId, Long kbId, MultipartFile file) {
+        if (file == null || file.isEmpty()) {
+            throw new ServerException(400, "文件为空");
+        }
+        KnowledgeBase kb = kbService.requireVisible(userId, kbId);
+        kbService.requireUploadable(userId, kb);
+        String safeName = sanitizeFileName(file.getOriginalFilename());
+        String suffix = suffixOf(safeName);
+        if (!props.effectiveSuffixes().contains(suffix)) {
+            throw new ServerException(400, "不支持的文件类型: " + suffix + "(仅支持可解析文本的文档/表格)");
+        }
+        if (file.getSize() > props.getMaxFileMb() * 1024 * 1024) {
+            throw new ServerException(400, "文件超过大小上限(" + props.getMaxFileMb() + "MB): " + safeName);
+        }
+        Long mine = lambdaQuery().eq(KnowledgeDoc::getKbId, kbId)
+                .eq(KnowledgeDoc::getUserId, userId).count();
+        if (mine != null && mine >= props.getMaxDocsPerUser()) {
+            throw new ServerException(400, "该知识库下我的文档数已达上限(" + props.getMaxDocsPerUser() + ")");
+        }
+
+        // 原件落盘:knowledge/{kbId}/doc/{uuid}.ext(存储根 data/files 下的相对路径)
+        String filePath = storageUtil.generatePath(kbId, safeName);
+        try {
+            storageUtil.upload(file, filePath);
+        } catch (Exception e) {
+            throw new ServerException(500, "文档保存失败: " + e.getMessage());
+        }
+
+        KnowledgeDoc doc = new KnowledgeDoc();
+        doc.setKbId(kbId);
+        doc.setUserId(userId);
+        doc.setFileName(safeName);
+        doc.setFilePath(filePath);
+        doc.setMime(file.getContentType());
+        doc.setSizeBytes(file.getSize());
+        doc.setChunkCount(0);
+        doc.setDownloadCount(0);
+        doc.setCreateTime(LocalDateTime.now());
+        doc.setUpdateTime(LocalDateTime.now());
+        boolean extracted;
+        try (InputStream in = file.getInputStream()) {
+            String fullText = docParser.extract(in, props.getMaxChunks() * props.getChunkSize());
+            String sidecarPath = KnowledgeDocParser.sidecarPathOf(filePath);
+            storageUtil.uploadString(fullText, sidecarPath);
+            doc.setSidecarPath(sidecarPath);
+            doc.setCharCount(fullText.length());
+            doc.setIndexStatus("PENDING");
+            extracted = true;
+        } catch (Exception e) {
+            doc.setIndexStatus("FAILED");
+            doc.setFailReason(StringUtils.truncate(e.getMessage(), 900));
+            extracted = false;
+            log.error("知识库文档提取失败: name={}, err={}", safeName, e.getMessage());
+        }
+        save(doc);
+        if (extracted) {
+            KnowledgeDocIndexEvent.publish(eventPublisher, doc.getId());
+        }
+        return toVO(getById(doc.getId()));
+    }
+
+    /**
+     * 下载文档:只允许下载<b>自己上传的</b>;写下载记录(审计表 + 冗余计数)。
+     */
+    public DownloadResult download(Long userId, Long docId) {
+        KnowledgeDoc doc = requireOwnedDoc(userId, docId);
+        InputStream in = storageUtil.download(doc.getFilePath());
+        KnowledgeDocDownloadLog logRow = new KnowledgeDocDownloadLog();
+        logRow.setKbId(doc.getKbId());
+        logRow.setDocId(doc.getId());
+        logRow.setUserId(userId);
+        logRow.setFileName(doc.getFileName());
+        logRow.setDownloadTime(LocalDateTime.now());
+        downloadLogMapper.insert(logRow);
+        // 冗余计数并发安全:SQL 自增而不是读改写
+        update(new LambdaUpdateWrapper<KnowledgeDoc>()
+                .eq(KnowledgeDoc::getId, docId)
+                .setSql("download_count = download_count + 1"));
+        return new DownloadResult(doc.getFileName(), doc.getSizeBytes() == null ? 0 : doc.getSizeBytes(), in);
+    }
+
+    /**
+     * 删除文档:只允许删自己上传的;级联清理原件/sidecar/向量行/下载记录。
+     */
+    public void delete(Long userId, Long docId) {
+        KnowledgeDoc doc = requireOwnedDoc(userId, docId);
+        deleteFileQuietly(doc.getFilePath());
+        deleteFileQuietly(doc.getSidecarPath());
+        docIndexer.deleteDocVectors(doc.getKbId(), doc.getId());
+        downloadLogMapper.deleteByDocId(doc.getId());
+        removeById(docId);
+        log.info("删除知识库文档: docId={}, file={}, userId={}", docId, doc.getFileName(), userId);
+    }
+
+    /**
+     * 重试索引:状态重置 PENDING 重发事件(索引器自行做旧向量清理,重放幂等)。
+     * 覆盖两类场景:提取失败(sidecar 缺失时索引器兜底重抽)、嵌入服务临时不可用。
+     */
+    public void reindex(Long userId, Long docId) {
+        KnowledgeDoc doc = requireOwnedDoc(userId, docId);
+        doc.setIndexStatus("PENDING");
+        doc.setFailReason(null);
+        doc.setUpdateTime(LocalDateTime.now());
+        // MP 默认 NOT_NULL 策略跳过 null:旧 fail_reason 必须显式清掉(updateById 清不掉)
+        update(new LambdaUpdateWrapper<KnowledgeDoc>()
+                .eq(KnowledgeDoc::getId, docId)
+                .set(KnowledgeDoc::getIndexStatus, "PENDING")
+                .set(KnowledgeDoc::getFailReason, null)
+                .set(KnowledgeDoc::getUpdateTime, doc.getUpdateTime()));
+        KnowledgeDocIndexEvent.publish(eventPublisher, docId);
+    }
+
+    /** 文档归属校验:不存在 404;不是自己上传的 400(文档层隔离的唯一闸口) */
+    private KnowledgeDoc requireOwnedDoc(Long userId, Long docId) {
+        KnowledgeDoc doc = getById(docId);
+        if (doc == null) {
+            throw new ServerException(404, "文档不存在或已删除");
+        }
+        if (userId == null || !userId.equals(doc.getUserId())) {
+            throw new ServerException(400, "只能操作自己上传的文档");
+        }
+        return doc;
+    }
+
+    private KnowledgeDocVO toVO(KnowledgeDoc doc) {
+        KnowledgeDocVO vo = new KnowledgeDocVO();
+        vo.setId(String.valueOf(doc.getId()));
+        vo.setKbId(String.valueOf(doc.getKbId()));
+        vo.setFileName(doc.getFileName());
+        vo.setMime(doc.getMime());
+        vo.setSizeBytes(doc.getSizeBytes() == null ? 0 : doc.getSizeBytes());
+        vo.setCharCount(doc.getCharCount() == null ? 0 : doc.getCharCount());
+        vo.setChunkCount(doc.getChunkCount() == null ? 0 : doc.getChunkCount());
+        vo.setIndexStatus(doc.getIndexStatus());
+        vo.setFailReason(doc.getFailReason());
+        vo.setDownloadCount(doc.getDownloadCount() == null ? 0 : doc.getDownloadCount());
+        vo.setCreateTime(doc.getCreateTime() == null ? null : doc.getCreateTime().toString());
+        return vo;
+    }
+
+    private void deleteFileQuietly(String path) {
+        if (!StringUtils.hasText(path)) {
+            return;
+        }
+        try {
+            storageUtil.delete(path);
+        } catch (Exception e) {
+            log.warn("知识库文档文件清理失败(忽略): path={}, err={}", path, e.getMessage());
+        }
+    }
+
+    /** 文件名清洗:路径分隔符/控制字符替换为下划线,限长保留扩展名 */
+    private static String sanitizeFileName(String name) {
+        if (!StringUtils.hasText(name)) {
+            return "unnamed";
+        }
+        String cleaned = name.replaceAll("[\\\\/:*?\"<>|\\p{Cntrl}]", "_").trim();
+        if (cleaned.length() > 200) {
+            int dot = cleaned.lastIndexOf('.');
+            String ext = dot > 0 ? cleaned.substring(dot) : "";
+            cleaned = cleaned.substring(0, 200 - ext.length()) + ext;
+        }
+        return cleaned.isBlank() ? "unnamed" : cleaned;
+    }
+
+    private static String suffixOf(String name) {
+        int dot = name.lastIndexOf('.');
+        return dot >= 0 && dot < name.length() - 1 ? name.substring(dot + 1).toLowerCase() : "";
+    }
+}

+ 31 - 0
ai-server/src/main/java/com/zsjz/ai/module/knowledge/vo/KnowledgeBaseVO.java

@@ -0,0 +1,31 @@
+package com.zsjz.ai.module.knowledge.vo;
+
+import lombok.Data;
+
+/**
+ * 知识库列表响应 VO。
+ *
+ * <p>雪花/自增主键一律下发字符串(前端 ID 约定,避免 JS 精度丢失)。
+ */
+@Data
+public class KnowledgeBaseVO {
+
+    /** 主键(字符串) */
+    private String id;
+
+    private String name;
+
+    private String description;
+
+    /** 是否内置(全局可见、不可编辑删除) */
+    private boolean builtin;
+
+    /** 是否当前用户的自建知识库(决定卡片上是否出编辑/删除) */
+    private boolean mine;
+
+    /** 当前用户在该库下上传的文档数(「我的文档 N 篇」) */
+    private long myDocCount;
+
+    /** 当前用户在该库下待索引/索引失败的文档数(卡片状态摘要) */
+    private long myPendingCount;
+}

+ 38 - 0
ai-server/src/main/java/com/zsjz/ai/module/knowledge/vo/KnowledgeDocVO.java

@@ -0,0 +1,38 @@
+package com.zsjz.ai.module.knowledge.vo;
+
+import lombok.Data;
+
+/**
+ * 知识库文档响应 VO(「我的上传文档」列表项)。
+ */
+@Data
+public class KnowledgeDocVO {
+
+    /** 主键(字符串) */
+    private String id;
+
+    private String kbId;
+
+    private String fileName;
+
+    private String mime;
+
+    /** 文件大小(字节) */
+    private long sizeBytes;
+
+    /** 提取文本字符数 */
+    private int charCount;
+
+    /** 已向量化块数 */
+    private int chunkCount;
+
+    /** PENDING / INDEXING / READY / FAILED */
+    private String indexStatus;
+
+    private String failReason;
+
+    /** 下载次数 */
+    private int downloadCount;
+
+    private String createTime;
+}

+ 10 - 0
ai-server/src/main/resources/application-dev.yaml

@@ -85,3 +85,13 @@ neo4j:
   uri: 'bolt://localhost:7687'
   username: 'neo4j'
   password: 'neo4j123'
+
+# ======================================================================
+# 嵌入模型(开发机覆盖):在线 DashScope embedding 免费额度耗尽(403 FreeTierOnly),
+# 切回本地 bge-m3 ONNX。模型目录在仓库根(E:/workspace/zsjz-ai/bge-m3),
+# 相对路径在 mvn spring-boot:run(user.dir=ai-server)下解析不到,这里用绝对路径。
+# ======================================================================
+zsjz:
+  embedding:
+    enabled: true
+    model-path: E:/workspace/zsjz-ai/bge-m3

+ 4 - 1
ai-server/src/test/java/com/zsjz/ai/module/agent/service/AgentCapabilityToolkitTest.java

@@ -108,7 +108,10 @@ class AgentCapabilityToolkitTest {
                 new ArtifactService(new ArtifactProperties()),
                 mock(GovernTreeService.class),
                 mock(ToolPackService.class),
-                mock(WorkspaceSchemaCatalog.class));
+                mock(WorkspaceSchemaCatalog.class),
+                mock(com.zsjz.ai.module.knowledge.mapper.KnowledgeBaseMapper.class),
+                mock(com.zsjz.ai.module.knowledge.KnowledgeDocIndexer.class),
+                mock(com.zsjz.ai.module.agent.rag.EmbeddingProvider.class));
     }
 
     @Test

+ 4 - 1
ai-server/src/test/java/com/zsjz/ai/module/agent/service/AgentExpertTest.java

@@ -134,7 +134,10 @@ class AgentExpertTest {
                 new ArtifactService(new ArtifactProperties()),
                 mock(GovernTreeService.class),
                 mock(ToolPackService.class),
-                mock(WorkspaceSchemaCatalog.class)));
+                mock(WorkspaceSchemaCatalog.class),
+                mock(com.zsjz.ai.module.knowledge.mapper.KnowledgeBaseMapper.class),
+                mock(com.zsjz.ai.module.knowledge.KnowledgeDocIndexer.class),
+                mock(com.zsjz.ai.module.agent.rag.EmbeddingProvider.class)));
         pool = (com.zsjz.ai.module.agent.service.AgentInstancePool<HarnessAgent>) ReflectionTestUtils.getField(service, "agentPool");
 
         // create 路径:库里那行还没建,回读刚 save 的实体(rowId 已被 save 桩回填)

+ 4 - 1
ai-server/src/test/java/com/zsjz/ai/module/agent/service/impl/AgentToolCatalogServiceImplTest.java

@@ -124,7 +124,10 @@ class AgentToolCatalogServiceImplTest {
                 new ArtifactService(new ArtifactProperties()),
                 mock(GovernTreeService.class),
                 mock(ToolPackService.class),
-                mock(WorkspaceSchemaCatalog.class));
+                mock(WorkspaceSchemaCatalog.class),
+                mock(com.zsjz.ai.module.knowledge.mapper.KnowledgeBaseMapper.class),
+                mock(com.zsjz.ai.module.knowledge.KnowledgeDocIndexer.class),
+                mock(com.zsjz.ai.module.agent.rag.EmbeddingProvider.class));
     }
 
     @Test

+ 301 - 0
ai-server/src/test/java/com/zsjz/ai/module/knowledge/service/KnowledgeIsolationTest.java

@@ -0,0 +1,301 @@
+package com.zsjz.ai.module.knowledge.service;
+
+import com.zsjz.ai.common.exception.ServerException;
+import com.zsjz.ai.common.utils.LocalFileStorageUtil;
+import com.zsjz.ai.module.agent.service.AgentService;
+import com.zsjz.ai.module.knowledge.KnowledgeDocIndexer;
+import com.zsjz.ai.module.knowledge.KnowledgeProperties;
+import com.zsjz.ai.module.knowledge.dto.SaveKnowledgeBaseDTO;
+import com.zsjz.ai.module.knowledge.entity.KnowledgeBase;
+import com.zsjz.ai.module.knowledge.entity.KnowledgeDoc;
+import com.zsjz.ai.module.knowledge.entity.KnowledgeDocDownloadLog;
+import com.zsjz.ai.module.knowledge.mapper.KnowledgeBaseMapper;
+import com.zsjz.ai.module.knowledge.mapper.KnowledgeDocDownloadLogMapper;
+import com.zsjz.ai.module.knowledge.mapper.KnowledgeDocMapper;
+import com.zsjz.ai.module.knowledge.vo.KnowledgeBaseVO;
+import com.zsjz.ai.module.knowledge.vo.KnowledgeDocVO;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.io.ByteArrayInputStream;
+import java.io.Serializable;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+
+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;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+
+/**
+ * 知识库/文档两层隔离规则:内置只读 / 归属校验 / 文档按上传者隔离 / 下载审计 / 删除级联。
+ *
+ * <p>DAO 层(getById/save/update/remove)与查询缝(loadAllBases / list(Wrapper))全部 stub,
+ * 本测试只验证业务规则本身,不依赖数据库。
+ */
+class KnowledgeIsolationTest {
+
+    private static final Long ME = 7L;
+    private static final Long OTHER = 99L;
+    private static final Long ADMIN = KnowledgeBaseService.ADMIN_USER_ID;
+
+    private KnowledgeBaseService kbService;
+    private KnowledgeDocService docService;
+
+    private KnowledgeDocMapper docMapper;
+    private KnowledgeDocDownloadLogMapper downloadLogMapper;
+    private KnowledgeDocIndexer docIndexer;
+    private LocalFileStorageUtil storageUtil;
+    private AgentService agentService;
+
+    /** getById 的返回目标(模拟库里那行) */
+    private KnowledgeBase kbById;
+    private KnowledgeDoc docById;
+
+    /** save/updateById 捕获的实体 */
+    private KnowledgeBase capturedKb;
+
+    @BeforeEach
+    void setUp() {
+        docMapper = mock(KnowledgeDocMapper.class);
+        downloadLogMapper = mock(KnowledgeDocDownloadLogMapper.class);
+        docIndexer = mock(KnowledgeDocIndexer.class);
+        storageUtil = mock(LocalFileStorageUtil.class);
+        agentService = mock(AgentService.class);
+
+        kbService = spy(new KnowledgeBaseService(
+                docMapper, downloadLogMapper, docIndexer, storageUtil,
+                new KnowledgeProperties(), agentService));
+
+        KnowledgeBaseService kbMock = mock(KnowledgeBaseService.class);
+        docService = spy(new KnowledgeDocService(
+                kbMock, downloadLogMapper, docIndexer, mock(com.zsjz.ai.module.knowledge.KnowledgeDocParser.class),
+                storageUtil, new KnowledgeProperties(),
+                mock(org.springframework.context.ApplicationEventPublisher.class)));
+        // requireVisible 走真实逻辑(校验规则本身是被测对象),把内部 getById 打到 kbById
+        ReflectionTestUtils.setField(docService, "kbService", kbService);
+
+        // DAO 缝
+        doAnswer(inv -> kbById).when(kbService).getById(any());
+        doAnswer(inv -> docById).when(docService).getById(any());
+        doAnswer(inv -> {
+            capturedKb = inv.getArgument(0);
+            capturedKb.setId(88L); // 模拟 DB 回填雪花主键
+            return true;
+        }).when(kbService).save(any(KnowledgeBase.class));
+        doReturn(true).when(kbService).updateById(any(KnowledgeBase.class));
+        doReturn(true).when(kbService).removeById(any(Serializable.class));
+        doReturn(true).when(docService).removeById(any(Serializable.class));
+        doReturn(true).when(docService).updateById(any(KnowledgeDoc.class));
+        doReturn(true).when(docService).update(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class));
+    }
+
+    private static KnowledgeBase kb(Long id, String name, Long userId, int builtin) {
+        KnowledgeBase e = new KnowledgeBase();
+        e.setId(id);
+        e.setName(name);
+        e.setUserId(userId);
+        e.setIsBuiltin(builtin);
+        return e;
+    }
+
+    private static KnowledgeDoc doc(Long id, Long kbId, Long userId, String fileName) {
+        KnowledgeDoc d = new KnowledgeDoc();
+        d.setId(id);
+        d.setKbId(kbId);
+        d.setUserId(userId);
+        d.setFileName(fileName);
+        d.setFilePath("knowledge/" + kbId + "/doc/" + id + ".pdf");
+        d.setIndexStatus("READY");
+        d.setChunkCount(3);
+        d.setDownloadCount(0);
+        return d;
+    }
+
+    // ==================== 知识库列表隔离 ====================
+
+    @Test
+    @DisplayName("列表 = 内置(全局)+ 自己的;别人的不可见(后端过滤,不是前端隐藏)")
+    void listFiltersToBuiltinAndMine() {
+        KnowledgeBase builtin = kb(1L, "系统通用知识库", ADMIN, 1);
+        KnowledgeBase mine = kb(2L, "我的库", ME, 0);
+        KnowledgeBase others = kb(3L, "别人的库", OTHER, 0);
+        doReturn(List.of(builtin, others, mine)).when(kbService).loadAllBases();
+        doReturn(List.of()).when(docMapper).countByUserGroupByKb(any());
+        doReturn(List.of()).when(docMapper).countPendingGroupByKb(any());
+
+        List<KnowledgeBaseVO> list = kbService.list(ME);
+
+        assertEquals(2, list.size(), "内置 + 我的,别人的必须被过滤");
+        assertEquals("1", list.get(0).getId());
+        assertTrue(list.get(0).isBuiltin());
+        assertFalse(list.get(0).isMine());
+        assertEquals("2", list.get(1).getId());
+        assertFalse(list.get(1).isBuiltin());
+        assertTrue(list.get(1).isMine());
+        assertTrue(list.stream().noneMatch(v -> "别人的库".equals(v.getName())));
+    }
+
+    // ==================== 内置不可改删 ====================
+
+    @Test
+    @DisplayName("内置知识库:编辑与删除都被拒绝(400),且不触碰 DAO")
+    void builtinRejectsEditAndDelete() {
+        kbById = kb(1L, "系统通用知识库", ADMIN, 1);
+        SaveKnowledgeBaseDTO dto = new SaveKnowledgeBaseDTO();
+        dto.setName("改名");
+
+        ServerException edit = assertThrows(ServerException.class,
+                () -> kbService.update(ME, 1L, dto));
+        assertEquals(400, edit.getCode());
+        assertTrue(edit.getMsg().contains("内置知识库不可编辑"), edit.getMsg());
+
+        ServerException del = assertThrows(ServerException.class,
+                () -> kbService.delete(ME, 1L));
+        assertEquals(400, del.getCode());
+        assertTrue(del.getMsg().contains("内置知识库不可删除"), del.getMsg());
+        verify(kbService, never()).removeById(any(Serializable.class));
+        verify(agentService, never()).detachKnowledgeBase(anyLong());
+    }
+
+    @Test
+    @DisplayName("自建知识库:不能改删别人的(无权操作)")
+    void cannotTouchOthersKb() {
+        kbById = kb(3L, "别人的库", OTHER, 0);
+        SaveKnowledgeBaseDTO dto = new SaveKnowledgeBaseDTO();
+        dto.setName("x");
+
+        ServerException ex = assertThrows(ServerException.class, () -> kbService.update(ME, 3L, dto));
+        assertEquals(400, ex.getCode());
+        assertTrue(ex.getMsg().contains("无权"), ex.getMsg());
+        assertThrows(ServerException.class, () -> kbService.delete(ME, 3L));
+    }
+
+    // ==================== 上传权限(内置库仅管理员可维护) ====================
+
+    @Test
+    @DisplayName("requireUploadable:内置库仅管理员(userId=1)可上传;自建库库主人可传")
+    void uploadPermissionOnBuiltinKb() {
+        KnowledgeBase builtin = kb(1L, "系统通用知识库", ADMIN, 1);
+
+        ServerException ex = assertThrows(ServerException.class, () -> kbService.requireUploadable(ME, builtin));
+        assertTrue(ex.getMsg().contains("仅管理员"), ex.getMsg());
+        kbService.requireUploadable(ADMIN, builtin); // 不抛即通过
+
+        KnowledgeBase mine = kb(2L, "我的库", ME, 0);
+        kbService.requireUploadable(ME, mine); // 不抛即通过
+    }
+
+    // ==================== 可见性(绑定校验复用) ====================
+
+    @Test
+    @DisplayName("requireVisible:内置谁都能看;别人的自建库 400;不存在 404")
+    void requireVisibleRules() {
+        kbById = kb(1L, "系统通用知识库", ADMIN, 1);
+        assertEquals(1L, kbService.requireVisible(ME, 1L).getId());
+
+        kbById = kb(3L, "别人的库", OTHER, 0);
+        ServerException ex = assertThrows(ServerException.class, () -> kbService.requireVisible(ME, 3L));
+        assertEquals(400, ex.getCode());
+
+        kbById = null;
+        assertThrows(ServerException.class, () -> kbService.requireVisible(ME, 404L));
+    }
+
+    // ==================== 删除级联 ====================
+
+    @Test
+    @DisplayName("删除自建知识库:文件/向量/下载记录/文档行级联清理 + 解绑引用它的方案")
+    void deleteCascades() {
+        kbById = kb(2L, "我的库", ME, 0);
+        KnowledgeDoc d1 = doc(11L, 2L, ME, "a.pdf");
+        KnowledgeDoc d2 = doc(12L, 2L, OTHER, "b.pdf"); // 同库别人的文档也要一并清
+        doReturn(List.of(d1, d2)).when(docMapper)
+                .selectList(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class));
+
+        kbService.delete(ME, 2L);
+
+        verify(storageUtil).delete(d1.getFilePath());
+        verify(storageUtil).delete(d2.getFilePath());
+        verify(docIndexer).deleteKbVectors(2L);
+        verify(downloadLogMapper).deleteByKbId(2L);
+        verify(docMapper).delete(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class));
+        verify(kbService).removeById(2L);
+        verify(agentService).detachKnowledgeBase(2L);
+    }
+
+    // ==================== 文档层隔离(只对上传者开放) ====================
+
+    @Test
+    @DisplayName("文档列表:只返回 user_id=当前用户 的文档(库可见性先校验)")
+    void listMineOnlyOwnDocs() {
+        kbById = kb(1L, "系统通用知识库", ADMIN, 1);
+        KnowledgeDoc mine = doc(11L, 1L, ME, "a.pdf");
+        doReturn(List.of(mine)).when(docService).loadMineDocs(ME, 1L);
+
+        List<KnowledgeDocVO> list = docService.listMine(ME, 1L);
+
+        assertEquals(1, list.size());
+        assertEquals("a.pdf", list.get(0).getFileName());
+        verify(kbService).requireVisible(ME, 1L);
+    }
+
+    @Test
+    @DisplayName("下载他人上传的文档被拒绝;下载自己的写审计 + 计数自增")
+    void downloadOwnOnly() {
+        docById = doc(11L, 1L, OTHER, "别人的文档.pdf");
+        ServerException ex = assertThrows(ServerException.class, () -> docService.download(ME, 11L));
+        assertEquals(400, ex.getCode());
+        assertTrue(ex.getMsg().contains("只能操作自己上传的文档"), ex.getMsg());
+
+        docById = doc(12L, 1L, ME, "我的文档.pdf");
+        doReturn(new ByteArrayInputStream("data".getBytes(StandardCharsets.UTF_8)))
+                .when(storageUtil).download(anyString());
+
+        KnowledgeDocService.DownloadResult result = docService.download(ME, 12L);
+
+        assertEquals("我的文档.pdf", result.fileName());
+        verify(downloadLogMapper).insert(any(KnowledgeDocDownloadLog.class));
+        verify(docService).update(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class));
+    }
+
+    @Test
+    @DisplayName("删除/重试他人上传的文档被拒绝(404/400 明确区分)")
+    void docOpsOwnOnly() {
+        docById = null;
+        ServerException missing = assertThrows(ServerException.class, () -> docService.delete(ME, 404L));
+        assertEquals(404, missing.getCode());
+
+        docById = doc(11L, 1L, OTHER, "b.pdf");
+        ServerException ex = assertThrows(ServerException.class, () -> docService.reindex(ME, 11L));
+        assertEquals(400, ex.getCode());
+        verify(docIndexer, never()).deleteDocVectors(anyLong(), anyLong());
+        verify(docService, never()).removeById(any(Serializable.class));
+    }
+
+    @Test
+    @DisplayName("删除自己的文档:文件 + 向量行 + 下载记录级联清理")
+    void deleteOwnDocCascades() {
+        docById = doc(12L, 1L, ME, "我的文档.pdf");
+
+        docService.delete(ME, 12L);
+
+        verify(storageUtil).delete("knowledge/1/doc/12.pdf");
+        verify(docIndexer).deleteDocVectors(1L, 12L);
+        verify(downloadLogMapper).deleteByDocId(12L);
+        verify(docService).removeById(12L);
+    }
+}

+ 70 - 0
sql/knowledge_base.sql

@@ -0,0 +1,70 @@
+-- =====================================================================
+-- 知识库(module/knowledge):用户隔离 + 内置标记
+--
+-- 幂等脚本(可重复执行)。项目无 Flyway,按 sql/ 目录既有约定手工执行。
+--
+-- 两层隔离模型:
+--   知识库本体  is_builtin=1 内置库全局可见可绑定(仅管理员 userId=1 可维护);
+--              is_builtin=0 自建库仅创建者可见(user_id 过滤)。
+--   库内文档    列表/下载/删除/重试只对上传者本人开放(knowledge_doc.user_id 过滤);
+--              内置库里管理员上传的文档清单对普通用户不可见。
+--   RAG 检索   search_knowledge 检索绑定知识库的全量文档(与文档列表隔离解耦)。
+--
+-- 向量表:pgvector 动态建表 kb_vec_{kbId}{tag}_d{dims}
+--   (tag 为嵌入模型标签,本地/在线模型物理隔离),仿 chat_att_vec_{caseId}_d{dims}。
+-- =====================================================================
+
+-- 知识库
+create table if not exists knowledge_base
+(
+    id          bigint primary key,               -- 雪花ID;内置库固定 id=1
+    user_id     bigint       not null,            -- 创建者(内置库=1 平台管理员)
+    is_builtin  smallint     not null default 0,  -- 1=内置(全局可见只读),0=用户自建
+    name        varchar(100) not null,
+    description varchar(500),
+    create_time timestamp    default current_timestamp,
+    update_time timestamp
+);
+create index if not exists idx_kb_user on knowledge_base (user_id);
+
+-- 知识库文档(元数据 + 解析/索引状态;user_id 即上传者,列表/下载隔离依据)
+create table if not exists knowledge_doc
+(
+    id             bigint primary key,
+    kb_id          bigint        not null,
+    user_id        bigint        not null,         -- 上传者(列表/下载隔离依据)
+    file_name      varchar(500)  not null,
+    file_path      varchar(1000) not null,         -- 原件存储相对路径(下载用)
+    sidecar_path   varchar(1000),                  -- Tika 全文 .txt 存储相对路径(向量化用)
+    mime           varchar(100),
+    size_bytes     bigint default 0,
+    char_count     integer default 0,              -- 提取文本字符数
+    chunk_count    integer default 0,              -- 已向量化块数
+    index_status   varchar(20) default 'PENDING',  -- PENDING / INDEXING / READY / FAILED
+    fail_reason    varchar(1000),
+    download_count integer default 0,              -- 下载次数(我的文档列表展示)
+    create_time    timestamp default current_timestamp,
+    update_time    timestamp
+);
+create index if not exists idx_kd_kb_user on knowledge_doc (kb_id, user_id);
+
+-- 下载记录(谁在何时下载了哪个文档)
+create table if not exists knowledge_doc_download_log
+(
+    id            bigint primary key,             -- 雪花ID
+    kb_id         bigint       not null,
+    doc_id        bigint       not null,
+    user_id       bigint       not null,          -- 下载人
+    file_name     varchar(500) not null,          -- 下载时文件名快照
+    download_time timestamp    default current_timestamp
+);
+create index if not exists idx_kdl_doc on knowledge_doc_download_log (doc_id);
+create index if not exists idx_kdl_user on knowledge_doc_download_log (user_id);
+
+-- 内置知识库(文档由管理员在界面上传)
+insert into knowledge_base (id, user_id, is_builtin, name, description)
+values (1, 1, 1, '系统通用知识库', '系统内置知识库,所有用户可见可绑定(仅管理员可维护文档)')
+on conflict (id) do nothing;
+
+-- agent(研判方案)绑定知识库:一个方案最多绑定一个,可空
+ALTER TABLE agent ADD COLUMN IF NOT EXISTS knowledge_base_id BIGINT;