10 小时以前 3ca2c89288db1d6cba514ced02c91624ef5fe497
feat(collaborativeApproval): 添加知识库RAG向量检索问答功能

- 新增知识库文件管理和向量化处理功能
- 实现文件上传、向量化状态监控和重新处理机制
- 集成知识库问答功能,支持流式AI对话
- 添加知识库文件数量和切片数量统计
- 支持多种文件格式(txt、md、docx、xlsx、xls、pdf)
- 实现向量数据库集成和智能检索功能
- 优化知识库管理界面和用户体验
- 完善相关API接口和错误处理机制
已添加1个文件
已修改3个文件
1613 ■■■■■ 文件已修改
.gitignore 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/知识库RAG功能实现文档.md 1034 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/collaborativeApproval/knowledgeBase.js 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/collaborativeApproval/knowledgeBase/index.vue 525 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.gitignore
@@ -6,6 +6,8 @@
yarn-error.log*
**/*.log
.claude/
tests/**/coverage/
tests/e2e/reports
selenium-debug.log
doc/֪ʶ¿âRAG¹¦ÄÜʵÏÖÎĵµ.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,1034 @@
# çŸ¥è¯†åº“RAG向量检索功能实现文档
## ä¸€ã€åŠŸèƒ½æ¦‚è¿°
基于 RAG(Retrieval-Augmented Generation)技术实现知识库问答功能,支持:
- çŸ¥è¯†åº“管理(CRUD)
- æ–‡ä»¶ä¸Šä¼ ä¸Žå‘量化处理
- åŸºäºŽå‘量检索的智能问答
- å¤šç§æ–‡ä»¶æ ¼å¼æ”¯æŒï¼ˆtxt、md、docx、xlsx、xls、pdf)
## äºŒã€æŠ€æœ¯æž¶æž„
### 2.1 æŠ€æœ¯æ ˆ
| ç»„ä»¶ | æŠ€æœ¯ |
|------|------|
| å‘量数据库 | Pinecone |
| Embedding模型 | é˜¿é‡Œäº‘ DashScope text-embedding-v3 |
| LLM | é˜¿é‡Œäº‘通义千问 qwen-max |
| æ¡†æž¶ | langchain4j |
| ORM | MyBatis-Plus |
### 2.2 æž¶æž„图
```
┌─────────────────────────────────────────────────────────────┐
│                        å‰ç«¯åº”用                              â”‚
└─────────────────────────────────────────────────────────────┘
                              â”‚
                              â–¼
┌─────────────────────────────────────────────────────────────┐
│                     Controller Layer                         â”‚
│  â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”  â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”   â”‚
│  â”‚ KnowledgeBaseCtrl   â”‚  â”‚ KnowledgeChatController     â”‚   â”‚
│  â”‚ (知识库管理)         â”‚  â”‚ (知识库问答)                 â”‚   â”‚
│  â””─────────────────────┘  â””─────────────────────────────┘   â”‚
└─────────────────────────────────────────────────────────────┘
                              â”‚
                              â–¼
┌─────────────────────────────────────────────────────────────┐
│                      Service Layer                           â”‚
│  â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”  â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”   â”‚
│  â”‚KnowledgeBaseService â”‚  â”‚ KnowledgeRagService         â”‚   â”‚
│  â”‚ (知识库CRUD)         â”‚  â”‚ (向量化/检索)                â”‚   â”‚
│  â””─────────────────────┘  â””─────────────────────────────┘   â”‚
└─────────────────────────────────────────────────────────────┘
                              â”‚
                              â–¼
┌─────────────────────────────────────────────────────────────┐
│                      AI Layer                                â”‚
│  â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”  â”Œâ”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”   â”‚
│  â”‚ KnowledgeChatAgent  â”‚  â”‚ EmbeddingStore (Pinecone)   â”‚   â”‚
│  â”‚ (问答Agent)          â”‚  â”‚ (向量存储)                   â”‚   â”‚
│  â””─────────────────────┘  â””─────────────────────────────┘   â”‚
└─────────────────────────────────────────────────────────────┘
```
---
## ä¸‰ã€åŽç«¯å®žçް
### 3.1 æ•°æ®åº“设计
#### 3.1.1 çŸ¥è¯†åº“表(knowledge_base)
```sql
CREATE TABLE knowledge_base (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(255) COMMENT '知识标题',
    type VARCHAR(50) COMMENT '知识类型',
    scenario VARCHAR(255) COMMENT '适用场景',
    efficiency VARCHAR(20) COMMENT '解决效率',
    problem TEXT COMMENT '问题描述',
    solution TEXT COMMENT '解决方案',
    key_points TEXT COMMENT '关键要点',
    creator VARCHAR(100) COMMENT '创建人',
    usage_count INT DEFAULT 0 COMMENT '使用次数',
    file_count INT DEFAULT 0 COMMENT '文件数量',
    total_chunk_count INT DEFAULT 0 COMMENT '总切片数量',
    description VARCHAR(500) COMMENT '知识库描述',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    create_user INT,
    update_user INT,
    tenant_id BIGINT,
    dept_id BIGINT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库表';
```
#### 3.1.2 çŸ¥è¯†åº“向量记录表(knowledge_base_vector)
```sql
CREATE TABLE knowledge_base_vector (
    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
    knowledge_base_id BIGINT NOT NULL COMMENT '关联知识库ID',
    storage_blob_id BIGINT NOT NULL COMMENT '关联文件blob ID',
    file_name VARCHAR(255) NOT NULL COMMENT '文件名称',
    file_type VARCHAR(50) NOT NULL COMMENT '文件类型',
    vector_status TINYINT DEFAULT 0 COMMENT '向量化状态: 0-待处理, 1-处理中, 2-已完成, 3-失败',
    vector_error VARCHAR(500) COMMENT '向量化失败原因',
    chunk_count INT DEFAULT 0 COMMENT '切片数量',
    namespace VARCHAR(100) COMMENT '向量命名空间',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    create_user INT,
    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    update_user INT,
    tenant_id BIGINT,
    dept_id BIGINT,
    INDEX idx_knowledge_base_id (knowledge_base_id),
    INDEX idx_storage_blob_id (storage_blob_id),
    INDEX idx_vector_status (vector_status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库文件向量记录表';
```
### 3.2 Maven依赖
```xml
<!-- langchain4j BOM -->
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>dev.langchain4j</groupId>
            <artifactId>langchain4j-bom</artifactId>
            <version>1.0.0-beta3</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>
<dependencies>
    <!-- langchain4j æ ¸å¿ƒ -->
    <dependency>
        <groupId>dev.langchain4j</groupId>
        <artifactId>langchain4j-spring-boot-starter</artifactId>
    </dependency>
    <!-- Pinecone å‘量数据库 -->
    <dependency>
        <groupId>dev.langchain4j</groupId>
        <artifactId>langchain4j-pinecone</artifactId>
    </dependency>
    <!-- é˜¿é‡Œäº‘ DashScope -->
    <dependency>
        <groupId>dev.langchain4j</groupId>
        <artifactId>langchain4j-community-dashscope-spring-boot-starter</artifactId>
    </dependency>
</dependencies>
```
### 3.3 é…ç½®æ–‡ä»¶ï¼ˆapplication.yml)
```yaml
# Pinecone å‘量数据库配置
pinecone:
  api-key: your-pinecone-api-key
  index: your-index-name
  namespace: knowledge-base
# langchain4j é…ç½®
langchain4j:
  community:
    dashscope:
      streaming-chat-model:
        api-key: your-dashscope-api-key
        model-name: "qwen-max"
      embedding-model:
        api-key: your-dashscope-api-key
        model-name: "text-embedding-v3"
```
### 3.4 æ ¸å¿ƒä»£ç å®žçް
#### 3.4.1 å®žä½“ç±»
**KnowledgeBase.java**
```java
@Data
@TableName("knowledge_base")
public class KnowledgeBase implements Serializable {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String title;
    private String type;
    private String scenario;
    private String efficiency;
    private String problem;
    private String solution;
    private String keyPoints;
    private String creator;
    private Integer usageCount;
    private Integer fileCount;
    private Integer totalChunkCount;
    private String description;
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
    @TableField(fill = FieldFill.INSERT)
    private Integer createUser;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Integer updateUser;
    @TableField(fill = FieldFill.INSERT)
    private Long tenantId;
    @TableField(fill = FieldFill.INSERT)
    private Long deptId;
}
```
**KnowledgeBaseVector.java**
```java
@Data
@TableName("knowledge_base_vector")
public class KnowledgeBaseVector implements Serializable {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Long knowledgeBaseId;
    private Long storageBlobId;
    private String fileName;
    private String fileType;
    private Integer vectorStatus;
    private String vectorError;
    private Integer chunkCount;
    private String namespace;
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;
    @TableField(fill = FieldFill.INSERT)
    private Integer createUser;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Integer updateUser;
    @TableField(fill = FieldFill.INSERT)
    private Long tenantId;
    @TableField(fill = FieldFill.INSERT)
    private Long deptId;
    // å‘量化状态常量
    public static final int STATUS_PENDING = 0;
    public static final int STATUS_PROCESSING = 1;
    public static final int STATUS_COMPLETED = 2;
    public static final int STATUS_FAILED = 3;
}
```
#### 3.4.2 EmbeddingStore配置
**EmbeddingStoreConfig.java**
```java
@Configuration
public class EmbeddingStoreConfig {
    @Value("${pinecone.api-key}")
    private String pineconeApiKey;
    @Value("${pinecone.index}")
    private String indexName;
    @Value("${pinecone.namespace}")
    private String namespace;
    @Bean
    public Pinecone pinecone() {
        return new Pinecone.Builder(pineconeApiKey).build();
    }
    @Bean
    public Index pineconeIndex(Pinecone pinecone) {
        return pinecone.getIndexConnection(indexName);
    }
    @Bean
    public EmbeddingStore<TextSegment> embeddingStore(EmbeddingModel embeddingModel) {
        return PineconeEmbeddingStore.builder()
                .apiKey(pineconeApiKey)
                .index(indexName)
                .nameSpace(namespace)
                .createIndex(PineconeServerlessIndexConfig.builder()
                        .cloud("AWS")
                        .region("us-east-1")
                        .dimension(embeddingModel.dimension())
                        .build())
                .build();
    }
}
```
#### 3.4.3 RAG服务实现
**KnowledgeRagService.java**
```java
public interface KnowledgeRagService {
    void processVectorAsync(Long vectorId);
    void processVector(Long vectorId);
    List<String> searchRelevantContent(String namespace, String query, int maxResults);
    void deleteEmbeddings(String namespace, Long storageBlobId);
}
```
**KnowledgeRagServiceImpl.java**(核心实现)
```java
@Slf4j
@Service
public class KnowledgeRagServiceImpl implements KnowledgeRagService {
    private final KnowledgeBaseVectorService knowledgeBaseVectorService;
    private final StorageBlobService storageBlobService;
    private final EmbeddingModel embeddingModel;
    private final EmbeddingStore<TextSegment> embeddingStore;
    private final FileProperties fileProperties;
    private final Index pineconeIndex;
    @Value("${pinecone.namespace}")
    private String namespace;
    private static final int CHUNK_SIZE = 500;
    private static final int CHUNK_OVERLAP = 100;
    private static final long CHUNK_THRESHOLD_BYTES = 80L * 1024 * 1024;
    private static final int EMBEDDING_MAX_LENGTH = 8000;
    @Override
    @Async("threadPoolTaskExecutor")
    public void processVectorAsync(Long vectorId) {
        processVector(vectorId);
    }
    @Override
    public void processVector(Long vectorId) {
        KnowledgeBaseVector vector = knowledgeBaseVectorService.getById(vectorId);
        if (vector == null) return;
        try {
            // æ›´æ–°çŠ¶æ€ä¸ºå¤„ç†ä¸­
            knowledgeBaseVectorService.updateVectorStatus(vectorId, STATUS_PROCESSING, null, null);
            // èŽ·å–æ–‡ä»¶å†…å®¹
            StorageBlob blob = storageBlobService.getById(vector.getStorageBlobId());
            File file = getFile(blob);
            String content = extractFileContent(file, vector.getFileName());
            if (content == null || content.trim().isEmpty()) {
                throw new RuntimeException("文件内容为空");
            }
            // æ–‡æœ¬åˆ‡ç‰‡
            List<TextSegment> chunks;
            boolean needChunk = file.length() > CHUNK_THRESHOLD_BYTES || content.length() > EMBEDDING_MAX_LENGTH;
            if (needChunk) {
                chunks = splitText(content, vector);
            } else {
                Map<String, Object> metadata = buildMetadata(vector);
                chunks = List.of(TextSegment.from(content, new Metadata(metadata)));
            }
            // ç”ŸæˆåµŒå…¥å‘量并存储
            int chunkCount = 0;
            for (TextSegment chunk : chunks) {
                Embedding embedding = embeddingModel.embed(chunk).content();
                embeddingStore.add(embedding, chunk);
                chunkCount++;
            }
            // æ›´æ–°çŠ¶æ€ä¸ºå®Œæˆ
            knowledgeBaseVectorService.updateVectorStatus(vectorId, STATUS_COMPLETED, chunkCount, null);
        } catch (Exception e) {
            log.error("向量化处理失败", e);
            knowledgeBaseVectorService.updateVectorStatus(vectorId, STATUS_FAILED, null, e.getMessage());
        }
    }
    @Override
    public List<String> searchRelevantContent(String namespace, String query, int maxResults) {
        Embedding queryEmbedding = embeddingModel.embed(query).content();
        EmbeddingSearchRequest searchRequest = EmbeddingSearchRequest.builder()
                .queryEmbedding(queryEmbedding)
                .maxResults(maxResults)
                .minScore(0.7)
                .build();
        EmbeddingSearchResult<TextSegment> searchResult = embeddingStore.search(searchRequest);
        return searchResult.matches().stream()
                .map(match -> match.embedded().text())
                .collect(Collectors.toList());
    }
    @Override
    public void deleteEmbeddings(String namespace, Long storageBlobId) {
        Struct filter = Struct.newBuilder()
                .putFields("storageBlobId", Value.newBuilder()
                        .setStructValue(Struct.newBuilder()
                                .putFields("$eq", Value.newBuilder()
                                        .setNumberValue(storageBlobId.doubleValue())
                                        .build()))
                        .build())
                .build();
        pineconeIndex.delete(new ArrayList<>(), false, this.namespace, filter);
    }
    private String extractFileContent(File file, String fileName) throws Exception {
        String ext = getFileExtension(fileName);
        if (isPlainText(ext)) {
            return readFileWithEncoding(file);
        }
        if ("docx".equals(ext)) {
            return extractDocx(file);
        }
        if ("xlsx".equals(ext) || "xls".equals(ext)) {
            return extractExcel(file);
        }
        return readFileWithEncoding(file);
    }
    // ... å…¶ä»–辅助方法
}
```
#### 3.4.4 çŸ¥è¯†åº“问答Agent
**KnowledgeChatAgent.java**
```java
@AiService(
        wiringMode = EXPLICIT,
        streamingChatModel = "qwenStreamingChatModel",
        chatMemoryProvider = "chatMemoryProvider"
)
public interface KnowledgeChatAgent {
    @SystemMessage("""
            ä½ æ˜¯ä¼ä¸šçŸ¥è¯†åº“问答助手。
            ä½ éœ€è¦åŸºäºŽæä¾›çš„知识库内容回答用户问题。
            éµå¾ªä»¥ä¸‹è§„则:
            1. ä¸¥æ ¼åŸºäºŽçŸ¥è¯†åº“内容回答,不要编造信息
            2. å¦‚果知识库中没有相关信息,明确告知用户
            3. å›žç­”要准确、简洁、有条理
            4. å¼•用来源时注明"根据知识库内容"
            """)
    Flux<String> chat(@MemoryId String memoryId, @UserMessage String userMessage);
}
```
#### 3.4.5 Controller层
**KnowledgeBaseController.java**
```java
@RestController
@RequestMapping("/knowledgeBase")
@Tag(name = "知识库管理")
public class KnowledgeBaseController {
    @GetMapping("/getList")
    public AjaxResult getList(@RequestParam(defaultValue = "1") long current,
                              @RequestParam(defaultValue = "10") long size,
                              KnowledgeBase knowledgeBase) {
        Page page = new Page(current, size);
        return AjaxResult.success(knowledgeBaseService.listpage(page, knowledgeBase));
    }
    @PostMapping("/add")
    public AjaxResult add(@RequestBody KnowledgeBase knowledgeBase) {
        return AjaxResult.success(knowledgeBaseService.save(knowledgeBase));
    }
    @PostMapping("/update")
    public AjaxResult update(@RequestBody KnowledgeBase knowledgeBase) {
        return AjaxResult.success(knowledgeBaseService.updateById(knowledgeBase));
    }
    @DeleteMapping("/delete")
    public AjaxResult delete(@RequestBody List<Long> ids) {
        return AjaxResult.success(knowledgeBaseService.removeByIds(ids));
    }
    @GetMapping("/vector/status/{knowledgeBaseId}")
    @Operation(summary = "查询知识库文件向量化状态")
    public AjaxResult getVectorStatus(@PathVariable Long knowledgeBaseId) {
        return AjaxResult.success(knowledgeBaseVectorService.getVectorStatusByKnowledgeBaseId(knowledgeBaseId));
    }
    @PostMapping("/vector/reprocess/{vectorId}")
    @Operation(summary = "重新向量化文件")
    public AjaxResult reprocessVector(@PathVariable Long vectorId) {
        knowledgeBaseVectorService.reprocessVector(vectorId);
        return AjaxResult.success("已重新提交向量化任务");
    }
    @PostMapping("/file/save")
    @Operation(summary = "保存知识库文件关联")
    public AjaxResult saveKnowledgeBaseFiles(@RequestBody KnowledgeBaseFileDTO dto) {
        // ä¿å­˜é™„件关联并触发向量化
        // ...
    }
    @DeleteMapping("/file/delete")
    @Operation(summary = "删除知识库文件")
    public AjaxResult deleteKnowledgeBaseFiles(@RequestBody List<Long> vectorIds) {
        knowledgeBaseVectorService.deleteVectors(vectorIds);
        return AjaxResult.success();
    }
}
```
**KnowledgeChatController.java**
```java
@RestController
@RequestMapping("/ai/knowledge")
@Tag(name = "知识库问答")
public class KnowledgeChatController {
    private final KnowledgeChatAgent knowledgeChatAgent;
    private final KnowledgeRagService knowledgeRagService;
    private final KnowledgeBaseService knowledgeBaseService;
    @PostMapping(value = "/chat", produces = "text/stream;charset=utf-8")
    @Operation(summary = "知识库问答")
    public Flux<String> chat(@RequestBody KnowledgeChatRequest request) {
        // æ£€ç´¢ç›¸å…³å†…容
        String namespace = "kb-" + request.getKnowledgeBaseId();
        List<String> relevantContents = knowledgeRagService.searchRelevantContent(
                namespace, request.getQuestion(), 5);
        if (relevantContents.isEmpty()) {
            return Flux.just("知识库中未找到相关内容");
        }
        // æž„建上下文
        StringBuilder context = new StringBuilder();
        context.append("以下是从知识库中检索到的相关内容:\n\n");
        for (int i = 0; i < relevantContents.size(); i++) {
            context.append("【内容").append(i + 1).append("】\n");
            context.append(relevantContents.get(i)).append("\n\n");
        }
        context.append("---\n请基于以上知识库内容回答:\n").append(request.getQuestion());
        return knowledgeChatAgent.chat(request.getMemoryId(), context.toString());
    }
    @GetMapping("/list")
    @Operation(summary = "知识库列表")
    public AjaxResult listKnowledgeBases() {
        return AjaxResult.success(knowledgeBaseService.list());
    }
}
```
---
## å››ã€API接口文档
### 4.1 çŸ¥è¯†åº“管理接口
| æŽ¥å£ | æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|------|
| èŽ·å–åˆ—è¡¨ | GET | /knowledgeBase/getList | åˆ†é¡µæŸ¥è¯¢çŸ¥è¯†åº“列表 |
| æ–°å¢žçŸ¥è¯†åº“ | POST | /knowledgeBase/add | åˆ›å»ºçŸ¥è¯†åº“ |
| æ›´æ–°çŸ¥è¯†åº“ | POST | /knowledgeBase/update | æ›´æ–°çŸ¥è¯†åº“信息 |
| åˆ é™¤çŸ¥è¯†åº“ | DELETE | /knowledgeBase/delete | æ‰¹é‡åˆ é™¤çŸ¥è¯†åº“ |
| æŸ¥è¯¢å‘量化状态 | GET | /knowledgeBase/vector/status/{id} | æŸ¥è¯¢æ–‡ä»¶å‘量化状态 |
| é‡æ–°å‘量化 | POST | /knowledgeBase/vector/reprocess/{id} | é‡æ–°å¤„理失败的文件 |
| ä¿å­˜æ–‡ä»¶å…³è” | POST | /knowledgeBase/file/save | ä¸Šä¼ æ–‡ä»¶åŽå…³è”到知识库 |
| åˆ é™¤æ–‡ä»¶ | DELETE | /knowledgeBase/file/delete | åˆ é™¤çŸ¥è¯†åº“文件 |
### 4.2 çŸ¥è¯†åº“问答接口
| æŽ¥å£ | æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|------|
| çŸ¥è¯†åº“问答 | POST | /ai/knowledge/chat | æµå¼è¿”回问答结果 |
| çŸ¥è¯†åº“列表 | GET | /ai/knowledge/list | èŽ·å–å¯é€‰çŸ¥è¯†åº“åˆ—è¡¨ |
### 4.3 æŽ¥å£è¯¦ç»†è¯´æ˜Ž
#### 4.3.1 ä¿å­˜çŸ¥è¯†åº“文件关联
**请求**
```json
POST /knowledgeBase/file/save
{
    "knowledgeBaseId": 1,
    "storageBlobIds": [100, 101, 102]
}
```
**响应**
```json
{
    "code": 200,
    "msg": "操作成功"
}
```
#### 4.3.2 çŸ¥è¯†åº“问答
**请求**
```json
POST /ai/knowledge/chat
Content-Type: application/json
{
    "knowledgeBaseId": 1,
    "memoryId": "session-uuid",
    "question": "如何处理库存盘点差异?"
}
```
**响应**(SSE流式)
```
根据知识库内容,库存盘点差异的处理流程如下:
1. å‘现差异后,首先核对盘点记录...
2. æ£€æŸ¥æ˜¯å¦æœ‰æ¼ç›˜æˆ–错盘...
3. ...
```
---
## äº”、前端实现
### 5.1 çŸ¥è¯†åº“管理页面
```vue
<template>
  <div class="knowledge-base">
    <!-- åˆ—表 -->
    <el-table :data="tableData" border>
      <el-table-column prop="title" label="知识标题" />
      <el-table-column prop="type" label="知识类型" />
      <el-table-column prop="fileCount" label="文件数量" />
      <el-table-column prop="totalChunkCount" label="切片数量" />
      <el-table-column label="操作">
        <template #default="{ row }">
          <el-button @click="handleEdit(row)">编辑</el-button>
          <el-button @click="handleFiles(row)">文件管理</el-button>
          <el-button @click="handleChat(row)">问答</el-button>
          <el-button type="danger" @click="handleDelete(row)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getKnowledgeBaseList, deleteKnowledgeBase } from '@/api/knowledge'
const tableData = ref([])
const loadData = async () => {
  const res = await getKnowledgeBaseList({ current: 1, size: 10 })
  tableData.value = res.data.records
}
onMounted(loadData)
</script>
```
### 5.2 æ–‡ä»¶ä¸Šä¼ ä¸Žå‘量化状态
```vue
<template>
  <div class="file-manager">
    <!-- æ–‡ä»¶ä¸Šä¼  -->
    <el-upload
      :action="uploadUrl"
      :on-success="handleUploadSuccess"
      multiple
    >
      <el-button type="primary">上传文件</el-button>
    </el-upload>
    <!-- æ–‡ä»¶åˆ—表与向量化状态 -->
    <el-table :data="fileList">
      <el-table-column prop="fileName" label="文件名" />
      <el-table-column label="向量化状态">
        <template #default="{ row }">
          <el-tag :type="getStatusType(row.vectorStatus)">
            {{ getStatusText(row.vectorStatus) }}
          </el-tag>
        </template>
      </el-table-column>
      <el-table-column prop="chunkCount" label="切片数" />
      <el-table-column label="操作">
        <template #default="{ row }">
          <el-button v-if="row.vectorStatus === 3" @click="reprocess(row)">
            é‡æ–°å¤„理
          </el-button>
          <el-button type="danger" @click="deleteFile(row)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>
<script setup>
const uploadUrl = import.meta.env.VITE_APP_BASE_API + '/common/upload'
// ä¸Šä¼ æˆåŠŸåŽä¿å­˜å…³è”
const uploadedBlobIds = ref([])
const handleUploadSuccess = (response, file) => {
  if (response.code === 200) {
    uploadedBlobIds.value.push(response.data.id)
  }
}
// ä¿å­˜æ–‡ä»¶å…³è”
const saveFiles = async () => {
  await saveKnowledgeBaseFiles({
    knowledgeBaseId: props.knowledgeBaseId,
    storageBlobIds: uploadedBlobIds.value
  })
  // åˆ·æ–°æ–‡ä»¶åˆ—表
  loadFileList()
}
// çŠ¶æ€æ–‡æœ¬æ˜ å°„
const getStatusText = (status) => {
  const map = {
    0: '待处理',
    1: '处理中',
    2: '已完成',
    3: '失败'
  }
  return map[status] || '未知'
}
const getStatusType = (status) => {
  const map = {
    0: 'info',
    1: 'warning',
    2: 'success',
    3: 'danger'
  }
  return map[status] || 'info'
}
</script>
```
### 5.3 çŸ¥è¯†åº“问答界面
```vue
<template>
  <div class="knowledge-chat">
    <!-- çŸ¥è¯†åº“选择 -->
    <el-select v-model="selectedKbId" placeholder="选择知识库">
      <el-option
        v-for="kb in knowledgeBases"
        :key="kb.id"
        :label="kb.title"
        :value="kb.id"
      />
    </el-select>
    <!-- å¯¹è¯åŒºåŸŸ -->
    <div class="chat-messages">
      <div
        v-for="(msg, index) in messages"
        :key="index"
        :class="['message', msg.role]"
      >
        <div class="content">{{ msg.content }}</div>
      </div>
    </div>
    <!-- è¾“入框 -->
    <el-input
      v-model="inputQuestion"
      placeholder="请输入问题"
      @keyup.enter="sendMessage"
    >
      <template #append>
        <el-button @click="sendMessage" :loading="loading">发送</el-button>
      </template>
    </el-input>
  </div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getKnowledgeBaseList, knowledgeChat } from '@/api/knowledge'
const knowledgeBases = ref([])
const selectedKbId = ref(null)
const messages = ref([])
const inputQuestion = ref('')
const loading = ref(false)
const memoryId = ref(crypto.randomUUID())
const sendMessage = async () => {
  if (!inputQuestion.value.trim()) return
  if (!selectedKbId.value) {
    ElMessage.warning('请选择知识库')
    return
  }
  // æ·»åŠ ç”¨æˆ·æ¶ˆæ¯
  messages.value.push({
    role: 'user',
    content: inputQuestion.value
  })
  loading.value = true
  try {
    // æµå¼è¯·æ±‚
    const response = await fetch('/api/ai/knowledge/chat', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        knowledgeBaseId: selectedKbId.value,
        memoryId: memoryId.value,
        question: inputQuestion.value
      })
    })
    // å¤„理SSE流式响应
    const reader = response.body.getReader()
    const decoder = new TextDecoder()
    let aiContent = ''
    messages.value.push({ role: 'assistant', content: '' })
    while (true) {
      const { done, value } = await reader.read()
      if (done) break
      const text = decoder.decode(value)
      aiContent += text
      messages.value[messages.value.length - 1].content = aiContent
    }
  } finally {
    loading.value = false
    inputQuestion.value = ''
  }
}
onMounted(async () => {
  const res = await getKnowledgeBaseList()
  knowledgeBases.value = res.data
})
</script>
```
### 5.4 API封装
```javascript
// api/knowledge.js
import request from '@/utils/request'
// èŽ·å–çŸ¥è¯†åº“åˆ—è¡¨
export function getKnowledgeBaseList(params) {
  return request({
    url: '/knowledgeBase/getList',
    method: 'get',
    params
  })
}
// æ–°å¢žçŸ¥è¯†åº“
export function addKnowledgeBase(data) {
  return request({
    url: '/knowledgeBase/add',
    method: 'post',
    data
  })
}
// æ›´æ–°çŸ¥è¯†åº“
export function updateKnowledgeBase(data) {
  return request({
    url: '/knowledgeBase/update',
    method: 'post',
    data
  })
}
// åˆ é™¤çŸ¥è¯†åº“
export function deleteKnowledgeBase(ids) {
  return request({
    url: '/knowledgeBase/delete',
    method: 'delete',
    data: ids
  })
}
// èŽ·å–æ–‡ä»¶å‘é‡åŒ–çŠ¶æ€
export function getVectorStatus(knowledgeBaseId) {
  return request({
    url: `/knowledgeBase/vector/status/${knowledgeBaseId}`,
    method: 'get'
  })
}
// é‡æ–°å‘量化
export function reprocessVector(vectorId) {
  return request({
    url: `/knowledgeBase/vector/reprocess/${vectorId}`,
    method: 'post'
  })
}
// ä¿å­˜æ–‡ä»¶å…³è”
export function saveKnowledgeBaseFiles(data) {
  return request({
    url: '/knowledgeBase/file/save',
    method: 'post',
    data
  })
}
// åˆ é™¤æ–‡ä»¶
export function deleteKnowledgeBaseFiles(vectorIds) {
  return request({
    url: '/knowledgeBase/file/delete',
    method: 'delete',
    data: vectorIds
  })
}
// çŸ¥è¯†åº“问答(流式)
export async function knowledgeChat(data) {
  const response = await fetch('/api/ai/knowledge/chat', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data)
  })
  return response.body
}
// èŽ·å–çŸ¥è¯†åº“åˆ—è¡¨ï¼ˆé—®ç­”ç”¨ï¼‰
export function getKnowledgeBaseListForChat() {
  return request({
    url: '/ai/knowledge/list',
    method: 'get'
  })
}
```
---
## å…­ã€æ ¸å¿ƒæµç¨‹
### 6.1 æ–‡ä»¶ä¸Šä¼ ä¸Žå‘量化流程
```
1. å‰ç«¯è°ƒç”¨ /common/upload ä¸Šä¼ æ–‡ä»¶ â†’ è¿”回 storageBlobId
2. å‰ç«¯è°ƒç”¨ /knowledgeBase/file/save å…³è”文件到知识库
3. åŽç«¯åˆ›å»º KnowledgeBaseVector è®°å½•(状态:待处理)
4. åŽç«¯å¼‚步调用 KnowledgeRagService.processVectorAsync()
   â”œâ”€â”€ æ›´æ–°çŠ¶æ€ä¸º"处理中"
   â”œâ”€â”€ æå–文件内容(支持多种格式)
   â”œâ”€â”€ è‡ªåŠ¨æ£€æµ‹æ–‡ä»¶ç¼–ç ï¼ˆUTF-8/GBK)
   â”œâ”€â”€ æ–‡æœ¬åˆ‡ç‰‡ï¼ˆå¤§æ–‡ä»¶æˆ–长内容才切片)
   â”œâ”€â”€ ç”Ÿæˆ Embedding å‘量
   â”œâ”€â”€ å­˜å‚¨åˆ° Pinecone
   â””── æ›´æ–°çŠ¶æ€ä¸º"完成"或"失败"
```
### 6.2 çŸ¥è¯†åº“问答流程
```
1. ç”¨æˆ·é€‰æ‹©çŸ¥è¯†åº“,输入问题
2. å‰ç«¯è°ƒç”¨ /ai/knowledge/chat(流式接口)
3. åŽç«¯å¤„理:
   â”œâ”€â”€ æž„建命名空间:kb-{knowledgeBaseId}
   â”œâ”€â”€ è°ƒç”¨ Embedding æ¨¡åž‹ç”Ÿæˆé—®é¢˜å‘量
   â”œâ”€â”€ ä»Ž Pinecone æ£€ç´¢ç›¸å…³å†…容(minScore=0.7, maxResults=5)
   â”œâ”€â”€ æž„建上下文 Prompt
   â”œâ”€â”€ è°ƒç”¨ LLM ç”Ÿæˆå›žç­”
   â””── æµå¼è¿”回结果
```
---
## ä¸ƒã€æ³¨æ„äº‹é¡¹
1. **Pinecone å‘½åç©ºé—´**:不能使用 `__default__`,必须使用自定义命名空间
2. **文件编码**:自动检测 UTF-8/GBK,避免乱码
3. **切片策略**:
   - æ–‡ä»¶ > 80MB æˆ–内容 > 8000 å­—符时才切片
   - åˆ‡ç‰‡å¤§å° 500 å­—符,重叠 100 å­—符
   - ä¼˜å…ˆåœ¨å¥å­è¾¹ç•Œåˆ‡åˆ†
