Jelajahi Sumber

api访问追加

anhuiqiang 6 jam lalu
induk
melakukan
53bf8c47cf

+ 45 - 45
docker-compose.yml

@@ -59,52 +59,52 @@ services:
   #     echo 'All models pulled successfully!' && 
   #     wait"
 
-  # server:
-  #   build:
-  #     context: ./server
-  #     dockerfile: Dockerfile
-  #   container_name: aurak-server
-  #   environment:
-  #     - NODE_ENV=production
-  #     - NODE_OPTIONS=--max-old-space-size=8192
-  #     - PORT=3001
-  #     - DATABASE_PATH=/app/data/metadata.db
-  #     - ELASTICSEARCH_HOST=http://es:9200
-  #     - TIKA_HOST=http://tika:9998
-  #     - LIBREOFFICE_URL=http://libreoffice:8100
-  #     - JWT_SECRET=13405a7d-742a-41f5-8b34-012735acffea
-  #     - UPLOAD_FILE_PATH=/app/uploads
-  #     - DEFAULT_VECTOR_DIMENSIONS=2048
-  #     - TEMP_DIR=/app/temp
-  #     - CHUNK_BATCH_SIZE=50
-  #   volumes:
-  #     - ./data:/app/data
-  #     - ./uploads:/app/uploads
-  #     - ./temp:/app/temp
-  #   depends_on:
-  #     - es
-  #     - tika
-  #     - libreoffice
-  #   #    restart: unless-stopped
-  #   networks:
-  #     - aurak-network
+  server:
+    build:
+      context: ./server
+      dockerfile: Dockerfile
+    container_name: aurak-server
+    environment:
+      - NODE_ENV=production
+      - NODE_OPTIONS=--max-old-space-size=8192
+      - PORT=3001
+      - DATABASE_PATH=/app/data/metadata.db
+      - ELASTICSEARCH_HOST=http://es:9200
+      - TIKA_HOST=http://tika:9998
+      - LIBREOFFICE_URL=http://libreoffice:8100
+      - JWT_SECRET=13405a7d-742a-41f5-8b34-012735acffea
+      - UPLOAD_FILE_PATH=/app/uploads
+      - DEFAULT_VECTOR_DIMENSIONS=2048
+      - TEMP_DIR=/app/temp
+      - CHUNK_BATCH_SIZE=50
+    volumes:
+      - ./data:/app/data
+      - ./uploads:/app/uploads
+      - ./temp:/app/temp
+    depends_on:
+      - es
+      - tika
+      - libreoffice
+    #    restart: unless-stopped
+    networks:
+      - aurak-network
 
-  # web:
-  #   build:
-  #     context: .
-  #     dockerfile: ./web/Dockerfile
-  #     args:
-  #       - VITE_API_BASE_URL=/api
-  #   container_name: aurak-web
-  #   depends_on:
-  #     - server
-  #   ports:
-  #     - "80:80"
-  #     - "443:443"
-  #   volumes:
-  #     - ./nginx/conf.d:/etc/nginx/conf.d
-  #   networks:
-  #     - aurak-network
+  web:
+    build:
+      context: .
+      dockerfile: ./web/Dockerfile
+      args:
+        - VITE_API_BASE_URL=/api
+    container_name: aurak-web
+    depends_on:
+      - server
+    ports:
+      - "80:80"
+      - "443:443"
+    volumes:
+      - ./nginx/conf.d:/etc/nginx/conf.d
+    networks:
+      - aurak-network
 
 networks:
   aurak-network:

+ 240 - 65
docs/API.md

@@ -1,14 +1,14 @@
-# API リファレンス
+# API 参考文档
 
-## 基本情報
+## 基本信息
 
-- **ベース URL**: `http://localhost:3000`
-- **認証方式**: JWT Bearer トークン
+- **基础 URL**: `http://localhost:3000`
+- **认证方式**: JWT Bearer 令牌
 - **Content-Type**: `application/json`
 
-## 認証 API
+## 认证 API
 
-### ユーザー登録
+### 用户注册
 
 ```http
 POST /auth/register
@@ -20,11 +20,11 @@ Content-Type: application/json
 }
 ```
 
-**レスポンス**:
+**响应**:
 
 ```json
 {
-  "message": "ユーザーが正常に作成されました",
+  "message": "用户创建成功",
   "user": {
     "id": "string",
     "username": "string",
@@ -33,7 +33,7 @@ Content-Type: application/json
 }
 ```
 
-### ユーザーログイン
+### 用户登录
 
 ```http
 POST /auth/login
@@ -45,7 +45,7 @@ Content-Type: application/json
 }
 ```
 
-**レスポンス**:
+**响应**:
 
 ```json
 {
@@ -58,7 +58,7 @@ Content-Type: application/json
 }
 ```
 
-### パスワード変更
+### 密码修改
 
 ```http
 POST /auth/change-password
@@ -71,16 +71,16 @@ Content-Type: application/json
 }
 ```
 
-## モデル設定 API
+## 模型配置 API
 
-### モデル一覧の取得
+### 获取模型列表
 
 ```http
 GET /model-configs
 Authorization: Bearer <token>
 ```
 
-**レスポンス**:
+**响应**:
 
 ```json
 [
@@ -96,7 +96,7 @@ Authorization: Bearer <token>
 ]
 ```
 
-### モデル設定の作成
+### 创建模型配置
 
 ```http
 POST /model-configs
@@ -114,7 +114,7 @@ Content-Type: application/json
 }
 ```
 
-### モデル設定の更新
+### 更新模型配置
 
 ```http
 PUT /model-configs/:id
@@ -124,20 +124,20 @@ Content-Type: application/json
 {
   "name": "string",
   "apiKey": "string",
-  // ... その他のフィールド
+  // ... 其他字段
 }
 ```
 
-### モデル設定の削除
+### 删除模型配置
 
 ```http
 DELETE /model-configs/:id
 Authorization: Bearer <token>
 ```
 
-## ナレッジベース API
+## 知识库 API
 
-### ファイルのアップロード
+### 文件上传
 
 ```http
 POST /upload
@@ -149,11 +149,11 @@ Content-Type: multipart/form-data
   "chunkSize": number,
   "chunkOverlap": number,
   "embeddingModelId": "string",
-  "mode": "fast|precise"  // 処理モード
+  "mode": "fast|precise"  // 处理模式
 }
 ```
 
-**レスポンス**:
+**响应**:
 
 ```json
 {
@@ -166,14 +166,14 @@ Content-Type: multipart/form-data
 }
 ```
 
-### ファイル一覧の取得
+### 获取文件列表
 
 ```http
 GET /knowledge-bases
 Authorization: Bearer <token>
 ```
 
-**レスポンス**:
+**响应**:
 
 ```json
 [
@@ -189,23 +189,23 @@ Authorization: Bearer <token>
 ]
 ```
 
-### ファイルの削除
+### 删除文件
 
 ```http
 DELETE /knowledge-bases/:id
 Authorization: Bearer <token>
 ```
 
-### ナレッジベースの全消去
+### 清空知识库
 
 ```http
 DELETE /knowledge-bases/clear
 Authorization: Bearer <token>
 ```
 
-## チャット API
+## 聊天 API
 
-### ストリーミングチャット
+### 流式聊天
 
 ```http
 POST /chat/stream
@@ -224,14 +224,14 @@ Content-Type: application/json
 }
 ```
 
-**レスポンス**: Server-Sent Events (SSE)
+**响应**: Server-Sent Events (SSE)
 
 ```
-data: {"type": "content", "data": "ナレッジベースを検索中..."}
+data: {"type": "content", "data": "正在搜索知识库..."}
 
-data: {"type": "content", "data": "関連情報が見つかりました..."}
+data: {"type": "content", "data": "找到相关信息..."}
 
-data: {"type": "content", "data": "回答内容の断片"}
+data: {"type": "content", "data": "回答内容片"}
 
 data: {"type": "sources", "data": [
   {
@@ -245,16 +245,16 @@ data: {"type": "sources", "data": [
 data: [DONE]
 ```
 
-## ユーザー設定 API
+## 用户设置 API
 
-### ユーザー設定の取得
+### 获取用户设置
 
 ```http
 GET /user-settings
 Authorization: Bearer <token>
 ```
 
-**レスポンス**:
+**响应**:
 
 ```json
 {
@@ -271,7 +271,7 @@ Authorization: Bearer <token>
 }
 ```
 
-### ユーザー設定の更新
+### 更新用户设置
 
 ```http
 PUT /user-settings
@@ -282,32 +282,32 @@ Content-Type: application/json
   "selectedLLMId": "string",
   "temperature": number,
   "maxTokens": number,
-  // ... その他の設定フィールド
+  // ... 其他设置字段
 }
 ```
 
-## Vision Pipeline API
+## 视觉管道 API
 
-### 推奨モードの取得
+### 获取推荐模式
 
 ```http
 GET /api/vision/recommend-mode?file=xxx&size=xxx
 Authorization: Bearer <token>
 ```
 
-**レスポンス**:
+**响应**:
 
 ```json
 {
   "recommendedMode": "precise",
-  "reason": "ファイルサイズが大きいため、高精度モードを推奨します",
+  "reason": "由于文件较大,推荐使用高精度模式",
   "estimatedCost": 0.5,
   "estimatedTime": 60,
-  "warnings": ["処理時間が長くなる可能性があります", "API 利用料が発生します"]
+  "warnings": ["处理时间可能会较长", "会产生API使用费用"]
 }
 ```
 
-### LibreOffice 変換サービス
+### LibreOffice 转换服务
 
 ```http
 POST /libreoffice/convert
@@ -318,7 +318,7 @@ Content-Type: multipart/form-data
 }
 ```
 
-**レスポンス**:
+**响应**:
 
 ```json
 {
@@ -329,13 +329,13 @@ Content-Type: multipart/form-data
 }
 ```
 
-### ヘルスチェック
+### 健康检查
 
 ```http
 GET /libreoffice/health
 ```
 
-**レスポンス**:
+**响应**:
 
 ```json
 {
@@ -346,16 +346,16 @@ GET /libreoffice/health
 }
 ```
 
-## ユーザー管理 API (管理者用)
+## 用户管理 API(管理员专用)
 
-### ユーザー一覧の取得
+### 获取用户列表
 
 ```http
 GET /users
 Authorization: Bearer <admin_token>
 ```
 
-### ユーザーの作成
+### 创建用户
 
 ```http
 POST /users
@@ -369,14 +369,14 @@ Content-Type: application/json
 }
 ```
 
-### ユーザーの削除
+### 删除用户
 
 ```http
 DELETE /users/:id
 Authorization: Bearer <admin_token>
 ```
 
-## エラーレスポンス形
+## 错误响应格
 
 ```json
 {
@@ -386,23 +386,23 @@ Authorization: Bearer <admin_token>
 }
 ```
 
-## ステータスコードの説
+## 状态码说
 
 - `200` - 成功
-- `201` - 作成成功
-- `400` - リクエストパラメータの不正
-- `401` - 認証エラー / トークン無効
-- `403` - 限不足
-- `404` - リソースが見つかりません
-- `409` - リソースの競合
-- `500` - サーバー内部エラー
+- `201` - 创建成功
+- `400` - 请求参数无效
+- `401` - 认证错误 / 令牌无效
+- `403` - 限不足
+- `404` - 资源未找到
+- `409` - 资源冲突
+- `500` - 服务器内部错误
 
-## 実装
+## 实现示
 
 ### JavaScript/TypeScript
 
 ```javascript
-// ログイン
+// 登录
 const loginResponse = await fetch('/auth/login', {
   method: 'POST',
   headers: {
@@ -416,7 +416,7 @@ const loginResponse = await fetch('/auth/login', {
 
 const { access_token } = await loginResponse.json();
 
-// ファイル一覧の取得
+// 获取文件列表
 const filesResponse = await fetch('/knowledge-bases', {
   headers: {
     'Authorization': `Bearer ${access_token}`
@@ -425,7 +425,7 @@ const filesResponse = await fetch('/knowledge-bases', {
 
 const files = await filesResponse.json();
 
-// ストリーミングチャット
+// 流式聊天
 const chatResponse = await fetch('/chat/stream', {
   method: 'POST',
   headers: {
@@ -433,12 +433,187 @@ const chatResponse = await fetch('/chat/stream', {
     'Content-Type': 'application/json'
   },
   body: JSON.stringify({
-    message: 'こんにちは',
+    message: '你好',
     history: [],
     userLanguage: 'ja'
   })
 });
 
 const reader = chatResponse.body.getReader();
-// SSE ストリームの処理...
+// SSE 流的处理...
 ```
