Compare commits

...

10 Commits

  1. 44
      CLAUDE.md
  2. 58
      SDK-INTEGRATION.md
  3. 32
      client/README.md
  4. 302
      client/src/api.ts
  5. 251
      client/src/chat.ts
  6. 2
      client/src/config.ts
  7. 4
      client/src/dom.ts
  8. 22
      client/src/types.ts
  9. 123
      client/tests/api.test.ts
  10. 118
      client/tests/chat.test.ts
  11. 8
      client/tests/config.test.ts
  12. 28
      frontend/src/api/chat.ts
  13. 15
      frontend/src/components/MessageSources.vue
  14. 148
      frontend/src/sdk-test/SdkTestPanel.vue
  15. 16
      frontend/src/types/models.ts
  16. 18
      frontend/src/types/sse.ts
  17. 3
      frontend/src/utils/chatAdapter.ts
  18. 275
      frontend/src/utils/sse.ts
  19. 153
      frontend/src/views/ChatPanel.vue
  20. 33
      frontend/src/views/ModelConfigManager.vue
  21. 45
      frontend/src/views/PipelineFlow.vue
  22. 164
      frontend/tests/chat-protocol.test.mjs
  23. 6
      pom.xml
  24. 50
      src/main/java/com/wok/supportbot/app/AssistantApp.java
  25. 7
      src/main/java/com/wok/supportbot/app/ChatContext.java
  26. 121
      src/main/java/com/wok/supportbot/app/ChatPipeline.java
  27. 10
      src/main/java/com/wok/supportbot/app/ChatResult.java
  28. 59
      src/main/java/com/wok/supportbot/app/SourceReference.java
  29. 27
      src/main/java/com/wok/supportbot/config/ChatModelFactory.java
  30. 73
      src/main/java/com/wok/supportbot/controller/AiController.java
  31. 14
      src/main/java/com/wok/supportbot/controller/OpenApiController.java
  32. 18
      src/main/java/com/wok/supportbot/rag/RagPipeline.java
  33. 114
      src/main/java/com/wok/supportbot/service/IntentRouter.java
  34. 250
      src/test/java/com/wok/supportbot/AnswerTransportTests.java
  35. 177
      src/test/java/com/wok/supportbot/ChatModelFactoryTests.java
  36. 300
      src/test/java/com/wok/supportbot/ChatPipelineTests.java
  37. 166
      src/test/java/com/wok/supportbot/ChatResultEndpointTests.java

44
CLAUDE.md

@ -16,18 +16,18 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
./mvnw spring-boot:run ./mvnw spring-boot:run
# 运行测试 # 运行测试
./mvnw test
sh ./mvnw test -DskipTests=false
# 运行单个测试类 # 运行单个测试类
./mvnw test -Dtest=SupportBotApplicationTests
sh ./mvnw test -DskipTests=false -Dtest=SupportBotApplicationTests
# 运行单个测试方法 # 运行单个测试方法
./mvnw test -Dtest=SupportBotApplicationTests#testRag
sh ./mvnw test -DskipTests=false -Dtest=SupportBotApplicationTests#testRag
``` ```
**前提条件**: PostgreSQL 12+ 需运行且安装 PGVector 扩展,数据库 `support_bot` 需存在。`knowledge_category`、`knowledge_document`、`ai_model_config` 等表由 `DatabaseInitConfig` 自动创建,无需手动建表。 **前提条件**: PostgreSQL 12+ 需运行且安装 PGVector 扩展,数据库 `support_bot` 需存在。`knowledge_category`、`knowledge_document`、`ai_model_config` 等表由 `DatabaseInitConfig` 自动创建,无需手动建表。
**测试说明**: 所有测试均为集成测试(`@SpringBootTest`),需要运行中的 PostgreSQL 和有效的 DashScope API Key。测试类:`SupportBotApplicationTests`(对话/RAG)、`PgVectorVectorStoreConfigTest`(向量存储)、`QueryTransformerTests`(查询重写策略)。无单元测试。
**测试说明**: `@SpringBootTest` 集成测试需要运行中的 PostgreSQL 和有效的 DashScope API Key。低延迟契约测试无需 DB 或真实 API Key:`ChatPipelineTests` 覆盖 FAQ/分类隔离/查询策略,`ChatModelFactoryTests` 用本地 HTTP 服务验证实际模型请求,`AnswerTransportTests` 覆盖同次引用与流生命周期,`ChatResultEndpointTests` 覆盖同步接口与权限。Surefire 默认跳过测试,运行这些测试使用 `sh ./mvnw test -DskipTests=false -Dtest=ChatPipelineTests,ChatModelFactoryTests,AnswerTransportTests,ChatResultEndpointTests`。前端流协议测试:`node --test frontend/tests/chat-protocol.test.mjs`;SDK 传输与会话生命周期测试:在 `client/` 运行 `npm exec -- vitest run tests/api.test.ts tests/chat.test.ts`。
**访问地址**: 前端管理页面 `http://localhost:9090/index.html`,API 文档 `http://localhost:9090/doc.html`(Knife4j) **访问地址**: 前端管理页面 `http://localhost:9090/index.html`,API 文档 `http://localhost:9090/doc.html`(Knife4j)
@ -46,25 +46,33 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
当前使用 `DatabaseChatMemory`(PostgreSQL 持久化),无文件型 ChatMemory(早期的 `FileBasedChatMemory` 已删除,Kryo 依赖一并移除)。 当前使用 `DatabaseChatMemory`(PostgreSQL 持久化),无文件型 ChatMemory(早期的 `FileBasedChatMemory` 已删除,Kryo 依赖一并移除)。
### 统一对话管道(重构后) ### 统一对话管道(重构后)
对话管道由 `ChatPipeline`(编排层)+ `RagPipeline`(RAG 检索层)+ `AssistantApp`(执行层)组成:
对话管道由 `ChatPipeline`(编排层)+ `RagPipeline`(RAG 检索层)+ `AssistantApp`(执行层)组成(更新:2026-09-14,默认零 LLM 预处理):
``` ```
用户请求 用户请求
→ 鉴权/角色解析(Controller) → 鉴权/角色解析(Controller)
→ AssistantApp 熔断检查(熔断直接降级,不执行 FAQ / 检索)
→ ChatPipeline.buildRequest(ChatContext) → ChatPipeline.buildRequest(ChatContext)
→ IntentRouter 意图路由(CHITCHAT/FAQ/RAG)
→ RagPipeline.retrieve(FAQ 优先 → 查询重写 → 统一检索)
→ 组装 finalMessage + finalSystemPrompt + 资料块
→ AssistantApp.chat / chatStream(构建 ChatClientRequestSpec → call/stream)
→ enableRag=false:普通对话(不调用 FAQ / 检索)
→ enableRag=true:完整 FAQ 三级匹配(角色分类隔离,命中直接返回)
→ 未命中:本地寒暄词判断(无意图分类 LLM)
→ 寒暄命中:纯对话
→ 其余:RagPipeline.retrieve(仅 FAQ 异常时重试 → 默认 NONE 原文检索)
→ 显式 REWRITE / TRANSLATION / COMPRESSION / MULTI_QUERY 才调用重写 LLM
→ 统一向量检索 → 命中/未命中日志仅记录一次
→ 组装 finalMessage + finalSystemPrompt + 资料块 + 当次命中文档
→ AssistantApp.chat / chatStream(安全检查 + 会话记忆 + 一次答案生成;FAQ 不生成)
→ 同步 JSON / SSE metadata 返回当次 sources(不二次检索)
``` ```
- **ChatPipeline**: 纯编排,不持有 ChatClient;产出 `ChatRequest` 决策对象
- **RagPipeline**: 统一 RAG 检索,所有策略(含 MULTI_QUERY)均走"手动检索 + 资料块注入 system prompt"模式,不再使用 `RetrievalAugmentationAdvisor` 的 query augmenter
- **RAG 查询重写策略**: 由 `RagPipeline` 统一路由,`AssistantApp` 等旧方法已移除
- **IntentRouter**: 已在 ChatPipeline 接入,`AiController.shouldBypassKnowledgeRetrieval` 已移除
- **ChatPipeline**: 纯编排,不持有 ChatClient;产出 `ChatRequest` 决策对象。启用 RAG 时先做完整 FAQ 匹配,命中直接返回;未命中仅做本地寒暄判断,其他问题直接检索。仅 `completedCleanly=true` 的未命中允许 `retrieve(ctx, true)` 跳过重复 FAQ,异常未命中仍保留 RAG 中的 FAQ 重试
- **RagPipeline**: 统一 RAG 检索,所有策略(含 MULTI_QUERY)均走"手动检索 + 资料块注入 system prompt"模式;检索命中/未命中日志仅由此层记录,不在编排层重复写入
- **RAG 查询重写策略**: 默认 `NONE`,省去串行意图分类和查询重写 LLM 调用。显式 `REWRITE` / `TRANSLATION` / `COMPRESSION` / `MULTI_QUERY` 保留;`COMPRESSION` 从会话记忆读取最近 10 条消息,将指代追问补全为独立检索问题。默认原文检索不做指代补全,主回答仍通过 `MessageChatMemoryAdvisor` 使用会话记忆
- **本地寒暄路径**: `ChatPipeline.isChitchat` 复用于对话和独立来源检索;不使用意图分类 LLM
- **分类过滤**: 统一由 `CategoryFilter` 工具类处理(`parse`/`normalize`/`buildExpression`) - **分类过滤**: 统一由 `CategoryFilter` 工具类处理(`parse`/`normalize`/`buildExpression`)
- **AssistantApp 入口**: `chat(ChatContext)` / `chatStream(ChatContext)` / `retrieveSources(ChatContext)`,旧方法(`doChat*`、`doChatWithRag*`)已移除 - **AssistantApp 入口**: `chat(ChatContext)` / `chatStream(ChatContext)` / `retrieveSources(ChatContext)`,旧方法(`doChat*`、`doChatWithRag*`)已移除
- **Open API**: `OpenApiController` 已接入 `ChatPipeline`,补齐角色/RAG/FAQ/MCP/分类隔离能力 - **Open API**: `OpenApiController` 已接入 `ChatPipeline`,补齐角色/RAG/FAQ/MCP/分类隔离能力
- **引用复用**: 自有客户端不调用 `/ai/chat/sources`。同步 `/ai/chat/result` 直接返回 `{text,mcpEvents,suggestions,sources}`;流式在正文完成后、stop / `[DONE]` 前发送一次 `choices:[]` + `sources` 的 OpenAI 扩展 metadata chunk,FAQ/非 RAG/熔断来源为空。已发布 `/ai/chat` 文本和 `/ai/chat/sources` 独立检索接口保留
### 文档处理管道 ### 文档处理管道
`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` 表。 `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` 表。
@ -113,6 +121,7 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
- **启动校验**: `ModelConfigLoader` 在应用就绪后检查 DB 中每种 App 类型是否有活跃配置,并对 DashScope 提供商比较 DB 与 yml 的 API Key 一致性 - **启动校验**: `ModelConfigLoader` 在应用就绪后检查 DB 中每种 App 类型是否有活跃配置,并对 DashScope 提供商比较 DB 与 yml 的 API Key 一致性
- **API Key 脱敏**: 前端展示时只显示前 4 位 + `****` + 后 4 位 - **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` - **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`
- **Seed 2.0 思考策略**: `volcengine` + `doubao-seed-2-0-*` 的 `CHAT` 默认 `reasoning_effort=minimal`(关闭思考);高级参数 `extraConfig.reasoningEffort` 可显式设为 `minimal/low/medium/high`,清空恢复 CHAT 快速默认。`RAG_REWRITE` 留空及其他模型沿用厂商默认;工厂白名单仅作用于 Seed 2.0,前端编辑保留其他 `extraConfig` 字段
- **EmbeddingModel 运行时切换**: 通过 `EmbeddingModelFactory` + `DynamicEmbeddingModel` 代理,按 DB 活跃配置动态创建/缓存 EmbeddingModel,`PgVectorStoreConfig` 和 `InMemoryVectorStoreConfig` 注入 `DynamicEmbeddingModel`,向量化模型配置变更后无需重启即可生效 - **EmbeddingModel 运行时切换**: 通过 `EmbeddingModelFactory` + `DynamicEmbeddingModel` 代理,按 DB 活跃配置动态创建/缓存 EmbeddingModel,`PgVectorStoreConfig` 和 `InMemoryVectorStoreConfig` 注入 `DynamicEmbeddingModel`,向量化模型配置变更后无需重启即可生效
- **多提供商支持**: DashScope(通义千问)+ OpenAI 兼容提供商(DeepSeek / 豆包 / Kimi / 智谱 / OpenAI),ChatModel 和 EmbeddingModel 均通过对应 API 手动构建 - **多提供商支持**: DashScope(通义千问)+ OpenAI 兼容提供商(DeepSeek / 豆包 / Kimi / 智谱 / OpenAI),ChatModel 和 EmbeddingModel 均通过对应 API 手动构建
- **缓存刷新**: 模型配置增删改激活时 Controller 自动调用 `ChatModelFactory.clearCache()` + `EmbeddingModelFactory.clearCache()` + `AssistantApp.clearCache()`;MCP Server 增删改/启停/全量刷新时 `McpServerConfigController` 亦会调用 `AssistantApp.clearCache()`(避免继续使用旧的 MCP 工具集) - **缓存刷新**: 模型配置增删改激活时 Controller 自动调用 `ChatModelFactory.clearCache()` + `EmbeddingModelFactory.clearCache()` + `AssistantApp.clearCache()`;MCP Server 增删改/启停/全量刷新时 `McpServerConfigController` 亦会调用 `AssistantApp.clearCache()`(避免继续使用旧的 MCP 工具集)
@ -271,7 +280,7 @@ catch (e) { toast('操作失败', 'error') }
- `ChatPipeline.buildRequest()` 的决策分支(意图路由、FAQ、RAG、纯对话路径)发生变更 - `ChatPipeline.buildRequest()` 的决策分支(意图路由、FAQ、RAG、纯对话路径)发生变更
- `RagPipeline.retrieve()` 的检索流程(查询重写策略、检索方式、资料拼装)发生变更 - `RagPipeline.retrieve()` 的检索流程(查询重写策略、检索方式、资料拼装)发生变更
- `AssistantApp` 的 Advisor 链成员或顺序发生变更(如新增/移除 Advisor) - `AssistantApp` 的 Advisor 链成员或顺序发生变更(如新增/移除 Advisor)
- 新增管道阶段组件(如 `IntentRouter`、`SuggestionGenerator`、`SimpleCircuitBreaker` 等)或移除现有组件
- 新增管道阶段组件(如 `SuggestionGenerator`、`SimpleCircuitBreaker` 等)或移除现有组件
- 组件间调用关系调整(如原来 A→B 改为 A→C→B) - 组件间调用关系调整(如原来 A→B 改为 A→C→B)
**图表元数据**: `PipelineFlow.vue` 中 DSL 首行有 `%%graph-meta` 注释标记最后更新时间,修改图表时必须更新该日期。 **图表元数据**: `PipelineFlow.vue` 中 DSL 首行有 `%%graph-meta` 注释标记最后更新时间,修改图表时必须更新该日期。
@ -341,8 +350,8 @@ catch (e) { toast('操作失败', 'error') }
- **Chat SDK**: `handleFeedback()` 已连接后端 API,同时保留 localStorage 作为乐观 UI 缓存 - **Chat SDK**: `handleFeedback()` 已连接后端 API,同时保留 localStorage 作为乐观 UI 缓存
- **会话导出**: `ConversationService.exportConversation()` 导出的 TXT 中包含反馈信息 - **会话导出**: `ConversationService.exportConversation()` 导出的 TXT 中包含反馈信息
### 意图识别 + FAQ 精准匹配(P0-003)
- **IntentRouter**: LLM 单次调用做意图分类(FAQ/RAG/CHITCHAT);结构化输出由标准组件 `BeanOutputConverter<IntentResult>` 生成 JSON Schema 指令并反序列化结果,解析失败/结果非法降级为 RAG
### 本地路由 + FAQ 精准匹配(P0-003)
- **本地路由**: 完整 FAQ 优先,未命中后仅精确寒暄词跳过 RAG;默认不调用 LLM 做意图分类
- **FaqMatchEngine**: 三级匹配策略 — 精确匹配 → 关键词匹配 → 向量语义匹配(阈值 `knowledge.faq.semantic-threshold`,默认 0.85) - **FaqMatchEngine**: 三级匹配策略 — 精确匹配 → 关键词匹配 → 向量语义匹配(阈值 `knowledge.faq.semantic-threshold`,默认 0.85)
- **FAQ 向量化**: 复用现有 `DynamicEmbeddingModel`,向量存入 `faq_embedding` 表,新增/修改 FAQ 时异步计算 - **FAQ 向量化**: 复用现有 `DynamicEmbeddingModel`,向量存入 `faq_embedding` 表,新增/修改 FAQ 时异步计算
- **similar_questions 字段**: 使用 String 类型存储 JSON 数组字符串(PostgresJsonTypeHandler 期望对象格式,故不用 typeHandler) - **similar_questions 字段**: 使用 String 类型存储 JSON 数组字符串(PostgresJsonTypeHandler 期望对象格式,故不用 typeHandler)
@ -363,12 +372,11 @@ catch (e) { toast('操作失败', 'error') }
- **异步写入**:`LlmCallTraceService.recordAsync` 走 `@Async("traceExecutor")`(有界线程池见 `AsyncExecutorConfig`,替代默认 `SimpleAsyncTaskExecutor` 线程爆炸隐患;`RagHitLogService` 已一并切换)。 - **异步写入**:`LlmCallTraceService.recordAsync` 走 `@Async("traceExecutor")`(有界线程池见 `AsyncExecutorConfig`,替代默认 `SimpleAsyncTaskExecutor` 线程爆炸隐患;`RagHitLogService` 已一并切换)。
- **查询面板**:前端「系统设置 → 提示词追踪」(`PromptTracePanel.vue`,admin),支持筛选/搜索/详情(来源标注)/聚合统计/并排对比/跳转编辑(deep-link `?roleId=`、`?key=ai_system_prompt`)。 - **查询面板**:前端「系统设置 → 提示词追踪」(`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 点自动清理)。字段级加密留二期。 - **安全声明**:`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` 字段)。
- **预留**:`account_id`/`api_key_id` 列预留租户隔离与个人信息删除权,本期不写入;旁路 LLM 调用(显式查询重写/建议生成)追踪留二期(表加 `trace_type`/`source` 字段)。
## 已知 TODO ## 已知 TODO
- `DocumentService.updateDocumentMetadata()`: Spring AI 无直接更新 vector_store metadata 的 API,向量元数据同步留后续 - `DocumentService.updateDocumentMetadata()`: Spring AI 无直接更新 vector_store metadata 的 API,向量元数据同步留后续
- `DocumentService.searchDocuments()`: **分类过滤已实现**(`FilterExpressionBuilder` 组合 `enabled` + `categoryId` 过滤表达式,向量检索异常时回退到本地 metadata 过滤)—— 原「Spring AI filter 支持有限」的 TODO 已不成立 - `DocumentService.searchDocuments()`: **分类过滤已实现**(`FilterExpressionBuilder` 组合 `enabled` + `categoryId` 过滤表达式,向量检索异常时回退到本地 metadata 过滤)—— 原「Spring AI filter 支持有限」的 TODO 已不成立
- `CompressionQueryRewriter`: 当前传入空历史列表
- MyBatis Plus `mybatis-plus-spring-boot3-starter` 不含 `PaginationInnerInterceptor`,分页通过 SQL `LIMIT/OFFSET` 手动实现 - MyBatis Plus `mybatis-plus-spring-boot3-starter` 不含 `PaginationInnerInterceptor`,分页通过 SQL `LIMIT/OFFSET` 手动实现
- `PgVectorStoreConfig.dimensions(1024)` 硬编码了向量维度,切换非 1024 维的 Embedding 模型时需修改并重建 vector_store 表 → **已修复:维度由 `knowledge.vector.dimension` 配置,启动时自动检测不匹配并告警** - `PgVectorStoreConfig.dimensions(1024)` 硬编码了向量维度,切换非 1024 维的 Embedding 模型时需修改并重建 vector_store 表 → **已修复:维度由 `knowledge.vector.dimension` 配置,启动时自动检测不匹配并告警**

58
SDK-INTEGRATION.md

@ -276,9 +276,10 @@ POST /open-api/auth/token
| 接口 | 方法 | 说明 | | 接口 | 方法 | 说明 |
|---|---|---| |---|---|---|
| `/ai/chat` | GET | 同步对话(已统一为 ChatPipeline,支持普通/RAG 自动判断) |
| `/ai/chat/stream` | GET | SSE 流式对话(OpenAI Chat Completions 兼容格式,已统一为 ChatPipeline) |
| `/ai/chat/sources` | GET | 获取 RAG 引用来源 |
| `/ai/chat/result` | GET | SDK/管理后台同步对话,直接返回完整 JSON 结果(正文、工具事件、建议、实际引用) |
| `/ai/chat` | GET | 已发布的外部纯文本同步接口,继续保留;SDK 不回退到此接口 |
| `/ai/chat/stream` | GET | SSE 流式对话(OpenAI Chat Completions 兼容格式,流内携带实际引用) |
| `/ai/chat/sources` | GET | 已发布的独立显式检索接口,继续保留;对话客户端不调用此接口二次检索 |
| `/ai/sdk/conversation/list` | GET | 会话列表 | | `/ai/sdk/conversation/list` | GET | 会话列表 |
| `/ai/sdk/conversation/{id}/messages` | GET | 会话消息 | | `/ai/sdk/conversation/{id}/messages` | GET | 会话消息 |
| `/ai/sdk/conversation/{id}` | DELETE | 删除会话 | | `/ai/sdk/conversation/{id}` | DELETE | 删除会话 |
@ -286,7 +287,48 @@ POST /open-api/auth/token
| `/category/tree` | GET | 知识库分类树 | | `/category/tree` | GET | 知识库分类树 |
| `/feedback` | POST | 消息反馈 | | `/feedback` | POST | 消息反馈 |
> **已废弃**:`/ai/assistant_app/chat/server_sent_event` 和 `/ai/assistant_app/chat/sse_emitter` 已移除。旧路径 `/ai/assistant_app/chat/sync`、`/ai/assistant_app/chat/sse`、`/ai/assistant_app/chat/rag/sse`、`/ai/assistant_app/rag/sources` 仍保留(向后兼容)但已废弃,请统一使用 `/ai/chat`、`/ai/chat/stream`、`/ai/chat/sources`。
> **已废弃**:`/ai/assistant_app/chat/server_sent_event` 和 `/ai/assistant_app/chat/sse_emitter` 已移除。旧路径 `/ai/assistant_app/chat/sync`、`/ai/assistant_app/chat/sse`、`/ai/assistant_app/chat/rag/sse`、`/ai/assistant_app/rag/sources` 仍保留(向后兼容)但已废弃。自有对话客户端统一使用 `/ai/chat/result`、`/ai/chat/stream`,引用复用同次回答的实际命中,不额外发起检索。
#### 同步结果与引用契约
`GET /ai/chat/result` 与 `/ai/chat` 使用完全相同的参数:`message`(必填)、`chatId`、`roleId`、`accountId`、`systemPrompt`、`enableRag`、`rewriteStrategy`、`categoryId`、`categoryIds`、`imageUrls`。图片仍按逗号分隔并解码 URL;角色提示词、MCP 工具与分类权限仍由服务端解析,客户端不能用分类参数覆盖角色范围。`/ai/**` 由 SDK JWT / 管理后台 JWT 鉴权守卫。
响应直接是 JSON(**没有** `success/data` 外层):
```json
{
"text": "回答正文",
"mcpEvents": [],
"suggestions": [],
"sources": [{
"documentId": "1960000000000000001",
"title": "退货说明",
"sourceName": "售后手册.pdf",
"chunkIndex": 0,
"score": 0.12,
"snippet": "实际用于本次回答的知识库片段"
}]
}
```
`sources` 只来自本次授权检索后注入答案的命中文档。每项固定包含 `documentId: string|null`、`title: string|null`、`sourceName: string|null`、`chunkIndex: number|null`、`score: number|null`、`snippet: string|null`。ID 始终为字符串,不能转为 JavaScript Number;snippet 含截断标记在内最多 160 字符。score 保留原来源接口的 distance 语义(检索实现只提供 score 时使用 score),不要一律假定越大越相关。FAQ、无 RAG、熔断或错误降级返回 `sources: []`。推荐问题仍按需另行生成。
#### 流内引用(同时适用于 Open API)
`/ai/chat/stream` 和 `/open-api/chat/stream` 保留 OpenAI envelope。正常正文结束后、`finish_reason: "stop"` 与 `[DONE]` **之前**恰好追加一条扩展元数据 chunk:
```text
data: {"id":"chatcmpl-example","object":"chat.completion.chunk","created":1789344000,"model":"configured-model","choices":[{"index":0,"delta":{"content":"回答正文"},"finish_reason":null}]}
data: {"id":"chatcmpl-example","object":"chat.completion.chunk","created":1789344000,"model":"configured-model","choices":[],"sources":[]}
data: {"id":"chatcmpl-example","object":"chat.completion.chunk","created":1789344000,"model":"configured-model","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
data: [DONE]
```
上例为无引用的回答;RAG 回答的 `sources` 使用上述相同结构。所有 chunk 共享 `id/created/model`。引用元数据不是正文,不得拼入回答;通用 OpenAI 客户端可忽略该扩展字段。完整 `[DONE]` 事件到达即结束读取,无需等待网络 EOF;主动取消或异常不会重跑生成/检索。首条仅含 `role` 的协议帧不是答案首 token。
### 6.3 Open API 对话接口(第三方系统直接调用) ### 6.3 Open API 对话接口(第三方系统直接调用)
@ -301,10 +343,12 @@ POST /open-api/auth/token
| 参数 | 类型 | 必填 | 说明 | | 参数 | 类型 | 必填 | 说明 |
|---|---|---|---| |---|---|---|---|
| `categoryIds` | Query | 否 | 知识库分类 ID,逗号分隔(非角色场景用) | | `categoryIds` | Query | 否 | 知识库分类 ID,逗号分隔(非角色场景用) |
| `rewriteStrategy` | Query | 否 | RAG 查询重写策略:REWRITE / TRANSLATION / COMPRESSION / MULTI_QUERY(默认) |
| `rewriteStrategy` | Query | 否 | NONE(默认,原问题直接检索)/ REWRITE / TRANSLATION / COMPRESSION / MULTI_QUERY |
| `enableRag` | Query | 否 | 是否启用 RAG 检索(默认 true),false 时走普通对话 | | `enableRag` | Query | 否 | 是否启用 RAG 检索(默认 true),false 时走普通对话 |
**认证方式:** 所有 `/open-api/**` 和 `/ai/**` 请求通过 `X-API-Key` Header 或 Bearer Token 鉴权。
`POST /open-api/chat` 保留 `success/data` 包装与 `data.reply`、`data.chatId`,并在 `data` 内增加 `mcpEvents`、`suggestions`、`sources`,与同步完整结果使用相同引用结构。
**认证方式:** `/open-api/**` 通过 API Key 鉴权;`/ai/**` 使用 `Authorization: Bearer <SDK JWT 或管理后台 JWT>`,不能直接用 API Key 代替 JWT。
### 6.3 管理接口(需要管理后台 JWT) ### 6.3 管理接口(需要管理后台 JWT)
@ -342,7 +386,7 @@ POST /open-api/auth/token
| `theme` | String | 'light' | 界面主题:light / dark | | `theme` | String | 'light' | 界面主题:light / dark |
| `streaming` | Boolean | true | 流式回复 | | `streaming` | Boolean | true | 流式回复 |
| `enableRag` | Boolean | true | RAG 知识库检索 | | `enableRag` | Boolean | true | RAG 知识库检索 |
| `rewriteStrategy` | String | 'REWRITE' | 查询重写策略 |
| `rewriteStrategy` | String | 'NONE' | 默认不调用 LLM 预处理;可显式选择查询重写策略 |
| `quickReplies` | String[] | [] | 快捷问题列表 | | `quickReplies` | String[] | [] | 快捷问题列表 |
| `showClear` | Boolean | true | 显示清空按钮 | | `showClear` | Boolean | true | 显示清空按钮 |
| `showAdminPanel` | Boolean | false | 显示管理入口 | | `showAdminPanel` | Boolean | false | 显示管理入口 |

32
client/README.md

@ -78,7 +78,8 @@ SDK 产物位于 `client/dist/` 目录:
| `requestDomain` | `string` | ✅ | — | P0 | 后端 API 域名 | | `requestDomain` | `string` | ✅ | — | P0 | 后端 API 域名 |
| `userId` | `string` | ❌ | — | P0 | 宿主用户标识 → 后端 `accountId` | | `userId` | `string` | ❌ | — | P0 | 宿主用户标识 → 后端 `accountId` |
| `roleId` | `number` | ❌ | — | P0 | 客服角色 ID | | `roleId` | `number` | ❌ | — | P0 | 客服角色 ID |
| `enableRag` | `boolean` | ❌ | `false` | P1 | 启用 RAG 知识库检索对话(走 `/ai/chat/stream` 接口,`enableRag=true`) |
| `enableRag` | `boolean` | 可选 | `true` | P1 | 同步和流式对话均启用 RAG 知识库检索 |
| `rewriteStrategy` | `string` | 可选 | `"NONE"` | P1 | 默认不调用查询重写模型;显式支持 `REWRITE` / `TRANSLATION` / `COMPRESSION` / `MULTI_QUERY` |
| `categoryId` | `number` | ❌ | — | P1 | 默认知识库分类 | | `categoryId` | `number` | ❌ | — | P1 | 默认知识库分类 |
| `showCategorySwitch` | `boolean` | ❌ | `false` | P1 | 是否显示知识库下拉切换 | | `showCategorySwitch` | `boolean` | ❌ | `false` | P1 | 是否显示知识库下拉切换 |
| `title` | `string` | ❌ | `"AI 智能助手"` | P0 | 弹窗标题 | | `title` | `string` | ❌ | `"AI 智能助手"` | P0 | 弹窗标题 |
@ -92,7 +93,7 @@ SDK 产物位于 `client/dist/` 目录:
| `theme` | `string` | ❌ | `"light"` | P2 | 主题模式:`"light"` / `"dark"` | | `theme` | `string` | ❌ | `"light"` | P2 | 主题模式:`"light"` / `"dark"` |
| `showTeaser` | `boolean` | ❌ | `true` | P1 | 首访提示气泡(延迟 1.5s 弹出) | | `showTeaser` | `boolean` | ❌ | `true` | P1 | 首访提示气泡(延迟 1.5s 弹出) |
| `teaserText` | `string` | ❌ | i18n 默认 | P1 | 提示气泡文字,留空使用语言包默认值 | | `teaserText` | `string` | ❌ | i18n 默认 | P1 | 提示气泡文字,留空使用语言包默认值 |
| `streaming` | `boolean` | ❌ | `true` | P0 | 是否启用 SSE 流式输出 |
| `streaming` | `boolean` | 可选 | `true` | P0 | `true` 使用 SSE;`false` 使用 `/ai/chat/result` 获取完整 JSON 答案及同次来源 |
| `locale` | `string` | ❌ | `"zh-CN"` | P2 | 界面语言:`zh-CN` / `en` | | `locale` | `string` | ❌ | `"zh-CN"` | P2 | 界面语言:`zh-CN` / `en` |
| `debug` | `boolean` | ❌ | `true` | P0 | 是否输出调试日志 | | `debug` | `boolean` | ❌ | `true` | P0 | 是否输出调试日志 |
@ -118,7 +119,7 @@ SDK 产物位于 `client/dist/` 目录:
默认开启(`streaming: true`),AI 回复逐字输出,支持: 默认开启(`streaming: true`),AI 回复逐字输出,支持:
- 流式追加到气泡,实时滚动到底部 - 流式追加到气泡,实时滚动到底部
- 流中断兜底:保留已接收内容 + 灰色提示 - 流中断兜底:保留已接收内容 + 灰色提示
- 无流内容时自动降级为同步请求
- 收到完整 `[DONE]` 行立即结束并释放 reader,不等待服务器关闭连接;空流不再发起同步重试
### 4.2 Markdown 渲染 ### 4.2 Markdown 渲染
@ -187,7 +188,7 @@ ChatbotSDK.init({
- 默认折叠,只显示标题行,点击展开/折叠 - 默认折叠,只显示标题行,点击展开/折叠
- 显示文档名称、摘要、来源文件、分块编号、相关度 - 显示文档名称、摘要、来源文件、分块编号、相关度
- 来源数据从 `/ai/chat/sources` 接口获取
- 来源来自本次答案实际命中的文档,与正文使用同一个请求;SDK 不再调用 `/ai/chat/sources` 做二次检索
--- ---
@ -247,18 +248,37 @@ SDK 全流程结构化日志,带 `[ChatbotSDK]` 前缀:
### P0 — 基础对话 ### P0 — 基础对话
``` ```
GET /ai/chat # 同步对话
GET /ai/chat/result # 同步 JSON 对话(streaming=false)
GET /ai/chat/stream # SSE 流式对话 GET /ai/chat/stream # SSE 流式对话
``` ```
### P1 — 知识库联动 ### P1 — 知识库联动
``` ```
GET /ai/chat/stream # RAG 增强流式对话(enableRag=true) GET /ai/chat/stream # RAG 增强流式对话(enableRag=true)
GET /ai/chat/sources # RAG 引用来源
GET /category/tree # 分类树(下拉框数据源) GET /category/tree # 分类树(下拉框数据源)
GET /category/list # 分类列表 GET /category/list # 分类列表
``` ```
### 答案与来源协议
默认 `rewriteStrategy: 'NONE'`,主链不再使用 LLM 意图分类,也不默认做查询重写;本地寒暄和 FAQ 匹配仍有效。用户显式配置其他重写策略时保持该策略,不强制覆盖。
同步 `/ai/chat/result` 直接返回 JSON(没有 `success/data` 包装):
```json
{"text":"答案正文","mcpEvents":[],"suggestions":[],"sources":[]}
```
流式 `/ai/chat/stream` 继续使用 OpenAI `chat.completion.chunk` envelope。正文完成后、`finish_reason: "stop"` / `[DONE]` 之前发送一次元数据 chunk:
```json
{"id":"chat-id","object":"chat.completion.chunk","created":1700000000,"model":"model-name","choices":[],"sources":[{"documentId":"1234567890123456789","title":"文档标题","sourceName":"manual.pdf","chunkIndex":0,"score":0.9,"snippet":"160 字以内摘要"}]}
```
`sources` 各字段允许 `null`;`documentId` 始终为字符串以保持雪花 ID 精度。FAQ、无 RAG、熔断答案返回空数组。SDK 仅将 `choices[].delta.content` 渲染为正文,来源扩展字段交给对应消息的来源卡片,未知 OpenAI 客户端可忽略该扩展。
解析器支持 UTF-8 分片、CRLF、多行 data 与 EOF 残留;`[DONE]` 终止整个流且完成回调只执行一次。取消或切换会话不会把旧请求引用写入新消息。原 `/ai/chat` 文本接口和 `/ai/chat/sources` 显式检索接口仍是后端发布 API,但不是 SDK 对话回退路径。
### P2 — 会话管理 ### P2 — 会话管理
``` ```
GET /conversation/list # 会话列表 GET /conversation/list # 会话列表

302
client/src/api.ts

@ -6,7 +6,7 @@
* userId → accountId(客户账号 ID) * userId → accountId(客户账号 ID)
* chatId → 自动管理的对话 ID(从 /ai/sdk/conversation/list 获取或自动生成) * chatId → 自动管理的对话 ID(从 /ai/sdk/conversation/list 获取或自动生成)
*/ */
import { ResolvedConfig, ApiResponse, CategoryNode, ImageAttachment } from './types';
import { ResolvedConfig, ApiResponse, CategoryNode, ImageAttachment, ChatResult, RagSource } from './types';
import { logger } from './logger'; import { logger } from './logger';
import { t } from './i18n'; import { t } from './i18n';
@ -81,30 +81,8 @@ function appendImageUrls(params: URLSearchParams, imageUrls?: string[]): void {
} }
} }
/**
* 构建同步对话请求 URL
* - integrateId → roleId
* - userId → accountId
* - chatId → 自动管理的对话 ID
*/
function buildChatUrl(message: string, imageUrls?: string[]): string {
const params = new URLSearchParams();
params.set('message', message);
params.set('chatId', currentConfig!.chatId);
// integrateId 映射为 roleId
setIfPresent(params, 'roleId', getActiveIntegrateId());
// userId 映射为 accountId
setIfPresent(params, 'accountId', currentConfig!.userId);
appendImageUrls(params, imageUrls);
return buildUrl(`/ai/chat?${params.toString()}`);
}
/**
* 构建 SSE 流式请求 URL
*/
function buildChatSSEUrl(message: string, categoryId?: number, useRag?: boolean, imageUrls?: string[]): string {
/** 同步和流式对话共享参数,确保 RAG、分类与角色语义一致。 */
function buildChatUrl(path: string, message: string, categoryId?: number, useRag?: boolean, imageUrls?: string[]): string {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('message', message); params.set('message', message);
params.set('chatId', currentConfig!.chatId); params.set('chatId', currentConfig!.chatId);
@ -113,31 +91,15 @@ function buildChatSSEUrl(message: string, categoryId?: number, useRag?: boolean,
setIfPresent(params, 'accountId', currentConfig!.userId); setIfPresent(params, 'accountId', currentConfig!.userId);
setIfPresent(params, 'categoryId', categoryId ?? currentConfig!.categoryId); setIfPresent(params, 'categoryId', categoryId ?? currentConfig!.categoryId);
// RAG 增强:追加 enableRag=true 与查询重写策略,后端按角色绑定的知识库分类自动检索
if (useRag) {
params.set('enableRag', 'true');
params.set('rewriteStrategy', currentConfig!.rewriteStrategy || 'REWRITE');
params.set('enableRag', String(useRag ?? currentConfig!.enableRag));
if (useRag ?? currentConfig!.enableRag) {
params.set('rewriteStrategy', currentConfig!.rewriteStrategy || 'NONE');
} }
appendImageUrls(params, imageUrls); appendImageUrls(params, imageUrls);
return buildUrl(`/ai/chat/stream?${params.toString()}`);
return buildUrl(`${path}?${params.toString()}`);
} }
/**
* 构建 RAG 引用来源请求 URL
*/
function buildRagSourcesUrl(message: string, categoryId?: number): string {
const params = new URLSearchParams();
params.set('message', message);
params.set('chatId', currentConfig!.chatId);
params.set('rewriteStrategy', currentConfig!.rewriteStrategy || 'REWRITE');
setIfPresent(params, 'roleId', getActiveIntegrateId());
setIfPresent(params, 'accountId', currentConfig!.userId);
setIfPresent(params, 'categoryId', categoryId ?? currentConfig!.categoryId);
return buildUrl(`/ai/chat/sources?${params.toString()}`);
}
// ==================== HTTP 基础封装 ==================== // ==================== HTTP 基础封装 ====================
@ -235,21 +197,21 @@ function getHttpErrorMessage(status: number): string {
/** /**
* 同步对话请求 * 同步对话请求
*/ */
export async function chatRequest(message: string, imageUrls?: string[]): Promise<string> {
const url = buildChatUrl(message, imageUrls);
export async function chatRequest(message: string, imageUrls?: string[], categoryId?: number, useRag?: boolean, signal?: AbortSignal): Promise<ChatResult> {
const url = buildChatUrl('/ai/chat/result', message, categoryId, useRag, imageUrls);
logger.lifecycleSend(getActiveIntegrateId(), message.length); logger.lifecycleSend(getActiveIntegrateId(), message.length);
try { try {
const response = await safeFetch(url);
const response = await safeFetch(url, {}, REQUEST_TIMEOUT, signal);
if (!response.ok) { if (!response.ok) {
const errorMsg = getHttpErrorMessage(response.status); const errorMsg = getHttpErrorMessage(response.status);
logger.lifecycleError(getActiveIntegrateId(), String(response.status), errorMsg); logger.lifecycleError(getActiveIntegrateId(), String(response.status), errorMsg);
throw new CskError(errorMsg, `http_${response.status}`); throw new CskError(errorMsg, `http_${response.status}`);
} }
const text = await response.text();
logger.lifecycleReply(getActiveIntegrateId(), text.length);
return text;
const result: ChatResult = await response.json();
logger.lifecycleReply(getActiveIntegrateId(), result.text.length);
return result;
} catch (err) { } catch (err) {
if (err instanceof CskError) throw err; if (err instanceof CskError) throw err;
logger.lifecycleError(getActiveIntegrateId(), 'unknown', String(err)); logger.lifecycleError(getActiveIntegrateId(), 'unknown', String(err));
@ -270,194 +232,131 @@ export async function chatSSERequest(
categoryId?: number, categoryId?: number,
useRag?: boolean, useRag?: boolean,
imageUrls?: string[], imageUrls?: string[],
signal?: AbortSignal
signal?: AbortSignal,
onSources?: (sources: RagSource[]) => void
): Promise<void> { ): Promise<void> {
const url = useRag
? buildChatSSEUrl(message, categoryId, true, imageUrls)
: buildChatSSEUrl(message, categoryId, false, imageUrls);
const url = buildChatUrl('/ai/chat/stream', message, categoryId, useRag, imageUrls);
let totalText = ''; let totalText = '';
/**
* 处理单个 SSE 原始块:优先按 OpenAI Chat Completions JSON 解析,
* 解析失败则回退为纯文本整体追加。
* @param raw 累积的原始事件内容(eventLines 拼接结果)
*/
let completed = false;
const finish = () => {
if (completed) return;
completed = true;
onDone();
};
const emitRawChunk = (raw: string): void => { const emitRawChunk = (raw: string): void => {
// 尝试按 OpenAI Chat Completions JSON 解析,解析失败则视为纯文本
let obj: unknown = null;
let obj: unknown;
try { try {
obj = JSON.parse(raw); obj = JSON.parse(raw);
} catch { } catch {
obj = null; obj = null;
} }
if (obj && typeof obj === 'object') { if (obj && typeof obj === 'object') {
const record = obj as Record<string, unknown>; const record = obj as Record<string, unknown>;
// OpenAI 错误 chunk:{ error: { message, type } }
if (record.error) { if (record.error) {
const err = record.error as { message?: string; type?: string }; const err = record.error as { message?: string; type?: string };
onError(new CskError(String(err.message || err.type || 'stream error'), 'stream_error'));
return;
throw new CskError(String(err.message || err.type || 'stream error'), 'stream_error');
} }
// OpenAI Chat Completions chunk:{ choices: [{ delta: { content } }] }
if (Array.isArray(record.choices)) { if (Array.isArray(record.choices)) {
if (Array.isArray(record.sources)) onSources?.(record.sources as RagSource[]);
const first = (record.choices as Array<{ delta?: { content?: unknown } }>)[0]; const first = (record.choices as Array<{ delta?: { content?: unknown } }>)[0];
const content = first?.delta?.content; const content = first?.delta?.content;
if (typeof content === 'string' && content.length > 0) { if (typeof content === 'string' && content.length > 0) {
totalText += content; totalText += content;
onChunk(content); onChunk(content);
} }
// role / finish_reason 等空 chunk 直接跳过
return; return;
} }
} }
// 纯文本回退:整体作为文本追加
totalText += raw; totalText += raw;
onChunk(raw); onChunk(raw);
}; };
logger.lifecycleSend(getActiveIntegrateId(), message.length); logger.lifecycleSend(getActiveIntegrateId(), message.length);
try { try {
const response = await safeFetch(url, {}, REQUEST_TIMEOUT * 2, signal); const response = await safeFetch(url, {}, REQUEST_TIMEOUT * 2, signal);
if (!response.ok) { if (!response.ok) {
const errorMsg = getHttpErrorMessage(response.status);
logger.lifecycleError(getActiveIntegrateId(), String(response.status), errorMsg);
onError(new CskError(errorMsg, `http_${response.status}`));
return;
throw new CskError(getHttpErrorMessage(response.status), `http_${response.status}`);
} }
const reader = response.body?.getReader(); const reader = response.body?.getReader();
if (!reader) {
onError(new CskError(t('error_stream_unsupported'), 'stream_unsupported'));
return;
}
if (!reader) throw new CskError(t('error_stream_unsupported'), 'stream_unsupported');
const decoder = new TextDecoder('utf-8', { stream: true } as TextDecoderOptions);
const decoder = new TextDecoder();
let buffer = ''; let buffer = '';
let eventLines: string[] = []; let eventLines: string[] = [];
/** 当前 SSE 事件类型(event:xxx 行声明的类型),空=默认 message 事件 */
let currentEventType = ''; let currentEventType = '';
let stopped = false;
const flushEvent = () => {
if (eventLines.length && currentEventType !== 'status' && currentEventType !== 'tool_call_result') {
emitRawChunk(eventLines.join('\n'));
}
eventLines = [];
currentEventType = '';
};
const consumeLine = (raw: string) => {
const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
if (line === '') {
flushEvent();
return;
}
if (line.startsWith(':')) return;
if (line.startsWith('event:')) {
currentEventType = line.slice(6).trim();
return;
}
if (line.startsWith('id:') || line.startsWith('retry:')) return;
let content = line;
if (line.startsWith('data:')) {
content = line.slice(5);
if (content.startsWith(' ')) content = content.slice(1);
}
if (content === '[DONE]') {
flushEvent();
stopped = true;
return;
}
eventLines.push(content);
};
// Cancellation must unblock a pending read, but cleanup failures must not
// replace the original stream error or delay completion after [DONE].
const cancelReader = () => {
try { void reader.cancel().catch(() => {}); } catch { /* best-effort cleanup */ }
};
signal?.addEventListener('abort', cancelReader, { once: true });
try { try {
while (true) {
while (!stopped && !signal?.aborted) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
// 空行 = SSE 事件边界,刷新当前事件
if (!trimmed) {
if (eventLines.length > 0) {
// 非内容事件(status 等)不回调 onChunk,避免污染 AI 回复渲染
if (currentEventType !== 'status' && currentEventType !== 'tool_call_result') {
emitRawChunk(eventLines.join('\n'));
}
eventLines = [];
}
currentEventType = '';
continue;
}
// 忽略注释行 (: ...)
if (trimmed.startsWith(':')) {
// TODO: 信号行 ":ok" 或其他
continue;
}
// [DONE] 信号(结束整次 SSE 流)
if (trimmed === '[DONE]') {
if (eventLines.length > 0 && currentEventType !== 'status' && currentEventType !== 'tool_call_result') {
emitRawChunk(eventLines.join('\n'));
eventLines = [];
}
currentEventType = '';
break;
}
// SSE 协议字段:event / id / retry
if (trimmed.startsWith('event:')) {
// 记录当前事件类型,用于决定是否将后续 data 行传递给 AI 渲染
currentEventType = trimmed.substring('event:'.length).trim();
continue;
}
if (trimmed.startsWith('id:') || trimmed.startsWith('retry:')) continue;
// 提取 content(data: 前缀行去掉前缀,裸行为 Flux 元素内换行的续行)
let content: string;
if (trimmed.startsWith('data:')) {
content = trimmed.substring('data:'.length);
// 去掉 data: 后面紧跟的一个空格(SSE 标准允许)
if (content.startsWith(' ')) content = content.substring(1);
// OpenAI 格式的结束信号:data: [DONE]
if (content === '[DONE]') {
if (eventLines.length > 0 && currentEventType !== 'status' && currentEventType !== 'tool_call_result') {
emitRawChunk(eventLines.join('\n'));
eventLines = [];
}
currentEventType = '';
break;
}
} else {
content = trimmed;
}
eventLines.push(content);
if (signal?.aborted) break;
buffer += decoder.decode(value, { stream: !done });
let newline: number;
while (!stopped && (newline = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, newline);
buffer = buffer.slice(newline + 1);
consumeLine(line);
} }
}
if (buffer.trim()) {
const trimmed = buffer.trim();
if (!trimmed.startsWith(':')) {
if (trimmed.startsWith('data:')) {
let content = trimmed.substring('data:'.length);
if (content.startsWith(' ')) content = content.substring(1);
eventLines.push(content);
} else if (trimmed !== '[DONE]' && !trimmed.startsWith('event:') && !trimmed.startsWith('id:') && !trimmed.startsWith('retry:')) {
eventLines.push(trimmed);
}
if (done) {
if (!stopped && buffer) consumeLine(buffer);
if (!stopped) flushEvent();
break;
} }
} }
// 处理缓冲区剩余
if (eventLines.length > 0 && currentEventType !== 'status' && currentEventType !== 'tool_call_result') {
emitRawChunk(eventLines.join('\n'));
eventLines = [];
}
} catch (readErr: unknown) {
// 用户主动中断:视为正常结束,保留已生成内容
if (signal?.aborted) {
logger.info(`流式被用户中断,保留已生成内容 length=${totalText.length}`);
} else if (totalText.length > 0) {
onChunk('\n\n' + t('stream_unstable'));
} else {
throw readErr;
}
} finally { } finally {
reader.releaseLock();
signal?.removeEventListener('abort', cancelReader);
cancelReader();
try { reader.releaseLock(); } catch { /* preserve original outcome */ }
} }
logger.lifecycleStreamDone(getActiveIntegrateId(), totalText.length); logger.lifecycleStreamDone(getActiveIntegrateId(), totalText.length);
onDone();
finish();
} catch (err) { } catch (err) {
// 用户主动中断不触发 onError,走 onDone
if (signal?.aborted || (err instanceof CskError && err.type === 'aborted')) { if (signal?.aborted || (err instanceof CskError && err.type === 'aborted')) {
onDone();
finish();
return; return;
} }
if (completed) throw err;
if (err instanceof CskError) { if (err instanceof CskError) {
onError(err); onError(err);
} else { } else {
logger.lifecycleError(getActiveIntegrateId(), 'unknown', String(err)); logger.lifecycleError(getActiveIntegrateId(), 'unknown', String(err));
onError(new CskError(t('error_network'), 'network'));
onError(new CskError(err instanceof Error ? err.message : t('error_network'), 'network'));
} }
} }
} }
@ -522,37 +421,6 @@ export async function fetchCategoryTree(): Promise<CategoryNode[]> {
} }
} }
// ==================== P1: RAG 引用来源 ====================
/** RAG 来源响应数据项 */
interface RagSourceData {
documentId?: string;
title?: string;
sourceName?: string;
chunkIndex?: number;
score?: number;
snippet?: string;
}
/**
* 获取 RAG 引用来源
*/
export async function fetchRagSources(message: string, categoryId?: number): Promise<RagSourceData[]> {
const url = buildRagSourcesUrl(message, categoryId);
try {
const response = await safeFetch(url);
if (!response.ok) throw new CskError(getHttpErrorMessage(response.status), `http_${response.status}`);
const json: ApiResponse<RagSourceData[]> = await response.json();
if (json.success && Array.isArray(json.data)) {
logger.info(`获取引用来源 count=${json.data.length}`);
return json.data;
}
return [];
} catch (err) {
logger.error('获取引用来源失败', err);
return [];
}
}
// ==================== P0-002: 消息反馈 ==================== // ==================== P0-002: 消息反馈 ====================

