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