Browse Source

Merge branch 'refs/heads/AI链路节点控制'

master
wanghanlin 2 weeks ago
parent
commit
595c481d98
  1. 395
      AGENTS.md
  2. 23
      CLAUDE.md
  3. 26
      frontend/src/api/pipeline-toggle.ts
  4. 2
      frontend/src/stores/navigation.ts
  5. 353
      frontend/src/views/PipelineFlow.vue
  6. 5
      frontend/src/views/SystemConfigManager.vue
  7. 33
      src/main/java/com/wok/supportbot/app/ChatPipeline.java
  8. 12
      src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
  9. 82
      src/main/java/com/wok/supportbot/controller/PipelineToggleController.java
  10. 17
      src/main/java/com/wok/supportbot/rag/RagPipeline.java
  11. 8
      src/main/java/com/wok/supportbot/service/FaqMatchEngine.java
  12. 214
      src/main/java/com/wok/supportbot/service/PipelineToggleService.java
  13. 19
      src/main/resources/init-database.sql
  14. 2
      src/main/resources/static/sdk/test.html

395
AGENTS.md

@ -0,0 +1,395 @@
# AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## 项目概述
AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支持 RAG 知识库检索、多轮对话、结构化数据提取、知识库全生命周期管理。
## 构建与运行
```bash
# 编译
./mvnw compile
# 运行(端口 9090)
./mvnw spring-boot:run
# 运行测试
./mvnw test
# 运行单个测试类
./mvnw test -Dtest=SupportBotApplicationTests
# 运行单个测试方法
./mvnw test -Dtest=SupportBotApplicationTests#testRag
```
**前提条件**: PostgreSQL 12+ 需运行且安装 PGVector 扩展,数据库 `support_bot` 需存在。`knowledge_category`、`knowledge_document`、`ai_model_config` 等表由 `DatabaseInitConfig` 自动创建,无需手动建表。
**测试说明**: 所有测试均为集成测试(`@SpringBootTest`),需要运行中的 PostgreSQL 和有效的 DashScope API Key。测试类:`SupportBotApplicationTests`(对话/RAG)、`PgVectorVectorStoreConfigTest`(向量存储)、`QueryTransformerTests`(查询重写策略)。无单元测试。
**访问地址**: 前端管理页面 `http://localhost:9090/index.html`,API 文档 `http://localhost:9090/doc.html`(Knife4j)
## 核心架构决策
### 手动配置 PgVectorStore(未引入自动配置)
`PgVectorStoreConfig` 手动配置 PgVectorStore Bean(标记 `@Primary`),`SupportBotApplication` 是裸 `@SpringBootApplication`、无任何 exclude —— 因为项目依赖的是**非 starter** 的 `spring-ai-pgvector-store`(只有实现类,不含 `spring-ai-autoconfigure-vector-store-pgvector`),classpath 上本就没有 `PgVectorStoreAutoConfiguration`。另有一个 `InMemoryVectorStoreConfig` 作为开发备选。
### Spring AI 集成模式
- **ChatClient Builder**: 所有对话通过 `ChatClient.builder(chatModelFactory.getChatModel("CHAT"))` 构建,ChatModel 由 `ChatModelFactory` 按 DB 活跃配置动态创建
- **ChatClient 构建**: 所有权在 `AssistantApp`(`getChatClient`),按 `appType` + `allowedMcpTools` 缓存不同实例
- **Advisor 链**: `ContentSafetyAdvisor`(最外层,`HIGHEST_PRECEDENCE`)→ `MessageChatMemoryAdvisor`(记忆)→ `MyLoggerAdvisor`(日志)
- **SSE 流式**: 仅保留 `Flux<String>` 形态;废弃的 `Flux<ServerSentEvent>` 和 `SseEmitter` 已移除
### ChatMemory 持久化
当前使用 `DatabaseChatMemory`(PostgreSQL 持久化),无文件型 ChatMemory(早期的 `FileBasedChatMemory` 已删除,Kryo 依赖一并移除)。
### 统一对话管道(重构后)
对话管道由 `ChatPipeline`(编排层)+ `RagPipeline`(RAG 检索层)+ `AssistantApp`(执行层)组成:
```
用户请求
→ 鉴权/角色解析(Controller)
→ ChatPipeline.buildRequest(ChatContext)
→ IntentRouter 意图路由(CHITCHAT/FAQ/RAG) ← 可关: pipeline_intent_llm_enabled
→ RagPipeline.retrieve(FAQ 优先 → 查询重写 → 统一检索)
· FAQ 三级匹配(第三级语义匹配) ← 可关: pipeline_faq_semantic_enabled
· 查询重写 REWRITE/TRANSLATION/COMPRESSION ← 可关: pipeline_rewrite_enabled
· MULTI_QUERY 多路扩展 ← 可关: pipeline_multiquery_enabled
→ 组装 finalMessage + finalSystemPrompt + 资料块
→ AssistantApp.chat / chatStream(构建 ChatClientRequestSpec → call/stream)
```
### AI 执行链节点开关(性能开关)
管理后台「系统设置 → AI 执行链」页面(`/#/settings/pipeline-flow`,admin)提供链路节点的启停开关。这些节点全部位于**首 token 产出之前**,且默认 `MULTI_QUERY` 策略下会串行累加多次跨网络调用,是「AI 回复慢」的主因。
| 配置键(`system_config`) | 默认 | 关闭后省下 | 代价 |
|---|---|---|---|
| `pipeline_intent_llm_enabled` | `true` | 1 次 LLM | 不再按意图跳过知识库检索(寒暄词快速路径保留,零开销) |
| `pipeline_rewrite_enabled` | `true` | 1 次 LLM | 忽略请求携带的重写策略,直接用原文检索 |
| `pipeline_multiquery_enabled` | `true` | 3 路 embedding | `MULTI_QUERY` 降级为单路原文检索(其他策略不受影响) |
| `pipeline_faq_semantic_enabled` | `true` | 0~2 次 embedding | FAQ 只剩精确匹配与关键词匹配,近义问法匹配不到 |
- **实现**:`PipelineToggleService` 持有 `volatile` 不可变快照(`@EventListener(ApplicationReadyEvent)` 载入,写时整体重建,与 `ContentSafetyService` 的 DFA 树同一范式)。之所以不复用 `SystemConfigService.getValueByKey`,是因为它**无缓存、每次直查数据库**,而这些开关在每请求的 hot path 上要判定 3~4 次。
- **生效**:后台保存后立刻重建快照,下一次对话即生效,无需重启、无需清任何缓存。代价是**直接改数据库不生效**(请走后台页面)。
- **接口**:`GET/PUT /pipeline-toggle`(`PipelineToggleController`,admin)。PUT 为增量更新,未提交的 key 保持原值。
- **前端**:`PipelineFlow.vue` 顶部开关面板 + 三个预设(极速/均衡/精准),被关闭的节点在 Mermaid 图上**置灰 + 虚线**,能力降级(如仅关 FAQ 语义匹配)的节点保持原色只加虚线。
- **注意**:菜单项已加 `roles: ['admin']`;非管理员直接访问 URL 时开关面板隐藏、流程图仍可查看(接口 403 被静默降级处理)。
- **ChatPipeline**: 纯编排,不持有 ChatClient;产出 `ChatRequest` 决策对象
- **RagPipeline**: 统一 RAG 检索,所有策略(含 MULTI_QUERY)均走"手动检索 + 资料块注入 system prompt"模式,不再使用 `RetrievalAugmentationAdvisor` 的 query augmenter
- **RAG 查询重写策略**: 由 `RagPipeline` 统一路由,`AssistantApp` 等旧方法已移除
- **IntentRouter**: 已在 ChatPipeline 接入,`AiController.shouldBypassKnowledgeRetrieval` 已移除
- **分类过滤**: 统一由 `CategoryFilter` 工具类处理(`parse`/`normalize`/`buildExpression`)
- **AssistantApp 入口**: `chat(ChatContext)` / `chatStream(ChatContext)` / `retrieveSources(ChatContext)`,旧方法(`doChat*`、`doChatWithRag*`)已移除
- **Open API**: `OpenApiController` 已接入 `ChatPipeline`,补齐角色/RAG/FAQ/MCP/分类隔离能力
### 文档处理管道
`DocumentService.uploadDocument()` 统一流程:文档提取(官方 `org.springframework.ai.reader.tika.TikaDocumentReader` / `MarkdownDocumentReader` / `JsonReader`)→ `OverlapTokenTextSplitter` 分块 → 为每块写 metadata → 按批向量化(默认 50 块/批,配置项 `knowledge.vector.batch-size`)`pgVectorVectorStore.add(batch)` 入库。每个分块的 metadata 注入 `documentId`、`chunkIndex`、`sourceName`、`title`、`categoryId`、`enabled` 关联 `knowledge_document` 表。
**向量化加固**(`DocumentProcessingService`):逐批 try-catch 隔离,失败批只记录缺失区间后继续,已入库块保留;`chunk_count` 记实际入库块数,`error_message` 聚合"已入库 x/y 块 + 缺失区间 + 原因";文档级失败自动整体重试至多 2 次(仅瞬时/限流/超时类错误,4xx 不空转),重试前先清残留向量再重建。**无逐块 AI 关键词提取环节**(`MyKeywordEnricher` 已移除,其产出 `excerpt_keywords` 全库无检索消费点)。
**`content` 列存全文 + 重新处理的数据源规则**(`DocumentService`,修复了「重跑丢内容」缺陷):
| 项 | 规则 |
|---|---|
| 存储 | `knowledge_document.content` 存**原文全文**(不再截断),并写 `extra_config.contentComplete = true` 作为完整性标记 |
| 序列化 | 实体 `content` 标 `@JsonIgnore`,**任何接口都不返回全文**(否则列表接口每行都带整篇正文)。仅文档详情接口(`DocumentController.toDetailMap`)返回 2000 字预览(键名仍是 `content`)+ `contentTruncated` 布尔标志 |
| 重新处理的数据源 | ① 有 `contentComplete` 标记 → 用库内全文(语义最忠实,如 JSON 的 fields/pointer 模式)② 历史遗留文档(无标记,content 是 2000 字截断预览)→ 按 `fileType` 从原始文件重解析,成功后**回填全文并打标记**(此后不再依赖文件)③ 两者都不可用 → **明确报错拒绝** |
**踩坑记录**:`reprocessDocument` 原先直接 `simpleStringDocumentReader.read(doc.getContent())`,而 `content` 是 2000 字截断预览,且 `DocumentProcessingService` 以 `cleanBeforeFirstAttempt=true` 运行(**先删光旧向量**)—— 对超过 2000 字符的文档重跑会永久丢失其余内容。修复前**不要**对存量文档执行 `POST /document/batch/reprocess`。判断遗留文档:`extra_config->>'contentComplete' IS NULL`;其中 `length(content) = 2000` 的才是真正被截断的。
**JSON 解析模式的已知降级**:JSON 的 basic/fields/pointer 三种模式上传时未持久化,历史 JSON 文档从文件重解析只能按 basic 还原(不丢数据,仅抽取口径可能变化,日志有 WARN)。新文档走库内全文分支,语义不变。
## 关键配置
- `application.yml` 含 DashScope API Key,已被 `.gitignore` 排除
- **模型名称、温度、最大 Token 等参数已全部迁移到前端「AI 大模型配置管理」页面**,通过 `ai_model_config` 表管理,不再在 yml 中配置(yml 仅保留 `api-key`)
- MyBatis Plus 逻辑删除字段: `isDelete`,主键策略: `assign_id`(雪花算法)
- **雪花 ID 精度问题**: `KnowledgeDocument.id`、`categoryId` 和 `KnowledgeCategory.id`、`parentId` 已添加 `@JsonSerialize(using = ToStringSerializer.class)`,序列化为字符串避免前端 JS 精度丢失。新增 Long ID 字段时务必加上此注解
- **JWT 时间配置统一用 Duration 可读格式**: `jwt.expiration` / `jwt.refresh-expiration` / `jwt.sdk-expiration` 均绑定为 `java.time.Duration`(`@Value` 直绑),值写成 `15m` / `8h` / `900s` 等可读形式,**纯数字仍按毫秒解析**(向后兼容旧配置)。项目同类先例:`storage.sftp.connect-timeout: 10s`(`StorageProperties`)。新增时间类配置项时照此办理,不要写裸毫秒。
- **管理后台会话有效期**: refresh token 有效期由 `jwt.refresh-expiration` **唯一**驱动(`application.yml`,当前 `8h`)。该配置**全环境统一生效**,不在 `application-dev/prod.yml` 中覆盖;`JwtTokenProvider` 构造器上的 `:8h` 仅为代码级兜底。**refresh Cookie 的 Max-Age 必须经 `jwtTokenProvider.getRefreshExpirationSeconds()` 取值,禁止硬编码**(`AuthController` 登录与刷新两处),否则「改配置不改 Cookie」会造成有效期漂移。
- 语义为**滚动续期**:access token 15 分钟过期后前端静默调 `/auth/refresh`,服务端重签并重置窗口。因此**页面持续活跃的用户不会掉线**,只有闲置超过该时长(含关闭页面超过该时长后重开,refresh Cookie 已过期)才需重新登录。
- 相关前端链路:`App.vue onMounted` 启动自检(`/auth/me` → 失败则 `tryRefreshToken` → **再校验一次 `/auth/me`**,仍失败即登出)+ `api/request.ts` 的 401 自动刷新重试(single-flight + 重试上限 `_retry`)。
- **SDK Token 有效期**: `jwt.sdk-expiration`(当前 `2h`)作为 SDK 换 Token 接口(`POST /open-api/auth/token`,SDK 版 `controller/AuthController`)**未指定 ttl 时的默认值**,由 `SdkJwtTokenProvider.getDefaultExpirationMillis()` 提供。有效期边界 `[5min, 24h]` 只在 `SdkJwtTokenProvider` 的 `MIN_EXPIRATION` / `MAX_EXPIRATION` 两处常量定义,控制器通过 `clampExpirationMillis()` 复用,**不得在控制器内重复写毫秒魔数**。注意 SDK 对外的 `ttl` 请求参数与 `expiresIn` 响应字段单位是**秒**(见 `SDK-INTEGRATION.md`),与内部毫秒配置是两套单位,勿混淆。
- PostgreSQL JSONB 字段使用自定义 `PostgresJsonTypeHandler`(期望 JSON 对象 `'{}'`,非数组 `'[]'`)
- **向量维度**: 由 `knowledge.vector.dimension` 配置(默认 1024)。修改后需执行 `DROP TABLE IF EXISTS vector_store CASCADE` 重建向量表,并重新上传知识库文档。距离类型: COSINE_DISTANCE,索引: HNSW
- **分块配置**: `knowledge.chunk.*` 配置项(`ChunkConfig`),默认 chunkSize=200, overlap=100, minChunkSizeChars=10, maxNumChunks=5000, keepSeparator=true
- **分块器 `OverlapTokenTextSplitter`**: Spring AI 的 `TokenTextSplitter` **不支持 overlap**(构造器与 Builder 均无该形参,社区 PR #4054 不向 1.x 回迁)。项目继承标准 `TextSplitter` 基类自研了 `OverlapTokenTextSplitter`(`document/transform/`),复刻 `TokenTextSplitter` 全部切分语义,仅把前进步长由 `chunkSize` 改为 `chunkSize - overlap`,并用 jtokkit(CL100K_BASE,与上游同库)做 token 编码。
- **前进步长必须有下限(`minAdvance()`)**: 标点截断会缩短本块消耗的 token 数,而步长 = `消耗量 - overlap`;若截断点靠前,步长会被压到 1 个 token,分块数成倍膨胀(实测 `chunkSize=60/overlap=30` 时 349 块 vs 修复后 44 块)。因此**仅当截断后仍能前进至少 `(chunkSize - overlap) / 2` 个 token 时才采用该截断**,否则宁可切断句子。`overlap=0` 时该下限取 1,与标准 `TokenTextSplitter` 行为**逐块一致**(已有对比验证)。
- **历史缺陷已修复**: 旧 `MyTokenTextSplitter` 因形参错位,把 `overlap` 传进了 `minChunkSizeChars` 位,导致 `knowledge.chunk.overlap` 从未生效。修复后重叠真正生效,**分块边界与块数会变化**,存量文档需重新分块+向量化(`POST /document/batch/reprocess`)。
- `minChunkLengthToEmbed` 固定为 10(`DocumentProcessingService.MIN_CHUNK_LENGTH_TO_EMBED`,ChunkConfig 无对应配置项)
- **上传校验**: `ALLOWED_EXTENSIONS` 白名单 + 50MB 大小限制(`spring.servlet.multipart` 配置),前后端双重校验
- **文档去重**: `KnowledgeDocument.contentHash` 字段(SHA-256),上传时自动计算并查重
- **数据库自动初始化**: `DatabaseInitConfig` 在启动时检查并创建 `knowledge_category`/`knowledge_document`/`ai_model_config` 等表,对已存在的 `knowledge_document` 表会自动补加 `content_hash` 列。注意 `knowledge-base.sql` 脚本为早期版本,缺少此列,实际以 `DatabaseInitConfig` 为准
### 模型配置管理
- **ai_model_config 表**: 存储大模型配置,支持多套配置按 App 类型(CHAT / EMBEDDING / RAG_REWRITE)独立管理。**所有模型参数(名称、温度、最大Token、API Key、Base URL 等)全部由此表管理,不再依赖 application.yml**
- **激活互斥**: 同一 App 类型只能有一个 `is_active=true` 的配置,激活操作由 Service 层 `@Transactional` 保证
- **启动 seed**: 首次启动时使用硬编码默认值写入 DB(`qwen-turbo` / `text-embedding-v2`),用户可在前端修改。**DashScope 自动配置已禁用**(`spring.ai.dashscope.enabled=false`),api-key 可选——yml 与 DB 均未配置 api-key 不影响启动,实际调用 AI 功能前在前端「AI 大模型配置管理」页面配置即可
- **启动校验**: `ModelConfigLoader` 在应用就绪后检查 DB 中每种 App 类型是否有活跃配置,并对 DashScope 提供商比较 DB 与 yml 的 API Key 一致性
- **API Key 脱敏**: 前端展示时只显示前 4 位 + `****` + 后 4 位
- **ChatModel 运行时切换**: 通过 `ChatModelFactory` 按 DB 活跃配置动态创建/缓存 ChatModel(包括 DashScope,不再复用 yml 自动配置的 Bean),配置变更时立即生效(无需重启)。**OpenAI 兼容路径使用自定义 `completionsPath`**(与 EmbeddingModelFactory 的 embeddingsPath 对应),baseUrl 已含版本段的厂商(moonshot `/v1`、volcengine `/api/v3`、zhipu `/api/paas/v4`)设为 `/chat/completions`,其余使用默认 `/v1/chat/completions`
- **EmbeddingModel 运行时切换**: 通过 `EmbeddingModelFactory` + `DynamicEmbeddingModel` 代理,按 DB 活跃配置动态创建/缓存 EmbeddingModel,`PgVectorStoreConfig` 和 `InMemoryVectorStoreConfig` 注入 `DynamicEmbeddingModel`,向量化模型配置变更后无需重启即可生效
- **多提供商支持**: DashScope(通义千问)+ OpenAI 兼容提供商(DeepSeek / 豆包 / Kimi / 智谱 / OpenAI),ChatModel 和 EmbeddingModel 均通过对应 API 手动构建
- **缓存刷新**: 模型配置增删改激活时 Controller 自动调用 `ChatModelFactory.clearCache()` + `EmbeddingModelFactory.clearCache()` + `AssistantApp.clearCache()`;MCP Server 增删改/启停/全量刷新时 `McpServerConfigController` 亦会调用 `AssistantApp.clearCache()`(避免继续使用旧的 MCP 工具集)
- **动态模型列表获取**: `ModelListFetcher` 通过调用各提供商的 `/v1/models` 兼容端点(DashScope 用 `/compatible-mode/v1/models`),动态获取可用模型列表。前端填入 API Key + API 地址后,点击「获取模型」即可自动填充模型名称下拉列表(`<datalist>` 支持搜索选择 + 自定义输入)
### 依赖版本
- Spring Boot: `3.5.8`(Spring AI Alibaba 1.1.2.2 官方基线)
- Spring AI BOM: `1.1.2`(properties 中的 `spring-ai.version`),统一管理所有 `org.springframework.ai` 依赖版本
- `spring-ai-alibaba-starter-dashscope`: `1.1.2.2`。**必须显式写版本号** —— `spring-ai-alibaba-bom` 并不管理该模块(它只管理 agentscope / graph / studio 等 8 个模块),因此项目**不导入** SAA BOM
- `spring-ai-openai`: BOM 管理(OpenAI 兼容提供商支持)。**刻意使用非 starter 坐标**:项目手动构建 ChatModel/EmbeddingModel,用 starter 会额外引入 `-model-openai` / `-chat-client` / `-chat-memory` 三个自动配置,可能生成与手写工厂、`DatabaseChatMemory` 冲突的 Bean。`spring-ai-pgvector-store` 同理
- `spring-ai-alibaba-starter` (M6.1) 已移除,不再使用
- **MCP SDK 必须锁 0.18.3**(`pom.xml` 中排除传递依赖 + 显式声明):Spring AI 1.1.2 的 `spring-ai-mcp` 仍锁 MCP SDK **0.17.0**,而 0.17.0 的 JsonMapper 包名是 `io.modelcontextprotocol.json.jackson`(无 `jackson2`),项目 `McpClientManager` 用的是 0.18.0 起才有的 `...json.jackson2.JacksonMcpJsonMapper`。`spring-ai-mcp` 只引用 `McpSyncClient/McpAsyncClient/McpClient/McpTransportContext/McpSchema/Assert`,这些类 0.18.3 均存在,故覆盖安全
- `com.github.victools:jsonschema-generator`: 不再显式声明,由 `spring-ai-model:1.1.2` 传递引入(4.38.0,结构化输出 `BeanOutputConverter` 依赖它)
- `com.knuddels:jtokkit:1.1.0`: 显式声明,`OverlapTokenTextSplitter` 直接使用
- `com.esotericsoftware:kryo` 与 `org.springframework.security:spring-security-oauth2-client` 均已移除(唯一使用者/唯一用途已消失;后者曾是 Spring AI 1.0.x `ToolCallingAutoConfiguration` 的 ClassNotFound workaround,1.1.2 已无该耦合)
### EmbeddingModel 架构
- **EmbeddingConfigFixer**:`ApplicationListener<ApplicationReadyEvent>`,启动时检查 EMBEDDING 配置合理性、**校验 EmbeddingModel 实际维度与配置维度是否一致**,不一致时 WARN 告警并给出修复步骤。**不再强制修正非 DashScope 配置**,尊重用户在 DB 中配置的提供商和模型
- **EmbeddingModelFactory**:按 DB 活跃配置动态创建/缓存 EmbeddingModel,**支持多种提供商**:
- DashScope(通义千问):`DashScopeEmbeddingModel`
- OpenAI 兼容提供商(DeepSeek / Kimi / 智谱 / OpenAI):通过 `OpenAiEmbeddingModel` + 自定义 baseUrl + `embeddingsPath` 创建
- **豆包文本模型**(volcengine + 非 vision):通过 `OpenAiEmbeddingModel`,embeddingsPath=`/embeddings`
- **豆包多模态模型**(volcengine + `*vision*`):通过 `VolcengineMultimodalEmbeddingModel`,手写 `RestClient` 直调 `/embeddings/multimodal`,适配 `{type, text}` 格式
- **各厂商 embeddingsPath 映射**:
| 提供商 | embeddingsPath | 实现类 |
|--------|---------------|--------|
| dashscope | — | DashScopeEmbeddingModel |
| volcengine (vision) | `/embeddings/multimodal` | VolcengineMultimodalEmbeddingModel |
| volcengine (text) | `/embeddings` | OpenAiEmbeddingModel |
| moonshot | `/embeddings` | OpenAiEmbeddingModel |
| zhipu | `/embeddings` | OpenAiEmbeddingModel |
| deepseek | `/v1/embeddings` | OpenAiEmbeddingModel |
| openai | `/v1/embeddings` | OpenAiEmbeddingModel |
- 注意:各提供商的 embedding 端点兼容性由用户自行验证,向量维度需与 PgVectorStore 的 `dimensions` 一致
- **DynamicEmbeddingModel**:代理类实现 `EmbeddingModel` 接口,每次调用委托给 Factory,使 VectorStore 无需重建即可热切换
- **前端**:`ModelConfigManager.js` 对 EMBEDDING 类型不再限制 provider,可自由选择任意提供商;EMBEDDING 类型弹窗增加「向量维度」输入框(写入 `extraConfig.dimensions`)
## 前端架构
- **技术栈**: Vue 3.5 + TypeScript + Vite + TDesign Vue Next(`tdesign-vue-next`)+ Pinia + Vue Router(hash 模式)
- **源码目录**: `frontend/`(不再手写 `static/js`;`static` 现在是 `npm run build` 的产物,由 `frontend/cp-to-static.mjs` 拷贝进去)
- **入口**: `frontend/src/main.ts` → `App.vue`(登录页 / `MainLayout` 切换)→ `router/index.ts`
- **组件化**: 每个功能页面一个 `.vue` 文件(`frontend/src/views/`),TDesign 组件通过 `unplugin-vue-components` 自动按需引入(模板直接写 `<t-xxx>`)
- **状态管理**: Pinia(`frontend/src/stores/`),路由守卫在 `router/index.ts` 中做页面数据预加载
- **API 封装**: `frontend/src/api/*.ts` 统一封装(axios 实例 + 拦截器见 `request.ts`),API 基址为空字符串(同源部署)
- **SSE 流式**: `frontend/src/utils/sse.ts` 中 `readSSEStream*()` 统一处理 SSE 接口
- **开发运行**: `cd frontend && npm run dev`(Vite 代理到 9090);生产 `npm run build` 后拷贝进 `static`
- **UI 开发准则**: 页面 UI 开发统一遵循 `frontend/UI-DEV-GUIDE.md`(组件优先、复用 composables、Token 配色等铁律)
## 开发规范与踩坑记录
### 后端:数据库变更必须同步到启动初始化
**规则**: 任何涉及表结构(建表、增删改列、索引)、初始数据(种子数据、默认配置)的变更,**必须同步记录到以下两个文件**,否则新部署环境或重建数据库时会丢失变更:
| 文件 | 作用 | 需要同步的内容 |
|------|------|----------------|
| `DatabaseInitConfig.java` | 应用启动自动执行(主力) | 建表 SQL(`CREATE TABLE IF NOT EXISTS`)、列迁移(`ALTER TABLE ADD COLUMN IF NOT EXISTS`)、种子数据(`ON CONFLICT ... DO UPDATE`) |
| `init-database.sql` | 手动备用脚本 | 与 `DatabaseInitConfig` 保持一致的完整建表 + 索引 + 种子数据 |
**流程**: 改完 Entity / Mapper 后,先在 `DatabaseInitConfig.init()` 中添加对应的初始化逻辑(幂等检查 + safeInit 包裹),再同步更新 `init-database.sql`。
**反面案例**: `knowledge_document.content_hash` 列仅在 `DatabaseInitConfig` 中迁移,未同步到 `knowledge-base.sql`,导致该脚本变为过时版本。
### 后端:vector_store 物理删除(禁止在原生 SQL 中引用 is_delete)
**规则**: `vector_store` 表由 Spring AI `PgVectorStore` 以 `initializeSchema(true)` 自动建表,默认只有 `id / content / metadata(json) / embedding` 四列;`DatabaseInitConfig` 仅额外补了 `content_tsvector`、`create_time` 两列。因此 **vector_store 只保证有六列**:`id / content / metadata / embedding / content_tsvector / create_time`,**没有 `is_delete`、没有 `update_time`**。
- vector_store 的向量删除走**物理删除**(`pgVectorVectorStore.delete(ids)`),不存在逻辑删除语义,原生 SQL 中**禁止**写 `AND is_delete = false`。
- 凡是引用业务表(`knowledge_document` 等 MyBatis Plus 逻辑删除表)的 SQL 里常用的 `is_delete = false` 过滤,**不得照搬到 vector_store**。
- 对 vector_store 写原生 SQL 前,先确认引用的列在上述六列白名单内。
**反面案例**: `HybridSearchService.keywordSearch()` 从 `knowledge_document` 表查询照搬了 `AND is_delete = false`,但 vector_store 无此列,PostgreSQL 报 `column "is_delete" does not exist`(SQLState 42703),被 Spring 统一翻译为 `BadSqlGrammarException`,前端显示「搜索失败: bad SQL grammar」,且仅关键词/混合模式触发(向量模式走 Spring AI similaritySearch 不执行该 SQL)。
### 后端:数据库初始化一律幂等自愈
**规则**: 新增列/索引/触发器的 init 方法(`DatabaseInitConfig` 内)**不要**「列已存在就 return」,应使用幂等 SQL 组合自愈:`ADD COLUMN IF NOT EXISTS` + 回填(`UPDATE ... WHERE xxx IS NULL`)+ `CREATE INDEX IF NOT EXISTS` + `CREATE OR REPLACE FUNCTION` + `DROP TRIGGER IF EXISTS` + `CREATE TRIGGER`。避免半途失败或对象被手动删除后留下静默缺口(如新文档 `content_tsvector` 恒为 NULL、检索静默返回空)。
### 后端:Long ID 序列化为字符串
**规则**: 所有雪花算法生成的 Long 类型 ID 字段,必须保证前端收到的是**字符串**而非数字,防止 JS 超过 `Number.MAX_SAFE_INTEGER`(2^53)精度丢失。
| 场景 | 做法 |
|------|------|
| Entity 字段 | `@JsonSerialize(using = ToStringSerializer.class)` + `@TableId(type = IdType.ASSIGN_ID)` |
| 原生 JDBC SQL | `SELECT u.id::TEXT AS id`(CAST 为 TEXT),不能直接 `SELECT u.id` |
| Controller Map 返回 | `Map.of("id", user.getId().toString())` |
**反面案例**: 用户管理 `listUsers` 使用原生 SQL 未 CAST,导致新建的用户(雪花 ID 约 19 位)在前端编辑时 ID 被截断,报"用户不存在"。
### 后端:敏感字段脱敏
**规则**: API 返回用户对象前,必须将 `password` 等敏感字段置为 `null`。不得将 BCrypt 哈希暴露给前端。
### 后端:列表排序字段白名单(防 SQL 注入)
**规则**: 任何用户可控的列表排序参数,必须经 `com.wok.supportbot.common.SortUtils` 的白名单映射解析,**严禁**将前端传入的 `sortField` 直接拼接进 `ORDER BY`。
| 场景 | 做法 |
|------|------|
| MyBatis Plus `QueryWrapper` | `String col = SortUtils.resolveColumn(sortField, 白名单Map, "create_time");` 再 `orderByAsc/orderByDesc(col)` |
| `LambdaQueryWrapper` | 其 `orderByAsc/orderByDesc` 只接受 `SFunction`、不接受字符串列名,需改用 `QueryWrapper` 做排序 |
| JdbcTemplate 原生 SQL | 同样用 `resolveColumn` 得到安全列名后拼 `ORDER BY col ASC/DESC` |
| `DISTINCT ON` 查询 | 排序需将原查询包成子查询,在外层按白名单列排序(见 `MessageFeedbackService.listConversationsByFeedback`) |
白名单用 `Map.of(colKey, 列名)` 定义;`resolveColumn` 内部先判空(避免 `Map.of().get(null)` 抛 NPE),未知字段回退默认列(通常 `create_time`);`sortOrder` 仅 `asc` 视为升序,其余一律降序。
**反面案例**: 早期 `DocumentService.listDocuments` 手工写白名单,因未判 `sortField == null` 直接 `Map.of().getOrDefault(sortField, ...)` 触发 NPE,已由 `SortUtils.resolveColumn` 统一规避。
### 后端:Java 文本块拼 SQL 的边界空格陷阱
**规则**: 用 Java 文本块(`"""`)拼 SQL 时,**严禁**让闭合定界符 `"""` 与内容行同尾、或让变量紧跟开启定界符 `"""` 拼接,否则空格/换行会被静默吞掉导致 SQL 粘连。Java 文本块有两个隐蔽行为:
1. **闭合定界符前的尾随空格被剥离**:`ORDER BY """` 中 `BY` 后的空格会丢,`"ORDER BY "` 变成 `"ORDER BY"`。
2. **开启定界符后的前导换行被消费**:`+ """` 后紧跟的下一文本块,其第一行前导换行不算内容,导致前一段末尾与后一段开头直接相连(如 `DESC` + `LIMIT` 变成 `DESCLIMIT`)。
**做法**: 涉及排序子句、动态变量拼接时,用普通字符串显式带空格拼接,不要依赖文本块边界:
```java
// ✅ 正确:显式空格 + 变量,不碰文本块边界
""" ... ) t
""" + " ORDER BY " + orderBy + " LIMIT ? OFFSET ?";
// ❌ 错误:ORDER BY 尾随空格被剥离、orderBy 与 LIMIT 粘连
""" ... ) t
ORDER BY """ + orderBy + """
LIMIT ? OFFSET ?
""";
```
`""" + whereClause` 这类「闭合定界符独立一行 + whereClause 自带前导空格」的写法是安全的,因为不依赖尾随空格。
**反面案例**: `MessageFeedbackService.listConversationsByFeedback` 用 `ORDER BY """ + orderBy + """` 拼接,编译后 `ORDER BY ` 尾随空格被剥离、`DESC` 与 `LIMIT` 粘连成 `ORDER BYfeedback_time DESCLIMIT`,PostgreSQL 报 `syntax error`(Spring 翻译为 `BadSqlGrammarException`),前端显示「查询失败:bad SQL grammar」。
### 前端:弹窗实现统一模式
项目中存在两种弹窗模式,**不可混用**:
| 模式 | 实现方式 | 适用 CSS 类 |
|------|----------|------------|
| **A: CSS 类切换** | `:class="{ active: xxx.visible }"` | `.modal-overlay`(CSS 定义 `display:none` + `.active { display:flex }`) |
| **B: 内联样式 + v-if** | `v-if="xxx" style="...display:flex..."` | 无 `.modal-overlay`,用内联样式 |
**禁止**: 将 `.modal-overlay` 与 `v-if` 搭配使用 — `v-if` 控制 DOM 存在性,但不添加 `.active` 类,导致弹窗渲染后被 CSS `display:none` 隐藏,按钮点击无反应。
### 前端:错误处理显示服务器信息
**规则**: `catch` 块必须透传服务器错误信息,禁止吞掉错误只显示泛化提示:
```javascript
// ✅ 正确
catch (e) { toast(e.message || '操作失败', 'error') }
// ❌ 错误 — 用户和开发者都无法排查
catch (e) { toast('操作失败', 'error') }
```
### 前后端:AI 执行链示意图与代码保持同步
**规则**: 对 `app/`、`rag/`、`advisor/` 包中核心编排类的**结构性变更**(新增/删除管道阶段、调整调用链、引入新组件),**必须同步更新以下两处架构图**,否则示意图与实际代码会不一致:
| 文件 | 内容 | 说明 |
|------|------|------|
| `frontend/src/views/PipelineFlow.vue` | Mermaid 流程图 DSL(`GRAPH_DEFINITION` 常量) | 管理后台「AI 执行链」页面的可视化图表 |
| `AGENTS.md` 中的 ASCII 管道图 | 文本流程描述("统一对话管道"章节) | 供 AI 和开发者快速了解架构 |
**触发条件**(满足任一即需更新):
- `ChatPipeline.buildRequest()` 的决策分支(意图路由、FAQ、RAG、纯对话路径)发生变更
- `RagPipeline.retrieve()` 的检索流程(查询重写策略、检索方式、资料拼装)发生变更
- `AssistantApp` 的 Advisor 链成员或顺序发生变更(如新增/移除 Advisor)
- 新增管道阶段组件(如 `IntentRouter`、`SuggestionGenerator`、`SimpleCircuitBreaker` 等)或移除现有组件
- 组件间调用关系调整(如原来 A→B 改为 A→C→B)
**图表元数据**: `PipelineFlow.vue` 中 DSL 首行有 `%%graph-meta` 注释标记最后更新时间,修改图表时必须更新该日期。
**反面案例**: commit `527d9e7` 创建的流程图展示了 RAG 子图中 `VECTOR → RRF | KEYWORD → RRF | HYBRID → RRF → Reranker` 的三模式检索流程,但实际 `RagPipeline.similaritySearch()` 仅做纯向量检索,`HybridSearchService`/`RrfFusion`/`RerankerService` 尚未接入主对话流程,导致图表与代码事实不符。
### 后端:CORS 双轨制(SDK 接口开放 / 管理接口白名单)
**规则**: CORS 配置分两套,**不可整体收紧为单一白名单**(系统可用性第一):
| 路径 | CORS 策略 | 原因 |
|------|----------|------|
| SDK 第三方接入接口:`/ai/**`、`/category/tree`、`/category/list`、`/feedback`、`/attachment/upload` | `allowedOriginPatterns("*")` 开放跨域 | 第三方域名动态未知,用 Bearer Token 鉴权,不依赖来源白名单 |
| 管理后台接口:其余 `/**` | `allowed-origins` 白名单 | 用 httpOnly refresh cookie,需白名单防跨域 CSRF |
配置有两处,**必须同步修改**:`SecurityConfig.corsConfigurationSource()`(Security 链 CorsFilter)与 `CorsConfig.addCorsMappings()`(Spring MVC 层 CorsInterceptor)。两者均按注册顺序匹配,**先注册精确的 SDK 路径,再注册兜底的 `/**`**。
**反面案例**: commit `9338fcb` 为配合 refresh token 迁移 httpOnly Cookie,把 CORS 从 `allowedOriginPatterns("*")` 整体收紧为白名单,引发两个回归:(1) 同源静态资源 `<script crossorigin>`/`<link crossorigin>` 带 `Origin` 头、白名单缺线上域名 → 首页空白 + assets 403;(2) SDK 第三方跨域、第三方域名不在白名单 → 接口 403。教训:改 CORS 必须同时考虑「同源静态资源的 crossorigin 属性」和「SDK 第三方跨域」两个场景。
**部署提醒**: 更换前端域名/端口时,必须同步把新域名加入 `application-prod.yml` 的 `app.cors.allowed-origins`,否则同源静态资源会 403。
## API 路由约定
- AI 对话: `/ai/*`(`AiController`)
- 模型配置: `/model-config/*`(`AiModelConfigController`)
- 文档上传: `/upload/*`(`DocumentController`)
- 文档管理: `/document/*`(`DocumentController`)
- 批量操作: `/document/batch/*`(`DocumentController`,用 POST 避免 DELETE+RequestBody 路径冲突)
- 分类管理: `/category/*`(`DocumentController`)
- 消息反馈: `/feedback/*`(`MessageFeedbackController`)
- 敏感词管理: `/sensitive-word/*`(`SensitiveWordController`)
- FAQ 管理: `/faq/*`(`FaqController`)
- SDK 认证: `/open-api/auth/*`(`AuthController`,Token 换取)
- API Key 角色绑定: `/api-key/{id}/roles`(`ApiKeyController`,admin 角色)
- LLM 调用追踪: `/llm-trace/*`(`LlmCallTraceController`,admin 角色)
- 执行链节点开关: `/pipeline-toggle`(`PipelineToggleController`,admin 角色)
### Filter 优先级
| 优先级 | Filter | 路径 | 说明 |
|--------|--------|------|------|
| `HIGHEST + 1` | `SdkAuthFilter` | `/ai/**` | SDK JWT 鉴权 |
| `HIGHEST + 2` | `ApiKeyAuthFilter` | `/open-api/**` | API Key 鉴权 |
| SecurityFilterChain 内 | `JwtAuthFilter` | 管理接口 | 管理后台 JWT |
### SDK 鉴权架构
采用两段式鉴权:客户端后端用 API Key 换取短期 JWT Token → SDK 携带 Token 请求 `/ai/**` → SdkAuthFilter 校验放行。SDK JWT 密钥独立于管理后台 JWT(`jwt.sdk-secret`)。
- API Key 支持绑定客服角色列表(`role_ids` JSONB 字段),Token 换取时优先返回绑定的角色
- 未绑定角色的 API Key 返回所有启用角色(向后兼容)
- JWT Token 中 `sub` 为 apiKeyId,`rids` 为允许的角色 ID 列表
- 第三方系统接入指南详见 `SDK-INTEGRATION.md`
## P0 阶段新增功能
### 内容安全过滤(P0-004)
- **DFA 引擎**: `ContentSafetyService` 使用字典树匹配敏感词,`volatile` + copy-on-write 保证线程安全热加载
- **ContentSafetyAdvisor**: 实现 `BaseAdvisor`,`getOrder()` 返回 `HIGHEST_PRECEDENCE`(Advisor 链最外层),before 阶段检查用户输入、after 阶段检查 AI 输出
- **敏感词级别**: level=1 仅脱敏(MASK),level=2 拦截(BLOCK)返回安全提示
- **审计日志**: `content_audit_log` 表记录所有违规事件,不删除
- **前端**: `SensitiveWordManager.js` 在系统设置 Tab,支持 CRUD + 批量导入 + 审计日志查看
### 用户反馈系统(P0-002)
- **反馈实体**: `MessageFeedback`,按 `message_id` 唯一索引,重复提交覆盖(upsert 语义)
- **反馈类型**: THUMBS_UP(有帮助)/ THUMBS_DOWN(没帮助),点踩可选原因分类 + 自由文本
- **ChatPanel**: 每条 AI 回复下方有 👍/👎 按钮,点击调用 `POST /feedback`
- **Chat SDK**: `handleFeedback()` 已连接后端 API,同时保留 localStorage 作为乐观 UI 缓存
- **会话导出**: `ConversationService.exportConversation()` 导出的 TXT 中包含反馈信息
### 意图识别 + FAQ 精准匹配(P0-003)
- **IntentRouter**: LLM 单次调用做意图分类(FAQ/RAG/CHITCHAT);结构化输出由标准组件 `BeanOutputConverter<IntentResult>` 生成 JSON Schema 指令并反序列化结果,解析失败/结果非法降级为 RAG
- **FaqMatchEngine**: 三级匹配策略 — 精确匹配 → 关键词匹配 → 向量语义匹配(阈值 `knowledge.faq.semantic-threshold`,默认 0.85)
- **FAQ 向量化**: 复用现有 `DynamicEmbeddingModel`,向量存入 `faq_embedding` 表,新增/修改 FAQ 时异步计算
- **similar_questions 字段**: 使用 String 类型存储 JSON 数组字符串(PostgresJsonTypeHandler 期望对象格式,故不用 typeHandler)
- **前端**: `FaqManager.js` 在知识库文档管理 Tab,支持 CRUD + 批量 JSON 导入/导出 + 启用/禁用
### 混合检索 + 重排序引擎(P0-001)
- **SearchMode**: 枚举 VECTOR(默认)/ KEYWORD / HYBRID,向后兼容
- **HybridSearchService**: 多模式检索核心,KEYWORD 使用 PostgreSQL `tsvector` 全文检索,HYBRID 使用双路检索 + RRF 融合
- **RrfFusion**: RRF 融合算法 `score = Σ 1/(k + rank_i)`,k=60
- **RerankerService**: 支持 DashScope + OpenAI 兼容提供商,通过 `ai_model_config` 表 RERANK 类型配置;HTTP 由 `RestClient` + `JdkClientHttpRequestFactory` 显式设置 connect/read 超时(各 3s,原 `RestTemplate` 无任何超时),超时/异常自动 fallback 到 RRF 原始排序
- **vector_store 全文检索**: 新增 `content_tsvector` 列 + GIN 索引 + PostgreSQL 触发器自动维护
- **前端**: `DocSearch.js` 增加检索模式下拉选择(向量/关键词/混合),结果标注来源模式
## LLM 调用追踪(提示词调试)
- **llm_call_trace 表**:append-only,无逻辑删除。记录每次 LLM 调用的完整现场——最终 `system_prompt` + `global_prompt`/`role_prompt`/`rag_context` 分段快照、用户消息、AI 回复(截断)、模型参数(model_name/provider/temperature/max_tokens)、耗时、意图(CHAT/CHITCHAT/FAQ/RAG)、状态(COMPLETE/ERROR/CANCEL/FAQ/BYPASS)。
- **埋点位置**:`AssistantApp.chatWithEvents`/`chatStream` 顶部埋点,覆盖 FAQ 命中、熔断降级、流式断连/异常等路径;流式用 `doOnNext` 聚合分片 + `doFinally` 按终止信号落库(绝不在方法返回处计时)。
- **异步写入**:`LlmCallTraceService.recordAsync` 走 `@Async("traceExecutor")`(有界线程池见 `AsyncExecutorConfig`,替代默认 `SimpleAsyncTaskExecutor` 线程爆炸隐患;`RagHitLogService` 已一并切换)。
- **查询面板**:前端「系统设置 → 提示词追踪」(`PromptTracePanel.vue`,admin),支持筛选/搜索/详情(来源标注)/聚合统计/并排对比/跳转编辑(deep-link `?roleId=`、`?key=ai_system_prompt`)。
- **安全声明**:`system_prompt`/`user_message`/`ai_response` 属敏感数据(提示词为商业秘密),当前**明文存储**、仅 admin 可查,落库前做敏感词脱敏(`ContentSafetyService.mask`)+ AI 回复截断;保留期限由 `system_config.llm_trace_retention_days` 控制(默认 30 天,`@Scheduled` 每日 2 点自动清理)。字段级加密留二期。
- **预留**:`account_id`/`api_key_id` 列预留租户隔离与个人信息删除权,本期不写入;旁路 LLM 调用(意图识别/查询重写/建议生成)追踪留二期(表加 `trace_type`/`source` 字段)。
## 已知 TODO
- `DocumentService.updateDocumentMetadata()`: Spring AI 无直接更新 vector_store metadata 的 API,向量元数据同步留后续
- `DocumentService.searchDocuments()`: **分类过滤已实现**(`FilterExpressionBuilder` 组合 `enabled` + `categoryId` 过滤表达式,向量检索异常时回退到本地 metadata 过滤)—— 原「Spring AI filter 支持有限」的 TODO 已不成立
- `CompressionQueryRewriter`: 当前传入空历史列表
- MyBatis Plus `mybatis-plus-spring-boot3-starter` 不含 `PaginationInnerInterceptor`,分页通过 SQL `LIMIT/OFFSET` 手动实现
- `PgVectorStoreConfig.dimensions(1024)` 硬编码了向量维度,切换非 1024 维的 Embedding 模型时需修改并重建 vector_store 表 → **已修复:维度由 `knowledge.vector.dimension` 配置,启动时自动检测不匹配并告警**

23
CLAUDE.md

@ -52,12 +52,32 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
用户请求 用户请求
→ 鉴权/角色解析(Controller) → 鉴权/角色解析(Controller)
→ ChatPipeline.buildRequest(ChatContext) → ChatPipeline.buildRequest(ChatContext)
→ IntentRouter 意图路由(CHITCHAT/FAQ/RAG)
→ IntentRouter 意图路由(CHITCHAT/FAQ/RAG) ← 可关: pipeline_intent_llm_enabled
→ RagPipeline.retrieve(FAQ 优先 → 查询重写 → 统一检索) → RagPipeline.retrieve(FAQ 优先 → 查询重写 → 统一检索)
· FAQ 三级匹配(第三级语义匹配) ← 可关: pipeline_faq_semantic_enabled
· 查询重写 REWRITE/TRANSLATION/COMPRESSION ← 可关: pipeline_rewrite_enabled
· MULTI_QUERY 多路扩展 ← 可关: pipeline_multiquery_enabled
→ 组装 finalMessage + finalSystemPrompt + 资料块 → 组装 finalMessage + finalSystemPrompt + 资料块
→ AssistantApp.chat / chatStream(构建 ChatClientRequestSpec → call/stream) → AssistantApp.chat / chatStream(构建 ChatClientRequestSpec → call/stream)
``` ```
### AI 执行链节点开关(性能开关)
管理后台「系统设置 → AI 执行链」页面(`/#/settings/pipeline-flow`,admin)提供链路节点的启停开关。这些节点全部位于**首 token 产出之前**,且默认 `MULTI_QUERY` 策略下会串行累加多次跨网络调用,是「AI 回复慢」的主因。
| 配置键(`system_config`) | 默认 | 关闭后省下 | 代价 |
|---|---|---|---|
| `pipeline_intent_llm_enabled` | `true` | 1 次 LLM | 不再按意图跳过知识库检索(寒暄词快速路径保留,零开销) |
| `pipeline_rewrite_enabled` | `true` | 1 次 LLM | 忽略请求携带的重写策略,直接用原文检索 |
| `pipeline_multiquery_enabled` | `true` | 3 路 embedding | `MULTI_QUERY` 降级为单路原文检索(其他策略不受影响) |
| `pipeline_faq_semantic_enabled` | `true` | 0~2 次 embedding | FAQ 只剩精确匹配与关键词匹配,近义问法匹配不到 |
- **实现**:`PipelineToggleService` 持有 `volatile` 不可变快照(`@EventListener(ApplicationReadyEvent)` 载入,写时整体重建,与 `ContentSafetyService` 的 DFA 树同一范式)。之所以不复用 `SystemConfigService.getValueByKey`,是因为它**无缓存、每次直查数据库**,而这些开关在每请求的 hot path 上要判定 3~4 次。
- **生效**:后台保存后立刻重建快照,下一次对话即生效,无需重启、无需清任何缓存。代价是**直接改数据库不生效**(请走后台页面)。
- **接口**:`GET/PUT /pipeline-toggle`(`PipelineToggleController`,admin)。PUT 为增量更新,未提交的 key 保持原值。
- **前端**:`PipelineFlow.vue` 顶部开关面板 + 三个预设(极速/均衡/精准),被关闭的节点在 Mermaid 图上**置灰 + 虚线**,能力降级(如仅关 FAQ 语义匹配)的节点保持原色只加虚线。
- **注意**:菜单项已加 `roles: ['admin']`;非管理员直接访问 URL 时开关面板隐藏、流程图仍可查看(接口 403 被静默降级处理)。
- **ChatPipeline**: 纯编排,不持有 ChatClient;产出 `ChatRequest` 决策对象 - **ChatPipeline**: 纯编排,不持有 ChatClient;产出 `ChatRequest` 决策对象
- **RagPipeline**: 统一 RAG 检索,所有策略(含 MULTI_QUERY)均走"手动检索 + 资料块注入 system prompt"模式,不再使用 `RetrievalAugmentationAdvisor` 的 query augmenter - **RagPipeline**: 统一 RAG 检索,所有策略(含 MULTI_QUERY)均走"手动检索 + 资料块注入 system prompt"模式,不再使用 `RetrievalAugmentationAdvisor` 的 query augmenter
- **RAG 查询重写策略**: 由 `RagPipeline` 统一路由,`AssistantApp` 等旧方法已移除 - **RAG 查询重写策略**: 由 `RagPipeline` 统一路由,`AssistantApp` 等旧方法已移除
@ -307,6 +327,7 @@ catch (e) { toast('操作失败', 'error') }
- SDK 认证: `/open-api/auth/*`(`AuthController`,Token 换取) - SDK 认证: `/open-api/auth/*`(`AuthController`,Token 换取)
- API Key 角色绑定: `/api-key/{id}/roles`(`ApiKeyController`,admin 角色) - API Key 角色绑定: `/api-key/{id}/roles`(`ApiKeyController`,admin 角色)
- LLM 调用追踪: `/llm-trace/*`(`LlmCallTraceController`,admin 角色) - LLM 调用追踪: `/llm-trace/*`(`LlmCallTraceController`,admin 角色)
- 执行链节点开关: `/pipeline-toggle`(`PipelineToggleController`,admin 角色)
### Filter 优先级 ### Filter 优先级

26
frontend/src/api/pipeline-toggle.ts

@ -0,0 +1,26 @@
import request from './request'
import type { ApiResponse } from '@/types/api'
/** 链路节点开关(对应后端 PipelineToggleService.ToggleView) */
export interface PipelineToggle {
/** 配置键(system_config.config_key) */
key: string
/** 中文名称 */
label: string
/** 功能说明 */
description: string
/** 关闭后的收益提示 */
impact: string
/** 当前是否启用 */
enabled: boolean
}
/** 查询全部 AI 执行链节点开关 */
export function getPipelineToggles(): Promise<ApiResponse<PipelineToggle[]>> {
return request.get('/pipeline-toggle').then(r => r.data)
}
/** 批量保存链路节点开关(保存后立即生效,无需重启) */
export function updatePipelineToggles(values: Record<string, boolean>): Promise<ApiResponse<PipelineToggle[]>> {
return request.put('/pipeline-toggle', values).then(r => r.data)
}

2
frontend/src/stores/navigation.ts

@ -30,7 +30,7 @@ export const MENU_ITEMS = [
{ id: 'api-key', label: 'API Key 管理', icon: '🔑', path: '/settings/api-key', roles: ['admin'] }, { id: 'api-key', label: 'API Key 管理', icon: '🔑', path: '/settings/api-key', roles: ['admin'] },
{ id: 'webhook', label: 'Webhook 管理', icon: '🔔', path: '/settings/webhook', roles: ['admin'] }, { id: 'webhook', label: 'Webhook 管理', icon: '🔔', path: '/settings/webhook', roles: ['admin'] },
{ id: 'mcp-server', label: 'MCP 服务管理', icon: '🔌', path: '/settings/mcp-server', roles: ['admin'] }, { id: 'mcp-server', label: 'MCP 服务管理', icon: '🔌', path: '/settings/mcp-server', roles: ['admin'] },
{ id: 'pipeline-flow', label: 'AI 执行链', icon: '🔀', path: '/settings/pipeline-flow' },
{ id: 'pipeline-flow', label: 'AI 执行链', icon: '🔀', path: '/settings/pipeline-flow', roles: ['admin'] },
{ id: 'system-config', label: '系统配置', icon: '🔧', path: '/settings/system-config', roles: ['admin'] }, { id: 'system-config', label: '系统配置', icon: '🔧', path: '/settings/system-config', roles: ['admin'] },
{ id: 'log-viewer', label: '系统日志', icon: '📜', path: '/settings/log-viewer', roles: ['admin'] }, { id: 'log-viewer', label: '系统日志', icon: '📜', path: '/settings/log-viewer', roles: ['admin'] },
{ id: 'prompt-trace', label: '提示词追踪', icon: '🔬', path: '/settings/prompt-trace', roles: ['admin'] }, { id: 'prompt-trace', label: '提示词追踪', icon: '🔬', path: '/settings/prompt-trace', roles: ['admin'] },

353
frontend/src/views/PipelineFlow.vue

@ -4,7 +4,7 @@
<div class="pipeline-header"> <div class="pipeline-header">
<div> <div>
<span style="font-size:16px;font-weight:600;">🔀 AI 执行链</span> <span style="font-size:16px;font-weight:600;">🔀 AI 执行链</span>
<p style="font-size:12px;color:var(--td-text-color-placeholder);margin:4px 0 0;">
<p style="font-size:12px;color:var(--color-text-tertiary);margin:4px 0 0;">
下图展示从用户请求到 AI 回复的完整处理流程,包含意图路由、RAG 检索、熔断保护和 Advisor 链。 下图展示从用户请求到 AI 回复的完整处理流程,包含意图路由、RAG 检索、熔断保护和 Advisor 链。
菱形节点 = 决策分支 · 虚线框 = 独立子系统 · 虚线箭头 = 降级/异步路径。 菱形节点 = 决策分支 · 虚线框 = 独立子系统 · 虚线箭头 = 降级/异步路径。
</p> </p>
@ -16,10 +16,57 @@
</div> </div>
</template> </template>
<!-- 链路节点开关:关闭可选节点以压缩首 token 延迟,保存后立即生效 -->
<div class="toggle-panel">
<div class="toggle-panel__head">
<div>
<span class="toggle-panel__title">链路节点开关</span>
<p class="desc-text">
关闭后立即生效(无需重启),下方流程图中对应节点会置灰显示。
这些节点都位于首 token 产出之前,每关一个就少一次串行的模型调用。
</p>
</div>
<t-space v-if="canEdit" :size="8">
<t-button
v-for="preset in PRESETS"
:key="preset.label"
size="small"
variant="outline"
:disabled="saving"
@click="applyPreset(preset)"
>
{{ preset.label }}
</t-button>
</t-space>
</div>
<t-loading v-if="toggleLoading" text="正在加载开关配置..." style="text-align:center;padding:20px 0;" />
<p v-else-if="!canEdit" class="desc-text" style="margin-top:12px;">
链路节点开关仅管理员可配置(当前账号无权限,仅展示流程图)。
</p>
<div v-else class="toggle-list">
<div v-for="item in toggles" :key="item.key" class="toggle-item" :class="{ 'toggle-item--off': !item.enabled }">
<t-switch v-model="item.enabled" :disabled="saving" @change="onToggleChange" />
<div class="toggle-item__body">
<div class="toggle-item__label">
{{ item.label }}
<t-tag v-if="!item.enabled" variant="light" theme="warning" size="small">已关闭</t-tag>
</div>
<div class="toggle-item__desc">{{ item.description }}</div>
<div class="toggle-item__impact">{{ item.impact }}</div>
</div>
</div>
</div>
</div>
<!-- 图例 --> <!-- 图例 -->
<div class="pipeline-legend"> <div class="pipeline-legend">
<div v-for="item in legendItems" :key="item.label" class="legend-item"> <div v-for="item in legendItems" :key="item.label" class="legend-item">
<span class="legend-dot" :class="{ 'legend-diamond': item.diamond }" :style="{ background: item.bg, border: `1px solid ${item.border}` }" />
<span
class="legend-dot"
:class="{ 'legend-diamond': item.diamond, 'legend-dashed': item.dashed }"
:style="{ background: item.bg, border: `1px solid ${item.border}` }"
/>
{{ item.label }} {{ item.label }}
</div> </div>
</div> </div>
@ -37,11 +84,56 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { ref, computed, onMounted, nextTick } from 'vue'
import { toast } from '@/utils/toast' import { toast } from '@/utils/toast'
import { DownloadIcon } from 'tdesign-icons-vue-next' import { DownloadIcon } from 'tdesign-icons-vue-next'
import mermaid from 'mermaid' import mermaid from 'mermaid'
import { palette, paletteBg, paletteBorder } from '@/utils/palette' import { palette, paletteBg, paletteBorder } from '@/utils/palette'
import { getPipelineToggles, updatePipelineToggles, type PipelineToggle } from '@/api/pipeline-toggle'
/**
* 链路节点开关的配置键。
* 与后端 PipelineToggleService 中的 KEY_* 常量一一对应,是前后端的稳定契约。
*/
const KEY_INTENT_LLM = 'pipeline_intent_llm_enabled'
const KEY_REWRITE = 'pipeline_rewrite_enabled'
const KEY_MULTI_QUERY = 'pipeline_multiquery_enabled'
const KEY_FAQ_SEMANTIC = 'pipeline_faq_semantic_enabled'
/** 预设:一键切换开关组合。键名与后端配置键一致,未列出的项按开启处理 */
interface Preset {
label: string
values: Record<string, boolean>
}
const PRESETS: Preset[] = [
{
label: '极速',
values: {
[KEY_INTENT_LLM]: false,
[KEY_REWRITE]: false,
[KEY_MULTI_QUERY]: false,
[KEY_FAQ_SEMANTIC]: false,
},
},
{
label: '均衡',
values: {
[KEY_INTENT_LLM]: true,
[KEY_REWRITE]: true,
[KEY_MULTI_QUERY]: false,
[KEY_FAQ_SEMANTIC]: false,
},
},
{
label: '精准',
values: {
[KEY_INTENT_LLM]: true,
[KEY_REWRITE]: true,
[KEY_MULTI_QUERY]: true,
[KEY_FAQ_SEMANTIC]: true,
},
},
]
// 图例配置(颜色统一走 palette,避免散落 hex) // 图例配置(颜色统一走 palette,避免散落 hex)
const legendItems = [ const legendItems = [
@ -52,6 +144,7 @@ const legendItems = [
{ label: 'Advisor 链', bg: paletteBg.pink, border: paletteBorder.pink }, { label: 'Advisor 链', bg: paletteBg.pink, border: paletteBorder.pink },
{ label: '最终输出', bg: paletteBg.green, border: paletteBorder.green }, { label: '最终输出', bg: paletteBg.green, border: paletteBorder.green },
{ label: 'LLM 调用', bg: paletteBg.orange, border: paletteBorder.orange }, { label: 'LLM 调用', bg: paletteBg.orange, border: paletteBorder.orange },
{ label: '已关闭 / 能力降级', bg: paletteBg.gray, border: paletteBorder.gray, dashed: true },
] ]
// 初始化 Mermaid 主题,匹配后台管理 UI 风格 // 初始化 Mermaid 主题,匹配后台管理 UI 风格
@ -74,10 +167,147 @@ mermaid.initialize({
}, },
}) })
// Mermaid 流程图 DSL 定义
// 节点类型: [矩形]=处理步骤, {菱形}=决策分支, subgraph=子系统
// %%graph-meta: { updated: "2026-08-27", basedOn: "ChatPipeline v3, RagPipeline v2, AssistantApp v2", mermaidVersion: "flowchart-v2" }
const GRAPH_DEFINITION = `
// ==================== 开关状态 ====================
const toggles = ref<PipelineToggle[]>([])
const toggleLoading = ref(true)
const saving = ref(false)
/** 是否具备配置权限(接口成功返回即视为具备;非 admin 会拿到 403) */
const canEdit = ref(false)
/** 已关闭的配置键集合,用于流程图置灰 */
const disabledKeys = computed(() => new Set(toggles.value.filter(t => !t.enabled).map(t => t.key)))
const svg = ref('')
const error = ref('')
/**
* 加载开关配置。非管理员无权限时静默降级为「只读流程图」,不弹错误提示。
*/
async function loadToggles() {
toggleLoading.value = true
try {
const r = await getPipelineToggles()
if (r.success) {
toggles.value = r.data || []
canEdit.value = true
} else {
canEdit.value = false
}
} catch (e: any) {
// 403(非管理员)等场景:保留流程图展示能力,仅隐藏开关面板,不弹提示打扰用户
canEdit.value = false
console.debug('链路节点开关不可用(无权限或接口异常):', e?.message || e)
} finally {
toggleLoading.value = false
}
}
/** 开关切换:乐观更新本地状态先重绘图,再提交服务端;失败则回滚 */
async function onToggleChange() {
await render()
await persist()
}
/** 应用预设组合(一次提交全部开关) */
async function applyPreset(preset: Preset) {
for (const item of toggles.value) {
item.enabled = preset.values[item.key] ?? true
}
await render()
await persist()
}
/**
* 提交当前全部开关值(后端按 key 增量更新,未提交的 key 保持原值)。
* 保存失败时回滚为服务端真实状态,避免界面与后端不一致。
*/
async function persist() {
saving.value = true
const payload: Record<string, boolean> = {}
for (const item of toggles.value) {
payload[item.key] = item.enabled
}
try {
const r = await updatePipelineToggles(payload)
if (r.success) {
toggles.value = r.data || toggles.value
await render()
toast(r.message || '已保存,立即生效', 'success')
} else {
toast(r.message || '保存失败', 'error')
await reloadAndRender()
}
} catch (e: any) {
toast(e.message || '保存失败', 'error')
await reloadAndRender()
} finally {
saving.value = false
}
}
/** 重新拉取服务端状态并重绘图(保存失败后的回滚路径) */
async function reloadAndRender() {
await loadToggles()
await render()
}
// ==================== 流程图 ====================
/**
* 生成 Mermaid 流程图 DSL。
* 已关闭的节点改用灰色 + 虚线,能力降级(如仅关 FAQ 语义匹配)的节点保持原色但加虚线。
*/
function buildGraphDefinition(): string {
const off = disabledKeys.value
const intentOff = off.has(KEY_INTENT_LLM)
const rewriteOff = off.has(KEY_REWRITE)
const multiQueryOff = off.has(KEY_MULTI_QUERY)
const faqSemanticOff = off.has(KEY_FAQ_SEMANTIC)
// 意图路由节点:关闭后仅保留寒暄词快速路径(纯内存,零开销)
const nodeF = intentOff
? `<b>IntentRouter</b><br/>⛔ 已关闭(省 1 次 LLM)<br/>仅保留寒暄词快速路径<br/>其余直接走 RAG 检索`
: `<b>IntentRouter</b><br/>🔹 寒暄词快速路径: 本地列表精确匹配(零 LLM)<br/>🔹 未命中则 LLM 意图分类<br/>FAQ / RAG / CHITCHAT`
// 查询重写节点:关闭后直接用原文检索;多路扩展关闭时该策略降级为单路
let nodeL: string
if (rewriteOff) {
nodeL = `<b>2. 查询重写</b><br/>⛔ 已关闭(省 1 次 LLM)<br/>直接用原始问题向量检索`
} else if (multiQueryOff) {
nodeL = `<b>2. 查询重写</b><br/>REWRITE / TRANSLATION / COMPRESSION<br/>MULTI_QUERY 已降级为单路检索`
} else {
nodeL = `<b>2. 查询重写</b><br/>REWRITE / TRANSLATION<br/>COMPRESSION / MULTI_QUERY`
}
// FAQ 匹配节点:语义匹配关闭后只剩无网络调用的前两级
const faqLevel = faqSemanticOff ? '二级匹配(语义已关闭)' : '三级匹配'
const nodeH = faqSemanticOff
? `<b>FaqMatchEngine</b><br/>二级匹配策略<br/>精确 → 关键词<br/>(语义匹配已关闭)`
: `<b>FaqMatchEngine</b><br/>三级匹配策略<br/>精确 → 关键词 → 向量语义`
const nodeK = `<b>1. FAQ 优先匹配(二次兜底)</b><br/>FaqMatchEngine ${faqLevel}<br/>命中则短路返回`
const styleLines = [
`style A fill:${paletteBg.blue},stroke:${paletteBorder.blue},stroke-width:2px`,
`style AB fill:${paletteBg.green},stroke:${paletteBorder.green},stroke-width:2px`,
`style Y fill:${paletteBg.orange},stroke:${paletteBorder.orange},stroke-width:2px`,
`style CB fill:${paletteBg.orange},stroke:${paletteBorder.orange},stroke-width:2px`,
`style FALLBACK fill:${paletteBg.red},stroke:${paletteBorder.red},stroke-width:2px,stroke-dasharray:5`,
`style M fill:${paletteBg.indigo},stroke:${paletteBorder.indigo},stroke-width:2px`,
`style S fill:${paletteBg.indigo},stroke:${paletteBorder.indigo},stroke-width:2px`,
]
// 已关闭的节点:置灰 + 虚线,与图例「已关闭 / 能力降级」一致
const grayStyle = (id: string) =>
`style ${id} fill:${paletteBg.gray},stroke:${paletteBorder.gray},stroke-width:2px,stroke-dasharray:5`
if (intentOff) styleLines.push(grayStyle('F'))
if (rewriteOff) styleLines.push(grayStyle('L'))
// FAQ 语义匹配关闭属「能力降级」而非整节点跳过,保持原色仅加虚线
if (faqSemanticOff) {
styleLines.push(`style H stroke-dasharray:5`)
styleLines.push(`style K stroke-dasharray:5`)
}
return `
flowchart TD flowchart TD
A["<b>用户请求</b><br/>message + roleId + accountId + chatId"] A["<b>用户请求</b><br/>message + roleId + accountId + chatId"]
@ -89,11 +319,11 @@ flowchart TD
D -- "❌ false" --> E["<b>模式: 纯对话</b><br/>systemPrompt(角色人设 + 全局配置)<br/>不检索知识库"] D -- "❌ false" --> E["<b>模式: 纯对话</b><br/>systemPrompt(角色人设 + 全局配置)<br/>不检索知识库"]
D -- "✅ true" --> F["<b>IntentRouter</b><br/>🔹 寒暄词快速路径: 本地列表精确匹配(零 LLM)<br/>🔹 未命中则 LLM 意图分类<br/>FAQ / RAG / CHITCHAT"]
D -- "✅ true" --> F["${nodeF}"]
F --> G{"意图分类结果"} F --> G{"意图分类结果"}
G -- "FAQ<br/>confidence ≧ 0.8" --> H["<b>FaqMatchEngine</b><br/>三级匹配策略<br/>精确 → 关键词 → 向量语义"]
G -- "FAQ<br/>confidence ≧ 0.8" --> H["${nodeH}"]
G -- "CHITCHAT<br/>confidence ≧ 0.6" --> CHK["<b>闲聊前 FAQ 精准匹配</b><br/>先试 FaqMatchEngine<br/>命中则短路返回"] G -- "CHITCHAT<br/>confidence ≧ 0.6" --> CHK["<b>闲聊前 FAQ 精准匹配</b><br/>先试 FaqMatchEngine<br/>命中则短路返回"]
@ -103,8 +333,8 @@ flowchart TD
H -. "❌ 未命中 → 降级 RAG" .-> J H -. "❌ 未命中 → 降级 RAG" .-> J
subgraph RAG["📚 RAG 检索流水线(当前: 纯向量检索)"] subgraph RAG["📚 RAG 检索流水线(当前: 纯向量检索)"]
J --> K["<b>1. FAQ 优先匹配(二次兜底)</b><br/>FaqMatchEngine 三级匹配<br/>命中则短路返回"]
K --> L["<b>2. 查询重写</b><br/>REWRITE / TRANSLATION<br/>COMPRESSION / MULTI_QUERY"]
J --> K["${nodeK}"]
K --> L["${nodeL}"]
L --> M["<b>3. 向量检索</b><br/>PGVector similaritySearch<br/>topK=4 + 分类过滤"] L --> M["<b>3. 向量检索</b><br/>PGVector similaritySearch<br/>topK=4 + 分类过滤"]
M --> S["<b>4. 构建资料块</b><br/>拼接检索文档<br/>注入 system prompt 末尾"] M --> S["<b>4. 构建资料块</b><br/>拼接检索文档<br/>注入 system prompt 末尾"]
end end
@ -142,28 +372,35 @@ flowchart TD
SG["<b>SuggestionGenerator</b><br/>独立 ChatClient(无 MCP 工具)<br/>基于最近 10 条历史<br/>生成 3 条推荐问题<br/>超时 15s · 结果缓存"] SG["<b>SuggestionGenerator</b><br/>独立 ChatClient(无 MCP 工具)<br/>基于最近 10 条历史<br/>生成 3 条推荐问题<br/>超时 15s · 结果缓存"]
end end
style A fill:${paletteBg.blue},stroke:${paletteBorder.blue},stroke-width:2px
style AB fill:${paletteBg.green},stroke:${paletteBorder.green},stroke-width:2px
style Y fill:${paletteBg.orange},stroke:${paletteBorder.orange},stroke-width:2px
style CB fill:${paletteBg.orange},stroke:${paletteBorder.orange},stroke-width:2px
style FALLBACK fill:${paletteBg.red},stroke:${paletteBorder.red},stroke-width:2px,stroke-dasharray:5
style M fill:${paletteBg.indigo},stroke:${paletteBorder.indigo},stroke-width:2px
style S fill:${paletteBg.indigo},stroke:${paletteBorder.indigo},stroke-width:2px
${styleLines.join('\n ')}
` `
}
const svg = ref('')
const error = ref('')
// 节点类型: [矩形]=处理步骤, {菱形}=决策分支, subgraph=子系统
// %%graph-meta: { updated: "2026-09-14", basedOn: "ChatPipeline v3, RagPipeline v3, FaqMatchEngine v2, AssistantApp v2, PipelineToggleService v1", mermaidVersion: "flowchart-v2" }
/** Mermaid render 的 id 序号:动态重渲染必须用唯一 id,否则会残留旧 DOM */
let renderSeq = 0
async function render() { async function render() {
error.value = '' error.value = ''
svg.value = '' svg.value = ''
await nextTick() await nextTick()
// 序号即唯一 id:Mermaid 对同一 id 重复 render 会残留旧 DOM,动态重渲染必须换 id
const seq = ++renderSeq
const id = `pipeline-graph-${seq}`
try { try {
const { svg: result } = await mermaid.render('pipeline-graph', GRAPH_DEFINITION)
const { svg: result } = await mermaid.render(id, buildGraphDefinition())
// 并发保护:连续快速切换开关会并发触发多次渲染,若期间已有更新的渲染发起,丢弃本次结果
if (seq !== renderSeq) return
svg.value = result svg.value = result
} catch (e: any) { } catch (e: any) {
if (seq !== renderSeq) return
console.error('Mermaid 渲染失败:', e) console.error('Mermaid 渲染失败:', e)
error.value = e.message || '未知渲染错误' error.value = e.message || '未知渲染错误'
// Mermaid 渲染失败时会向 body 注入临时节点(d<id>),需清理避免页面残留
document.getElementById(`d${id}`)?.remove()
document.getElementById(id)?.remove()
} }
} }
@ -280,8 +517,10 @@ async function exportImage() {
} }
} }
onMounted(() => {
render()
onMounted(async () => {
// 先取开关状态再渲染,保证首次出图就带正确的置灰效果
await loadToggles()
await render()
}) })
</script> </script>
@ -293,10 +532,77 @@ onMounted(() => {
gap: 16px; gap: 16px;
} }
/* ===== 链路节点开关 ===== */
.toggle-panel {
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
background: var(--color-bg-subtle);
padding: 16px;
}
.toggle-panel__head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
}
.toggle-panel__title {
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary);
}
.toggle-panel__head .desc-text {
margin: 4px 0 0;
}
.toggle-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 12px;
margin-top: 14px;
}
.toggle-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 14px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-page);
transition: border-color 0.2s;
}
.toggle-item--off {
border-color: var(--color-warning-border);
background: var(--color-warning-bg);
}
.toggle-item__body {
flex: 1;
min-width: 0;
}
.toggle-item__label {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
color: var(--color-text-primary);
}
.toggle-item__desc {
margin-top: 4px;
font-size: 12px;
line-height: 1.5;
color: var(--color-text-secondary);
}
.toggle-item__impact {
margin-top: 4px;
font-size: 12px;
line-height: 1.5;
color: var(--color-text-tertiary);
}
.pipeline-legend { .pipeline-legend {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 10px 20px; gap: 10px 20px;
margin-top: 16px;
margin-bottom: 8px; margin-bottom: 8px;
} }
.legend-item { .legend-item {
@ -313,6 +619,7 @@ onMounted(() => {
flex: none; flex: none;
} }
.legend-diamond { transform: rotate(45deg); border-radius: 2px; } .legend-diamond { transform: rotate(45deg); border-radius: 2px; }
.legend-dashed { border-style: dashed !important; }
.pipeline-diagram { .pipeline-diagram {
width: 100%; width: 100%;

5
frontend/src/views/SystemConfigManager.vue

@ -198,6 +198,11 @@ const CONFIG_PRESETS: Record<string, ConfigPreset> = {
ai_system_prompt: { type: 'text', label: 'AI 全局系统提示词', desc: 'AI 对话全局系统提示词(为空则不注入)' }, ai_system_prompt: { type: 'text', label: 'AI 全局系统提示词', desc: 'AI 对话全局系统提示词(为空则不注入)' },
suggestion_enabled: { type: 'boolean', label: 'AI 推荐问题开关', desc: 'AI 推荐问题功能开关' }, suggestion_enabled: { type: 'boolean', label: 'AI 推荐问题开关', desc: 'AI 推荐问题功能开关' },
suggestion_prompt: { type: 'text', label: 'AI 推荐问题 Prompt', desc: 'AI 推荐问题 Prompt 模板' }, suggestion_prompt: { type: 'text', label: 'AI 推荐问题 Prompt', desc: 'AI 推荐问题 Prompt 模板' },
// AI 执行链节点开关(主入口在「AI 执行链」页面,此处登记以保证以开关而非文本框呈现)
pipeline_intent_llm_enabled: { type: 'boolean', label: '意图路由 LLM 分类', desc: '关闭后省 1 次 LLM 调用(寒暄词快速路径保留)' },
pipeline_rewrite_enabled: { type: 'boolean', label: '查询重写', desc: '关闭后忽略重写策略,直接用原始问题检索' },
pipeline_multiquery_enabled: { type: 'boolean', label: '多路查询扩展', desc: '关闭后 MULTI_QUERY 降级为单路检索,省 3 路 embedding' },
pipeline_faq_semantic_enabled: { type: 'boolean', label: 'FAQ 语义匹配', desc: '关闭后 FAQ 仅保留精确匹配与关键词匹配' },
} }
const TYPE_META: Record<ConfigType, { label: string; theme: string }> = { const TYPE_META: Record<ConfigType, { label: string; theme: string }> = {

33
src/main/java/com/wok/supportbot/app/ChatPipeline.java

@ -4,6 +4,7 @@ import com.wok.supportbot.rag.RagContext;
import com.wok.supportbot.rag.RagPipeline; import com.wok.supportbot.rag.RagPipeline;
import com.wok.supportbot.service.FaqMatchEngine.FaqMatchResult; import com.wok.supportbot.service.FaqMatchEngine.FaqMatchResult;
import com.wok.supportbot.service.IntentRouter; import com.wok.supportbot.service.IntentRouter;
import com.wok.supportbot.service.PipelineToggleService;
import com.wok.supportbot.service.SystemConfigService; import com.wok.supportbot.service.SystemConfigService;
import com.wok.supportbot.service.RagHitLogService; import com.wok.supportbot.service.RagHitLogService;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@ -34,6 +35,10 @@ import java.util.Optional;
* {@code @pipeline-step} buildRequest: 意图路由 → FAQ优先 → RAG检索 → 提示词组装<br> * {@code @pipeline-step} buildRequest: 意图路由 → FAQ优先 → RAG检索 → 提示词组装<br>
* {@code @pipeline-step} routeIntent: 寒暄词快速路径 → IntentRouter LLM分类 → 降级RAG<br> * {@code @pipeline-step} routeIntent: 寒暄词快速路径 → IntentRouter LLM分类 → 降级RAG<br>
* {@code @pipeline-step} effectiveSystem: DB全局提示词 + 角色人设 动态组合<br> * {@code @pipeline-step} effectiveSystem: DB全局提示词 + 角色人设 动态组合<br>
* <br>
* 性能开关:意图路由的 LLM 分类可由 {@link PipelineToggleService} 的
* {@code pipeline_intent_llm_enabled} 在管理后台关闭(寒暄词快速路径不受影响),
* 查询重写 / 多路扩展 / FAQ 语义匹配的开关见 {@code RagPipeline} 与 {@code FaqMatchEngine}。<br>
* 同步至: frontend/src/views/PipelineFlow.vue, CLAUDE.md ASCII管道图 * 同步至: frontend/src/views/PipelineFlow.vue, CLAUDE.md ASCII管道图
*/ */
@Component @Component
@ -58,6 +63,9 @@ public class ChatPipeline {
@Resource @Resource
private RagHitLogService ragHitLogService; private RagHitLogService ragHitLogService;
@Resource
private PipelineToggleService pipelineToggleService;
/** /**
* 编排一次对话请求,产出执行决策。 * 编排一次对话请求,产出执行决策。
* <p> * <p>
@ -150,12 +158,21 @@ public class ChatPipeline {
/** /**
* 意图路由:先用寒暄词列表做快速路径,未命中再调 IntentRouter 做 LLM 分类。 * 意图路由:先用寒暄词列表做快速路径,未命中再调 IntentRouter 做 LLM 分类。
* 异常时返回 null(调用方默认走 RAG 检索)。 * 异常时返回 null(调用方默认走 RAG 检索)。
* <p>
* 性能开关:{@code pipeline_intent_llm_enabled} 关闭时跳过 LLM 分类直接返回 null,
* 调用方视为「未知意图」继续走 RAG 检索,省一次 LLM 调用。寒暄词快速路径为纯内存匹配,
* 不受该开关影响,始终保留。
*/ */
private IntentRouter.IntentResult routeIntent(String message) { private IntentRouter.IntentResult routeIntent(String message) {
// 寒暄词快速路径(零 LLM 开销) // 寒暄词快速路径(零 LLM 开销)
if (isChitchat(message)) { if (isChitchat(message)) {
return new IntentRouter.IntentResult("CHITCHAT", 1.0); return new IntentRouter.IntentResult("CHITCHAT", 1.0);
} }
// 后台已关闭意图识别 LLM 分类:跳过细粒度意图判断,直接走 RAG 检索
if (!pipelineToggleService.isIntentLlmEnabled()) {
log.debug("意图识别 LLM 分类已被后台关闭,跳过意图路由直接走 RAG 检索");
return null;
}
try { try {
return intentRouter.route(message); return intentRouter.route(message);
} catch (Exception e) { } catch (Exception e) {
@ -164,22 +181,6 @@ public class ChatPipeline {
} }
} }
/**
* 判断是否跳过 KB 检索(保留旧方法签名供 retrieveSources 等使用)。
*/
private boolean shouldBypassRag(String message) {
if (isChitchat(message)) {
return true;
}
try {
IntentRouter.IntentResult intent = intentRouter.route(message);
return "CHITCHAT".equals(intent.getIntent()) && intent.getConfidence() >= CHITCHAT_CONFIDENCE_THRESHOLD;
} catch (Exception e) {
log.debug("意图路由异常,沿用 RAG: {}", e.getMessage());
}
return false;
}
/** /**
* 寒暄词快速判断:问候/感谢/告别等短消息无知识库检索意图。 * 寒暄词快速判断:问候/感谢/告别等短消息无知识库检索意图。
* 与原 {@code AiController.shouldBypassKnowledgeRetrieval} 逻辑一致,作为 IntentRouter 的快速路径与兜底。 * 与原 {@code AiController.shouldBypassKnowledgeRetrieval} 逻辑一致,作为 IntentRouter 的快速路径与兜底。

12
src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java

@ -1,5 +1,6 @@
package com.wok.supportbot.config; package com.wok.supportbot.config;
import com.wok.supportbot.service.PipelineToggleService;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@ -1463,6 +1464,17 @@ public class DatabaseInitConfig {
VALUES (?, ?, ?) VALUES (?, ?, ?)
ON CONFLICT (config_key) DO NOTHING ON CONFLICT (config_key) DO NOTHING
""", "llm_trace_retention_days", "30", "LLM 调用追踪记录保留天数(自动清理,默认 30 天)"); """, "llm_trace_retention_days", "30", "LLM 调用追踪记录保留天数(自动清理,默认 30 天)");
// AI 执行链节点开关(对话链路性能开关)
// 默认全部 true = 与历史行为完全一致,由管理员在后台「AI 执行链」页面按需关闭以压缩首 token 延迟。
// 配置键与中文说明复用 PipelineToggleService 的定义,避免两处文案漂移。
for (PipelineToggleService.ToggleDefinition def : PipelineToggleService.definitions()) {
jdbcTemplate.update("""
INSERT INTO system_config (config_key, config_value, description)
VALUES (?, ?, ?)
ON CONFLICT (config_key) DO NOTHING
""", def.key(), String.valueOf(def.defaultValue()), def.description());
}
} }
/** /**

82
src/main/java/com/wok/supportbot/controller/PipelineToggleController.java

@ -0,0 +1,82 @@
package com.wok.supportbot.controller;
import com.wok.supportbot.service.PipelineToggleService;
import com.wok.supportbot.service.PipelineToggleService.ToggleView;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
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.RestController;
import java.util.List;
import java.util.Map;
/**
* AI 执行链节点开关接口。
* <p>
* 供管理后台「AI 执行链」页面({@code /#/settings/pipeline-flow})读写对话链路上
* 可选的性能节点开关(意图路由 / 查询重写 / 多路扩展 / FAQ 语义匹配)。
* 开关落库到 {@code system_config} 表,保存后立即生效,无需重启。
* <p>
* 管理接口,需要 admin 角色。路由落在 SecurityFilterChain 既有的 {@code /**} 管理白名单内,
* 无需额外的 CORS 配置。
*/
@Slf4j
@RestController
@RequestMapping("/pipeline-toggle")
public class PipelineToggleController {
@Autowired
private PipelineToggleService pipelineToggleService;
/**
* 查询全部链路节点开关(含中文名、说明与收益提示)。
*/
@GetMapping
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> list() {
try {
List<ToggleView> toggles = pipelineToggleService.listToggles();
return ResponseEntity.ok(Map.of(
"success", true,
"data", toggles
));
} catch (Exception e) {
log.error("查询链路节点开关失败", e);
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "查询失败:" + e.getMessage()
));
}
}
/**
* 批量保存链路节点开关(保存后立即生效)。
*
* @param body 请求体 {@code { "pipeline_intent_llm_enabled": false, ... }},
* 未出现的键保持原值
*/
@PutMapping
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> update(@RequestBody Map<String, Boolean> body) {
try {
List<ToggleView> toggles = pipelineToggleService.saveAll(body);
log.info("链路节点开关已更新: {}", body);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "保存成功,已立即生效",
"data", toggles
));
} catch (Exception e) {
log.error("保存链路节点开关失败", e);
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "保存失败:" + e.getMessage()
));
}
}
}

17
src/main/java/com/wok/supportbot/rag/RagPipeline.java

@ -9,6 +9,7 @@ import com.wok.supportbot.rag.preretrieval.RewriteQueryRewriter;
import com.wok.supportbot.rag.preretrieval.TranslationQueryRewriter; import com.wok.supportbot.rag.preretrieval.TranslationQueryRewriter;
import com.wok.supportbot.service.FaqMatchEngine; import com.wok.supportbot.service.FaqMatchEngine;
import com.wok.supportbot.service.FaqMatchEngine.FaqMatchResult; import com.wok.supportbot.service.FaqMatchEngine.FaqMatchResult;
import com.wok.supportbot.service.PipelineToggleService;
import com.wok.supportbot.service.RagHitLogService; import com.wok.supportbot.service.RagHitLogService;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -84,6 +85,9 @@ public class RagPipeline {
@Resource @Resource
private CategoryFilter categoryFilter; private CategoryFilter categoryFilter;
@Resource
private PipelineToggleService pipelineToggleService;
@Resource @Resource
private RewriteQueryRewriter rewriteQueryRewriter; private RewriteQueryRewriter rewriteQueryRewriter;
@ -163,16 +167,25 @@ public class RagPipeline {
/** /**
* 统一检索 + 拼接资料文本(不含 FAQ 匹配),供 {@link #retrieve} 与 {@link #retrieveDocuments} 复用。 * 统一检索 + 拼接资料文本(不含 FAQ 匹配),供 {@link #retrieve} 与 {@link #retrieveDocuments} 复用。
* <p>
* 性能开关({@link PipelineToggleService}):
* <ul>
* <li>{@code pipeline_multiquery_enabled} 关闭 → MULTI_QUERY 请求降级走单路原文检索,省 3 路 embedding</li>
* <li>{@code pipeline_rewrite_enabled} 关闭 → 忽略请求携带的重写策略,直接用原始问题检索,省 1 次 LLM</li>
* </ul>
*/ */
private RagContext retrieveDocumentsAndContext(ChatContext ctx) { private RagContext retrieveDocumentsAndContext(ChatContext ctx) {
List<Document> docs; List<Document> docs;
String rewrittenQuery; String rewrittenQuery;
if ("MULTI_QUERY".equalsIgnoreCase(ctx.rewriteStrategy())) {
if (pipelineToggleService.isMultiQueryEnabled() && "MULTI_QUERY".equalsIgnoreCase(ctx.rewriteStrategy())) {
// MULTI_QUERY:资料注入 system,user 消息保持原始 message // MULTI_QUERY:资料注入 system,user 消息保持原始 message
docs = retrieveMultiQueryDocs(ctx.message(), ctx.categoryIds()); docs = retrieveMultiQueryDocs(ctx.message(), ctx.categoryIds());
rewrittenQuery = ctx.message(); rewrittenQuery = ctx.message();
} else { } else {
rewrittenQuery = rewriteQuery(ctx.message(), ctx.chatId(), ctx.rewriteStrategy());
// 关闭查询重写时传 null 策略,复用 rewriteQuery 既有的「未知策略返回原文」降级分支,
// 该分支已稳定运行,不为此新增代码路径;MULTI_QUERY 被降级时同样落到此分支(策略不被识别 → 原文)
String strategy = pipelineToggleService.isRewriteEnabled() ? ctx.rewriteStrategy() : null;
rewrittenQuery = rewriteQuery(ctx.message(), ctx.chatId(), strategy);
docs = similaritySearch(rewrittenQuery, ctx.categoryIds()); docs = similaritySearch(rewrittenQuery, ctx.categoryIds());
} }

8
src/main/java/com/wok/supportbot/service/FaqMatchEngine.java

@ -41,6 +41,9 @@ public class FaqMatchEngine {
@Autowired @Autowired
private CategoryFilter categoryFilter; private CategoryFilter categoryFilter;
@Autowired
private PipelineToggleService pipelineToggleService;
/** 向量维度,与 PgVectorStore 保持一致 */ /** 向量维度,与 PgVectorStore 保持一致 */
@Value("${knowledge.vector.dimension:1024}") @Value("${knowledge.vector.dimension:1024}")
private int vectorDimension; private int vectorDimension;
@ -97,6 +100,11 @@ public class FaqMatchEngine {
} }
// 第三级:语义匹配 // 第三级:语义匹配
// 性能开关:pipeline_faq_semantic_enabled 关闭时跳过,只保留前两级(精确/关键词,均为本地 SQL,无网络调用)
if (!pipelineToggleService.isFaqSemanticEnabled()) {
log.info("FAQ 语义匹配已被后台关闭,跳过第三级: question={}", trimmedQuestion);
return Optional.empty();
}
Optional<FaqMatchResult> semanticResult = semanticMatch(trimmedQuestion, categoryIds); Optional<FaqMatchResult> semanticResult = semanticMatch(trimmedQuestion, categoryIds);
if (semanticResult.isPresent()) { if (semanticResult.isPresent()) {
log.info("FAQ 语义匹配命中: question={}, score={}", trimmedQuestion, semanticResult.get().getScore()); log.info("FAQ 语义匹配命中: question={}, score={}", trimmedQuestion, semanticResult.get().getScore());

214
src/main/java/com/wok/supportbot/service/PipelineToggleService.java

@ -0,0 +1,214 @@
package com.wok.supportbot.service;
import com.wok.supportbot.entity.SystemConfig;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* AI 执行链节点开关服务(对话链路性能开关)。
* <p>
* 一次对话在产出首个 token 之前会串行执行若干「旁路节点」——意图路由 LLM 分类、查询重写、
* FAQ 语义匹配,每个节点都要多花一次跨网络调用,累加后直接体现在用户感知的「回复慢」上。
* 本服务为这些节点提供可后台配置的开关,按需关闭以压缩首 token 延迟。
* <p>
* 存储复用 {@code system_config} 表(key-value 模式),默认<b>全部开启</b>,
* 保证升级后行为与改动前完全一致,由管理员主动提速。
* <p>
* <b>为什么需要内存快照</b>:{@link SystemConfigService#getValueByKey} 无缓存、每次直查数据库,
* 而这些开关位于每请求的 hot path(一次对话需判定 3~4 次)。因此本服务在应用就绪时一次性载入
* 全部开关值,构建不可变快照({@code volatile} + 写时整体替换,与 {@code ContentSafetyService}
* 的 DFA 树同一范式),读取路径零锁零数据库访问。
* <p>
* <b>生效语义</b>:管理后台保存后立即重建快照,下一次对话即生效,无需重启。
* 代价是直接修改数据库不会生效——请走管理后台「AI 执行链」页面。
*/
@Slf4j
@Service
public class PipelineToggleService {
/** 意图路由(IntentRouter LLM 分类)开关 */
public static final String KEY_INTENT_LLM = "pipeline_intent_llm_enabled";
/** 查询重写开关 */
public static final String KEY_REWRITE = "pipeline_rewrite_enabled";
/** MULTI_QUERY 多路查询扩展开关 */
public static final String KEY_MULTI_QUERY = "pipeline_multiquery_enabled";
/** FAQ 语义匹配(三级匹配的第三级)开关 */
public static final String KEY_FAQ_SEMANTIC = "pipeline_faq_semantic_enabled";
/**
* 开关元数据定义。后端为单一事实来源,前端不硬编码名称与说明。
*
* @param key 配置键({@code system_config.config_key})
* @param label 中文名称(管理后台「AI 执行链」页面展示)
* @param description 功能说明,同时作为 {@code system_config.description} 落库
* @param impact 关闭后的收益提示(管理后台展示,帮助管理员权衡)
* @param defaultValue 配置项缺失时的兜底默认值(true = 保持历史行为)
*/
public record ToggleDefinition(String key, String label, String description, String impact, boolean defaultValue) {
}
/** 全部可配置的链路节点开关(顺序即管理后台展示顺序,按耗时收益从高到低排列) */
private static final List<ToggleDefinition> DEFINITIONS = List.of(
new ToggleDefinition(KEY_INTENT_LLM, "意图路由(LLM 分类)",
"是否使用 IntentRouter 做 LLM 意图分类(FAQ / RAG / CHITCHAT)",
"关闭后省 1 次 LLM 调用;寒暄词快速路径(本地列表匹配、零开销)仍保留,"
+ "但不再能按意图跳过知识库检索",
true),
new ToggleDefinition(KEY_REWRITE, "查询重写",
"是否按请求携带的 rewriteStrategy 对用户问题做重写后再检索",
"关闭后省 1 次 LLM 调用(REWRITE / TRANSLATION / COMPRESSION 均不再执行),"
+ "直接用原始问题做向量检索",
true),
new ToggleDefinition(KEY_MULTI_QUERY, "多路查询扩展(MULTI_QUERY)",
"MULTI_QUERY 策略是否把 1 个问题扩展为多路查询并分别检索",
"关闭后 MULTI_QUERY 请求降级为单路原文检索,省 3 路 embedding;"
+ "其他重写策略不受影响。注意该策略是前端默认值,关闭收益最大",
true),
new ToggleDefinition(KEY_FAQ_SEMANTIC, "FAQ 语义匹配(第三级)",
"FAQ 三级匹配的第三级(向量余弦相似度)是否启用",
"关闭后省 0~2 次 embedding 调用,FAQ 仅保留精确匹配与关键词匹配;"
+ "近义问法(如「怎么退款」与「如何申请退货」)可能匹配不到",
true)
);
@Autowired
private SystemConfigService systemConfigService;
/** 开关快照:key → 是否启用。不可变 Map,写入时整体替换(volatile 保证可见性) */
private volatile Map<String, Boolean> snapshot = Collections.emptyMap();
/**
* 应用就绪后载入开关快照。
* <p>
* 选用 {@link ApplicationReadyEvent} 而非 {@code @PostConstruct}:建表由
* {@code DatabaseInitConfig} 的 {@code @PostConstruct} 完成,两个 {@code @PostConstruct}
* 的先后顺序不受 Spring 控制,过早读取会因表不存在而失败。就绪前的调用走
* {@link #defaultOf} 兜底(默认 true,与历史行为一致)。
*/
@EventListener(ApplicationReadyEvent.class)
public void loadOnReady() {
reload();
}
/**
* 重新载入开关快照并整体替换(写时重建,读取路径无锁)。
* 读取失败时保留原快照并告警,不影响对话链路可用性。
*/
public void reload() {
try {
// 一次性取全表,避免逐 key 查库(4 次 → 1 次)
Map<String, String> rawValues = new HashMap<>();
for (SystemConfig config : systemConfigService.listAll()) {
rawValues.put(config.getConfigKey(), config.getConfigValue());
}
Map<String, Boolean> loaded = new HashMap<>(DEFINITIONS.size());
for (ToggleDefinition def : DEFINITIONS) {
String value = rawValues.get(def.key());
loaded.put(def.key(), value == null ? def.defaultValue() : parseBoolean(value));
}
this.snapshot = Collections.unmodifiableMap(loaded);
log.info("AI 执行链节点开关已加载: {}", this.snapshot);
} catch (Exception e) {
log.warn("AI 执行链节点开关加载失败,沿用兜底默认值(全部开启): {}", e.getMessage());
}
}
/**
* 判断指定开关是否启用;配置项缺失或应用尚未就绪时回退到定义中的默认值。
*
* @param key 配置键
*/
public boolean isEnabled(String key) {
Boolean enabled = snapshot.get(key);
return enabled != null ? enabled : defaultOf(key);
}
/** 意图路由开关:关闭后跳过 IntentRouter 的 LLM 分类(寒暄词快速路径保留) */
public boolean isIntentLlmEnabled() {
return isEnabled(KEY_INTENT_LLM);
}
/** 查询重写开关:关闭后忽略请求携带的策略,直接用原始问题检索 */
public boolean isRewriteEnabled() {
return isEnabled(KEY_REWRITE);
}
/** 多路扩展开关:关闭后 MULTI_QUERY 请求降级为单路原文检索 */
public boolean isMultiQueryEnabled() {
return isEnabled(KEY_MULTI_QUERY);
}
/** FAQ 语义匹配开关:关闭后 FAQ 仅保留精确匹配与关键词匹配 */
public boolean isFaqSemanticEnabled() {
return isEnabled(KEY_FAQ_SEMANTIC);
}
/**
* 开关清单(含中文名、说明与收益提示),供管理后台「AI 执行链」页面渲染。
*/
public List<ToggleView> listToggles() {
return DEFINITIONS.stream()
.map(def -> new ToggleView(def.key(), def.label(), def.description(), def.impact(),
isEnabled(def.key())))
.toList();
}
/**
* 批量保存开关值并立即生效。
*
* @param values key → 是否启用;未出现在 Map 中的开关保持原值
* @return 保存后的完整开关清单
*/
public List<ToggleView> saveAll(Map<String, Boolean> values) {
for (ToggleDefinition def : DEFINITIONS) {
Boolean enabled = values.get(def.key());
if (enabled == null) {
continue;
}
systemConfigService.saveOrUpdate(def.key(), String.valueOf(enabled), def.description());
}
reload();
return listToggles();
}
/** 开关视图对象(管理后台展示用) */
public record ToggleView(String key, String label, String description, String impact, boolean enabled) {
}
/**
* 全部开关定义。
* <p>
* 供种子数据初始化({@code DatabaseInitConfig})等场景复用,保证配置键、中文说明
* 在「服务定义」与「数据库种子」之间是单一事实来源,避免两处文案漂移。
*/
public static List<ToggleDefinition> definitions() {
return DEFINITIONS;
}
/** 宽松布尔解析:兼容 true / 1(大小写不敏感),其余一律视为 false */
private static boolean parseBoolean(String raw) {
String value = raw == null ? "" : raw.trim();
return "true".equalsIgnoreCase(value) || "1".equals(value);
}
/** 配置项缺失时的兜底默认值;未定义的 key 一律按开启处理(不阻塞链路) */
private static boolean defaultOf(String key) {
return DEFINITIONS.stream()
.filter(def -> def.key().equals(key))
.map(ToggleDefinition::defaultValue)
.findFirst()
.orElse(true);
}
}

19
src/main/resources/init-database.sql

@ -851,6 +851,25 @@ INSERT INTO system_config (config_key, config_value, description)
VALUES ('llm_trace_retention_days', '30', 'LLM 调用追踪记录保留天数(自动清理,默认 30 天)') VALUES ('llm_trace_retention_days', '30', 'LLM 调用追踪记录保留天数(自动清理,默认 30 天)')
ON CONFLICT (config_key) DO NOTHING; ON CONFLICT (config_key) DO NOTHING;
-- AI 执行链节点开关(对话链路性能开关)
-- 默认全部 true = 与历史行为完全一致,由管理员在后台「AI 执行链」页面按需关闭以压缩首 token 延迟。
-- 配置键与中文说明与 PipelineToggleService.DEFINITIONS 保持一致(该处为单一事实来源)。
INSERT INTO system_config (config_key, config_value, description)
VALUES ('pipeline_intent_llm_enabled', 'true', '是否使用 IntentRouter 做 LLM 意图分类(FAQ / RAG / CHITCHAT)')
ON CONFLICT (config_key) DO NOTHING;
INSERT INTO system_config (config_key, config_value, description)
VALUES ('pipeline_rewrite_enabled', 'true', '是否按请求携带的 rewriteStrategy 对用户问题做重写后再检索')
ON CONFLICT (config_key) DO NOTHING;
INSERT INTO system_config (config_key, config_value, description)
VALUES ('pipeline_multiquery_enabled', 'true', 'MULTI_QUERY 策略是否把 1 个问题扩展为多路查询并分别检索')
ON CONFLICT (config_key) DO NOTHING;
INSERT INTO system_config (config_key, config_value, description)
VALUES ('pipeline_faq_semantic_enabled', 'true', 'FAQ 三级匹配的第三级(向量余弦相似度)是否启用')
ON CONFLICT (config_key) DO NOTHING;
-- ============================================================ -- ============================================================
-- 表 17-21: 用户认证与登录安全(与 DatabaseInitConfig 同步) -- 表 17-21: 用户认证与登录安全(与 DatabaseInitConfig 同步)
-- ============================================================ -- ============================================================

2
src/main/resources/static/sdk/test.html

@ -11,8 +11,8 @@
<link rel="modulepreload" crossorigin href="/assets/markdown-BzlZwYco.js"> <link rel="modulepreload" crossorigin href="/assets/markdown-BzlZwYco.js">
<link rel="modulepreload" crossorigin href="/assets/chatAdapter-BMDefjTd.js"> <link rel="modulepreload" crossorigin href="/assets/chatAdapter-BMDefjTd.js">
<link rel="stylesheet" crossorigin href="/assets/tdesign-CY0HVqZ3.css"> <link rel="stylesheet" crossorigin href="/assets/tdesign-CY0HVqZ3.css">
<link rel="stylesheet" crossorigin href="/assets/tdesign-chat-Dj1Q23QO.css">
<link rel="stylesheet" crossorigin href="/assets/tdesign-web-components-B-ycfzW_.css"> <link rel="stylesheet" crossorigin href="/assets/tdesign-web-components-B-ycfzW_.css">
<link rel="stylesheet" crossorigin href="/assets/tdesign-chat-Dj1Q23QO.css">
<link rel="stylesheet" crossorigin href="/assets/sdk-test-wRid8JYa.css"> <link rel="stylesheet" crossorigin href="/assets/sdk-test-wRid8JYa.css">
</head> </head>
<body> <body>

Loading…
Cancel
Save