feat: M-CHAT ChatScope 会话系统完整实现
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 46s
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:
parent
17f16cea67
commit
fe44dec567
712
docs/chat-scope-api-contract.md
Normal file
712
docs/chat-scope-api-contract.md
Normal 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
635
docs/chat-scope-design.md
Normal 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 打开或创建对应会话。前端所有入口必须显式传 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 里程碑的唯一权威参考。如有冲突,以本文档为准。**
|
||||||
113
docs/chat-scope-test-plan.md
Normal file
113
docs/chat-scope-test-plan.md
Normal 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"}'
|
||||||
|
```
|
||||||
20
prisma/migrations/backfill_chat_scope.sql
Normal file
20
prisma/migrations/backfill_chat_scope.sql
Normal 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;
|
||||||
@ -997,27 +997,42 @@ model AdminMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model ChatSession {
|
model ChatSession {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
knowledgeBaseId String
|
knowledgeBaseId String?
|
||||||
title String @default("新对话") @db.VarChar(200)
|
scopeType String @default("knowledge_base") @db.VarChar(32)
|
||||||
knowledgeItemIds Json? @default("[]")
|
scopeId String?
|
||||||
createdAt DateTime @default(now())
|
parentKnowledgeBaseId String?
|
||||||
updatedAt DateTime @updatedAt
|
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
|
||||||
|
|
||||||
messages ChatMessage[]
|
messages ChatMessage[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([knowledgeBaseId])
|
@@index([knowledgeBaseId])
|
||||||
|
@@index([userId, scopeType, scopeId])
|
||||||
|
@@index([userId, parentKnowledgeBaseId])
|
||||||
|
@@index([userId, isDeleted])
|
||||||
|
@@index([lastMessageAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model ChatMessage {
|
model ChatMessage {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
sessionId String
|
sessionId String
|
||||||
role String @db.VarChar(16)
|
role String @db.VarChar(16)
|
||||||
content String @db.LongText
|
content String @db.LongText
|
||||||
tokens Int @default(0)
|
tokens Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
scopeSnapshot Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
session ChatSession @relation(fields: [sessionId], references: [id])
|
session ChatSession @relation(fields: [sessionId], references: [id])
|
||||||
citations ChatCitation[]
|
citations ChatCitation[]
|
||||||
@ -1033,11 +1048,14 @@ model ChatCitation {
|
|||||||
sourceTitle String? @db.VarChar(255)
|
sourceTitle String? @db.VarChar(255)
|
||||||
excerptText String? @db.VarChar(2000)
|
excerptText String? @db.VarChar(2000)
|
||||||
pageNumber Int?
|
pageNumber Int?
|
||||||
|
lineStart Int?
|
||||||
|
lineEnd Int?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
message ChatMessage @relation(fields: [messageId], references: [id])
|
message ChatMessage @relation(fields: [messageId], references: [id])
|
||||||
|
|
||||||
@@index([messageId])
|
@@index([messageId])
|
||||||
|
@@index([sourceId])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -37,12 +37,13 @@ export class ImportCandidateService {
|
|||||||
|
|
||||||
await this.repository.updateStatus(id, 'ACCEPTED');
|
await this.repository.updateStatus(id, 'ACCEPTED');
|
||||||
|
|
||||||
// 生成 KnowledgeItem
|
// 生成 KnowledgeItem,关联到来源
|
||||||
await this.itemsRepo.create(candidate.userId, candidate.knowledgeBaseId, {
|
await this.itemsRepo.create(candidate.userId, candidate.knowledgeBaseId, {
|
||||||
title: candidate.title,
|
title: candidate.title,
|
||||||
content: (candidate.content as string) ?? '',
|
content: (candidate.content as string) ?? '',
|
||||||
itemType: 'ai_generated',
|
itemType: 'ai_generated',
|
||||||
orderIndex: candidate.orderIndex,
|
orderIndex: candidate.orderIndex,
|
||||||
|
sourceRef: (candidate as any).sourceId ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { status: 'ACCEPTED' };
|
return { status: 'ACCEPTED' };
|
||||||
|
|||||||
@ -50,6 +50,7 @@ export class KnowledgeItemsRepository {
|
|||||||
parentId?: string;
|
parentId?: string;
|
||||||
itemType?: string;
|
itemType?: string;
|
||||||
sourceType?: string;
|
sourceType?: string;
|
||||||
|
sourceRef?: string;
|
||||||
durationSeconds?: number;
|
durationSeconds?: number;
|
||||||
fileSize?: number;
|
fileSize?: number;
|
||||||
orderIndex?: number;
|
orderIndex?: number;
|
||||||
@ -64,6 +65,7 @@ export class KnowledgeItemsRepository {
|
|||||||
parentId: dto.parentId ?? null,
|
parentId: dto.parentId ?? null,
|
||||||
itemType: dto.itemType ?? 'lesson',
|
itemType: dto.itemType ?? 'lesson',
|
||||||
sourceType,
|
sourceType,
|
||||||
|
sourceRef: dto.sourceRef ?? null,
|
||||||
durationSeconds: dto.durationSeconds ?? 0,
|
durationSeconds: dto.durationSeconds ?? 0,
|
||||||
fileSize: dto.fileSize ?? null,
|
fileSize: dto.fileSize ?? null,
|
||||||
orderIndex: dto.orderIndex ?? 0,
|
orderIndex: dto.orderIndex ?? 0,
|
||||||
@ -112,7 +114,7 @@ export class KnowledgeItemsRepository {
|
|||||||
|
|
||||||
async update(id: string, dto: Record<string, any>) {
|
async update(id: string, dto: Record<string, any>) {
|
||||||
// Whitelist allowed fields to prevent mass assignment
|
// 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> = {};
|
const data: Record<string, any> = {};
|
||||||
for (const key of allowed) {
|
for (const key of allowed) {
|
||||||
if (dto[key] !== undefined) data[key] = dto[key];
|
if (dto[key] !== undefined) data[key] = dto[key];
|
||||||
|
|||||||
@ -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 { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { RagChatService } from './rag-chat.service';
|
import { RagChatService } from './rag-chat.service';
|
||||||
|
import type { CreateSessionResult } from './rag-chat.service';
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
import type { UserPayload } from '../../common/types';
|
import type { UserPayload } from '../../common/types';
|
||||||
|
|
||||||
@ -12,21 +13,55 @@ export class RagChatController {
|
|||||||
constructor(private readonly svc: RagChatService) {}
|
constructor(private readonly svc: RagChatService) {}
|
||||||
|
|
||||||
@Post('sessions')
|
@Post('sessions')
|
||||||
@ApiOperation({ summary: '创建对话' })
|
@ApiOperation({ summary: '创建/打开对话 (open-or-create)' })
|
||||||
async createSession(@CurrentUser() user: UserPayload, @Body() dto: { knowledgeBaseId: string; title?: string; knowledgeItemIds?: string[] }) {
|
async createSession(
|
||||||
return this.svc.createSession(String(user.id), dto.knowledgeBaseId, dto.title, dto.knowledgeItemIds);
|
@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')
|
@Get('sessions')
|
||||||
@ApiOperation({ summary: '对话列表' })
|
@ApiOperation({ summary: '对话列表' })
|
||||||
async listSessions(@CurrentUser() user: UserPayload, @Query('knowledgeBaseId') kbId?: string) {
|
async listSessions(
|
||||||
return this.svc.listSessions(String(user.id), kbId);
|
@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')
|
@Get('sessions/:id/messages')
|
||||||
@ApiOperation({ summary: '对话历史' })
|
@ApiOperation({ summary: '对话历史' })
|
||||||
async messages(@Param('id') id: string) {
|
async messages(@CurrentUser() user: UserPayload, @Param('id') id: string) {
|
||||||
return this.svc.getMessages(id);
|
return this.svc.getMessages(String(user.id), id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('sessions/:id/messages')
|
@Post('sessions/:id/messages')
|
||||||
@ -51,9 +86,25 @@ export class RagChatController {
|
|||||||
res.end();
|
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')
|
@Delete('sessions/:id')
|
||||||
@ApiOperation({ summary: '删除对话' })
|
@ApiOperation({ summary: '删除对话(软删除)' })
|
||||||
async deleteSession(@Param('id') id: string) {
|
async deleteSession(@CurrentUser() user: UserPayload, @Param('id') id: string) {
|
||||||
return this.svc.deleteSession(id);
|
return this.svc.deleteSession(String(user.id), id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { ContentSafetyService } from '../content-safety/content-safety.service';
|
import { ContentSafetyService } from '../content-safety/content-safety.service';
|
||||||
import { AiGatewayService } from '../ai/gateway/ai-gateway.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 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()
|
@Injectable()
|
||||||
export class RagChatService {
|
export class RagChatService {
|
||||||
private readonly logger = new Logger(RagChatService.name);
|
private readonly logger = new Logger(RagChatService.name);
|
||||||
@ -17,25 +32,101 @@ export class RagChatService {
|
|||||||
@Optional() private readonly aiGateway?: AiGatewayService,
|
@Optional() private readonly aiGateway?: AiGatewayService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createSession(userId: string, knowledgeBaseId: string, title?: string, knowledgeItemIds?: string[]) {
|
async createSession(userId: string, params: CreateSessionParams): Promise<CreateSessionResult> {
|
||||||
return this.prisma.chatSession.create({
|
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"');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new session
|
||||||
|
const session = await this.prisma.chatSession.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
knowledgeBaseId,
|
knowledgeBaseId: derivedKbId,
|
||||||
|
scopeType,
|
||||||
|
scopeId: scopeId ?? null,
|
||||||
|
parentKnowledgeBaseId: derivedKbId,
|
||||||
|
createdFrom: createdFrom ?? 'global_ai_entry',
|
||||||
title: title || '新对话',
|
title: title || '新对话',
|
||||||
knowledgeItemIds: knowledgeItemIds ?? [],
|
knowledgeItemIds: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.logger.log(`open-or-create: CREATED new session ${session.id} for scope ${scopeType}/${scopeId}`);
|
||||||
|
return { session, isNew: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
async listSessions(userId: string, kbId?: string) {
|
async listSessions(
|
||||||
return this.prisma.chatSession.findMany({
|
userId: string,
|
||||||
where: { userId, ...(kbId ? { knowledgeBaseId: kbId } : {}) },
|
opts?: {
|
||||||
orderBy: { updatedAt: 'desc' },
|
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(sessionId: string) {
|
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({
|
return this.prisma.chatMessage.findMany({
|
||||||
where: { sessionId },
|
where: { sessionId },
|
||||||
orderBy: { createdAt: 'asc' },
|
orderBy: { createdAt: 'asc' },
|
||||||
@ -53,15 +144,19 @@ export class RagChatService {
|
|||||||
return { blocked: true, message: '输入包含违规内容,请修改后重试' };
|
return { blocked: true, message: '输入包含违规内容,请修改后重试' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save user message
|
// Save user message with scope snapshot
|
||||||
|
const scopeSnapshot = this.buildScopeSnapshot(session);
|
||||||
await this.prisma.chatMessage.create({
|
await this.prisma.chatMessage.create({
|
||||||
data: { sessionId, role: 'user', content },
|
data: { sessionId, role: 'user', content, scopeSnapshot },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Retrieve knowledge base context
|
// Auto-title from first user message
|
||||||
this.logger.log(`RAG: kbId=${session.knowledgeBaseId}, content preview: ${content.substring(0, 30)}`);
|
await this.autoTitle(session, content);
|
||||||
const itemIds = (session as any).knowledgeItemIds as string[] | undefined;
|
|
||||||
const context = await this.loadContext(session.knowledgeBaseId, itemIds?.length ? itemIds : undefined);
|
// 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}`);
|
this.logger.log(`RAG context: isEmpty=${context.isEmpty}, textLen=${context.text.length}, citations=${context.citations.length}, aiGateway=${!!this.aiGateway}`);
|
||||||
|
|
||||||
// Generate AI response
|
// Generate AI response
|
||||||
@ -85,7 +180,6 @@ export class RagChatService {
|
|||||||
maxTokens: 2048,
|
maxTokens: 2048,
|
||||||
outputSchema: RagChatOutputSchema,
|
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 暂时无法生成回答。');
|
reply = resp.parsed?.answer ?? String(resp.parsed?.content ?? '抱歉,AI 暂时无法生成回答。');
|
||||||
citations = context.citations;
|
citations = context.citations;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@ -97,9 +191,9 @@ export class RagChatService {
|
|||||||
reply = this.fallbackReply(context.isEmpty);
|
reply = this.fallbackReply(context.isEmpty);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save AI message
|
// Save AI message with scope snapshot
|
||||||
const aiMsg = await this.prisma.chatMessage.create({
|
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
|
// Save citations
|
||||||
@ -115,7 +209,7 @@ export class RagChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update session timestamp
|
// 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 };
|
return { message: aiMsg, citations };
|
||||||
}
|
}
|
||||||
@ -133,24 +227,17 @@ export class RagChatService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save user message
|
// Save user message with scope snapshot
|
||||||
await this.prisma.chatMessage.create({ data: { sessionId, role: 'user', content } });
|
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
|
// Auto-title from first user message
|
||||||
if (!session.title || session.title === '新对话') {
|
await this.autoTitle(session, content);
|
||||||
await this.prisma.chatSession.update({
|
|
||||||
where: { id: sessionId },
|
|
||||||
data: { title: content.slice(0, 50) },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also auto-title in sendMessage (this is the sync method)
|
// Load context by scope
|
||||||
|
const context = await this.loadContextByScope(session);
|
||||||
// Load context
|
if (!this.aiGateway || !context.text) {
|
||||||
const itemIds = (session as any).knowledgeItemIds as string[] | undefined;
|
yield { type: 'content', content: this.fallbackReply(!context.text) };
|
||||||
const context = await this.loadContext(session.knowledgeBaseId, itemIds?.length ? itemIds : undefined);
|
|
||||||
if (!context.text) {
|
|
||||||
yield { type: 'content', content: this.fallbackReply(true) };
|
|
||||||
} else {
|
} else {
|
||||||
const messages = [
|
const messages = [
|
||||||
{ role: 'system' as const, content: this.buildSystemPrompt(context.text) },
|
{ role: 'system' as const, content: this.buildSystemPrompt(context.text) },
|
||||||
@ -158,7 +245,7 @@ export class RagChatService {
|
|||||||
];
|
];
|
||||||
|
|
||||||
let fullContent = '';
|
let fullContent = '';
|
||||||
for await (const chunk of this.aiGateway!.generateStream({
|
for await (const chunk of this.aiGateway.generateStream({
|
||||||
feature: 'rag-chat', userId, tier: 'primary',
|
feature: 'rag-chat', userId, tier: 'primary',
|
||||||
promptKey: 'rag-chat', promptVersion: 'v1', messages, maxTokens: 2048,
|
promptKey: 'rag-chat', promptVersion: 'v1', messages, maxTokens: 2048,
|
||||||
})) {
|
})) {
|
||||||
@ -171,7 +258,7 @@ export class RagChatService {
|
|||||||
// Save AI reply
|
// Save AI reply
|
||||||
if (fullContent) {
|
if (fullContent) {
|
||||||
const aiMsg = await this.prisma.chatMessage.create({
|
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)) {
|
for (const c of context.citations.slice(0, 5)) {
|
||||||
await this.prisma.chatCitation.create({
|
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) {
|
async deleteSession(userId: string, sessionId: string) {
|
||||||
await this.prisma.chatCitation.deleteMany({ where: { message: { sessionId } } });
|
const session = await this.prisma.chatSession.findUnique({ where: { id: sessionId } });
|
||||||
await this.prisma.chatMessage.deleteMany({ where: { sessionId } });
|
if (!session || session.userId !== userId) throw new NotFoundException('对话不存在');
|
||||||
await this.prisma.chatSession.delete({ where: { id: sessionId } });
|
await this.prisma.chatSession.update({
|
||||||
return { success: true };
|
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 ──
|
||||||
|
|
||||||
private async loadContext(kbId: string, itemIds?: string[]) {
|
private async autoTitle(session: any, content: string) {
|
||||||
try {
|
// Only auto-title if the session still has the default title
|
||||||
const items = await this.prisma.knowledgeItem.findMany({
|
if (!session.title || session.title === '新对话') {
|
||||||
where: {
|
const title = this.generateTitle(content);
|
||||||
knowledgeBaseId: kbId,
|
await this.prisma.chatSession.update({
|
||||||
deletedAt: null,
|
where: { id: session.id },
|
||||||
...(itemIds && itemIds.length > 0 ? { id: { in: itemIds } } : {}),
|
data: { title },
|
||||||
},
|
|
||||||
select: { id: true, title: true, content: true, summary: true },
|
|
||||||
orderBy: { updatedAt: 'desc' },
|
|
||||||
take: 30,
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (items.length === 0) return { text: '', citations: [], isEmpty: true };
|
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 {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
select: { id: true, title: true, content: true, summary: true },
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
take: 30,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
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 parts: string[] = [];
|
||||||
const citations: any[] = [];
|
const citations: any[] = [];
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user