4. **Embedding é™åˆ¶**:阿里云 DashScope é™åˆ¶å•次输入最大 8192 å­—符
5. **向量删除**:使用 Pinecone åŽŸç”Ÿå®¢æˆ·ç«¯ï¼Œé€šè¿‡ metadata filter åˆ é™¤
6. **异步处理**:向量化使用 `@Async` å¼‚步执行,避免阻塞接口
---
## å…«ã€æ–‡ä»¶æ¸…单
### åŽç«¯æ–‡ä»¶
```
src/main/java/com/ruoyi/
├── approve/
│   â”œâ”€â”€ controller/
│   â”‚   â””── KnowledgeBaseController.java
│   â”œâ”€â”€ pojo/
│   â”‚   â”œâ”€â”€ KnowledgeBase.java
│   â”‚   â””── KnowledgeBaseVector.java
│   â”œâ”€â”€ service/
│   â”‚   â”œâ”€â”€ KnowledgeBaseService.java
│   â”‚   â”œâ”€â”€ KnowledgeBaseVectorService.java
│   â”‚   â””── impl/
│   â”‚       â”œâ”€â”€ KnowledgeBaseServiceImpl.java
│   â”‚       â””── KnowledgeBaseVectorServiceImpl.java
│   â”œâ”€â”€ mapper/
│   â”‚   â”œâ”€â”€ KnowledgeBaseMapper.java
│   â”‚   â””── KnowledgeBaseVectorMapper.java
│   â””── dto/
│       â””── KnowledgeBaseVectorVO.java
└── ai/
    â”œâ”€â”€ config/
    â”‚   â”œâ”€â”€ EmbeddingStoreConfig.java
    â”‚   â””── XiaozhiAgentConfig.java
    â”œâ”€â”€ controller/
    â”‚   â””── KnowledgeChatController.java
    â”œâ”€â”€ assistant/
    â”‚   â””── KnowledgeChatAgent.java
    â”œâ”€â”€ service/
    â”‚   â”œâ”€â”€ KnowledgeRagService.java
    â”‚   â””── impl/
    â”‚       â””── KnowledgeRagServiceImpl.java
    â””── dto/
        â””── KnowledgeChatRequest.java
```
### å‰ç«¯æ–‡ä»¶
```
src/views/knowledge/
├── index.vue              # çŸ¥è¯†åº“列表
├── form.vue               # æ–°å¢ž/编辑表单
├── files.vue              # æ–‡ä»¶ç®¡ç†
└── chat.vue               # çŸ¥è¯†åº“问答
src/api/knowledge.js       # API封装
```
src/api/collaborativeApproval/knowledgeBase.js
@@ -53,3 +53,55 @@
  });
}
// èŽ·å–çŸ¥è¯†åº“æ–‡ä»¶å‘é‡åŒ–çŠ¶æ€
export function getVectorStatus(knowledgeBaseId) {
  return request({
    url: `/knowledgeBase/vector/status/${knowledgeBaseId}`,
    method: "get",
  });
}
// é‡æ–°å‘量化文件
export function reprocessVector(vectorId) {
  return request({
    url: `/knowledgeBase/vector/reprocess/${vectorId}`,
    method: "post",
  });
}
// ä¿å­˜çŸ¥è¯†åº“文件关联
export function saveKnowledgeBaseFiles(data) {
  return request({
    url: "/knowledgeBase/file/save",
    method: "post",
    data: data,
  });
}
// åˆ é™¤çŸ¥è¯†åº“文件
export function deleteKnowledgeBaseFiles(vectorIds) {
  return request({
    url: "/knowledgeBase/file/delete",
    method: "delete",
    data: vectorIds,
  });
}
// èŽ·å–çŸ¥è¯†åº“åˆ—è¡¨(问答用)
export function getKnowledgeBaseListForChat() {
  return request({
    url: "/ai/knowledge/list",
    method: "get",
  });
}
// çŸ¥è¯†åº“问答(流式)
export async function knowledgeChat(data) {
  const response = await fetch("/api/ai/knowledge/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(data),
  });
  return response.body;
}
src/views/collaborativeApproval/knowledgeBase/index.vue
@@ -225,18 +225,141 @@
        </div>
      </div>
    </FormDialog>
    <!-- æ–‡ä»¶ç®¡ç†å¼¹çª— -->
    <FormDialog
      v-model="filesDialogVisible"
      title="文件管理"
      :width="'900px'"
      @close="closeFilesDialog"
      @confirm="closeFilesDialog"
      @cancel="closeFilesDialog"
    >
      <div class="file-manager">
        <!-- æ–‡ä»¶ä¸Šä¼  -->
        <div class="upload-section">
          <el-upload
            :action="uploadUrl"
            :headers="uploadHeaders"
            :on-success="handleUploadSuccess"
            :on-error="handleUploadError"
            :before-upload="beforeUpload"
            multiple
            :show-file-list="false"
            accept=".txt,.md,.docx,.xlsx,.xls,.pdf"
          >
            <el-button type="primary">上传文件</el-button>
          </el-upload>
          <el-button
            type="success"
            @click="saveFiles"
            :disabled="uploadedBlobIds.length === 0"
            :loading="savingFiles"
            style="margin-left: 10px"
          >
            ä¿å­˜æ–‡ä»¶å…³è”
          </el-button>
        </div>
        <!-- æ–‡ä»¶åˆ—表与向量化状态 -->
        <el-table :data="fileList" style="margin-top: 20px" border>
          <el-table-column prop="fileName" label="文件名" show-overflow-tooltip />
          <el-table-column prop="fileType" label="文件类型" width="100" />
          <el-table-column label="向量化状态" width="120">
            <template #default="{ row }">
              <el-tag :type="getStatusType(row.vectorStatus)">
                {{ getStatusText(row.vectorStatus) }}
              </el-tag>
            </template>
          </el-table-column>
          <el-table-column prop="chunkCount" label="切片数" width="100" align="center" />
          <el-table-column prop="createTime" label="上传时间" width="180" />
          <el-table-column label="操作" width="150" align="center">
            <template #default="{ row }">
              <el-button
                v-if="row.vectorStatus === 3"
                type="text"
                @click="reprocessFile(row)"
              >
                é‡æ–°å¤„理
              </el-button>
              <el-button type="text" @click="deleteFile(row)" style="color: #f56c6c">
                åˆ é™¤
              </el-button>
            </template>
          </el-table-column>
        </el-table>
      </div>
    </FormDialog>
    <!-- çŸ¥è¯†åº“问答弹窗 -->
    <FormDialog
      v-model="chatDialogVisible"
      title="知识库问答"
      :width="'800px'"
      @close="closeChatDialog"
      @confirm="closeChatDialog"
      @cancel="closeChatDialog"
    >
      <div class="knowledge-chat">
        <div class="chat-header">
          <el-tag type="success">当前知识库: {{ currentKnowledgeBase?.title }}</el-tag>
        </div>
        <!-- å¯¹è¯åŒºåŸŸ -->
        <div class="chat-messages" ref="chatMessagesRef">
          <div
            v-for="(msg, index) in messages"
            :key="index"
            :class="['message', msg.role]"
          >
            <div class="message-role">{{ msg.role === 'user' ? '我' : 'AI助手' }}</div>
            <div class="message-content">{{ msg.content }}</div>
          </div>
          <div v-if="chatLoading" class="message assistant">
            <div class="message-role">AI助手</div>
            <div class="message-content typing">正在思考中...</div>
          </div>
        </div>
        <!-- è¾“入框 -->
        <div class="chat-input">
          <el-input
            v-model="inputQuestion"
            placeholder="请输入问题,按回车发送"
            @keyup.enter="sendMessage"
            :disabled="chatLoading"
          >
            <template #append>
              <el-button @click="sendMessage" :loading="chatLoading">发送</el-button>
            </template>
          </el-input>
        </div>
      </div>
    </FormDialog>
  </div>