+
+---
+
+# 开放API功能实现总结
+
+## 概述
+
+本项目已实现完整的开放API功能,支持外部系统通过API Key进行认证和访问。
+
+## 后端实现
+
+### API控制器
+
+#### 基础API控制器 (`ApiController`)
+
+| 端点 | 方法 | 描述 | 认证方式 |
+|------|------|------|----------|
+| `/health` | GET | 健康检查 | 无 |
+| `/chat` | POST | 简单聊天接口 | JWT |
+
+#### V1版本开放API控制器 (`ApiV1Controller`)
+
+| 端点 | 方法 | 描述 | 认证方式 |
+|------|------|------|----------|
+| `/api/v1/chat` | POST | RAG聊天(支持流式SSE和JSON响应) | API Key |
+| `/api/v1/search` | POST | 知识库混合搜索 | API Key |
+| `/api/v1/knowledge-bases` | GET | 列出知识库文件 | API Key |
+| `/api/v1/knowledge-bases/upload` | POST | 上传文件到知识库 | API Key |
+| `/api/v1/knowledge-bases/:id` | DELETE | 删除知识库文件 | API Key |
+| `/api/v1/knowledge-bases/:id` | GET | 获取单个文件详情 | API Key |
+
+### API Key认证机制
+
+**文件位置**: `server/src/auth/api-key.guard.ts`
+
+支持两种方式传递API Key:
+- `Authorization: Bearer kb_xxx` 头
+- `x-api-key` 头
+
+**API Key格式**: `kb_` + 64位十六进制字符串
+
+### API Key数据模型
+
+**文件位置**: `server/src/auth/entities/api-key.entity.ts`
+
+```typescript
+@Entity('api_keys')
+export class ApiKey {
+    id: string;           // UUID主键
+    userId: string;       // 关联用户ID
+    key: string;          // API Key值(唯一)
+    createdAt: Date;      // 创建时间
+}
+```
+
+### API Key管理服务
+
+**文件位置**: `server/src/user/user.service.ts`
+
+| 方法 | 描述 |
+|------|------|
+| `findByApiKey(apiKey)` | 通过API Key查找用户 |
+| `getOrCreateApiKey(userId)` | 获取或创建用户的API Key |
+| `regenerateApiKey(userId)` | 重新生成API Key |
+
+## 前端实现
+
+### 登录支持
+
+**文件位置**: `web/src/pages/auth/Login.tsx`
+
+支持两种登录方式:
+- 用户名/密码登录
+- API Key直接登录
+
+### API客户端
+
+**文件位置**: `web/services/apiClient.ts`
+
+- 自动在请求头中添加 `x-api-key`
+- 支持多租户切换(`x-tenant-id`)
+
+### 认证上下文
+
+**文件位置**: `web/src/contexts/AuthContext.tsx`
+
+- 管理API Key状态
+- 持久化存储到 `localStorage`
+
+## 功能特点
+
+| 特性 | 状态 | 说明 |
+|------|------|------|
+| API Key认证 | ✅ 已实现 | 支持Bearer Token和x-api-key两种方式 |
+| 多租户隔离 | ✅ 已实现 | 每个API Key关联到特定用户和租户 |
+| RAG聊天(流式) | ✅ 已实现 | 支持SSE流式响应 |
+| 知识库搜索 | ✅ 已实现 | 支持向量搜索和全文搜索的混合搜索 |
+| 文件上传/管理 | ✅ 已实现 | 支持文件上传、列表查询和删除 |
+| 前端API Key登录 | ✅ 已实现 | 支持直接使用API Key登录 |
+
+## 使用示例
+
+### 使用API Key进行认证
+
+```javascript
+// 使用x-api-key头
+const response = await fetch('/api/v1/chat', {
+  method: 'POST',
+  headers: {
+    'Content-Type': 'application/json',
+    'x-api-key': 'kb_your_api_key_here'
+  },
+  body: JSON.stringify({
+    message: '你好',
+    stream: false
+  })
+});
+
+// 或使用Authorization头
+const response = await fetch('/api/v1/chat', {
+  method: 'POST',
+  headers: {
+    'Content-Type': 'application/json',
+    'Authorization': 'Bearer kb_your_api_key_here'
+  },
+  body: JSON.stringify({
+    message: '你好',
+    stream: true
+  })
+});
+```
+
+### 上传文件到知识库
+
+```javascript
+const formData = new FormData();
+formData.append('file', fileInput.files[0]);
+formData.append('mode', 'fast');
+formData.append('chunkSize', '1000');
+formData.append('chunkOverlap', '200');
+
+const response = await fetch('/api/v1/knowledge-bases/upload', {
+  method: 'POST',
+  headers: {
+    'x-api-key': 'kb_your_api_key_here'
+  },
+  body: formData
+});
+```
+
+## 待完善功能
+
+| 功能 | 状态 | 说明 |
+|------|------|------|
+| API Key管理界面 | ❌ 未实现 | 需要在设置页面提供API Key的查看/复制/重新生成功能 |
+| API文档页面 | ❌ 未实现 | 需要独立的API文档页面 |
+| 细粒度权限控制 | ❌ 未实现 | 目前所有API Key权限相同 |
+| API使用统计 | ❌ 未实现 | 需要记录和展示API调用次数和频率 |
+| API速率限制 | ❌ 未实现 | 需要防止API滥用 |
+
+## 相关文件清单
+
+### 后端文件
+- `server/src/api/api.controller.ts` - 基础API控制器
+- `server/src/api/api-v1.controller.ts` - V1版本开放API控制器
+- `server/src/api/api.service.ts` - API服务
+- `server/src/api/api.module.ts` - API模块
+- `server/src/auth/api-key.guard.ts` - API Key认证守卫
+- `server/src/auth/entities/api-key.entity.ts` - API Key实体
+- `server/src/user/user.service.ts` - 用户服务(包含API Key管理)
+
+### 前端文件
+- `web/src/pages/auth/Login.tsx` - 登录页面(支持API Key登录)
+- `web/src/contexts/AuthContext.tsx` - 认证上下文
+- `web/services/apiClient.ts` - API客户端

+ 469 - 0
docs/API_USAGE.md

