Compare commits

...

10 Commits

  1. 44
      CLAUDE.md
  2. 58
      SDK-INTEGRATION.md
  3. 32
      client/README.md
  4. 288
      client/src/api.ts
  5. 209
      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. 140
      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. 273
      frontend/src/utils/sse.ts
  19. 127
      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. 115
      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 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` 自动创建,无需手动建表。
**测试说明**: 所有测试均为集成测试(`@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)
@ -46,25 +46,33 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
当前使用 `DatabaseChatMemory`(PostgreSQL 持久化),无文件型 ChatMemory(早期的 `FileBasedChatMemory` 已删除,Kryo 依赖一并移除)。
### 统一对话管道(重构后)
对话管道由 `ChatPipeline`(编排层)+ `RagPipeline`(RAG 检索层)+ `AssistantApp`(执行层)组成:
对话管道由 `ChatPipeline`(编排层)+ `RagPipeline`(RAG 检索层)+ `AssistantApp`(执行层)组成(更新:2026-09-14,默认零 LLM 预处理):
```
用户请求
→ 鉴权/角色解析(Controller)
→ AssistantApp 熔断检查(熔断直接降级,不执行 FAQ / 检索)
→ 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`)
- **AssistantApp 入口**: `chat(ChatContext)` / `chatStream(ChatContext)` / `retrieveSources(ChatContext)`,旧方法(`doChat*`、`doChatWithRag*`)已移除
- **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` 表。
@ -113,6 +121,7 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
- **启动校验**: `ModelConfigLoader` 在应用就绪后检查 DB 中每种 App 类型是否有活跃配置,并对 DashScope 提供商比较 DB 与 yml 的 API Key 一致性
- **API Key 脱敏**: 前端展示时只显示前 4 位 + `****` + 后 4 位
- **ChatModel 运行时切换**: 通过 `ChatModelFactory` 按 DB 活跃配置动态创建/缓存 ChatModel(包括 DashScope,不再复用 yml 自动配置的 Bean),配置变更时立即生效(无需重启)。**OpenAI 兼容路径使用自定义 `completionsPath`**(与 EmbeddingModelFactory 的 embeddingsPath 对应),baseUrl 已含版本段的厂商(moonshot `/v1`、volcengine `/api/v3`、zhipu `/api/paas/v4`)设为 `/chat/completions`,其余使用默认 `/v1/chat/completions`
- **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`,向量化模型配置变更后无需重启即可生效
- **多提供商支持**: DashScope(通义千问)+ OpenAI 兼容提供商(DeepSeek / 豆包 / Kimi / 智谱 / OpenAI),ChatModel 和 EmbeddingModel 均通过对应 API 手动构建
- **缓存刷新**: 模型配置增删改激活时 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、纯对话路径)发生变更
- `RagPipeline.retrieve()` 的检索流程(查询重写策略、检索方式、资料拼装)发生变更
- `AssistantApp` 的 Advisor 链成员或顺序发生变更(如新增/移除 Advisor)
- 新增管道阶段组件(如 `IntentRouter`、`SuggestionGenerator`、`SimpleCircuitBreaker` 等)或移除现有组件
- 新增管道阶段组件(如 `SuggestionGenerator`、`SimpleCircuitBreaker` 等)或移除现有组件
- 组件间调用关系调整(如原来 A→B 改为 A→C→B)
**图表元数据**: `PipelineFlow.vue` 中 DSL 首行有 `%%graph-meta` 注释标记最后更新时间,修改图表时必须更新该日期。
@ -341,8 +350,8 @@ catch (e) { toast('操作失败', 'error') }
- **Chat SDK**: `handleFeedback()` 已连接后端 API,同时保留 localStorage 作为乐观 UI 缓存
- **会话导出**: `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)
- **FAQ 向量化**: 复用现有 `DynamicEmbeddingModel`,向量存入 `faq_embedding` 表,新增/修改 FAQ 时异步计算
- **similar_questions 字段**: 使用 String 类型存储 JSON 数组字符串(PostgresJsonTypeHandler 期望对象格式,故不用 typeHandler)
@ -363,12 +372,11 @@ catch (e) { toast('操作失败', 'error') }
- **异步写入**:`LlmCallTraceService.recordAsync` 走 `@Async("traceExecutor")`(有界线程池见 `AsyncExecutorConfig`,替代默认 `SimpleAsyncTaskExecutor` 线程爆炸隐患;`RagHitLogService` 已一并切换)。
- **查询面板**:前端「系统设置 → 提示词追踪」(`PromptTracePanel.vue`,admin),支持筛选/搜索/详情(来源标注)/聚合统计/并排对比/跳转编辑(deep-link `?roleId=`、`?key=ai_system_prompt`)。
- **安全声明**:`system_prompt`/`user_message`/`ai_response` 属敏感数据(提示词为商业秘密),当前**明文存储**、仅 admin 可查,落库前做敏感词脱敏(`ContentSafetyService.mask`)+ AI 回复截断;保留期限由 `system_config.llm_trace_retention_days` 控制(默认 30 天,`@Scheduled` 每日 2 点自动清理)。字段级加密留二期。
- **预留**:`account_id`/`api_key_id` 列预留租户隔离与个人信息删除权,本期不写入;旁路 LLM 调用(意图识别/查询重写/建议生成)追踪留二期(表加 `trace_type`/`source` 字段)。
- **预留**:`account_id`/`api_key_id` 列预留租户隔离与个人信息删除权,本期不写入;旁路 LLM 调用(显式查询重写/建议生成)追踪留二期(表加 `trace_type`/`source` 字段)。
## 已知 TODO
- `DocumentService.updateDocumentMetadata()`: Spring AI 无直接更新 vector_store metadata 的 API,向量元数据同步留后续
- `DocumentService.searchDocuments()`: **分类过滤已实现**(`FilterExpressionBuilder` 组合 `enabled` + `categoryId` 过滤表达式,向量检索异常时回退到本地 metadata 过滤)—— 原「Spring AI filter 支持有限」的 TODO 已不成立
- `CompressionQueryRewriter`: 当前传入空历史列表
- MyBatis Plus `mybatis-plus-spring-boot3-starter` 不含 `PaginationInnerInterceptor`,分页通过 SQL `LIMIT/OFFSET` 手动实现
- `PgVectorStoreConfig.dimensions(1024)` 硬编码了向量维度,切换非 1024 维的 Embedding 模型时需修改并重建 vector_store 表 → **已修复:维度由 `knowledge.vector.dimension` 配置,启动时自动检测不匹配并告警**

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/{id}/messages` | GET | 会话消息 |
| `/ai/sdk/conversation/{id}` | DELETE | 删除会话 |
@ -286,7 +287,48 @@ POST /open-api/auth/token
| `/category/tree` | GET | 知识库分类树 |
| `/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 对话接口(第三方系统直接调用)
@ -301,10 +343,12 @@ POST /open-api/auth/token
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `categoryIds` | Query | 否 | 知识库分类 ID,逗号分隔(非角色场景用) |
| `rewriteStrategy` | Query | 否 | RAG 查询重写策略:REWRITE / TRANSLATION / COMPRESSION / MULTI_QUERY(默认) |
| `rewriteStrategy` | Query | 否 | NONE(默认,原问题直接检索)/ REWRITE / TRANSLATION / COMPRESSION / MULTI_QUERY |
| `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)
@ -342,7 +386,7 @@ POST /open-api/auth/token
| `theme` | String | 'light' | 界面主题:light / dark |
| `streaming` | Boolean | true | 流式回复 |
| `enableRag` | Boolean | true | RAG 知识库检索 |
| `rewriteStrategy` | String | 'REWRITE' | 查询重写策略 |
| `rewriteStrategy` | String | 'NONE' | 默认不调用 LLM 预处理;可显式选择查询重写策略 |
| `quickReplies` | String[] | [] | 快捷问题列表 |
| `showClear` | Boolean | true | 显示清空按钮 |
| `showAdminPanel` | Boolean | false | 显示管理入口 |

