All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 46s
## 数据模型 - ChatSession +13 字段 (scopeType/scopeId/parentKnowledgeBaseId/createdFrom/isPinned/isArchived/isDeleted/modelMode/modelId/lastMessageAt) - ChatMessage +scopeSnapshot (消息级 scope 快照) - ChatCitation +lineStart/lineEnd +sourceId 索引 - 5 个新查询索引 ## 核心能力 - open-or-create: 同 scope 继续会话 (200) / 新建 (201) - scope 级检索: global/knowledge_base/material/knowledge_item/folder - listSessions: scope 过滤 + isDeleted 排除 + isPinned 排序 + 分页元数据 - 自动标题: 首条消息截取 + 词边界处理 - 软删除 + 置顶/归档 - scope 字段创建后不可修改 - 全部端点 userId 鉴权 ## 文档 - docs/chat-scope-design.md (设计文档 + 决策表) - docs/chat-scope-api-contract.md (API 契约) - docs/chat-scope-test-plan.md (33 条测试用例) - prisma/migrations/backfill_chat_scope.sql (旧数据回填) ## Bug 修复 - #104: KnowledgeItem.sourceRef 填充 (material scope 检索修复) - #102: sendMessageStream aiGateway null 保护 - listSessions isDeleted/isArchived 过滤 + 分页 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
19 KiB
19 KiB
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 类型定义
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)
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(新增字段)
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(完善字段)
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:
{
"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 |
示例:
# 获取某个知识库下的所有会话(不分 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(不变):
{ "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:
{
"title": "三次握手讨论",
"isPinned": true,
"isArchived": false,
"modelMode": "deep_think"
}
scopeType / scopeId 不可通过 PATCH 修改。
7. open-or-create 算法
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 检索实现伪代码
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 模型
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 参数
// 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:
// 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 个会话,字段为:
model ChatSession {
id String
userId String
knowledgeBaseId String
title String
knowledgeItemIds Json?
}
10.2 迁移步骤
- Prisma migration — 添加新字段(
scopeType,scopeId,parentKnowledgeBaseId,createdFrom,isPinned,isArchived,isDeleted,modelMode,modelId,lastMessageAt),所有新增字段有默认值,不破坏现有数据 - 数据回填脚本 — 对每个旧会话,设置:
scopeType = "knowledge_base"scopeId = knowledgeBaseIdparentKnowledgeBaseId = knowledgeBaseIdcreatedFrom = "legacy_migration"
- 迁移后 — 移除旧的
knowledgeBaseId和knowledgeItemIds列(可选,可保留)
10.3 回填 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)
// === 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)
// 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 里程碑的唯一权威参考。如有冲突,以本文档为准。