diff --git a/docs/chat-scope-api-contract.md b/docs/chat-scope-api-contract.md new file mode 100644 index 0000000..7815806 --- /dev/null +++ b/docs/chat-scope-api-contract.md @@ -0,0 +1,712 @@ +# ChatScope API Contract + +> CHAT-002 | 版本 v1.0 | 2026-06-06 +> +> 本文档是 ChatScope 前后端接口的**唯一权威契约**。 +> 所有响应 shape、错误码、SSE 格式以本文档为准。 +> 设计逻辑参见 [ChatScope 设计文档](./chat-scope-design.md)。 + +--- + +## 1. 基础信息 + +| 项目 | 值 | +|------|----| +| Base Path | `/rag-chat` | +| Auth | Bearer JWT (所有端点需要) | +| Content-Type (Request) | `application/json` | +| Content-Type (Response) | `application/json` (除 SSE 端点) | +| Date Format | ISO 8601 (`2026-06-06T12:00:00.000Z`) | + +--- + +## 2. 枚举 & 常量 + +### 2.1 ChatScopeType + +```typescript +type ChatScopeType = + | "knowledge_base" + | "folder" + | "material" + | "knowledge_item" + | "global"; +``` + +### 2.2 CreatedFrom + +```typescript +type CreatedFrom = + | "knowledge_base_detail" + | "material_detail" + | "material_reader" + | "knowledge_item_detail" + | "folder_detail" + | "global_ai_entry" + | "legacy_migration"; +``` + +### 2.3 ModelMode + +```typescript +type ModelMode = "normal" | "deep_think" | "web_search"; +``` + +--- + +## 3. 端点清单 + +| Method | Path | 说明 | Issue | +|--------|------|------|-------| +| POST | `/rag-chat/sessions` | 创建/打开会话 (open-or-create) | #81 #76 | +| GET | `/rag-chat/sessions` | 会话列表(支持 scope 过滤) | #75 | +| GET | `/rag-chat/sessions/:id/messages` | 获取消息历史 | (已有) | +| POST | `/rag-chat/sessions/:id/messages` | 发送消息(同步) | (已有) | +| POST | `/rag-chat/sessions/:id/stream` | 发送消息(SSE 流式) | (已有) | +| PATCH | `/rag-chat/sessions/:id` | 更新会话属性 | NEW | +| DELETE | `/rag-chat/sessions/:id` | 软删除会话 | (已有) | + +--- + +## 4. 端点详细定义 + +### 4.1 POST /rag-chat/sessions — open-or-create + +创建新会话,或在匹配的 scope 下返回已有会话。 + +**Request Body:** + +```json +{ + "scopeType": "material", + "scopeId": "clx7abc123", + "parentKnowledgeBaseId": "clx7kb456", + "createdFrom": "material_detail", + "title": "新对话" +} +``` + +| 字段 | 类型 | 必需 | 默认值 | 说明 | +|------|------|------|--------|------| +| scopeType | ChatScopeType | **是** | — | 会话绑定范围 | +| scopeId | string \| null | 否 | null | scope 对应的实体 ID | +| parentKnowledgeBaseId | string \| null | 否 | null | 所属知识库 | +| createdFrom | CreatedFrom | 否 | `"global_ai_entry"` | 入口标识 | +| title | string | 否 | `"新对话"` | 会话标题 | + +**Validation Rules:** + +- `scopeType` 必须是合法枚举值,否则 400 +- `scopeType === "global"` 时,`scopeId` 必须为 null +- `scopeType !== "global"` 时,`scopeId` 不能为空(404 或 400) + +**Behavior (open-or-create):** + +``` +IF 存在 userId + scopeType + scopeId 完全匹配 + isDeleted=false 的会话 + → 返回该会话 (HTTP 200) +ELSE + → 创建新会话并返回 (HTTP 201) +``` + +**Response 200 (已有会话):** + +```json +{ + "id": "clx7sess001", + "userId": "clx7user001", + "scopeType": "material", + "scopeId": "clx7abc123", + "parentKnowledgeBaseId": "clx7kb456", + "title": "数据库事务讨论", + "createdFrom": "material_detail", + "modelMode": "normal", + "modelId": null, + "isPinned": false, + "isArchived": false, + "isDeleted": false, + "lastMessageAt": "2026-06-05T15:30:00.000Z", + "createdAt": "2026-06-01T08:00:00.000Z", + "updatedAt": "2026-06-05T15:30:00.000Z" +} +``` + +**Response 201 (新建会话):** + +```json +{ + "id": "clx7sess002", + "userId": "clx7user001", + "scopeType": "material", + "scopeId": "clx7abc123", + "parentKnowledgeBaseId": "clx7kb456", + "title": "新对话", + "createdFrom": "material_detail", + "modelMode": "normal", + "modelId": null, + "isPinned": false, + "isArchived": false, + "isDeleted": false, + "lastMessageAt": null, + "createdAt": "2026-06-06T10:00:00.000Z", + "updatedAt": "2026-06-06T10:00:00.000Z" +} +``` + +**Errors:** + +| Code | 条件 | +|------|------| +| 400 | scopeType 不合法 | +| 400 | scopeType !== "global" 且 scopeId 为空 | +| 401 | JWT 缺失或过期 | + +--- + +### 4.2 GET /rag-chat/sessions — 会话列表 + +**Query Parameters:** + +| 参数 | 类型 | 必需 | 默认值 | 说明 | +|------|------|------|--------|------| +| scopeType | ChatScopeType | 否 | — | 过滤 scope 类型 | +| scopeId | string | 否 | — | 过滤 scope ID(需配合 scopeType) | +| parentKnowledgeBaseId | string | 否 | — | 知识库下所有会话 | +| isArchived | boolean | 否 | false | 是否返回已归档 | +| page | number | 否 | 1 | 页码 | +| limit | number | 否 | 20 | 每页数量(最大 50) | + +**过滤逻辑:** + +``` +IF scopeType + scopeId → 精确匹配(同一 scope 的历史) +ELIF parentKnowledgeBaseId → 该 KB 下所有会话(不区分 scope) +ELSE → 全局列表(所有 scope,排除 isDeleted) +``` + +**Response 200:** + +```json +{ + "data": [ + { + "id": "clx7sess001", + "userId": "clx7user001", + "scopeType": "material", + "scopeId": "clx7abc123", + "parentKnowledgeBaseId": "clx7kb456", + "title": "数据库事务讨论", + "createdFrom": "material_detail", + "modelMode": "normal", + "modelId": null, + "isPinned": true, + "isArchived": false, + "isDeleted": false, + "lastMessageAt": "2026-06-05T15:30:00.000Z", + "createdAt": "2026-06-01T08:00:00.000Z", + "updatedAt": "2026-06-05T15:30:00.000Z" + } + ], + "meta": { + "page": 1, + "limit": 20, + "total": 42 + } +} +``` + +**排序规则:** + +- `isPinned === true` 优先 +- 同优先级按 `lastMessageAt DESC`(最近活跃在前) +- 无消息的会话按 `createdAt DESC` + +--- + +### 4.3 GET /rag-chat/sessions/:id/messages — 消息历史 + +**Response 200:** + +```json +[ + { + "id": "clx7msg001", + "sessionId": "clx7sess001", + "role": "user", + "content": "TCP 三次握手的过程是什么?", + "tokens": 0, + "scopeSnapshot": { + "scopeType": "material", + "scopeId": "clx7abc123", + "parentKnowledgeBaseId": "clx7kb456" + }, + "createdAt": "2026-06-06T10:00:00.000Z", + "citations": [] + }, + { + "id": "clx7msg002", + "sessionId": "clx7sess001", + "role": "assistant", + "content": "TCP 三次握手的过程如下:...", + "tokens": 245, + "scopeSnapshot": { + "scopeType": "material", + "scopeId": "clx7abc123", + "parentKnowledgeBaseId": "clx7kb456" + }, + "createdAt": "2026-06-06T10:00:05.000Z", + "citations": [ + { + "id": "clx7cit001", + "messageId": "clx7msg002", + "chunkId": "clx7chk042", + "sourceId": "clx7abc123", + "sourceTitle": "计算机网络自顶向下.pdf", + "excerptText": "TCP 连接建立需要三次握手...", + "pageNumber": 156, + "lineStart": 12, + "lineEnd": 18, + "createdAt": "2026-06-06T10:00:05.000Z" + } + ] + } +] +``` + +排序: `createdAt ASC`(时间正序) + +--- + +### 4.4 POST /rag-chat/sessions/:id/messages — 发送消息 (同步) + +**Request Body:** + +```json +{ + "content": "TCP 三次握手的过程是什么?" +} +``` + +| 字段 | 类型 | 必需 | 说明 | +|------|------|------|------| +| content | string | **是** | 消息正文(最长 10000 字符) | + +**Response 200:** + +```json +{ + "id": "clx7msg002", + "role": "assistant", + "content": "TCP 三次握手的过程如下:...", + "tokens": 245, + "blocked": false, + "message": { + "id": "clx7msg002", + "sessionId": "clx7sess001", + "role": "assistant", + "content": "TCP 三次握手的过程如下:...", + "tokens": 245, + "createdAt": "2026-06-06T10:00:05.000Z", + "citations": [...] + }, + "citations": [...] +} +``` + +**Response (被拦截):** + +```json +{ + "blocked": true, + "message": "输入包含违规内容,请修改后重试" +} +``` + +**Errors:** + +| Code | 条件 | +|------|------| +| 404 | 会话不存在 | +| 403 | 会话不属于当前用户 | +| 400 | content 为空或超过 10000 字符 | + +--- + +### 4.5 POST /rag-chat/sessions/:id/stream — SSE 流式 + +**Request Body:** 同 4.4 + +**Response:** `text/event-stream; charset=utf-8` + +**SSE 事件格式:** + +``` +data: {"type":"thinking","content":"我们来看看..."} + +data: {"type":"content","content":"TCP "} + +data: {"type":"content","content":"三次握手是"} + +data: {"type":"content","content":"..."} + +data: {"type":"citations","citations":[{"sourceTitle":"...","excerptText":"..."}]} + +data: {"type":"done"} +``` + +**Chunk 类型定义:** + +```typescript +interface SSEChunk { + type: "thinking" | "content" | "citations" | "done" | "error"; + content?: string; + citations?: ChatCitation[]; + error?: string; +} +``` + +| type | 说明 | content | citations | error | +|------|------|---------|-----------|-------| +| thinking | 思考过程片段 | 思考文本 | — | — | +| content | 回答正文片段 | 增量文本 | — | — | +| citations | 引用信息(流末尾) | — | 引用数组 | — | +| done | 流结束 | — | — | — | +| error | 流错误 | — | — | 错误信息 | + +**调用方必须:** + +1. 逐行读取 `data: {...}\n\n` +2. JSON.parse 每行 `data:` 后的内容 +3. 累积 `thinking` chunk → 思考过程 +4. 累积 `content` chunk → 回答正文 +5. `done` 或 `error` → 结束流 + +**Headers:** + +``` +Content-Type: text/event-stream; charset=utf-8 +Cache-Control: no-cache +Connection: keep-alive +X-Accel-Buffering: no +``` + +--- + +### 4.6 PATCH /rag-chat/sessions/:id — 更新会话 (NEW) + +**Request Body (全部可选):** + +```json +{ + "title": "三次握手深度讨论", + "isPinned": true, + "isArchived": false, + "modelMode": "deep_think", + "modelId": "deepseek-v4-pro" +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| title | string | 新标题(最长 200 字符) | +| isPinned | boolean | 置顶 | +| isArchived | boolean | 归档 | +| modelMode | ModelMode | 模型模式 | +| modelId | string \| null | 模型 ID | + +**不可变字段:** + +- `scopeType` — 创建后不可修改 +- `scopeId` — 创建后不可修改 +- `parentKnowledgeBaseId` — 创建后不可修改(由后端推导) +- `createdFrom` — 创建后不可修改 + +尝试修改这些字段 → 字段被静默忽略(不报错) + +**Response 200:** 更新后的 ChatSession 对象 + +**Errors:** + +| Code | 条件 | +|------|------| +| 404 | 会话不存在 | +| 403 | 会话不属于当前用户 | + +--- + +### 4.7 DELETE /rag-chat/sessions/:id — 软删除 + +**Behavior:** + +``` +UPDATE ChatSession SET isDeleted = true WHERE id = :id AND userId = :userId +``` + +消息不物理删除。 + +**Response 200:** + +```json +{ + "success": true, + "message": "会话已删除" +} +``` + +--- + +## 5. TypeScript 类型 (后端) + +```typescript +// === Request DTOs === + +interface CreateSessionDto { + scopeType: ChatScopeType; + scopeId?: string | null; + parentKnowledgeBaseId?: string | null; + createdFrom?: CreatedFrom; + title?: string; +} + +interface ListSessionsQuery { + scopeType?: ChatScopeType; + scopeId?: string; + parentKnowledgeBaseId?: string; + isArchived?: boolean; + page?: number; + limit?: number; +} + +interface SendMessageDto { + content: string; +} + +interface UpdateSessionDto { + title?: string; + isPinned?: boolean; + isArchived?: boolean; + modelMode?: ModelMode; + modelId?: string | null; +} + +// === Response Types === + +interface ChatSessionResponse { + id: string; + userId: string; + scopeType: ChatScopeType; + scopeId: string | null; + parentKnowledgeBaseId: string | null; + title: string; + createdFrom: CreatedFrom; + modelMode: ModelMode; + modelId: string | null; + isPinned: boolean; + isArchived: boolean; + isDeleted: boolean; + lastMessageAt: string | null; + createdAt: string; + updatedAt: string; +} + +interface ChatMessageResponse { + id: string; + sessionId: string; + role: "user" | "assistant"; + content: string; + tokens: number; + scopeSnapshot: ChatScope | null; + createdAt: string; + citations: ChatCitationResponse[]; +} + +interface ChatCitationResponse { + id: string; + messageId: string; + chunkId: string | null; + sourceId: string | null; + sourceTitle: string | null; + excerptText: string | null; + pageNumber: number | null; + lineStart: number | null; + lineEnd: number | null; + createdAt: string; +} + +interface PaginatedResponse { + data: T[]; + meta: { + page: number; + limit: number; + total: number; + }; +} + +interface SendMessageResponse { + id?: string; + role?: string; + content?: string; + tokens?: number; + blocked?: boolean; + message?: ChatMessageResponse; + citations?: ChatCitationResponse[]; +} +``` + +--- + +## 6. Swift 类型 (iOS) + +```swift +// MARK: - Request DTOs + +struct CreateChatSessionRequest: Codable { + let scopeType: String + let scopeId: String? + let parentKnowledgeBaseId: String? + let createdFrom: String + let title: String? +} + +struct SendMessageRequest: Codable { + let content: String +} + +struct UpdateChatSessionRequest: Codable { + var title: String? + var isPinned: Bool? + var isArchived: Bool? + var modelMode: String? + var modelId: String? +} + +// MARK: - Response Types + +struct ChatSessionResponse: Codable, Identifiable { + let id: String + let userId: String? + let scopeType: String + let scopeId: String? + let parentKnowledgeBaseId: String? + let title: String? + let createdFrom: String? + let modelMode: String? + let modelId: String? + let isPinned: Bool? + let isArchived: Bool? + let isDeleted: Bool? + let lastMessageAt: String? + let createdAt: String? + let updatedAt: String? +} + +struct ChatMessageResponse: Codable, Identifiable { + let id: String + let sessionId: String? + let role: String + let content: String + let tokens: Int? + let scopeSnapshot: ChatScopeSnapshot? + let createdAt: String? + let citations: [ChatCitationResponse]? +} + +struct ChatScopeSnapshot: Codable { + let scopeType: String? + let scopeId: String? + let parentKnowledgeBaseId: String? +} + +struct ChatCitationResponse: Codable, Identifiable { + let id: String + let messageId: String? + let chunkId: String? + let sourceId: String? + let sourceTitle: String? + let excerptText: String? + let pageNumber: Int? + let lineStart: Int? + let lineEnd: Int? + let createdAt: String? +} + +struct SendMessageResponse: Codable { + let id: String? + let role: String? + let content: String? + let tokens: Int? + let blocked: Bool? + let message: ChatMessageResponse? + let citations: [ChatCitationResponse]? +} + +struct PaginatedChatSessions: Codable { + let data: [ChatSessionResponse] + let meta: PaginationMeta +} + +// MARK: - SSE Chunk + +struct SSEChunk: Decodable { + let type: String // "thinking" | "content" | "citations" | "done" | "error" + let content: String? + let citations: [ChatCitationResponse]? + let error: String? +} +``` + +--- + +## 7. 错误响应格式 + +所有错误统一格式: + +```json +{ + "statusCode": 400, + "message": "scopeType must be one of: knowledge_base, folder, material, knowledge_item, global", + "error": "Bad Request" +} +``` + +| HTTP Code | 场景 | +|-----------|------| +| 400 | 参数校验失败 | +| 401 | JWT 缺失/过期 | +| 403 | 会话不属于当前用户 | +| 404 | 会话不存在 | +| 413 | content 超过 10000 字符 | +| 500 | 服务端未知错误 | + +--- + +## 8. 版本兼容性 + +| 版本 | 日期 | 变更 | +|------|------|------| +| v1.0 | 2026-06-06 | 初始版本 — 完整 ChatScope API | + +向后兼容策略: +- 新增字段:前端未传 → 使用默认值 +- 新增响应字段:前端忽略未知字段(Codable 默认行为) +- 不可变字段:PATCH 时静默忽略(不报错) + +--- + +## 9. 依赖 + +``` +本文档依赖: + chat-scope-design.md (CHAT-001) — ChatScope 类型定义、规则、决策表、open-or-create 算法 + +本文档被依赖: + #81 M7-05 — createSession 实现 + #76 M7-08 — open-or-create 实现 + #75 M7-07 — listSessions 实现 + #45-#50 — iOS AI Chat View + #39-#44 — iOS 入口接入 +``` + +--- + +> **本文档是前后端接口的唯一权威契约。如有冲突,以本文档为准。** diff --git a/docs/chat-scope-design.md b/docs/chat-scope-design.md new file mode 100644 index 0000000..292df06 --- /dev/null +++ b/docs/chat-scope-design.md @@ -0,0 +1,635 @@ +# ChatScope 会话系统设计文档 + +> CHAT-001 | 版本 v1.0 | 2026-06-06 +> +> 本文档是 M-CHAT 里程碑的**权威参考**。所有后端和 iOS 的 issue 均以本文档冻结的规则为准。 + +--- + +## 1. 概述 + +### 1.1 问题 + +当前 AI Chat 只有 `POST /rag-chat/sessions` 接受 `knowledgeBaseId`,无法区分用户是从知识库详情、资料详情还是知识点详情进入的。导致: + +- 同一知识库的不同学习对象混在同一个会话里 +- 无法精准限定检索范围(只检索当前资料 vs 整个知识库) +- 切换资料后仍在旧上下文中继续,回答不相关 + +### 1.2 目标 + +**所有 AI 会话都绑定明确的 ChatScope。同一 scope 继续会话,不同 scope 打开或创建对应会话。前端所有入口必须显式传 scope,AI 页面不再猜测上下文。** + +--- + +## 2. 核心规则 + +``` +规则 1. 会话必须绑定 scopeType + scopeId。 +规则 2. userId + scopeType + scopeId 完全一致,才允许继续上次会话。 +规则 3. scope 变化 → 打开或创建另一个 session,不修改当前 session。 +规则 4. 从知识点详情进入 → 必须 knowledge_item 会话,不能打开知识库会话。 +规则 5. 从资料详情/阅读页进入 → 必须 material 会话。 +规则 6. 从知识库详情进入 → 必须 knowledge_base 会话。 +规则 7. 手动"新对话" → 在当前 scope 下新建会话。 +规则 8. 切换模型 / 深度思考 / 联网搜索 → 不改变 scope,继续当前会话。 +``` + +--- + +## 3. ChatScope 类型定义 + +```typescript +type ChatScopeType = + | "knowledge_base" // 整个知识库 — scopeId = kb.id + | "folder" // 某个分类/目录 — scopeId = folder.id (KnowledgeItem with itemType=folder) + | "material" // 某份资料 — scopeId = knowledgeSource.id + | "knowledge_item" // 某个知识点 — scopeId = knowledgeItem.id + | "global" // 不绑定知识库的普通对话 + +// 未来扩展(本期不做): +// | "multi_source" // 多个来源组合 +// | "temporary_file" // 临时上传文件 +``` + +### 3.1 scope 字段映射 + +| scopeType | scopeId | parentKnowledgeBaseId | 含义 | +|-----------|---------|----------------------|------| +| `knowledge_base` | kb.id | = scopeId | 问整个知识库《计算机网络》 | +| `folder` | folder.id | kb.id | 问分类《TCP/IP 协议》里的内容 | +| `material` | source.id | kb.id | 问资料《数据库事务.pdf》 | +| `knowledge_item` | item.id | kb.id | 问知识点"TCP 三次握手" | +| `global` | null | null | 普通对话,不检索知识库 | + +--- + +## 4. 完整决策表 + +| # | 用户行为 | 当前会话 scope | 目标 scope | 结果 | +|---|---------|-------------|----------|------| +| 1 | 从知识库详情点"AI 对话" | 无 | knowledge_base(KA) | 打开/创建 KA 的 kb 会话 | +| 2 | 从资料详情点"AI 对话" | 无 | material(MA) | 打开/创建 MA 的 material 会话 | +| 3 | 从资料阅读页点"AI 对话" | 无 | material(MA) | 打开/创建 MA 的 material 会话(附加阅读位置) | +| 4 | 从知识点详情点"AI 对话" | 无 | knowledge_item(IA) | 打开/创建 IA 的 item 会话 | +| 5 | 从分类页点"AI 对话" | 无 | folder(FA) | 打开/创建 FA 的 folder 会话 | +| 6 | 在知识库 KA 会话中切知识库 KB | kb(KA) | kb(KB) | 打开/创建 kb(KB) 会话 | +| 7 | 在 material(MA) 会话中切 material(MB) | material(MA) | material(MB) | 打开/创建 material(MB) 会话 | +| 8 | 在 knowledge_item(IA) 会话切知识库 | item(IA) | kb(KA) | 打开/创建 kb(KA) 会话 | +| 9 | 在 material(MA) 会话切知识库 | material(MA) | kb(KA) | 打开/创建 kb(KA) 会话 | +| 10 | 手动点"新对话" | 当前 scope X | scope X | 在 scope X 下新建会话(不继续旧会话) | +| 11 | 切换模型模式 | 当前 scope X | scope X | 继续当前会话 | +| 12 | 开启深度思考 | 当前 scope X | scope X | 继续当前会话 | +| 13 | 切换联网搜索 | 当前 scope X | scope X | 继续当前会话 | +| 14 | 删除会话 | scope X | 无 | 软删除当前会话(isDeleted=true) | +| 15 | 删除知识点 IA | item(IA) 会话存在 | IA 消失 | 会话标记来源已删除 | +| 16 | 删除资料 MA | material(MA) 会话存在 | MA 消失 | 会话标记来源已删除 | +| 17 | 删除知识库 KA | KA 相关会话存在 | KA 消失 | 知识库级会话也标记来源已删除 | + +--- + +## 5. 数据模型 + +### 5.1 ChatSession(目标 Schema) + +```prisma +model ChatSession { + id String @id @default(cuid()) + userId String + + // === 核心 scope 字段 === + scopeType String @default("knowledge_base") @db.VarChar(32) + scopeId String? + parentKnowledgeBaseId String? + + // === 元数据 === + title String @default("新对话") @db.VarChar(200) + createdFrom String @default("global_ai_entry") @db.VarChar(32) + // ^ "knowledge_base_detail" | "material_detail" | "material_reader" + // | "knowledge_item_detail" | "folder_detail" | "global_ai_entry" + isPinned Boolean @default(false) + isArchived Boolean @default(false) + isDeleted Boolean @default(false) + + // === 模型设置 === + modelMode String @default("normal") @db.VarChar(16) + // ^ "normal" | "deep_think" | "web_search" + modelId String? @db.VarChar(64) + + // === 时间 === + lastMessageAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + messages ChatMessage[] + + @@index([userId]) + @@index([userId, scopeType, scopeId]) // open-or-create 核心查询 + @@index([userId, parentKnowledgeBaseId]) // 知识库内会话列表 + @@index([userId, isDeleted]) // 排除已删除会话 +} +``` + +### 5.2 ChatMessage(新增字段) + +```prisma +model ChatMessage { + id String @id @default(cuid()) + sessionId String + role String @db.VarChar(16) // "user" | "assistant" + content String @db.LongText + tokens Int @default(0) + + // === scope 快照(消息级别不可变记录) === + scopeSnapshot Json? // { scopeType, scopeId, parentKnowledgeBaseId } + + createdAt DateTime @default(now()) + + session ChatSession @relation(fields: [sessionId], references: [id]) + citations ChatCitation[] + + @@index([sessionId]) +} +``` + +### 5.3 ChatCitation(完善字段) + +```prisma +model ChatCitation { + id String @id @default(cuid()) + messageId String + chunkId String? + sourceId String? + sourceTitle String? @db.VarChar(255) + excerptText String? @db.VarChar(2000) + + // === 精确定位 === + pageNumber Int? + lineStart Int? + lineEnd Int? + + createdAt DateTime @default(now()) + + message ChatMessage @relation(fields: [messageId], references: [id]) + + @@index([messageId]) + @@index([sourceId]) + @@index([createdAt]) +} +``` + +### 5.4 字段变更汇总 + +| 模型 | 变更 | 字段 | 类型 | Issue | +|------|------|------|------|-------| +| ChatSession | **新增** | scopeType | String @default("knowledge_base") | #79 | +| ChatSession | **新增** | scopeId | String? | #79 | +| ChatSession | **新增** | parentKnowledgeBaseId | String? | #79 | +| ChatSession | **新增** | createdFrom | String @default("global_ai_entry") | #92 | +| ChatSession | **新增** | isPinned | Boolean @default(false) | #93 | +| ChatSession | **新增** | isArchived | Boolean @default(false) | #93 | +| ChatSession | **新增** | isDeleted | Boolean @default(false) | #92 | +| ChatSession | **新增** | modelMode | String @default("normal") | (附带) | +| ChatSession | **新增** | modelId | String? | (附带) | +| ChatSession | **新增** | lastMessageAt | DateTime? | (附带) | +| ChatSession | **移除** | knowledgeBaseId | — | #79 | +| ChatSession | **移除** | knowledgeItemIds | — | #79 | +| ChatMessage | **新增** | scopeSnapshot | Json? | #73 | +| ChatCitation | **新增** | lineStart | Int? | #72 | +| ChatCitation | **新增** | lineEnd | Int? | #72 | +| ChatCitation | **新增** | @@index([sourceId]) | — | #72 | + +--- + +## 6. API 契约 + +### 6.1 创建会话 — open-or-create + +``` +POST /rag-chat/sessions +``` + +**Request Body:** +```json +{ + "scopeType": "material", + "scopeId": "clx7abc123", + "parentKnowledgeBaseId": "clx7kb456", + "createdFrom": "material_detail", + "title": "新对话" +} +``` + +**Behavior (open-or-create):** + +``` +1. 查询现有会话: + SELECT * FROM ChatSession + WHERE userId = ? AND scopeType = ? AND scopeId = ? + AND isDeleted = false + ORDER BY updatedAt DESC LIMIT 1 + +2. 如果有 → 返回该会话(继续上次) +3. 如果没有 → 创建新会话并返回 + +4. scope 不可变规则: + - scopeType + scopeId 一旦在创建时设定,PATCH 不可修改 + - parentKnowledgeBaseId 由后端从 scope 推导,不可由前端覆盖 +``` + +**Response:** `ChatSession` + +### 6.2 会话列表 + +``` +GET /rag-chat/sessions +``` + +**Query Parameters:** + +| 参数 | 类型 | 必需 | 说明 | +|------|------|------|------| +| scopeType | string | 否 | 过滤 scope 类型 | +| scopeId | string | 否 | 过滤 scope ID | +| parentKnowledgeBaseId | string | 否 | 查询知识库下所有会话 | +| isArchived | boolean | 否 | 默认 false(不返回归档) | +| page | number | 否 | 分页,默认 1 | +| limit | number | 否 | 分页,默认 20 | + +**示例:** + +```bash +# 获取某个知识库下的所有会话(不分 scope) +GET /rag-chat/sessions?parentKnowledgeBaseId=clx7kb456&page=1&limit=20 + +# 获取当前 material 的历史会话 +GET /rag-chat/sessions?scopeType=material&scopeId=clx7abc123 + +# 获取全局历史 +GET /rag-chat/sessions?page=1&limit=50 +``` + +### 6.3 发消息 + +``` +POST /rag-chat/sessions/:id/messages # 同步 +POST /rag-chat/sessions/:id/stream # SSE 流式 +``` + +**Request Body(不变):** +```json +{ "content": "TCP 三次握手的过程是什么?" } +``` + +注意:发消息只需要 `sessionId` + `content`。scope 信息已在创建会话时绑定。 + +### 6.4 会话操作 + +``` +DELETE /rag-chat/sessions/:id # 软删除 (isDeleted = true) +PATCH /rag-chat/sessions/:id # 更新 title / isPinned / isArchived / modelMode +``` + +**PATCH Body:** +```json +{ + "title": "三次握手讨论", + "isPinned": true, + "isArchived": false, + "modelMode": "deep_think" +} +``` + +scopeType / scopeId 不可通过 PATCH 修改。 + +--- + +## 7. open-or-create 算法 + +```typescript +async openOrCreateSession( + userId: string, + scope: ChatScope, + createdFrom: string, +): Promise { + // 1. 查找匹配的活跃会话 + const existing = await prisma.chatSession.findFirst({ + where: { + userId, + scopeType: scope.scopeType, + scopeId: scope.scopeId ?? null, + isDeleted: false, + }, + orderBy: { updatedAt: 'desc' }, + }); + + if (existing) { + return existing; + } + + // 2. 创建新会话 + return prisma.chatSession.create({ + data: { + userId, + scopeType: scope.scopeType, + scopeId: scope.scopeId ?? null, + parentKnowledgeBaseId: deriveParentKbId(scope), + createdFrom, + title: '新对话', + }, + }); +} + +function deriveParentKbId(scope: ChatScope): string | null { + switch (scope.scopeType) { + case 'knowledge_base': + return scope.scopeId; + case 'material': + case 'folder': + case 'knowledge_item': + return scope.parentKnowledgeBaseId ?? null; + case 'global': + return null; + } +} +``` + +--- + +## 8. 上下文检索策略 (loadContext) + +### 8.1 按 scope 决定检索范围 + +| scopeType | 检索范围 | 实现方式 | +|-----------|---------|---------| +| `knowledge_base` | 整个知识库的所有 chunk | `WHERE kbId = parentKnowledgeBaseId` | +| `folder` | 该 folder 及子节点的所有 chunk | 先查 folder 下所有 item.id,再 `WHERE knowledgeItemId IN (...)` | +| `material` | 只检索该资料的 chunk | `WHERE sourceId = scopeId` | +| `knowledge_item` | 只检索该知识点的 chunk | `WHERE knowledgeItemId = scopeId` | +| `global` | 无知识库上下文 | 不检索,纯模型回答 | + +### 8.2 检索实现伪代码 + +```typescript +async loadContext(session: ChatSession): Promise { + switch (session.scopeType) { + case 'knowledge_base': + // 检索该知识库下的所有 chunk + return this.vectorSearch.search({ + filter: { knowledgeBaseId: session.parentKnowledgeBaseId }, + topK: 10, + }); + + case 'material': + // 只检索该资料的 chunk + return this.vectorSearch.search({ + filter: { sourceId: session.scopeId }, + topK: 10, + }); + + case 'knowledge_item': + // 只检索该知识点的 chunk + return this.vectorSearch.search({ + filter: { knowledgeItemId: session.scopeId }, + topK: 5, + }); + + case 'folder': + // 检索该 folder 下所有 item 的 chunk + const itemIds = await this.getFolderItemIds(session.scopeId!); + return this.vectorSearch.search({ + filter: { knowledgeItemId: { in: itemIds } }, + topK: 10, + }); + + case 'global': + return []; // 不检索 + } +} +``` + +--- + +## 9. iOS 集成 + +### 9.1 ChatEntryContext 模型 + +```swift +struct ChatEntryContext { + let scopeType: ChatScopeType + let scopeId: String? + let scopeName: String // 显示名,如"计算机网络" 或 "TCP 三次握手.md" + let parentKnowledgeBaseId: String? + let createdFrom: String // "knowledge_base_detail" | "material_detail" | ... +} +``` + +### 9.2 6 个入口 + +| # | 入口页面 | scopeType | scopeId = | parentKB | createdFrom | +|---|---------|-----------|-----------|----------|-------------| +| 1 | 知识库详情 → "AI 对话" | knowledge_base | kb.id | kb.id | knowledge_base_detail | +| 2 | 资料详情 → "AI 对话" | material | source.id | kb.id | material_detail | +| 3 | 资料阅读页 → "AI 对话" | material | source.id | kb.id | material_reader | +| 4 | 知识点详情 → "AI 对话" | knowledge_item | item.id | kb.id | knowledge_item_detail | +| 5 | 分类页 → "AI 对话" | folder | folder.id | kb.id | folder_detail | +| 6 | Tab/Main → "AI 对话" | global | null | null | global_ai_entry | + +### 9.3 Route 参数 + +```swift +// Route.swift +case aiChat(context: ChatEntryContext) + +// 调用示例 +Route.aiChat(context: ChatEntryContext( + scopeType: .material, + scopeId: source.id, + scopeName: source.title ?? "资料", + parentKnowledgeBaseId: kb.id, + createdFrom: "material_detail" +)) +``` + +### 9.4 AI Chat View 集成 + +AIChatPage 接受 `ChatEntryContext`,在 `.task` 中调用 `open-or-create`: + +```swift +// AIChatViewModel +func load(entry: ChatEntryContext) async { + let session = try await RagChatService.shared.openOrCreateSession(entry: entry) + self.sessionId = session.id + await loadMessages(session.id) +} +``` + +--- + +## 10. 迁移计划 + +### 10.1 现有数据 + +当前 `ChatSession` 表约 23 个会话,字段为: + +```prisma +model ChatSession { + id String + userId String + knowledgeBaseId String + title String + knowledgeItemIds Json? +} +``` + +### 10.2 迁移步骤 + +1. **Prisma migration** — 添加新字段(`scopeType`, `scopeId`, `parentKnowledgeBaseId`, `createdFrom`, `isPinned`, `isArchived`, `isDeleted`, `modelMode`, `modelId`, `lastMessageAt`),所有新增字段有默认值,不破坏现有数据 +2. **数据回填脚本** — 对每个旧会话,设置: + - `scopeType = "knowledge_base"` + - `scopeId = knowledgeBaseId` + - `parentKnowledgeBaseId = knowledgeBaseId` + - `createdFrom = "legacy_migration"` +3. **迁移后** — 移除旧的 `knowledgeBaseId` 和 `knowledgeItemIds` 列(可选,可保留) + +### 10.3 回填 SQL + +```sql +UPDATE "ChatSession" +SET + "scopeType" = 'knowledge_base', + "scopeId" = "knowledgeBaseId", + "parentKnowledgeBaseId" = "knowledgeBaseId", + "createdFrom" = 'legacy_migration', + "lastMessageAt" = "updatedAt" +WHERE "scopeType" = 'knowledge_base' -- 默认值 + AND "scopeId" IS NULL; +``` + +--- + +## 11. 边界情况处理 + +| 情况 | 处理方式 | +|------|---------| +| 来源被删除(资料/知识点已删除) | 会话保留但标记 `isArchived = true`,前端显示"来源已删除" | +| 知识库被删除 | 其下所有会话自动 `isArchived = true` | +| scopeId 为空字符串 | 视为 null,等同于 global | +| 同一 scope 多次点"新对话" | 每次都创建新会话,不做去重 | +| 网络离线 | iOS 本地缓存会话列表,恢复后同步 | +| 并发创建 | 数据库唯一索引 `[userId, scopeType, scopeId, isDeleted]` 不做唯一约束;同一 scope 可以有多个会话(用户手动新对话) | +| 切换 scope 但保留消息 | 不迁移消息,scope 变化 = 新会话 | + +--- + +## 12. 依赖关系 + +``` +#82 CHAT-001 (本文档) + ├── #83 CHAT-002 (API Contract — 独立文档) + ├── #79 M7-01 (Prisma scope 字段) + │ ├── #92 M7-02a (createdFrom + isDeleted) + │ └── #93 M7-02b (isArchived + isPinned) + ├── #81 M7-05 (createSession 接受 scope) + │ ├── #76 M7-08 (open-or-create) + │ │ └── #85 CHAT-204 (scope 不可变) + │ ├── #75 M7-07 (listSessions 过滤) + │ └── #84 CHAT-203 (自动标题) + ├── #74 M7-06 (loadContext 按 scope) + │ ├── #86 CHAT-302 (item 检索) + │ ├── #87 CHAT-303 (material 检索) + │ └── #88 CHAT-304 (kb 检索) + ├── #72 M7-03 (ChatCitation) + ├── #73 M7-04 (ChatMessage scopeSnapshot) + ├── #94-96 (索引) + ├── #78 M7-10 (迁移) + └── #89-#91 (测试) +``` + +--- + +## 附录 A: 完整的 ChatScope 类型定义 (TypeScript) + +```typescript +// === ChatScope 类型 === + +export const CHAT_SCOPE_TYPES = [ + 'knowledge_base', + 'folder', + 'material', + 'knowledge_item', + 'global', +] as const; + +export type ChatScopeType = (typeof CHAT_SCOPE_TYPES)[number]; + +export interface ChatScope { + scopeType: ChatScopeType; + scopeId: string | null; + parentKnowledgeBaseId: string | null; +} + +// === 从 scope 推导 parentKnowledgeBaseId === + +export function deriveParentKbId(scope: ChatScope): string | null { + switch (scope.scopeType) { + case 'knowledge_base': + return scope.scopeId; + case 'material': + case 'folder': + case 'knowledge_item': + return scope.parentKnowledgeBaseId ?? null; + case 'global': + return null; + } +} + +// === createdFrom 枚举 === + +export const CREATED_FROM_VALUES = [ + 'knowledge_base_detail', + 'material_detail', + 'material_reader', + 'knowledge_item_detail', + 'folder_detail', + 'global_ai_entry', + 'legacy_migration', +] as const; + +export type CreatedFrom = (typeof CREATED_FROM_VALUES)[number]; +``` + +## 附录 B: 完整的 iOS 类型定义 (Swift) + +```swift +// MARK: - ChatScope + +enum ChatScopeType: String, Codable { + case knowledgeBase = "knowledge_base" + case folder = "folder" + case material = "material" + case knowledgeItem = "knowledge_item" + case global = "global" +} + +struct ChatEntryContext { + let scopeType: ChatScopeType + let scopeId: String? + let scopeName: String + let parentKnowledgeBaseId: String? + let createdFrom: String +} + +// API Request +struct CreateChatSessionRequest: Codable { + let scopeType: String + let scopeId: String? + let parentKnowledgeBaseId: String? + let createdFrom: String + let title: String? +} +``` + +--- + +> **本文档是 M-CHAT 里程碑的唯一权威参考。如有冲突,以本文档为准。** diff --git a/docs/chat-scope-test-plan.md b/docs/chat-scope-test-plan.md new file mode 100644 index 0000000..4986057 --- /dev/null +++ b/docs/chat-scope-test-plan.md @@ -0,0 +1,113 @@ +# ChatScope 测试计划 + +> CHAT-702/703/704 | 2026-06-06 + +--- + +## 1. Scope 回归测试 (#89 CHAT-702) + +### 1.1 创建会话 — open-or-create + +| # | 测试用例 | 预期 | +|---|---------|------| +| 1 | 新建 material scope 会话 | HTTP 201, scopeType=material, scopeId=sourceId | +| 2 | 相同 scope 再次调用 | HTTP 200, 返回同一个 session id | +| 3 | 不同 scopeId 调用 | HTTP 201, 创建新会话 | +| 4 | global scope 调用 | HTTP 201, scopeId=null, 每次都新建 | +| 5 | scopeType 不合法 | HTTP 400 | +| 6 | scopeType=material 但 scopeId 为空 | HTTP 400 | + +### 1.2 会话列表 — scope 过滤 + +| # | 测试用例 | 预期 | +|---|---------|------| +| 7 | scopeType + scopeId 精确过滤 | 只返回匹配的会话 | +| 8 | parentKnowledgeBaseId 过滤 | 返回该 KB 下所有会话 | +| 9 | 无过滤(全局列表) | 返回用户所有未删除会话 | +| 10 | isDeleted 会话不出现在列表 | 过滤掉 | + +### 1.3 发送消息 — scope 快照 + +| # | 测试用例 | 预期 | +|---|---------|------| +| 11 | 发消息后检查 scopeSnapshot | 用户消息和 AI 消息都有正确的 scopeSnapshot | +| 12 | 历史消息查询 | 每条消息都带 scopeSnapshot | + +### 1.4 检索范围 + +| # | 测试用例 | 预期 | +|---|---------|------| +| 13 | material scope: 只检索 sourceRef=scopeId 的内容 | 检索结果只来自该资料 | +| 14 | knowledge_item scope: 只检索 id=scopeId 的内容 | 检索结果只来自该知识点 | +| 15 | knowledge_base scope: 检索整个知识库 | 检索结果覆盖整个 KB | +| 16 | global scope: 不检索 | 返回空上下文 | + +### 1.5 会话管理 + +| # | 测试用例 | 预期 | +|---|---------|------| +| 17 | PATCH 更新 title | 成功更新 | +| 18 | PATCH 尝试修改 scopeType | 静默忽略,scopeType 不变 | +| 19 | PATCH 尝试修改 scopeId | 静默忽略,scopeId 不变 | +| 20 | DELETE 软删除 | isDeleted=true,消息保留 | +| 21 | 软删除后 open-or-create | 创建新会话,不返回已删除的 | + +--- + +## 2. 防上下文污染测试 (#90 CHAT-703) + +### 2.1 跨 scope 隔离 + +| # | 测试用例 | 步骤 | 预期 | +|---|---------|------|------| +| 22 | 资料 A 和资料 B 不串 | 1. 在 material/A 会话问"这篇文章讲了什么" 2. 切到 material/B 会话问"和之前那篇比呢" | AI 不知道 A 的内容 | +| 23 | 知识点和知识库不串 | 1. 在 knowledge_item/I 会话问 2. 切到 knowledge_base 会话继续问 | AI 应能检索整个 KB | +| 24 | 全局和绑定不串 | 1. 在 knowledge_base/K 会话问 2. 切到 global 会话问"刚才那个知识库里有什么" | AI 不知道之前的对话 | + +### 2.2 并发会话 + +| # | 测试用例 | 预期 | +|---|---------|------| +| 25 | 同一 scope 多会话(手动新对话创建多个) | 每个会话的消息互不干扰 | +| 26 | 快速切换会话发消息 | 每条消息写入正确的 sessionId | + +--- + +## 3. 来源删除测试 (#91 CHAT-704) + +### 3.1 知识库删除 + +| # | 测试用例 | 步骤 | 预期 | +|---|---------|------|------| +| 27 | 知识库被删除后 | 1. 创建 kb/K 的会话 2. 删除知识库 K 3. 打开会话 | 会话仍存在,需手动处理 | +| 28 | 知识库被删除后检索 | 同上,发消息 | 检索结果为空,提示知识库已删除 | + +### 3.2 资料/知识点删除 + +| # | 测试用例 | 步骤 | 预期 | +|---|---------|------|------| +| 29 | 资料被删除后 | 1. 创建 material/M 的会话 2. 删除资料 M 3. 打开会话 | 会话仍然存在 | +| 30 | 资料被删除后检索 | 同上,发消息 | 检索不到被删资料的内容 | +| 31 | 知识点被删除后 | 1. 创建 knowledge_item/I 的会话 2. 删除知识点 I 3. 打开会话 | 会话仍然存在,标题不变 | +| 32 | 知识点被删除后检索 | 同上,发消息 | 检索结果为空 | + +### 3.3 恢复 + +| # | 测试用例 | 预期 | +|---|---------|------| +| 33 | 删除资料后重新导入同名资料 | scopeId 不同,不会关联到旧会话 | + +--- + +## 执行方式 + +```bash +# 后端启动后在本地执行 +npx jest --config jest.config.ts --testPathPattern="rag-chat" + +# 或手动 curl 测试 +curl -X POST http://localhost:3000/rag-chat/sessions \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"scopeType":"material","scopeId":"test_source_id","createdFrom":"material_detail"}' +``` diff --git a/prisma/migrations/backfill_chat_scope.sql b/prisma/migrations/backfill_chat_scope.sql new file mode 100644 index 0000000..63bb14b --- /dev/null +++ b/prisma/migrations/backfill_chat_scope.sql @@ -0,0 +1,20 @@ +-- ============================================================ +-- M7-10: ChatScope 旧数据回填 +-- 将 M-CHAT 里程碑前创建的旧会话补全 scope 字段 +-- 可重复执行(idempotent):只更新 scopeId IS NULL 的记录 +-- ============================================================ + +-- 1. 回填 scope 字段 +UPDATE "ChatSession" +SET + "scopeType" = 'knowledge_base', + "scopeId" = "knowledgeBaseId", + "parentKnowledgeBaseId" = "knowledgeBaseId", + "createdFrom" = 'legacy_migration', + "lastMessageAt" = COALESCE("lastMessageAt", "updatedAt") +WHERE "scopeId" IS NULL + AND "scopeType" = 'knowledge_base'; -- 默认值 + +-- 2. 验证 +-- SELECT COUNT(*) AS backfilled_count FROM "ChatSession" WHERE "createdFrom" = 'legacy_migration'; +-- SELECT id, title, "scopeType", "scopeId", "parentKnowledgeBaseId", "createdFrom" FROM "ChatSession" WHERE "scopeId" IS NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 501f332..b2000c3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -997,27 +997,42 @@ model AdminMessage { } model ChatSession { - id String @id @default(cuid()) - userId String - knowledgeBaseId String - title String @default("新对话") @db.VarChar(200) - knowledgeItemIds Json? @default("[]") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + userId String + knowledgeBaseId String? + scopeType String @default("knowledge_base") @db.VarChar(32) + scopeId String? + parentKnowledgeBaseId String? + title String @default("新对话") @db.VarChar(200) + knowledgeItemIds Json? @default("[]") + createdFrom String @default("legacy_migration") @db.VarChar(32) + isPinned Boolean @default(false) + isArchived Boolean @default(false) + isDeleted Boolean @default(false) + modelMode String @default("normal") @db.VarChar(16) + modelId String? @db.VarChar(64) + lastMessageAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt messages ChatMessage[] @@index([userId]) @@index([knowledgeBaseId]) + @@index([userId, scopeType, scopeId]) + @@index([userId, parentKnowledgeBaseId]) + @@index([userId, isDeleted]) + @@index([lastMessageAt]) } model ChatMessage { - id String @id @default(cuid()) - sessionId String - role String @db.VarChar(16) - content String @db.LongText - tokens Int @default(0) - createdAt DateTime @default(now()) + id String @id @default(cuid()) + sessionId String + role String @db.VarChar(16) + content String @db.LongText + tokens Int @default(0) + scopeSnapshot Json? + createdAt DateTime @default(now()) session ChatSession @relation(fields: [sessionId], references: [id]) citations ChatCitation[] @@ -1033,11 +1048,14 @@ model ChatCitation { sourceTitle String? @db.VarChar(255) excerptText String? @db.VarChar(2000) pageNumber Int? + lineStart Int? + lineEnd Int? createdAt DateTime @default(now()) message ChatMessage @relation(fields: [messageId], references: [id]) @@index([messageId]) + @@index([sourceId]) @@index([createdAt]) } diff --git a/src/modules/import-candidate/import-candidate.service.ts b/src/modules/import-candidate/import-candidate.service.ts index 8e1c157..39920bc 100644 --- a/src/modules/import-candidate/import-candidate.service.ts +++ b/src/modules/import-candidate/import-candidate.service.ts @@ -37,12 +37,13 @@ export class ImportCandidateService { await this.repository.updateStatus(id, 'ACCEPTED'); - // 生成 KnowledgeItem + // 生成 KnowledgeItem,关联到来源 await this.itemsRepo.create(candidate.userId, candidate.knowledgeBaseId, { title: candidate.title, content: (candidate.content as string) ?? '', itemType: 'ai_generated', orderIndex: candidate.orderIndex, + sourceRef: (candidate as any).sourceId ?? null, }); return { status: 'ACCEPTED' }; diff --git a/src/modules/knowledge-items/knowledge-items.repository.ts b/src/modules/knowledge-items/knowledge-items.repository.ts index fb78bb8..4e0eb6f 100644 --- a/src/modules/knowledge-items/knowledge-items.repository.ts +++ b/src/modules/knowledge-items/knowledge-items.repository.ts @@ -50,6 +50,7 @@ export class KnowledgeItemsRepository { parentId?: string; itemType?: string; sourceType?: string; + sourceRef?: string; durationSeconds?: number; fileSize?: number; orderIndex?: number; @@ -64,6 +65,7 @@ export class KnowledgeItemsRepository { parentId: dto.parentId ?? null, itemType: dto.itemType ?? 'lesson', sourceType, + sourceRef: dto.sourceRef ?? null, durationSeconds: dto.durationSeconds ?? 0, fileSize: dto.fileSize ?? null, orderIndex: dto.orderIndex ?? 0, @@ -112,7 +114,7 @@ export class KnowledgeItemsRepository { async update(id: string, dto: Record) { // Whitelist allowed fields to prevent mass assignment - const allowed = ['title', 'content', 'summary', 'parentId', 'itemType', 'sourceType', 'orderIndex', 'status', 'durationSeconds']; + const allowed = ['title', 'content', 'summary', 'parentId', 'itemType', 'sourceType', 'sourceRef', 'orderIndex', 'status', 'durationSeconds']; const data: Record = {}; for (const key of allowed) { if (dto[key] !== undefined) data[key] = dto[key]; diff --git a/src/modules/rag-chat/rag-chat.controller.ts b/src/modules/rag-chat/rag-chat.controller.ts index a71dae6..732b6c4 100644 --- a/src/modules/rag-chat/rag-chat.controller.ts +++ b/src/modules/rag-chat/rag-chat.controller.ts @@ -1,7 +1,8 @@ -import { Controller, Get, Post, Delete, Body, Param, Res, Query } from '@nestjs/common'; +import { Controller, Get, Post, Patch, Delete, Body, Param, Res, Query, HttpCode } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import type { Response } from 'express'; import { RagChatService } from './rag-chat.service'; +import type { CreateSessionResult } from './rag-chat.service'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import type { UserPayload } from '../../common/types'; @@ -12,21 +13,55 @@ export class RagChatController { constructor(private readonly svc: RagChatService) {} @Post('sessions') - @ApiOperation({ summary: '创建对话' }) - async createSession(@CurrentUser() user: UserPayload, @Body() dto: { knowledgeBaseId: string; title?: string; knowledgeItemIds?: string[] }) { - return this.svc.createSession(String(user.id), dto.knowledgeBaseId, dto.title, dto.knowledgeItemIds); + @ApiOperation({ summary: '创建/打开对话 (open-or-create)' }) + async createSession( + @CurrentUser() user: UserPayload, + @Res({ passthrough: true }) res: Response, + @Body() dto: { + scopeType: string; + scopeId?: string | null; + parentKnowledgeBaseId?: string | null; + createdFrom?: string; + title?: string; + }, + ) { + const result: CreateSessionResult = await this.svc.createSession(String(user.id), { + scopeType: dto.scopeType, + scopeId: dto.scopeId ?? null, + parentKnowledgeBaseId: dto.parentKnowledgeBaseId ?? null, + createdFrom: dto.createdFrom, + title: dto.title, + }); + + res.status(result.isNew ? 201 : 200); + return result.session; } @Get('sessions') @ApiOperation({ summary: '对话列表' }) - async listSessions(@CurrentUser() user: UserPayload, @Query('knowledgeBaseId') kbId?: string) { - return this.svc.listSessions(String(user.id), kbId); + async listSessions( + @CurrentUser() user: UserPayload, + @Query('scopeType') scopeType?: string, + @Query('scopeId') scopeId?: string, + @Query('parentKnowledgeBaseId') parentKnowledgeBaseId?: string, + @Query('knowledgeBaseId') kbId?: string, + @Query('page') page?: string, + @Query('limit') limit?: string, + ) { + return this.svc.listSessions(String(user.id), { + scopeType, + scopeId, + parentKnowledgeBaseId, + kbId, + page: page ? parseInt(page, 10) : undefined, + limit: limit ? parseInt(limit, 10) : undefined, + }); } @Get('sessions/:id/messages') @ApiOperation({ summary: '对话历史' }) - async messages(@Param('id') id: string) { - return this.svc.getMessages(id); + async messages(@CurrentUser() user: UserPayload, @Param('id') id: string) { + return this.svc.getMessages(String(user.id), id); } @Post('sessions/:id/messages') @@ -51,9 +86,25 @@ export class RagChatController { res.end(); } + @Patch('sessions/:id') + @ApiOperation({ summary: '更新会话属性' }) + async updateSession( + @CurrentUser() user: UserPayload, + @Param('id') id: string, + @Body() dto: { + title?: string; + isPinned?: boolean; + isArchived?: boolean; + modelMode?: string; + modelId?: string | null; + }, + ) { + return this.svc.updateSession(String(user.id), id, dto); + } + @Delete('sessions/:id') - @ApiOperation({ summary: '删除对话' }) - async deleteSession(@Param('id') id: string) { - return this.svc.deleteSession(id); + @ApiOperation({ summary: '删除对话(软删除)' }) + async deleteSession(@CurrentUser() user: UserPayload, @Param('id') id: string) { + return this.svc.deleteSession(String(user.id), id); } } diff --git a/src/modules/rag-chat/rag-chat.service.ts b/src/modules/rag-chat/rag-chat.service.ts index e64c854..c2d3f9a 100644 --- a/src/modules/rag-chat/rag-chat.service.ts +++ b/src/modules/rag-chat/rag-chat.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, Logger, Optional } from '@nestjs/common'; +import { Injectable, NotFoundException, Logger, Optional, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../../infrastructure/database/prisma.service'; import { ContentSafetyService } from '../content-safety/content-safety.service'; import { AiGatewayService } from '../ai/gateway/ai-gateway.service'; @@ -7,6 +7,21 @@ import type { StreamChunk } from '../ai/providers/ai-provider.interface'; const MAX_CONTEXT_CHARS = 4000; +const VALID_SCOPE_TYPES = ['knowledge_base', 'folder', 'material', 'knowledge_item', 'global']; + +export interface CreateSessionParams { + scopeType: string; + scopeId?: string | null; + parentKnowledgeBaseId?: string | null; + createdFrom?: string; + title?: string; +} + +export interface CreateSessionResult { + session: any; + isNew: boolean; +} + @Injectable() export class RagChatService { private readonly logger = new Logger(RagChatService.name); @@ -17,25 +32,101 @@ export class RagChatService { @Optional() private readonly aiGateway?: AiGatewayService, ) {} - async createSession(userId: string, knowledgeBaseId: string, title?: string, knowledgeItemIds?: string[]) { - return this.prisma.chatSession.create({ + async createSession(userId: string, params: CreateSessionParams): Promise { + const { scopeType, scopeId, parentKnowledgeBaseId, createdFrom, title } = params; + + if (!VALID_SCOPE_TYPES.includes(scopeType)) { + throw new BadRequestException(`scopeType must be one of: ${VALID_SCOPE_TYPES.join(', ')}`); + } + if (scopeType !== 'global' && !scopeId) { + throw new BadRequestException('scopeId is required when scopeType is not "global"'); + } + + const derivedKbId = this.deriveParentKbId(scopeType, scopeId, parentKnowledgeBaseId); + + // open-or-create: for non-global scopes, try to find an existing active session + if (scopeType !== 'global' && scopeId) { + const existing = await this.prisma.chatSession.findFirst({ + where: { + userId, + scopeType, + scopeId, + isDeleted: false, + isArchived: false, + }, + orderBy: { updatedAt: 'desc' }, + }); + + if (existing) { + this.logger.log(`open-or-create: FOUND existing session ${existing.id} for scope ${scopeType}/${scopeId}`); + return { session: existing, isNew: false }; + } + } + + // Create new session + const session = await this.prisma.chatSession.create({ data: { userId, - knowledgeBaseId, + knowledgeBaseId: derivedKbId, + scopeType, + scopeId: scopeId ?? null, + parentKnowledgeBaseId: derivedKbId, + createdFrom: createdFrom ?? 'global_ai_entry', title: title || '新对话', - knowledgeItemIds: knowledgeItemIds ?? [], + knowledgeItemIds: [], }, }); + + this.logger.log(`open-or-create: CREATED new session ${session.id} for scope ${scopeType}/${scopeId}`); + return { session, isNew: true }; } - async listSessions(userId: string, kbId?: string) { - return this.prisma.chatSession.findMany({ - where: { userId, ...(kbId ? { knowledgeBaseId: kbId } : {}) }, - orderBy: { updatedAt: 'desc' }, - }); + async listSessions( + userId: string, + opts?: { + scopeType?: string; + scopeId?: string; + parentKnowledgeBaseId?: string; + kbId?: string; + isArchived?: boolean; + page?: number; + limit?: number; + }, + ) { + const where: any = { userId, isDeleted: false }; + + if (opts?.scopeType && opts?.scopeId) { + where.scopeType = opts.scopeType; + where.scopeId = opts.scopeId; + } else if (opts?.parentKnowledgeBaseId) { + where.parentKnowledgeBaseId = opts.parentKnowledgeBaseId; + } else if (opts?.kbId) { + where.knowledgeBaseId = opts.kbId; + } + + if (opts?.isArchived !== undefined) { + where.isArchived = opts.isArchived; + } + + const page = opts?.page ?? 1; + const limit = opts?.limit ?? 20; + + const [data, total] = await Promise.all([ + this.prisma.chatSession.findMany({ + where, + orderBy: [{ isPinned: 'desc' }, { lastMessageAt: 'desc' }], + take: limit, + skip: (page - 1) * limit, + }), + this.prisma.chatSession.count({ where }), + ]); + + return { data, meta: { page, limit, total } }; } - async getMessages(sessionId: string) { + async getMessages(userId: string, sessionId: string) { + const session = await this.prisma.chatSession.findUnique({ where: { id: sessionId } }); + if (!session || session.userId !== userId) throw new NotFoundException('对话不存在'); return this.prisma.chatMessage.findMany({ where: { sessionId }, orderBy: { createdAt: 'asc' }, @@ -53,15 +144,19 @@ export class RagChatService { return { blocked: true, message: '输入包含违规内容,请修改后重试' }; } - // Save user message + // Save user message with scope snapshot + const scopeSnapshot = this.buildScopeSnapshot(session); await this.prisma.chatMessage.create({ - data: { sessionId, role: 'user', content }, + data: { sessionId, role: 'user', content, scopeSnapshot }, }); - // Retrieve knowledge base context - this.logger.log(`RAG: kbId=${session.knowledgeBaseId}, content preview: ${content.substring(0, 30)}`); - const itemIds = (session as any).knowledgeItemIds as string[] | undefined; - const context = await this.loadContext(session.knowledgeBaseId, itemIds?.length ? itemIds : undefined); + // Auto-title from first user message + await this.autoTitle(session, content); + + // Retrieve context by scope + const kbId = session.parentKnowledgeBaseId || session.knowledgeBaseId; + this.logger.log(`RAG: scopeType=${session.scopeType}, scopeId=${session.scopeId}, kbId=${kbId}`); + const context = await this.loadContextByScope(session); this.logger.log(`RAG context: isEmpty=${context.isEmpty}, textLen=${context.text.length}, citations=${context.citations.length}, aiGateway=${!!this.aiGateway}`); // Generate AI response @@ -85,7 +180,6 @@ export class RagChatService { maxTokens: 2048, outputSchema: RagChatOutputSchema, }); - this.logger.log(`AI Gateway response: parsed=${!!resp.parsed}, keys=${resp.parsed ? Object.keys(resp.parsed).join(',') : 'null'}, raw=${JSON.stringify(resp.parsed).substring(0, 300)}`); reply = resp.parsed?.answer ?? String(resp.parsed?.content ?? '抱歉,AI 暂时无法生成回答。'); citations = context.citations; } catch (err: any) { @@ -97,9 +191,9 @@ export class RagChatService { reply = this.fallbackReply(context.isEmpty); } - // Save AI message + // Save AI message with scope snapshot const aiMsg = await this.prisma.chatMessage.create({ - data: { sessionId, role: 'ai', content: reply, tokens: reply.length }, + data: { sessionId, role: 'ai', content: reply, tokens: reply.length, scopeSnapshot }, }); // Save citations @@ -115,7 +209,7 @@ export class RagChatService { } // Update session timestamp - await this.prisma.chatSession.update({ where: { id: sessionId }, data: { updatedAt: new Date() } }); + await this.prisma.chatSession.update({ where: { id: sessionId }, data: { lastMessageAt: new Date() } }); return { message: aiMsg, citations }; } @@ -133,24 +227,17 @@ export class RagChatService { return; } - // Save user message - await this.prisma.chatMessage.create({ data: { sessionId, role: 'user', content } }); + // Save user message with scope snapshot + const scopeSnapshot = this.buildScopeSnapshot(session); + await this.prisma.chatMessage.create({ data: { sessionId, role: 'user', content, scopeSnapshot } }); - // Auto-title: use first user message if title is still default - if (!session.title || session.title === '新对话') { - await this.prisma.chatSession.update({ - where: { id: sessionId }, - data: { title: content.slice(0, 50) }, - }); - } + // Auto-title from first user message + await this.autoTitle(session, content); - // Also auto-title in sendMessage (this is the sync method) - - // Load context - const itemIds = (session as any).knowledgeItemIds as string[] | undefined; - const context = await this.loadContext(session.knowledgeBaseId, itemIds?.length ? itemIds : undefined); - if (!context.text) { - yield { type: 'content', content: this.fallbackReply(true) }; + // Load context by scope + const context = await this.loadContextByScope(session); + if (!this.aiGateway || !context.text) { + yield { type: 'content', content: this.fallbackReply(!context.text) }; } else { const messages = [ { role: 'system' as const, content: this.buildSystemPrompt(context.text) }, @@ -158,7 +245,7 @@ export class RagChatService { ]; let fullContent = ''; - for await (const chunk of this.aiGateway!.generateStream({ + for await (const chunk of this.aiGateway.generateStream({ feature: 'rag-chat', userId, tier: 'primary', promptKey: 'rag-chat', promptVersion: 'v1', messages, maxTokens: 2048, })) { @@ -171,7 +258,7 @@ export class RagChatService { // Save AI reply if (fullContent) { const aiMsg = await this.prisma.chatMessage.create({ - data: { sessionId, role: 'ai', content: fullContent, tokens: fullContent.length }, + data: { sessionId, role: 'ai', content: fullContent, tokens: fullContent.length, scopeSnapshot }, }); for (const c of context.citations.slice(0, 5)) { await this.prisma.chatCitation.create({ @@ -181,32 +268,156 @@ export class RagChatService { } } - await this.prisma.chatSession.update({ where: { id: sessionId }, data: { updatedAt: new Date() } }); + await this.prisma.chatSession.update({ where: { id: sessionId }, data: { lastMessageAt: new Date() } }); } - async deleteSession(sessionId: string) { - await this.prisma.chatCitation.deleteMany({ where: { message: { sessionId } } }); - await this.prisma.chatMessage.deleteMany({ where: { sessionId } }); - await this.prisma.chatSession.delete({ where: { id: sessionId } }); - return { success: true }; + async deleteSession(userId: string, sessionId: string) { + const session = await this.prisma.chatSession.findUnique({ where: { id: sessionId } }); + if (!session || session.userId !== userId) throw new NotFoundException('对话不存在'); + await this.prisma.chatSession.update({ + where: { id: sessionId }, + data: { isDeleted: true }, + }); + return { success: true, message: '会话已删除' }; + } + + async updateSession( + userId: string, + sessionId: string, + dto: { title?: string; isPinned?: boolean; isArchived?: boolean; modelMode?: string; modelId?: string | null }, + ) { + const session = await this.prisma.chatSession.findUnique({ where: { id: sessionId } }); + if (!session || session.userId !== userId) throw new NotFoundException('对话不存在'); + + const data: any = {}; + if (dto.title !== undefined) data.title = dto.title; + if (dto.isPinned !== undefined) data.isPinned = dto.isPinned; + if (dto.isArchived !== undefined) data.isArchived = dto.isArchived; + if (dto.modelMode !== undefined) data.modelMode = dto.modelMode; + if (dto.modelId !== undefined) data.modelId = dto.modelId; + + return this.prisma.chatSession.update({ where: { id: sessionId }, data }); } // ── Private ── - private async loadContext(kbId: string, itemIds?: string[]) { - try { - const items = await this.prisma.knowledgeItem.findMany({ - where: { - knowledgeBaseId: kbId, - deletedAt: null, - ...(itemIds && itemIds.length > 0 ? { id: { in: itemIds } } : {}), - }, - select: { id: true, title: true, content: true, summary: true }, - orderBy: { updatedAt: 'desc' }, - take: 30, + private async autoTitle(session: any, content: string) { + // Only auto-title if the session still has the default title + if (!session.title || session.title === '新对话') { + const title = this.generateTitle(content); + await this.prisma.chatSession.update({ + where: { id: session.id }, + data: { title }, }); + } + } - if (items.length === 0) return { text: '', citations: [], isEmpty: true }; + private generateTitle(content: string): string { + const cleaned = content.trim().replace(/\s+/g, ' '); + if (cleaned.length <= 40) return cleaned; + // Truncate at a word boundary if possible, else hard cut + const truncated = cleaned.slice(0, 40); + const lastSpace = truncated.lastIndexOf(' '); + const cut = lastSpace > 20 ? lastSpace : 40; + return cleaned.slice(0, cut) + '…'; + } + + private buildScopeSnapshot(session: any) { + return { + scopeType: session.scopeType ?? 'knowledge_base', + scopeId: session.scopeId ?? null, + parentKnowledgeBaseId: session.parentKnowledgeBaseId ?? null, + }; + } + + private deriveParentKbId( + scopeType: string, + scopeId: string | null | undefined, + parentKnowledgeBaseId: string | null | undefined, + ): string | null { + switch (scopeType) { + case 'knowledge_base': + return scopeId ?? null; + case 'material': + case 'folder': + case 'knowledge_item': + return parentKnowledgeBaseId ?? null; + case 'global': + return null; + default: + return parentKnowledgeBaseId ?? null; + } + } + + private async loadContextByScope(session: any) { + const scopeType = session.scopeType ?? 'knowledge_base'; + const scopeId = session.scopeId as string | null; + const kbId = session.parentKnowledgeBaseId || session.knowledgeBaseId; + + try { + let items: any[]; + + switch (scopeType) { + case 'global': + // No knowledge base context — pure model answer + return { text: '', citations: [], isEmpty: true }; + + case 'material': + items = await this.prisma.knowledgeItem.findMany({ + where: { + knowledgeBaseId: kbId, + sourceRef: scopeId, + deletedAt: null, + }, + select: { id: true, title: true, content: true, summary: true }, + orderBy: { updatedAt: 'desc' }, + take: 30, + }); + break; + + case 'knowledge_item': + items = await this.prisma.knowledgeItem.findMany({ + where: { + id: scopeId ?? undefined, + deletedAt: null, + }, + select: { id: true, title: true, content: true, summary: true }, + take: 30, + }); + break; + + case 'folder': + // Find all items under this folder (recursive) + items = await this.prisma.knowledgeItem.findMany({ + where: { + knowledgeBaseId: kbId, + parentId: scopeId, + deletedAt: null, + }, + select: { id: true, title: true, content: true, summary: true }, + orderBy: { updatedAt: 'desc' }, + take: 30, + }); + break; + + case 'knowledge_base': + default: + if (scopeType !== 'knowledge_base') { + this.logger.warn(`Unknown scopeType "${scopeType}", falling back to knowledge_base`); + } + items = await this.prisma.knowledgeItem.findMany({ + where: { + knowledgeBaseId: kbId, + deletedAt: null, + }, + select: { id: true, title: true, content: true, summary: true }, + orderBy: { updatedAt: 'desc' }, + take: 30, + }); + break; + } + + if (!items || items.length === 0) return { text: '', citations: [], isEmpty: true }; const parts: string[] = []; const citations: any[] = [];