32
client/README.md

@ -78,7 +78,8 @@ SDK 产物位于 `client/dist/` 目录:
| `requestDomain` | `string` | ✅ | — | P0 | 后端 API 域名 |
| `userId` | `string` | ❌ | — | P0 | 宿主用户标识 → 后端 `accountId` |
| `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 | 默认知识库分类 |
| `showCategorySwitch` | `boolean` | ❌ | `false` | P1 | 是否显示知识库下拉切换 |
| `title` | `string` | ❌ | `"AI 智能助手"` | P0 | 弹窗标题 |
@ -92,7 +93,7 @@ SDK 产物位于 `client/dist/` 目录:
| `theme` | `string` | ❌ | `"light"` | P2 | 主题模式:`"light"` / `"dark"` |
| `showTeaser` | `boolean` | ❌ | `true` | P1 | 首访提示气泡(延迟 1.5s 弹出) |
| `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` |
| `debug` | `boolean` | ❌ | `true` | P0 | 是否输出调试日志 |
@ -118,7 +119,7 @@ SDK 产物位于 `client/dist/` 目录:
默认开启(`streaming: true`),AI 回复逐字输出,支持:
- 流式追加到气泡,实时滚动到底部
- 流中断兜底:保留已接收内容 + 灰色提示
- 无流内容时自动降级为同步请求
- 收到完整 `[DONE]` 行立即结束并释放 reader,不等待服务器关闭连接;空流不再发起同步重试
### 4.2 Markdown 渲染
@ -187,7 +188,7 @@ ChatbotSDK.init({
- 默认折叠,只显示标题行,点击展开/折叠
- 显示文档名称、摘要、来源文件、分块编号、相关度
- 来源数据从 `/ai/chat/sources` 接口获取
- 来源来自本次答案实际命中的文档,与正文使用同一个请求;SDK 不再调用 `/ai/chat/sources` 做二次检索
---
@ -247,18 +248,37 @@ SDK 全流程结构化日志,带 `[ChatbotSDK]` 前缀:
### P0 — 基础对话
```
GET /ai/chat # 同步对话
GET /ai/chat/result # 同步 JSON 对话(streaming=false)
GET /ai/chat/stream # SSE 流式对话
```
### P1 — 知识库联动
```
GET /ai/chat/stream # RAG 增强流式对话(enableRag=true)
GET /ai/chat/sources # RAG 引用来源
GET /category/tree # 分类树(下拉框数据源)
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 — 会话管理
```
GET /conversation/list # 会话列表

288
client/src/api.ts

@ -6,7 +6,7 @@
* userId → accountId(客户账号 ID)
* 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 { 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();
params.set('message', message);
params.set('chatId', currentConfig!.chatId);
@ -113,31 +91,15 @@ function buildChatSSEUrl(message: string, categoryId?: number, useRag?: boolean,
setIfPresent(params, 'accountId', currentConfig!.userId);
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);
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 基础封装 ====================
@ -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);
try {
const response = await safeFetch(url);
const response = await safeFetch(url, {}, REQUEST_TIMEOUT, signal);
if (!response.ok) {
const errorMsg = getHttpErrorMessage(response.status);
logger.lifecycleError(getActiveIntegrateId(), String(response.status), errorMsg);
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) {
if (err instanceof CskError) throw err;
logger.lifecycleError(getActiveIntegrateId(), 'unknown', String(err));
@ -270,194 +232,131 @@ export async function chatSSERequest(
categoryId?: number,
useRag?: boolean,
imageUrls?: string[],
signal?: AbortSignal
signal?: AbortSignal,
onSources?: (sources: RagSource[]) => 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 = '';
/**
* 处理单个 SSE 原始块:优先按 OpenAI Chat Completions JSON 解析,
* 解析失败则回退为纯文本整体追加。
* @param raw 累积的原始事件内容(eventLines 拼接结果)
*/
let completed = false;
const finish = () => {
if (completed) return;
completed = true;
onDone();
};
const emitRawChunk = (raw: string): void => {
// 尝试按 OpenAI Chat Completions JSON 解析,解析失败则视为纯文本
let obj: unknown = null;
let obj: unknown;
try {
obj = JSON.parse(raw);
} catch {
obj = null;
}
if (obj && typeof obj === 'object') {
const record = obj as Record<string, unknown>;
// OpenAI 错误 chunk:{ error: { message, type } }
if (record.error) {
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.sources)) onSources?.(record.sources as RagSource[]);
const first = (record.choices as Array<{ delta?: { content?: unknown } }>)[0];
const content = first?.delta?.content;
if (typeof content === 'string' && content.length > 0) {
totalText += content;
onChunk(content);
}
// role / finish_reason 等空 chunk 直接跳过
return;
}
}
// 纯文本回退:整体作为文本追加
totalText += raw;
onChunk(raw);
};
logger.lifecycleSend(getActiveIntegrateId(), message.length);
try {
const response = await safeFetch(url, {}, REQUEST_TIMEOUT * 2, signal);
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();
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 eventLines: string[] = [];
/** 当前 SSE 事件类型(event:xxx 行声明的类型),空=默认 message 事件 */
let currentEventType = '';
try {
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 (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') {
let stopped = false;
const flushEvent = () => {
if (eventLines.length && 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 = [];
};
const consumeLine = (raw: string) => {
const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
if (line === '') {
flushEvent();
return;
}
currentEventType = '';
break;
if (line.startsWith(':')) return;
if (line.startsWith('event:')) {
currentEventType = line.slice(6).trim();
return;
}
// SSE 协议字段:event / id / retry
if (trimmed.startsWith('event:')) {
// 记录当前事件类型,用于决定是否将后续 data 行传递给 AI 渲染
currentEventType = trimmed.substring('event:'.length).trim();
continue;
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 (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);
}
flushEvent();
stopped = true;
return;
}
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 (eventLines.length > 0 && currentEventType !== 'status' && currentEventType !== 'tool_call_result') {
emitRawChunk(eventLines.join('\n'));
eventLines = [];
};
// 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 {
while (!stopped && !signal?.aborted) {
const { done, value } = await reader.read();
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 (done) {
if (!stopped && buffer) consumeLine(buffer);
if (!stopped) flushEvent();
break;
}
} 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 {
reader.releaseLock();
signal?.removeEventListener('abort', cancelReader);
cancelReader();
try { reader.releaseLock(); } catch { /* preserve original outcome */ }
}
logger.lifecycleStreamDone(getActiveIntegrateId(), totalText.length);
onDone();
finish();
} catch (err) {
// 用户主动中断不触发 onError,走 onDone
if (signal?.aborted || (err instanceof CskError && err.type === 'aborted')) {
onDone();
finish();
return;
}
if (completed) throw err;
if (err instanceof CskError) {
onError(err);
} else {
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: 消息反馈 ====================

209
client/src/chat.ts

@ -11,7 +11,6 @@ import {
chatRequest,
chatSSERequest,
fetchCategoryTree,
fetchRagSources,
fetchConversationList,
fetchConversationMessages,
deleteConversation,
@ -511,145 +510,129 @@ async function produceAIReply(userText: string, images?: ImageAttachment[]): Pro
isSending = true;
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 aiTimestamp = now();
// RAG 启用条件:由 enableRag 控制
const shouldUseRag = useRag;
const categoryId = currentCategoryId;
// 显示 loading
try {
if (!requestConfig.chatId) await initChatId();
if (!isCurrent() || controller.signal.aborted) return false;
if (showLoadingFn) showLoadingFn();
if (messagesContainer) smartScrollToBottom();
smartScrollToBottom();
const aiMsgId = uuid();
let aiContent = '';
try {
if (config.streaming) {
aiContent = await sendStreamMessage(userText, aiTimestamp, shouldUseRag, aiMsgId, imageUrls);
if (requestConfig.streaming) {
await sendStreamMessage(userText, aiMsg, shouldUseRag, categoryId, controller.signal, isCurrent, imageUrls);
} 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 (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);
saveMessages(config.integrateId, messages);
saveMessages(requestRoleId, messages);
if (messagesContainer) smartScrollToBottom();
// a11y 播报新 AI 消息
announceMessage(aiContent);
// 通知 launcher 显示未读徽章(弹窗关闭时生效,由 index.ts 监听)
announceMessage(aiMsg.content);
if (messagesContainer) {
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();
return true;
} 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;
} finally {
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) => {
/** 流式正文和来源始终写入发起请求时创建的消息及其气泡。 */
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 accumulated = '';
let streamStarted = false;
chatSSERequest(
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: string) => {
// 直接拼接:后端每个 SSE 事件是模型一个原始 token,
// token 内的换行已由 api.ts 的 eventLines.join('\n') 还原,
// chunk 之间不能再加 \n,否则会把单词/短句拆成多行、破坏 Markdown 结构
accumulated += chunk;
if (!streamStarted && messagesContainer) {
(chunk) => {
if (!isCurrent() || signal.aborted) return;
aiMsg.content += chunk;
if (!bubbleEl && messagesContainer) {
if (hideLoadingFn) hideLoadingFn();
const { wrapper, bubble } = createEmptyAIBubble(messagesContainer, aiTimestamp, aiMsgId);
const { wrapper, bubble } = createEmptyAIBubble(messagesContainer, aiMsg.timestamp, aiMsg.id);
wrapperEl = wrapper;
bubbleEl = bubble;
streamStarted = true;
}
if (bubbleEl) {
(bubbleEl as unknown as { content: unknown[] }).content = [{ type: 'markdown', data: accumulated }];
(bubbleEl as unknown as { content: unknown[] }).content = [{ type: 'markdown', data: aiMsg.content }];
}
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);
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 {
reject(error);
streamError = error;
}
},
currentCategoryId,
categoryId,
shouldUseRag,
imageUrls,
signal
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> {
@ -807,6 +766,10 @@ function renderHistory(): void {
/** 开启新对话(生成新 chatId) */
function handleClear(): void {
if (!config) return;
abortController?.abort();
abortController = null;
isSending = false;
setSendButtonMode('send');
messages = [];
if (messagesContainer) {
@ -980,6 +943,10 @@ export async function loadHistoryConversations(): Promise<void> {
*/
async function switchToConversation(conversationId: string): Promise<void> {
if (!config || !messagesContainer) return;
abortController?.abort();
abortController = null;
isSending = false;
setSendButtonMode('send');
// 找到该会话对应的历史条目,获取其所属角色
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,
streaming: raw.streaming ?? true,
enableRag: raw.enableRag ?? true,
rewriteStrategy: raw.rewriteStrategy || 'REWRITE',
rewriteStrategy: raw.rewriteStrategy || 'NONE',
locale: raw.locale || 'zh-CN',
debug: raw.debug ?? 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';
const metaParts: string[] = [];
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(' · ');
item.appendChild(name);

22
client/src/types.ts

@ -76,7 +76,7 @@ export interface SDKConfig {
* 如果角色未绑定知识库,后端自动降级为普通对话,不会报错。
*/
enableRag?: boolean;
/** RAG 查询重写策略,默认 "REWRITE"(在 enableRag=true 时生效) */
/** RAG 查询重写策略,默认 "NONE"(显式启用其他策略时才调用重写模型) */
rewriteStrategy?: 'NONE' | 'REWRITE' | 'TRANSLATION' | 'COMPRESSION' | 'MULTI_QUERY';
/** 界面语言,默认 "zh-CN" */
locale?: string;
@ -212,12 +212,20 @@ export interface ChatMessage {
/** RAG 引用来源 */
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);
});
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', () => {
const result = parseConfig(validConfig);
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 type { ApiResponse } from '@/types/api'
import type { ChatResult } from '@/types/sse'
const API_BASE = ''
@ -28,13 +27,11 @@ export interface ChatOptions {
* @param message 用户消息
* @param chatId 会话 ID
* @param options 可选参数
* @param includeEnableRag 是否写入 enableRag 参数(sources 接口无意义,需排除)
*/
function buildChatQueryParams(
message: string,
chatId: string,
options?: ChatOptions,
includeEnableRag = true,
): URLSearchParams {
const params = new URLSearchParams()
params.set('message', message)
@ -42,7 +39,7 @@ function buildChatQueryParams(
if (options) {
if (options.roleId) params.set('roleId', options.roleId)
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.categoryId !== undefined && options.categoryId !== '') params.set('categoryId', String(options.categoryId))
if (options.categoryIds && options.categoryIds.length > 0) params.set('categoryIds', options.categoryIds.join(','))
@ -51,10 +48,17 @@ function buildChatQueryParams(
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 */
@ -62,12 +66,6 @@ export function chatSSEUrl(message: string, chatId: string, options?: ChatOption
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 用)。
* 调用 /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 class="source-doc-title">📄 {{ group.title || '文档 ' + key }}</div>
<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>
@ -19,17 +19,18 @@
<script setup lang="ts">
import { computed } from 'vue'
import { renderMarkdown } from '@/utils/markdown'
import type { SourceReference } from '@/types/sse'
const props = defineProps<{ sources: any[] }>()
const props = defineProps<{ sources: SourceReference[] }>()
/** 按文档 ID 归并 chunk */
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 || [])) {
const docId = s.documentId || s.metadata?.documentId || 'unknown'
const docId = s.documentId || s.sourceName || s.title || 'unknown'
if (!groups[docId]) {
groups[docId] = {
title: s.metadata?.title || s.sourceName,
title: s.title || s.sourceName,
chunks: [],
}
}

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

@ -282,11 +282,12 @@
</template>
<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 '@tdesign-vue-next/chat/es/style/index.css'
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 { toChatData, type ChatMessage } from '@/utils/chatAdapter'
@ -308,11 +309,11 @@ const modeOptions = [
{ label: '同步调用', value: 'sync' },
]
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 对齐),用于判断是否需要在生成代码中输出
@ -608,7 +609,7 @@ function domAction(act: 'open' | 'close' | 'toggle'): void {
const activeTab = ref<'chat' | 'sdk'>('chat')
const demoMode = ref<'sse' | 'sync'>('sse')
const demoRag = ref(true)
const demoStrategy = ref('MULTI_QUERY')
const demoStrategy = ref('NONE')
const demoInput = ref('')
const demoChatId = ref('')
const isSending = ref(false)
@ -625,7 +626,7 @@ function welcomeMessage(): ChatMessage {
return {
id: genId(),
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,
time: fmtTime(),
}
@ -635,7 +636,7 @@ const demoMessages = ref<ChatMessage[]>([welcomeMessage()])
const demoData = computed(() => toChatData(demoMessages.value))
function buildDemoUrl(
path: 'chat' | 'chat/stream' | 'chat/sources',
path: 'chat/result' | 'chat/stream',
message: string,
chatId: string,
opts: { enableRag: boolean; rewriteStrategy?: string },
@ -647,7 +648,7 @@ function buildDemoUrl(
if (rid) p.set('roleId', rid)
const uid = config.userId.trim()
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)
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() })
const assistantMsg: ChatMessage = { id: genId(), role: 'assistant', content: '', streaming: true, time: fmtTime(), sources: [] }
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 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 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 {
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 {
// 复用 readSSEStream(内部手写解析 OpenAI delta.content),逐 chunk 追加
await readSSEStream(
url,
(chunk: string) => {
await readSSEStreamWithEvents(url, {
onMessage: (chunk) => {
if (!isCurrent()) return
assistantMsg.content += chunk
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) {
if (!isCurrent()) return
if (e.name === 'AbortError') {
assistantMsg.content = assistantMsg.content || '已取消'
} else {
@ -719,12 +725,14 @@ async function sendDemo(val?: string): Promise<void> {
assistantMsg.error = true
}
} finally {
if (isCurrent()) {
assistantMsg.streaming = false
isSending.value = false
demoMessages.value = [...demoMessages.value]
await scrollDemoBottom()
}
}
}
function abortDemo(): void {
if (abortController) abortController.abort()
@ -743,6 +751,13 @@ function clearDemo(): void {
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> {
const msg = demoMessages.value[index]
if (!msg) return
@ -913,41 +928,30 @@ const testResults = ref<TestCaseResult[]>([
const url = buildDemoUrl('chat/stream', '你好', 'verify_sse', { enableRag: false })
apiCount.value++
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 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)
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)')
},
},
{
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) => {
const url = buildDemoUrl('chat/sources', '请假', 'verify_sources', { enableRag: true, rewriteStrategy: 'REWRITE' })
const url = buildDemoUrl('chat/result', '请假', 'verify_sources', { enableRag: true, rewriteStrategy: 'NONE' })
apiCount.value++
let res: Response
try {
@ -963,9 +967,9 @@ const testResults = ref<TestCaseResult[]>([
return
}
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', '通过')
},
},

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'
/** 豆包 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 {
id: string
@ -112,7 +126,7 @@ export interface AiModelConfig {
temperature?: number
maxTokens?: number
topP?: number
extraConfig?: Record<string, any>
extraConfig?: ModelExtraConfig
isActive: boolean
priority?: number
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 事件类型定义 */
export interface SSECallbacks {
onMessage?: (chunk: string) => void
onSources?: (sources: SourceReference[]) => void
onToolCallStart?: (data: any) => void
onToolCallResult?: (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 { SourceReference } from '@/types/sse'
/** 附件信息(图片或文件) */
export interface Attachment {
@ -24,7 +25,7 @@ export interface ChatMessage {
content: string
streaming: boolean
time: string
sources?: any[]
sources?: SourceReference[]
toolCalls?: any[]
error?: boolean
feedback?: string | null

273
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'
/** 构建带认证的请求头 */
/** Preserve explicit SDK authorization instead of replacing it with the admin token. */
function authHeaders(extra?: Record<string, string>): Record<string, string> {
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 : '服务异常'
}
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)→ 跳过
}
return undefined // 是 JSON 但非 OpenAI 形状 → 回退纯文本
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)
}
/**
* 通用 SSE 流式读取 —— 统一处理 Flux<String> / ServerSentEvent / SseEmitter 三种 SSE 接口
*
* @param url 请求地址
* @param onChunk 每收到一段文本的回调
* @param onDone 流结束的回调
* @param headers 额外请求头
* @param signal AbortSignal 用于取消请求(组件卸载时必须传入以释放网络资源)
*/
export async function readSSEStream(
/** 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 (!('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)
}
const content = value.choices[0]?.delta?.content
if (typeof content === 'string' && content) handlers.onMessage?.(content)
return true
}
/** Text-only callers share the same framing, completion and cleanup semantics. */
export function readSSEStream(
url: string,
onChunk: (text: string) => void,
onDone?: () => void,
headers?: Record<string, string>,
signal?: AbortSignal
signal?: AbortSignal,
): 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')
return readSSEStreamWithEvents(url, { onMessage: onChunk, onDone }, headers, signal)
}
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()
}
/**
* 增强版 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(
url: string,
handlers: SSECallbacks,
headers?: Record<string, string>,
signal?: AbortSignal
signal?: AbortSignal,
): Promise<void> {
const { onMessage, onToolCallStart, onToolCallResult, onError, onDone } = handlers
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()
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 = () => {
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() || ''
for (let line of lines) {
if (line.endsWith('\r')) line = line.slice(0, -1)
if (line === '') {
// 空行 = SSE 事件边界
const consumeLine = (raw: string) => {
const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw
if (!line) {
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)
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 (line.trim() && onMessage) onMessage(line)
if (!completed && line.trim()) handlers.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()
}

127
frontend/src/views/ChatPanel.vue

@ -235,14 +235,14 @@ import {
ChatActionbar,
} from '@tdesign-vue-next/chat'
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 { getActiveModelConfig } from '@/api/model-config'
import { truncateConversation } from '@/api/conversation'
import { submitFeedback as submitFeedbackApi } from '@/api/feedback'
import { uploadAttachment } from '@/api/upload'
import { toast } from '@/utils/toast'
import { readSSEStream, readSSEStreamWithEvents } from '@/utils/sse'
import { readSSEStreamWithEvents } from '@/utils/sse'
import { renderMarkdown } from '@/utils/markdown'
import { useCategoryStore } from '@/stores/category'
import { toChatData, isLastAssistant } from '@/utils/chatAdapter'
@ -266,11 +266,11 @@ const QUICK_QUESTIONS = [
]
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 = [
@ -284,7 +284,7 @@ const mode = ref('sse') // 默认 SSE 流式
const selectedRole = ref('general')
const roles = ref([FALLBACK_ROLE])
const isRagMode = ref(false)
const ragStrategy = ref('MULTI_QUERY')
const ragStrategy = ref('NONE')
const activeModel = ref<any>(null)
const modelLoadError = ref('')
const userInput = ref('')
@ -376,6 +376,7 @@ function providerLabel(provider: string): string {
// ==================== 角色管理 ====================
function selectRole(roleKey: string): void {
abortChat()
selectedRole.value = roleKey
newChatId()
currentSuggestions.value = [] // 切换角色时清空推荐问题
@ -491,6 +492,19 @@ async function send(): Promise<void> {
isSending.value = true
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({
id: generateMsgId(), role: 'user', content: text,
@ -503,73 +517,56 @@ async function send(): Promise<void> {
streaming: true, time: formatTime(), sources: [], toolCalls: [],
}
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()
const cid = chatId.value || ('web_' + Date.now())
chatId.value = cid
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 {
// SSE 流式
const url = chatSSEUrl(text, cid, chatOptions)
if (isRagMode.value) {
await readSSEStreamWithEvents(url, {
onMessage: async (chunk: string) => {
await readSSEStreamWithEvents(chatSSEUrl(text, cid, chatOptions), {
onMessage: (chunk) => {
if (!isCurrent()) return
assistantMsg.content += chunk
messages.value = [...messages.value]
await scrollToBottom()
void scrollToBottom()
},
onSources: (sources) => {
if (!isCurrent()) return
assistantMsg.sources = sources
messages.value = [...messages.value]
},
onToolCallStart: (data: any) => {
onToolCallStart: (data) => {
if (!isCurrent()) return
assistantMsg.toolCalls!.push({ tool: data.tool, input: data.input, status: 'running', result: null })
scrollToBottom()
messages.value = [...messages.value]
void scrollToBottom()
},
onToolCallResult: (data: any) => {
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]
scrollToBottom()
void scrollToBottom()
},
onError: (data: any) => {
assistantMsg.content += '\n\n⚠️ ' + (data.message || '工具调用出错')
onError: (data) => {
if (!isCurrent()) return
assistantMsg.content += '\n\n' + (data.message || '工具调用出错')
messages.value = [...messages.value]
},
onDone: () => {},
}, undefined, signal)
} else {
await readSSEStream(url, async (chunk: string) => {
assistantMsg.content += chunk
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 (_) { /* 来源获取失败不影响主回答 */ }
}, undefined, controller.signal)
}
} catch (e: any) {
if (!isCurrent()) return
// AbortError 不是真正的错误,不显示错误信息
if (e.name === 'AbortError') {
assistantMsg.content = assistantMsg.content || '已取消'
@ -579,20 +576,20 @@ async function send(): Promise<void> {
toast('对话失败:' + e.message, 'error')
}
} finally {
if (isCurrent()) {
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 (assistantMsg.content && !assistantMsg.error) {
void fetchSuggestions(cid).then(items => {
if (isCurrent() && items.length) currentSuggestions.value = items
})
}
await scrollToBottom()
}
}
}
// ==================== 停止生成 ====================
function abortChat(): void {

33
frontend/src/views/ModelConfigManager.vue

@ -207,6 +207,14 @@
<t-form-item label="停止词(逗号分隔)" style="grid-column:1/-1;">
<t-input v-model="editModal.form.stopSequences" placeholder="如:END,STOP" />
</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>
</t-collapse-panel>
</t-collapse>
@ -292,6 +300,7 @@ import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import * as api from '@/api/model-config'
import { toast } from '@/utils/toast'
import { useConfirm } from '@/composables/useConfirm'
import type { ModelExtraConfig } from '@/types/models'
const { confirm } = useConfirm()
@ -404,11 +413,27 @@ const editModal = ref<any>({
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() {
return {
name: '', app_type: 'CHAT', provider: '', api_key: '', model_name: '',
temperature: 0.7, max_tokens: 2000, base_url: '', embeddingDimensions: 1024,
topP: null, topK: null, frequencyPenalty: null, presencePenalty: null, stopSequences: '',
reasoningEffort: '', extraConfig: {} as ModelExtraConfig,
priority: 0, is_active: false, description: '',
}
}
@ -578,6 +603,7 @@ function openEditModal(config: any) {
topP: extra.topP ?? null, topK: extra.topK ?? null,
frequencyPenalty: extra.frequencyPenalty ?? null, presencePenalty: extra.presencePenalty ?? null,
stopSequences: extra.stopSequences || '', priority: config.priority || 0,
reasoningEffort: extra.reasoningEffort || '', extraConfig: { ...extra },
is_active: config.is_active || false, description: config.description || '',
},
}
@ -591,13 +617,18 @@ function openEditModal(config: 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 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.topP != null) extraConfig.topP = form.topP
if (form.topK != null) extraConfig.topK = form.topK
if (form.frequencyPenalty != null) extraConfig.frequencyPenalty = form.frequencyPenalty
if (form.presencePenalty != null) extraConfig.presencePenalty = form.presencePenalty
if (form.stopSequences?.trim()) extraConfig.stopSequences = form.stopSequences.trim()
if (form.reasoningEffort) extraConfig.reasoningEffort = form.reasoningEffort
data.extraConfig = extraConfig
return data
}

45
frontend/src/views/PipelineFlow.vue

@ -5,7 +5,7 @@
<div>
<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;">
下图展示从用户请求到 AI 回复的完整处理流程,包含意图路由、RAG 检索、熔断保护和 Advisor 链。
下图展示从用户请求到 AI 回复的完整处理流程,包含 FAQ 与本地路由、RAG 检索、熔断保护和 Advisor 链。
菱形节点 = 决策分支 · 虚线框 = 独立子系统 · 虚线箭头 = 降级/异步路径。
</p>
</div>
@ -76,52 +76,52 @@ mermaid.initialize({
// Mermaid 流程图 DSL 定义
// 节点类型: [矩形]=处理步骤, {菱形}=决策分支, 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 = `
flowchart TD
A["<b>用户请求</b><br/>message + roleId + accountId + chatId"]
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 ?"}
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 检索流水线(当前: 纯向量检索)"]
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
CHK -. "❌ 未命中 → 纯对话" .-> I["<b>模式: 纯对话</b><br/>跳过知识库检索<br/>不注入资料块"]
CHK -- "✅ 命中标准答案" --> T
I --> T
S --> 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 -- "正常" --> 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 调用)"]
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 违规内容"]
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
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>
<!-- 设置为 true 可跳过前端 SDK 构建,加快后端编译:mvn compile -Dskip.frontend=true -->
<skip.frontend>false</skip.frontend>
<!-- 集成测试默认跳过,允许通过 -DskipTests=false 显式运行。 -->
<skipTests>true</skipTests>
</properties>
<!-- ==================== Maven 多环境 Profile ==================== -->
@ -229,13 +231,13 @@
</resource>
</resources>
<plugins>
<!-- 默认跳过集成测试(所有测试均为 @SpringBootTest,需要 DB 和 API Key) -->
<!-- 默认跳过测试;集成测试需要 DB 和 API Key,针对性单元测试可按类运行。 -->
<!-- 需要运行测试时显式指定:mvn test -DskipTests=false -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<skipTests>true</skipTests>
<skipTests>${skipTests}</skipTests>
</configuration>
</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.FluxSink;
import reactor.core.publisher.SignalType;
import reactor.core.scheduler.Schedulers;
import java.util.ArrayList;
import java.util.Collections;
@ -314,19 +315,19 @@ public class AssistantApp {
*/
public ChatResult chatWithEvents(ChatContext ctx) {
long startNanos = System.nanoTime();
// 熔断:全局 AI 调用处于熔断状态,直接返回降级提示(不做 buildRequest,避免熔断期间仍走意图路由/检索)
// 熔断时直接返回降级提示,不再编排或检索。
if (aiCircuitBreaker.isOpen(AI_CIRCUIT_KEY)) {
log.warn("AI 调用熔断中,返回降级提示");
recordTrace(ctx, null, CIRCUIT_OPEN_MESSAGE, 0, "BYPASS",
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);
if (req.faqHit()) {
String faqAnswer = req.faqAnswer().get();
recordTrace(ctx, req, faqAnswer, 0, "FAQ",
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 的问题
List<ToolCallEvent> events = new CopyOnWriteArrayList<>();
@ -354,14 +355,14 @@ public class AssistantApp {
events));
// 推荐问题已不再由主回复同步生成,改由 SuggestionGenerator 异步按需生成
return new ChatResult(text, events, List.of());
return new ChatResult(text, events, List.of(), SourceReference.fromDocuments(req.hitDocuments()));
} catch (Exception e) {
aiCircuitBreaker.recordFailure(AI_CIRCUIT_KEY);
log.error("AI 同步调用失败: chatId={}, error={}", ctx.chatId(), e.getMessage());
String fallback = "抱歉,AI 服务调用失败:" + e.getMessage();
recordTrace(ctx, req, fallback, elapsedMillis(startNanos), "ERROR",
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) {
long startNanos = System.nanoTime();
// 熔断:全局 AI 调用处于熔断状态(不做 buildRequest,避免熔断期间仍走意图路由/检索)
// 熔断时直接返回降级提示,不再编排或检索。
if (aiCircuitBreaker.isOpen(AI_CIRCUIT_KEY)) {
log.warn("AI 调用熔断中(流式),返回降级提示");
recordTrace(ctx, null, CIRCUIT_OPEN_MESSAGE, 0, "BYPASS",
@ -452,7 +453,7 @@ public class AssistantApp {
* <p>
* 复用 {@link #chatStream(ChatContext)} 的完整编排逻辑(熔断早退 / FAQ 命中早退 /
* 正常流式调用 / 空白缓冲 / 埋点),差异在于把每个文本片段包装为 OpenAI 标准 JSON chunk:
* 首片 delta 携带 role=assistant,流结束时追加 finish_reason=stop 的 chunk 与 [DONE]。
* 首片 delta 携带 role=assistant,正文结束后追加 sources 元数据、finish_reason=stop 与 [DONE]。
* <p>
* 每个 Flux 元素即一个完整 JSON 字符串,Spring WebFlux 自动加 data: 前缀。
*
@ -460,6 +461,10 @@ public class AssistantApp {
* @return OpenAI 标准格式的流式回答
*/
public Flux<String> chatStreamOpenAi(ChatContext ctx) {
return Flux.defer(() -> buildOpenAiStream(ctx)).subscribeOn(Schedulers.boundedElastic());
}
private Flux<String> buildOpenAiStream(ChatContext ctx) {
long startNanos = System.nanoTime();
// OpenAI 标准 chunk 的公共元信息:同一次流式回答共享 id / created / model
String completionId = "chatcmpl-" + UUID.randomUUID().toString().replace("-", "");
@ -472,7 +477,7 @@ public class AssistantApp {
log.warn("获取活跃模型配置失败,model 回退 unknown: chatId={}, error={}", ctx.chatId(), e.getMessage());
}
String model = (cfg != null && cfg.getModelName() != null) ? cfg.getModelName() : "unknown";
// 熔断:全局 AI 调用处于熔断状态(不做 buildRequest,避免熔断期间仍走意图路由/检索)
// 熔断时直接返回降级提示,不再编排或检索。
if (aiCircuitBreaker.isOpen(AI_CIRCUIT_KEY)) {
log.warn("AI 调用熔断中(OpenAI 流式),返回降级提示");
recordTrace(ctx, null, CIRCUIT_OPEN_MESSAGE, 0, "BYPASS",
@ -518,7 +523,8 @@ public class AssistantApp {
});
// 聚合所有分片用于埋点(在 doFinally 时取完整回复文本)
StringBuilder aggregated = new StringBuilder();
return preserveTrailingWhitespace(rawStream)
// JSON 编码会保留正文空白,无需为 SSE 行尾 trim 缓冲 token。
return rawStream.filter(chunk -> !chunk.isEmpty())
.doOnNext(aggregated::append)
.map(chunk -> buildOpenAiChunk(completionId, model, created, chunk, false, null))
.doOnComplete(() -> aiCircuitBreaker.recordSuccess(AI_CIRCUIT_KEY))
@ -540,12 +546,12 @@ public class AssistantApp {
usage != null ? usage.getTotalTokens() : null,
events));
})
// 首片(仅 role=assistant、无 content)在流订阅时立即发出,确保 SSE 响应头/首字节及时 flush。
// 推理模型(如 doubao-seed)思考阶段 delta.content 为空、被 preserveTrailingWhitespace 吞掉,
// 若不提前发首片,思考阶段将无任何字节输出,前端等待首字节会触发 60s 超时。
// 编排结束后先发送 role 协议帧,避免模型思考期间连接完全静默。
// 此帧没有正文,不代表用户已收到首个回答 token。
.startWith(buildOpenAiChunk(completionId, model, created, "", true, null))
// 流正常结束时追加 finish_reason=stop 的 chunk 与 [DONE]
// 来源只取本次编排命中;元数据不经过正文聚合与 trace。
.concatWith(Flux.just(
buildSourcesChunk(completionId, model, created, SourceReference.fromDocuments(req.hitDocuments())),
buildOpenAiChunk(completionId, model, created, "", false, "stop"),
"[DONE]"))
// 错误兜底:脱敏错误信息,避免泄露内部细节(首片 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 命中与错误兜底三种场景。
*
* @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) {
return Flux.just(
buildOpenAiChunk(id, model, created, content, withRole, null),
buildSourcesChunk(id, model, created, List.of()),
buildOpenAiChunk(id, model, created, "", false, "stop"),
"[DONE]");
}

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

@ -17,7 +17,7 @@ import java.util.List;
* @param systemPrompt 角色人设/系统提示词,可为 null
* @param allowedMcpTools 允许的 MCP 工具名列表;null=无角色允许全部;空=有角色但无授权(禁止);["*"]=全部
* @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 streaming 是否流式输出
* @param roleId 客服角色 ID(可空,供调用追踪等使用)
@ -47,12 +47,15 @@ public record ChatContext(
private static final String DEFAULT_APP_TYPE = "CHAT";
/**
* 紧凑构造器:规范化默认值,保证 appType 非空。
* 紧凑构造器:规范化应用类型与检索默认值。
*/
public ChatContext {
if (appType == null || appType.isBlank()) {
appType = DEFAULT_APP_TYPE;
}
if (rewriteStrategy == null || rewriteStrategy.isBlank()) {
rewriteStrategy = "NONE";
}
}
/**

115
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.RagPipeline;
import com.wok.supportbot.service.FaqMatchEngine.FaqMatchResult;
import com.wok.supportbot.service.IntentRouter;
import com.wok.supportbot.service.SystemConfigService;
import com.wok.supportbot.service.RagHitLogService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
@ -20,19 +18,17 @@ import java.util.Optional;
/**
* 统一对话管道(编排层)。
* <p>
* 编排一次完整对话的决策流程:意图路由 → FAQ 优先 → RAG 检索 → 组装系统提示词与用户消息,
* 编排一次完整对话的决策流程:FAQ 优先 → 本地寒暄判断 → RAG 检索 → 组装系统提示词与用户消息,
* 产出 {@link ChatRequest} 交由 {@code AssistantApp} 执行实际的 {@code call()} / {@code stream()}。
* <p>
* 设计说明:本类为纯编排层,不持有 ChatClient(ChatClient 构建与 Advisor 链装配仍在
* {@code AssistantApp}),因此 {@code call} / {@code stream} 由 {@code AssistantApp} 承担,
* 避免 {@code ChatPipeline} ↔ {@code AssistantApp} 循环依赖。
* <p>
* 接入 {@link IntentRouter} 替代原 {@code AiController.shouldBypassKnowledgeRetrieval} 的硬编码寒暄词判断:
* 寒暄词列表保留为快速路径与兜底,IntentRouter 负责细粒度意图分类,二者命中其一即跳过 KB 检索。
* 完整 FAQ 匹配后仅使用本地寒暄判断,其他请求直接检索,不调用 LLM 做意图分类。
* <p>
* {@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>
* 同步至: frontend/src/views/PipelineFlow.vue, CLAUDE.md ASCII管道图
*/
@ -40,32 +36,20 @@ import java.util.Optional;
@Slf4j
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
private RagPipeline ragPipeline;
@Resource
private SystemConfigService systemConfigService;
@Resource
private RagHitLogService ragHitLogService;
/**
* 编排一次对话请求,产出执行决策。
* <p>
* 决策分支:
* <ul>
* <li>未启用 RAG(普通对话 / 严格隔离下 KB 拒绝)→ 用原始 message、基础 system</li>
* <li>寒暄/闲聊(IntentRouter 或寒暄词命中)→ 同上,跳过 KB 检索</li>
* <li>FAQ 命中 → 直接返回标准答案,不调用 ChatClient</li>
* <li>FAQ 未命中的本地寒暄词 → 跳过 KB 检索</li>
* <li>RAG 生成 → 资料块注入 system,原始 message 作为 user 消息(重写查询仅用于检索)</li>
* </ul>
*
@ -82,59 +66,25 @@ public class ChatPipeline {
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) {
// 完整 FAQ 三级匹配前置,保留角色分类隔离。
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();
}
// 寒暄/闲聊: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());
FaqMatchResult faqMatch = faqOutcome.result().get();
log.info("FAQ 命中标准答案: chatId={}, matchType={}", ctx.chatId(), faqMatch.getMatchType());
return new ChatRequest(ctx, ctx.message(), baseSystem,
Optional.ofNullable(faqMatch.get().getFaq().getAnswer()),
globalPrompt, null, null, "FAQ", null, null, faqMatch.get());
Optional.ofNullable(faqMatch.getFaq().getAnswer()),
globalPrompt, null, null, "FAQ", null, null, faqMatch);
}
// 仅本地寒暄词绕过检索;业务问题默认不做任何 LLM 预处理。
if (isChitchat(ctx.message())) {
return new ChatRequest(ctx, ctx.message(), baseSystem, Optional.empty(),
globalPrompt, null, null, "CHITCHAT", null, null, null);
}
// RAG 检索(含 FAQ 优先匹配;FAQ 高置信已完整匹配过则跳过二次 FAQ)
RagContext rag = ragPipeline.retrieve(ctx, faqSkippableInRetrieve);
// 仅干净完成的 FAQ 匹配可跳过;异常降级的 miss 仍由 RAG 重试,避免误跳。
RagContext rag = ragPipeline.retrieve(ctx, faqOutcome.completedCleanly());
// 记录 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);
}
if (rag.faqHit()) {
return new ChatRequest(ctx, ctx.message(), baseSystem, rag.faqAnswer(),
globalPrompt, null, null, "FAQ", null, null, rag.faqMatchResult());
@ -147,44 +97,9 @@ public class ChatPipeline {
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) {
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 mcpEvents 本次触发的 MCP 工具调用事件,无调用时为空列表
* @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 {
suggestions = suggestions != null ? List.copyOf(suggestions) : 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)
applyExtraConfig(config, optionsBuilder);
applyReasoningEffort(config, optionsBuilder);
return OpenAiChatModel.builder()
.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
*/

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.ChatContext;
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.cache.SuggestionCache;
import com.wok.supportbot.config.RoleAccessConfig;
@ -12,7 +14,6 @@ import com.wok.supportbot.service.CustomerServiceRoleService;
import com.wok.supportbot.service.CustomerServiceRoleService.RoleScope;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils;
@ -28,10 +29,8 @@ import reactor.core.publisher.Flux;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@ -122,21 +121,7 @@ public class AiController {
List<Long> cats = resolveCategoryIds(scope, categoryId, categoryIds);
ChatContext ctx = new ChatContext(message, chatId, "CHAT", null, null, cats,
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) {
log.error("获取 RAG 引用来源失败 [strategy={}]: {}", rewriteStrategy, e.getMessage(), e);
return Map.of("success", true, "data", List.of());
@ -286,9 +271,9 @@ public class AiController {
return roleAccessConfig.isStrictIsolation() && scope.hasRole() && scope.categoryIds().isEmpty();
}
/** 未指定策略时默认 MULTI_QUERY(多路扩展)。 */
/** 未指定策略时使用原始问题检索,不调用重写模型。 */
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) {
@ -344,15 +329,35 @@ public class AiController {
@RequestParam(required = false) Long categoryId,
@RequestParam(required = false) String categoryIds,
@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) String categoryIds,
@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);
}

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.ChatContext;
import com.wok.supportbot.app.ChatResult;
import com.wok.supportbot.app.SuggestionGenerator;
import com.wok.supportbot.cache.SuggestionCache;
import com.wok.supportbot.config.RoleAccessConfig;
@ -84,13 +85,16 @@ public class OpenApiController {
ChatContext ctx = buildOpenApiChatContext(message, resolvedChatId, apiKey, roleId,
categoryIds, rewriteStrategy, enableRag, false);
String reply = assistantApp.chat(ctx);
ChatResult reply = assistantApp.chatWithEvents(ctx);
Map<String, Object> result = new HashMap<>();
result.put("success", true);
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);
} catch (Exception e) {
@ -219,9 +223,9 @@ public class OpenApiController {
boolean useRag = (enableRag == null || enableRag) // 默认启用 RAG
&& !(roleAccessConfig.isStrictIsolation() && scope.hasRole() && scope.categoryIds().isEmpty());
// 6. 未指定策略时默认 MULTI_QUERY
// 6. 未指定策略时直接使用原始问题检索
String strategy = (rewriteStrategy != null && !rewriteStrategy.isBlank())
? rewriteStrategy : "MULTI_QUERY";
? rewriteStrategy : "NONE";
return new ChatContext(message, chatId, "CHAT", systemPrompt,
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} 中按策略分支的检索逻辑:
* <ul>
* <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>统一资料块模板 {@link #buildRagContextBlock},替代原 {@code buildRetrievalAdvisor.qaTemplate}
* 与 {@code buildRagSystemPrompt} 两份回答模板</li>
@ -48,10 +48,10 @@ import java.util.stream.Collectors;
* 不再使用 {@code RetrievalAugmentationAdvisor} 的 query augmenter 自动注入,
* 消除上下文注入位置随策略不同而不同的不一致。
* <p>
* 阶段一作为旁路组件存在,旧 {@code AssistantApp} RAG 路径未改动;阶段二由 {@code ChatPipeline} 接入。
* 由 {@code ChatPipeline} 编排调用;RAG 命中/未命中日志仅在本管道记录一次。
* <p>
* {@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>
* 注意: HybridSearchService/RrfFusion/RerankerService 尚未接入本管道,当前仅单路向量检索。<br>
* 同步至: frontend/src/views/PipelineFlow.vue RAG 子图
@ -113,7 +113,7 @@ public class RagPipeline {
/**
* 执行一次统一的 RAG 检索。
* <p>
* 流程:FAQ 优先 → 查询重写/扩展 → 统一检索 → 拼接资料文本。
* 流程:FAQ 优先 → 默认原文(显式选择才重写/扩展)→ 统一检索 → 拼接资料文本。
*
* @param ctx 对话上下文(使用 {@code message / chatId / rewriteStrategy / categoryIds})
* @return 检索结果;FAQ 命中时 documents 与 contextText 为空,rewrittenQuery 为原始 message
@ -125,10 +125,10 @@ public class RagPipeline {
/**
* 执行一次统一的 RAG 检索(可跳过前序已做过的 FAQ 匹配)。
* <p>
* 流程:FAQ 优先(未匹配过时)→ 查询重写/扩展 → 统一检索 → 拼接资料文本。
* 流程:FAQ 优先(未匹配过时)→ 默认原文(显式选择才重写/扩展)→ 统一检索 → 拼接资料文本。
*
* @param ctx 对话上下文(使用 {@code message / chatId / rewriteStrategy / categoryIds})
* @param faqAlreadyMatched 编排层是否已在前序阶段(FAQ 高置信未命中降级)干净跑过完整 FAQ 三级匹配;
* @param faqAlreadyMatched 编排层是否已在前序阶段干净跑过完整 FAQ 三级匹配;
* true 时跳过 retrieve 内重复的 FAQ 匹配,避免同一请求重复做 FAQ 语义 embedding
* @return 检索结果;FAQ 命中时 documents 与 contextText 为空,rewrittenQuery 为原始 message
*/
@ -149,10 +149,10 @@ public class RagPipeline {
}
/**
* 仅检索知识库片段(跳过 FAQ 匹配),用于"引用来源"展示。
* 仅检索知识库片段(跳过 FAQ 匹配),用于已发布的独立来源检索接口。
* <p>
* 与 {@link #retrieve} 共用同一套查询重写与检索逻辑,确保来源即答案所依据的片段,
* 但不触发 FAQ 优先匹配——来源接口的语义是展示 KB 片段,FAQ 命中时本就无 KB 来源。
* 与 {@link #retrieve} 共用查询重写与检索逻辑;对话引用直接复用当次生成使用的文档,
* 不调用此方法二次检索。
*
* @param ctx 对话上下文
* @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