# 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 里程碑的唯一权威参考。如有冲突,以本文档为准。**