@@ -0,0 +1,469 @@
+# AuraK 开放 API 使用文档
+
+## 概述
+
+AuraK 提供了一套完整的开放 API,允许外部系统通过 API Key 认证访问知识库功能。
+
+**基础 URL**: `http://localhost:3001`
+
+## 认证方式
+
+支持两种认证方式:
+
+### 方式 1:x-api-key 头(推荐)
+
+```http
+x-api-key: kb_your_api_key_here
+x-tenant-id: your_tenant_id  # 可选,指定租户
+```
+
+### 方式 2:Authorization Bearer 头
+
+```http
+Authorization: Bearer kb_your_api_key_here
+x-tenant-id: your_tenant_id  # 可选,指定租户
+```
+
+### 租户说明
+
+- 一个用户可能属于多个租户
+- 如果不指定 `x-tenant-id`,将使用用户的默认租户
+- 如果指定 `x-tenant-id`,将验证用户是否属于该租户
+- 可以通过 `?tenantId=xxx` 查询参数指定租户
+
+### 获取 API Key
+
+1. 登录 AuraK 系统
+2. 进入 **设置** → **API Key** 标签页
+3. 复制您的 API Key
+
+---
+
+## API 端点
+
+### 1. 聊天接口
+
+#### 1.1 RAG 聊天(推荐)
+
+与知识库进行对话,支持流式响应。
+
+```http
+POST /api/v1/chat
+```
+
+**请求头**:
+```http
+Content-Type: application/json
+x-api-key: kb_your_api_key_here
+```
+
+**请求体**:
+```json
+{
+  "message": "什么是机器学习?",
+  "stream": true,
+  "history": [
+    {
+      "role": "user",
+      "content": "你好"
+    },
+    {
+      "role": "assistant",
+      "content": "你好!有什么我可以帮助你的吗?"
+    }
+  ],
+  "userLanguage": "zh"
+}
+```
+
+**参数说明**:
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `message` | string | ✅ | 用户消息 |
+| `stream` | boolean | ❌ | 是否使用流式响应,默认 `true` |
+| `history` | array | ❌ | 历史对话记录 |
+| `userLanguage` | string | ❌ | 用户语言,支持 `zh`、`en`、`ja` |
+
+**响应(流式)**:
+
+```
+data: {"type":"status","data":{"stage":"searching","message":"正在搜索知识库..."}}
+
+data: {"type":"status","data":{"stage":"search_complete","message":"找到 3 条相关信息"}}
+
+data: {"type":"sources","data":[{"fileName":"机器学习入门.pdf","content":"机器学习是人工智能的一个分支...","score":0.95,"chunkIndex":2,"fileId":"xxx"}]}
+
+data: {"type":"content","data":"机器学习是"}
+
+data: {"type":"content","data":"人工智能的一个"}
+
+data: {"type":"content","data":"重要分支..."}
+
+data: [DONE]
+```
+
+**响应类型说明**:
+
+| 类型 | 说明 |
+|------|------|
+| `status` | 状态信息(搜索中、搜索完成、生成中) |
+| `sources` | 引用来源 |
+| `content` | AI 生成的回答内容 |
+| `thinking` | 深度思考过程(仅部分模型支持) |
+| `historyId` | 对话历史 ID |
+
+**响应(非流式)**:
+
+```json
+{
+  "message": "机器学习是人工智能的一个重要分支...",
+  "sources": [
+    {
+      "fileName": "机器学习入门.pdf",
+      "content": "机器学习是人工智能的一个分支...",
+      "score": 0.95,
+      "chunkIndex": 2,
+      "fileId": "xxx"
+    }
+  ],
+  "historyId": "hist_xxx"
+}
+```
+
+---
+
+#### 1.2 简单聊天
+
+不使用知识库的简单聊天。
+
+```http
+POST /chat
+```
+
+**请求体**:
+```json
+{
+  "message": "你好",
+  "stream": true
+}
+```
+
+---
+
+### 2. 知识库搜索
+
+搜索知识库内容。
+
+```http
+POST /api/v1/search
+```
+
+**请求体**:
+```json
+{
+  "query": "机器学习",
+  "topK": 5,
+  "similarityThreshold": 0.7
+}
+```
+
+**参数说明**:
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `query` | string | ✅ | 搜索查询 |
+| `topK` | number | ❌ | 返回结果数量,默认 5 |
+| `similarityThreshold` | number | ❌ | 相似度阈值,默认 0.7 |
+
+**响应**:
+```json
+{
+  "results": [
+    {
+      "fileName": "机器学习入门.pdf",
+      "content": "机器学习是人工智能的一个分支...",
+      "score": 0.95,
+      "chunkIndex": 2,
+      "fileId": "xxx"
+    }
+  ],
+  "total": 1
+}
+```
+
+---
+
+### 3. 知识库文件管理
+
+#### 3.1 列出文件
+
+获取知识库中的所有文件。
+
+```http
+GET /api/v1/knowledge-bases
+```
+
+**响应**:
+```json
+{
+  "files": [
+    {
+      "id": "file_xxx",
+      "name": "机器学习入门.pdf",
+      "size": 1024000,
+      "status": "completed",
+      "createdAt": "2024-01-01T00:00:00.000Z"
+    }
+  ],
+  "total": 1
+}
+```
+
+---
+
+#### 3.2 上传文件
+
+上传文件到知识库。
+
+```http
+POST /api/v1/knowledge-bases/upload
+```
+
+**请求头**:
+```http
+Content-Type: multipart/form-data
+x-api-key: kb_your_api_key_here
+```
+
+**请求体**:
+```
+file: [文件]
+mode: fast
+chunkSize: 1000
+chunkOverlap: 200
+```
+
+**参数说明**:
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `file` | File | ✅ | 要上传的文件 |
+| `mode` | string | ❌ | 处理模式:`fast`(快速)或 `precise`(精确) |
+| `chunkSize` | number | ❌ | 分块大小,默认 1000 |
+| `chunkOverlap` | number | ❌ | 分块重叠,默认 200 |
+
+**响应**:
+```json
+{
+  "id": "file_xxx",
+  "name": "机器学习入门.pdf",
+  "size": 1024000,
+  "status": "indexing",
+  "message": "文件上传成功,正在处理中"
+}
+```
+
+---
+
+#### 3.3 获取文件详情
+
+```http
+GET /api/v1/knowledge-bases/:id
+```
+
+**响应**:
+```json
+{
+  "id": "file_xxx",
+  "name": "机器学习入门.pdf",
+  "size": 1024000,
+  "status": "completed",
+  "chunkCount": 50,
+  "createdAt": "2024-01-01T00:00:00.000Z"
+}
+```
+
+---
+
+#### 3.4 删除文件
+
+```http
+DELETE /api/v1/knowledge-bases/:id
+```
+
+**响应**:
+```json
+{
+  "message": "文件删除成功"
+}
+```
+
+---
+
+## 错误处理
+
+### 错误响应格式
+
+```json
+{
+  "statusCode": 400,
+  "message": "错误描述",
+  "error": "Bad Request"
+}
+```
+
+### 常见错误码
+
+| 状态码 | 说明 |
+|--------|------|
+| 400 | 请求参数错误 |
+| 401 | 认证失败(API Key 无效或缺失) |
+| 403 | 权限不足 |
+| 404 | 资源不存在 |
+| 500 | 服务器内部错误 |
+
+---
+
+## 使用示例
+
+### JavaScript/TypeScript
+
+```javascript
+// 流式聊天
+const response = await fetch('http://localhost:3001/api/v1/chat', {
+  method: 'POST',
+  headers: {
+    'Content-Type': 'application/json',
+    'x-api-key': 'kb_your_api_key_here'
+  },
+  body: JSON.stringify({
+    message: '什么是机器学习?',
+    stream: true
+  })
+});
+
+const reader = response.body.getReader();
+const decoder = new TextDecoder();
+
+while (true) {
+  const { done, value } = await reader.read();
+  if (done) break;
+  
+  const chunk = decoder.decode(value);
+  const lines = chunk.split('\n');
+  
+  for (const line of lines) {
+    if (line.startsWith('data: ')) {
+      const data = line.slice(6);
+      if (data === '[DONE]') break;
+      
+      const parsed = JSON.parse(data);
+      console.log(parsed);
+    }
+  }
+}
+```
+
+### Python
+
+```python
+import requests
+
+# 非流式聊天
+response = requests.post(
+    'http://localhost:3001/api/v1/chat',
+    headers={
+        'Content-Type': 'application/json',
+        'x-api-key': 'kb_your_api_key_here'
+    },
+    json={
+        'message': '什么是机器学习?',
+        'stream': False
+    }
+)
+
+data = response.json()
+print(data['message'])
+```
+
+### cURL
+
+```bash
+# 流式聊天
+curl -X POST http://localhost:3001/api/v1/chat \
+  -H "Content-Type: application/json" \
+  -H "x-api-key: kb_your_api_key_here" \
+  -d '{"message": "什么是机器学习?", "stream": true}'
+
+# 上传文件
+curl -X POST http://localhost:3001/api/v1/knowledge-bases/upload \
+  -H "x-api-key: kb_your_api_key_here" \
+  -F "file=@/path/to/file.pdf" \
+  -F "mode=fast"
+
+# 搜索知识库
+curl -X POST http://localhost:3001/api/v1/search \
+  -H "Content-Type: application/json" \
+  -H "x-api-key: kb_your_api_key_here" \
+  -d '{"query": "机器学习", "topK": 5}'
+```
+
+---
+
+## 支持的文件格式
+
+上传文件时支持以下格式:
+
+- **文档**:PDF、DOCX、DOC、TXT、MD
+- **表格**:XLSX、XLS、CSV
+- **演示**:PPTX、PPT
+- **代码**:JS、TS、PY、JAVA、CPP、C、H、HPP
+- **其他**:JSON、XML、HTML、YAML
+
+---
+
+## 速率限制
+
+目前暂无速率限制,但建议:
+- 聊天请求:每分钟不超过 30 次
+- 文件上传:每小时不超过 100 个文件
+- 搜索请求:每分钟不超过 60 次
+
+---
+
+## 常见问题
+
+### Q: API Key 在哪里获取?
+
+A: 登录系统后,进入 **设置** → **API Key** 标签页即可查看和复制。
+
+### Q: 如何重新生成 API Key?
+
+A: 在 API Key 管理页面点击"重新生成 API Key"按钮。注意:旧 Key 将立即失效。
+
+### Q: 支持哪些语言?
+
+A: 支持中文(zh)、英文(en)、日文(ja),通过 `userLanguage` 参数指定。
+
+### Q: 文件上传后多久可以搜索?
+
+A: 取决于文件大小和处理模式:
+- 快速模式:通常 1-5 分钟
+- 精确模式:可能需要 5-30 分钟
+
+### Q: 如何查看文件处理状态?
+
+A: 使用 `GET /api/v1/knowledge-bases/:id` 接口查看文件状态。
+
+---
+
+## 更新日志
+
+### v1.0.0 (2024-01-01)
+
+- 初始版本发布
+- 支持 RAG 聊天(流式/非流式)
+- 支持知识库搜索
+- 支持文件上传/删除/查询
+- 支持 API Key 认证

+ 545 - 0
docs/CHAT_API_DESIGN.md

