feat: M-CHAT ChatScope 会话系统完整实现
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>
This commit is contained in:
wangdl 2026-06-06 17:27:40 +08:00
parent 17f16cea67
commit fe44dec567
9 changed files with 1846 additions and 83 deletions

View File

@ -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<T> {
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 入口接入
```
---
> **本文档是前后端接口的唯一权威契约。如有冲突,以本文档为准。**

635
docs/chat-scope-design.md Normal file
View File

@ -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 打开或创建对应会话。前端所有入口必须显式传 scopeAI 页面不再猜测上下文。**
---
## 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 里程碑的唯一权威参考。如有冲突,以本文档为准。**

View File

@ -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"}'
```

View File

@ -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;

View File

@ -999,9 +999,19 @@ model AdminMessage {
model ChatSession {
id String @id @default(cuid())
userId String
knowledgeBaseId 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
@ -1009,6 +1019,10 @@ model ChatSession {
@@index([userId])
@@index([knowledgeBaseId])
@@index([userId, scopeType, scopeId])
@@index([userId, parentKnowledgeBaseId])
@@index([userId, isDeleted])
@@index([lastMessageAt])
}
model ChatMessage {
@ -1017,6 +1031,7 @@ model ChatMessage {
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])
@ -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])
}

View File

@ -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' };

View File

@ -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<string, any>) {
// 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<string, any> = {};
for (const key of allowed) {
if (dto[key] !== undefined) data[key] = dto[key];

View File

@ -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);
}
}

View File

@ -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({
data: {
userId,
knowledgeBaseId,
title: title || '新对话',
knowledgeItemIds: knowledgeItemIds ?? [],
},
});
async createSession(userId: string, params: CreateSessionParams): Promise<CreateSessionResult> {
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"');
}
async listSessions(userId: string, kbId?: string) {
return this.prisma.chatSession.findMany({
where: { userId, ...(kbId ? { knowledgeBaseId: kbId } : {}) },
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 };
}
}
async getMessages(sessionId: string) {
// Create new session
const session = await this.prisma.chatSession.create({
data: {
userId,
knowledgeBaseId: derivedKbId,
scopeType,
scopeId: scopeId ?? null,
parentKnowledgeBaseId: derivedKbId,
createdFrom: createdFrom ?? 'global_ai_entry',
title: title || '新对话',
knowledgeItemIds: [],
},
});
this.logger.log(`open-or-create: CREATED new session ${session.id} for scope ${scopeType}/${scopeId}`);
return { session, isNew: true };
}
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(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[]) {
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 },
});
}
}
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 {
const items = await this.prisma.knowledgeItem.findMany({
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,
...(itemIds && itemIds.length > 0 ? { id: { in: itemIds } } : {}),
},
select: { id: true, title: true, content: true, summary: true },
orderBy: { updatedAt: 'desc' },
take: 30,
});
break;
if (items.length === 0) return { text: '', citations: [], isEmpty: true };
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[] = [];