</template>
<script setup>
import { Search } from "@element-plus/icons-vue";
import { onMounted, ref, reactive, toRefs, getCurrentInstance, computed, watch } from "vue";
import { onMounted, ref, reactive, toRefs, getCurrentInstance, computed, watch, nextTick } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import PIMTable from "@/components/PIMTable/PIMTable.vue";
import FormDialog from '@/components/Dialog/FormDialog.vue';
import { listKnowledgeBase, delKnowledgeBase,addKnowledgeBase,updateKnowledgeBase } from "@/api/collaborativeApproval/knowledgeBase.js";
import {
  listKnowledgeBase,
  delKnowledgeBase,
  addKnowledgeBase,
  updateKnowledgeBase,
  getVectorStatus,
  reprocessVector,
  saveKnowledgeBaseFiles,
  deleteKnowledgeBaseFiles,
  knowledgeChat
} from "@/api/collaborativeApproval/knowledgeBase.js";
import useUserStore from '@/store/modules/user';
import { userListNoPageByTenantId } from '@/api/system/user.js';
import { getToken } from "@/utils/auth";
// è¡¨å•验证规则
const rules = {
@@ -283,7 +406,17 @@
  dialogTitle: "",
  dialogType: "add",
  viewDialogVisible: false,
  currentKnowledge: {}
  currentKnowledge: {},
  filesDialogVisible: false,
  currentKnowledgeBase: null,
  fileList: [],
  uploadedBlobIds: [],
  savingFiles: false,
  chatDialogVisible: false,
  messages: [],
  inputQuestion: "",
  chatLoading: false,
  memoryId: ""
});
const {
@@ -297,7 +430,17 @@
  dialogTitle,
  dialogType,
  viewDialogVisible,
  currentKnowledge
  currentKnowledge,
  filesDialogVisible,
  currentKnowledgeBase,
  fileList,
  uploadedBlobIds,
  savingFiles,
  chatDialogVisible,
  messages,
  inputQuestion,
  chatLoading,
  memoryId
} = toRefs(data);
// è¡¨å•引用
@@ -305,6 +448,12 @@
// ç”¨æˆ·ç›¸å…³
const userStore = useUserStore();
const userList = ref([]);
// èŠå¤©æ¶ˆæ¯å®¹å™¨å¼•用
const chatMessagesRef = ref();
// æ–‡ä»¶ä¸Šä¼ ç›¸å…³
const uploadUrl = import.meta.env.VITE_APP_BASE_API + "/common/upload";
const uploadHeaders = { Authorization: "Bearer " + getToken() };
// è¡¨æ ¼åˆ—配置
const tableColumn = ref([
@@ -352,6 +501,18 @@
    }
  },
  {
    label: "文件数量",
    prop: "fileCount",
    width: 100,
    align: "center"
  },
  {
    label: "切片数量",
    prop: "totalChunkCount",
    width: 100,
    align: "center"
  },
  {
    label: "使用次数",
    prop: "usageCount",
    width: 100,
@@ -379,6 +540,20 @@
        type: "text",
        clickFun: (row) => {
          openForm("edit", row);
        }
      },
      {
        name: "文件",
        type: "text",
        clickFun: (row) => {
          openFilesDialog(row);
        }
      },
      {
        name: "问答",
        type: "text",
        clickFun: (row) => {
          openChatDialog(row);
        }
      },
      {
@@ -680,6 +855,260 @@
const handleExport = () => {
  proxy.download('/knowledgeBase/export', { ...searchForm.value }, '知识库.xlsx')
}
// ============ æ–‡ä»¶ç®¡ç†ç›¸å…³ ============
// æ‰“开文件管理弹窗
const openFilesDialog = (row) => {
  currentKnowledgeBase.value = row;
  filesDialogVisible.value = true;
  loadFileList();
};
// åŠ è½½æ–‡ä»¶åˆ—è¡¨
const loadFileList = async () => {
  if (!currentKnowledgeBase.value?.id) return;
  try {
    const res = await getVectorStatus(currentKnowledgeBase.value.id);
    fileList.value = res.data || [];
  } catch (error) {
    console.error("加载文件列表失败:", error);
    ElMessage.error("加载文件列表失败");
  }
};
// ä¸Šä¼ å‰æ ¡éªŒ
const beforeUpload = (file) => {
  const allowedTypes = ['.txt', '.md', '.docx', '.xlsx', '.xls', '.pdf'];
  const fileName = file.name.toLowerCase();
  const isAllowed = allowedTypes.some(type => fileName.endsWith(type));
  if (!isAllowed) {
    ElMessage.error('只支持 txt、md、docx、xlsx、xls、pdf æ ¼å¼çš„æ–‡ä»¶');
    return false;
  }
  const isLt50M = file.size / 1024 / 1024 < 50;
  if (!isLt50M) {
    ElMessage.error('文件大小不能超过 50MB');
    return false;
  }
  return true;
};
// ä¸Šä¼ æˆåŠŸ
const handleUploadSuccess = (response, file) => {
  if (response.code === 200) {
    uploadedBlobIds.value.push(response.data.id);
    ElMessage.success(`文件 ${file.name} ä¸Šä¼ æˆåŠŸ`);
  } else {
    ElMessage.error(response.msg || "上传失败");
  }
};
// ä¸Šä¼ å¤±è´¥
const handleUploadError = (error, file) => {
  ElMessage.error(`文件 ${file.name} ä¸Šä¼ å¤±è´¥`);
};
// ä¿å­˜æ–‡ä»¶å…³è”
const saveFiles = async () => {
  if (uploadedBlobIds.value.length === 0) {
    ElMessage.warning("请先上传文件");
    return;
  }
  savingFiles.value = true;
  try {
    await saveKnowledgeBaseFiles({
      knowledgeBaseId: currentKnowledgeBase.value.id,
      storageBlobIds: uploadedBlobIds.value
    });
    ElMessage.success("文件关联保存成功,正在后台处理向量化");
    uploadedBlobIds.value = [];
    // å»¶è¿Ÿåˆ·æ–°æ–‡ä»¶åˆ—表,给后台处理时间
    setTimeout(() => {
      loadFileList();
    }, 1000);
  } catch (error) {
    console.error("保存文件关联失败:", error);
    ElMessage.error("保存文件关联失败");
  } finally {
    savingFiles.value = false;
  }
};
// é‡æ–°å¤„理向量化的文件
const reprocessFile = async (row) => {
  try {
    await reprocessVector(row.id);
    ElMessage.success("已重新提交向量化任务");
    // å»¶è¿Ÿåˆ·æ–°
    setTimeout(() => {
      loadFileList();
    }, 1000);
  } catch (error) {
    console.error("重新处理失败:", error);
    ElMessage.error("重新处理失败");
  }
};
// åˆ é™¤æ–‡ä»¶
const deleteFile = async (row) => {
  try {
    await ElMessageBox.confirm(
      "确定要删除该文件吗?删除后将无法恢复向量数据",
      "删除确认",
      {
        confirmButtonText: "确定",
        cancelButtonText: "取消",
        type: "warning"
      }
    );
    await deleteKnowledgeBaseFiles([row.id]);
    ElMessage.success("删除成功");
    loadFileList();
  } catch (error) {
    if (error !== 'cancel') {
      console.error("删除文件失败:", error);
      ElMessage.error("删除文件失败");
    }
  }
};
// çŠ¶æ€æ–‡æœ¬æ˜ å°„
const getStatusText = (status) => {
  const map = {
    0: '待处理',
    1: '处理中',
    2: '已完成',
    3: '失败'
  };
  return map[status] || '未知';
};
// çŠ¶æ€æ ‡ç­¾ç±»åž‹æ˜ å°„
const getStatusType = (status) => {
  const map = {
    0: 'info',
    1: 'warning',
    2: 'success',
    3: 'danger'
  };
  return map[status] || 'info';
};
// å…³é—­æ–‡ä»¶ç®¡ç†å¼¹çª—
const closeFilesDialog = () => {
  filesDialogVisible.value = false;
  currentKnowledgeBase.value = null;
  fileList.value = [];
  uploadedBlobIds.value = [];
  getList(); // åˆ·æ–°ä¸»åˆ—表,更新文件数量
};
// ============ çŸ¥è¯†åº“问答相关 ============
// æ‰“开问答弹窗
const openChatDialog = (row) => {
  currentKnowledgeBase.value = row;
  chatDialogVisible.value = true;
  memoryId.value = crypto.randomUUID();
  messages.value = [];
  inputQuestion.value = "";
};
// å‘送消息
const sendMessage = async () => {
  if (!inputQuestion.value.trim()) {
    ElMessage.warning("请输入问题");
    return;
  }
  const question = inputQuestion.value.trim();
  // æ·»åŠ ç”¨æˆ·æ¶ˆæ¯
  messages.value.push({
    role: 'user',
    content: question
  });
  inputQuestion.value = "";
  chatLoading.value = true;
  // æ»šåŠ¨åˆ°åº•éƒ¨
  await nextTick();
  scrollToBottom();
  try {
    // æµå¼è¯·æ±‚
    const response = await fetch('/api/ai/knowledge/chat', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer ' + getToken()
      },
      body: JSON.stringify({
        knowledgeBaseId: currentKnowledgeBase.value.id,
        memoryId: memoryId.value,
        question: question
      })
    });
    if (!response.ok) {
      throw new Error('请求失败');
    }
    // å¤„理SSE流式响应
    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let aiContent = '';
    messages.value.push({ role: 'assistant', content: '' });
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      const text = decoder.decode(value);
      aiContent += text;
      messages.value[messages.value.length - 1].content = aiContent;
      // æ»šåŠ¨åˆ°åº•éƒ¨
      await nextTick();
      scrollToBottom();
    }
  } catch (error) {
    console.error("问答请求失败:", error);
    ElMessage.error("问答请求失败,请稍后重试");
    messages.value.push({
      role: 'assistant',
      content: '抱歉,发生了错误,请稍后重试'
    });
  } finally {
    chatLoading.value = false;
  }
};
// æ»šåŠ¨åˆ°åº•éƒ¨
const scrollToBottom = () => {
  if (chatMessagesRef.value) {
    chatMessagesRef.value.scrollTop = chatMessagesRef.value.scrollHeight;
  }
};
// å…³é—­é—®ç­”弹窗
const closeChatDialog = () => {
  chatDialogVisible.value = false;
  currentKnowledgeBase.value = null;
  messages.value = [];
  inputQuestion.value = "";
};
</script>
<style scoped>
@@ -755,4 +1184,92 @@
  font-size: 14px;
  color: #909399;
}
/* æ–‡ä»¶ç®¡ç†æ ·å¼ */
.file-manager {
  padding: 20px 0;
}
.upload-section {
  display: flex;
  align-items: center;
}
/* çŸ¥è¯†åº“问答样式 */
.knowledge-chat {
  display: flex;
  flex-direction: column;
  height: 500px;
}
.chat-header {
  margin-bottom: 16px;
}
.chat-messages {
  flex: 1;
  overflow-y: auto;
  padding: 16px;
  background: #f5f7fa;
  border-radius: 8px;
  margin-bottom: 16px;
}
.message {
  margin-bottom: 16px;
  max-width: 80%;
}
.message.user {
  margin-left: auto;
  text-align: right;
}
.message.assistant {
  margin-right: auto;
}
.message-role {
  font-size: 12px;
  color: #909399;
  margin-bottom: 4px;
}
.message-content {
  display: inline-block;
  padding: 10px 14px;
  border-radius: 8px;
  line-height: 1.6;
  word-wrap: break-word;
  white-space: pre-wrap;
}
.message.user .message-content {
  background: #409eff;
  color: white;
}
.message.assistant .message-content {
  background: white;
  color: #303133;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.typing {
  animation: typing 1.5s infinite;
}
@keyframes typing {
  0%, 50%, 100% {
    opacity: 1;
  }
  25%, 75% {
    opacity: 0.5;
  }
}
.chat-input {
  margin-top: auto;
}
</style>