@@ -0,0 +1,545 @@
+# Chat API 返回内容区分设计文档
+
+## 概述
+
+本文档描述如何将 Chat API 的返回内容区分为"上文"、"引用"、"正文"、"深度思考"四个部分。
+
+## 当前实现分析
+
+### 现有返回结构
+
+```typescript
+{ type: 'content' | 'sources' | 'historyId'; data: any }
+```
+
+### 现有问题
+
+1. **content 类型过于笼统**:混合了搜索状态、调试信息、AI正文等多种内容
+2. **缺少上文返回**:历史对话被格式化后传给LLM,但没有返回给客户端
+3. **缺少深度思考**:没有支持 reasoning/thinking 过程的返回
+
+## 设计方案
+
+### 新的返回类型定义
+
+```typescript
+export type ChatStreamType = 
+  | 'history'      // 上文:历史对话内容
+  | 'sources'      // 引用:搜索结果来源
+  | 'content'      // 正文:AI生成的回答
+  | 'thinking'     // 深度思考:推理过程
+  | 'status'       // 状态:搜索进度、调试信息
+  | 'historyId';   // 历史ID:聊天会话ID
+
+export interface ChatStreamChunk {
+  type: ChatStreamType;
+  data: any;
+}
+```
+
+### 各类型详细说明
+
+#### 1. history - 上文
+
+**用途**:返回历史对话内容,让客户端可以显示上下文
+
+**数据结构**:
+```typescript
+{
+  type: 'history',
+  data: {
+    messages: Array<{
+      role: 'user' | 'assistant';
+      content: string;
+    }>;
+  }
+}
+```
+
+**触发时机**:在聊天开始时返回
+
+#### 2. sources - 引用
+
+**用途**:返回搜索结果来源,包含文件名、内容片段、相似度分数
+
+**数据结构**:
+```typescript
+{
+  type: 'sources',
+  data: Array<{
+    fileName: string;
+    content: string;
+    score: number;
+    chunkIndex: number;
+    fileId: string;
+  }>
+}
+```
+
+**触发时机**:在搜索完成后、生成正文前返回
+
+#### 3. content - 正文
+
+**用途**:返回AI生成的回答内容(纯文本)
+
+**数据结构**:
+```typescript
+{
+  type: 'content',
+  data: string  // 流式返回的文本片段
+}
+```
+
+**触发时机**:LLM生成过程中持续返回
+
+#### 4. thinking - 深度思考
+
+**用途**:返回AI的推理过程(如Chain-of-Thought)
+
+**数据结构**:
+```typescript
+{
+  type: 'thinking',
+  data: string  // 流式返回的思考过程
+}
+```
+
+**触发时机**:在正文之前返回(如果模型支持)
+
+#### 5. status - 状态
+
+**用途**:返回搜索进度、调试信息等
+
+**数据结构**:
+```typescript
+{
+  type: 'status',
+  data: {
+    stage: 'searching' | 'search_complete' | 'generating' | 'debug';
+    message: string;
+    details?: any;
+  }
+}
+```
+
+**触发时机**:在整个过程中穿插返回
+
+#### 6. historyId - 历史ID
+
+**用途**:返回聊天会话ID
+
+**数据结构**:
+```typescript
+{
+  type: 'historyId',
+  data: string  // 会话ID
+}
+```
+
+**触发时机**:在聊天开始时返回
+
+## 需要修改的文件
+
+### 后端文件
+
+1. **server/src/chat/chat.service.ts**
+   - 修改 `streamChat` 方法的返回类型
+   - 区分不同类型的 content
+   - 添加 history 类型返回
+   - 添加 thinking 类型支持
+
+2. **server/src/api/api-v1.controller.ts**
+   - 更新 SSE 响应格式
+   - 确保所有类型都被正确处理
+
+3. **server/src/types.ts**
+   - 添加新的类型定义
+
+### 前端文件
+
+4. **web/services/chatService.ts**
+   - 更新流式响应处理逻辑
+   - 区分不同类型的 chunk
+
+5. **web/components/ChatInterface.tsx** 或相关组件
+   - 更新UI渲染逻辑
+   - 分别显示上文、引用、正文、深度思考
+
+## 实现步骤
+
+### 第一阶段:类型定义
+
+1. 在 `server/src/types.ts` 中添加新的类型定义
+2. 更新 `ChatStreamChunk` 接口
+
+### 第二阶段:后端实现
+
+3. 修改 `chat.service.ts` 中的 `streamChat` 方法
+   - 添加 history 类型返回
+   - 将 content 细分为 status、thinking、content
+   - 保持 sources 不变
+
+4. 更新 `api-v1.controller.ts` 确保正确处理新类型
+
+### 第三阶段:前端实现
+
+5. 更新 `chatService.ts` 处理新的流式响应
+6. 更新聊天界面组件,分别渲染不同类型的 chunk
+
+## 兼容性考虑
+
+- 保持向后兼容:旧的 `content` 类型仍然可用
+- 新增类型是可选的,不影响现有功能
+- 前端可以逐步适配新类型
+
+## 深度思考支持(健壮性设计)
+
+### 设计原则
+
+**不预判模型能力,而是动态检测响应内容**
+
+优点:
+- 无需维护模型列表
+- 自动适配任何新模型
+- 对不支持的模型零开销
+- 始终保持健壮性
+
+### 动态检测机制
+
+#### 核心思路
+
+在流式响应过程中,实时检测是否出现 thinking 标记:
+- 如果检测到 → 解析并返回 `thinking` + `content`
+- 如果未检测到 → 按普通 `content` 处理
+
+#### 流式处理状态机
+
+```typescript
+enum StreamState {
+  WAITING,           // 等待首个 chunk
+  IN_THINKING,       // 正在接收 thinking 内容
+  IN_CONTENT,        // 正在接收 content 内容
+  COMPLETED          // 完成
+}
+
+interface StreamProcessor {
+  state: StreamState;
+  buffer: string;           // 缓冲区,用于检测标签
+  thinkingContent: string;  // 累积的 thinking 内容
+  contentContent: string;   // 累积的 content 内容
+  thinkingDetected: boolean; // 是否检测到 thinking
+}
+```
+
+#### 处理流程
+
+```typescript
+async function* processStream(stream: AsyncIterable<{content: string}>) {
+  const processor: StreamProcessor = {
+    state: StreamState.WAITING,
+    buffer: '',
+    thinkingContent: '',
+    contentContent: '',
+    thinkingDetected: false,
+  };
+  
+  for await (const chunk of stream) {
+    processor.buffer += chunk.content;
+    
+    // 状态机处理
+    switch (processor.state) {
+      case StreamState.WAITING:
+        // 检测是否以 thinking 标签开头
+        if (processor.buffer.includes('<thinking>')) {
+          processor.state = StreamState.IN_THINKING;
+          processor.thinkingDetected = true;
+          // 提取 <thinking> 之后的内容
+          const parts = processor.buffer.split('<thinking>');
+          processor.buffer = parts[1] || '';
+        } else if (processor.buffer.length > 100) {
+          // 超过 100 字符仍未检测到 thinking,判定为普通内容
+          processor.state = StreamState.IN_CONTENT;
+          yield { type: 'content', data: processor.buffer };
+          processor.buffer = '';
+        }
+        break;
+        
+      case StreamState.IN_THINKING:
+        // 检测 thinking 结束标签
+        if (processor.buffer.includes('</thinking>')) {
+          const parts = processor.buffer.split('</thinking>');
+          processor.thinkingContent += parts[0];
+          processor.buffer = parts[1] || '';
+          processor.state = StreamState.IN_CONTENT;
+          
+          // 返回完整的 thinking 内容
+          yield { type: 'thinking', data: processor.thinkingContent };
+          
+          // 如果 buffer 中有内容,作为 content 开始
+          if (processor.buffer) {
+            yield { type: 'content', data: processor.buffer };
+            processor.buffer = '';
+          }
+        } else {
+          // 继续累积 thinking 内容
+          // 为了流式显示,可以分批返回
+          yield { type: 'thinking', data: chunk.content };
+        }
+        break;
+        
+      case StreamState.IN_CONTENT:
+        // 直接返回 content
+        yield { type: 'content', data: chunk.content };
+        break;
+    }
+  }
+  
+  // 处理剩余 buffer
+  if (processor.buffer) {
+    if (processor.state === StreamState.IN_THINKING) {
+      yield { type: 'thinking', data: processor.buffer };
+    } else {
+      yield { type: 'content', data: processor.buffer };
+    }
+  }
+}
+```
+
+### 三种场景自动适配
+
+#### 场景1:普通模型(不支持 thinking)
+
+**检测过程**:
+1. 收到首个 chunk
+2. 等待 100 字符仍未检测到 `<thinking>`
+3. 判定为普通内容
+
+**返回**:
+- 仅 `content` 类型
+- 无 `thinking` 类型
+
+#### 场景2:Reasoning 模型但未触发
+
+**检测过程**:
+1. 收到首个 chunk
+2. 等待 100 字符仍未检测到 `<thinking>`
+3. 判定为普通内容
+
+**返回**:
+- 仅 `content` 类型
+- 无 `thinking` 类型
+
+#### 场景3:Reasoning 模型且触发
+
+**检测过程**:
+1. 收到首个 chunk
+2. 检测到 `<thinking>` 标签
+3. 进入 thinking 状态
+4. 收集直到 `</thinking>`
+5. 之后的内容作为 content
+
+**返回**:
+- `thinking` 类型(思考过程)
+- `content` 类型(正文)
+
+### 多格式支持
+
+支持多种 thinking 标签格式:
+
+```typescript
+const THINKING_PATTERNS = {
+  // 格式1: <thinking>...</thinking>
+  xml: {
+    start: /<thinking>/i,
+    end: /<\/thinking>/i,
+  },
+  // 格式2: ```thinking\n...\n```
+  codeBlock: {
+    start: /```thinking/i,
+    end: /```/i,
+  },
+  // 格式3: [THINKING]...[/THINKING]
+  bracket: {
+    start: /\[THINKING\]/i,
+    end: /\[\/THINKING\]/i,
+  },
+};
+
+// 自动检测使用哪种格式
+function detectThinkingFormat(buffer: string): keyof typeof THINKING_PATTERNS | null {
+  for (const [format, pattern] of Object.entries(THINKING_PATTERNS)) {
+    if (pattern.start.test(buffer)) {
+      return format as keyof typeof THINKING_PATTERNS;
+    }
+  }
+  return null;
+}
+```
+
+### Thinking 内容解析
+
+支持多种 thinking 格式:
+
+```typescript
+function extractThinking(response: string): { thinking: string; content: string } {
+  // 格式1: <thinking>...</thinking>
+  const thinkingTagMatch = response.match(/<thinking>([\s\S]*?)<\/thinking>/i);
+  if (thinkingTagMatch) {
+    const thinking = thinkingTagMatch[1].trim();
+    const content = response.replace(/<thinking>[\s\S]*?<\/thinking>/i, '').trim();
+    return { thinking, content };
+  }
+  
+  // 格式2: ```thinking\n...\n```
+  const codeBlockMatch = response.match(/```thinking\n([\s\S]*?)\n```/i);
+  if (codeBlockMatch) {
+    const thinking = codeBlockMatch[1].trim();
+    const content = response.replace(/```thinking\n[\s\S]*?\n```/i, '').trim();
+    return { thinking, content };
+  }
+  
+  // 格式3: [THINKING]...[/THINKING]
+  const bracketMatch = response.match(/\[THINKING\]([\s\S]*?)\[\/THINKING\]/i);
+  if (bracketMatch) {
+    const thinking = bracketMatch[1].trim();
+    const content = response.replace(/\[THINKING\][\s\S]*?\[\/THINKING\]/i, '').trim();
+    return { thinking, content };
+  }
+  
+  // 未检测到 thinking,返回原内容
+  return { thinking: '', content: response };
+}
+```
+
+### 前端健壮性处理
+
+前端需要能够处理所有情况:
+
+```typescript
+// 流式响应处理
+function handleStreamChunk(chunk: ChatStreamChunk) {
+  switch (chunk.type) {
+    case 'history':
+      setHistory(chunk.data.messages);
+      break;
+      
+    case 'sources':
+      setSources(chunk.data);
+      break;
+      
+    case 'thinking':
+      // 只有收到 thinking 类型时才显示深度思考区域
+      setThinking(prev => prev + chunk.data);
+      setShowThinking(true);
+      break;
+      
+    case 'content':
+      setContent(prev => prev + chunk.data);
+      break;
+      
+    case 'status':
+      setStatus(chunk.data);
+      break;
+  }
+}
+
+// 渲染时的健壮性检查
+function renderChat() {
+  return (
+    <div>
+      {/* 上文:只有有内容时才显示 */}
+      {history.length > 0 && <HistorySection messages={history} />}
+      
+      {/* 引用:只有有内容时才显示 */}
+      {sources.length > 0 && <SourcesSection sources={sources} />}
+      
+      {/* 深度思考:只有有内容时才显示 */}
+      {showThinking && thinking.length > 0 && (
+        <ThinkingSection thinking={thinking} />
+      )}
+      
+      {/* 正文:始终显示 */}
+      <ContentSection content={content} />
+    </div>
+  );
+}
+```
+
+### 配置选项
+
+在模型配置中添加 reasoning 支持选项:
+
+```typescript
+// 模型配置扩展
+interface ModelConfig {
+  // ... 现有字段
+  
+  // 新增:reasoning 配置
+  reasoning?: {
+    enabled: boolean;           // 是否启用 reasoning 解析
+    format?: 'auto' | 'thinking_tag' | 'reasoning_field';  // 格式检测方式
+  };
+}
+```
+
+### 降级策略总结
+
+| 模型类型 | 是否支持 | 是否触发 | 返回类型 | 前端显示 |
+|----------|----------|----------|----------|----------|
+| 普通模型(如 GPT-4) | ❌ | - | content | 仅正文 |
+| Reasoning模型未触发 | ✅ | ❌ | content | 仅正文 |
+| Reasoning模型已触发 | ✅ | ✅ | thinking + content | 深度思考 + 正文 |
+
+### 错误处理
+
+```typescript
+// 解析 thinking 时的错误处理
+try {
+  const { thinking, content } = extractThinking(fullResponse);
+  
+  if (thinking) {
+    yield { type: 'thinking', data: thinking };
+  }
+  
+  yield { type: 'content', data: content };
+} catch (error) {
+  // 解析失败时,降级为普通 content
+  logger.warn('Failed to parse thinking, falling back to content', error);
+  yield { type: 'content', data: fullResponse };
+}
+```
+
+## 测试计划
+
+### 基础功能测试
+
+1. 测试 history 类型正确返回历史对话
+2. 测试 sources 类型正确返回引用来源
+3. 测试 content 类型正确返回正文
+4. 测试 status 类型正确返回状态信息
+
+### 深度思考健壮性测试
+
+5. **测试普通模型**:使用 GPT-4 等不支持 reasoning 的模型
+   - 验证不返回 thinking 类型
+   - 验证只返回 content 类型
+   - 验证前端不显示深度思考区域
+
+6. **测试 Reasoning 模型未触发**:使用 DeepSeek-R1 但提问简单问题
+   - 验证不返回 thinking 类型
+   - 验证只返回 content 类型
+
+7. **测试 Reasoning 模型已触发**:使用 DeepSeek-R1 提问复杂问题
+   - 验证返回 thinking 类型
+   - 验证返回 content 类型
+   - 验证前端正确显示深度思考区域
+
+8. **测试解析容错**:模拟 malformed thinking 标签
+   - 验证解析失败时降级为 content
+   - 验证不抛出异常
+
+### 前端渲染测试
+
+9. 测试前端正确渲染所有类型
+10. 测试前端在没有 thinking 时不显示深度思考区域
+11. 测试前端在有 thinking 时正确显示深度思考区域

+ 45 - 3
server/src/api/api-v1.controller.ts

@@ -21,7 +21,10 @@ import { ModelConfigService } from '../model-config/model-config.service';
 import { TenantService } from '../tenant/tenant.service';
 import { UserSettingService } from '../user/user-setting.service';
 import { I18nService } from '../i18n/i18n.service';
+import { ApiTags, ApiOperation, ApiResponse, ApiSecurity, ApiConsumes, ApiBody } from '@nestjs/swagger';
 