251
client/src/chat.ts

@ -11,7 +11,6 @@ import {
chatRequest, chatRequest,
chatSSERequest, chatSSERequest,
fetchCategoryTree, fetchCategoryTree,
fetchRagSources,
fetchConversationList, fetchConversationList,
fetchConversationMessages, fetchConversationMessages,
deleteConversation, deleteConversation,
@ -511,145 +510,129 @@ async function produceAIReply(userText: string, images?: ImageAttachment[]): Pro
isSending = true; isSending = true;
setSendButtonMode('stop'); setSendButtonMode('stop');
// 确保 chatId 已初始化
if (!config.chatId) {
await initChatId();
}
// 提取图片 URL,仅图片参与多模态对话
const requestConfig = config;
const requestMessages = messages;
const requestRoleId = getActiveIntegrateId();
const controller = new AbortController();
abortController = controller;
const isCurrent = () => config === requestConfig && messages === requestMessages
&& getActiveIntegrateId() === requestRoleId;
const aiMsg: ChatMessage = { id: uuid(), role: 'ai', content: '', timestamp: now(), sources: [] };
const imageUrls = (images || []).map(img => img.url); const imageUrls = (images || []).map(img => img.url);
const aiTimestamp = now();
// RAG 启用条件:由 enableRag 控制
const shouldUseRag = useRag; const shouldUseRag = useRag;
// 显示 loading
if (showLoadingFn) showLoadingFn();
if (messagesContainer) smartScrollToBottom();
const aiMsgId = uuid();
let aiContent = '';
const categoryId = currentCategoryId;
try { try {
if (config.streaming) {
aiContent = await sendStreamMessage(userText, aiTimestamp, shouldUseRag, aiMsgId, imageUrls);
if (!requestConfig.chatId) await initChatId();
if (!isCurrent() || controller.signal.aborted) return false;
if (showLoadingFn) showLoadingFn();
smartScrollToBottom();
if (requestConfig.streaming) {
await sendStreamMessage(userText, aiMsg, shouldUseRag, categoryId, controller.signal, isCurrent, imageUrls);
} else { } else {
aiContent = await chatRequest(userText, imageUrls);
const result = await chatRequest(userText, imageUrls, categoryId, shouldUseRag, controller.signal);
if (!isCurrent() || controller.signal.aborted) return false;
aiMsg.content = result.text;
aiMsg.sources = result.sources;
if (hideLoadingFn) hideLoadingFn(); if (hideLoadingFn) hideLoadingFn();
if (messagesContainer) { if (messagesContainer) {
renderAIBubble(messagesContainer, aiContent, aiTimestamp, aiMsgId);
const wrapper = renderAIBubble(messagesContainer, aiMsg.content, aiMsg.timestamp, aiMsg.id);
renderSources(wrapper, aiMsg.sources);
} }
} }
const aiMsg: ChatMessage = { id: aiMsgId, role: 'ai', content: aiContent, timestamp: aiTimestamp };
if (!isCurrent()) return false;
if (controller.signal.aborted && !aiMsg.content) return false;
messages.push(aiMsg); messages.push(aiMsg);
saveMessages(config.integrateId, messages);
saveMessages(requestRoleId, messages);
if (messagesContainer) smartScrollToBottom(); if (messagesContainer) smartScrollToBottom();
// a11y 播报新 AI 消息
announceMessage(aiContent);
// 通知 launcher 显示未读徽章(弹窗关闭时生效,由 index.ts 监听)
announceMessage(aiMsg.content);
if (messagesContainer) { if (messagesContainer) {
messagesContainer.dispatchEvent(new CustomEvent('csk:newMessage', { bubbles: true, detail: { msg: aiMsg } })); messagesContainer.dispatchEvent(new CustomEvent('csk:newMessage', { bubbles: true, detail: { msg: aiMsg } }));
} }
// RAG 引用来源
if (shouldUseRag) fetchAndRenderSources(userText, aiMsg);
// 拉取并展示推荐问题(suggest-message-list)
if (config.suggestions) {
fetchAndShowSuggestions(aiMsgId);
}
// 发送成功后清除离线横幅(网络已恢复)
if (requestConfig.suggestions && !controller.signal.aborted) fetchAndShowSuggestions(aiMsg.id);
hideOfflineBanner(); hideOfflineBanner();
return true; return true;
} catch (err) { } catch (err) {
if (hideLoadingFn) hideLoadingFn();
const errMsg = err instanceof CskError ? err.message : t('error_send');
if (messagesContainer) {
renderErrorBubble(messagesContainer, errMsg, now());
}
logger.error(`发送失败 integrateId=${config.integrateId}`, err);
if (!isCurrent() || controller.signal.aborted) return false;
const errMsg = err instanceof Error ? err.message : t('error_send');
if (messagesContainer) renderErrorBubble(messagesContainer, errMsg, now());
logger.error(`发送失败 integrateId=${requestRoleId}`, err);
return false; return false;
} finally { } finally {
isSending = false;
abortController = null;
setSendButtonMode('send');
if (abortController === controller) {
if (hideLoadingFn) hideLoadingFn();
isSending = false;
abortController = null;
setSendButtonMode('send');
}
} }
} }
/** 流式发送消息 */
async function sendStreamMessage(text: string, aiTimestamp: number, shouldUseRag: boolean, aiMsgId: string, imageUrls?: string[]): Promise<string> {
// 创建中断控制器,供"停止生成"使用
abortController = new AbortController();
const signal = abortController.signal;
return new Promise((resolve, reject) => {
let bubbleEl: HTMLElement | null = null;
let wrapperEl: HTMLElement | null = null;
let accumulated = '';
let streamStarted = false;
chatSSERequest(
text,
(chunk: string) => {
// 直接拼接:后端每个 SSE 事件是模型一个原始 token,
// token 内的换行已由 api.ts 的 eventLines.join('\n') 还原,
// chunk 之间不能再加 \n,否则会把单词/短句拆成多行、破坏 Markdown 结构
accumulated += chunk;
if (!streamStarted && messagesContainer) {
if (hideLoadingFn) hideLoadingFn();
const { wrapper, bubble } = createEmptyAIBubble(messagesContainer, aiTimestamp, aiMsgId);
wrapperEl = wrapper;
bubbleEl = bubble;
streamStarted = true;
}
if (bubbleEl) {
(bubbleEl as unknown as { content: unknown[] }).content = [{ type: 'markdown', data: accumulated }];
}
if (messagesContainer) smartScrollToBottom();
},
() => {
// 流结束
// 无流内容降级为同步请求(须在 wrapperEl/bubbleEl 判断之外:
// 二者仅在 onChunk 收到首个 token 时才赋值,否则此分支不可达)
if (!streamStarted && accumulated === '') {
chatRequest(text, imageUrls).then(resolve).catch(reject);
return;
}
if (wrapperEl && bubbleEl) {
if (accumulated) {
(bubbleEl as unknown as { content: unknown[] }).content = [{ type: 'markdown', data: accumulated }];
}
finalizeAIBubble(wrapperEl, bubbleEl);
}
resolve(accumulated);
},
(error: CskError) => {
// 离线检测:网络不可达时展示横幅提示,下次发送成功自动清除
if (error.type === 'network' || error.type === 'cors') {
if (messagesContainer) showOfflineBanner(messagesContainer);
}
if (accumulated.length > 0) {
if (bubbleEl) {
(bubbleEl as unknown as { content: unknown[] }).content = [{ type: 'markdown', data: accumulated + '\n\n' + t('stream_interrupted') }];
if (wrapperEl) finalizeAIBubble(wrapperEl, bubbleEl);
}
resolve(accumulated);
} else {
reject(error);
}
},
currentCategoryId,
shouldUseRag,
imageUrls,
signal
);
});
/** 流式正文和来源始终写入发起请求时创建的消息及其气泡。 */
async function sendStreamMessage(
text: string,
aiMsg: ChatMessage,
shouldUseRag: boolean,
categoryId: number | undefined,
signal: AbortSignal,
isCurrent: () => boolean,
imageUrls?: string[]
): Promise<void> {
let bubbleEl: HTMLElement | null = null;
let wrapperEl: HTMLElement | null = null;
let streamError: CskError | undefined;
const finalize = () => {
if (!isCurrent()) return;
if (signal.aborted) aiMsg.sources = [];
if (wrapperEl && bubbleEl) {
finalizeAIBubble(wrapperEl, bubbleEl);
renderSources(wrapperEl, aiMsg.sources || []);
}
};
await chatSSERequest(
text,
(chunk) => {
if (!isCurrent() || signal.aborted) return;
aiMsg.content += chunk;
if (!bubbleEl && messagesContainer) {
if (hideLoadingFn) hideLoadingFn();
const { wrapper, bubble } = createEmptyAIBubble(messagesContainer, aiMsg.timestamp, aiMsg.id);
wrapperEl = wrapper;
bubbleEl = bubble;
}
if (bubbleEl) {
(bubbleEl as unknown as { content: unknown[] }).content = [{ type: 'markdown', data: aiMsg.content }];
}
if (messagesContainer) smartScrollToBottom();
},
finalize,
(error) => {
if (!isCurrent()) return;
aiMsg.sources = [];
if ((error.type === 'network' || error.type === 'cors') && messagesContainer) {
showOfflineBanner(messagesContainer);
}
if (aiMsg.content && bubbleEl) {
(bubbleEl as unknown as { content: unknown[] }).content = [{
type: 'markdown', data: aiMsg.content + '\n\n' + t('stream_interrupted') + '\n' + error.message,
}];
finalize();
} else {
streamError = error;
}
},
categoryId,
shouldUseRag,
imageUrls,
signal,
(sources: RagSource[]) => {
if (!isCurrent() || signal.aborted) return;
aiMsg.sources = sources;
if (wrapperEl) renderSources(wrapperEl, sources);
}
);
if (streamError) throw streamError;
} }
/** /**
@ -714,30 +697,6 @@ export async function retryFromMessage(msgId: string): Promise<void> {
} }
} }
/** 获取并渲染 RAG 引用来源 */
async function fetchAndRenderSources(message: string, aiMsg: ChatMessage): Promise<void> {
try {
const sources = await fetchRagSources(message, currentCategoryId);
if (sources.length > 0) {
const ragSources: RagSource[] = sources.map(s => ({
documentId: s.documentId || '',
title: s.title || '',
sourceName: s.sourceName || '',
chunkIndex: s.chunkIndex ?? 0,
score: s.score ?? 0,
snippet: s.snippet || '',
}));
aiMsg.sources = ragSources;
if (messagesContainer) {
const lastAiMsg = messagesContainer.querySelector('.csk-msg--ai:last-of-type');
if (lastAiMsg) renderSources(lastAiMsg as HTMLElement, ragSources);
}
if (config) saveMessages(config.integrateId, messages);
}
} catch (err) {
logger.warn('获取引用来源失败', err);
}
}
/** 加载知识库分类到原生下拉框(选项含缩进前缀表达层级) */ /** 加载知识库分类到原生下拉框(选项含缩进前缀表达层级) */
async function loadCategories(): Promise<void> { async function loadCategories(): Promise<void> {
@ -807,6 +766,10 @@ function renderHistory(): void {
/** 开启新对话(生成新 chatId) */ /** 开启新对话(生成新 chatId) */
function handleClear(): void { function handleClear(): void {
if (!config) return; if (!config) return;
abortController?.abort();
abortController = null;
isSending = false;
setSendButtonMode('send');
messages = []; messages = [];
if (messagesContainer) { if (messagesContainer) {
@ -980,6 +943,10 @@ export async function loadHistoryConversations(): Promise<void> {
*/ */
async function switchToConversation(conversationId: string): Promise<void> { async function switchToConversation(conversationId: string): Promise<void> {
if (!config || !messagesContainer) return; if (!config || !messagesContainer) return;
abortController?.abort();
abortController = null;
isSending = false;
setSendButtonMode('send');
// 找到该会话对应的历史条目,获取其所属角色 // 找到该会话对应的历史条目,获取其所属角色
const historyItem = historyItems.find( const historyItem = historyItems.find(

2
client/src/config.ts

@ -84,7 +84,7 @@ export function parseConfig(raw: SDKConfig): ResolvedConfig | null {
watermark: (typeof raw.watermark === 'string' && raw.watermark.trim()) || undefined, watermark: (typeof raw.watermark === 'string' && raw.watermark.trim()) || undefined,
streaming: raw.streaming ?? true, streaming: raw.streaming ?? true,
enableRag: raw.enableRag ?? true, enableRag: raw.enableRag ?? true,
rewriteStrategy: raw.rewriteStrategy || 'REWRITE',
rewriteStrategy: raw.rewriteStrategy || 'NONE',
locale: raw.locale || 'zh-CN', locale: raw.locale || 'zh-CN',
debug: raw.debug ?? true, debug: raw.debug ?? true,
allowImageUpload: raw.allowImageUpload ?? true, allowImageUpload: raw.allowImageUpload ?? true,

4
client/src/dom.ts

@ -1016,8 +1016,8 @@ export function renderSources(wrapper: HTMLElement, sources: RagSource[]): void
meta.className = 'csk-source-item__meta'; meta.className = 'csk-source-item__meta';
const metaParts: string[] = []; const metaParts: string[] = [];
if (src.sourceName) metaParts.push(src.sourceName); if (src.sourceName) metaParts.push(src.sourceName);
if (src.chunkIndex !== undefined) metaParts.push(`分块 #${src.chunkIndex}`);
if (src.score !== undefined) metaParts.push(`相关度 ${(src.score * 100).toFixed(0)}%`);
if (src.chunkIndex != null) metaParts.push(`分块 #${src.chunkIndex}`);
if (src.score != null) metaParts.push(`相关度 ${(src.score * 100).toFixed(0)}%`);
meta.textContent = metaParts.join(' · '); meta.textContent = metaParts.join(' · ');
item.appendChild(name); item.appendChild(name);

22
client/src/types.ts

@ -76,7 +76,7 @@ export interface SDKConfig {
* 如果角色未绑定知识库,后端自动降级为普通对话,不会报错。 * 如果角色未绑定知识库,后端自动降级为普通对话,不会报错。
*/ */
enableRag?: boolean; enableRag?: boolean;
/** RAG 查询重写策略,默认 "REWRITE"(在 enableRag=true 时生效) */
/** RAG 查询重写策略,默认 "NONE"(显式启用其他策略时才调用重写模型) */
rewriteStrategy?: 'NONE' | 'REWRITE' | 'TRANSLATION' | 'COMPRESSION' | 'MULTI_QUERY'; rewriteStrategy?: 'NONE' | 'REWRITE' | 'TRANSLATION' | 'COMPRESSION' | 'MULTI_QUERY';
/** 界面语言,默认 "zh-CN" */ /** 界面语言,默认 "zh-CN" */
locale?: string; locale?: string;
@ -212,12 +212,20 @@ export interface ChatMessage {
/** RAG 引用来源 */ /** RAG 引用来源 */
export interface RagSource { export interface RagSource {
documentId: string;
title: string;
sourceName: string;
chunkIndex: number;
score: number;
snippet: string;
documentId: string | null;
title: string | null;
sourceName: string | null;
chunkIndex: number | null;
score: number | null;
snippet: string | null;
}
/** 同一次答案生成返回的正文与引用;无需额外检索。 */
export interface ChatResult {
text: string;
mcpEvents: unknown[];
suggestions: string[];
sources: RagSource[];
} }
/** 知识库分类节点(树形结构) */ /** 知识库分类节点(树形结构) */

123
client/tests/api.test.ts

@ -0,0 +1,123 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { chatRequest, chatSSERequest, clearApiConfig, setApiConfig } from '../src/api';
import { parseConfig } from '../src/config';
import type { RagSource } from '../src/types';
const source: RagSource = {
documentId: '1234567890123456789', title: 'manual', sourceName: null,
chunkIndex: 0, score: null, snippet: '实际命中的文档',
};
const encoder = new TextEncoder();
const chunk = (content: string) => `data: ${JSON.stringify({ choices: [{ delta: { content } }] })}\r\n\r\n`;
const metadata = `data: ${JSON.stringify({ object: 'chat.completion.chunk', choices: [], sources: [source] })}\r\n\r\n`;
function response(parts: Uint8Array[], close = true) {
const cancel = vi.fn();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
parts.forEach(part => controller.enqueue(part));
if (close) controller.close();
},
cancel,
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(stream)));
return { stream, cancel };
}
function receive(signal?: AbortSignal) {
const onChunk = vi.fn();
const onDone = vi.fn();
const onError = vi.fn();
const onSources = vi.fn();
const pending = chatSSERequest('问题', onChunk, onDone, onError, 7, true, undefined, signal, onSources);
return { pending, onChunk, onDone, onError, onSources };
}
beforeEach(() => {
const config = parseConfig({ integrateId: '42', requestDomain: 'https://example.test', userId: 'user' })!;
config.chatId = 'conversation';
setApiConfig(config);
});
afterEach(() => { clearApiConfig(); vi.unstubAllGlobals(); });
describe('same-request answer transport', () => {
it('stops on DONE without EOF, preserving split UTF-8, CRLF and sources outside text', async () => {
const bytes = encoder.encode(chunk('中文 ') + metadata + 'data: [DONE]\r\n\r\n' + chunk('不得出现'));
const { stream, cancel } = response(Array.from(bytes, byte => Uint8Array.of(byte)), false);
const result = receive();
await result.pending;
expect(result.onChunk.mock.calls.flat().join('')).toBe('中文 ');
expect(result.onSources).toHaveBeenCalledExactlyOnceWith([source]);
expect(result.onDone).toHaveBeenCalledTimes(1);
expect(result.onError).not.toHaveBeenCalled();
expect(cancel).toHaveBeenCalledTimes(1);
expect(stream.locked).toBe(false);
expect(fetch).toHaveBeenCalledTimes(1);
const url = new URL(vi.mocked(fetch).mock.calls[0]![0] as string);
expect(url.pathname).toBe('/ai/chat/stream');
expect(url.searchParams.get('rewriteStrategy')).toBe('NONE');
});
it('keeps multiline data whitespace and flushes the last unterminated event at EOF', async () => {
response([encoder.encode('event: status\ndata: 不显示\n\ndata: first \r\ndata: second\r\n\r\ndata: last ')]);
const result = receive();
await result.pending;
expect(result.onChunk.mock.calls.flat()).toEqual([' first \nsecond', 'last ']);
expect(result.onDone).toHaveBeenCalledTimes(1);
});
it('recognizes EOF remainder DONE rather than rendering it', async () => {
response([encoder.encode(chunk('answer') + 'data: [DONE]')]);
const result = receive();
await result.pending;
expect(result.onChunk).toHaveBeenCalledExactlyOnceWith('answer');
expect(result.onDone).toHaveBeenCalledTimes(1);
});
it('delivers original server error once even when reader cleanup rejects', async () => {
const { cancel, stream } = response([encoder.encode('data: {"error":{"message":"权限不足"}}\n\n')], false);
cancel.mockRejectedValue(new Error('cleanup failed'));
const result = receive();
await result.pending;
expect(result.onError).toHaveBeenCalledTimes(1);
expect(result.onError.mock.calls[0]![0].message).toBe('权限不足');
expect(result.onDone).not.toHaveBeenCalled();
expect(stream.locked).toBe(false);
});
it('cancels a pending read once without consuming later metadata', async () => {
const { stream } = response([], false);
const controller = new AbortController();
const result = receive(controller.signal);
await Promise.resolve();
await Promise.resolve();
controller.abort();
await result.pending;
expect(result.onDone).toHaveBeenCalledTimes(1);
expect(result.onError).not.toHaveBeenCalled();
expect(result.onSources).not.toHaveBeenCalled();
expect(stream.locked).toBe(false);
});
it('uses one synchronous JSON request with the same RAG, role and category parameters', async () => {
const answer = { text: 'answer', mcpEvents: [], suggestions: [], sources: [source] };
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json(answer)));
expect(await chatRequest('问题', ['https://example.test/photo.png'], 7, true)).toEqual(answer);
expect(fetch).toHaveBeenCalledTimes(1);
const url = new URL(vi.mocked(fetch).mock.calls[0]![0] as string);
expect(url.pathname).toBe('/ai/chat/result');
expect(Object.fromEntries(url.searchParams)).toMatchObject({
roleId: '42', accountId: 'user', chatId: 'conversation', categoryId: '7', enableRag: 'true', rewriteStrategy: 'NONE',
});
});
it('retains explicit rewrite configuration and disabled RAG', async () => {
const config = parseConfig({ integrateId: '42', requestDomain: 'https://example.test', rewriteStrategy: 'MULTI_QUERY' })!;
setApiConfig(config);
vi.stubGlobal('fetch', vi.fn().mockImplementation(() => Promise.resolve(Response.json({ text: '', mcpEvents: [], suggestions: [], sources: [] }))));
await chatRequest('问题', undefined, undefined, true);
await chatRequest('问题', undefined, undefined, false);
const urls = vi.mocked(fetch).mock.calls.map(([url]) => new URL(url as string));
expect(urls[0]!.searchParams.get('rewriteStrategy')).toBe('MULTI_QUERY');
expect(urls[1]!.searchParams.get('enableRag')).toBe('false');
});
});

118
client/tests/chat.test.ts

@ -0,0 +1,118 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { initChat, sendQuickReply } from '../src/chat';
import { setApiConfig, clearApiConfig } from '../src/api';
import { parseConfig } from '../src/config';
import { renderAIBubble, createEmptyAIBubble, renderSources } from '../src/dom';
import { saveMessages } from '../src/storage';
vi.mock('../src/dom', () => ({
renderUserBubble: vi.fn(), renderAIBubble: vi.fn(() => ({ id: 'sync-wrapper' })),
createEmptyAIBubble: vi.fn(() => ({ wrapper: { id: 'stream-wrapper' }, bubble: {} })),
renderSources: vi.fn(), finalizeAIBubble: vi.fn(), scrollToBottom: vi.fn(),
hideOfflineBanner: vi.fn(), showOfflineBanner: vi.fn(), renderErrorBubble: vi.fn(),
}));
vi.mock('../src/storage', () => ({ saveMessages: vi.fn(), clearMessages: vi.fn() }));
class ElementStub extends EventTarget {
style: Record<string, string> = {};
classList = { add: vi.fn(), remove: vi.fn() };
setAttribute() {}
removeAttribute() {}
querySelector() { return null; }
querySelectorAll() { return []; }
}
const encoder = new TextEncoder();
const source = { documentId: '1234567890123456789', title: 'same answer', sourceName: null, chunkIndex: null, score: null, snippet: null };
const event = (value: unknown) => `data: ${JSON.stringify(value)}\n\n`;
const answer = event({ choices: [{ delta: { content: 'answer' } }] });
const sources = event({ choices: [], sources: [source] });
let clearButton: ElementStub;
let sender: ElementStub;
function setup(streaming: boolean) {
const config = parseConfig({ integrateId: '42', requestDomain: 'https://example.test', streaming, suggestions: false })!;
config.chatId = 'conversation';
setApiConfig(config);
clearButton = new ElementStub();
sender = new ElementStub();
initChat(config, {
messagesContainer: new ElementStub(), inputEl: sender, clearBtn: clearButton,
categorySelect: null, roleSelect: null, historyPanel: new ElementStub(),
welcomeEl: new ElementStub(), newMsgBtn: new ElementStub(), searchInput: null,
ariaLiveEl: new ElementStub(), showLoading: vi.fn(), hideLoading: vi.fn(),
} as unknown as Parameters<typeof initChat>[1]);
clearButton.dispatchEvent(new Event('click'));
vi.clearAllMocks();
}
function stream(body: string) {
return new Response(new ReadableStream<Uint8Array>({ start(c) { c.enqueue(encoder.encode(body)); c.close(); } }));
}
beforeEach(() => { vi.clearAllMocks(); });
afterEach(() => { clearApiConfig(); vi.unstubAllGlobals(); });
describe('SDK message references belong to the producing request', () => {
it.each([true, false])('makes one answer request and persists/renders its sources (streaming=%s)', async streaming => {
setup(streaming);
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(streaming
? stream(answer + sources + 'data: [DONE]\n\n')
: Response.json({ text: 'answer', mcpEvents: [], suggestions: [], sources: [source] })));
await sendQuickReply('question');
expect(fetch).toHaveBeenCalledTimes(1);
expect(new URL(vi.mocked(fetch).mock.calls[0]![0] as string).pathname).toBe(streaming ? '/ai/chat/stream' : '/ai/chat/result');
const saved = vi.mocked(saveMessages).mock.calls.at(-1)![1];
expect(saved.at(-1)).toMatchObject({ role: 'ai', content: 'answer', sources: [source] });
const wrapper = streaming
? vi.mocked(createEmptyAIBubble).mock.results[0]!.value.wrapper
: vi.mocked(renderAIBubble).mock.results[0]!.value;
expect(renderSources).toHaveBeenLastCalledWith(wrapper, [source]);
});
it('does not turn an empty completed stream into a second answer request', async () => {
setup(true);
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(stream('data: [DONE]\n\n')));
await sendQuickReply('question');
expect(fetch).toHaveBeenCalledTimes(1);
});
it('ignores a delayed synchronous answer after a new conversation begins', async () => {
setup(false);
let reply!: (response: Response) => void;
vi.stubGlobal('fetch', vi.fn(() => new Promise<Response>(resolve => { reply = resolve; })));
const pending = sendQuickReply('old question');
clearButton.dispatchEvent(new Event('click'));
reply(Response.json({ text: 'old answer', mcpEvents: [], suggestions: [], sources: [source] }));
await pending;
expect(saveMessages).not.toHaveBeenCalled();
expect(renderSources).not.toHaveBeenCalled();
expect(renderAIBubble).not.toHaveBeenCalled();
});
it('cancels in-flight old streams without adding their sources to a new conversation', async () => {
setup(true);
const cancel = vi.fn();
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(new ReadableStream<Uint8Array>({
start(controller) { controller.enqueue(encoder.encode(answer)); }, cancel,
}))));
const pending = sendQuickReply('old question');
await vi.waitFor(() => expect(createEmptyAIBubble).toHaveBeenCalledTimes(1));
clearButton.dispatchEvent(new Event('click'));
await pending;
expect(cancel).toHaveBeenCalledTimes(1);
expect(saveMessages).not.toHaveBeenCalled();
expect(renderSources).not.toHaveBeenCalled();
});
it('retains partial text but removes references on user cancellation', async () => {
setup(true);
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(new ReadableStream<Uint8Array>({
start(controller) { controller.enqueue(encoder.encode(answer + sources)); },
}))));
const pending = sendQuickReply('question');
await vi.waitFor(() => expect(renderSources).toHaveBeenCalled());
sender.dispatchEvent(new Event('stop'));
await pending;
expect(vi.mocked(saveMessages).mock.calls.at(-1)![1].at(-1)).toMatchObject({ content: 'answer', sources: [] });
expect(renderSources).toHaveBeenLastCalledWith(expect.anything(), []);
});
});

8
client/tests/config.test.ts

@ -75,6 +75,14 @@ describe('parseConfig - 默认值填充', () => {
expect(result!.streaming).toBe(true); expect(result!.streaming).toBe(true);
}); });
it('默认不执行查询重写,显式策略和同步选项保持不变', () => {
expect(parseConfig(validConfig)!.rewriteStrategy).toBe('NONE');
for (const rewriteStrategy of ['REWRITE', 'TRANSLATION', 'COMPRESSION', 'MULTI_QUERY'] as const) {
expect(parseConfig({ ...validConfig, rewriteStrategy })!.rewriteStrategy).toBe(rewriteStrategy);
}
expect(parseConfig({ ...validConfig, streaming: false })!.streaming).toBe(false);
});
it('debug 默认 true', () => { it('debug 默认 true', () => {
const result = parseConfig(validConfig); const result = parseConfig(validConfig);
expect(result!.debug).toBe(true); expect(result!.debug).toBe(true);

28
frontend/src/api/chat.ts

@ -1,6 +1,5 @@
import request from './request'
import { getToken } from '@/utils/token' import { getToken } from '@/utils/token'
import type { ApiResponse } from '@/types/api'
import type { ChatResult } from '@/types/sse'
const API_BASE = '' const API_BASE = ''
@ -28,13 +27,11 @@ export interface ChatOptions {
* @param message 用户消息 * @param message 用户消息
* @param chatId 会话 ID * @param chatId 会话 ID
* @param options 可选参数 * @param options 可选参数
* @param includeEnableRag 是否写入 enableRag 参数(sources 接口无意义,需排除)
*/ */
function buildChatQueryParams( function buildChatQueryParams(
message: string, message: string,
chatId: string, chatId: string,
options?: ChatOptions, options?: ChatOptions,
includeEnableRag = true,
): URLSearchParams { ): URLSearchParams {
const params = new URLSearchParams() const params = new URLSearchParams()
params.set('message', message) params.set('message', message)
@ -42,7 +39,7 @@ function buildChatQueryParams(
if (options) { if (options) {
if (options.roleId) params.set('roleId', options.roleId) if (options.roleId) params.set('roleId', options.roleId)
if (options.accountId) params.set('accountId', options.accountId) if (options.accountId) params.set('accountId', options.accountId)
if (includeEnableRag && options.enableRag === true) params.set('enableRag', 'true')
if (options.enableRag === true) params.set('enableRag', 'true')
if (options.rewriteStrategy) params.set('rewriteStrategy', options.rewriteStrategy) if (options.rewriteStrategy) params.set('rewriteStrategy', options.rewriteStrategy)
if (options.categoryId !== undefined && options.categoryId !== '') params.set('categoryId', String(options.categoryId)) if (options.categoryId !== undefined && options.categoryId !== '') params.set('categoryId', String(options.categoryId))
if (options.categoryIds && options.categoryIds.length > 0) params.set('categoryIds', options.categoryIds.join(',')) if (options.categoryIds && options.categoryIds.length > 0) params.set('categoryIds', options.categoryIds.join(','))
@ -51,10 +48,17 @@ function buildChatQueryParams(
return params return params
} }
/** 同步对话:GET /ai/chat */
export function chatSync(message: string, chatId: string, options?: ChatOptions): Promise<string> {
const url = `${API_BASE}/ai/chat?${buildChatQueryParams(message, chatId, options).toString()}`
return fetch(url, { headers: authHeaders() }).then(res => res.text())
/** 同次回答和引用来源:GET /ai/chat/result(直接 JSON,无 data 包裹)。 */
export function chatSync(message: string, chatId: string, options?: ChatOptions, signal?: AbortSignal): Promise<ChatResult> {
const url = `${API_BASE}/ai/chat/result?${buildChatQueryParams(message, chatId, options).toString()}`
return fetchChatResult(url, authHeaders(), signal)
}
/** SDK 测试页也复用此响应处理,保留其独立域名与 SDK Token。 */
export async function fetchChatResult(url: string, headers: Record<string, string>, signal?: AbortSignal): Promise<ChatResult> {
const res = await fetch(url, { headers, signal })
if (!res.ok) throw new Error((await res.text()) || 'HTTP ' + res.status)
return res.json()
} }
/** 获取 SSE 流式对话 URL:GET /ai/chat/stream */ /** 获取 SSE 流式对话 URL:GET /ai/chat/stream */
@ -62,12 +66,6 @@ export function chatSSEUrl(message: string, chatId: string, options?: ChatOption
return `${API_BASE}/ai/chat/stream?${buildChatQueryParams(message, chatId, options).toString()}` return `${API_BASE}/ai/chat/stream?${buildChatQueryParams(message, chatId, options).toString()}`
} }
/** 获取 RAG 引用来源:GET /ai/chat/sources(不传 enableRag,其余参数照传) */
export function ragSources(message: string, chatId: string, options?: ChatOptions): Promise<ApiResponse> {
const path = `/ai/chat/sources?${buildChatQueryParams(message, chatId, options, false).toString()}`
return request.get(path).then(r => r.data)
}
/** /**
* 获取 AI 推荐问题列表(管理后台 ChatPanel 用)。 * 获取 AI 推荐问题列表(管理后台 ChatPanel 用)。
* 调用 /conversation/{id}/suggestions,由管理后台 JwtAuthFilter 守卫。 * 调用 /conversation/{id}/suggestions,由管理后台 JwtAuthFilter 守卫。

15
frontend/src/components/MessageSources.vue

@ -5,9 +5,9 @@
<div v-for="(group, key) in grouped" :key="key" class="source-group"> <div v-for="(group, key) in grouped" :key="key" class="source-group">
<div class="source-doc-title">📄 {{ group.title || '文档 ' + key }}</div> <div class="source-doc-title">📄 {{ group.title || '文档 ' + key }}</div>
<div class="source-chunks"> <div class="source-chunks">
<div v-for="chunk in group.chunks" :key="chunk.chunkIndex" class="source-chunk">
<span class="chunk-badge">#{{ chunk.chunkIndex }}</span>
<span v-html="renderMarkdown(chunk.content?.substring(0, 300) + (chunk.content?.length > 300 ? '...' : ''))"></span>
<div v-for="(chunk, index) in group.chunks" :key="index" class="source-chunk">
<span v-if="chunk.chunkIndex !== null" class="chunk-badge">#{{ chunk.chunkIndex }}</span>
<span v-html="renderMarkdown(chunk.snippet || '')"></span>
</div> </div>
</div> </div>
</div> </div>
@ -19,17 +19,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { renderMarkdown } from '@/utils/markdown' import { renderMarkdown } from '@/utils/markdown'
import type { SourceReference } from '@/types/sse'
const props = defineProps<{ sources: any[] }>()
const props = defineProps<{ sources: SourceReference[] }>()
/** 按文档 ID 归并 chunk */ /** 按文档 ID 归并 chunk */
const grouped = computed(() => { const grouped = computed(() => {
const groups: Record<string, { title?: string; chunks: any[] }> = {}
const groups: Record<string, { title: string | null; chunks: SourceReference[] }> = {}
for (const s of (props.sources || [])) { for (const s of (props.sources || [])) {
const docId = s.documentId || s.metadata?.documentId || 'unknown'
const docId = s.documentId || s.sourceName || s.title || 'unknown'
if (!groups[docId]) { if (!groups[docId]) {
groups[docId] = { groups[docId] = {
title: s.metadata?.title || s.sourceName,
title: s.title || s.sourceName,
chunks: [], chunks: [],
} }
} }

148
frontend/src/sdk-test/SdkTestPanel.vue

@ -282,11 +282,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { ref, reactive, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { ChatList, ChatSender, ChatActionbar } from '@tdesign-vue-next/chat' import { ChatList, ChatSender, ChatActionbar } from '@tdesign-vue-next/chat'
import '@tdesign-vue-next/chat/es/style/index.css' import '@tdesign-vue-next/chat/es/style/index.css'
import { MessagePlugin } from 'tdesign-vue-next' import { MessagePlugin } from 'tdesign-vue-next'
import { readSSEStream } from '@/utils/sse'
import { readSSEStreamWithEvents } from '@/utils/sse'
import { fetchChatResult } from '@/api/chat'
import { renderMarkdown } from '@/utils/markdown' import { renderMarkdown } from '@/utils/markdown'
import { toChatData, type ChatMessage } from '@/utils/chatAdapter' import { toChatData, type ChatMessage } from '@/utils/chatAdapter'
@ -308,11 +309,11 @@ const modeOptions = [
{ label: '同步调用', value: 'sync' }, { label: '同步调用', value: 'sync' },
] ]
const strategyOptions = [ const strategyOptions = [
{ label: '不重写', value: 'NONE' },
{ label: '查询重写', value: 'REWRITE' },
{ label: '翻译扩展', value: 'TRANSLATION' },
{ label: '查询压缩', value: 'COMPRESSION' },
{ label: '多路扩展', value: 'MULTI_QUERY' },
{ label: '不重写(最快)', value: 'NONE' },
{ label: '查询重写(额外耗时)', value: 'REWRITE' },
{ label: '翻译扩展(额外耗时)', value: 'TRANSLATION' },
{ label: '查询压缩(额外耗时)', value: 'COMPRESSION' },
{ label: '多路扩展(额外耗时)', value: 'MULTI_QUERY' },
] ]
// SDK 可选参数的默认值(与 SDK parseConfig 对齐),用于判断是否需要在生成代码中输出 // SDK 可选参数的默认值(与 SDK parseConfig 对齐),用于判断是否需要在生成代码中输出
@ -608,7 +609,7 @@ function domAction(act: 'open' | 'close' | 'toggle'): void {
const activeTab = ref<'chat' | 'sdk'>('chat') const activeTab = ref<'chat' | 'sdk'>('chat')
const demoMode = ref<'sse' | 'sync'>('sse') const demoMode = ref<'sse' | 'sync'>('sse')
const demoRag = ref(true) const demoRag = ref(true)
const demoStrategy = ref('MULTI_QUERY')
const demoStrategy = ref('NONE')
const demoInput = ref('') const demoInput = ref('')
const demoChatId = ref('') const demoChatId = ref('')
const isSending = ref(false) const isSending = ref(false)
@ -625,7 +626,7 @@ function welcomeMessage(): ChatMessage {
return { return {
id: genId(), id: genId(),
role: 'assistant', role: 'assistant',
content: '您好,这里是新接口(GET /ai/chat、/ai/chat/stream、/ai/chat/sources)对话演示区。请先在左侧获取 SDK Token,然后输入问题开始对话。',
content: '您好,这里是对话演示区(GET /ai/chat/result、/ai/chat/stream),回答会同时返回实际引用来源。请先在左侧获取 SDK Token,然后输入问题开始对话。',
streaming: false, streaming: false,
time: fmtTime(), time: fmtTime(),
} }
@ -635,7 +636,7 @@ const demoMessages = ref<ChatMessage[]>([welcomeMessage()])
const demoData = computed(() => toChatData(demoMessages.value)) const demoData = computed(() => toChatData(demoMessages.value))
function buildDemoUrl( function buildDemoUrl(
path: 'chat' | 'chat/stream' | 'chat/sources',
path: 'chat/result' | 'chat/stream',
message: string, message: string,
chatId: string, chatId: string,
opts: { enableRag: boolean; rewriteStrategy?: string }, opts: { enableRag: boolean; rewriteStrategy?: string },
@ -647,7 +648,7 @@ function buildDemoUrl(
if (rid) p.set('roleId', rid) if (rid) p.set('roleId', rid)
const uid = config.userId.trim() const uid = config.userId.trim()
if (uid) p.set('accountId', uid) if (uid) p.set('accountId', uid)
if (path !== 'chat/sources' && opts.enableRag) p.set('enableRag', 'true')
if (opts.enableRag) p.set('enableRag', 'true')
if (opts.enableRag && opts.rewriteStrategy) p.set('rewriteStrategy', opts.rewriteStrategy) if (opts.enableRag && opts.rewriteStrategy) p.set('rewriteStrategy', opts.rewriteStrategy)
return demoDomain.value + '/ai/' + path + '?' + p.toString() return demoDomain.value + '/ai/' + path + '?' + p.toString()
} }
@ -673,45 +674,50 @@ async function sendDemo(val?: string): Promise<void> {
demoMessages.value.push({ id: genId(), role: 'user', content: text, streaming: false, time: fmtTime() }) demoMessages.value.push({ id: genId(), role: 'user', content: text, streaming: false, time: fmtTime() })
const assistantMsg: ChatMessage = { id: genId(), role: 'assistant', content: '', streaming: true, time: fmtTime(), sources: [] } const assistantMsg: ChatMessage = { id: genId(), role: 'assistant', content: '', streaming: true, time: fmtTime(), sources: [] }
demoMessages.value.push(assistantMsg) demoMessages.value.push(assistantMsg)
await scrollDemoBottom()
const cid = demoChatId.value
const requestMode = demoMode.value
const opts = { enableRag: demoRag.value, rewriteStrategy: demoRag.value ? demoStrategy.value : undefined } const opts = { enableRag: demoRag.value, rewriteStrategy: demoRag.value ? demoStrategy.value : undefined }
const url = buildDemoUrl(demoMode.value === 'sync' ? 'chat' : 'chat/stream', text, demoChatId.value, opts)
// 提前构建 URL,固定本轮角色、账号、域名和检索配置。
const url = buildDemoUrl(requestMode === 'sync' ? 'chat/result' : 'chat/stream', text, cid, opts)
const headers = sdkAuthHeaders() const headers = sdkAuthHeaders()
const controller = new AbortController()
abortController?.abort()
abortController = controller
const isCurrent = () => abortController === controller
&& !controller.signal.aborted && demoChatId.value === cid
&& demoMessages.value.some(msg => msg.id === assistantMsg.id)
await scrollDemoBottom()
try { try {
if (abortController) abortController.abort()
abortController = new AbortController()
if (demoMode.value === 'sync') {
const res = await fetch(url, { headers, signal: abortController.signal })
if (!res.ok) throw new Error('HTTP ' + res.status)
assistantMsg.content = await res.text()
if (!isCurrent()) return
if (requestMode === 'sync') {
const result = await fetchChatResult(url, headers, controller.signal)
if (!isCurrent()) return
assistantMsg.content = result.text
assistantMsg.sources = result.sources
} else { } else {
// 复用 readSSEStream(内部手写解析 OpenAI delta.content),逐 chunk 追加
await readSSEStream(
url,
(chunk: string) => {
await readSSEStreamWithEvents(url, {
onMessage: (chunk) => {
if (!isCurrent()) return
assistantMsg.content += chunk assistantMsg.content += chunk
demoMessages.value = [...demoMessages.value] demoMessages.value = [...demoMessages.value]
void scrollDemoBottom()
}, },
undefined,
headers,
abortController.signal,
)
}
// RAG 模式下拉取引用来源
if (demoRag.value) {
try {
const sres = await fetch(buildDemoUrl('chat/sources', text, demoChatId.value, opts), { headers })
const json = await sres.json()
if (json && json.success) assistantMsg.sources = json.data || []
} catch {
/* 来源获取失败不影响主回答 */
}
onSources: (sources) => {
if (!isCurrent()) return
assistantMsg.sources = sources
demoMessages.value = [...demoMessages.value]
},
onError: (data) => {
if (!isCurrent()) return
assistantMsg.content += '\n\n' + (data.message || '工具调用出错')
demoMessages.value = [...demoMessages.value]
},
}, headers, controller.signal)
} }
} catch (e: any) { } catch (e: any) {
if (!isCurrent()) return
if (e.name === 'AbortError') { if (e.name === 'AbortError') {
assistantMsg.content = assistantMsg.content || '已取消' assistantMsg.content = assistantMsg.content || '已取消'
} else { } else {
@ -719,10 +725,12 @@ async function sendDemo(val?: string): Promise<void> {
assistantMsg.error = true assistantMsg.error = true
} }
} finally { } finally {
assistantMsg.streaming = false
isSending.value = false
demoMessages.value = [...demoMessages.value]
await scrollDemoBottom()
if (isCurrent()) {
assistantMsg.streaming = false
isSending.value = false
demoMessages.value = [...demoMessages.value]
await scrollDemoBottom()
}
} }
} }
@ -743,6 +751,13 @@ function clearDemo(): void {
demoMessages.value = [welcomeMessage()] demoMessages.value = [welcomeMessage()]
} }
// Changing the SDK identity starts a new conversation and invalidates pending callbacks.
watch(
[() => config.integrateId, () => config.userId, demoDomain, sdkToken],
clearDemo,
{ flush: 'sync' },
)
async function onDemoAction(action: string, index: number): Promise<void> { async function onDemoAction(action: string, index: number): Promise<void> {
const msg = demoMessages.value[index] const msg = demoMessages.value[index]
if (!msg) return if (!msg) return
@ -913,41 +928,30 @@ const testResults = ref<TestCaseResult[]>([
const url = buildDemoUrl('chat/stream', '你好', 'verify_sse', { enableRag: false }) const url = buildDemoUrl('chat/stream', '你好', 'verify_sse', { enableRag: false })
apiCount.value++ apiCount.value++
const t0 = performance.now() const t0 = performance.now()
let res: Response
try {
res = await fetch(url, { headers: sdkAuthHeaders(), signal: AbortSignal.timeout(20000) })
} catch {
log('⚠ 请求失败', 'warn')
mark('skip', '后端不可用')
return
}
if (!res.ok) {
log('⚠ HTTP ' + res.status, 'warn')
mark('skip', '接口异常')
return
}
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let total = '' let total = ''
let chunks = 0 let chunks = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
total += decoder.decode(value, { stream: true })
chunks++
}
let metadataChunks = 0
let doneCalls = 0
await readSSEStreamWithEvents(url, {
onMessage: (text) => { total += text; chunks++ },
onSources: (sources) => {
metadataChunks++
assert(sources.length === 0, '普通对话应返回空 sources')
},
onDone: () => { doneCalls++ },
}, sdkAuthHeaders(), AbortSignal.timeout(20000))
const elapsed = Math.round(performance.now() - t0) const elapsed = Math.round(performance.now() - t0)
apiDurations.value.push(elapsed) apiDurations.value.push(elapsed)
assert(total.includes('choices') || total.includes('[DONE]'), '非 OpenAI Chat Completions 格式')
log('✓ OpenAI 格式(含 choices 或 [DONE])', 'pass')
log('✓ SSE ' + chunks + ' chunks, ' + total.length + ' chars', 'pass')
assert(metadataChunks === 1 && doneCalls === 1, '应收到一次引用元数据和一次完成事件')
log('OpenAI 正文和来源元数据分别解析,完成事件只触发一次', 'pass')
log('SSE ' + chunks + ' chunks, ' + total.length + ' chars', 'pass')
mark('pass', '通过 (' + elapsed + 'ms)') mark('pass', '通过 (' + elapsed + 'ms)')
}, },
}, },
{ {
id: 'T8', name: 'API RAG 引用来源', desc: 'GET /ai/chat/sources', phase: 'p1', status: 'idle', summary: '', logs: [],
id: 'T8', name: 'API 回答引用来源', desc: 'GET /ai/chat/result:同次回答携带 sources', phase: 'p1', status: 'idle', summary: '', logs: [],
fn: async (log, mark) => { fn: async (log, mark) => {
const url = buildDemoUrl('chat/sources', '请假', 'verify_sources', { enableRag: true, rewriteStrategy: 'REWRITE' })
const url = buildDemoUrl('chat/result', '请假', 'verify_sources', { enableRag: true, rewriteStrategy: 'NONE' })
apiCount.value++ apiCount.value++
let res: Response let res: Response
try { try {
@ -963,9 +967,9 @@ const testResults = ref<TestCaseResult[]>([
return return
} }
const json = await res.json() const json = await res.json()
log('返回 success=' + json.success + ' data=' + (json.data ? json.data.length : 0) + ' 条', 'info')
assert(json.success !== undefined, '应返回 success 字段')
log('✓ RAG 引用来源接口可用', 'pass')
log('返回回答 ' + (json.text?.length || 0) + ' 字,引用 ' + (json.sources?.length || 0) + ' 条', 'info')
assert(typeof json.text === 'string' && Array.isArray(json.sources), '应直接返回 text 和 sources 字段')
log('回答与引用来源由同一请求返回', 'pass')
mark('pass', '通过') mark('pass', '通过')
}, },
}, },

16
frontend/src/types/models.ts

@ -99,6 +99,20 @@ export type ModelProvider = 'dashscope' | 'deepseek' | 'volcengine' | 'moonshot'
/** 应用类型 */ /** 应用类型 */
export type AppType = 'CHAT' | 'EMBEDDING' | 'RAG_REWRITE' | 'RERANK' export type AppType = 'CHAT' | 'EMBEDDING' | 'RAG_REWRITE' | 'RERANK'
/** 豆包 Seed 2.0 思考强度;未设置时 CHAT 使用 minimal,其他应用沿用厂商默认。 */
export type ReasoningEffort = 'minimal' | 'low' | 'medium' | 'high'
export interface ModelExtraConfig {
[key: string]: unknown
reasoningEffort?: ReasoningEffort
dimensions?: number
topP?: number
topK?: number
frequencyPenalty?: number
presencePenalty?: number
stopSequences?: string
}
/** 模型配置 */ /** 模型配置 */
export interface AiModelConfig { export interface AiModelConfig {
id: string id: string
@ -112,7 +126,7 @@ export interface AiModelConfig {
temperature?: number temperature?: number
maxTokens?: number maxTokens?: number
topP?: number topP?: number
extraConfig?: Record<string, any>
extraConfig?: ModelExtraConfig
isActive: boolean isActive: boolean
priority?: number priority?: number
healthStatus?: 'healthy' | 'unhealthy' | 'unknown' healthStatus?: 'healthy' | 'unhealthy' | 'unknown'

18
frontend/src/types/sse.ts

@ -1,6 +1,24 @@
/** 同次回答实际命中的知识库分块;ID 保持字符串精度。 */
export interface SourceReference {
documentId: string | null
title: string | null
sourceName: string | null
chunkIndex: number | null
score: number | null
snippet: string | null
}
export interface ChatResult {
text: string
mcpEvents: unknown[]
suggestions: string[]
sources: SourceReference[]
}
/** SSE 事件类型定义 */ /** SSE 事件类型定义 */
export interface SSECallbacks { export interface SSECallbacks {
onMessage?: (chunk: string) => void onMessage?: (chunk: string) => void
onSources?: (sources: SourceReference[]) => void
onToolCallStart?: (data: any) => void onToolCallStart?: (data: any) => void
onToolCallResult?: (data: any) => void onToolCallResult?: (data: any) => void
onError?: (data: any) => void onError?: (data: any) => void

3
frontend/src/utils/chatAdapter.ts

@ -7,6 +7,7 @@
*/ */
import type { TdChatItemMeta, AIMessageContent, UserMessageContent } from '@tdesign-vue-next/chat' import type { TdChatItemMeta, AIMessageContent, UserMessageContent } from '@tdesign-vue-next/chat'
import type { SourceReference } from '@/types/sse'
/** 附件信息(图片或文件) */ /** 附件信息(图片或文件) */
export interface Attachment { export interface Attachment {
@ -24,7 +25,7 @@ export interface ChatMessage {
content: string content: string
streaming: boolean streaming: boolean
time: string time: string
sources?: any[]
sources?: SourceReference[]
toolCalls?: any[] toolCalls?: any[]
error?: boolean error?: boolean
feedback?: string | null feedback?: string | null

275
frontend/src/utils/sse.ts

@ -1,206 +1,139 @@
/**
* SSE 流式读取工具 —— 从 utils.js 原封不动搬移
*
* 统一处理 Flux<String> / ServerSentEvent / SseEmitter 三种 SSE 接口。
* 此文件使用原生 fetch + ReadableStream API,零框架依赖。
*/
import type { SSECallbacks } from '@/types/sse'
/** Shared SSE reader for plain text, OpenAI chunks and tool events. */
import type { SSECallbacks, SourceReference } from '@/types/sse'
import { getToken } from '@/utils/token' import { getToken } from '@/utils/token'
/** 构建带认证的请求头 */
/** Preserve explicit SDK authorization instead of replacing it with the admin token. */
function authHeaders(extra?: Record<string, string>): Record<string, string> { function authHeaders(extra?: Record<string, string>): Record<string, string> {
const token = getToken() const token = getToken()
const base: Record<string, string> = extra ? { ...extra } : {}
// 仅当调用方未显式提供 Authorization 时才注入管理后台 token,避免覆盖调用方传入的鉴权头(如测试面板的 SDK Token)
if (token && !base['Authorization']) base['Authorization'] = `Bearer ${token}`
return base
const headers = { ...extra }
if (token && !headers['Authorization']) headers['Authorization'] = `Bearer ${token}`
return headers
} }
/** 尝试从 OpenAI Chat Completions chunk 中提取 delta.content;返回 undefined 表示非 OpenAI 格式(回退纯文本) */
function extractOpenAIDelta(text: string): string | undefined {
let obj: any
try { obj = JSON.parse(text) } catch { return undefined }
// OpenAI 错误 chunk({"error":{"message":...}}):提取错误信息作为内容,避免原始 JSON 泄漏到对话框
if (obj && obj.error) {
const msg = obj.error.message || obj.error.type
return typeof msg === 'string' && msg ? msg : '服务异常'
function isSourceReference(value: unknown): value is SourceReference {
if (!value || typeof value !== 'object') return false
const nullableString = (v: unknown) => v === null || typeof v === 'string'
const nullableNumber = (v: unknown) => v === null || typeof v === 'number'
return 'documentId' in value && nullableString(value.documentId)
&& 'title' in value && nullableString(value.title)
&& 'sourceName' in value && nullableString(value.sourceName)
&& 'chunkIndex' in value && nullableNumber(value.chunkIndex)
&& 'score' in value && nullableNumber(value.score)
&& 'snippet' in value && nullableString(value.snippet)
}
/** Returns false only for legacy plain-text payloads. Metadata never enters message text. */
function dispatchOpenAI(text: string, handlers: SSECallbacks): boolean {
let value: unknown
try { value = JSON.parse(text) } catch { return false }
if (!value || typeof value !== 'object') return false
if ('error' in value && value.error && typeof value.error === 'object') {
const error = value.error
const message = 'message' in error ? error.message : 'type' in error ? error.type : undefined
handlers.onMessage?.(typeof message === 'string' && message ? message : '服务异常')
return true
} }
if (obj && Array.isArray(obj.choices)) {
const delta = obj.choices[0]?.delta
if (delta && typeof delta.content === 'string') return delta.content
return '' // OpenAI 形状但无 content(role/finish_reason 空 chunk)→ 跳过
if (!('choices' in value) || !Array.isArray(value.choices)) return false
if ('sources' in value && Array.isArray(value.sources)) {
if (!value.sources.every(isSourceReference)) throw new Error('无效的引用来源数据')
handlers.onSources?.(value.sources)
} }
return undefined // 是 JSON 但非 OpenAI 形状 → 回退纯文本
const content = value.choices[0]?.delta?.content
if (typeof content === 'string' && content) handlers.onMessage?.(content)
return true
} }
/**
* 通用 SSE 流式读取 —— 统一处理 Flux<String> / ServerSentEvent / SseEmitter 三种 SSE 接口
*
* @param url 请求地址
* @param onChunk 每收到一段文本的回调
* @param onDone 流结束的回调
* @param headers 额外请求头
* @param signal AbortSignal 用于取消请求(组件卸载时必须传入以释放网络资源)
*/
export async function readSSEStream(
/** Text-only callers share the same framing, completion and cleanup semantics. */
export function readSSEStream(
url: string, url: string,
onChunk: (text: string) => void, onChunk: (text: string) => void,
onDone?: () => void, onDone?: () => void,
headers?: Record<string, string>, headers?: Record<string, string>,
signal?: AbortSignal
signal?: AbortSignal,
): Promise<void> { ): Promise<void> {
const res = await fetch(url, { headers: authHeaders(headers), signal, credentials: 'include' })
if (!res.ok) throw new Error('HTTP ' + res.status)
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''
// SSE 规范:同一事件内的多行 data: 字段用 \n 拼接,事件间用空行分隔
let eventDataLines: string[] = []
let currentEvent = 'message'
const flushEvent = () => {
if (eventDataLines.length === 0) return
const text = eventDataLines.join('\n')
eventDataLines = []
const ev = currentEvent
currentEvent = 'message'
if (text === '[DONE]') return
// 跳过 status / faq 等系统事件,不显示在对话框中
if (ev === 'status') return
// OpenAI Chat Completions 格式:提取 delta.content;无 content 的空 chunk 跳过
const extracted = extractOpenAIDelta(text)
if (extracted !== undefined) {
if (extracted) onChunk(extracted)
return
}
// 空事件视为 LLM 流式输出的换行符(Spring 将 "\n" 编码为单条空 data: 事件)
onChunk(text || '\n')
}
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (let line of lines) {
// 兼容 \r\n 行结束符
if (line.endsWith('\r')) line = line.slice(0, -1)
if (line === '') {
// 空行 = SSE 事件边界
flushEvent()
} else if (line.startsWith('event:')) {
// 记录事件类型,用于 flushEvent 时过滤系统事件
currentEvent = line.slice(6).trim()
} else if (line.startsWith('data:')) {
// 累积 data 字段;仅剥离 data: 后的一个可选空格,保留 markdown 列表缩进
let data = line.slice(5)
if (data.startsWith(' ')) data = data.slice(1)
eventDataLines.push(data)
} else if (!line.startsWith(':')) {
// Flux<String> 模式(非标准 SSE),先把已累积的 SSE 事件 flush 再处理
flushEvent()
if (line.trim()) onChunk(line)
}
}
}
// 流结束,flush 末尾未以空行收尾的事件
flushEvent()
if (onDone) onDone()
return readSSEStreamWithEvents(url, { onMessage: onChunk, onDone }, headers, signal)
} }
/**
* 增强版 SSE 流式读取(支持事件类型分发)
* 解析 SSE 标准的 event: 字段,将不同类型事件分发到对应回调。
*
* @param url 请求地址
* @param handlers 回调对象:
* - onMessage(chunk): 普通文本内容(event: message 或无 event 的 data)
* - onToolCallStart(data): 工具调用开始(event: tool_call_start)
* - onToolCallResult(data): 工具调用结果(event: tool_call_result)
* - onError(data): 错误事件(event: error)
* - onDone(): 流结束
* @param headers 额外请求头
* @param signal AbortSignal 用于取消请求(组件卸载时必须传入以释放网络资源)
*/
/** Read complete SSE events; [DONE] terminates immediately without waiting for network EOF. */
export async function readSSEStreamWithEvents( export async function readSSEStreamWithEvents(
url: string, url: string,
handlers: SSECallbacks, handlers: SSECallbacks,
headers?: Record<string, string>, headers?: Record<string, string>,
signal?: AbortSignal
signal?: AbortSignal,
): Promise<void> { ): Promise<void> {
const { onMessage, onToolCallStart, onToolCallResult, onError, onDone } = handlers
const res = await fetch(url, { headers: authHeaders(headers), signal, credentials: 'include' }) const res = await fetch(url, { headers: authHeaders(headers), signal, credentials: 'include' })
if (!res.ok) throw new Error('HTTP ' + res.status)
const reader = res.body!.getReader()
if (!res.ok) throw new Error((await res.text()) || 'HTTP ' + res.status)
if (!res.body) throw new Error('响应流为空')
const reader = res.body.getReader()
const decoder = new TextDecoder() const decoder = new TextDecoder()
let buffer = '' let buffer = ''
let currentEvent = 'message'
// SSE 规范:同一事件内的多行 data: 字段用 \n 拼接
let eventDataLines: string[] = []
let event = 'message'
let data: string[] = []
let completed = false
let eof = false
const flushEvent = () => { const flushEvent = () => {
if (eventDataLines.length === 0) return
const ev = currentEvent
currentEvent = 'message'
const raw = eventDataLines.join('\n')
eventDataLines = []
if (raw === '[DONE]') return
// 空事件视为换行符(仅 message 事件);JSON 事件(工具调用等)空内容不应出现
const text = raw || '\n'
switch (ev) {
case 'tool_call_start':
if (onToolCallStart) onToolCallStart(JSON.parse(text))
break
case 'tool_call_result':
if (onToolCallResult) onToolCallResult(JSON.parse(text))
break
case 'error':
if (onError) onError(JSON.parse(text))
break
case 'status':
// 系统状态事件(generating / faq_hit 等),不显示在对话框中
break
case 'message':
default: {
// OpenAI Chat Completions 格式:提取 delta.content;无 content 的空 chunk 跳过
const extracted = extractOpenAIDelta(text)
if (extracted !== undefined) {
if (extracted && onMessage) onMessage(extracted)
} else if (onMessage) {
onMessage(text)
}
break
}
const currentEvent = event
event = 'message'
if (!data.length) return
const raw = data.join('\n')
data = []
if (raw === '[DONE]') {
completed = true
return
}
switch (currentEvent) {
case 'status': return
case 'tool_call_start': handlers.onToolCallStart?.(JSON.parse(raw)); return
case 'tool_call_result': handlers.onToolCallResult?.(JSON.parse(raw)); return
case 'error': handlers.onError?.(JSON.parse(raw)); return
default:
if (!dispatchOpenAI(raw, handlers)) handlers.onMessage?.(raw || '\n')
} }
} }
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
const consumeLine = (raw: string) => {
const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw
if (!line) {
flushEvent()
} else if (line.startsWith('event:')) {
event = line.slice(6).trim()
} else if (line === 'data' || line.startsWith('data:')) {
const value = line === 'data' ? '' : line.slice(5)
data.push(value.startsWith(' ') ? value.slice(1) : value)
} else if (!line.startsWith(':') && !line.startsWith('id:') && !line.startsWith('retry:')) {
// Legacy Flux<String> bodies may contain unframed lines.
flushEvent()
if (!completed && line.trim()) handlers.onMessage?.(line)
}
}
for (let line of lines) {
if (line.endsWith('\r')) line = line.slice(0, -1)
if (line === '') {
// 空行 = SSE 事件边界
flushEvent()
} else if (line.startsWith('event:')) {
currentEvent = line.slice(6).trim()
} else if (line.startsWith('data:')) {
let data = line.slice(5)
if (data.startsWith(' ')) data = data.slice(1)
eventDataLines.push(data)
} else if (!line.startsWith(':')) {
// Flux<String> 模式(非标准 SSE)
flushEvent()
if (line.trim() && onMessage) onMessage(line)
try {
while (!completed) {
signal?.throwIfAborted()
const result = await reader.read()
signal?.throwIfAborted()
eof = result.done
buffer += eof ? decoder.decode() : decoder.decode(result.value, { stream: true })
let start = 0
let end: number
while (!completed && (end = buffer.indexOf('\n', start)) !== -1) {
consumeLine(buffer.slice(start, end))
start = end + 1
} }
buffer = buffer.slice(start)
if (eof) {
if (!completed && buffer) consumeLine(buffer)
if (!completed) flushEvent()
break
}
}
handlers.onDone?.()
} finally {
// Cancellation may itself reject or stall; it must not delay DONE or mask the original error.
if (!eof) {
try { void reader.cancel().catch(() => {}) } catch { /* Preserve the stream outcome. */ }
} }
try { reader.releaseLock() } catch { /* Preserve the stream outcome. */ }
} }
flushEvent()
if (onDone) onDone()
} }

153
frontend/src/views/ChatPanel.vue

@ -235,14 +235,14 @@ import {
ChatActionbar, ChatActionbar,
} from '@tdesign-vue-next/chat' } from '@tdesign-vue-next/chat'
import '@tdesign-vue-next/chat/es/style/index.css' import '@tdesign-vue-next/chat/es/style/index.css'
import { chatSync, chatSSEUrl, ragSources, fetchSuggestions, type ChatOptions } from '@/api/chat'
import { chatSync, chatSSEUrl, fetchSuggestions, type ChatOptions } from '@/api/chat'
import { getRoleList } from '@/api/role' import { getRoleList } from '@/api/role'
import { getActiveModelConfig } from '@/api/model-config' import { getActiveModelConfig } from '@/api/model-config'
import { truncateConversation } from '@/api/conversation' import { truncateConversation } from '@/api/conversation'
import { submitFeedback as submitFeedbackApi } from '@/api/feedback' import { submitFeedback as submitFeedbackApi } from '@/api/feedback'
import { uploadAttachment } from '@/api/upload' import { uploadAttachment } from '@/api/upload'
import { toast } from '@/utils/toast' import { toast } from '@/utils/toast'
import { readSSEStream, readSSEStreamWithEvents } from '@/utils/sse'
import { readSSEStreamWithEvents } from '@/utils/sse'
import { renderMarkdown } from '@/utils/markdown' import { renderMarkdown } from '@/utils/markdown'
import { useCategoryStore } from '@/stores/category' import { useCategoryStore } from '@/stores/category'
import { toChatData, isLastAssistant } from '@/utils/chatAdapter' import { toChatData, isLastAssistant } from '@/utils/chatAdapter'
@ -266,11 +266,11 @@ const QUICK_QUESTIONS = [
] ]
const ragStrategyOptions = [ const ragStrategyOptions = [
{ label: '不重写', value: 'NONE' },
{ label: '查询重写', value: 'REWRITE' },
{ label: '翻译扩展', value: 'TRANSLATION' },
{ label: '查询压缩', value: 'COMPRESSION' },
{ label: '多路扩展', value: 'MULTI_QUERY' },
{ label: '不重写(最快)', value: 'NONE' },
{ label: '查询重写(额外耗时)', value: 'REWRITE' },
{ label: '翻译扩展(额外耗时)', value: 'TRANSLATION' },
{ label: '查询压缩(额外耗时)', value: 'COMPRESSION' },
{ label: '多路扩展(额外耗时)', value: 'MULTI_QUERY' },
] ]
const modeOptions = [ const modeOptions = [
@ -284,7 +284,7 @@ const mode = ref('sse') // 默认 SSE 流式
const selectedRole = ref('general') const selectedRole = ref('general')
const roles = ref([FALLBACK_ROLE]) const roles = ref([FALLBACK_ROLE])
const isRagMode = ref(false) const isRagMode = ref(false)
const ragStrategy = ref('MULTI_QUERY')
const ragStrategy = ref('NONE')
const activeModel = ref<any>(null) const activeModel = ref<any>(null)
const modelLoadError = ref('') const modelLoadError = ref('')
const userInput = ref('') const userInput = ref('')
@ -376,6 +376,7 @@ function providerLabel(provider: string): string {
// ==================== 角色管理 ==================== // ==================== 角色管理 ====================
function selectRole(roleKey: string): void { function selectRole(roleKey: string): void {
abortChat()
selectedRole.value = roleKey selectedRole.value = roleKey
newChatId() newChatId()
currentSuggestions.value = [] // 切换角色时清空推荐问题 currentSuggestions.value = [] // 切换角色时清空推荐问题
@ -491,6 +492,19 @@ async function send(): Promise<void> {
isSending.value = true isSending.value = true
currentSuggestions.value = [] // 清空建议 currentSuggestions.value = [] // 清空建议
const cid = chatId.value || ('web_' + Date.now())
chatId.value = cid
const requestMode = mode.value
// 固定本轮参数,后续角色或配置切换不影响已发出的回答。
const imageUrls = attachments.filter(a => a.type === 'image').map(a => a.url)
const chatOptions: ChatOptions = {
roleId: currentRoleId(),
enableRag: isRagMode.value,
rewriteStrategy: isRagMode.value ? ragStrategy.value : undefined,
categoryIds: [...selectedCategoryIds.value],
imageUrls: imageUrls.length ? imageUrls : undefined,
}
// 追加用户消息(含附件) // 追加用户消息(含附件)
messages.value.push({ messages.value.push({
id: generateMsgId(), role: 'user', content: text, id: generateMsgId(), role: 'user', content: text,
@ -503,73 +517,56 @@ async function send(): Promise<void> {
streaming: true, time: formatTime(), sources: [], toolCalls: [], streaming: true, time: formatTime(), sources: [], toolCalls: [],
} }
messages.value.push(assistantMsg) messages.value.push(assistantMsg)
const controller = new AbortController()
sseAbortController?.abort()
sseAbortController = controller
const isCurrent = () => sseAbortController === controller
&& !controller.signal.aborted && chatId.value === cid
&& messages.value.some(msg => msg.id === assistantMsg.id)
await scrollToBottom() await scrollToBottom()
const cid = chatId.value || ('web_' + Date.now())
chatId.value = cid
try { try {
// 取消上一个 SSE 请求并创建新的 AbortController
if (sseAbortController) { sseAbortController.abort() }
sseAbortController = new AbortController()
const signal = sseAbortController.signal
// 统一对话请求参数(RAG 与普通对话共用同一接口,由 enableRag 参数区分)
const imageUrls = attachments.filter(a => a.type === 'image').map(a => a.url)
const chatOptions: ChatOptions = {
roleId: currentRoleId(),
enableRag: isRagMode.value,
rewriteStrategy: isRagMode.value ? ragStrategy.value : undefined,
categoryIds: selectedCategoryIds.value,
imageUrls: imageUrls.length ? imageUrls : undefined,
}
if (mode.value === 'sync') {
// 同步调用
assistantMsg.content = await chatSync(text, cid, chatOptions)
if (!isCurrent()) return
if (requestMode === 'sync') {
const result = await chatSync(text, cid, chatOptions, controller.signal)
if (!isCurrent()) return
assistantMsg.content = result.text
assistantMsg.sources = result.sources
} else { } else {
// SSE 流式
const url = chatSSEUrl(text, cid, chatOptions)
if (isRagMode.value) {
await readSSEStreamWithEvents(url, {
onMessage: async (chunk: string) => {
assistantMsg.content += chunk
messages.value = [...messages.value]
await scrollToBottom()
},
onToolCallStart: (data: any) => {
assistantMsg.toolCalls!.push({ tool: data.tool, input: data.input, status: 'running', result: null })
scrollToBottom()
},
onToolCallResult: (data: any) => {
const tc = assistantMsg.toolCalls!.find(t => t.tool === data.tool && t.status === 'running')
if (tc) { tc.status = 'done'; tc.result = data.result; tc.latencyMs = data.latencyMs }
messages.value = [...messages.value]
scrollToBottom()
},
onError: (data: any) => {
assistantMsg.content += '\n\n⚠️ ' + (data.message || '工具调用出错')
messages.value = [...messages.value]
},
onDone: () => {},
}, undefined, signal)
} else {
await readSSEStream(url, async (chunk: string) => {
await readSSEStreamWithEvents(chatSSEUrl(text, cid, chatOptions), {
onMessage: (chunk) => {
if (!isCurrent()) return
assistantMsg.content += chunk assistantMsg.content += chunk
messages.value = [...messages.value] messages.value = [...messages.value]
await scrollToBottom()
}, () => {}, undefined, signal)
}
}
// RAG 模式下,拉取引用来源
if (isRagMode.value) {
try {
const sj = await ragSources(text, cid, chatOptions)
if (sj && sj.success) assistantMsg.sources = sj.data || []
} catch (_) { /* 来源获取失败不影响主回答 */ }
void scrollToBottom()
},
onSources: (sources) => {
if (!isCurrent()) return
assistantMsg.sources = sources
messages.value = [...messages.value]
},
onToolCallStart: (data) => {
if (!isCurrent()) return
assistantMsg.toolCalls!.push({ tool: data.tool, input: data.input, status: 'running', result: null })
messages.value = [...messages.value]
void scrollToBottom()
},
onToolCallResult: (data) => {
if (!isCurrent()) return
const tc = assistantMsg.toolCalls!.find(t => t.tool === data.tool && t.status === 'running')
if (tc) { tc.status = 'done'; tc.result = data.result; tc.latencyMs = data.latencyMs }
messages.value = [...messages.value]
void scrollToBottom()
},
onError: (data) => {
if (!isCurrent()) return
assistantMsg.content += '\n\n' + (data.message || '工具调用出错')
messages.value = [...messages.value]
},
}, undefined, controller.signal)
} }
} catch (e: any) { } catch (e: any) {
if (!isCurrent()) return
// AbortError 不是真正的错误,不显示错误信息 // AbortError 不是真正的错误,不显示错误信息
if (e.name === 'AbortError') { if (e.name === 'AbortError') {
assistantMsg.content = assistantMsg.content || '已取消' assistantMsg.content = assistantMsg.content || '已取消'
@ -579,18 +576,18 @@ async function send(): Promise<void> {
toast('对话失败:' + e.message, 'error') toast('对话失败:' + e.message, 'error')
} }
} finally { } finally {
assistantMsg.streaming = false
isSending.value = false
messages.value = [...messages.value]
// 拉取推荐问题(suggest-message-list);AbortError / 异常时不拉取
if (!sseAbortController?.signal.aborted && assistantMsg.content && !assistantMsg.error) {
fetchSuggestions(chatId.value).then(items => {
if (items.length) currentSuggestions.value = items
}).catch(() => {})
if (isCurrent()) {
assistantMsg.streaming = false
isSending.value = false
messages.value = [...messages.value]
// 推荐问题仍独立获取,但旧会话/旧回答的结果不得覆盖新对话。
if (assistantMsg.content && !assistantMsg.error) {
void fetchSuggestions(cid).then(items => {
if (isCurrent() && items.length) currentSuggestions.value = items
})
}
await scrollToBottom()
} }
await scrollToBottom()
} }
} }

33
frontend/src/views/ModelConfigManager.vue

@ -207,6 +207,14 @@
<t-form-item label="停止词(逗号分隔)" style="grid-column:1/-1;"> <t-form-item label="停止词(逗号分隔)" style="grid-column:1/-1;">
<t-input v-model="editModal.form.stopSequences" placeholder="如:END,STOP" /> <t-input v-model="editModal.form.stopSequences" placeholder="如:END,STOP" />
</t-form-item> </t-form-item>
<t-form-item v-if="supportsSeedReasoning" label="Seed 2.0 思考强度" style="grid-column:1/-1;">
<t-select v-model="editModal.form.reasoningEffort" :options="reasoningEffortOptions"
clearable :placeholder="reasoningDefaultLabel" />
<template #help>
<span>对话留空默认 minimal(关闭思考、优先响应速度);low / medium / high 启用思考,
可能改善复杂问题的回答质量,但会增加首字等待与生成耗时。RAG 重写留空沿用厂商默认。</span>
</template>
</t-form-item>
</div> </div>
</t-collapse-panel> </t-collapse-panel>
</t-collapse> </t-collapse>
@ -292,6 +300,7 @@ import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import * as api from '@/api/model-config' import * as api from '@/api/model-config'
import { toast } from '@/utils/toast' import { toast } from '@/utils/toast'
import { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
import type { ModelExtraConfig } from '@/types/models'
const { confirm } = useConfirm() const { confirm } = useConfirm()
@ -404,11 +413,27 @@ const editModal = ref<any>({
form: createEmptyForm(), form: createEmptyForm(),
}) })
const supportsSeedReasoning = computed(() => {
const form = editModal.value.form
return form.provider?.toLowerCase() === 'volcengine'
&& form.model_name?.startsWith('doubao-seed-2-0-')
&& ['CHAT', 'RAG_REWRITE'].includes(form.app_type)
})
const reasoningDefaultLabel = computed(() => editModal.value.form.app_type === 'CHAT'
? '系统快速默认(minimal,关闭思考)' : '厂商默认(medium)')
const reasoningEffortOptions = [
{ label: 'minimal · 关闭思考,快速响应', value: 'minimal' },
{ label: 'low · 低强度思考', value: 'low' },
{ label: 'medium · 中强度思考', value: 'medium' },
{ label: 'high · 高强度思考,等待更久', value: 'high' },
]
function createEmptyForm() { function createEmptyForm() {
return { return {
name: '', app_type: 'CHAT', provider: '', api_key: '', model_name: '', name: '', app_type: 'CHAT', provider: '', api_key: '', model_name: '',
temperature: 0.7, max_tokens: 2000, base_url: '', embeddingDimensions: 1024, temperature: 0.7, max_tokens: 2000, base_url: '', embeddingDimensions: 1024,
topP: null, topK: null, frequencyPenalty: null, presencePenalty: null, stopSequences: '', topP: null, topK: null, frequencyPenalty: null, presencePenalty: null, stopSequences: '',
reasoningEffort: '', extraConfig: {} as ModelExtraConfig,
priority: 0, is_active: false, description: '', priority: 0, is_active: false, description: '',
} }
} }
@ -578,6 +603,7 @@ function openEditModal(config: any) {
topP: extra.topP ?? null, topK: extra.topK ?? null, topP: extra.topP ?? null, topK: extra.topK ?? null,
frequencyPenalty: extra.frequencyPenalty ?? null, presencePenalty: extra.presencePenalty ?? null, frequencyPenalty: extra.frequencyPenalty ?? null, presencePenalty: extra.presencePenalty ?? null,
stopSequences: extra.stopSequences || '', priority: config.priority || 0, stopSequences: extra.stopSequences || '', priority: config.priority || 0,
reasoningEffort: extra.reasoningEffort || '', extraConfig: { ...extra },
is_active: config.is_active || false, description: config.description || '', is_active: config.is_active || false, description: config.description || '',
}, },
} }
@ -591,13 +617,18 @@ function openEditModal(config: any) {
// ==================== 保存 ==================== // ==================== 保存 ====================
function toCamelCase(form: any) { function toCamelCase(form: any) {
const data: any = { name: form.name, appType: form.app_type, provider: form.provider || 'other', apiKey: form.api_key, modelName: form.model_name, temperature: form.temperature, maxTokens: form.max_tokens, baseUrl: form.base_url, priority: form.priority, isActive: form.is_active, description: form.description } const data: any = { name: form.name, appType: form.app_type, provider: form.provider || 'other', apiKey: form.api_key, modelName: form.model_name, temperature: form.temperature, maxTokens: form.max_tokens, baseUrl: form.base_url, priority: form.priority, isActive: form.is_active, description: form.description }
const extraConfig: any = {}
const extraConfig: ModelExtraConfig = { ...form.extraConfig }
// 仅覆盖表单管理的参数,保留其他扩展配置;清空控件应删除旧值。
for (const key of ['topP', 'topK', 'frequencyPenalty', 'presencePenalty', 'stopSequences', 'reasoningEffort']) {
delete extraConfig[key]
}
if (form.app_type === 'EMBEDDING') extraConfig.dimensions = form.embeddingDimensions || 1024 if (form.app_type === 'EMBEDDING') extraConfig.dimensions = form.embeddingDimensions || 1024
if (form.topP != null) extraConfig.topP = form.topP if (form.topP != null) extraConfig.topP = form.topP
if (form.topK != null) extraConfig.topK = form.topK if (form.topK != null) extraConfig.topK = form.topK
if (form.frequencyPenalty != null) extraConfig.frequencyPenalty = form.frequencyPenalty if (form.frequencyPenalty != null) extraConfig.frequencyPenalty = form.frequencyPenalty
if (form.presencePenalty != null) extraConfig.presencePenalty = form.presencePenalty if (form.presencePenalty != null) extraConfig.presencePenalty = form.presencePenalty
if (form.stopSequences?.trim()) extraConfig.stopSequences = form.stopSequences.trim() if (form.stopSequences?.trim()) extraConfig.stopSequences = form.stopSequences.trim()
if (form.reasoningEffort) extraConfig.reasoningEffort = form.reasoningEffort
data.extraConfig = extraConfig data.extraConfig = extraConfig
return data return data
} }

45
frontend/src/views/PipelineFlow.vue

@ -5,7 +5,7 @@
<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(--td-text-color-placeholder);margin:4px 0 0;">
下图展示从用户请求到 AI 回复的完整处理流程,包含意图路由、RAG 检索、熔断保护和 Advisor 链。
下图展示从用户请求到 AI 回复的完整处理流程,包含 FAQ 与本地路由、RAG 检索、熔断保护和 Advisor 链。
菱形节点 = 决策分支 · 虚线框 = 独立子系统 · 虚线箭头 = 降级/异步路径。 菱形节点 = 决策分支 · 虚线框 = 独立子系统 · 虚线箭头 = 降级/异步路径。
</p> </p>
</div> </div>
@ -76,52 +76,52 @@ mermaid.initialize({
// Mermaid 流程图 DSL 定义 // Mermaid 流程图 DSL 定义
// 节点类型: [矩形]=处理步骤, {菱形}=决策分支, subgraph=子系统 // 节点类型: [矩形]=处理步骤, {菱形}=决策分支, subgraph=子系统
// %%graph-meta: { updated: "2026-08-27", basedOn: "ChatPipeline v3, RagPipeline v2, AssistantApp v2", mermaidVersion: "flowchart-v2" }
// %%graph-meta: { updated: "2026-09-14", basedOn: "ChatPipeline v5 direct retrieval, RagPipeline v3, AssistantApp sources metadata", mermaidVersion: "flowchart-v2" }
const GRAPH_DEFINITION = ` const GRAPH_DEFINITION = `
flowchart TD flowchart TD
A["<b>用户请求</b><br/>message + roleId + accountId + chatId"] A["<b>用户请求</b><br/>message + roleId + accountId + chatId"]
A --> B{"<b>Controller</b><br/>鉴权 / 角色解析 / KB 隔离判断<br/>构建 ChatContext"} A --> B{"<b>Controller</b><br/>鉴权 / 角色解析 / KB 隔离判断<br/>构建 ChatContext"}
B --> C["<b>ChatPipeline.buildRequest</b><br/>编排决策入口"]
B --> CB{"<b>AssistantApp 熔断检查</b><br/>SimpleCircuitBreaker<br/>阈值: 连续 3 次失败 / 恢复: 5 分钟"}
CB -- "正常" --> C["<b>ChatPipeline.buildRequest</b><br/>编排决策入口"]
C --> D{"enableRag ?"} C --> D{"enableRag ?"}
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" --> H["<b>FAQ 优先匹配</b><br/>FaqMatchEngine 完整三级匹配<br/>精确 → 关键词 → 向量语义<br/>沿用角色分类隔离"]
F --> G{"意图分类结果"}
H -- "命中标准答案" --> T
H -- "未命中 / 异常" --> G{"<b>本地 isChitchat</b><br/>精确寒暄词匹配<br/>无意图分类 LLM"}
G -- "FAQ<br/>confidence ≧ 0.8" --> H["<b>FaqMatchEngine</b><br/>三级匹配策略<br/>精确 → 关键词 → 向量语义"]
G -- "寒暄命中" --> I["<b>模式: 纯对话</b><br/>跳过知识库检索<br/>不注入资料块"]
G -- "CHITCHAT<br/>confidence ≧ 0.6" --> CHK["<b>闲聊前 FAQ 精准匹配</b><br/>先试 FaqMatchEngine<br/>命中则短路返回"]
G -- "RAG / 降级<br/>其余情况" --> J["<b>RagPipeline.retrieve</b><br/>RAG 检索流水线入口"]
H -- "✅ 命中标准答案" --> T
H -. "❌ 未命中 → 降级 RAG" .-> J
G -- "其余问题" --> J["<b>RagPipeline.retrieve</b><br/>默认零 LLM 预处理"]
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"]
L --> M["<b>3. 向量检索</b><br/>PGVector similaritySearch<br/>topK=4 + 分类过滤"]
M --> S["<b>4. 构建资料块</b><br/>拼接检索文档<br/>注入 system prompt 末尾"]
J --> K{"前置 FAQ 匹配<br/>completedCleanly ?"}
K -- "true: 跳过重复 FAQ" --> L{"<b>2. rewriteStrategy</b><br/>默认 NONE"}
K -- "false: 异常后重试" --> KR["<b>1. FAQ 匹配重试</b><br/>FaqMatchEngine 完整三级匹配"]
KR -- "命中标准答案" --> T
KR -- "未命中 / 异常" --> L
L -- "NONE 原文直检索" --> M["<b>3. 向量检索</b><br/>PGVector similaritySearch<br/>topK=4 + 分类过滤"]
L -- "显式选择" --> LR["<b>查询重写 LLM</b><br/>REWRITE / TRANSLATION / MULTI_QUERY<br/>COMPRESSION: 最近 10 条历史补全指代"]
LR --> M
M --> S["<b>4. 构建资料块</b><br/>命中/未命中日志仅此层记录一次<br/>注入 system prompt + 保留命中文档"]
end end
CHK -. "❌ 未命中 → 纯对话" .-> I["<b>模式: 纯对话</b><br/>跳过知识库检索<br/>不注入资料块"]
CHK -- "✅ 命中标准答案" --> T
I --> T I --> T
S --> T S --> T
E --> T E --> T
T["<b>组装 ChatRequest</b><br/>finalMessage + finalSystemPrompt<br/>+ faqAnswer (可选)"]
T["<b>组装 ChatRequest</b><br/>finalMessage + finalSystemPrompt<br/>+ faqAnswer / 当次命中文档"]
T --> CB{"<b>🔌 AI 熔断检查</b><br/>SimpleCircuitBreaker<br/>阈值: 连续 3 次失败 / 恢复: 5 分钟"}
T -- "FAQ 标准答案" --> FAQ["<b>FAQ 直接回复</b><br/>内容安全检查 + 写入会话记忆<br/>不调用答案 LLM"]
CB -- "熔断中" --> FALLBACK["<b>返回降级提示</b><br/>「AI 服务暂时不可用<br/>请稍后重试」"] CB -- "熔断中" --> FALLBACK["<b>返回降级提示</b><br/>「AI 服务暂时不可用<br/>请稍后重试」"]
CB -- "正常" --> U["<b>AssistantApp</b><br/>chat / chatStream<br/>构建 ChatClient + MCP 工具"]
T -- "需要生成" --> U["<b>AssistantApp</b><br/>chat / chatStream<br/>构建 ChatClient + MCP 工具"]
subgraph ADVISOR["🛡️ Advisor 链(环绕 LLM 调用)"] subgraph ADVISOR["🛡️ Advisor 链(环绕 LLM 调用)"]
U --> V["<b>ContentSafetyAdvisor</b><br/>🔽 before: DFA 敏感词检测<br/>用户输入 BLOCK/MASK"] U --> V["<b>ContentSafetyAdvisor</b><br/>🔽 before: DFA 敏感词检测<br/>用户输入 BLOCK/MASK"]
@ -132,8 +132,9 @@ flowchart TD
Z --> AA["<b>ContentSafetyAdvisor</b><br/>🔼 after: AI 输出检测<br/>BLOCK/MASK 违规内容"] Z --> AA["<b>ContentSafetyAdvisor</b><br/>🔼 after: AI 输出检测<br/>BLOCK/MASK 违规内容"]
end end
AA --> AB["<b>返回 AI 回复</b><br/>SSE 流式输出<br/>+ MCP 工具调用事件"]
AA --> AB["<b>返回 AI 回复 + 当次引用</b><br/>同步 JSON 或 SSE 正文 → sources metadata → stop / DONE<br/>引用不进入正文,不二次检索"]
FAQ --> AB
FALLBACK --> AB FALLBACK --> AB
AB -. "异步按需触发" .-> SG AB -. "异步按需触发" .-> SG

164
frontend/tests/chat-protocol.test.mjs

@ -0,0 +1,164 @@
import assert from 'node:assert/strict'
import { afterEach, beforeEach, test } from 'node:test'
import { fileURLToPath } from 'node:url'
import { build } from 'esbuild'
// Exercise the production TypeScript with Vite's existing esbuild dependency, no test framework.
const root = fileURLToPath(new URL('../', import.meta.url))
async function loadModule(entry) {
const result = await build({
entryPoints: [root + entry], bundle: true, write: false, format: 'esm', platform: 'node',
alias: { '@': root + 'src' },
})
return import('data:text/javascript;base64,' + Buffer.from(result.outputFiles[0].text).toString('base64'))
}
const { readSSEStream, readSSEStreamWithEvents } = await loadModule('src/utils/sse.ts')
const { chatSync, fetchChatResult } = await loadModule('src/api/chat.ts')
const originalFetch = globalThis.fetch
const originalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage')
beforeEach(() => {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true, value: { getItem: () => 'admin-token' },
})
})
afterEach(() => {
globalThis.fetch = originalFetch
if (originalStorage) Object.defineProperty(globalThis, 'localStorage', originalStorage)
else delete globalThis.localStorage
})
const sources = [{
documentId: '9223372036854775806', title: '报销制度', sourceName: 'policy.pdf',
chunkIndex: 2, score: 0.125, snippet: '申请应在三十天内提交。',
}]
const metadata = JSON.stringify({ object: 'chat.completion.chunk', choices: [], sources })
const answer = JSON.stringify({ choices: [{ delta: { content: '答复。' } }] })
function responseFor(text, { open = false, fragment = false, cancel } = {}) {
const bytes = new TextEncoder().encode(text)
const body = new ReadableStream({
start(controller) {
if (fragment) for (const byte of bytes) controller.enqueue(Uint8Array.of(byte))
else controller.enqueue(bytes)
if (!open) controller.close()
},
cancel,
})
return new Response(body)
}
test('fragmented CRLF metadata preserves ID/snippet and DONE completes without EOF or cancellation settlement', { timeout: 1000 }, async () => {
let cancelled = 0
const response = responseFor(
`data: ${answer}\r\n\r\ndata: ${metadata}\r\n\r\ndata: [DONE]\r\n\r\ndata: ignored\r\n\r\n`,
{ open: true, fragment: true, cancel() { cancelled++; return new Promise(() => {}) } },
)
const requests = []
globalThis.fetch = async (...args) => { requests.push(args); return response }
const events = []
await readSSEStreamWithEvents('/ai/chat/stream', {
onMessage: text => events.push(['text', text]),
onSources: value => events.push(['sources', value]),
onDone: () => events.push(['done']),
}, { Authorization: 'Bearer sdk-token' })
assert.deepEqual(events, [['text', '答复。'], ['sources', sources], ['done']])
assert.equal(requests.length, 1)
assert.equal(requests[0][1].headers.Authorization, 'Bearer sdk-token')
assert.equal(cancelled, 1)
assert.equal(response.body.locked, false)
})
test('text-only facade uses identical DONE handling and never leaks empty choices or sources', { timeout: 1000 }, async () => {
globalThis.fetch = async () => responseFor(
`data: ${metadata}\n\ndata: ${answer}\n\ndata: [DONE]\n\ndata: [DONE]\n\ndata: trailing\n\n`,
{ open: true },
)
const text = []
let completed = 0
await readSSEStream('/ai/chat/stream', value => text.push(value), () => completed++)
assert.deepEqual(text, ['答复。'])
assert.equal(completed, 1)
})
test('EOF residuals, multiline data, tool events, status and legacy text retain their semantics', async () => {
globalThis.fetch = async () => responseFor(
': heartbeat\r\nid: event-1\r\nretry: 1000\r\nevent: status\r\ndata: generating\r\n\r\n'
+ 'event: tool_call_start\r\ndata: {"tool":"lookup"}\r\n\r\n'
+ 'event: tool_call_result\r\ndata: {"tool":"lookup","result":"ok"}\r\n\r\n'
+ 'event: error\r\ndata: {"message":"tool unavailable"}\r\n\r\n'
+ 'data: first\r\ndata: indented\r\n\r\ndata:\r\n\r\nlegacy\r\ndata: 尾部',
{ fragment: true },
)
const events = []
await readSSEStreamWithEvents('/stream', {
onMessage: text => events.push(text),
onToolCallStart: value => events.push(value),
onToolCallResult: value => events.push(value),
onError: value => events.push(value),
onDone: () => events.push('done'),
})
assert.deepEqual(events, [
{ tool: 'lookup' }, { tool: 'lookup', result: 'ok' }, { message: 'tool unavailable' },
'first\n indented', '\n', 'legacy', '尾部', 'done',
])
})
test('EOF flushes an unterminated plain-text line and an unterminated DONE event', async () => {
for (const body of ['legacy tail', 'data: [DONE]']) {
globalThis.fetch = async () => responseFor(body)
const text = []
let done = 0
await readSSEStream('/stream', value => text.push(value), () => done++)
assert.deepEqual(text, body.startsWith('data:') ? [] : ['legacy tail'])
assert.equal(done, 1)
}
})
test('cleanup failure cannot replace callback errors or call onDone on an error', async () => {
const original = new Error('consumer failed')
const response = responseFor(`data: ${answer}\n\n`, {
open: true, cancel() { throw new Error('cleanup failed') },
})
globalThis.fetch = async () => response
let done = 0
await assert.rejects(readSSEStream('/stream', () => { throw original }, () => done++), error => error === original)
assert.equal(done, 0)
assert.equal(response.body.locked, false)
})
test('empty sources for ordinary answers are delivered separately from content', async () => {
globalThis.fetch = async () => responseFor('data: {"choices":[],"sources":[]}\n\ndata: [DONE]\n\n')
const values = []
await readSSEStreamWithEvents('/stream', {
onMessage: () => assert.fail('metadata is not text'),
onSources: value => values.push(value),
})
assert.deepEqual(values, [[]])
})
test('synchronous chat makes one result request and retains direct answer, sources, explicit strategy and signal', async () => {
const result = { text: '答复。', mcpEvents: [], suggestions: [], sources }
const calls = []
globalThis.fetch = async (...args) => { calls.push(args); return Response.json(result) }
const controller = new AbortController()
assert.deepEqual(await chatSync('费用?', 'conversation-1', {
enableRag: true, roleId: '9223372036854775806', rewriteStrategy: 'MULTI_QUERY', categoryIds: ['123'],
}, controller.signal), result)
assert.equal(calls.length, 1)
const url = new URL(calls[0][0], 'https://test.invalid')
assert.equal(url.pathname, '/ai/chat/result')
assert.equal(url.searchParams.get('rewriteStrategy'), 'MULTI_QUERY')
assert.equal(url.searchParams.get('roleId'), '9223372036854775806')
assert.equal(calls[0][1].signal, controller.signal)
assert.equal(calls[0][1].headers.Authorization, 'Bearer admin-token')
})
test('synchronous SDK response uses the same direct contract and surfaces server errors', async () => {
const result = { text: '普通回答', mcpEvents: [], suggestions: [], sources: [] }
globalThis.fetch = async () => Response.json(result)
assert.deepEqual(await fetchChatResult('https://sdk.invalid/ai/chat/result', {}), result)
globalThis.fetch = async () => new Response('角色无访问权限', { status: 403 })
await assert.rejects(fetchChatResult('/ai/chat/result', {}), /角色无访问权限/)
await assert.rejects(readSSEStream('/ai/chat/stream', () => {}), /角色无访问权限/)
})

6
pom.xml

@ -20,6 +20,8 @@
<spring-ai-alibaba.version>1.1.2.2</spring-ai-alibaba.version> <spring-ai-alibaba.version>1.1.2.2</spring-ai-alibaba.version>
<!-- 设置为 true 可跳过前端 SDK 构建,加快后端编译:mvn compile -Dskip.frontend=true --> <!-- 设置为 true 可跳过前端 SDK 构建,加快后端编译:mvn compile -Dskip.frontend=true -->
<skip.frontend>false</skip.frontend> <skip.frontend>false</skip.frontend>
<!-- 集成测试默认跳过,允许通过 -DskipTests=false 显式运行。 -->
<skipTests>true</skipTests>
</properties> </properties>
<!-- ==================== Maven 多环境 Profile ==================== --> <!-- ==================== Maven 多环境 Profile ==================== -->
@ -229,13 +231,13 @@
</resource> </resource>
</resources> </resources>
<plugins> <plugins>
<!-- 默认跳过集成测试(所有测试均为 @SpringBootTest,需要 DB 和 API Key) -->
<!-- 默认跳过测试;集成测试需要 DB 和 API Key,针对性单元测试可按类运行。 -->
<!-- 需要运行测试时显式指定:mvn test -DskipTests=false --> <!-- 需要运行测试时显式指定:mvn test -DskipTests=false -->
<plugin> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId> <artifactId>maven-surefire-plugin</artifactId>
<configuration> <configuration>
<skipTests>true</skipTests>
<skipTests>${skipTests}</skipTests>
</configuration> </configuration>
</plugin> </plugin>
<plugin> <plugin>

50
src/main/java/com/wok/supportbot/app/AssistantApp.java

@ -36,6 +36,7 @@ import reactor.core.Disposable;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.FluxSink; import reactor.core.publisher.FluxSink;
import reactor.core.publisher.SignalType; import reactor.core.publisher.SignalType;
import reactor.core.scheduler.Schedulers;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
@ -314,19 +315,19 @@ public class AssistantApp {
*/ */
public ChatResult chatWithEvents(ChatContext ctx) { public ChatResult chatWithEvents(ChatContext ctx) {
long startNanos = System.nanoTime(); long startNanos = System.nanoTime();
// 熔断:全局 AI 调用处于熔断状态,直接返回降级提示(不做 buildRequest,避免熔断期间仍走意图路由/检索)
// 熔断时直接返回降级提示,不再编排或检索。
if (aiCircuitBreaker.isOpen(AI_CIRCUIT_KEY)) { if (aiCircuitBreaker.isOpen(AI_CIRCUIT_KEY)) {
log.warn("AI 调用熔断中,返回降级提示"); log.warn("AI 调用熔断中,返回降级提示");
recordTrace(ctx, null, CIRCUIT_OPEN_MESSAGE, 0, "BYPASS", recordTrace(ctx, null, CIRCUIT_OPEN_MESSAGE, 0, "BYPASS",
new TraceMeta("CIRCUIT_BREAK", "AI 服务熔断降级", null, null, null, null)); new TraceMeta("CIRCUIT_BREAK", "AI 服务熔断降级", null, null, null, null));
return new ChatResult(CIRCUIT_OPEN_MESSAGE, List.of());
return new ChatResult(CIRCUIT_OPEN_MESSAGE, List.of(), List.of(), List.of());
} }
ChatRequest req = chatPipeline.buildRequest(ctx); ChatRequest req = chatPipeline.buildRequest(ctx);
if (req.faqHit()) { if (req.faqHit()) {
String faqAnswer = req.faqAnswer().get(); String faqAnswer = req.faqAnswer().get();
recordTrace(ctx, req, faqAnswer, 0, "FAQ", recordTrace(ctx, req, faqAnswer, 0, "FAQ",
new TraceMeta(null, null, null, null, null, null)); new TraceMeta(null, null, null, null, null, null));
return new ChatResult(faqAnswer, List.of());
return new ChatResult(faqAnswer, List.of(), List.of(), List.of());
} }
// 显式事件收集器 + 轮次计数器,通过 toolContext 传给 McpToolCallback,规避 Reactor 跨线程丢 ThreadLocal 的问题 // 显式事件收集器 + 轮次计数器,通过 toolContext 传给 McpToolCallback,规避 Reactor 跨线程丢 ThreadLocal 的问题
List<ToolCallEvent> events = new CopyOnWriteArrayList<>(); List<ToolCallEvent> events = new CopyOnWriteArrayList<>();
@ -354,14 +355,14 @@ public class AssistantApp {
events)); events));
// 推荐问题已不再由主回复同步生成,改由 SuggestionGenerator 异步按需生成 // 推荐问题已不再由主回复同步生成,改由 SuggestionGenerator 异步按需生成
return new ChatResult(text, events, List.of());
return new ChatResult(text, events, List.of(), SourceReference.fromDocuments(req.hitDocuments()));
} catch (Exception e) { } catch (Exception e) {
aiCircuitBreaker.recordFailure(AI_CIRCUIT_KEY); aiCircuitBreaker.recordFailure(AI_CIRCUIT_KEY);
log.error("AI 同步调用失败: chatId={}, error={}", ctx.chatId(), e.getMessage()); log.error("AI 同步调用失败: chatId={}, error={}", ctx.chatId(), e.getMessage());
String fallback = "抱歉,AI 服务调用失败:" + e.getMessage(); String fallback = "抱歉,AI 服务调用失败:" + e.getMessage();
recordTrace(ctx, req, fallback, elapsedMillis(startNanos), "ERROR", recordTrace(ctx, req, fallback, elapsedMillis(startNanos), "ERROR",
new TraceMeta(classifyError(e), maskError(e.getMessage()), null, null, null, events)); new TraceMeta(classifyError(e), maskError(e.getMessage()), null, null, null, events));
return new ChatResult(fallback, List.of());
return new ChatResult(fallback, List.of(), List.of(), List.of());
} }
} }
@ -376,7 +377,7 @@ public class AssistantApp {
*/ */
public Flux<String> chatStream(ChatContext ctx) { public Flux<String> chatStream(ChatContext ctx) {
long startNanos = System.nanoTime(); long startNanos = System.nanoTime();
// 熔断:全局 AI 调用处于熔断状态(不做 buildRequest,避免熔断期间仍走意图路由/检索)
// 熔断时直接返回降级提示,不再编排或检索。
if (aiCircuitBreaker.isOpen(AI_CIRCUIT_KEY)) { if (aiCircuitBreaker.isOpen(AI_CIRCUIT_KEY)) {
log.warn("AI 调用熔断中(流式),返回降级提示"); log.warn("AI 调用熔断中(流式),返回降级提示");
recordTrace(ctx, null, CIRCUIT_OPEN_MESSAGE, 0, "BYPASS", recordTrace(ctx, null, CIRCUIT_OPEN_MESSAGE, 0, "BYPASS",
@ -452,7 +453,7 @@ public class AssistantApp {
* <p> * <p>
* 复用 {@link #chatStream(ChatContext)} 的完整编排逻辑(熔断早退 / FAQ 命中早退 / * 复用 {@link #chatStream(ChatContext)} 的完整编排逻辑(熔断早退 / FAQ 命中早退 /
* 正常流式调用 / 空白缓冲 / 埋点),差异在于把每个文本片段包装为 OpenAI 标准 JSON chunk: * 正常流式调用 / 空白缓冲 / 埋点),差异在于把每个文本片段包装为 OpenAI 标准 JSON chunk:
* 首片 delta 携带 role=assistant,流结束时追加 finish_reason=stop 的 chunk 与 [DONE]。
* 首片 delta 携带 role=assistant,正文结束后追加 sources 元数据、finish_reason=stop 与 [DONE]。
* <p> * <p>
* 每个 Flux 元素即一个完整 JSON 字符串,Spring WebFlux 自动加 data: 前缀。 * 每个 Flux 元素即一个完整 JSON 字符串,Spring WebFlux 自动加 data: 前缀。
* *
@ -460,6 +461,10 @@ public class AssistantApp {
* @return OpenAI 标准格式的流式回答 * @return OpenAI 标准格式的流式回答
*/ */
public Flux<String> chatStreamOpenAi(ChatContext ctx) { public Flux<String> chatStreamOpenAi(ChatContext ctx) {
return Flux.defer(() -> buildOpenAiStream(ctx)).subscribeOn(Schedulers.boundedElastic());
}
private Flux<String> buildOpenAiStream(ChatContext ctx) {
long startNanos = System.nanoTime(); long startNanos = System.nanoTime();
// OpenAI 标准 chunk 的公共元信息:同一次流式回答共享 id / created / model // OpenAI 标准 chunk 的公共元信息:同一次流式回答共享 id / created / model
String completionId = "chatcmpl-" + UUID.randomUUID().toString().replace("-", ""); String completionId = "chatcmpl-" + UUID.randomUUID().toString().replace("-", "");
@ -472,7 +477,7 @@ public class AssistantApp {
log.warn("获取活跃模型配置失败,model 回退 unknown: chatId={}, error={}", ctx.chatId(), e.getMessage()); log.warn("获取活跃模型配置失败,model 回退 unknown: chatId={}, error={}", ctx.chatId(), e.getMessage());
} }
String model = (cfg != null && cfg.getModelName() != null) ? cfg.getModelName() : "unknown"; String model = (cfg != null && cfg.getModelName() != null) ? cfg.getModelName() : "unknown";
// 熔断:全局 AI 调用处于熔断状态(不做 buildRequest,避免熔断期间仍走意图路由/检索)
// 熔断时直接返回降级提示,不再编排或检索。
if (aiCircuitBreaker.isOpen(AI_CIRCUIT_KEY)) { if (aiCircuitBreaker.isOpen(AI_CIRCUIT_KEY)) {
log.warn("AI 调用熔断中(OpenAI 流式),返回降级提示"); log.warn("AI 调用熔断中(OpenAI 流式),返回降级提示");
recordTrace(ctx, null, CIRCUIT_OPEN_MESSAGE, 0, "BYPASS", recordTrace(ctx, null, CIRCUIT_OPEN_MESSAGE, 0, "BYPASS",
@ -518,7 +523,8 @@ public class AssistantApp {
}); });
// 聚合所有分片用于埋点(在 doFinally 时取完整回复文本) // 聚合所有分片用于埋点(在 doFinally 时取完整回复文本)
StringBuilder aggregated = new StringBuilder(); StringBuilder aggregated = new StringBuilder();
return preserveTrailingWhitespace(rawStream)
// JSON 编码会保留正文空白,无需为 SSE 行尾 trim 缓冲 token。
return rawStream.filter(chunk -> !chunk.isEmpty())
.doOnNext(aggregated::append) .doOnNext(aggregated::append)
.map(chunk -> buildOpenAiChunk(completionId, model, created, chunk, false, null)) .map(chunk -> buildOpenAiChunk(completionId, model, created, chunk, false, null))
.doOnComplete(() -> aiCircuitBreaker.recordSuccess(AI_CIRCUIT_KEY)) .doOnComplete(() -> aiCircuitBreaker.recordSuccess(AI_CIRCUIT_KEY))
@ -540,12 +546,12 @@ public class AssistantApp {
usage != null ? usage.getTotalTokens() : null, usage != null ? usage.getTotalTokens() : null,
events)); events));
}) })
// 首片(仅 role=assistant、无 content)在流订阅时立即发出,确保 SSE 响应头/首字节及时 flush。
// 推理模型(如 doubao-seed)思考阶段 delta.content 为空、被 preserveTrailingWhitespace 吞掉,
// 若不提前发首片,思考阶段将无任何字节输出,前端等待首字节会触发 60s 超时。
// 编排结束后先发送 role 协议帧,避免模型思考期间连接完全静默。
// 此帧没有正文,不代表用户已收到首个回答 token。
.startWith(buildOpenAiChunk(completionId, model, created, "", true, null)) .startWith(buildOpenAiChunk(completionId, model, created, "", true, null))
// 流正常结束时追加 finish_reason=stop 的 chunk 与 [DONE]
// 来源只取本次编排命中;元数据不经过正文聚合与 trace。
.concatWith(Flux.just( .concatWith(Flux.just(
buildSourcesChunk(completionId, model, created, SourceReference.fromDocuments(req.hitDocuments())),
buildOpenAiChunk(completionId, model, created, "", false, "stop"), buildOpenAiChunk(completionId, model, created, "", false, "stop"),
"[DONE]")) "[DONE]"))
// 错误兜底:脱敏错误信息,避免泄露内部细节(首片 role 已提前发出,此处不再带 role) // 错误兜底:脱敏错误信息,避免泄露内部细节(首片 role 已提前发出,此处不再带 role)
@ -594,8 +600,23 @@ public class AssistantApp {
} }
} }
private String buildSourcesChunk(String id, String model, long created, List<SourceReference> sources) {
Map<String, Object> chunk = new LinkedHashMap<>();
chunk.put("id", id);
chunk.put("object", "chat.completion.chunk");
chunk.put("created", created);
chunk.put("model", model);
chunk.put("choices", List.of());
chunk.put("sources", sources);
try {
return OBJECT_MAPPER.writeValueAsString(chunk);
} catch (Exception e) {
throw new IllegalStateException("序列化引用来源失败", e);
}
}
/** /**
* 组装 OpenAI 格式的早退/兜底流:内容 chunk + finish_reason=stop + [DONE]。
* 组装 OpenAI 格式的早退/兜底流:内容 + 空 sources + finish_reason=stop + [DONE]。
* 用于熔断降级、FAQ 命中与错误兜底三种场景。 * 用于熔断降级、FAQ 命中与错误兜底三种场景。
* *
* @param id chunk 唯一 ID * @param id chunk 唯一 ID
@ -608,6 +629,7 @@ public class AssistantApp {
private Flux<String> openAiFallbackStream(String id, String model, long created, String content, boolean withRole) { private Flux<String> openAiFallbackStream(String id, String model, long created, String content, boolean withRole) {
return Flux.just( return Flux.just(
buildOpenAiChunk(id, model, created, content, withRole, null), buildOpenAiChunk(id, model, created, content, withRole, null),
buildSourcesChunk(id, model, created, List.of()),
buildOpenAiChunk(id, model, created, "", false, "stop"), buildOpenAiChunk(id, model, created, "", false, "stop"),
"[DONE]"); "[DONE]");
} }

7
src/main/java/com/wok/supportbot/app/ChatContext.java

@ -17,7 +17,7 @@ import java.util.List;
* @param systemPrompt 角色人设/系统提示词,可为 null * @param systemPrompt 角色人设/系统提示词,可为 null
* @param allowedMcpTools 允许的 MCP 工具名列表;null=无角色允许全部;空=有角色但无授权(禁止);["*"]=全部 * @param allowedMcpTools 允许的 MCP 工具名列表;null=无角色允许全部;空=有角色但无授权(禁止);["*"]=全部
* @param categoryIds 知识库分类隔离范围,可为空(不限制) * @param categoryIds 知识库分类隔离范围,可为空(不限制)
* @param rewriteStrategy RAG 查询重写策略(REWRITE / TRANSLATION / COMPRESSION / MULTI_QUERY),可为 null
* @param rewriteStrategy RAG 查询重写策略,默认 NONE 原文检索;可显式选择 REWRITE / TRANSLATION / COMPRESSION / MULTI_QUERY
* @param enableRag 是否启用 RAG 检索;false=普通对话 * @param enableRag 是否启用 RAG 检索;false=普通对话
* @param streaming 是否流式输出 * @param streaming 是否流式输出
* @param roleId 客服角色 ID(可空,供调用追踪等使用) * @param roleId 客服角色 ID(可空,供调用追踪等使用)
@ -47,12 +47,15 @@ public record ChatContext(
private static final String DEFAULT_APP_TYPE = "CHAT"; private static final String DEFAULT_APP_TYPE = "CHAT";
/** /**
* 紧凑构造器:规范化默认值,保证 appType 非空。
* 紧凑构造器:规范化应用类型与检索默认值。
*/ */
public ChatContext { public ChatContext {
if (appType == null || appType.isBlank()) { if (appType == null || appType.isBlank()) {
appType = DEFAULT_APP_TYPE; appType = DEFAULT_APP_TYPE;
} }
if (rewriteStrategy == null || rewriteStrategy.isBlank()) {
rewriteStrategy = "NONE";
}
} }
/** /**

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

@ -3,9 +3,7 @@ package com.wok.supportbot.app;
import com.wok.supportbot.rag.RagContext; 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.SystemConfigService; import com.wok.supportbot.service.SystemConfigService;
import com.wok.supportbot.service.RagHitLogService;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document; import org.springframework.ai.document.Document;
@ -20,19 +18,17 @@ import java.util.Optional;
/** /**
* 统一对话管道(编排层)。 * 统一对话管道(编排层)。
* <p> * <p>
* 编排一次完整对话的决策流程:意图路由 → FAQ 优先 → RAG 检索 → 组装系统提示词与用户消息,
* 编排一次完整对话的决策流程:FAQ 优先 → 本地寒暄判断 → RAG 检索 → 组装系统提示词与用户消息,
* 产出 {@link ChatRequest} 交由 {@code AssistantApp} 执行实际的 {@code call()} / {@code stream()}。 * 产出 {@link ChatRequest} 交由 {@code AssistantApp} 执行实际的 {@code call()} / {@code stream()}。
* <p> * <p>
* 设计说明:本类为纯编排层,不持有 ChatClient(ChatClient 构建与 Advisor 链装配仍在 * 设计说明:本类为纯编排层,不持有 ChatClient(ChatClient 构建与 Advisor 链装配仍在
* {@code AssistantApp}),因此 {@code call} / {@code stream} 由 {@code AssistantApp} 承担, * {@code AssistantApp}),因此 {@code call} / {@code stream} 由 {@code AssistantApp} 承担,
* 避免 {@code ChatPipeline} ↔ {@code AssistantApp} 循环依赖。 * 避免 {@code ChatPipeline} ↔ {@code AssistantApp} 循环依赖。
* <p> * <p>
* 接入 {@link IntentRouter} 替代原 {@code AiController.shouldBypassKnowledgeRetrieval} 的硬编码寒暄词判断:
* 寒暄词列表保留为快速路径与兜底,IntentRouter 负责细粒度意图分类,二者命中其一即跳过 KB 检索。
* 完整 FAQ 匹配后仅使用本地寒暄判断,其他请求直接检索,不调用 LLM 做意图分类。
* <p> * <p>
* {@code @pipeline} orchestration-layer order=0<br> * {@code @pipeline} orchestration-layer order=0<br>
* {@code @pipeline-step} buildRequest: 意图路由 → FAQ优先 → RAG检索 → 提示词组装<br>
* {@code @pipeline-step} routeIntent: 寒暄词快速路径 → IntentRouter LLM分类 → 降级RAG<br>
* {@code @pipeline-step} buildRequest: FAQ优先 → 本地寒暄判断 → 默认原文检索 → 提示词组装<br>
* {@code @pipeline-step} effectiveSystem: DB全局提示词 + 角色人设 动态组合<br> * {@code @pipeline-step} effectiveSystem: DB全局提示词 + 角色人设 动态组合<br>
* 同步至: frontend/src/views/PipelineFlow.vue, CLAUDE.md ASCII管道图 * 同步至: frontend/src/views/PipelineFlow.vue, CLAUDE.md ASCII管道图
*/ */
@ -40,32 +36,20 @@ import java.util.Optional;
@Slf4j @Slf4j
public class ChatPipeline { public class ChatPipeline {
/** IntentRouter 判为 CHITCHAT 的置信度阈值,低于此值视为不确定,继续走 RAG */
private static final double CHITCHAT_CONFIDENCE_THRESHOLD = 0.6;
/** FAQ 意图高置信度阈值:IntentRouter 返回 FAQ 且高于此值时,仅走 FAQ 匹配,不降级 RAG */
private static final double FAQ_HIGH_CONFIDENCE_THRESHOLD = 0.8;
@Resource
private IntentRouter intentRouter;
@Resource @Resource
private RagPipeline ragPipeline; private RagPipeline ragPipeline;
@Resource @Resource
private SystemConfigService systemConfigService; private SystemConfigService systemConfigService;
@Resource
private RagHitLogService ragHitLogService;
/** /**
* 编排一次对话请求,产出执行决策。 * 编排一次对话请求,产出执行决策。
* <p> * <p>
* 决策分支: * 决策分支:
* <ul> * <ul>
* <li>未启用 RAG(普通对话 / 严格隔离下 KB 拒绝)→ 用原始 message、基础 system</li> * <li>未启用 RAG(普通对话 / 严格隔离下 KB 拒绝)→ 用原始 message、基础 system</li>
* <li>寒暄/闲聊(IntentRouter 或寒暄词命中)→ 同上,跳过 KB 检索</li>
* <li>FAQ 命中 → 直接返回标准答案,不调用 ChatClient</li> * <li>FAQ 命中 → 直接返回标准答案,不调用 ChatClient</li>
* <li>FAQ 未命中的本地寒暄词 → 跳过 KB 检索</li>
* <li>RAG 生成 → 资料块注入 system,原始 message 作为 user 消息(重写查询仅用于检索)</li> * <li>RAG 生成 → 资料块注入 system,原始 message 作为 user 消息(重写查询仅用于检索)</li>
* </ul> * </ul>
* *
@ -82,59 +66,25 @@ public class ChatPipeline {
globalPrompt, null, null, "CHAT", null, null, null); globalPrompt, null, null, "CHAT", null, null, null);
} }
// 意图路由:先用 IntentRouter 做细粒度分类
IntentRouter.IntentResult intent = routeIntent(ctx.message());
// FAQ 高置信度:优先匹配标准答案;未命中时降级到 RAG 检索,避免知识库中已有答案却返回兜底提示。
// 若此处已「干净跑完」完整 FAQ 三级匹配仍未命中,进入 RAG 检索时跳过重复的 FAQ 匹配,避免同一请求两次 FAQ 语义 embedding。
boolean faqSkippableInRetrieve = false;
if (intent != null && "FAQ".equals(intent.getIntent())
&& intent.getConfidence() >= FAQ_HIGH_CONFIDENCE_THRESHOLD) {
RagPipeline.FaqMatchOutcome faqOutcome = ragPipeline.tryFaqMatchClean(ctx.message(), ctx.categoryIds());
if (faqOutcome.result().isPresent()) {
log.info("FAQ 高置信({}),命中标准答案: chatId={}", intent.getConfidence(), ctx.chatId());
Optional<String> faqAnswer = Optional.ofNullable(faqOutcome.result().get().getFaq().getAnswer());
return new ChatRequest(ctx, ctx.message(), baseSystem, faqAnswer,
globalPrompt, null, null, "FAQ", null, null, faqOutcome.result().get());
}
log.info("FAQ 高置信({}) 未命中标准答案,降级到 RAG 检索: chatId={}", intent.getConfidence(), ctx.chatId());
// 仅当第一次 FAQ 匹配「干净完成」才允许后续检索跳过第二次 FAQ(异常降级的 miss 不跳过,避免误跳)
faqSkippableInRetrieve = faqOutcome.completedCleanly();
// 完整 FAQ 三级匹配前置,保留角色分类隔离。
RagPipeline.FaqMatchOutcome faqOutcome = ragPipeline.tryFaqMatchClean(ctx.message(), ctx.categoryIds());
if (faqOutcome.result().isPresent()) {
FaqMatchResult faqMatch = faqOutcome.result().get();
log.info("FAQ 命中标准答案: chatId={}, matchType={}", ctx.chatId(), faqMatch.getMatchType());
return new ChatRequest(ctx, ctx.message(), baseSystem,
Optional.ofNullable(faqMatch.getFaq().getAnswer()),
globalPrompt, null, null, "FAQ", null, null, faqMatch);
} }
// 寒暄/闲聊:IntentRouter 判定 CHITCHAT 高置信,跳过 KB 检索
if (intent != null && "CHITCHAT".equals(intent.getIntent())
&& intent.getConfidence() >= CHITCHAT_CONFIDENCE_THRESHOLD) {
// 闲聊前先尝试 FAQ 精准匹配,避免"你是谁"等被配置成 FAQ 后命中不了
Optional<FaqMatchResult> faqMatch = ragPipeline.tryFaqMatchResult(ctx.message(), ctx.categoryIds());
if (faqMatch.isPresent()) {
log.info("闲聊意图但 FAQ 命中标准答案: chatId={}, matchType={}", ctx.chatId(), faqMatch.get().getMatchType());
return new ChatRequest(ctx, ctx.message(), baseSystem,
Optional.ofNullable(faqMatch.get().getFaq().getAnswer()),
globalPrompt, null, null, "FAQ", null, null, faqMatch.get());
}
// 仅本地寒暄词绕过检索;业务问题默认不做任何 LLM 预处理。
if (isChitchat(ctx.message())) {
return new ChatRequest(ctx, ctx.message(), baseSystem, Optional.empty(), return new ChatRequest(ctx, ctx.message(), baseSystem, Optional.empty(),
globalPrompt, null, null, "CHITCHAT", null, null, null); globalPrompt, null, null, "CHITCHAT", null, null, null);
} }
// RAG 检索(含 FAQ 优先匹配;FAQ 高置信已完整匹配过则跳过二次 FAQ)
RagContext rag = ragPipeline.retrieve(ctx, faqSkippableInRetrieve);
// 记录 RAG 检索日志到 rag_hit_log 表(供知识库分析看板使用)
if (!rag.faqHit() && rag.documents() != null && !rag.documents().isEmpty()) {
String searchMode = ctx.rewriteStrategy() != null ? ctx.rewriteStrategy() : "VECTOR";
for (Document doc : rag.documents()) {
String docIdStr = String.valueOf(doc.getMetadata().getOrDefault("documentId", ""));
Long documentId = null;
try { if (!docIdStr.isEmpty()) documentId = Long.parseLong(docIdStr); } catch (NumberFormatException ignored) { }
String title = String.valueOf(doc.getMetadata().getOrDefault("title", ""));
String score = String.valueOf(doc.getMetadata().getOrDefault("score", ""));
ragHitLogService.recordHit(ctx.chatId(), ctx.message(), documentId, title, score, searchMode);
}
} else if (!rag.faqHit()) {
String searchMode = ctx.rewriteStrategy() != null ? ctx.rewriteStrategy() : "VECTOR";
ragHitLogService.recordMiss(ctx.chatId(), ctx.message(), searchMode);
}
// 仅干净完成的 FAQ 匹配可跳过;异常降级的 miss 仍由 RAG 重试,避免误跳。
RagContext rag = ragPipeline.retrieve(ctx, faqOutcome.completedCleanly());
if (rag.faqHit()) { if (rag.faqHit()) {
return new ChatRequest(ctx, ctx.message(), baseSystem, rag.faqAnswer(), return new ChatRequest(ctx, ctx.message(), baseSystem, rag.faqAnswer(),
globalPrompt, null, null, "FAQ", null, null, rag.faqMatchResult()); globalPrompt, null, null, "FAQ", null, null, rag.faqMatchResult());
@ -147,44 +97,9 @@ public class ChatPipeline {
rag.searchMode(), rag.documents(), null); rag.searchMode(), rag.documents(), null);
} }
/**
* 意图路由:先用寒暄词列表做快速路径,未命中再调 IntentRouter 做 LLM 分类。
* 异常时返回 null(调用方默认走 RAG 检索)。
*/
private IntentRouter.IntentResult routeIntent(String message) {
// 寒暄词快速路径(零 LLM 开销)
if (isChitchat(message)) {
return new IntentRouter.IntentResult("CHITCHAT", 1.0);
}
try {
return intentRouter.route(message);
} catch (Exception e) {
log.debug("意图路由异常,沿用 RAG 检索: {}", e.getMessage());
return null;
}
}
/**
* 判断是否跳过 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 的快速路径与兜底。
* <p>
* 供 {@code buildRequest} 与"引用来源"等不需 LLM 意图分类的场景共用。
* 供 {@code buildRequest} 与独立引用检索接口共用,不调用 LLM。
*/ */
public boolean isChitchat(String message) { public boolean isChitchat(String message) {
if (!StringUtils.hasText(message)) { if (!StringUtils.hasText(message)) {

10
src/main/java/com/wok/supportbot/app/ChatResult.java

@ -17,17 +17,15 @@ import java.util.List;
* @param text AI 回答文本 * @param text AI 回答文本
* @param mcpEvents 本次触发的 MCP 工具调用事件,无调用时为空列表 * @param mcpEvents 本次触发的 MCP 工具调用事件,无调用时为空列表
* @param suggestions AI 推荐问题列表(0~3 条),非 LLM 路径为空 * @param suggestions AI 推荐问题列表(0~3 条),非 LLM 路径为空
* @param sources 本次答案实际使用的知识库片段,不进行附加检索
*/ */
public record ChatResult(String text, List<McpToolCallback.ToolCallEvent> mcpEvents, List<String> suggestions) {
/** 向后兼容构造器(无 suggestions) */
public ChatResult(String text, List<McpToolCallback.ToolCallEvent> mcpEvents) {
this(text, mcpEvents, List.of());
}
public record ChatResult(String text, List<McpToolCallback.ToolCallEvent> mcpEvents,
List<String> suggestions, List<SourceReference> sources) {
/** 紧凑构造器:保证不可变性 */ /** 紧凑构造器:保证不可变性 */
public ChatResult { public ChatResult {
suggestions = suggestions != null ? List.copyOf(suggestions) : List.of(); suggestions = suggestions != null ? List.copyOf(suggestions) : List.of();
mcpEvents = mcpEvents != null ? List.copyOf(mcpEvents) : List.of(); mcpEvents = mcpEvents != null ? List.copyOf(mcpEvents) : List.of();
sources = sources != null ? List.copyOf(sources) : List.of();
} }
} }

59
src/main/java/com/wok/supportbot/app/SourceReference.java

@ -0,0 +1,59 @@
package com.wok.supportbot.app;
import com.fasterxml.jackson.annotation.JsonInclude;
import org.springframework.ai.document.Document;
import java.util.List;
import java.util.Map;
/** Public citation metadata from the documents actually used by this answer. */
@JsonInclude(JsonInclude.Include.ALWAYS)
public record SourceReference(String documentId, String title, String sourceName,
Integer chunkIndex, Double score, String snippet) {
public SourceReference {
if (snippet != null && snippet.length() > 160) {
int end = Character.isHighSurrogate(snippet.charAt(158)) ? 158 : 159;
snippet = snippet.substring(0, end) + "…";
}
}
public static List<SourceReference> fromDocuments(List<Document> documents) {
if (documents == null || documents.isEmpty()) {
return List.of();
}
return documents.stream().map(SourceReference::fromDocument).toList();
}
private static SourceReference fromDocument(Document document) {
Map<String, Object> metadata = document.getMetadata();
// Preserve the published source endpoint's distance semantics; other retrievers expose score.
Object score = metadata.get("distance") != null ? metadata.get("distance") : metadata.get("score");
return new SourceReference(stringValue(metadata.get("documentId")),
stringValue(metadata.get("title")), stringValue(metadata.get("sourceName")),
integerValue(metadata.get("chunkIndex")), doubleValue(score), document.getText());
}
private static String stringValue(Object value) {
return value == null ? null : value.toString();
}
private static Integer integerValue(Object value) {
if (value == null) return null;
try {
return Integer.valueOf(value.toString());
} catch (NumberFormatException ignored) {
return null;
}
}
private static Double doubleValue(Object value) {
if (value == null) return null;
try {
double number = Double.parseDouble(value.toString());
return Double.isFinite(number) ? number : null;
} catch (NumberFormatException ignored) {
return null;
}
}
}

27
src/main/java/com/wok/supportbot/config/ChatModelFactory.java

@ -200,6 +200,7 @@ public class ChatModelFactory {
} }
// F3: 应用高级参数(topP、frequencyPenalty、presencePenalty、stopSequences) // F3: 应用高级参数(topP、frequencyPenalty、presencePenalty、stopSequences)
applyExtraConfig(config, optionsBuilder); applyExtraConfig(config, optionsBuilder);
applyReasoningEffort(config, optionsBuilder);
return OpenAiChatModel.builder() return OpenAiChatModel.builder()
.openAiApi(api) .openAiApi(api)
@ -349,6 +350,32 @@ public class ChatModelFactory {
} }
} }
/**
* Seed 2.0 的 minimal 关闭思考;仅 CHAT 默认快速生成,其他模型不发送未知参数。
* https://docs.volcengine.com/docs/82379/1449737
*/
private void applyReasoningEffort(AiModelConfig config, OpenAiChatOptions.Builder optionsBuilder) {
if (!"volcengine".equalsIgnoreCase(config.getProvider())
|| config.getModelName() == null
|| !config.getModelName().startsWith("doubao-seed-2-0-")) {
return;
}
Object configured = config.getExtraConfig() == null
? null : config.getExtraConfig().get("reasoningEffort");
if (configured == null || configured instanceof String value && value.isBlank()) {
if ("CHAT".equalsIgnoreCase(config.getAppType())) {
optionsBuilder.reasoningEffort("minimal");
}
return;
}
if (!(configured instanceof String effort)
|| !("minimal".equals(effort) || "low".equals(effort)
|| "medium".equals(effort) || "high".equals(effort))) {
throw new IllegalArgumentException("reasoningEffort 必须为 minimal、low、medium 或 high");
}
optionsBuilder.reasoningEffort(effort);
}
/** /**
* 从 extraConfig 中读取高级参数并设置到 DashScopeChatOptions.Builder * 从 extraConfig 中读取高级参数并设置到 DashScopeChatOptions.Builder
*/ */

73
src/main/java/com/wok/supportbot/controller/AiController.java

@ -3,6 +3,8 @@ package com.wok.supportbot.controller;
import com.wok.supportbot.app.AssistantApp; import com.wok.supportbot.app.AssistantApp;
import com.wok.supportbot.app.ChatContext; import com.wok.supportbot.app.ChatContext;
import com.wok.supportbot.app.ChatPipeline; import com.wok.supportbot.app.ChatPipeline;
import com.wok.supportbot.app.ChatResult;
import com.wok.supportbot.app.SourceReference;
import com.wok.supportbot.app.SuggestionGenerator; import com.wok.supportbot.app.SuggestionGenerator;
import com.wok.supportbot.cache.SuggestionCache; import com.wok.supportbot.cache.SuggestionCache;
import com.wok.supportbot.config.RoleAccessConfig; import com.wok.supportbot.config.RoleAccessConfig;
@ -12,7 +14,6 @@ import com.wok.supportbot.service.CustomerServiceRoleService;
import com.wok.supportbot.service.CustomerServiceRoleService.RoleScope; import com.wok.supportbot.service.CustomerServiceRoleService.RoleScope;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
@ -28,10 +29,8 @@ import reactor.core.publisher.Flux;
import java.net.URLDecoder; import java.net.URLDecoder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -122,21 +121,7 @@ public class AiController {
List<Long> cats = resolveCategoryIds(scope, categoryId, categoryIds); List<Long> cats = resolveCategoryIds(scope, categoryId, categoryIds);
ChatContext ctx = new ChatContext(message, chatId, "CHAT", null, null, cats, ChatContext ctx = new ChatContext(message, chatId, "CHAT", null, null, cats,
normalizeStrategy(rewriteStrategy), true, false, context.roleId(), scope.name(), context.accountId(), null, null); normalizeStrategy(rewriteStrategy), true, false, context.roleId(), scope.name(), context.accountId(), null, null);
List<Document> docs = assistantApp.retrieveSources(ctx);
List<Map<String, Object>> out = new ArrayList<>();
for (Document doc : docs) {
Map<String, Object> meta = doc.getMetadata();
Map<String, Object> item = new LinkedHashMap<>();
item.put("documentId", meta.get("documentId"));
item.put("title", meta.get("title"));
item.put("sourceName", meta.get("sourceName"));
item.put("chunkIndex", meta.get("chunkIndex"));
item.put("score", meta.get("distance"));
String text = doc.getText();
item.put("snippet", text != null && text.length() > 160 ? text.substring(0, 160) + "…" : text);
out.add(item);
}
return Map.of("success", true, "data", out);
return Map.of("success", true, "data", SourceReference.fromDocuments(assistantApp.retrieveSources(ctx)));
} catch (Exception e) { } catch (Exception e) {
log.error("获取 RAG 引用来源失败 [strategy={}]: {}", rewriteStrategy, e.getMessage(), e); log.error("获取 RAG 引用来源失败 [strategy={}]: {}", rewriteStrategy, e.getMessage(), e);
return Map.of("success", true, "data", List.of()); return Map.of("success", true, "data", List.of());
@ -286,9 +271,9 @@ public class AiController {
return roleAccessConfig.isStrictIsolation() && scope.hasRole() && scope.categoryIds().isEmpty(); return roleAccessConfig.isStrictIsolation() && scope.hasRole() && scope.categoryIds().isEmpty();
} }
/** 未指定策略时默认 MULTI_QUERY(多路扩展)。 */
/** 未指定策略时使用原始问题检索,不调用重写模型。 */
private String normalizeStrategy(String rewriteStrategy) { private String normalizeStrategy(String rewriteStrategy) {
return (rewriteStrategy != null && !rewriteStrategy.isEmpty()) ? rewriteStrategy : "MULTI_QUERY";
return StringUtils.hasText(rewriteStrategy) ? rewriteStrategy : "NONE";
} }
private AccountRoleContext resolveAccountRole(String accountId, Long fallbackRoleId) { private AccountRoleContext resolveAccountRole(String accountId, Long fallbackRoleId) {
@ -344,15 +329,35 @@ public class AiController {
@RequestParam(required = false) Long categoryId, @RequestParam(required = false) Long categoryId,
@RequestParam(required = false) String categoryIds, @RequestParam(required = false) String categoryIds,
@RequestParam(required = false) String imageUrls) { @RequestParam(required = false) String imageUrls) {
ChatContext ctx;
if (Boolean.TRUE.equals(enableRag)) {
ctx = buildRagChatContext(message, chatId, rewriteStrategy, roleId, accountId,
categoryId, categoryIds, systemPrompt);
} else {
ctx = buildChatContext(message, chatId, roleId, accountId, systemPrompt);
}
ctx = ctx.withImageUrls(parseImageUrls(imageUrls));
return assistantApp.chat(ctx);
return chatResult(message, chatId, roleId, accountId, systemPrompt, enableRag,
rewriteStrategy, categoryId, categoryIds, imageUrls).text();
}
/** 同步完整结果;与文本接口共享角色隔离、会话绑定和图片处理。 */
@GetMapping(value = "/chat/result", produces = MediaType.APPLICATION_JSON_VALUE)
public ChatResult chatResult(
@RequestParam String message,
@RequestParam(required = false) String chatId,
@RequestParam(required = false) Long roleId,
@RequestParam(required = false) String accountId,
@RequestParam(required = false) String systemPrompt,
@RequestParam(required = false) Boolean enableRag,
@RequestParam(required = false) String rewriteStrategy,
@RequestParam(required = false) Long categoryId,
@RequestParam(required = false) String categoryIds,
@RequestParam(required = false) String imageUrls) {
return assistantApp.chatWithEvents(buildStandardChatContext(message, chatId, roleId, accountId,
systemPrompt, enableRag, rewriteStrategy, categoryId, categoryIds, imageUrls));
}
private ChatContext buildStandardChatContext(String message, String chatId, Long roleId, String accountId,
String systemPrompt, Boolean enableRag, String rewriteStrategy, Long categoryId,
String categoryIds, String imageUrls) {
ChatContext ctx = Boolean.TRUE.equals(enableRag)
? buildRagChatContext(message, chatId, rewriteStrategy, roleId, accountId,
categoryId, categoryIds, systemPrompt)
: buildChatContext(message, chatId, roleId, accountId, systemPrompt);
return ctx.withImageUrls(parseImageUrls(imageUrls));
} }
/** /**
@ -371,14 +376,8 @@ public class AiController {
@RequestParam(required = false) Long categoryId, @RequestParam(required = false) Long categoryId,
@RequestParam(required = false) String categoryIds, @RequestParam(required = false) String categoryIds,
@RequestParam(required = false) String imageUrls) { @RequestParam(required = false) String imageUrls) {
ChatContext ctx;
if (Boolean.TRUE.equals(enableRag)) {
ctx = buildRagChatContext(message, chatId, rewriteStrategy, roleId, accountId,
categoryId, categoryIds, systemPrompt);
} else {
ctx = buildChatContext(message, chatId, roleId, accountId, systemPrompt);
}
ctx = ctx.withImageUrls(parseImageUrls(imageUrls)).withStreaming(true);
ChatContext ctx = buildStandardChatContext(message, chatId, roleId, accountId, systemPrompt,
enableRag, rewriteStrategy, categoryId, categoryIds, imageUrls).withStreaming(true);
return assistantApp.chatStreamOpenAi(ctx); return assistantApp.chatStreamOpenAi(ctx);
} }

14
src/main/java/com/wok/supportbot/controller/OpenApiController.java

@ -2,6 +2,7 @@ package com.wok.supportbot.controller;
import com.wok.supportbot.app.AssistantApp; import com.wok.supportbot.app.AssistantApp;
import com.wok.supportbot.app.ChatContext; import com.wok.supportbot.app.ChatContext;
import com.wok.supportbot.app.ChatResult;
import com.wok.supportbot.app.SuggestionGenerator; import com.wok.supportbot.app.SuggestionGenerator;
import com.wok.supportbot.cache.SuggestionCache; import com.wok.supportbot.cache.SuggestionCache;
import com.wok.supportbot.config.RoleAccessConfig; import com.wok.supportbot.config.RoleAccessConfig;
@ -84,13 +85,16 @@ public class OpenApiController {
ChatContext ctx = buildOpenApiChatContext(message, resolvedChatId, apiKey, roleId, ChatContext ctx = buildOpenApiChatContext(message, resolvedChatId, apiKey, roleId,
categoryIds, rewriteStrategy, enableRag, false); categoryIds, rewriteStrategy, enableRag, false);
String reply = assistantApp.chat(ctx);
ChatResult reply = assistantApp.chatWithEvents(ctx);
Map<String, Object> result = new HashMap<>(); Map<String, Object> result = new HashMap<>();
result.put("success", true); result.put("success", true);
result.put("data", Map.of( result.put("data", Map.of(
"reply", reply,
"chatId", resolvedChatId
"reply", reply.text(),
"chatId", resolvedChatId,
"mcpEvents", reply.mcpEvents(),
"suggestions", reply.suggestions(),
"sources", reply.sources()
)); ));
return ResponseEntity.ok(result); return ResponseEntity.ok(result);
} catch (Exception e) { } catch (Exception e) {
@ -219,9 +223,9 @@ public class OpenApiController {
boolean useRag = (enableRag == null || enableRag) // 默认启用 RAG boolean useRag = (enableRag == null || enableRag) // 默认启用 RAG
&& !(roleAccessConfig.isStrictIsolation() && scope.hasRole() && scope.categoryIds().isEmpty()); && !(roleAccessConfig.isStrictIsolation() && scope.hasRole() && scope.categoryIds().isEmpty());
// 6. 未指定策略时默认 MULTI_QUERY
// 6. 未指定策略时直接使用原始问题检索
String strategy = (rewriteStrategy != null && !rewriteStrategy.isBlank()) String strategy = (rewriteStrategy != null && !rewriteStrategy.isBlank())
? rewriteStrategy : "MULTI_QUERY";
? rewriteStrategy : "NONE";
return new ChatContext(message, chatId, "CHAT", systemPrompt, return new ChatContext(message, chatId, "CHAT", systemPrompt,
scope.hasRole() ? scope.allowedMcpTools() : null, scope.hasRole() ? scope.allowedMcpTools() : null,

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

@ -37,7 +37,7 @@ import java.util.stream.Collectors;
* 收敛原本分散在 {@code AssistantApp} 中按策略分支的检索逻辑: * 收敛原本分散在 {@code AssistantApp} 中按策略分支的检索逻辑:
* <ul> * <ul>
* <li>FAQ 优先匹配(复用 {@link FaqMatchEngine} 三级匹配)</li> * <li>FAQ 优先匹配(复用 {@link FaqMatchEngine} 三级匹配)</li>
* <li>查询重写(按 {@code rewriteStrategy} 复用 {@code rag/preretrieval/*} 四种 rewriter)</li>
* <li>默认 NONE 原文直检索;仅显式选择时调用 {@code rag/preretrieval/*} 四种 rewriter</li>
* <li>统一检索:{@code MULTI_QUERY} 扩展多查询后按文档 ID 去重合并,其余策略单查询检索</li> * <li>统一检索:{@code MULTI_QUERY} 扩展多查询后按文档 ID 去重合并,其余策略单查询检索</li>
* <li>统一资料块模板 {@link #buildRagContextBlock},替代原 {@code buildRetrievalAdvisor.qaTemplate} * <li>统一资料块模板 {@link #buildRagContextBlock},替代原 {@code buildRetrievalAdvisor.qaTemplate}
* 与 {@code buildRagSystemPrompt} 两份回答模板</li> * 与 {@code buildRagSystemPrompt} 两份回答模板</li>
@ -48,10 +48,10 @@ import java.util.stream.Collectors;
* 不再使用 {@code RetrievalAugmentationAdvisor} 的 query augmenter 自动注入, * 不再使用 {@code RetrievalAugmentationAdvisor} 的 query augmenter 自动注入,
* 消除上下文注入位置随策略不同而不同的不一致。 * 消除上下文注入位置随策略不同而不同的不一致。
* <p> * <p>
* 阶段一作为旁路组件存在,旧 {@code AssistantApp} RAG 路径未改动;阶段二由 {@code ChatPipeline} 接入。
* 由 {@code ChatPipeline} 编排调用;RAG 命中/未命中日志仅在本管道记录一次。
* <p> * <p>
* {@code @pipeline} rag-layer order=1<br> * {@code @pipeline} rag-layer order=1<br>
* {@code @pipeline-step} retrieve: FAQ优先匹配 → 查询重写/扩展 → similaritySearch(PGVector) → 资料拼接<br>
* {@code @pipeline-step} retrieve: FAQ优先匹配 → 默认原文/显式重写 → similaritySearch(PGVector) → 资料拼接<br>
* {@code @pipeline-step} similaritySearch: 纯向量检索 topK=4 + CategoryFilter 分类过滤<br> * {@code @pipeline-step} similaritySearch: 纯向量检索 topK=4 + CategoryFilter 分类过滤<br>
* 注意: HybridSearchService/RrfFusion/RerankerService 尚未接入本管道,当前仅单路向量检索。<br> * 注意: HybridSearchService/RrfFusion/RerankerService 尚未接入本管道,当前仅单路向量检索。<br>
* 同步至: frontend/src/views/PipelineFlow.vue RAG 子图 * 同步至: frontend/src/views/PipelineFlow.vue RAG 子图
@ -113,7 +113,7 @@ public class RagPipeline {
/** /**
* 执行一次统一的 RAG 检索。 * 执行一次统一的 RAG 检索。
* <p> * <p>
* 流程:FAQ 优先 → 查询重写/扩展 → 统一检索 → 拼接资料文本。
* 流程:FAQ 优先 → 默认原文(显式选择才重写/扩展)→ 统一检索 → 拼接资料文本。
* *
* @param ctx 对话上下文(使用 {@code message / chatId / rewriteStrategy / categoryIds}) * @param ctx 对话上下文(使用 {@code message / chatId / rewriteStrategy / categoryIds})
* @return 检索结果;FAQ 命中时 documents 与 contextText 为空,rewrittenQuery 为原始 message * @return 检索结果;FAQ 命中时 documents 与 contextText 为空,rewrittenQuery 为原始 message
@ -125,10 +125,10 @@ public class RagPipeline {
/** /**
* 执行一次统一的 RAG 检索(可跳过前序已做过的 FAQ 匹配)。 * 执行一次统一的 RAG 检索(可跳过前序已做过的 FAQ 匹配)。
* <p> * <p>
* 流程:FAQ 优先(未匹配过时)→ 查询重写/扩展 → 统一检索 → 拼接资料文本。
* 流程:FAQ 优先(未匹配过时)→ 默认原文(显式选择才重写/扩展)→ 统一检索 → 拼接资料文本。
* *
* @param ctx 对话上下文(使用 {@code message / chatId / rewriteStrategy / categoryIds}) * @param ctx 对话上下文(使用 {@code message / chatId / rewriteStrategy / categoryIds})
* @param faqAlreadyMatched 编排层是否已在前序阶段(FAQ 高置信未命中降级)干净跑过完整 FAQ 三级匹配;
* @param faqAlreadyMatched 编排层是否已在前序阶段干净跑过完整 FAQ 三级匹配;
* true 时跳过 retrieve 内重复的 FAQ 匹配,避免同一请求重复做 FAQ 语义 embedding * true 时跳过 retrieve 内重复的 FAQ 匹配,避免同一请求重复做 FAQ 语义 embedding
* @return 检索结果;FAQ 命中时 documents 与 contextText 为空,rewrittenQuery 为原始 message * @return 检索结果;FAQ 命中时 documents 与 contextText 为空,rewrittenQuery 为原始 message
*/ */
@ -149,10 +149,10 @@ public class RagPipeline {
} }
/** /**
* 仅检索知识库片段(跳过 FAQ 匹配),用于"引用来源"展示。
* 仅检索知识库片段(跳过 FAQ 匹配),用于已发布的独立来源检索接口。
* <p> * <p>
* 与 {@link #retrieve} 共用同一套查询重写与检索逻辑,确保来源即答案所依据的片段,
* 但不触发 FAQ 优先匹配——来源接口的语义是展示 KB 片段,FAQ 命中时本就无 KB 来源。
* 与 {@link #retrieve} 共用查询重写与检索逻辑;对话引用直接复用当次生成使用的文档,
* 不调用此方法二次检索。
* *
* @param ctx 对话上下文 * @param ctx 对话上下文
* @return 命中的知识库片段(含 metadata),无命中返回空列表 * @return 命中的知识库片段(含 metadata),无命中返回空列表

114
src/main/java/com/wok/supportbot/service/IntentRouter.java

@ -1,114 +0,0 @@
package com.wok.supportbot.service;
import com.wok.supportbot.config.ChatModelFactory;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
/**
* LLM 意图分类路由器
* 使用 ChatModel 对用户问题进行意图分类,决定后续处理流程:
* - FAQ: 常见问题 → FaqMatchEngine 精准匹配
* - RAG: 知识库检索 → 现有 RAG 流程
* - CHITCHAT: 闲聊 → 简单对话
*
* <p>结构化输出使用 Spring AI 标准组件 {@link BeanOutputConverter}:
* 由它把 JSON Schema 指令追加进 Prompt,并把模型返回的 JSON 反序列化为 {@link IntentResult},
* 不再手写正则解析。{@code ChatClient.entity(...)} 内部即是同一套机制。
*/
@Service
@Slf4j
public class IntentRouter {
@Autowired
private ChatModelFactory chatModelFactory;
/** 结构化输出转换器(无状态,可安全复用):生成 Schema 指令 + 反序列化模型响应 */
private static final BeanOutputConverter<IntentResult> INTENT_CONVERTER =
new BeanOutputConverter<>(IntentResult.class);
/**
* 意图分类 Prompt 模板。
* 输出格式约束(JSON Schema)由 {@code BeanOutputConverter.getFormat()} 统一追加,模板中不再硬编码。
*/
private static final String INTENT_PROMPT_TEMPLATE = """
你是一个意图分类器。根据用户问题,判断其属于以下哪个意图:
- FAQ: 常见问题,如产品功能、价格、退换货政策、服务流程等标准问答
- RAG: 需要查阅文档/知识库才能回答的专业问题或细节问题
- CHITCHAT: 闲聊、问候、感谢、告别等非业务话题
用户问题: %s
%s
""";
// ==================== 意图结果内部类 ====================
/**
* 意图分类结果
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class IntentResult {
/** 意图类型: FAQ / RAG / CHITCHAT */
private String intent;
/** 置信度 (0.0 ~ 1.0) */
private double confidence;
}
// ==================== 核心路由方法 ====================
/**
* 对用户问题进行意图分类
*
* @param userQuestion 用户问题
* @return 意图分类结果
*/
public IntentResult route(String userQuestion) {
if (userQuestion == null || userQuestion.isBlank()) {
return new IntentResult("RAG", 0.0);
}
try {
ChatModel chatModel = chatModelFactory.getChatModel("CHAT");
String promptText = INTENT_PROMPT_TEMPLATE.formatted(userQuestion, INTENT_CONVERTER.getFormat());
String response = chatModel.call(new Prompt(promptText)).getResult().getOutput().getText();
log.debug("意图分类原始响应: {}", response);
IntentResult result = INTENT_CONVERTER.convert(response);
if (result == null || !isValidIntent(result.getIntent())) {
log.warn("意图分类结果无效,降级为 RAG: rawResponse={}", abbreviate(response));
return new IntentResult("RAG", 0.5);
}
return result;
} catch (Exception e) {
log.warn("意图分类失败,降级为 RAG: question={}", abbreviate(userQuestion), e);
return new IntentResult("RAG", 0.0);
}
}
/**
* 校验意图类型是否有效(模型可能返回枚举外的值)
*/
private boolean isValidIntent(String intent) {
return "FAQ".equals(intent) || "RAG".equals(intent) || "CHITCHAT".equals(intent);
}
/**
* 日志截断,避免回显整段响应
*/
private static String abbreviate(String text) {
if (text == null) {
return null;
}
return text.length() > 200 ? text.substring(0, 200) + "..." : text;
}
}

250
src/test/java/com/wok/supportbot/AnswerTransportTests.java

@ -0,0 +1,250 @@
package com.wok.supportbot;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.wok.supportbot.app.AssistantApp;
import com.wok.supportbot.app.ChatContext;
import com.wok.supportbot.app.ChatPipeline;
import com.wok.supportbot.app.ChatRequest;
import com.wok.supportbot.app.ChatResult;
import com.wok.supportbot.app.SourceReference;
import com.wok.supportbot.chatmemory.DatabaseChatMemory;
import com.wok.supportbot.config.ChatModelFactory;
import com.wok.supportbot.config.SimpleCircuitBreaker;
import com.wok.supportbot.entity.LlmCallTrace;
import com.wok.supportbot.service.AiModelConfigService;
import com.wok.supportbot.service.ContentSafetyService;
import com.wok.supportbot.service.LlmCallTraceService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.document.Document;
import org.springframework.test.util.ReflectionTestUtils;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class AnswerTransportTests {
private static final ObjectMapper JSON = new ObjectMapper();
private AssistantApp app;
private ChatPipeline pipeline;
private ChatClient.ChatClientRequestSpec spec;
private ChatClient.CallResponseSpec call;
private ChatClient.StreamResponseSpec stream;
private LlmCallTraceService traces;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
app = new AssistantApp(mock(ChatModelFactory.class), mock(DatabaseChatMemory.class));
pipeline = mock(ChatPipeline.class);
traces = mock(LlmCallTraceService.class);
ContentSafetyService safety = mock(ContentSafetyService.class);
when(safety.mask(any())).thenAnswer(invocation -> invocation.getArgument(0));
ReflectionTestUtils.setField(app, "chatPipeline", pipeline);
ReflectionTestUtils.setField(app, "llmCallTraceService", traces);
ReflectionTestUtils.setField(app, "contentSafetyService", safety);
ReflectionTestUtils.setField(app, "aiModelConfigService", mock(AiModelConfigService.class));
ChatClient client = mock(ChatClient.class);
spec = mock(ChatClient.ChatClientRequestSpec.class, RETURNS_SELF);
call = mock(ChatClient.CallResponseSpec.class);
stream = mock(ChatClient.StreamResponseSpec.class);
when(client.prompt()).thenReturn(spec);
when(spec.call()).thenReturn(call);
when(spec.stream()).thenReturn(stream);
Map<String, ChatClient> cache = (Map<String, ChatClient>) ReflectionTestUtils.getField(app, "chatClientCache");
cache.put("CHAT:none", client);
}
@ParameterizedTest
@ValueSource(booleans = {true, false})
void synchronousAnswerUsesOnlyThisBuildsDocuments(boolean rag) throws Exception {
ChatContext ctx = context(rag);
List<Document> documents = rag ? List.of(document()) : List.of();
when(pipeline.buildRequest(ctx)).thenReturn(request(ctx, documents, null));
when(call.chatResponse()).thenReturn(response("回答"));
ChatResult result = app.chatWithEvents(ctx);
assertEquals("回答", result.text());
assertEquals(SourceReference.fromDocuments(documents), result.sources());
assertTrue(result.suggestions().isEmpty());
JsonNode json = JSON.valueToTree(result);
if (rag) {
assertEquals("9223372036854775807", json.at("/sources/0/documentId").asText());
assertTrue(json.at("/sources/0/documentId").isTextual());
}
verify(pipeline).buildRequest(ctx);
verifyNoMoreInteractions(pipeline);
verify(call).chatResponse();
}
@ParameterizedTest
@ValueSource(booleans = {true, false})
void completedStreamCarriesExactlyOneMetadataChunkBeforeStop(boolean rag) throws Exception {
ChatContext ctx = context(rag);
List<Document> documents = rag ? List.of(document()) : List.of();
when(pipeline.buildRequest(ctx)).thenReturn(request(ctx, documents, null));
when(stream.chatResponse()).thenReturn(Flux.just(response("## "), response("回答\n")));
Flux<String> result = app.chatStreamOpenAi(ctx);
verifyNoInteractions(pipeline);
List<String> chunks = result.collectList().block(Duration.ofSeconds(5));
assertEnvelope(chunks, SourceReference.fromDocuments(documents), "## 回答\n");
verify(pipeline).buildRequest(ctx);
verifyNoMoreInteractions(pipeline);
verify(stream).chatResponse();
ArgumentCaptor<LlmCallTrace> trace = ArgumentCaptor.forClass(LlmCallTrace.class);
verify(traces, timeout(1000)).recordAsync(trace.capture());
assertEquals("## 回答\n", trace.getValue().getAiResponse());
assertEquals("COMPLETE", trace.getValue().getStatus());
}
@Test
void faqHasEmptySourcesWithoutModelInvocation() throws Exception {
ChatContext ctx = context(true);
when(pipeline.buildRequest(ctx)).thenReturn(request(ctx, List.of(), "标准答案"));
assertEnvelope(app.chatStreamOpenAi(ctx).collectList().block(Duration.ofSeconds(5)), List.of(), "标准答案");
verify(pipeline).buildRequest(ctx);
verifyNoMoreInteractions(pipeline);
verifyNoInteractions(call, stream);
}
@Test
void synchronousFaqHasEmptySources() {
ChatContext ctx = context(true);
when(pipeline.buildRequest(ctx)).thenReturn(request(ctx, List.of(), "标准答案"));
ChatResult result = app.chatWithEvents(ctx);
assertEquals("标准答案", result.text());
assertTrue(result.sources().isEmpty());
verify(pipeline).buildRequest(ctx);
verifyNoMoreInteractions(pipeline);
verifyNoInteractions(call, stream);
}
@Test
void circuitFallbackDoesNotBuildOrRetrieve() throws Exception {
SimpleCircuitBreaker breaker = (SimpleCircuitBreaker) ReflectionTestUtils.getField(app, "aiCircuitBreaker");
for (int i = 0; i < 3; i++) breaker.recordFailure(-1L);
ChatContext ctx = context(true);
assertTrue(app.chatWithEvents(ctx).sources().isEmpty());
assertEnvelope(app.chatStreamOpenAi(ctx).collectList().block(Duration.ofSeconds(5)),
List.of(), "AI 服务暂时不可用,请稍后重试。");
verifyNoInteractions(pipeline, call, stream);
}
@Test
void streamFailureDoesNotRepeatGenerationOrExposeUnusedSources() throws Exception {
ChatContext ctx = context(true);
when(pipeline.buildRequest(ctx)).thenReturn(request(ctx, List.of(document()), null));
when(stream.chatResponse()).thenReturn(Flux.concat(Flux.just(response("部分回答")),
Flux.error(new IllegalStateException("模型超时"))));
List<String> chunks = app.chatStreamOpenAi(ctx).collectList().block(Duration.ofSeconds(5));
assertEnvelope(chunks, List.of(), "部分回答抱歉,AI 服务调用失败:模型超时");
verify(pipeline).buildRequest(ctx);
verifyNoMoreInteractions(pipeline);
verify(stream).chatResponse();
verifyNoInteractions(call);
}
@Test
void cancellationPropagatesWithoutMetadataOrSecondRequest() throws Exception {
ChatContext ctx = context(true);
AtomicBoolean cancelled = new AtomicBoolean();
when(pipeline.buildRequest(ctx)).thenReturn(request(ctx, List.of(document()), null));
when(stream.chatResponse()).thenReturn(Flux.concat(Flux.just(response("首段")), Flux.<ChatResponse>never())
.doOnCancel(() -> cancelled.set(true)));
List<String> chunks = app.chatStreamOpenAi(ctx).take(2).collectList().block(Duration.ofSeconds(5));
assertEquals(2, chunks.size());
assertEquals("首段", JSON.readTree(chunks.get(1)).at("/choices/0/delta/content").asText());
assertFalse(chunks.stream().anyMatch(chunk -> chunk.contains("\"sources\"") || chunk.equals("[DONE]")));
verify(traces, timeout(1000)).recordAsync(argThat(trace -> "CANCEL".equals(trace.getStatus())));
assertTrue(cancelled.get());
verify(pipeline).buildRequest(ctx);
verifyNoMoreInteractions(pipeline);
verify(stream).chatResponse();
verifyNoInteractions(call);
}
@Test
void sourceSerializationPreservesNullableFieldsDistanceAndSnippetBoundaries() {
Document doc = document();
SourceReference source = SourceReference.fromDocuments(List.of(doc)).get(0);
assertEquals("9223372036854775807", source.documentId());
assertEquals(0.25, source.score());
assertEquals(2, source.chunkIndex());
assertEquals(160, source.snippet().length());
assertTrue(source.snippet().endsWith("…"));
SourceReference nullable = SourceReference.fromDocuments(List.of(new Document("short"))).get(0);
JsonNode json = JSON.copy().setSerializationInclusion(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL)
.valueToTree(nullable);
assertEquals(6, json.size());
assertTrue(json.get("documentId").isNull());
assertTrue(json.get("score").isNull());
SourceReference unicode = new SourceReference(null, null, null, null, null, "a".repeat(158) + "😀xx");
assertTrue(unicode.snippet().length() <= 160);
assertFalse(Character.isHighSurrogate(unicode.snippet().charAt(unicode.snippet().length() - 2)));
ArrayList<SourceReference> mutable = new ArrayList<>(List.of(source));
ChatResult result = new ChatResult("answer", null, null, mutable);
mutable.clear();
assertEquals(List.of(source), result.sources());
}
private static void assertEnvelope(List<String> chunks, List<SourceReference> sources, String text) throws Exception {
assertNotNull(chunks);
assertEquals("[DONE]", chunks.get(chunks.size() - 1));
JsonNode metadata = JSON.readTree(chunks.get(chunks.size() - 3));
JsonNode stop = JSON.readTree(chunks.get(chunks.size() - 2));
assertEquals("stop", stop.at("/choices/0/finish_reason").asText());
assertEquals(0, metadata.get("choices").size());
assertEquals(JSON.valueToTree(sources), metadata.get("sources"));
StringBuilder answer = new StringBuilder();
int metadataCount = 0;
for (String raw : chunks.subList(0, chunks.size() - 1)) {
JsonNode chunk = JSON.readTree(raw);
assertEquals("chat.completion.chunk", chunk.get("object").asText());
assertEquals(metadata.get("id"), chunk.get("id"));
assertEquals(metadata.get("model"), chunk.get("model"));
assertEquals(metadata.get("created"), chunk.get("created"));
if (chunk.has("sources")) metadataCount++;
answer.append(chunk.at("/choices/0/delta/content").asText(""));
}
assertEquals(1, metadataCount);
assertEquals(text, answer.toString());
}
private static Document document() {
return new Document("知识".repeat(100), Map.of("documentId", Long.MAX_VALUE, "title", "授权文档",
"sourceName", "manual.pdf", "chunkIndex", "2", "distance", 0.25, "score", 0.9));
}
private static ChatContext context(boolean rag) {
return new ChatContext("退货", "transport-chat", "CHAT", null, null, List.of(7L),
"NONE", rag, false, 11L, "售后", "account", null, null);
}
private static ChatRequest request(ChatContext ctx, List<Document> documents, String faq) {
return new ChatRequest(ctx, ctx.message(), "system", Optional.ofNullable(faq), "system",
documents.isEmpty() ? null : "资料", documents.size(), faq != null ? "FAQ" : "RAG",
"VECTOR", documents, null);
}
private static ChatResponse response(String text) {
return new ChatResponse(List.of(new Generation(new AssistantMessage(text))));
}
}

177
src/test/java/com/wok/supportbot/ChatModelFactoryTests.java

@ -0,0 +1,177 @@
package com.wok.supportbot;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpServer;
import com.wok.supportbot.config.ChatModelFactory;
import com.wok.supportbot.entity.AiModelConfig;
import com.wok.supportbot.service.AiModelConfigService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.openai.OpenAiChatOptions;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ChatModelFactoryTests {
private static final ObjectMapper JSON = new ObjectMapper();
private final BlockingQueue<JsonNode> requests = new LinkedBlockingQueue<>();
private HttpServer server;
@Mock
private AiModelConfigService configService;
@InjectMocks
private ChatModelFactory factory;
@BeforeEach
void startLocalEndpoint() throws IOException {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/", exchange -> {
try (exchange) {
JsonNode request = JSON.readTree(exchange.getRequestBody());
requests.add(request);
boolean streaming = request.path("stream").asBoolean();
String response = streaming
? "data: {\"id\":\"test\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"test\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"ok\"},\"finish_reason\":null}]}\n\n"
+ "data: {\"id\":\"test\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"test\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n"
+ "data: [DONE]\n\n"
: "{\"id\":\"test\",\"object\":\"chat.completion\",\"created\":1,\"model\":\"test\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}";
byte[] bytes = response.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", streaming ? "text/event-stream" : "application/json");
exchange.sendResponseHeaders(200, bytes.length);
exchange.getResponseBody().write(bytes);
}
});
server.start();
}
@AfterEach
void stopLocalEndpoint() {
if (server != null) server.stop(0);
}
@ParameterizedTest
@ValueSource(strings = {"doubao-seed-2-0-pro-260215", "doubao-seed-2-0-lite-260215",
"doubao-seed-2-0-mini-260215", "doubao-seed-2-0-code-preview-260215"})
void seedChatDefaultsToMinimalOnTheWire(String modelName) throws Exception {
AiModelConfig config = config("CHAT", "volcengine", modelName);
assertRequest(model(config), "minimal");
}
@ParameterizedTest
@ValueSource(strings = {"minimal", "low", "medium", "high"})
void explicitSeedEffortOverridesFastDefault(String effort) throws Exception {
AiModelConfig config = config("CHAT", "volcengine", "doubao-seed-2-0-pro-260215");
config.setExtraConfig(Map.of("reasoningEffort", effort, "topP", 0.8));
JsonNode request = assertRequest(model(config), effort);
assertEquals(0.8, request.path("top_p").asDouble());
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" "})
void clearedSeedEffortRestoresFastDefault(String cleared) throws Exception {
AiModelConfig config = config("CHAT", "volcengine", "doubao-seed-2-0-pro-260215");
config.setExtraConfig(cleared == null ? Map.of() : Map.of("reasoningEffort", cleared));
assertRequest(model(config), "minimal");
}
@ParameterizedTest
@CsvSource({"CHAT,openai,gpt-4o", "CHAT,deepseek,deepseek-chat", "CHAT,moonshot,moonshot-v1-8k",
"CHAT,volcengine,doubao-1-5-pro-32k", "CHAT,openai,doubao-seed-2-0-pro-260215",
"RAG_REWRITE,volcengine,doubao-seed-2-0-pro-260215", "RAG_REWRITE,volcengine,doubao-1-5-pro-32k"})
void otherModelsAndRewriteRetainProviderDefaults(String appType, String provider, String name) throws Exception {
assertRequest(model(config(appType, provider, name)), null);
}
@Test
void unsupportedRewriteModelDoesNotReceiveStaleReasoningSetting() throws Exception {
AiModelConfig config = config("RAG_REWRITE", "volcengine", "doubao-1-5-pro-32k");
config.setExtraConfig(Map.of("reasoningEffort", "medium"));
assertRequest(model(config), null);
}
@Test
void supportedRewriteModelHonorsExplicitEffort() throws Exception {
AiModelConfig config = config("RAG_REWRITE", "volcengine", "doubao-seed-2-0-pro-260215");
config.setExtraConfig(Map.of("reasoningEffort", "low"));
assertRequest(model(config), "low");
}
@Test
void cacheRefreshAppliesChangedAndClearedEffort() throws Exception {
AiModelConfig config = config("CHAT", "volcengine", "doubao-seed-2-0-pro-260215");
ChatModel initial = model(config);
assertSame(initial, factory.getChatModel("CHAT"));
assertRequest(initial, "minimal");
config.setExtraConfig(Map.of("reasoningEffort", "medium"));
factory.clearCache();
ChatModel changed = factory.getChatModel("CHAT");
assertNotSame(initial, changed);
assertRequest(changed, "medium");
config.setExtraConfig(Map.of());
factory.clearCache();
assertRequest(factory.getChatModel("CHAT"), "minimal");
}
@Test
void streamingUsesTheSameFastDefault() throws Exception {
ChatModel model = model(config("CHAT", "volcengine", "doubao-seed-2-0-pro-260215"));
var responses = model.stream(new Prompt("测试")).collectList().block(Duration.ofSeconds(5));
assertNotNull(responses);
assertFalse(responses.isEmpty());
JsonNode request = requests.poll(5, TimeUnit.SECONDS);
assertNotNull(request);
assertTrue(request.path("stream").asBoolean());
assertEquals("minimal", request.path("reasoning_effort").asText());
}
private AiModelConfig config(String appType, String provider, String modelName) {
return AiModelConfig.builder()
.id(1L).appType(appType).provider(provider).modelName(modelName)
.apiKey("local-test-only").baseUrl("http://127.0.0.1:" + server.getAddress().getPort())
.temperature(0.7).maxTokens(4096).build();
}
private ChatModel model(AiModelConfig config) {
when(configService.getActiveConfigWithFullKey(config.getAppType())).thenReturn(config);
return factory.getChatModel(config.getAppType());
}
private JsonNode assertRequest(ChatModel model, String expectedEffort) throws Exception {
assertEquals(expectedEffort, ((OpenAiChatOptions) model.getDefaultOptions()).getReasoningEffort());
assertEquals("ok", model.call(new Prompt("测试")).getResult().getOutput().getText());
JsonNode request = requests.poll(5, TimeUnit.SECONDS);
assertNotNull(request);
if (expectedEffort == null) assertFalse(request.has("reasoning_effort"));
else assertEquals(expectedEffort, request.path("reasoning_effort").asText());
assertEquals(4096, request.path("max_tokens").asInt());
assertEquals(0.7, request.path("temperature").asDouble());
assertFalse(request.has("thinking"));
return request;
}
}

300
src/test/java/com/wok/supportbot/ChatPipelineTests.java

@ -0,0 +1,300 @@
package com.wok.supportbot;
import com.wok.supportbot.app.ChatContext;
import com.wok.supportbot.app.ChatPipeline;
import com.wok.supportbot.app.ChatRequest;
import com.wok.supportbot.chatmemory.DatabaseChatMemory;
import com.wok.supportbot.config.RagPromptConfig;
import com.wok.supportbot.entity.KnowledgeFaq;
import com.wok.supportbot.rag.CategoryFilter;
import com.wok.supportbot.rag.RagPipeline;
import com.wok.supportbot.rag.preretrieval.CompressionQueryRewriter;
import com.wok.supportbot.rag.preretrieval.MultiQueryExpanderRewriter;
import com.wok.supportbot.rag.preretrieval.RewriteQueryRewriter;
import com.wok.supportbot.rag.preretrieval.TranslationQueryRewriter;
import com.wok.supportbot.service.FaqMatchEngine;
import com.wok.supportbot.service.FaqMatchEngine.FaqMatchResult;
import com.wok.supportbot.service.RagHitLogService;
import com.wok.supportbot.service.SystemConfigService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class ChatPipelineTests {
@Mock private VectorStore vectorStore;
@Mock private FaqMatchEngine faqMatchEngine;
@Mock private RagHitLogService ragHitLogService;
@Mock private SystemConfigService systemConfigService;
@Mock private DatabaseChatMemory chatMemory;
@Mock private RagPromptConfig ragPromptConfig;
@Mock private RewriteQueryRewriter rewrite;
@Mock private TranslationQueryRewriter translation;
@Mock private CompressionQueryRewriter compression;
@Mock private MultiQueryExpanderRewriter multiQuery;
private final CategoryFilter categoryFilter = new CategoryFilter();
private ChatPipeline pipeline;
@BeforeEach
void setUp() {
RagPipeline rag = new RagPipeline(chatMemory);
ReflectionTestUtils.setField(rag, "pgVectorVectorStore", vectorStore);
ReflectionTestUtils.setField(rag, "faqMatchEngine", faqMatchEngine);
ReflectionTestUtils.setField(rag, "ragHitLogService", ragHitLogService);
ReflectionTestUtils.setField(rag, "ragPromptConfig", ragPromptConfig);
ReflectionTestUtils.setField(rag, "categoryFilter", categoryFilter);
ReflectionTestUtils.setField(rag, "rewriteQueryRewriter", rewrite);
ReflectionTestUtils.setField(rag, "translationQueryRewriter", translation);
ReflectionTestUtils.setField(rag, "compressionQueryRewriter", compression);
ReflectionTestUtils.setField(rag, "multiQueryExpanderRewriter", multiQuery);
pipeline = new ChatPipeline();
ReflectionTestUtils.setField(pipeline, "ragPipeline", rag);
ReflectionTestUtils.setField(pipeline, "systemConfigService", systemConfigService);
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {"NONE", " "})
void defaultRetrievalUsesOriginalQuestionWithoutLlmPreprocessing(String strategy) {
ChatContext ctx = context("退货流程是什么", true).withRewriteStrategy(strategy);
when(vectorStore.similaritySearch(any(SearchRequest.class))).thenReturn(List.of());
ChatRequest request = pipeline.buildRequest(ctx);
assertEquals("NONE", ctx.rewriteStrategy());
assertEquals("RAG", request.intent());
assertSame(ctx, request.ctx());
assertEquals(ctx.message(), request.finalMessage());
assertEquals("faq-fast-path", request.ctx().chatId());
assertTrue(request.finalSystemPrompt().contains("售后客服"));
verifySearch(ctx, ctx.message());
verify(faqMatchEngine).match(ctx.message(), ctx.categoryIds());
verifyNoMoreInteractions(faqMatchEngine);
verifyNoPreprocessing();
verify(ragHitLogService).recordMiss(ctx.chatId(), ctx.message(), "VECTOR");
verifyNoMoreInteractions(ragHitLogService);
}
@Test
void hitDocumentsAreRetainedAndLoggedOnlyOnce() {
ChatContext ctx = context("退货流程是什么", true);
Document doc = new Document("退货说明", Map.of("documentId", "123", "title", "退货政策", "score", 0.9));
when(vectorStore.similaritySearch(any(SearchRequest.class))).thenReturn(List.of(doc));
when(ragPromptConfig.getAnswerRules()).thenReturn("使用知识库回答");
ChatRequest request = pipeline.buildRequest(ctx);
assertEquals(List.of(doc), request.hitDocuments());
assertEquals("退货说明", request.ragContextText());
assertEquals(1, request.hitCount());
assertTrue(request.finalSystemPrompt().contains("使用知识库回答"));
assertTrue(request.finalSystemPrompt().endsWith("退货说明"));
verify(ragHitLogService).recordHit(ctx.chatId(), ctx.message(), 123L, "退货政策", "0.9", "VECTOR");
verifyNoMoreInteractions(ragHitLogService);
verifyNoPreprocessing();
}
@ParameterizedTest
@ValueSource(strings = {"EXACT", "KEYWORD", "SEMANTIC"})
void fullFaqMatchPrecedesEvenLocalGreeting(String matchType) {
ChatContext ctx = context("你好", true);
FaqMatchResult match = faqMatch("标准答案", matchType);
when(faqMatchEngine.match(ctx.message(), ctx.categoryIds())).thenReturn(Optional.of(match));
when(systemConfigService.getValueByKey("ai_system_prompt")).thenReturn("全局提示词");
ChatRequest request = pipeline.buildRequest(ctx);
assertEquals("FAQ", request.intent());
assertEquals(Optional.of("标准答案"), request.faqAnswer());
assertSame(match, request.faqMatchResult());
assertSame(ctx, request.ctx());
assertEquals("全局提示词\n\n【当前角色设定】\n售后客服", request.finalSystemPrompt());
verify(faqMatchEngine).match(ctx.message(), ctx.categoryIds());
verifyNoMoreInteractions(faqMatchEngine);
verifyNoInteractions(vectorStore, ragHitLogService);
verifyNoPreprocessing();
}
@ParameterizedTest
@ValueSource(strings = {"你好", " HI! ", "谢谢", "再见"})
void localGreetingMissSkipsRetrieval(String message) {
ChatContext ctx = context(message, true);
ChatRequest request = pipeline.buildRequest(ctx);
assertEquals("CHITCHAT", request.intent());
assertEquals(message, request.finalMessage());
verify(faqMatchEngine).match(message, ctx.categoryIds());
verifyNoMoreInteractions(faqMatchEngine);
verifyNoInteractions(vectorStore, ragHitLogService);
verifyNoPreprocessing();
}
@Test
void ordinaryChatDoesNotMatchFaqOrRetrieve() {
ChatContext ctx = context("退货流程是什么", false);
ChatRequest request = pipeline.buildRequest(ctx);
assertEquals("CHAT", request.intent());
assertSame(ctx, request.ctx());
assertEquals(ctx.message(), request.finalMessage());
assertFalse(request.faqHit());
verifyNoInteractions(faqMatchEngine, vectorStore, ragHitLogService);
verifyNoPreprocessing();
}
@Test
void exceptionalFaqMissRetriesFullMatchAndCanReturnStandardAnswer() {
ChatContext ctx = context("退货流程是什么", true);
FaqMatchResult match = faqMatch("恢复后的标准答案", "SEMANTIC");
when(faqMatchEngine.match(ctx.message(), ctx.categoryIds()))
.thenThrow(new IllegalStateException("暂时不可用")).thenReturn(Optional.of(match));
ChatRequest request = pipeline.buildRequest(ctx);
assertEquals("FAQ", request.intent());
assertEquals(Optional.of("恢复后的标准答案"), request.faqAnswer());
verify(faqMatchEngine, times(2)).match(ctx.message(), ctx.categoryIds());
verifyNoInteractions(vectorStore, ragHitLogService);
verifyNoPreprocessing();
}
@Test
void repeatedFaqFailureStillRetrievesAndLogsOneMiss() {
ChatContext ctx = context("退货流程是什么", true);
when(faqMatchEngine.match(ctx.message(), ctx.categoryIds())).thenThrow(new IllegalStateException("不可用"));
assertEquals("RAG", pipeline.buildRequest(ctx).intent());
verify(faqMatchEngine, times(2)).match(ctx.message(), ctx.categoryIds());
verifySearch(ctx, ctx.message());
verify(ragHitLogService).recordMiss(ctx.chatId(), ctx.message(), "VECTOR");
verifyNoMoreInteractions(ragHitLogService);
verifyNoPreprocessing();
}
@ParameterizedTest
@ValueSource(strings = {"REWRITE", "TRANSLATION", "COMPRESSION", "MULTI_QUERY"})
void explicitRewritePreservesOriginalAnswerMessageAndCategoryScope(String strategy) {
ChatContext ctx = context("它怎么退", true).withRewriteStrategy(strategy);
List<Message> history = List.of(new UserMessage("我买了一台打印机"));
switch (strategy) {
case "REWRITE" -> when(rewrite.doQueryRewrite(ctx.message())).thenReturn("打印机退货流程");
case "TRANSLATION" -> when(translation.doQueryRewrite(ctx.message())).thenReturn("打印机退货流程");
case "COMPRESSION" -> {
when(chatMemory.get(ctx.chatId(), 10)).thenReturn(history);
when(compression.doQueryRewrite(ctx.message(), history)).thenReturn("打印机退货流程");
}
case "MULTI_QUERY" -> when(multiQuery.doQueryRewrite(ctx.message())).thenReturn(List.of("打印机退货流程"));
}
ChatRequest request = pipeline.buildRequest(ctx);
assertEquals(strategy, ctx.rewriteStrategy());
assertEquals(ctx.message(), request.finalMessage());
assertSame(ctx, request.ctx());
verifySearch(ctx, "打印机退货流程");
verify(faqMatchEngine).match(ctx.message(), ctx.categoryIds());
if ("COMPRESSION".equals(strategy)) {
verify(chatMemory).get(ctx.chatId(), 10);
verify(compression).doQueryRewrite(ctx.message(), history);
} else {
verifyNoInteractions(chatMemory);
}
}
@Test
void explicitRewriteFailureFallsBackToOriginalQuestion() {
ChatContext ctx = context("退货流程是什么", true).withRewriteStrategy("REWRITE");
when(rewrite.doQueryRewrite(ctx.message())).thenThrow(new IllegalStateException("重写不可用"));
assertEquals("RAG", pipeline.buildRequest(ctx).intent());
verifySearch(ctx, ctx.message());
}
@Test
void independentSourcesRetrievalDoesNotClassifyOrMatchFaq() {
ChatContext ctx = context("退货流程是什么", true);
assertTrue(pipeline.retrieveSources(ctx).isEmpty());
verifySearch(ctx, ctx.message());
verifyNoInteractions(faqMatchEngine);
verifyNoPreprocessing();
}
@Test
void multipleQueriesKeepCategoryScopeAndLogMergedDocumentOnce() {
ChatContext ctx = context("退货流程是什么", true).withRewriteStrategy("MULTI_QUERY");
Document shared = new Document("退货说明", Map.of("documentId", "123", "title", "退货政策"));
when(multiQuery.doQueryRewrite(ctx.message())).thenReturn(List.of("退货步骤", "退货条件"));
when(vectorStore.similaritySearch(any(SearchRequest.class))).thenReturn(List.of(shared));
when(ragPromptConfig.getAnswerRules()).thenReturn("使用知识库回答");
ChatRequest request = pipeline.buildRequest(ctx);
assertEquals(List.of(shared), request.hitDocuments());
assertEquals(ctx.message(), request.finalMessage());
ArgumentCaptor<SearchRequest> searches = ArgumentCaptor.forClass(SearchRequest.class);
verify(vectorStore, times(2)).similaritySearch(searches.capture());
assertEquals(List.of("退货步骤", "退货条件"),
searches.getAllValues().stream().map(SearchRequest::getQuery).toList());
for (SearchRequest search : searches.getAllValues()) {
assertEquals(categoryFilter.buildExpression(ctx.categoryIds()), search.getFilterExpression());
}
verify(ragHitLogService).recordHit(ctx.chatId(), ctx.message(), 123L, "退货政策", "", "VECTOR");
verifyNoMoreInteractions(ragHitLogService);
verifyNoInteractions(rewrite, translation, compression, chatMemory);
}
@ParameterizedTest
@NullAndEmptySource
void faqWithoutAnswerPreservesOptionalSemantics(String answer) {
ChatContext ctx = context("退货流程是什么", true);
FaqMatchResult match = faqMatch(answer, "EXACT");
when(faqMatchEngine.match(ctx.message(), ctx.categoryIds())).thenReturn(Optional.of(match));
ChatRequest request = pipeline.buildRequest(ctx);
assertEquals("FAQ", request.intent());
assertEquals(Optional.ofNullable(answer), request.faqAnswer());
assertEquals(answer != null, request.faqHit());
verifyNoInteractions(vectorStore, ragHitLogService);
verifyNoPreprocessing();
}
private void verifySearch(ChatContext ctx, String query) {
ArgumentCaptor<SearchRequest> search = ArgumentCaptor.forClass(SearchRequest.class);
verify(vectorStore).similaritySearch(search.capture());
assertEquals(query, search.getValue().getQuery());
assertEquals(4, search.getValue().getTopK());
assertEquals(categoryFilter.buildExpression(ctx.categoryIds()), search.getValue().getFilterExpression());
}
private void verifyNoPreprocessing() {
verifyNoInteractions(rewrite, translation, compression, multiQuery, chatMemory);
}
private ChatContext context(String message, boolean enableRag) {
return ChatContext.of(message, "faq-fast-path")
.withSystemPrompt("售后客服")
.withCategoryIds(List.of(101L, 202L))
.withEnableRag(enableRag);
}
private FaqMatchResult faqMatch(String answer, String matchType) {
KnowledgeFaq faq = new KnowledgeFaq();
faq.setAnswer(answer);
return new FaqMatchResult(faq, matchType, 0.95);
}
}

166
src/test/java/com/wok/supportbot/ChatResultEndpointTests.java

@ -0,0 +1,166 @@
package com.wok.supportbot;
import com.wok.supportbot.app.AssistantApp;
import com.wok.supportbot.app.ChatContext;
import com.wok.supportbot.app.ChatResult;
import com.wok.supportbot.app.SourceReference;
import com.wok.supportbot.config.RoleAccessConfig;
import com.wok.supportbot.controller.AiController;
import com.wok.supportbot.controller.OpenApiController;
import com.wok.supportbot.entity.ApiKey;
import com.wok.supportbot.rag.CategoryFilter;
import com.wok.supportbot.security.JwtTokenProvider;
import com.wok.supportbot.security.SdkAuthFilter;
import com.wok.supportbot.security.SdkJwtTokenProvider;
import com.wok.supportbot.service.ConversationService;
import com.wok.supportbot.service.CustomerServiceRoleService;
import com.wok.supportbot.service.CustomerServiceRoleService.RoleScope;
import io.jsonwebtoken.Claims;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
class ChatResultEndpointTests {
private AssistantApp assistant;
private CustomerServiceRoleService roles;
private ConversationService conversations;
private RoleAccessConfig access;
private AiController controller;
private MockMvc mvc;
private SdkJwtTokenProvider tokens;
@BeforeEach
void setUp() {
assistant = mock(AssistantApp.class);
roles = mock(CustomerServiceRoleService.class);
conversations = mock(ConversationService.class);
access = new RoleAccessConfig();
controller = new AiController();
ReflectionTestUtils.setField(controller, "assistantApp", assistant);
ReflectionTestUtils.setField(controller, "customerServiceRoleService", roles);
ReflectionTestUtils.setField(controller, "conversationService", conversations);
ReflectionTestUtils.setField(controller, "roleAccessConfig", access);
ReflectionTestUtils.setField(controller, "categoryFilter", mock(CategoryFilter.class));
tokens = mock(SdkJwtTokenProvider.class);
SdkAuthFilter filter = new SdkAuthFilter(tokens, mock(JwtTokenProvider.class), mock(JdbcTemplate.class));
mvc = MockMvcBuilders.standaloneSetup(controller).addFilters(filter).build();
}
@Test
void resultRejectsMissingTokenAndUnauthorizedRoleBeforeGeneration() throws Exception {
mvc.perform(get("/ai/chat/result").servletPath("/ai/chat/result").param("message", "问题"))
.andExpect(status().isUnauthorized());
authorize();
mvc.perform(get("/ai/chat/result").servletPath("/ai/chat/result")
.header("Authorization", "Bearer valid").param("message", "问题").param("roleId", "99"))
.andExpect(status().isForbidden());
verifyNoInteractions(assistant, roles, conversations);
}
@Test
void resultKeepsRoleScopeAccountBindingAndDecodedImages() throws Exception {
authorize();
when(roles.getRoleScope(11L)).thenReturn(new RoleScope(true, "售后", "授权人设", List.of(7L), List.of("tool")));
SourceReference source = new SourceReference("9223372036854775807", "授权文档", null, null, null, "片段");
when(assistant.chatWithEvents(any())).thenReturn(new ChatResult("回答", List.of(), List.of(), List.of(source)));
mvc.perform(get("/ai/chat/result").servletPath("/ai/chat/result")
.header("Authorization", "Bearer valid").param("message", "问题").param("chatId", "chat")
.param("roleId", "11").param("accountId", " account ").param("systemPrompt", "越权人设")
.param("enableRag", "true").param("categoryIds", "99").param("imageUrls", "https%3A%2F%2Fexample.org%2Fa.png"))
.andExpect(status().isOk()).andExpect(content().contentTypeCompatibleWith("application/json"))
.andExpect(jsonPath("$.text").value("回答"))
.andExpect(jsonPath("$.data").doesNotExist())
.andExpect(jsonPath("$.sources[0].documentId").value("9223372036854775807"))
.andExpect(jsonPath("$.mcpEvents").isArray()).andExpect(jsonPath("$.suggestions").isArray());
ArgumentCaptor<ChatContext> context = ArgumentCaptor.forClass(ChatContext.class);
verify(assistant).chatWithEvents(context.capture());
ChatContext ctx = context.getValue();
assertEquals(List.of(7L), ctx.categoryIds());
assertEquals("授权人设", ctx.systemPrompt());
assertEquals(List.of("tool"), ctx.allowedMcpTools());
assertEquals(List.of("https://example.org/a.png"), ctx.imageUrls());
assertEquals("NONE", ctx.rewriteStrategy());
assertTrue(ctx.enableRag());
verify(conversations).bindConversation("chat", "account", 11L);
verifyNoMoreInteractions(assistant);
}
@Test
void strictIsolationAndExplicitRewriteApplyToBothSyncEndpoints() {
access.setStrictIsolation(true);
when(roles.getRoleScope(11L)).thenReturn(new RoleScope(true, "售后", "人设", List.of(), List.of()));
when(assistant.chatWithEvents(any())).thenReturn(new ChatResult("回答", List.of(), List.of(), List.of()));
assertEquals("回答", controller.chatSync("问题", "chat", 11L, "account", null, true,
"REWRITE", null, "99", null));
ArgumentCaptor<ChatContext> context = ArgumentCaptor.forClass(ChatContext.class);
verify(assistant).chatWithEvents(context.capture());
assertFalse(context.getValue().enableRag());
assertTrue(context.getValue().categoryIds().isEmpty());
assertEquals("REWRITE", context.getValue().rewriteStrategy());
verifyNoMoreInteractions(assistant);
}
@Test
void openApiRetainsEnvelopeAndAddsSameAnswerSourcesWithNoneDefault() {
OpenApiController open = new OpenApiController();
ReflectionTestUtils.setField(open, "assistantApp", assistant);
ReflectionTestUtils.setField(open, "customerServiceRoleService", roles);
ReflectionTestUtils.setField(open, "categoryFilter", mock(CategoryFilter.class));
ReflectionTestUtils.setField(open, "roleAccessConfig", access);
when(roles.getRoleScope(11L)).thenReturn(new RoleScope(true, "售后", "人设", List.of(7L), List.of()));
SourceReference source = new SourceReference("9223372036854775807", "授权文档", null, null, null, "片段");
when(assistant.chatWithEvents(any())).thenReturn(new ChatResult("回答", List.of(), List.of(), List.of(source)));
ApiKey key = new ApiKey();
key.setId(3L);
key.setRoleIds("[11]");
MockHttpServletRequest request = new MockHttpServletRequest();
request.setAttribute("apiKey", key);
var result = open.chat("问题", "11", "chat", "99", null, true, request);
assertEquals(200, result.getStatusCode().value());
assertEquals(true, result.getBody().get("success"));
var data = (java.util.Map<?, ?>) result.getBody().get("data");
assertEquals("回答", data.get("reply"));
assertEquals(List.of(source), data.get("sources"));
ArgumentCaptor<ChatContext> context = ArgumentCaptor.forClass(ChatContext.class);
verify(assistant).chatWithEvents(context.capture());
assertEquals("NONE", context.getValue().rewriteStrategy());
assertEquals(List.of(7L), context.getValue().categoryIds());
verifyNoMoreInteractions(assistant);
}
@Test
void explicitSourcesApiUsesTheSameCitationSerializer() {
ReflectionTestUtils.setField(controller, "chatPipeline", mock(com.wok.supportbot.app.ChatPipeline.class));
when(roles.getRoleScope(11L)).thenReturn(new RoleScope(true, "售后", "人设", List.of(7L), List.of()));
var document = new org.springframework.ai.document.Document("片段".repeat(100),
java.util.Map.of("documentId", Long.MAX_VALUE, "distance", 0.2));
when(assistant.retrieveSources(any())).thenReturn(List.of(document));
var result = controller.chatSources("问题", "chat", null, 11L, "account", null, "99");
assertEquals(SourceReference.fromDocuments(List.of(document)), result.get("data"));
ArgumentCaptor<ChatContext> context = ArgumentCaptor.forClass(ChatContext.class);
verify(assistant).retrieveSources(context.capture());
assertEquals(List.of(7L), context.getValue().categoryIds());
assertEquals("NONE", context.getValue().rewriteStrategy());
verifyNoMoreInteractions(assistant);
}
private void authorize() {
Claims claims = mock(Claims.class);
when(tokens.parseToken("valid")).thenReturn(claims);
when(tokens.getAllowedRoleIds(claims)).thenReturn(Set.of(11L));
}
}
Loading…
Cancel
Save