# 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<ChatSession> {
  // 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<SearchResult[]> {
  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 里程碑的唯一权威参考。如有冲突，以本文档为准。**