+@ApiTags('API v1')
+@ApiSecurity('x-api-key')
 @Controller('v1')
 @UseGuards(ApiKeyGuard)
 export class ApiV1Controller {
@@ -42,6 +45,10 @@ export class ApiV1Controller {
      * Body: { message, stream?, selectedGroups?, selectedFiles? }
      */
     @Post('chat')
+    @ApiOperation({ summary: 'RAG 聊天', description: '与知识库进行对话,支持流式和非流式响应' })
+    @ApiResponse({ status: 200, description: '成功' })
+    @ApiResponse({ status: 400, description: '请求参数错误' })
+    @ApiResponse({ status: 401, description: '认证失败' })
     async chat(
         @Request() req,
         @Body()
@@ -63,11 +70,19 @@ export class ApiV1Controller {
         // Get organization settings and model configuration
         const tenantSettings = await this.tenantService.getSettings(user.tenantId);
         const userSetting = await this.userSettingService.getByUser(user.id);
-        const models = await this.modelConfigService.findAll();
-        const llmModel = models.find((m) => m.id === tenantSettings?.selectedLLMId) ?? models.find((m) => m.type === 'llm' && m.isDefault);
+        
+        // Use the same logic as chat.controller.ts
+        let llmModel;
+        if (tenantSettings?.selectedLLMId) {
+            // Find specifically selected model from tenant settings
+            llmModel = await this.modelConfigService.findOne(tenantSettings.selectedLLMId);
+        } else {
+            // Use organization's default LLM from Index Chat Config
+            llmModel = await this.modelConfigService.findDefaultByType(user.tenantId, 'llm' as any);
+        }
 
         if (!llmModel) {
-            return res.status(400).json({ error: 'No LLM model configured for this user' });
+            return res.status(400).json({ error: 'No LLM model configured for this organization. Please set the default model in Admin Settings.' });
         }
 
         const modelConfig = llmModel as any;
@@ -158,6 +173,10 @@ export class ApiV1Controller {
      * Body: { query, topK?, threshold?, selectedGroups?, selectedFiles? }
      */
     @Post('search')
+    @ApiOperation({ summary: '知识库搜索', description: '在知识库中搜索相关内容' })
+    @ApiResponse({ status: 200, description: '成功' })
+    @ApiResponse({ status: 400, description: '请求参数错误' })
+    @ApiResponse({ status: 401, description: '认证失败' })
     async search(
         @Request() req,
         @Body()
@@ -202,6 +221,9 @@ export class ApiV1Controller {
      * List all files belonging to the caller's tenant.
      */
     @Get('knowledge-bases')
+    @ApiOperation({ summary: '列出知识库文件', description: '获取当前租户的所有知识库文件' })
+    @ApiResponse({ status: 200, description: '成功' })
+    @ApiResponse({ status: 401, description: '认证失败' })
     async listFiles(@Request() req) {
         const user = req.user;
         const files = await this.knowledgeBaseService.findAll(user.id, user.tenantId);
@@ -225,6 +247,22 @@ export class ApiV1Controller {
      */
     @Post('knowledge-bases/upload')
     @UseInterceptors(FileInterceptor('file'))
+    @ApiOperation({ summary: '上传文件', description: '上传文件到知识库' })
+    @ApiConsumes('multipart/form-data')
+    @ApiBody({
+        schema: {
+            type: 'object',
+            properties: {
+                file: { type: 'string', format: 'binary' },
+                mode: { type: 'string', enum: ['fast', 'precise'], description: '处理模式' },
+                chunkSize: { type: 'number', description: '分块大小' },
+                chunkOverlap: { type: 'number', description: '分块重叠' },
+            },
+        },
+    })
+    @ApiResponse({ status: 200, description: '成功' })
+    @ApiResponse({ status: 400, description: '请求参数错误' })
+    @ApiResponse({ status: 401, description: '认证失败' })
     async uploadFile(
         @Request() req,
         @UploadedFile() file: Express.Multer.File,
@@ -257,6 +295,10 @@ export class ApiV1Controller {
      * Delete a specific file from the knowledge base.
      */
     @Delete('knowledge-bases/:id')
+    @ApiOperation({ summary: '删除文件', description: '从知识库删除指定文件' })
+    @ApiResponse({ status: 200, description: '成功' })
+    @ApiResponse({ status: 401, description: '认证失败' })
+    @ApiResponse({ status: 404, description: '文件不存在' })
     async deleteFile(@Request() req, @Param('id') id: string) {
         const user = req.user;
         await this.knowledgeBaseService.deleteFile(id, user.id, user.tenantId);

+ 23 - 3
server/src/auth/api-key.guard.ts

@@ -1,4 +1,4 @@
-import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
+import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, BadRequestException } from '@nestjs/common';
 import { Reflector } from '@nestjs/core';
 import { UserService } from '../user/user.service';
 import { Request } from 'express';
@@ -26,8 +26,28 @@ export class ApiKeyGuard implements CanActivate {
         if (apiKey) {
             const user = await this.userService.findByApiKey(apiKey);
             if (user) {
-                request.user = user;
-                request.tenantId = user.tenantId;
+                // Get tenantId from header or query parameter
+                const requestedTenantId = request.headers['x-tenant-id'] as string || 
+                                         request.query.tenantId as string;
+                
+                // If tenantId is provided, verify user belongs to that tenant
+                if (requestedTenantId) {
+                    // Check if user belongs to the requested tenant
+                    const userTenants = await this.userService.getUserTenants(user.id);
+                    const belongsToTenant = userTenants.some(t => t.id === requestedTenantId);
+                    
+                    if (!belongsToTenant) {
+                        throw new BadRequestException(`User does not belong to tenant "${requestedTenantId}"`);
+                    }
+                    
+                    request.user = { ...user, tenantId: requestedTenantId };
+                    request.tenantId = requestedTenantId;
+                } else {
+                    // Use user's default tenantId
+                    request.user = user;
+                    request.tenantId = user.tenantId;
+                }
+                
                 return true;
             }
             throw new UnauthorizedException('Invalid API key');

+ 280 - 0
server/src/chat/chat.service.spec.ts

@@ -0,0 +1,280 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { ChatService } from './chat.service';
+import { ElasticsearchService } from '../elasticsearch/elasticsearch.service';
+import { EmbeddingService } from '../knowledge-base/embedding.service';
+import { ModelConfigService } from '../model-config/model-config.service';
+import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service';
+import { SearchHistoryService } from '../search-history/search-history.service';
+import { ConfigService } from '@nestjs/config';
+import { RagService } from '../rag/rag.service';
+import { I18nService } from '../i18n/i18n.service';
+import { TenantService } from '../tenant/tenant.service';
+import { UserSettingService } from '../user/user-setting.service';
+import { ChatStreamChunk } from '../types';
+
+describe('ChatService', () => {
+  let service: ChatService;
+
+  const mockElasticsearchService = {
+    hybridSearch: jest.fn(),
+  };
+
+  const mockEmbeddingService = {
+    getEmbeddings: jest.fn(),
+  };
+
+  const mockModelConfigService = {
+    findOne: jest.fn(),
+    findDefaultByType: jest.fn(),
+  };
+
+  const mockKnowledgeGroupService = {
+    getFileIdsByGroups: jest.fn(),
+  };
+
+  const mockSearchHistoryService = {
+    create: jest.fn(),
+    addMessage: jest.fn(),
+    findOne: jest.fn(),
+    updateTitle: jest.fn(),
+  };
+
+  const mockConfigService = {
+    get: jest.fn(),
+  };
+
+  const mockRagService = {
+    searchKnowledge: jest.fn(),
+  };
+
+  const mockI18nService = {
+    getMessage: jest.fn(),
+    formatMessage: jest.fn(),
+    getPrompt: jest.fn(),
+    getChatTitlePrompt: jest.fn(),
+  };
+
+  const mockTenantService = {
+    getSettings: jest.fn(),
+  };
+
+  const mockUserSettingService = {
+    getByUser: jest.fn(),
+  };
+
+  beforeEach(async () => {
+    const module: TestingModule = await Test.createTestingModule({
+      providers: [
+        ChatService,
+        { provide: ElasticsearchService, useValue: mockElasticsearchService },
+        { provide: EmbeddingService, useValue: mockEmbeddingService },
+        { provide: ModelConfigService, useValue: mockModelConfigService },
+        { provide: KnowledgeGroupService, useValue: mockKnowledgeGroupService },
+        { provide: SearchHistoryService, useValue: mockSearchHistoryService },
+        { provide: ConfigService, useValue: mockConfigService },
+        { provide: RagService, useValue: mockRagService },
+        { provide: I18nService, useValue: mockI18nService },
+        { provide: TenantService, useValue: mockTenantService },
+        { provide: UserSettingService, useValue: mockUserSettingService },
+      ],
+    }).compile();
+
+    service = module.get<ChatService>(ChatService);
+  });
+
+  describe('processStreamWithThinking', () => {
+    it('should detect and extract thinking content with <thinking> tags', async () => {
+      // Mock stream with thinking tags
+      const mockStream = async function* () {
+        yield { content: '<thinking>' };
+        yield { content: 'This is my reasoning process.' };
+        yield { content: '</thinking>' };
+        yield { content: 'This is the final answer.' };
+      };
+
+      const chunks: ChatStreamChunk[] = [];
+      for await (const chunk of service['processStreamWithThinking'](mockStream())) {
+        chunks.push(chunk);
+      }
+
+      expect(chunks).toEqual([
+        { type: 'thinking', data: 'This is my reasoning process.' },
+        { type: 'content', data: 'This is the final answer.' },
+      ]);
+    });
+
+    it('should detect and extract thinking content with code block format', async () => {
+      // Mock stream with code block thinking format
+      const mockStream = async function* () {
+        yield { content: '```thinking\nLet me think about this.\n```\nHere is my answer.' };
+      };
+
+      const chunks: ChatStreamChunk[] = [];
+      for await (const chunk of service['processStreamWithThinking'](mockStream())) {
+        chunks.push(chunk);
+      }
+
+      expect(chunks).toEqual([
+        { type: 'thinking', data: '\nLet me think about this.\n' },
+        { type: 'content', data: '\nHere is my answer.' },
+      ]);
+    });
+
+    it('should detect and extract thinking content with bracket format', async () => {
+      // Mock stream with bracket thinking format
+      const mockStream = async function* () {
+        yield { content: '[THINKING]' };
+        yield { content: 'Analyzing the problem.' };
+        yield { content: '[/THINKING]' };
+        yield { content: 'The solution is...' };
+      };
+
+      const chunks: ChatStreamChunk[] = [];
+      for await (const chunk of service['processStreamWithThinking'](mockStream())) {
+        chunks.push(chunk);
+      }
+
+      expect(chunks).toEqual([
+        { type: 'thinking', data: 'Analyzing the problem.' },
+        { type: 'content', data: 'The solution is...' },
+      ]);
+    });
+
+    it('should handle regular content without thinking tags', async () => {
+      // Mock stream without thinking tags
+      const mockStream = async function* () {
+        yield { content: 'This is a regular response.' };
+        yield { content: ' No thinking here.' };
+      };
+
+      const chunks: ChatStreamChunk[] = [];
+      for await (const chunk of service['processStreamWithThinking'](mockStream())) {
+        chunks.push(chunk);
+      }
+
+      expect(chunks).toEqual([
+        { type: 'content', data: 'This is a regular response. No thinking here.' },
+      ]);
+    });
+
+    it('should handle empty stream', async () => {
+      // Mock empty stream
+      const mockStream = async function* () {
+        // Empty stream
+      };
+
+      const chunks: ChatStreamChunk[] = [];
+      for await (const chunk of service['processStreamWithThinking'](mockStream())) {
+        chunks.push(chunk);
+      }
+
+      expect(chunks).toEqual([]);
+    });
+
+    it('should handle stream with only thinking content', async () => {
+      // Mock stream with only thinking
+      const mockStream = async function* () {
+        yield { content: '<thinking>' };
+        yield { content: 'Only thinking, no answer.' };
+        yield { content: '</thinking>' };
+      };
+
+      const chunks: ChatStreamChunk[] = [];
+      for await (const chunk of service['processStreamWithThinking'](mockStream())) {
+        chunks.push(chunk);
+      }
+
+      expect(chunks).toEqual([
+        { type: 'thinking', data: 'Only thinking, no answer.' },
+      ]);
+    });
+
+    it('should handle small content that does not exceed 100 char threshold', async () => {
+      // Mock stream with small content
+      const mockStream = async function* () {
+        yield { content: 'Short answer.' };
+      };
+
+      const chunks: ChatStreamChunk[] = [];
+      for await (const chunk of service['processStreamWithThinking'](mockStream())) {
+        chunks.push(chunk);
+      }
+
+      expect(chunks).toEqual([
+        { type: 'content', data: 'Short answer.' },
+      ]);
+    });
+  });
+
+  describe('extractThinking', () => {
+    it('should extract thinking from <thinking> tags', () => {
+      const response = '<thinking>My reasoning</thinking>The answer';
+      const result = service['extractThinking'](response);
+
+      expect(result).toEqual({
+        thinking: 'My reasoning',
+        content: 'The answer',
+      });
+    });
+
+    it('should extract thinking from code block format', () => {
+      const response = '```thinking\nMy reasoning\n```\nThe answer';
+      const result = service['extractThinking'](response);
+
+      expect(result).toEqual({
+        thinking: 'My reasoning',
+        content: 'The answer',
+      });
+    });
+
+    it('should extract thinking from bracket format', () => {
+      const response = '[THINKING]My reasoning[/THINKING]The answer';
+      const result = service['extractThinking'](response);
+
+      expect(result).toEqual({
+        thinking: 'My reasoning',
+        content: 'The answer',
+      });
+    });
+
+    it('should return empty thinking for regular content', () => {
+      const response = 'Just a regular response';
+      const result = service['extractThinking'](response);
+
+      expect(result).toEqual({
+        thinking: '',
+        content: 'Just a regular response',
+      });
+    });
+  });
+
+  describe('detectThinkingFormat', () => {
+    it('should detect xml format', () => {
+      const buffer = '<thinking>test';
+      const format = service['detectThinkingFormat'](buffer);
+
+      expect(format).toBe('xml');
+    });
+
+    it('should detect code block format', () => {
+      const buffer = '```thinking\ntest';
+      const format = service['detectThinkingFormat'](buffer);
+
+      expect(format).toBe('codeBlock');
+    });
+
+    it('should detect bracket format', () => {
+      const buffer = '[THINKING]test';
+      const format = service['detectThinkingFormat'](buffer);
+
+      expect(format).toBe('bracket');
+    });
+
+    it('should return null for no thinking format', () => {
+      const buffer = 'regular content';
+      const format = service['detectThinkingFormat'](buffer);
+
+      expect(format).toBeNull();
+    });
+  });
+});

+ 229 - 17
server/src/chat/chat.service.ts

@@ -7,7 +7,7 @@ import { EmbeddingService } from '../knowledge-base/embedding.service';
 import { ModelConfigService } from '../model-config/model-config.service';
 import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service';
 import { SearchHistoryService } from '../search-history/search-history.service';
-import { ModelConfig, ModelType } from '../types';
+import { ModelConfig, ModelType, ChatStreamChunk, HistoryMessage, SourceReference, StatusMessage } from '../types';
 import { RagService } from '../rag/rag.service';
 
 import { DEFAULT_VECTOR_DIMENSIONS, DEFAULT_LANGUAGE } from '../common/constants';
@@ -20,6 +20,43 @@ export interface ChatMessage {
   content: string;
 }
 
+// Thinking detection patterns
+interface ThinkingPattern {
+  start: RegExp;
+  end: RegExp;
+}
+
+const THINKING_PATTERNS: Record<string, ThinkingPattern> = {
+  xml: {
+    start: /<thinking>/i,
+    end: /<\/thinking>/i,
+  },
+  codeBlock: {
+    start: /```thinking/i,
+    end: /```/i,
+  },
+  bracket: {
+    start: /\[THINKING\]/i,
+    end: /\[\/THINKING\]/i,
+  },
+};
+
+// Stream processing state
+enum StreamState {
+  WAITING,           // Waiting for first chunk
+  IN_THINKING,       // Receiving thinking content
+  IN_CONTENT,        // Receiving content
+}
+
+interface StreamProcessor {
+  state: StreamState;
+  buffer: string;
+  thinkingContent: string;
+  contentContent: string;
+  thinkingDetected: boolean;
+  detectedFormat: string | null;
+}
+
 @Injectable()
 export class ChatService {
   private readonly logger = new Logger(ChatService.name);
@@ -44,6 +81,154 @@ export class ChatService {
     );
   }
 
+  /**
+   * Detect thinking format in buffer
+   */
+  private detectThinkingFormat(buffer: string): string | null {
+    for (const [format, pattern] of Object.entries(THINKING_PATTERNS)) {
+      if (pattern.start.test(buffer)) {
+        return format;
+      }
+    }
+    return null;
+  }
+
+  /**
+   * Extract thinking and content from response
+   */
+  private extractThinking(response: string): { thinking: string; content: string } {
+    // Format 1: <thinking>...</thinking>
+    const thinkingTagMatch = response.match(/<thinking>([\s\S]*?)<\/thinking>/i);
+    if (thinkingTagMatch) {
+      const thinking = thinkingTagMatch[1].trim();
+      const content = response.replace(/<thinking>[\s\S]*?<\/thinking>/i, '').trim();
+      return { thinking, content };
+    }
+
+    // Format 2: ```thinking\n...\n```
+    const codeBlockMatch = response.match(/```thinking\n([\s\S]*?)\n```/i);
+    if (codeBlockMatch) {
+      const thinking = codeBlockMatch[1].trim();
+      const content = response.replace(/```thinking\n[\s\S]*?\n```/i, '').trim();
+      return { thinking, content };
+    }
+
+    // Format 3: [THINKING]...[/THINKING]
+    const bracketMatch = response.match(/\[THINKING\]([\s\S]*?)\[\/THINKING\]/i);
+    if (bracketMatch) {
+      const thinking = bracketMatch[1].trim();
+      const content = response.replace(/\[THINKING\][\s\S]*?\[\/THINKING\]/i, '').trim();
+      return { thinking, content };
+    }
+
+    // No thinking detected
+    return { thinking: '', content: response };
+  }
+
+  /**
+   * Process stream with dynamic thinking detection
+   */
+  private async *processStreamWithThinking(
+    stream: AsyncIterable<{ content: string | any }>,
+  ): AsyncGenerator<ChatStreamChunk> {
+    const processor: StreamProcessor = {
+      state: StreamState.WAITING,
+      buffer: '',
+      thinkingContent: '',
+      contentContent: '',
+      thinkingDetected: false,
+      detectedFormat: null,
+    };
+
+    for await (const chunk of stream) {
+      const chunkContent = typeof chunk.content === 'string' 
+        ? chunk.content 
+        : String(chunk.content || '');
+      
+      if (!chunkContent) continue;
+
+      processor.buffer += chunkContent;
+
+      switch (processor.state) {
+        case StreamState.WAITING:
+          // Check for thinking tag at start
+          const format = this.detectThinkingFormat(processor.buffer);
+          if (format) {
+            processor.state = StreamState.IN_THINKING;
+            processor.thinkingDetected = true;
+            processor.detectedFormat = format;
+            
+            // Extract content after opening tag
+            const pattern = THINKING_PATTERNS[format];
+            const parts = processor.buffer.split(pattern.start);
+            processor.buffer = parts[1] || '';
+            
+            // Check if end tag is already in buffer
+            if (pattern.end.test(processor.buffer)) {
+              const endParts = processor.buffer.split(pattern.end);
+              processor.thinkingContent = endParts[0];
+              processor.buffer = endParts[1] || '';
+              processor.state = StreamState.IN_CONTENT;
+              
+              yield { type: 'thinking', data: processor.thinkingContent };
+              processor.thinkingContent = ''; // Clear to avoid duplicate yield
+              
+              if (processor.buffer) {
+                yield { type: 'content', data: processor.buffer };
+                processor.buffer = '';
+              }
+            }
+          } else if (processor.buffer.length > 100) {
+            // No thinking detected after 100 chars, switch to content mode
+            processor.state = StreamState.IN_CONTENT;
+            yield { type: 'content', data: processor.buffer };
+            processor.buffer = '';
+          }
+          break;
+
+        case StreamState.IN_THINKING:
+          const currentPattern = THINKING_PATTERNS[processor.detectedFormat!];
+          if (currentPattern.end.test(processor.buffer)) {
+            // End of thinking section
+            const endParts = processor.buffer.split(currentPattern.end);
+            const lastThinkingPart = endParts[0];
+            processor.buffer = endParts[1] || '';
+            processor.state = StreamState.IN_CONTENT;
+
+            // Only yield the last part if it hasn't been yielded yet
+            if (lastThinkingPart && lastThinkingPart !== processor.thinkingContent) {
+              yield { type: 'thinking', data: lastThinkingPart };
+            }
+            processor.thinkingContent = '';
+
+            if (processor.buffer) {
+              yield { type: 'content', data: processor.buffer };
+              processor.buffer = '';
+            }
+          } else {
+            // Continue accumulating thinking content and yield chunk
+            processor.thinkingContent += chunkContent;
+            yield { type: 'thinking', data: chunkContent };
+          }
+          break;
+
+        case StreamState.IN_CONTENT:
+          // Direct content output
+          yield { type: 'content', data: chunkContent };
+          break;
+      }
+    }
+
+    // Handle remaining buffer only if not already yielded
+    if (processor.buffer && processor.state !== StreamState.IN_CONTENT) {
+      if (processor.state === StreamState.IN_THINKING) {
+        yield { type: 'thinking', data: processor.buffer };
+      } else {
+        yield { type: 'content', data: processor.buffer };
+      }
+    }
+  }
+
   async *streamChat(
     message: string,
     history: ChatMessage[],
@@ -64,7 +249,7 @@ export class ChatService {
     enableQueryExpansion?: boolean, // New
     enableHyDE?: boolean, // New
     tenantId?: string // New: tenant isolation
-  ): AsyncGenerator<{ type: 'content' | 'sources' | 'historyId'; data: any }> {
+  ): AsyncGenerator<ChatStreamChunk> {
     console.log('=== ChatService.streamChat ===');
     console.log('User ID:', userId);
     console.log('User language:', userLanguage);
@@ -110,6 +295,20 @@ export class ChatService {
 
       // Save user message
       await this.searchHistoryService.addMessage(currentHistoryId, 'user', message);
+      
+      // Return history (上文) if there are previous messages
+      if (history && history.length > 0) {
+        yield {
+          type: 'history',
+          data: {
+            messages: history.map(msg => ({
+              role: msg.role,
+              content: msg.content,
+            })),
+          },
+        };
+      }
+      
       // 1. Get user's embedding model settings
       let embeddingModel: any;
 
@@ -125,7 +324,13 @@ export class ChatService {
 
       // 2. Search using user's query directly
       console.log(this.i18nService.getMessage('startingSearch', effectiveUserLanguage));
-      yield { type: 'content', data: this.i18nService.getMessage('searching', effectiveUserLanguage) + '\n' };
+      yield { 
+        type: 'status', 
+        data: { 
+          stage: 'searching', 
+          message: this.i18nService.getMessage('searching', effectiveUserLanguage) 
+        } 
+      };
 
       let searchResults: any[] = [];
       let context = '';
@@ -169,16 +374,19 @@ export class ChatService {
           if (selectedGroups && selectedGroups.length > 0) {
             // User selected knowledge groups but no matches found
             const noMatchMsg = this.i18nService.getMessage('noMatchInKnowledgeGroup', effectiveUserLanguage);
-            yield { type: 'content', data: `⚠️ ${noMatchMsg}\n\n` };
+            yield { type: 'status', data: { stage: 'search_complete', message: `⚠️ ${noMatchMsg}` } };
           } else {
-            yield { type: 'content', data: this.i18nService.getMessage('noResults', effectiveUserLanguage) + '\n\n' };
+            yield { type: 'status', data: { stage: 'search_complete', message: this.i18nService.getMessage('noResults', effectiveUserLanguage) } };
           }
-          yield { type: 'content', data: `[Debug] ${this.i18nService.getMessage('searchScope', effectiveUserLanguage)}: ${selectedFiles ? selectedFiles.length + ' ' + this.i18nService.getMessage('files', effectiveUserLanguage) : selectedGroups ? selectedGroups.length + ' ' + this.i18nService.getMessage('notebooks', effectiveUserLanguage) : this.i18nService.getMessage('all', effectiveUserLanguage)}\n` };
-          yield { type: 'content', data: `[Debug] ${this.i18nService.getMessage('searchResults', effectiveUserLanguage)}: 0 ${this.i18nService.getMessage('items', effectiveUserLanguage)}\n` };
+          yield { type: 'status', data: { stage: 'debug', message: `${this.i18nService.getMessage('searchScope', effectiveUserLanguage)}: ${selectedFiles ? selectedFiles.length + ' ' + this.i18nService.getMessage('files', effectiveUserLanguage) : selectedGroups ? selectedGroups.length + ' ' + this.i18nService.getMessage('notebooks', effectiveUserLanguage) : this.i18nService.getMessage('all', effectiveUserLanguage)}` } };
+          yield { type: 'status', data: { stage: 'debug', message: `${this.i18nService.getMessage('searchResults', effectiveUserLanguage)}: 0 ${this.i18nService.getMessage('items', effectiveUserLanguage)}` } };
         } else {
           yield {
-            type: 'content',
-            data: `${searchResults.length} ${this.i18nService.getMessage('relevantInfoFound', effectiveUserLanguage)}。${this.i18nService.getMessage('generatingResponse', effectiveUserLanguage)}...\n\n`,
+            type: 'status',
+            data: { 
+              stage: 'search_complete', 
+              message: `${searchResults.length} ${this.i18nService.getMessage('relevantInfoFound', effectiveUserLanguage)}。${this.i18nService.getMessage('generatingResponse', effectiveUserLanguage)}...` 
+            },
           };
           // Debug info
           const scores = searchResults.map(r => {
@@ -188,9 +396,9 @@ export class ChatService {
             return r.score.toFixed(2);
           }).join(', ');
           const files = [...new Set(searchResults.map(r => r.fileName))].join(', ');
-          yield { type: 'content', data: `> [Debug] ${this.i18nService.getMessage('searchHits', effectiveUserLanguage)}: ${searchResults.length} ${this.i18nService.getMessage('items', effectiveUserLanguage)}\n` };
-          yield { type: 'content', data: `> [Debug] ${this.i18nService.getMessage('relevance', effectiveUserLanguage)}: ${scores}\n` };
-          yield { type: 'content', data: `> [Debug] ${this.i18nService.getMessage('sourceFiles', effectiveUserLanguage)}: ${files}\n\n---\n\n` };
+          yield { type: 'status', data: { stage: 'debug', message: `${this.i18nService.getMessage('searchHits', effectiveUserLanguage)}: ${searchResults.length} ${this.i18nService.getMessage('items', effectiveUserLanguage)}` } };
+          yield { type: 'status', data: { stage: 'debug', message: `${this.i18nService.getMessage('relevance', effectiveUserLanguage)}: ${scores}` } };
+          yield { type: 'status', data: { stage: 'debug', message: `${this.i18nService.getMessage('sourceFiles', effectiveUserLanguage)}: ${files}` } };
         }
       } catch (searchError) {
         console.error(this.i18nService.getMessage('searchFailedLog', effectiveUserLanguage) + ':', searchError);
@@ -203,6 +411,9 @@ export class ChatService {
         model: `${modelConfig.name} (${modelConfig.modelId})`,
         user: userId
       }, effectiveUserLanguage));
+      
+      yield { type: 'status', data: { stage: 'generating', message: this.i18nService.getMessage('generatingResponse', effectiveUserLanguage) } };
+      
       const llm = new ChatOpenAI({
         apiKey: modelConfig.apiKey || 'ollama',
         streaming: true,
@@ -233,11 +444,12 @@ export class ChatService {
         question: message,
       });
 
-      for await (const chunk of stream) {
-        if (chunk.content) {
-          fullResponse += chunk.content;
-          yield { type: 'content', data: chunk.content };
+      // Process stream with dynamic thinking detection
+      for await (const chunk of this.processStreamWithThinking(stream)) {
+        if (chunk.type === 'content' || chunk.type === 'thinking') {
+          fullResponse += chunk.data;
         }
+        yield chunk;
       }
 
       // Save AI response
@@ -276,7 +488,7 @@ export class ChatService {
       };
     } catch (error) {
       this.logger.error(this.i18nService.getMessage('chatStreamError', effectiveUserLanguage), error);
-      yield { type: 'content', data: `${this.i18nService.getMessage('error', effectiveUserLanguage)}: ${error.message}` };
+      yield { type: 'status', data: { stage: 'debug', message: `${this.i18nService.getMessage('error', effectiveUserLanguage)}: ${error.message}` } };
     }
   }
 

+ 36 - 0
server/src/types.ts

@@ -54,3 +54,39 @@ export interface AppSettings {
   // Language
   language: string;
 }
+
+// 3. Chat Stream Types (for streaming chat responses)
+export type ChatStreamType = 
+  | 'history'      // 上文:历史对话内容
+  | 'sources'      // 引用:搜索结果来源
+  | 'content'      // 正文:AI生成的回答
+  | 'thinking'     // 深度思考:推理过程
+  | 'status'       // 状态:搜索进度、调试信息
+  | 'historyId';   // 历史ID:聊天会话ID
+
+export interface ChatStreamChunk {
+  type: ChatStreamType;
+  data: any;
+}
+
+// History message structure
+export interface HistoryMessage {
+  role: 'user' | 'assistant';
+  content: string;
+}
+
+// Source reference structure
+export interface SourceReference {
+  fileName: string;
+  content: string;
+  score: number;
+  chunkIndex: number;
+  fileId: string;
+}
+
+// Status message structure
+export interface StatusMessage {
+  stage: 'searching' | 'search_complete' | 'generating' | 'debug';
+  message: string;
+  details?: any;
+}

+ 104 - 31
web/components/ChatInterface.tsx

@@ -12,7 +12,7 @@ import {
 } from '../types';
 import ChatMessage from './ChatMessage'; // Assuming ChatMessage is a default export based on original code
 import SearchResultsPanel from './SearchResultsPanel';
-import { chatService, ChatMessage as ChatMsg, ChatSource } from '../services/chatService';
+import { chatService, ChatMessage as ChatMsg, ChatSource, ChatStreamChunk, HistoryData, StatusData } from '../services/chatService';
 import { generateUUID } from '../utils/uuid';
 
 interface ChatInterfaceProps {
@@ -60,6 +60,9 @@ const ChatInterface: React.FC<ChatInterfaceProps> = ({
   const [isLoading, setIsLoading] = useState(false);
   const [sources, setSources] = useState<ChatSource[]>([]);
   const [showSources, setShowSources] = useState(false);
+  const [thinking, setThinking] = useState<string>('');
+  const [showThinking, setShowThinking] = useState(false);
+  const [statusMessage, setStatusMessage] = useState<string>('');
   const messagesEndRef = useRef<HTMLDivElement>(null);
   const inputRef = useRef<HTMLTextAreaElement>(null);
   const lastSubmitTime = useRef<number>(0);
@@ -195,6 +198,12 @@ const ChatInterface: React.FC<ChatInterfaceProps> = ({
 
       const botMessageId = generateUUID();
       let botContent = '';
+      let botThinking = '';
+
+      // Reset thinking state for new message
+      setThinking('');
+      setShowThinking(false);
+      setStatusMessage('');
 
       // 初期ボットメッセージを追加
       const botMessage: Message = {
@@ -227,35 +236,59 @@ const ChatInterface: React.FC<ChatInterfaceProps> = ({
       );
 
       for await (const chunk of stream) {
-        if (chunk.type === 'content') {
-          botContent += chunk.data;
-          setMessages(prev =>
-            prev.map(msg =>
-              msg.id === botMessageId
-                ? { ...msg, text: botContent }
-                : msg
-            )
-          );
-        } else if (chunk.type === 'sources') {
-          // Attach sources to the current bot message
-          setMessages(prev =>
-            prev.map(msg =>
-              msg.id === botMessageId
-                ? { ...msg, sources: chunk.data }
-                : msg
-            )
-          );
-        } else if (chunk.type === 'historyId') {
-          onHistoryIdCreated?.(chunk.data);
-        } else if (chunk.type === 'error') {
-          setMessages(prev =>
-            prev.map(msg =>
-              msg.id === botMessageId
-                ? { ...msg, text: t('errorMessage').replace('$1', chunk.data), isError: true }
-                : msg
-            )
-          );
-          break;
+        switch (chunk.type) {
+          case 'content':
+            botContent += chunk.data;
+            setMessages(prev =>
+              prev.map(msg =>
+                msg.id === botMessageId
+                  ? { ...msg, text: botContent }
+                  : msg
+              )
+            );
+            break;
+
+          case 'thinking':
+            botThinking += chunk.data;
+            setThinking(botThinking);
+            setShowThinking(true);
+            break;
+
+          case 'sources':
+            // Attach sources to the current bot message
+            setMessages(prev =>
+              prev.map(msg =>
+                msg.id === botMessageId
+                  ? { ...msg, sources: chunk.data }
+                  : msg
+              )
+            );
+            break;
+
+          case 'historyId':
+            onHistoryIdCreated?.(chunk.data);
+            break;
+
+          case 'status':
+            const statusData = chunk.data as StatusData;
+            if (statusData.stage === 'searching' || statusData.stage === 'generating') {
+              setStatusMessage(statusData.message);
+            }
+            // Debug messages can be logged or shown in dev mode
+            if (statusData.stage === 'debug') {
+              console.log('[Chat Debug]', statusData.message);
+            }
+            break;
+
+          case 'error':
+            setMessages(prev =>
+              prev.map(msg =>
+                msg.id === botMessageId
+                  ? { ...msg, text: t('errorMessage').replace('$1', chunk.data), isError: true }
+                  : msg
+              )
+            );
+            break;
         }
       }
     } catch (error: any) {
@@ -277,6 +310,8 @@ const ChatInterface: React.FC<ChatInterfaceProps> = ({
       setMessages((prev) => [...prev, errorMessage]);
     } finally {
       setIsLoading(false);
+      setStatusMessage('');
+      setShowThinking(false);
       lastSubmitTime.current = 0;
     }
   };
@@ -308,7 +343,45 @@ const ChatInterface: React.FC<ChatInterfaceProps> = ({
           />
         ))}
 
-        {isLoading && (
+        {/* Thinking Section */}
+        {showThinking && thinking && (
+          <div className="flex justify-start animate-in fade-in slide-in-from-left-2 duration-300">
+            <div className="flex flex-row gap-4 items-start translate-x-1">
+              <div className="w-9 h-9 rounded-xl bg-purple-100 backdrop-blur-sm border border-purple-200/50 flex items-center justify-center shadow-sm">
+                <Loader2 className="w-4 h-4 text-purple-600 animate-spin" />
+              </div>
+              <div className="bg-purple-50/80 backdrop-blur-md border border-purple-200/40 px-5 py-3.5 rounded-2xl rounded-tl-none shadow-sm max-w-2xl">
+                <div className="flex items-center gap-2 mb-2">
+                  <span className="text-xs font-semibold text-purple-600 uppercase tracking-wide">深度思考</span>
+                </div>
+                <div className="text-sm text-purple-800 whitespace-pre-wrap">{thinking}</div>
+              </div>
+            </div>
+          </div>
+        )}
+
+        {/* Status Message */}
+        {statusMessage && (
+          <div className="flex justify-start animate-in fade-in slide-in-from-left-2 duration-300">
+            <div className="flex flex-row gap-4 items-start translate-x-1">
+              <div className="w-9 h-9 rounded-xl bg-white/80 backdrop-blur-sm border border-slate-200/50 flex items-center justify-center shadow-sm">
+                <Loader2 className="w-4 h-4 text-indigo-600 animate-spin" />
+              </div>
+              <div className="bg-white/80 backdrop-blur-md border border-white/40 px-5 py-3.5 rounded-2xl rounded-tl-none shadow-sm flex items-center">
+                <div className="flex items-center gap-2">
+                  <div className="flex gap-1">
+                    <span className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce [animation-delay:-0.3s]"></span>
+                    <span className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce [animation-delay:-0.15s]"></span>
+                    <span className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce"></span>
+                  </div>
+                  <span className="text-sm font-medium text-slate-500 ml-2 tracking-wide">{statusMessage}</span>
+                </div>
+              </div>
+            </div>
+          </div>
+        )}
+
+        {isLoading && !statusMessage && !showThinking && (
           <div className="flex justify-start animate-in fade-in slide-in-from-left-2 duration-300">
             <div className="flex flex-row gap-4 items-start translate-x-1">
               <div className="w-9 h-9 rounded-xl bg-white/80 backdrop-blur-sm border border-slate-200/50 flex items-center justify-center shadow-sm">

+ 156 - 3
web/components/views/SettingsView.tsx

@@ -49,6 +49,7 @@ import { settingsService } from '../../services/settingsService';
 import { userSettingService } from '../../services/userSettingService';
 import { knowledgeGroupService } from '../../services/knowledgeGroupService';
 import { apiClient } from '../../services/apiClient';
+import { apiKeyService } from '../../services/apiKeyService';
 
 import { useConfirm } from '../../contexts/ConfirmContext';
 import { useToast } from '../../contexts/ToastContext';
@@ -63,7 +64,7 @@ interface SettingsViewProps {
     initialTab?: TabType;
 }
 
-type TabType = 'general' | 'user' | 'model' | 'tenants' | 'knowledge_base' | 'import_tasks';
+type TabType = 'general' | 'user' | 'model' | 'tenants' | 'knowledge_base' | 'import_tasks' | 'api_key';
 
 const buildTenantTree = (tenants: Tenant[]): Tenant[] => {
     const map = new Map<string, Tenant>();
@@ -177,6 +178,12 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
     const [passwordForm, setPasswordForm] = useState({ current: '', new: '', confirm: '' });
     const [passwordSuccess, setPasswordSuccess] = useState('');
 
+    // --- API Key State ---
+    const [apiKey, setApiKey] = useState<string>('');
+    const [isLoadingKey, setIsLoadingKey] = useState(false);
+    const [isRegenerating, setIsRegenerating] = useState(false);
+    const [apiKeyCopied, setApiKeyCopied] = useState(false);
+
     // --- App Settings State ---
     const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
     const [knowledgeGroups, setKnowledgeGroups] = useState<KnowledgeGroup[]>([]);
@@ -248,6 +255,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
             fetchUsers(1);
         } else if (activeTab === 'general') {
             fetchSettingsAndGroups();
+        } else if (activeTab === 'api_key') {
+            fetchApiKey();
         } else if (activeTab === 'tenants' && currentUser?.role === 'SUPER_ADMIN') {
             fetchTenantsData();
             fetchUsers(1); // Ensure users are loaded for admin binding
@@ -335,6 +344,48 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
         }
     };
 
+    const fetchApiKey = async () => {
+        setIsLoadingKey(true);
+        try {
+            const data = await apiKeyService.getApiKey();
+            setApiKey(data.apiKey);
+        } catch (error) {
+            console.error('Failed to fetch API key:', error);
+            showError('获取 API Key 失败');
+        } finally {
+            setIsLoadingKey(false);
+        }
+    };
+
+    const handleRegenerateApiKey = async () => {
+        if (!confirm('确定要重新生成 API Key 吗?旧的 API Key 将立即失效。')) {
+            return;
+        }
+        setIsRegenerating(true);
+        try {
+            const data = await apiKeyService.regenerateApiKey();
+            setApiKey(data.apiKey);
+            showSuccess('API Key 已重新生成');
+        } catch (error) {
+            console.error('Failed to regenerate API key:', error);
+            showError('重新生成 API Key 失败');
+        } finally {
+            setIsRegenerating(false);
+        }
+    };
+
+    const handleCopyApiKey = async () => {
+        try {
+            await navigator.clipboard.writeText(apiKey);
+            setApiKeyCopied(true);
+            showSuccess('已复制到剪贴板');
+            setTimeout(() => setApiKeyCopied(false), 2000);
+        } catch (error) {
+            console.error('Failed to copy:', error);
+            showError('复制失败');
+        }
+    };
+
     // --- 一般タブのハンドラー ---
 
 
@@ -846,6 +897,99 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
         </div >
     );
 
+    const renderApiKeyTab = () => (
+        <div className="space-y-8 animate-in slide-in-from-right duration-300 max-w-2xl">
+            <section className="bg-white p-6 rounded-lg border border-slate-200 shadow-sm">
+                <h3 className="text-sm font-medium text-slate-800 mb-4 flex items-center gap-2">
+                    <Key className="w-4 h-4 text-blue-500" />
+                    API Key 管理
+                </h3>
+                <p className="text-sm text-slate-500 mb-4">
+                    使用 API Key 可以通过开放 API 访问您的知识库。请妥善保管您的 API Key,不要泄露给他人。
+                </p>
+
+                {isLoadingKey ? (
+                    <div className="flex items-center gap-2 text-slate-500">
+                        <Loader2 className="w-4 h-4 animate-spin" />
+                        <span className="text-sm">加载中...</span>
+                    </div>
+                ) : (
+                    <div className="space-y-4">
+                        <div>
+                            <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 block mb-2">
+                                您的 API Key
+                            </label>
+                            <div className="flex gap-2">
+                                <input
+                                    type="text"
+                                    value={apiKey}
+                                    readOnly
+                                    className="flex-1 px-3 py-2 text-sm font-mono bg-slate-50 border border-slate-300 rounded-md text-slate-700"
+                                />
+                                <button
+                                    onClick={handleCopyApiKey}
+                                    className="px-4 py-2 bg-slate-100 text-slate-700 rounded-md text-sm hover:bg-slate-200 transition-colors flex items-center gap-2"
+                                >
+                                    {apiKeyCopied ? <Check className="w-4 h-4" /> : <Download className="w-4 h-4" />}
+                                    {apiKeyCopied ? '已复制' : '复制'}
+                                </button>
+                            </div>
+                        </div>
+
+                        <div className="pt-4 border-t border-slate-200">
+                            <button
+                                onClick={handleRegenerateApiKey}
+                                disabled={isRegenerating}
+                                className="px-4 py-2 bg-red-50 text-red-600 rounded-md text-sm hover:bg-red-100 transition-colors disabled:opacity-50 flex items-center gap-2"
+                            >
+                                {isRegenerating ? (
+                                    <Loader2 className="w-4 h-4 animate-spin" />
+                                ) : (
+                                    <RefreshCcw className="w-4 h-4" />
+                                )}
+                                重新生成 API Key
+                            </button>
+                            <p className="text-xs text-slate-400 mt-2">
+                                注意:重新生成后,旧的 API Key 将立即失效,所有使用旧 Key 的应用将无法访问。
+                            </p>
+                        </div>
+                    </div>
+                )}
+            </section>
+
+            <section className="bg-white p-6 rounded-lg border border-slate-200 shadow-sm">
+                <h3 className="text-sm font-medium text-slate-800 mb-4 flex items-center gap-2">
+                    <ExternalLink className="w-4 h-4 text-blue-500" />
+                    API 使用示例
+                </h3>
+                <div className="space-y-4">
+                    <div>
+                        <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 block mb-2">
+                            使用 x-api-key 头
+                        </label>
+                        <pre className="bg-slate-900 text-slate-100 p-4 rounded-lg text-xs overflow-x-auto">
+{`curl -X POST http://localhost:3001/api/v1/chat \\
+  -H "Content-Type: application/json" \\
+  -H "x-api-key: ${apiKey || 'YOUR_API_KEY'}" \\
+  -d '{"message": "你好", "stream": false}'`}
+                        </pre>
+                    </div>
+                    <div>
+                        <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 block mb-2">
+                            使用 Authorization 头
+                        </label>
+                        <pre className="bg-slate-900 text-slate-100 p-4 rounded-lg text-xs overflow-x-auto">
+{`curl -X POST http://localhost:3001/api/v1/chat \\
+  -H "Content-Type: application/json" \\
+  -H "Authorization: Bearer ${apiKey || 'YOUR_API_KEY'}" \\
+  -d '{"message": "你好", "stream": true}'`}
+                        </pre>
+                    </div>
+                </div>
+            </section>
+        </div>
+    );
+
     const renderUserTab = () => (
         <div className="space-y-6 w-full">
             <div className="flex justify-between items-center mb-6">
@@ -2064,6 +2208,14 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                         <SettingsIcon size={18} />
                         {t('generalSettings')}
                     </button>
+                    <button
+                        onClick={() => setActiveTab('api_key')}
+                        className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${activeTab === 'api_key' ? 'bg-white text-indigo-600 shadow-sm border border-slate-200/60' : 'text-slate-600 hover:bg-slate-100'
+                            }`}
+                    >
+                        <Key size={18} />
+                        API Key
+                    </button>
                     {currentUser?.role === 'SUPER_ADMIN' && (
                         <button
                             onClick={() => setActiveTab('user')}
@@ -2112,10 +2264,10 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                 <div className="px-8 pt-8 pb-6 flex items-start justify-between shrink-0">
                     <div>
                         <h1 className="text-2xl font-bold text-slate-900 leading-tight">
-                            {activeTab === 'general' ? t('generalSettings') : activeTab === 'user' ? t('userManagement') : activeTab === 'model' ? t('modelManagement') : activeTab === 'knowledge_base' ? t('sidebarTitle') : t('navTenants')}
+                            {activeTab === 'general' ? t('generalSettings') : activeTab === 'api_key' ? 'API Key 管理' : activeTab === 'user' ? t('userManagement') : activeTab === 'model' ? t('modelManagement') : activeTab === 'knowledge_base' ? t('sidebarTitle') : t('navTenants')}
                         </h1>
                         <p className="text-[15px] text-slate-500 mt-1">
-                            {activeTab === 'general' ? t('generalSettingsSubtitle') : activeTab === 'user' ? t('userManagementSubtitle') : activeTab === 'model' ? t('modelManagementSubtitle') : activeTab === 'knowledge_base' ? t('kbSettingsSubtitle') : t('tenantsSubtitle')}
+                            {activeTab === 'general' ? t('generalSettingsSubtitle') : activeTab === 'api_key' ? '查看和管理您的 API Key' : activeTab === 'user' ? t('userManagementSubtitle') : activeTab === 'model' ? t('modelManagementSubtitle') : activeTab === 'knowledge_base' ? t('kbSettingsSubtitle') : t('tenantsSubtitle')}
                         </p>
                     </div>
                 </div>
@@ -2147,6 +2299,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                 transition={{ duration: 0.3 }}
                             >
                                 {activeTab === 'general' && renderGeneralTab()}
+                                {activeTab === 'api_key' && renderApiKeyTab()}
                                 {activeTab === 'user' && currentUser?.role === 'SUPER_ADMIN' && renderUserTab()}
                                 {activeTab === 'model' && isAdmin && renderModelTab()}
                                 {activeTab === 'knowledge_base' && isAdmin && renderKnowledgeBaseTab()}

+ 10 - 0
web/public/vite.svg

@@ -0,0 +1,10 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
+  <defs>
+    <linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
+      <stop offset="0%" style="stop-color:#6366f1;stop-opacity:1" />
+      <stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
+    </linearGradient>
+  </defs>
+  <rect width="100" height="100" rx="20" fill="url(#grad)"/>
+  <text x="50" y="68" font-family="Arial, sans-serif" font-size="50" font-weight="bold" fill="white" text-anchor="middle">A</text>
+</svg>

+ 25 - 0
web/services/apiKeyService.ts

@@ -0,0 +1,25 @@
+import { apiClient } from './apiClient';
+
+export interface ApiKeyInfo {
+  apiKey: string;
+}
+
+class ApiKeyService {
+  /**
+   * Get current user's API key (creates one if not exists)
+   */
+  async getApiKey(): Promise<ApiKeyInfo> {
+    const response = await apiClient.get<ApiKeyInfo>('/users/api-key');
+    return response.data;
+  }
+
+  /**
+   * Regenerate API key for current user
+   */
+  async regenerateApiKey(): Promise<ApiKeyInfo> {
+    const response = await apiClient.post<ApiKeyInfo>('/users/api-key/rotate');
+    return response.data;
+  }
+}
+
+export const apiKeyService = new ApiKeyService();

+ 26 - 1
web/services/chatService.ts

@@ -14,6 +14,31 @@ export interface ChatSource {
   fileId?: string;
 }
 
+// New types for streaming chat responses
+export type ChatStreamType = 
+  | 'history'      // 上文:历史对话内容
+  | 'sources'      // 引用:搜索结果来源
+  | 'content'      // 正文:AI生成的回答
+  | 'thinking'     // 深度思考:推理过程
+  | 'status'       // 状态:搜索进度、调试信息
+  | 'historyId'    // 历史ID:聊天会话ID
+  | 'error';       // 错误
+
+export interface ChatStreamChunk {
+  type: ChatStreamType;
+  data: any;
+}
+
+export interface HistoryData {
+  messages: ChatMessage[];
+}
+
+export interface StatusData {
+  stage: 'searching' | 'search_complete' | 'generating' | 'debug';
+  message: string;
+  details?: any;
+}
+
 export class ChatService {
   async *streamChat(
     message: string,
@@ -34,7 +59,7 @@ export class ChatService {
     rerankSimilarityThreshold?: number, // 追加: rerankSimilarityThreshold パラメータ
     enableQueryExpansion?: boolean, // 追加
     enableHyDE?: boolean // 追加
-  ): AsyncGenerator<{ type: 'content' | 'sources' | 'error' | 'historyId'; data: any }> {
+  ): AsyncGenerator<ChatStreamChunk> {
     try {
       const response = await apiClient.request('/chat/stream', {
         method: 'POST',

File diff ditekan karena terlalu besar
+ 537 - 158
yarn.lock


Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini