瀏覽代碼

feat: 新增向量知识库维护功能

- LangGraph: 新增 knowledge_base.py,提供 stats/list/delete-source/sync/update-document 接口
- Java: 新增 KnowledgeBaseVectorController,代理调用 LangGraph
- 前端: 新增 VectorKnowledgeBase.vue 页面,包含统计卡片、文档列表(分页/筛选/搜索)、编辑弹窗、全量同步按钮
- 菜单: 知识中心下新增「向量知识库」入口
- 兼容新旧 ChromaDB metadata 字段(source / type)
Sisyphus 4 天之前
父節點
當前提交
cde1b03d2c

+ 133 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/KnowledgeBaseVectorController.java

@@ -0,0 +1,133 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.client.RestTemplate;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 向量知识库管理 Controller
+ * 代理调用 LangGraph Python 服务的向量库管理接口
+ */
+@RestController
+@RequestMapping("/api/admin/knowledge-base/vector")
+public class KnowledgeBaseVectorController {
+
+    private static final Logger log = LoggerFactory.getLogger(KnowledgeBaseVectorController.class);
+
+    @Value("${python.base-url:http://localhost:9000}")
+    private String langgraphBaseUrl;
+
+    private final RestTemplate restTemplate = new RestTemplate();
+
+    private HttpHeaders createJsonHeaders() {
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_JSON);
+        return headers;
+    }
+
+    /**
+     * 获取向量库统计信息
+     */
+    @PostMapping("/stats")
+    public Result<Map<String, Object>> getStats() {
+        try {
+            String url = langgraphBaseUrl + "/api/v1/knowledge-base/stats";
+            HttpEntity<String> entity = new HttpEntity<>(createJsonHeaders());
+            Map<String, Object> response = restTemplate.postForEntity(url, entity, Map.class).getBody();
+            return Result.success(response);
+        } catch (Exception e) {
+            log.warn("获取向量库统计失败: {}", e.getMessage());
+            return Result.error("获取统计信息失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 分页列出向量库文档
+     */
+    @PostMapping("/documents")
+    public Result<Map<String, Object>> listDocuments(@RequestBody Map<String, Object> params) {
+        try {
+            String url = langgraphBaseUrl + "/api/v1/knowledge-base/documents";
+            HttpEntity<Map<String, Object>> entity = new HttpEntity<>(params, createJsonHeaders());
+            Map<String, Object> response = restTemplate.postForEntity(url, entity, Map.class).getBody();
+            return Result.success(response);
+        } catch (Exception e) {
+            log.warn("列出文档失败: {}", e.getMessage());
+            return Result.error("列出文档失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 删除指定 source 的所有文档
+     */
+    @PostMapping("/delete-source")
+    public Result<Map<String, Object>> deleteBySource(@RequestBody Map<String, String> params) {
+        String source = params.get("source");
+        if (source == null || source.isEmpty()) {
+            return Result.error("source 不能为空");
+        }
+        try {
+            String url = langgraphBaseUrl + "/api/v1/knowledge-base/source/" + source;
+            HttpEntity<String> entity = new HttpEntity<>(createJsonHeaders());
+            Map<String, Object> response = restTemplate.exchange(url, org.springframework.http.HttpMethod.DELETE, entity, Map.class).getBody();
+            return Result.success(response);
+        } catch (Exception e) {
+            log.warn("删除文档失败: {}", e.getMessage());
+            return Result.error("删除文档失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 触发完整同步
+     */
+    @PostMapping("/sync")
+    public Result<Map<String, Object>> triggerSync() {
+        try {
+            String url = langgraphBaseUrl + "/api/v1/knowledge-base/sync";
+            HttpEntity<String> entity = new HttpEntity<>(createJsonHeaders());
+            Map<String, Object> response = restTemplate.postForEntity(url, entity, Map.class).getBody();
+            return Result.success(response);
+        } catch (Exception e) {
+            log.warn("触发同步失败: {}", e.getMessage());
+            return Result.error("触发同步失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 更新单个文档
+     */
+    @PostMapping("/update-document")
+    public Result<Map<String, Object>> updateDocument(@RequestBody Map<String, Object> params) {
+        String docId = (String) params.get("docId");
+        if (docId == null || docId.isEmpty()) {
+            return Result.error("docId 不能为空");
+        }
+        try {
+            String url = langgraphBaseUrl + "/api/v1/knowledge-base/document/" + docId;
+            // 构建请求体
+            Map<String, Object> body = new HashMap<>();
+            body.put("content", params.get("content"));
+            if (params.get("title") != null) {
+                body.put("title", params.get("title"));
+            }
+            if (params.get("metadataOverride") != null) {
+                body.put("metadata_override", params.get("metadataOverride"));
+            }
+            HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, createJsonHeaders());
+            Map<String, Object> response = restTemplate.exchange(url, org.springframework.http.HttpMethod.PUT, entity, Map.class).getBody();
+            return Result.success(response);
+        } catch (Exception e) {
+            log.warn("更新文档失败: {}", e.getMessage());
+            return Result.error("更新文档失败: " + e.getMessage());
+        }
+    }
+}

+ 263 - 0
cfc-langgraph/app/api/knowledge_base.py

@@ -0,0 +1,263 @@
+"""向量知识库管理接口 - 直接操作 ChromaDB"""
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel
+from typing import Optional, List
+import logging
+import asyncio
+import os
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api/v1/knowledge-base", tags=["knowledge-base"])
+
+# 后台同步任务引用
+_sync_task = None
+
+
+class DocumentInfo(BaseModel):
+    id: str
+    source: str
+    title: str
+    content: str = ""
+    content_preview: str = ""
+    metadata: dict = {}
+
+
+class ListRequest(BaseModel):
+    source: Optional[str] = None
+    keyword: Optional[str] = None
+    page: int = 1
+    size: int = 20
+
+
+class UpdateDocumentRequest(BaseModel):
+    content: str
+    title: Optional[str] = None
+    metadata_override: Optional[dict] = None
+
+
+@router.post("/stats")
+async def get_stats():
+    """获取向量库统计信息"""
+    try:
+        from app.rag.retriever import RagRetriever
+        from app.rag.embeddings import get_embeddings
+        from app.config import settings
+        from langchain_chroma import Chroma
+
+        retriever = RagRetriever()
+        collection = retriever.vectorstore._collection
+        count = collection.count()
+
+        # 按 source 统计(兼容新旧两种 metadata 字段:source / type)
+        all_data = collection.get(include=["metadatas"])
+        metas = all_data.get("metadatas") or []
+        source_counts = {}
+        type_counts = {}
+        for meta in metas:
+            if not meta:
+                continue
+            src = meta.get("source") or meta.get("type") or "unknown"
+            source_counts[src] = source_counts.get(src, 0) + 1
+            doc_type = meta.get("type") or meta.get("source") or "unknown"
+            type_counts[doc_type] = type_counts.get(doc_type, 0) + 1
+
+        return {
+            "total": count,
+            "by_source": source_counts,
+            "by_type": type_counts,
+            "last_sync": _load_sync_state(),
+        }
+    except Exception as e:
+        logger.error("获取向量库统计失败: %s", e)
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/documents")
+async def list_documents(req: ListRequest):
+    """分页列出向量库文档"""
+    try:
+        from app.rag.retriever import RagRetriever
+        from app.rag.embeddings import get_embeddings
+        from app.config import settings
+        from langchain_chroma import Chroma
+
+        retriever = RagRetriever()
+        collection = retriever.vectorstore._collection
+
+        # 构建过滤条件(兼容新旧两种 metadata 字段:source / type)
+        where_filter = None
+        if req.source:
+            where_filter = {"$or": [{"source": req.source}, {"type": req.source}]}
+
+        # 使用 get() 分页获取,避免加载全部 embedding
+        limit = req.size
+        offset = (req.page - 1) * req.size
+
+        result = collection.get(
+            where=where_filter,
+            include=["documents", "metadatas"],
+            limit=limit,
+            offset=offset,
+        )
+
+        ids = result.get("ids") or []
+        docs = result.get("documents") or []
+        metas = result.get("metadatas") or []
+
+        documents = []
+        for i, doc_id in enumerate(ids):
+            meta = metas[i] if i < len(metas) else {}
+            content = docs[i] if i < len(docs) else ""
+            # 截取内容预览
+            preview = content[:200] + "..." if len(content) > 200 else content
+            documents.append(DocumentInfo(
+                id=doc_id,
+                source=meta.get("source") or meta.get("type") or "unknown",
+                title=meta.get("title", ""),
+                content=content,
+                content_preview=preview,
+                metadata=meta,
+            ))
+
+        # 获取总数
+        count_result = collection.get(
+            where=where_filter,
+            include=[],
+        )
+        total = len(count_result.get("ids", []))
+
+        return {
+            "total": total,
+            "page": req.page,
+            "size": req.size,
+            "documents": documents,
+        }
+    except Exception as e:
+        logger.error("列出文档失败: %s", e)
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.delete("/source/{source_prefix}")
+async def delete_by_source(source_prefix: str):
+    """删除指定 source 前缀的所有文档"""
+    try:
+        from app.rag.retriever import RagRetriever
+        from app.rag.embeddings import get_embeddings
+        from app.config import settings
+        from langchain_chroma import Chroma
+
+        retriever = RagRetriever()
+        collection = retriever.vectorstore._collection
+
+        # 兼容新旧两种 metadata 字段:source / type
+        existing = collection.get(
+            where={"$or": [{"source": source_prefix}, {"type": source_prefix}]},
+            include=[],
+        )
+        ids = existing.get("ids", []) or []
+
+        if not ids:
+            return {"deleted": 0, "message": f"无 {source_prefix} 文档"}
+
+        collection.delete(ids=ids)
+        logger.info("已删除 source=%s 的 %d 个文档", source_prefix, len(ids))
+
+        return {"deleted": len(ids), "source": source_prefix}
+    except Exception as e:
+        logger.error("删除文档失败: %s", e)
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/sync")
+async def trigger_sync():
+    """触发一次完整知识库同步"""
+    global _sync_task
+
+    try:
+        # 如果已有同步任务在运行,返回提示
+        if _sync_task and not _sync_task.done():
+            return {"status": "running", "message": "同步任务已在运行中"}
+
+        _sync_task = asyncio.create_task(_run_sync())
+        return {"status": "started", "message": "已启动知识库同步任务"}
+    except Exception as e:
+        logger.error("触发同步失败: %s", e)
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+async def _run_sync():
+    """执行完整同步"""
+    try:
+        from app.tasks.knowledge_sync import sync_knowledge_base
+        await sync_knowledge_base()
+        logger.info("知识库同步完成")
+    except Exception as e:
+        logger.error("知识库同步失败: %s", e)
+
+
+@router.put("/document/{doc_id}")
+async def update_document(doc_id: str, req: UpdateDocumentRequest):
+    """更新单个文档内容(先获取原 metadata,再删除重建,保持 ID 不变)"""
+    try:
+        from app.rag.retriever import RagRetriever
+        from app.rag.embeddings import get_embeddings
+        from app.config import settings
+        from langchain_chroma import Chroma
+
+        retriever = RagRetriever()
+        collection = retriever.vectorstore._collection
+
+        # 先获取原文档 metadata,避免删除后丢失
+        old_result = collection.get(ids=[doc_id], include=["metadatas", "documents"])
+        old_metas = old_result.get("metadatas") or []
+        old_docs = old_result.get("documents") or []
+
+        if not old_metas:
+            raise HTTPException(status_code=404, detail="文档不存在")
+
+        original_meta = old_metas[0]
+        original_content = old_docs[0] if old_docs else ""
+
+        # 删除旧文档
+        collection.delete(ids=[doc_id])
+        logger.info("已删除旧文档: %s", doc_id)
+
+        # 构建新文档:保留原有 metadata,仅覆盖 content/title
+        meta = req.metadata_override or {}
+        # 保留原有 source/type/title 等字段,除非显式覆盖
+        for k, v in original_meta.items():
+            if k not in meta:
+                meta[k] = v
+
+        if req.title:
+            meta["title"] = req.title
+
+        # 使用原 ID 重新写入(显式传 ids,避免生成新 ID)
+        from langchain_core.documents import Document
+        doc = Document(page_content=req.content, metadata=meta)
+        await retriever.vectorstore.aadd_documents([doc], ids=[doc_id])
+        logger.info("已更新文档: %s", doc_id)
+
+        return {"success": True, "message": "文档已更新", "doc_id": doc_id}
+    except HTTPException:
+        raise
+    except Exception as e:
+        logger.error("更新文档失败: %s", e)
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+def _load_sync_state() -> str:
+    """加载上次同步时间"""
+    try:
+        import json
+        from app.config import settings
+        import os
+        sync_file = os.path.join(settings.chroma_db_path, ".sync_state")
+        if os.path.exists(sync_file):
+            with open(sync_file) as f:
+                state = json.load(f)
+                return state.get("last_sync", "未知")
+    except Exception:
+        pass
+    return "未知"

+ 2 - 1
cfc-langgraph/app/main.py

@@ -3,7 +3,7 @@ import time
 import asyncio
 import logging
 from fastapi import FastAPI, Request
-from app.api import health, recommend, chat, analyze, tongue, adapter, report_parse, meal, logs, audio, innate_portrait, self_check
+from app.api import health, recommend, chat, analyze, tongue, adapter, report_parse, meal, logs, audio, innate_portrait, self_check, knowledge_base
 from app import monitoring
 from src.app import router as questionnaire_router
 from app.middleware import RequestLogMiddleware
@@ -29,6 +29,7 @@ app.include_router(audio.router)
 app.include_router(logs.router)
 app.include_router(innate_portrait.router)
 app.include_router(self_check.router)
+app.include_router(knowledge_base.router)
 
 
 @app.on_event("startup")

+ 40 - 0
cfc-web/src/api/vectorKnowledge.js

@@ -0,0 +1,40 @@
+import request from '@/utils/request'
+
+// 向量知识库 API
+export function getVectorStats() {
+  return request({
+    url: '/api/admin/knowledge-base/vector/stats',
+    method: 'post'
+  })
+}
+
+export function listVectorDocuments(params) {
+  return request({
+    url: '/api/admin/knowledge-base/vector/documents',
+    method: 'post',
+    data: params
+  })
+}
+
+export function deleteVectorSource(source) {
+  return request({
+    url: '/api/admin/knowledge-base/vector/delete-source',
+    method: 'post',
+    data: { source }
+  })
+}
+
+export function triggerVectorSync() {
+  return request({
+    url: '/api/admin/knowledge-base/vector/sync',
+    method: 'post'
+  })
+}
+
+export function updateVectorDocument(data) {
+  return request({
+    url: '/api/admin/knowledge-base/vector/update-document',
+    method: 'post',
+    data
+  })
+}

+ 6 - 0
cfc-web/src/router/index.js

@@ -398,6 +398,12 @@ const routes = [
         component: () => import('@/views/admin/KnowledgeTag.vue'),
         meta: { title: '知识标签', perm: 'articles:categories' }
       },
+      {
+        path: 'vector-knowledge',
+        name: 'VectorKnowledgeBase',
+        component: () => import('@/views/admin/VectorKnowledgeBase.vue'),
+        meta: { title: '向量知识库', perm: 'knowledge:base' }
+      },
       {
         path: 'virtual-goods-config',
         name: 'VirtualGoodsConfig',

+ 1 - 0
cfc-web/src/views/Layout.vue

@@ -226,6 +226,7 @@ export default {
             { path: '/knowledge-tags', label: '知识标签', icon: 'el-icon-price-tag', perm: 'articles:categories' },
             { path: '/knowledge-base', label: '知识库', icon: 'el-icon-reading', perm: 'knowledge:base' },
             { path: '/health-knowledge', label: '健康知识库', icon: 'el-icon-first-aid-kit', perm: 'knowledge:base' },
+            { path: '/vector-knowledge', label: '向量知识库', icon: 'el-icon-data-line', perm: 'knowledge:base' },
           ]},
 
         // ===== 6.5 活动管理(activity:*,活动审核已移入审核中心) =====

+ 313 - 0
cfc-web/src/views/admin/VectorKnowledgeBase.vue

@@ -0,0 +1,313 @@
+<template>
+  <div class="vector-kb admin-page">
+    <!-- 顶部统计卡片 -->
+    <el-row :gutter="16" class="kb-stats">
+      <el-col :span="6">
+        <el-card shadow="hover" class="stat-card">
+          <div class="stat-label">文档总数</div>
+          <div class="stat-value">{{ stats.total || 0 }}</div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover" class="stat-card">
+          <div class="stat-label">文章来源</div>
+          <div class="stat-value">{{ (stats.by_source && stats.by_source.article) || 0 }}</div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover" class="stat-card">
+          <div class="stat-label">菌群知识库</div>
+          <div class="stat-value">{{ (stats.by_source && stats.by_source.microbiome) || 0 }}</div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover" class="stat-card">
+          <div class="stat-label">知识库(DAN)</div>
+          <div class="stat-value">{{ (stats.by_source && stats.by_source.dan_knowledge) || 0 }}</div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <!-- 操作栏 -->
+    <el-card class="kb-toolbar">
+      <div class="kb-toolbar-inner">
+        <div class="kb-filters">
+          <el-select v-model="filterSource" placeholder="来源筛选" size="small" clearable style="width: 160px" @change="handleSearch">
+            <el-option label="文章 (article)" value="article" />
+            <el-option label="菌群 (microbiome)" value="microbiome" />
+            <el-option label="知识库 (dan_knowledge)" value="dan_knowledge" />
+          </el-select>
+          <el-input v-model="searchKeyword" placeholder="搜索标题/内容" prefix-icon="el-icon-search" size="small" style="width: 240px" clearable @keyup.enter.native="handleSearch" @clear="handleSearch" />
+          <el-button type="primary" size="small" @click="handleSearch">搜索</el-button>
+          <el-button size="small" @click="resetSearch">重置</el-button>
+        </div>
+        <div class="kb-actions">
+          <el-tag size="small" type="info" v-if="stats.last_sync && stats.last_sync !== '未知'">
+            上次同步: {{ stats.last_sync }}
+          </el-tag>
+          <el-button type="warning" size="small" icon="el-icon-refresh" :loading="syncing" @click="handleSync">全量同步</el-button>
+          <el-button type="primary" size="small" icon="el-icon-refresh" @click="fetchStats" :loading="statsLoading">刷新</el-button>
+        </div>
+      </div>
+    </el-card>
+
+    <!-- 文档列表 -->
+    <el-card class="kb-list">
+      <el-table :data="documents" v-loading="listLoading" border stripe size="small">
+        <el-table-column prop="id" label="ID" width="200" show-overflow-tooltip />
+        <el-table-column prop="source" label="来源" width="120">
+          <template slot-scope="{ row }">
+            <el-tag :type="sourceTagType(row.source)" size="mini">{{ sourceLabel(row.source) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="title" label="标题" min-width="160" show-overflow-tooltip>
+          <template slot-scope="{ row }">
+            {{ row.title || '(无标题)' }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="content_preview" label="内容预览" min-width="240" show-overflow-tooltip />
+        <el-table-column label="操作" width="150" fixed="right">
+          <template slot-scope="{ row }">
+            <el-button type="text" size="mini" @click="openEditDialog(row)">编辑</el-button>
+            <el-button type="text" size="mini" class="danger-text" @click="handleDelete(row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <el-pagination
+        style="margin-top: 16px; text-align: right"
+        :current-page="page"
+        :page-size="size"
+        :total="total"
+        layout="total, prev, pager, next"
+        @current-change="handlePageChange"
+      />
+    </el-card>
+
+    <!-- 编辑文档弹窗 -->
+    <el-dialog :visible.sync="editDialogVisible" title="编辑向量文档" width="720px" :close-on-click-modal="false">
+      <el-form label-width="80px" size="small">
+        <el-form-item label="文档 ID">
+          <el-input :value="editingDoc.id" disabled />
+        </el-form-item>
+        <el-form-item label="标题">
+          <el-input v-model="editForm.title" placeholder="文档标题" />
+        </el-form-item>
+        <el-form-item label="内容">
+          <el-input v-model="editForm.content" type="textarea" :rows="10" placeholder="文档内容(修改后需重新向量化)" />
+        </el-form-item>
+      </el-form>
+      <span slot="footer">
+        <el-button size="small" @click="editDialogVisible = false">取消</el-button>
+        <el-button type="primary" size="small" :loading="saving" @click="submitEdit">保存</el-button>
+      </span>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import {
+  getVectorStats,
+  listVectorDocuments,
+  deleteVectorSource,
+  triggerVectorSync,
+  updateVectorDocument
+} from '@/api/vectorKnowledge'
+
+export default {
+  name: 'VectorKnowledgeBase',
+  data() {
+    return {
+      stats: {},
+      statsLoading: false,
+      documents: [],
+      listLoading: false,
+      filterSource: '',
+      searchKeyword: '',
+      page: 1,
+      size: 20,
+      total: 0,
+      syncing: false,
+      editDialogVisible: false,
+      editingDoc: {},
+      editForm: { title: '', content: '' },
+      saving: false
+    }
+  },
+  created() {
+    this.fetchStats()
+    this.fetchDocuments()
+  },
+  methods: {
+    sourceLabel(source) {
+      const labels = {
+        article: '文章',
+        microbiome: '菌群',
+        dan_knowledge: '知识库'
+      }
+      return labels[source] || source || '未知'
+    },
+    sourceTagType(source) {
+      const types = {
+        article: 'primary',
+        microbiome: 'success',
+        dan_knowledge: 'warning'
+      }
+      return types[source] || 'info'
+    },
+    async fetchStats() {
+      this.statsLoading = true
+      try {
+        const res = await getVectorStats()
+        this.stats = res.data || {}
+      } catch (e) {
+        this.$message.error('获取统计失败: ' + (e.message || ''))
+      } finally {
+        this.statsLoading = false
+      }
+    },
+    async fetchDocuments() {
+      this.listLoading = true
+      try {
+        const params = {
+          page: this.page,
+          size: this.size,
+          source: this.filterSource || undefined,
+          keyword: this.searchKeyword || undefined
+        }
+        const res = await listVectorDocuments(params)
+        const data = res.data || {}
+        this.documents = data.documents || []
+        this.total = data.total || 0
+      } catch (e) {
+        this.$message.error('获取文档列表失败: ' + (e.message || ''))
+      } finally {
+        this.listLoading = false
+      }
+    },
+    handleSearch() {
+      this.page = 1
+      this.fetchDocuments()
+    },
+    resetSearch() {
+      this.filterSource = ''
+      this.searchKeyword = ''
+      this.page = 1
+      this.fetchDocuments()
+    },
+    handlePageChange(page) {
+      this.page = page
+      this.fetchDocuments()
+    },
+    async handleSync() {
+      this.$confirm('全量同步将从 Java 后端拉取最新数据重建向量库,需要数分钟,确认继续?', '确认同步', {
+        confirmButtonText: '开始同步',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(async () => {
+        this.syncing = true
+        try {
+          const res = await triggerVectorSync()
+          this.$message.success((res.data && res.data.message) || '同步已启动')
+        } catch (e) {
+          this.$message.error('同步失败: ' + (e.message || ''))
+        } finally {
+          this.syncing = false
+        }
+      }).catch(() => {})
+    },
+    openEditDialog(row) {
+      this.editingDoc = row
+      this.editForm = {
+        title: row.title || '',
+        content: row.content || row.content_preview || ''
+      }
+      this.editDialogVisible = true
+    },
+    async submitEdit() {
+      if (!this.editForm.content) {
+        this.$message.warning('内容不能为空')
+        return
+      }
+      this.saving = true
+      try {
+        await updateVectorDocument({
+          docId: this.editingDoc.id,
+          title: this.editForm.title,
+          content: this.editForm.content
+        })
+        this.$message.success('文档已更新')
+        this.editDialogVisible = false
+        this.fetchDocuments()
+        this.fetchStats()
+      } catch (e) {
+        this.$message.error('更新失败: ' + (e.message || ''))
+      } finally {
+        this.saving = false
+      }
+    },
+    handleDelete(row) {
+      this.$confirm(`删除来源 "${this.sourceLabel(row.source)}" 的全部文档?此操作不可恢复!`, '确认删除', {
+        confirmButtonText: '删除',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(async () => {
+        try {
+          const res = await deleteVectorSource(row.source)
+          this.$message.success(`已删除 ${(res.data && res.data.deleted) || 0} 个文档`)
+          this.fetchDocuments()
+          this.fetchStats()
+        } catch (e) {
+          this.$message.error('删除失败: ' + (e.message || ''))
+        }
+      }).catch(() => {})
+    }
+  }
+}
+</script>
+
+<style scoped>
+.vector-kb {
+  padding: 12px;
+}
+.kb-stats {
+  margin-bottom: 12px;
+}
+.stat-card {
+  text-align: center;
+}
+.stat-label {
+  font-size: 13px;
+  color: #909399;
+  margin-bottom: 8px;
+}
+.stat-value {
+  font-size: 26px;
+  font-weight: 700;
+  color: #303133;
+}
+.kb-toolbar {
+  margin-bottom: 12px;
+}
+.kb-toolbar-inner {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+.kb-filters {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-wrap: wrap;
+}
+.kb-actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+.danger-text {
+  color: #f56c6c;
+}
+</style>