Compare commits
54 Commits
backup-bef
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
b66bdaedd2 | |
|
|
1df9ee3cf2 | |
|
|
379e033e17 | |
|
|
eff089c7ad | |
|
|
7b4fb72cdc | |
|
|
9566c6e0c4 | |
|
|
4d2caddeee | |
|
|
b51831dd15 | |
|
|
3b7a831840 | |
|
|
d08ddaadfd | |
|
|
0ff9f1fd74 | |
|
|
7569e588b9 | |
|
|
ecff6edd61 | |
|
|
bb44134e08 | |
|
|
2417a769bb | |
|
|
fb7f72fcb9 | |
|
|
7922227f46 | |
|
|
f5b6a499ed | |
|
|
bdba44c0a1 | |
|
|
39b5e30d4c | |
|
|
8b8f77cfcc | |
|
|
965514b7b4 | |
|
|
8c2bfbb214 | |
|
|
072eb95b4b | |
|
|
0160e0e8fa | |
|
|
b9d302e543 | |
|
|
f3650da7e1 | |
|
|
5232b1482f | |
|
|
0da4e06050 | |
|
|
92d5f78e1d | |
|
|
3471e8552f | |
|
|
38faeeb46d | |
|
|
3c53e89b43 | |
|
|
d81fb4d0a0 | |
|
|
7b40e60872 | |
|
|
ffe7642165 | |
|
|
9a141585a1 | |
|
|
9ca89209af | |
|
|
b2bbba8db6 | |
|
|
34e0bc4de7 | |
|
|
18cedb5d14 | |
|
|
354c6f4cc7 | |
|
|
244907b152 | |
|
|
3421d0db47 | |
|
|
ec96a424c4 | |
|
|
b6ee6c949b | |
|
|
fe4ee53c38 | |
|
|
633e5101a2 | |
|
|
3f2c964731 | |
|
|
2706a0c732 | |
|
|
d8a6f696e7 | |
|
|
547ba742b7 | |
|
|
c33067fbcb | |
|
|
e9c563ce77 |
|
|
@ -16,6 +16,10 @@ uploads
|
||||||
.venv
|
.venv
|
||||||
__pycache__
|
__pycache__
|
||||||
.claude
|
.claude
|
||||||
|
*.db
|
||||||
|
.trae
|
||||||
|
.agent
|
||||||
|
.agents
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|
@ -27,3 +31,14 @@ __pycache__
|
||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
# Skills
|
||||||
|
.skills
|
||||||
|
.agent
|
||||||
|
.agents
|
||||||
|
.trae
|
||||||
|
skills-lock.json
|
||||||
|
*.db
|
||||||
|
server/data/*.db
|
||||||
|
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
269
README.md
269
README.md
|
|
@ -1,6 +1,6 @@
|
||||||
# AI-CHAT-UI
|
# AI-CHAT-UI
|
||||||
|
|
||||||
一个基于 Vue 和 markstream-vue 构建的现代化 AI 对话界面,提供丰富的交互功能和精美的视觉体验。
|
一个基于 Vue 和 markstream-vue 构建的现代化 AI 对话界面,提供丰富的交互功能和精美的视觉体验。支持多模型集成,包括智谱 GLM、阿里云百炼、OpenAI/Deepseek 等。
|
||||||
|
|
||||||
## 页面展示
|
## 页面展示
|
||||||
|
|
||||||
|
|
@ -14,6 +14,8 @@
|
||||||
|
|
||||||
## ✨ 核心功能
|
## ✨ 核心功能
|
||||||
|
|
||||||
|
### 前端功能
|
||||||
|
|
||||||
| 功能 | 详细描述 |
|
| 功能 | 详细描述 |
|
||||||
|-------|-------------------------------------|
|
|-------|-------------------------------------|
|
||||||
| 对话历史 | 支持多对话管理、置顶、重命名、删除 |
|
| 对话历史 | 支持多对话管理、置顶、重命名、删除 |
|
||||||
|
|
@ -36,34 +38,106 @@
|
||||||
| 数据管理 | 导入/导出设置、清除数据,保障数据安全 |
|
| 数据管理 | 导入/导出设置、清除数据,保障数据安全 |
|
||||||
| 预设提示词 | 快速选择常用角色设定,提升对话效率 |
|
| 预设提示词 | 快速选择常用角色设定,提升对话效率 |
|
||||||
|
|
||||||
|
### 后端功能
|
||||||
|
|
||||||
|
| 功能 | 详细描述 |
|
||||||
|
|-------|-------------------------------------|
|
||||||
|
| OpenAI 兼容 API | 提供标准 OpenAI 兼容接口,支持多模型路由 |
|
||||||
|
| 多模型支持 | 集成智谱 GLM、阿里云百炼、OpenAI/Deepseek 等平台 |
|
||||||
|
| 会话管理 | 支持对话历史的保存、加载、删除 |
|
||||||
|
| 文件上传 | 支持图片、文档等文件的上传和管理 |
|
||||||
|
| 健康检查 | 提供系统状态和可用模型检查 |
|
||||||
|
| 日志系统 | 详细的请求日志和响应时间记录 |
|
||||||
|
|
||||||
## 🛠 技术栈
|
## 🛠 技术栈
|
||||||
|
|
||||||
- **核心框架**: Vue
|
### 前端
|
||||||
|
- **核心框架**: Vue 3
|
||||||
- **流式渲染**: markstream-vue
|
- **流式渲染**: markstream-vue
|
||||||
|
- **类型系统**: TypeScript
|
||||||
- **UI 设计**: 现代化响应式设计,支持暗色主题
|
- **UI 设计**: 现代化响应式设计,支持暗色主题
|
||||||
|
|
||||||
- **交互体验**: 丰富的快捷键系统和消息操作
|
- **交互体验**: 丰富的快捷键系统和消息操作
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
- **核心框架**: FastAPI
|
||||||
|
- **编程语言**: Python 3.12+
|
||||||
|
- **数据库**: SQLite
|
||||||
|
- **API 设计**: RESTful API + OpenAI 兼容接口
|
||||||
|
- **文件存储**: 本地文件系统
|
||||||
|
|
||||||
|
### 模型支持
|
||||||
|
- **智谱 GLM**: glm-4-flash, glm-4, 等
|
||||||
|
- **阿里云百炼**: qwen-turbo, qwen-plus, 等
|
||||||
|
- **OpenAI/Deepseek**: gpt-3.5-turbo, gpt-4, deepseek-chat, 等
|
||||||
|
|
||||||
## 🚀 快速开始
|
## 🚀 快速开始
|
||||||
|
|
||||||
```Bash
|
### 1. 克隆仓库
|
||||||
|
|
||||||
# 克隆仓库
|
```bash
|
||||||
git clone https://github.com/zll-it/ai-chat-ui.git
|
git clone https://github.com/zll-it/ai-chat-ui.git
|
||||||
|
|
||||||
# 进入项目目录
|
|
||||||
cd ai-chat-ui
|
cd ai-chat-ui
|
||||||
|
```
|
||||||
|
|
||||||
# 安装依赖
|
### 2. 安装前端依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
npm install
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
# 启动开发服务器
|
### 3. 安装后端依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate # Linux/Mac
|
||||||
|
# .venv\Scripts\activate # Windows
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 配置 API 密钥
|
||||||
|
|
||||||
|
在 `server` 目录下创建 `.env` 文件,添加以下配置:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# 智谱 GLM API 配置
|
||||||
|
ZHIPUAI_API_KEY=your_zhipuai_api_key
|
||||||
|
|
||||||
|
# 阿里云百炼 API 配置
|
||||||
|
DASHSCOPE_API_KEY=your_dashscope_api_key
|
||||||
|
|
||||||
|
# OpenAI API 配置(支持 Deepseek 等兼容接口)
|
||||||
|
OPENAI_API_KEY=your_openai_api_key
|
||||||
|
OPENAI_API_BASE=https://api.openai.com/v1 # 或其他兼容接口地址
|
||||||
|
|
||||||
|
# 服务器配置
|
||||||
|
PORT=8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 启动服务
|
||||||
|
|
||||||
|
#### 启动后端服务器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在 server 目录下
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 启动前端开发服务器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在项目根目录下
|
||||||
npm run dev
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
# 构建生产版本
|
### 6. 构建生产版本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建前端
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
|
# 后端服务可以直接运行
|
||||||
|
python server/main.py
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📋 使用说明
|
## 📋 使用说明
|
||||||
|
|
@ -71,11 +145,8 @@ npm run build
|
||||||
### 基础操作
|
### 基础操作
|
||||||
|
|
||||||
- **新建对话**: `Ctrl+N` 快捷键或点击页面右上角 "+" 按钮
|
- **新建对话**: `Ctrl+N` 快捷键或点击页面右上角 "+" 按钮
|
||||||
|
|
||||||
- **切换布局**: 点击页面右下角布局切换按钮
|
- **切换布局**: 点击页面右下角布局切换按钮
|
||||||
|
|
||||||
- **主题切换**: 设置面板中选择浅色/深色/跟随系统
|
- **主题切换**: 设置面板中选择浅色/深色/跟随系统
|
||||||
|
|
||||||
- **搜索对话**: 使用页面顶部搜索框或快捷键 `Ctrl+K`
|
- **搜索对话**: 使用页面顶部搜索框或快捷键 `Ctrl+K`
|
||||||
|
|
||||||
### 快捷键一览
|
### 快捷键一览
|
||||||
|
|
@ -87,7 +158,146 @@ npm run build
|
||||||
| 复制当前消息 | Ctrl+C (消息 hover 时) |
|
| 复制当前消息 | Ctrl+C (消息 hover 时) |
|
||||||
| 切换布局 | Ctrl+Shift+L |
|
| 切换布局 | Ctrl+Shift+L |
|
||||||
|
|
||||||
## 📄 许可证
|
### 模型选择
|
||||||
|
|
||||||
|
在对话设置中,可以选择不同的模型:
|
||||||
|
|
||||||
|
- **智谱 GLM**: 国内高性能模型,响应速度快
|
||||||
|
- **阿里云百炼**: 支持多语言和多模态
|
||||||
|
- **OpenAI**: 全球领先的 GPT 系列模型
|
||||||
|
- **Deepseek**: 专注于代码和技术领域的模型
|
||||||
|
|
||||||
|
## 📡 API 文档
|
||||||
|
|
||||||
|
### OpenAI 兼容接口
|
||||||
|
|
||||||
|
#### POST /v1/chat/completions
|
||||||
|
|
||||||
|
标准的 OpenAI 兼容聊天接口,支持流式输出。
|
||||||
|
|
||||||
|
**请求示例**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": "glm-4-flash",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "你好,请介绍一下你自己"}
|
||||||
|
],
|
||||||
|
"stream": true,
|
||||||
|
"temperature": 0.7
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应示例**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "chatcmpl-123",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": 1677858242,
|
||||||
|
"model": "glm-4-flash",
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "你好!我是一个基于智谱 GLM 模型的AI助手..."
|
||||||
|
},
|
||||||
|
"finish_reason": "stop"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GET /v1/models
|
||||||
|
|
||||||
|
获取所有可用模型列表。
|
||||||
|
|
||||||
|
**响应示例**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"object": "list",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "glm-4-flash",
|
||||||
|
"object": "model",
|
||||||
|
"created": 1677825464,
|
||||||
|
"owned_by": "zhipuai"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "gpt-3.5-turbo",
|
||||||
|
"object": "model",
|
||||||
|
"created": 1677610602,
|
||||||
|
"owned_by": "openai"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 传统接口
|
||||||
|
|
||||||
|
#### POST /api/chat-ui/chat
|
||||||
|
|
||||||
|
传统聊天接口,保持向后兼容。
|
||||||
|
|
||||||
|
#### GET /api/chat-ui/models
|
||||||
|
|
||||||
|
获取模型列表(聚合所有可用平台)。
|
||||||
|
|
||||||
|
#### GET /api/chat-ui/conversations
|
||||||
|
|
||||||
|
获取所有对话历史。
|
||||||
|
|
||||||
|
#### POST /api/chat-ui/upload
|
||||||
|
|
||||||
|
上传文件(支持图片、文档等)。
|
||||||
|
|
||||||
|
## 🔧 配置说明
|
||||||
|
|
||||||
|
### 前端配置
|
||||||
|
|
||||||
|
前端配置文件位于 `src/config.ts`,主要配置项:
|
||||||
|
|
||||||
|
- **API_BASE_URL**: 后端 API 地址
|
||||||
|
- **DEFAULT_MODEL**: 默认模型
|
||||||
|
- **THEME**: 默认主题
|
||||||
|
- **FONT_SIZE**: 默认字体大小
|
||||||
|
|
||||||
|
### 后端配置
|
||||||
|
|
||||||
|
后端配置通过 `.env` 文件和 `server/config.py` 进行管理:
|
||||||
|
|
||||||
|
- **PORT**: 服务器端口
|
||||||
|
- **API 密钥**: 各平台的 API 密钥
|
||||||
|
- **上传目录**: 文件上传的存储路径
|
||||||
|
- **数据库配置**: SQLite 数据库设置
|
||||||
|
|
||||||
|
## <20> 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
ai-chat-ui/
|
||||||
|
├── src/ # 前端源码
|
||||||
|
│ ├── components/ # Vue 组件
|
||||||
|
│ ├── services/ # API 服务
|
||||||
|
│ ├── utils/ # 工具函数
|
||||||
|
│ ├── App.vue # 主应用组件
|
||||||
|
│ └── main.ts # 入口文件
|
||||||
|
├── server/ # 后端源码
|
||||||
|
│ ├── adapters/ # 模型适配器
|
||||||
|
│ ├── api/ # API 路由
|
||||||
|
│ ├── database/ # 数据库操作
|
||||||
|
│ ├── utils/ # 工具函数
|
||||||
|
│ ├── main.py # 主入口
|
||||||
|
│ └── requirements.txt # 依赖文件
|
||||||
|
├── public/ # 静态资源
|
||||||
|
├── screenshots/ # 截图
|
||||||
|
├── package.json # 前端依赖
|
||||||
|
├── tsconfig.json # TypeScript 配置
|
||||||
|
└── README.md # 项目说明
|
||||||
|
```
|
||||||
|
|
||||||
|
## <20>📄 许可证
|
||||||
|
|
||||||
本项目采用 MIT 许可证 - 详见 [LICENSE](LICENSE) 文件
|
本项目采用 MIT 许可证 - 详见 [LICENSE](LICENSE) 文件
|
||||||
|
|
||||||
|
|
@ -95,10 +305,35 @@ npm run build
|
||||||
|
|
||||||
欢迎提交 Issue 和 Pull Request 来帮助改进这个项目!
|
欢迎提交 Issue 和 Pull Request 来帮助改进这个项目!
|
||||||
|
|
||||||
|
### 开发流程
|
||||||
|
|
||||||
|
1. Fork 本仓库
|
||||||
|
2. 创建特性分支 (`git checkout -b feature/amazing-feature`)
|
||||||
|
3. 提交更改 (`git commit -m 'Add amazing feature'`)
|
||||||
|
4. 推送到分支 (`git push origin feature/amazing-feature`)
|
||||||
|
5. 打开 Pull Request
|
||||||
|
|
||||||
|
## 🌟 特性亮点
|
||||||
|
|
||||||
|
- **多模型集成**: 支持多个 AI 平台的模型,灵活切换
|
||||||
|
- **现代化界面**: 美观的 UI 设计,支持深色主题
|
||||||
|
- **流畅体验**: 流式输出和丰富的交互效果
|
||||||
|
- **功能丰富**: 完整的对话管理和设置选项
|
||||||
|
- **易于部署**: 简单的配置和启动流程
|
||||||
|
- **OpenAI 兼容**: 标准的 OpenAI 兼容接口
|
||||||
|
|
||||||
|
## 📞 支持
|
||||||
|
|
||||||
|
如果您有任何问题或建议,欢迎:
|
||||||
|
|
||||||
|
- 提交 Issue
|
||||||
|
- 发送邮件至:contact@example.com
|
||||||
|
- 参与项目讨论
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<sub>Made with ❤️ using Vue & markstream-vue</sub>
|
<sub>Made with ❤️ using Vue & FastAPI</sub>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -6,7 +6,10 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:run": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@microsoft/fetch-event-source": "^2.0.1",
|
"@microsoft/fetch-event-source": "^2.0.1",
|
||||||
|
|
@ -30,14 +33,18 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"@vitest/coverage-v8": "^4.1.1",
|
||||||
|
"@vue/test-utils": "^2.4.6",
|
||||||
"@vue/tsconfig": "^0.8.1",
|
"@vue/tsconfig": "^0.8.1",
|
||||||
"autoprefixer": "^10.4.24",
|
"autoprefixer": "^10.4.24",
|
||||||
|
"happy-dom": "^20.8.8",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"sass": "^1.97.3",
|
"sass": "^1.97.3",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"vite": "^7.2.4",
|
"vite": "^7.2.4",
|
||||||
|
"vitest": "^4.1.1",
|
||||||
"vue-tsc": "^3.1.4"
|
"vue-tsc": "^3.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ class ModelInfo:
|
||||||
"maxTokens": self.max_tokens,
|
"maxTokens": self.max_tokens,
|
||||||
"provider": self.provider,
|
"provider": self.provider,
|
||||||
"supports_thinking": self.supports_thinking,
|
"supports_thinking": self.supports_thinking,
|
||||||
"supports_web_Search": self.supports_web_search,
|
"supports_web_search": self.supports_web_search,
|
||||||
"supports_vision": self.supports_vision,
|
"supports_vision": self.supports_vision,
|
||||||
"supports_files": self.supports_files,
|
"supports_files": self.supports_files,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,42 +10,49 @@ from typing import Dict, List
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
|
|
||||||
from .base import BaseAdapter, ChatCompletionRequest, ModelInfo
|
from .base import BaseAdapter, ChatCompletionRequest, ModelInfo
|
||||||
from utils.logger import get_logger
|
from .plugins import get_web_search_mode
|
||||||
|
from core import get_logger
|
||||||
|
|
||||||
logger = get_logger()
|
logger = get_logger()
|
||||||
|
|
||||||
|
# 支持深度思考的模型
|
||||||
|
THINKING_MODELS = {"qwen3-max", "qwen3.5-plus"}
|
||||||
|
|
||||||
|
# 需要使用多模态接口的模型(qwen3.5 系列)
|
||||||
|
MULTIMODAL_API_MODELS = {"qwen3.5-plus", "qwen3.5-flash"}
|
||||||
|
|
||||||
# 百炼模型配置
|
# 百炼模型配置
|
||||||
DASHSCOPE_MODELS = [
|
DASHSCOPE_MODELS = [
|
||||||
ModelInfo(
|
ModelInfo(
|
||||||
id="qwen-max",
|
id="qwen3-max",
|
||||||
name="通义千问 Max",
|
name="Qwen3-Max",
|
||||||
description="最强大的模型",
|
description="千问系列效果最好的模型,适合复杂、多步骤的任务。",
|
||||||
max_tokens=8192,
|
max_tokens=8192,
|
||||||
provider="Aliyun",
|
provider="Aliyun",
|
||||||
supports_thinking=True,
|
supports_thinking=True,
|
||||||
supports_web_search=False,
|
supports_web_search=True,
|
||||||
supports_vision=False,
|
supports_vision=False,
|
||||||
supports_files=False,
|
supports_files=False,
|
||||||
),
|
),
|
||||||
ModelInfo(
|
ModelInfo(
|
||||||
id="qwen-plus",
|
id="qwen3.5-plus",
|
||||||
name="通义千问 Plus",
|
name="Qwen3.5-Plus",
|
||||||
description="能力均衡",
|
description="能力均衡,推理效果、成本和速度介于千问Max和千问Flash之间,适合中等复杂任务。",
|
||||||
max_tokens=8192,
|
max_tokens=8192,
|
||||||
provider="Aliyun",
|
provider="Aliyun",
|
||||||
supports_thinking=False,
|
supports_thinking=True,
|
||||||
supports_web_search=False,
|
supports_web_search=True,
|
||||||
supports_vision=False,
|
supports_vision=True,
|
||||||
supports_files=False,
|
supports_files=False,
|
||||||
),
|
),
|
||||||
ModelInfo(
|
ModelInfo(
|
||||||
id="qwen-turbo",
|
id="qwen3.5-flash",
|
||||||
name="通义千问 Turbo",
|
name="Qwen3.5-Flash",
|
||||||
description="速度更快、成本更低",
|
description="千问系列速度最快、成本极低的模型,适合简单任务。千问Flash采用灵活的阶梯定价,相比千问Turbo计费更合理。",
|
||||||
max_tokens=8192,
|
max_tokens=8192,
|
||||||
provider="Aliyun",
|
provider="Aliyun",
|
||||||
supports_thinking=False,
|
supports_thinking=False,
|
||||||
supports_web_search=False,
|
supports_web_search=True,
|
||||||
supports_vision=False,
|
supports_vision=False,
|
||||||
supports_files=False,
|
supports_files=False,
|
||||||
),
|
),
|
||||||
|
|
@ -60,17 +67,6 @@ DASHSCOPE_MODELS = [
|
||||||
supports_vision=True,
|
supports_vision=True,
|
||||||
supports_files=False,
|
supports_files=False,
|
||||||
),
|
),
|
||||||
ModelInfo(
|
|
||||||
id="qwen-vl-plus",
|
|
||||||
name="通义万相 VL-Plus",
|
|
||||||
description="支持视觉理解的多模态模型",
|
|
||||||
max_tokens=8192,
|
|
||||||
provider="Aliyun",
|
|
||||||
supports_thinking=False,
|
|
||||||
supports_web_search=False,
|
|
||||||
supports_vision=True,
|
|
||||||
supports_files=False,
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -89,6 +85,14 @@ class DashScopeAdapter(BaseAdapter):
|
||||||
"""获取 API Key"""
|
"""获取 API Key"""
|
||||||
return os.getenv("ALIYUN_API_KEY") or os.getenv("DASHSCOPE_API_KEY", "")
|
return os.getenv("ALIYUN_API_KEY") or os.getenv("DASHSCOPE_API_KEY", "")
|
||||||
|
|
||||||
|
def _needs_multimodal_api(self, model: str) -> bool:
|
||||||
|
"""检查模型是否需要使用多模态 API"""
|
||||||
|
return model.lower() in MULTIMODAL_API_MODELS
|
||||||
|
|
||||||
|
def _supports_thinking(self, model: str) -> bool:
|
||||||
|
"""检查模型是否支持深度思考"""
|
||||||
|
return model.lower() in THINKING_MODELS
|
||||||
|
|
||||||
def list_models(self) -> List[ModelInfo]:
|
def list_models(self) -> List[ModelInfo]:
|
||||||
return DASHSCOPE_MODELS
|
return DASHSCOPE_MODELS
|
||||||
|
|
||||||
|
|
@ -104,6 +108,7 @@ class DashScopeAdapter(BaseAdapter):
|
||||||
logger.info(f" - temperature: {request.temperature}")
|
logger.info(f" - temperature: {request.temperature}")
|
||||||
logger.info(f" - max_tokens: {request.max_tokens}")
|
logger.info(f" - max_tokens: {request.max_tokens}")
|
||||||
logger.info(f" - files: {request.files}")
|
logger.info(f" - files: {request.files}")
|
||||||
|
logger.info(f" - deep_thinking: {request.deep_thinking}")
|
||||||
logger.info(
|
logger.info(
|
||||||
f" - messages: {json.dumps(request.messages, ensure_ascii=False, indent=2)}"
|
f" - messages: {json.dumps(request.messages, ensure_ascii=False, indent=2)}"
|
||||||
)
|
)
|
||||||
|
|
@ -112,7 +117,11 @@ class DashScopeAdapter(BaseAdapter):
|
||||||
has_multimodal = self._has_multimodal_content(request)
|
has_multimodal = self._has_multimodal_content(request)
|
||||||
logger.info(f" - has_multimodal: {has_multimodal}")
|
logger.info(f" - has_multimodal: {has_multimodal}")
|
||||||
|
|
||||||
if has_multimodal:
|
# 检查是否需要使用多模态接口(qwen3.5 系列)
|
||||||
|
needs_multimodal_api = self._needs_multimodal_api(request.model)
|
||||||
|
logger.info(f" - needs_multimodal_api: {needs_multimodal_api}")
|
||||||
|
|
||||||
|
if has_multimodal or needs_multimodal_api:
|
||||||
return await self._multimodal_chat(request)
|
return await self._multimodal_chat(request)
|
||||||
else:
|
else:
|
||||||
return await self._text_chat(request)
|
return await self._text_chat(request)
|
||||||
|
|
@ -136,6 +145,9 @@ class DashScopeAdapter(BaseAdapter):
|
||||||
|
|
||||||
# 转换消息格式
|
# 转换消息格式
|
||||||
messages = self._build_text_messages(request)
|
messages = self._build_text_messages(request)
|
||||||
|
logger.info(f"[DashScope] 文本聊天 - 转换后的消息:")
|
||||||
|
logger.info(f" - messages_count: {len(messages)}")
|
||||||
|
logger.info(f" - messages: {json.dumps(messages, ensure_ascii=False, indent=2)}")
|
||||||
|
|
||||||
if request.stream:
|
if request.stream:
|
||||||
return self._stream_text_chat(messages, request)
|
return self._stream_text_chat(messages, request)
|
||||||
|
|
@ -163,26 +175,104 @@ class DashScopeAdapter(BaseAdapter):
|
||||||
"""流式文本聊天"""
|
"""流式文本聊天"""
|
||||||
logger.info(f"[DashScope] 开始流式文本响应...")
|
logger.info(f"[DashScope] 开始流式文本响应...")
|
||||||
|
|
||||||
|
# 检查是否启用深度思考
|
||||||
|
thinking_enabled = request.deep_thinking and self._supports_thinking(request.model)
|
||||||
|
logger.info(f"[DashScope] 深度思考: {thinking_enabled} (request={request.deep_thinking}, supports={self._supports_thinking(request.model)})")
|
||||||
|
|
||||||
def generator():
|
def generator():
|
||||||
from utils.helpers import generate_unique_id, get_current_timestamp
|
from utils.helpers import generate_unique_id, get_current_timestamp
|
||||||
|
|
||||||
from dashscope import Generation
|
from dashscope import Generation
|
||||||
|
|
||||||
full_content = ""
|
full_content = ""
|
||||||
|
full_reasoning = ""
|
||||||
chunk_count = 0
|
chunk_count = 0
|
||||||
responses = Generation.call(
|
error_occurred = False
|
||||||
model=request.model,
|
|
||||||
messages=messages,
|
# 打印 API 调用参数
|
||||||
stream=True,
|
api_params = {
|
||||||
temperature=request.temperature,
|
"model": request.model,
|
||||||
max_tokens=request.max_tokens,
|
"messages": messages,
|
||||||
result_format="message",
|
"stream": True,
|
||||||
)
|
"temperature": request.temperature,
|
||||||
|
"max_tokens": request.max_tokens,
|
||||||
|
"result_format": "message",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 使用统一网络搜索配置
|
||||||
|
web_search_mode = get_web_search_mode(request)
|
||||||
|
if web_search_mode:
|
||||||
|
api_params["enable_search"] = True
|
||||||
|
if web_search_mode == "deep":
|
||||||
|
api_params["search_options"] = {"enable_search_extension": True}
|
||||||
|
|
||||||
|
# 添加深度思考参数
|
||||||
|
if thinking_enabled:
|
||||||
|
api_params["enable_thinking"] = True
|
||||||
|
|
||||||
|
# 打印 API 调用参数
|
||||||
|
logger.info(f"[DashScope] API 调用参数:")
|
||||||
|
logger.info(f" - model: {api_params['model']}")
|
||||||
|
logger.info(f" - stream: {api_params['stream']}")
|
||||||
|
logger.info(f" - temperature: {api_params['temperature']}")
|
||||||
|
logger.info(f" - max_tokens: {api_params['max_tokens']}")
|
||||||
|
logger.info(f" - result_format: {api_params['result_format']}")
|
||||||
|
if thinking_enabled:
|
||||||
|
logger.info(f" - enable_thinking: True")
|
||||||
|
|
||||||
|
try:
|
||||||
|
responses = Generation.call(**api_params)
|
||||||
|
except Exception as e:
|
||||||
|
error_occurred = True
|
||||||
|
logger.error(f"[DashScope] API 调用异常: {str(e)}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
# 返回错误响应
|
||||||
|
error_data = {
|
||||||
|
"id": f"chatcmpl-{generate_unique_id()}",
|
||||||
|
"object": "chat.completion.chunk",
|
||||||
|
"created": get_current_timestamp(),
|
||||||
|
"model": request.model,
|
||||||
|
"choices": [{
|
||||||
|
"index": 0,
|
||||||
|
"delta": {"content": f"API 调用失败: {str(e)}"},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
yield f"data: {json.dumps(error_data, ensure_ascii=False)}\n\n"
|
||||||
|
yield "data: [DONE]\n\n"
|
||||||
|
return
|
||||||
|
|
||||||
for resp in responses:
|
for resp in responses:
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
chunk_count += 1
|
chunk_count += 1
|
||||||
content = resp.output.choices[0].message.content
|
choice = resp.output.choices[0]
|
||||||
|
|
||||||
|
# 处理深度思考内容(reasoning_content)
|
||||||
|
reasoning_content = getattr(choice.message, "reasoning_content", None)
|
||||||
|
if reasoning_content:
|
||||||
|
# 计算增量
|
||||||
|
if len(reasoning_content) > len(full_reasoning):
|
||||||
|
delta_reasoning = reasoning_content[len(full_reasoning):]
|
||||||
|
full_reasoning = reasoning_content
|
||||||
|
data = {
|
||||||
|
"id": f"chatcmpl-{generate_unique_id()}",
|
||||||
|
"object": "chat.completion.chunk",
|
||||||
|
"created": get_current_timestamp(),
|
||||||
|
"model": request.model,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"delta": {"reasoning_content": delta_reasoning},
|
||||||
|
"finish_reason": None,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 处理普通内容
|
||||||
|
content = choice.message.content
|
||||||
if content and len(content) > len(full_content):
|
if content and len(content) > len(full_content):
|
||||||
# DashScope 流式响应返回完整内容,计算增量
|
# DashScope 流式响应返回完整内容,计算增量
|
||||||
delta = content[len(full_content) :]
|
delta = content[len(full_content) :]
|
||||||
|
|
@ -201,6 +291,9 @@ class DashScopeAdapter(BaseAdapter):
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
|
yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||||
|
else:
|
||||||
|
# 记录非200响应
|
||||||
|
logger.warning(f"[DashScope] 非200响应: status_code={resp.status_code}, code={resp.code}, message={resp.message}")
|
||||||
|
|
||||||
finish = {
|
finish = {
|
||||||
"id": f"chatcmpl-{generate_unique_id()}",
|
"id": f"chatcmpl-{generate_unique_id()}",
|
||||||
|
|
@ -216,6 +309,8 @@ class DashScopeAdapter(BaseAdapter):
|
||||||
logger.info(f"[DashScope] 流式文本响应完成:")
|
logger.info(f"[DashScope] 流式文本响应完成:")
|
||||||
logger.info(f" - chunks: {chunk_count}")
|
logger.info(f" - chunks: {chunk_count}")
|
||||||
logger.info(f" - content_length: {len(full_content)} 字符")
|
logger.info(f" - content_length: {len(full_content)} 字符")
|
||||||
|
if full_reasoning:
|
||||||
|
logger.info(f" - reasoning_length: {len(full_reasoning)} 字符")
|
||||||
logger.info(
|
logger.info(
|
||||||
f" - content_preview: {full_content[:200]}..."
|
f" - content_preview: {full_content[:200]}..."
|
||||||
if len(full_content) > 200
|
if len(full_content) > 200
|
||||||
|
|
@ -230,17 +325,64 @@ class DashScopeAdapter(BaseAdapter):
|
||||||
|
|
||||||
from dashscope import Generation
|
from dashscope import Generation
|
||||||
|
|
||||||
resp = Generation.call(
|
# 检查是否启用深度思考
|
||||||
model=request.model,
|
thinking_enabled = request.deep_thinking and self._supports_thinking(request.model)
|
||||||
messages=messages,
|
logger.info(f"[DashScope] 深度思考: {thinking_enabled} (request={request.deep_thinking}, supports={self._supports_thinking(request.model)})")
|
||||||
stream=False,
|
|
||||||
temperature=request.temperature,
|
# 构建 API 调用参数
|
||||||
max_tokens=request.max_tokens,
|
api_params = {
|
||||||
result_format="message",
|
"model": request.model,
|
||||||
|
"messages": messages,
|
||||||
|
"stream": False,
|
||||||
|
"temperature": request.temperature,
|
||||||
|
"max_tokens": request.max_tokens,
|
||||||
|
"result_format": "message",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 使用统一网络搜索配置
|
||||||
|
web_search_mode = get_web_search_mode(request)
|
||||||
|
if web_search_mode:
|
||||||
|
api_params["enable_search"] = True
|
||||||
|
if web_search_mode == "deep":
|
||||||
|
api_params["search_options"] = {"enable_search_extension": True}
|
||||||
|
|
||||||
|
# 添加深度思考参数
|
||||||
|
if thinking_enabled:
|
||||||
|
api_params["enable_thinking"] = True
|
||||||
|
|
||||||
|
# 打印 API 调用参数
|
||||||
|
logger.info(f"[DashScope] API 调用参数:")
|
||||||
|
logger.info(f" - model: {api_params['model']}")
|
||||||
|
logger.info(f" - stream: {api_params['stream']}")
|
||||||
|
logger.info(f" - temperature: {api_params['temperature']}")
|
||||||
|
logger.info(f" - max_tokens: {api_params['max_tokens']}")
|
||||||
|
logger.info(f" - result_format: {api_params['result_format']}")
|
||||||
|
if thinking_enabled:
|
||||||
|
logger.info(f" - enable_thinking: True")
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = Generation.call(**api_params)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[DashScope] API 调用异常: {str(e)}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"error": f"DashScope API 调用异常: {str(e)}"},
|
||||||
)
|
)
|
||||||
|
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
content = resp.output.choices[0].message.content
|
message = resp.output.choices[0].message
|
||||||
|
content = message.content or ""
|
||||||
|
|
||||||
|
# 构建响应消息
|
||||||
|
response_message = {"role": "assistant", "content": content}
|
||||||
|
|
||||||
|
# 处理深度思考内容
|
||||||
|
reasoning_content = getattr(message, "reasoning_content", None)
|
||||||
|
if reasoning_content:
|
||||||
|
response_message["reasoning_content"] = reasoning_content
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
"id": f"chatcmpl-{generate_unique_id()}",
|
"id": f"chatcmpl-{generate_unique_id()}",
|
||||||
"object": "chat.completion",
|
"object": "chat.completion",
|
||||||
|
|
@ -249,7 +391,7 @@ class DashScopeAdapter(BaseAdapter):
|
||||||
"choices": [
|
"choices": [
|
||||||
{
|
{
|
||||||
"index": 0,
|
"index": 0,
|
||||||
"message": {"role": "assistant", "content": content},
|
"message": response_message,
|
||||||
"finish_reason": "stop",
|
"finish_reason": "stop",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
@ -263,8 +405,11 @@ class DashScopeAdapter(BaseAdapter):
|
||||||
}
|
}
|
||||||
|
|
||||||
# 打印响应结果
|
# 打印响应结果
|
||||||
logger.info(f"[DashScope] 响应结果:")
|
logger.info(f"[DashScope] 响应成功:")
|
||||||
|
logger.info(f" - status_code: {resp.status_code}")
|
||||||
logger.info(f" - content_length: {len(content)} 字符")
|
logger.info(f" - content_length: {len(content)} 字符")
|
||||||
|
if reasoning_content:
|
||||||
|
logger.info(f" - reasoning_length: {len(reasoning_content)} 字符")
|
||||||
logger.info(
|
logger.info(
|
||||||
f" - content_preview: {content[:200]}..."
|
f" - content_preview: {content[:200]}..."
|
||||||
if len(content) > 200
|
if len(content) > 200
|
||||||
|
|
@ -275,7 +420,10 @@ class DashScopeAdapter(BaseAdapter):
|
||||||
|
|
||||||
return JSONResponse(content=response)
|
return JSONResponse(content=response)
|
||||||
|
|
||||||
logger.error(f"[DashScope] 请求失败: {resp.code} - {resp.message}")
|
logger.error(f"[DashScope] 请求失败:")
|
||||||
|
logger.error(f" - status_code: {resp.status_code}")
|
||||||
|
logger.error(f" - code: {resp.code}")
|
||||||
|
logger.error(f" - message: {resp.message}")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
content={"error": f"DashScope Error: {resp.code} - {resp.message}"},
|
content={"error": f"DashScope Error: {resp.code} - {resp.message}"},
|
||||||
|
|
@ -288,13 +436,20 @@ class DashScopeAdapter(BaseAdapter):
|
||||||
|
|
||||||
dashscope.api_key = self._get_api_key()
|
dashscope.api_key = self._get_api_key()
|
||||||
|
|
||||||
|
logger.info(f"[DashScope] 开始多模态聊天...")
|
||||||
|
|
||||||
# 转换消息格式
|
# 转换消息格式
|
||||||
messages = self._build_multimodal_messages(request)
|
messages = self._build_multimodal_messages(request)
|
||||||
|
logger.info(f"[DashScope] 多模态消息转换完成:")
|
||||||
|
logger.info(f" - messages_count: {len(messages)}")
|
||||||
|
logger.info(f" - messages: {json.dumps(messages, ensure_ascii=False, indent=2)}")
|
||||||
|
|
||||||
# 选择多模态模型
|
# 选择多模态模型
|
||||||
model = request.model
|
model = request.model
|
||||||
if "qwen-" in model and "vl" not in model:
|
if "qwen-" in model and "vl" not in model:
|
||||||
|
original_model = model
|
||||||
model = model.replace("qwen-", "qwen-vl-")
|
model = model.replace("qwen-", "qwen-vl-")
|
||||||
|
logger.info(f"[DashScope] 模型自动切换: {original_model} -> {model}")
|
||||||
|
|
||||||
if request.stream:
|
if request.stream:
|
||||||
return self._stream_multimodal_chat(messages, model, request)
|
return self._stream_multimodal_chat(messages, model, request)
|
||||||
|
|
@ -338,6 +493,8 @@ class DashScopeAdapter(BaseAdapter):
|
||||||
else:
|
else:
|
||||||
img_url = ""
|
img_url = ""
|
||||||
|
|
||||||
|
logger.info(f"[DashScope] 原始图片URL: {img_url}")
|
||||||
|
|
||||||
# 转换 http URL 为 file:// 格式(如果是本地文件)
|
# 转换 http URL 为 file:// 格式(如果是本地文件)
|
||||||
if img_url.startswith(("http://", "https://")):
|
if img_url.startswith(("http://", "https://")):
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
@ -350,42 +507,129 @@ class DashScopeAdapter(BaseAdapter):
|
||||||
img_url = f"file://{'/'.join(path_parts[uploads_idx:])}"
|
img_url = f"file://{'/'.join(path_parts[uploads_idx:])}"
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
elif not img_url.startswith("file://"):
|
elif not img_url.startswith("file://") and not img_url.startswith(("http://", "https://")):
|
||||||
img_url = f"file://{img_url}"
|
img_url = f"file://{img_url}"
|
||||||
|
|
||||||
|
logger.info(f"[DashScope] 转换后图片URL: {img_url}")
|
||||||
|
|
||||||
return img_url
|
return img_url
|
||||||
|
|
||||||
def _stream_multimodal_chat(
|
def _stream_multimodal_chat(
|
||||||
self, messages: List[Dict], model: str, request: ChatCompletionRequest
|
self, messages: List[Dict], model: str, request: ChatCompletionRequest
|
||||||
):
|
):
|
||||||
"""流式多模态聊天"""
|
"""流式多模态聊天"""
|
||||||
|
logger.info(f"[DashScope] 开始流式多模态响应...")
|
||||||
|
logger.info(f" - model: {model}")
|
||||||
|
logger.info(f" - max_tokens: {request.max_tokens}")
|
||||||
|
logger.info(f" - temperature: {request.temperature}")
|
||||||
|
|
||||||
|
# 检查是否启用深度思考
|
||||||
|
thinking_enabled = request.deep_thinking and self._supports_thinking(model)
|
||||||
|
logger.info(f"[DashScope] 深度思考: {thinking_enabled} (request={request.deep_thinking}, supports={self._supports_thinking(model)})")
|
||||||
|
|
||||||
def generator():
|
def generator():
|
||||||
from utils.helpers import generate_unique_id, get_current_timestamp
|
from utils.helpers import generate_unique_id, get_current_timestamp
|
||||||
|
|
||||||
from dashscope import MultiModalConversation
|
from dashscope import MultiModalConversation
|
||||||
|
|
||||||
responses = MultiModalConversation.call(
|
|
||||||
model=model,
|
|
||||||
messages=messages,
|
|
||||||
stream=True,
|
|
||||||
max_tokens=request.max_tokens,
|
|
||||||
temperature=request.temperature,
|
|
||||||
)
|
|
||||||
|
|
||||||
full_content = ""
|
full_content = ""
|
||||||
|
full_reasoning = ""
|
||||||
|
chunk_count = 0
|
||||||
|
error_occurred = False
|
||||||
|
|
||||||
|
# 打印 API 调用参数
|
||||||
|
api_params = {
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
"stream": True,
|
||||||
|
"enable_thinking": False,
|
||||||
|
"max_tokens": request.max_tokens,
|
||||||
|
"temperature": request.temperature,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 使用统一网络搜索配置
|
||||||
|
web_search_mode = get_web_search_mode(request)
|
||||||
|
if web_search_mode:
|
||||||
|
api_params["enable_search"] = True
|
||||||
|
if web_search_mode == "deep":
|
||||||
|
api_params["search_options"] = {"enable_search_extension": True}
|
||||||
|
|
||||||
|
# 添加深度思考参数
|
||||||
|
if thinking_enabled:
|
||||||
|
api_params["enable_thinking"] = True
|
||||||
|
|
||||||
|
logger.info(f"[DashScope] 流式多模态 API 调用参数:")
|
||||||
|
logger.info(f" - model: {api_params['model']}")
|
||||||
|
logger.info(f" - stream: {api_params['stream']}")
|
||||||
|
logger.info(f" - max_tokens: {api_params['max_tokens']}")
|
||||||
|
logger.info(f" - temperature: {api_params['temperature']}")
|
||||||
|
logger.info(f" - enable_thinking: {api_params['enable_thinking']}")
|
||||||
|
logger.info(f" - messages: {json.dumps(messages, ensure_ascii=False, indent=2)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
responses = MultiModalConversation.call(**api_params)
|
||||||
|
except Exception as e:
|
||||||
|
error_occurred = True
|
||||||
|
logger.error(f"[DashScope] 多模态 API 调用异常: {str(e)}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
error_data = {
|
||||||
|
"id": f"chatcmpl-{generate_unique_id()}",
|
||||||
|
"object": "chat.completion.chunk",
|
||||||
|
"created": get_current_timestamp(),
|
||||||
|
"model": model,
|
||||||
|
"choices": [{
|
||||||
|
"index": 0,
|
||||||
|
"delta": {"content": f"API 调用失败: {str(e)}"},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
yield f"data: {json.dumps(error_data, ensure_ascii=False)}\n\n"
|
||||||
|
yield "data: [DONE]\n\n"
|
||||||
|
return
|
||||||
|
|
||||||
for resp in responses:
|
for resp in responses:
|
||||||
|
chunk_count += 1
|
||||||
|
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
try:
|
try:
|
||||||
content_items = resp.output.choices[0]["message"]["content"]
|
choice = resp.output.choices[0]
|
||||||
|
message = choice["message"]
|
||||||
|
|
||||||
|
# 处理深度思考内容(reasoning_content)
|
||||||
|
# 多模态 API 返回的 reasoning_content 也是独立的片段
|
||||||
|
reasoning_content = message.get("reasoning_content", "")
|
||||||
|
if reasoning_content:
|
||||||
|
delta_reasoning = reasoning_content
|
||||||
|
full_reasoning += reasoning_content
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"id": f"chatcmpl-{generate_unique_id()}",
|
||||||
|
"object": "chat.completion.chunk",
|
||||||
|
"created": get_current_timestamp(),
|
||||||
|
"model": model,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"delta": {"reasoning_content": delta_reasoning},
|
||||||
|
"finish_reason": None,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 处理普通内容
|
||||||
|
content_items = message.get("content", [])
|
||||||
text = ""
|
text = ""
|
||||||
for item in content_items:
|
for item in content_items:
|
||||||
if isinstance(item, dict) and "text" in item:
|
if isinstance(item, dict) and "text" in item:
|
||||||
text += item["text"]
|
text += item["text"]
|
||||||
|
|
||||||
if len(text) > len(full_content):
|
# 多模态 API 返回的 content 是独立的片段(不是累积的),直接作为 delta
|
||||||
delta = text[len(full_content) :]
|
if text:
|
||||||
full_content = text
|
delta = text
|
||||||
|
full_content += text
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"id": f"chatcmpl-{generate_unique_id()}",
|
"id": f"chatcmpl-{generate_unique_id()}",
|
||||||
|
|
@ -401,8 +645,10 @@ class DashScopeAdapter(BaseAdapter):
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
|
yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||||
except (KeyError, IndexError, TypeError):
|
except (KeyError, IndexError, TypeError) as e:
|
||||||
pass
|
logger.warning(f"[DashScope] 解析多模态响应异常: {str(e)}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"[DashScope] 非200响应: status_code={resp.status_code}, code={resp.code}, message={resp.message}")
|
||||||
|
|
||||||
finish = {
|
finish = {
|
||||||
"id": f"chatcmpl-{generate_unique_id()}",
|
"id": f"chatcmpl-{generate_unique_id()}",
|
||||||
|
|
@ -414,6 +660,19 @@ class DashScopeAdapter(BaseAdapter):
|
||||||
yield f"data: {json.dumps(finish, ensure_ascii=False)}\n\n"
|
yield f"data: {json.dumps(finish, ensure_ascii=False)}\n\n"
|
||||||
yield "data: [DONE]\n\n"
|
yield "data: [DONE]\n\n"
|
||||||
|
|
||||||
|
# 打印流式响应结果
|
||||||
|
logger.info(f"[DashScope] 流式多模态响应完成:")
|
||||||
|
logger.info(f" - chunks: {chunk_count}")
|
||||||
|
if full_reasoning:
|
||||||
|
logger.info(f" - reasoning_length: {len(full_reasoning)} 字符")
|
||||||
|
logger.info(f" - reasoning: {full_reasoning[:500]}..." if len(full_reasoning) > 500 else f" - reasoning: {full_reasoning}")
|
||||||
|
logger.info(f" - content_length: {len(full_content)} 字符")
|
||||||
|
logger.info(
|
||||||
|
f" - content: {full_content[:500]}..."
|
||||||
|
if len(full_content) > 500
|
||||||
|
else f" - content: {full_content}"
|
||||||
|
)
|
||||||
|
|
||||||
return StreamingResponse(generator(), media_type="text/event-stream")
|
return StreamingResponse(generator(), media_type="text/event-stream")
|
||||||
|
|
||||||
def _sync_multimodal_chat(
|
def _sync_multimodal_chat(
|
||||||
|
|
@ -424,22 +683,71 @@ class DashScopeAdapter(BaseAdapter):
|
||||||
|
|
||||||
from dashscope import MultiModalConversation
|
from dashscope import MultiModalConversation
|
||||||
|
|
||||||
resp = MultiModalConversation.call(
|
# 检查是否启用深度思考
|
||||||
model=model,
|
thinking_enabled = request.deep_thinking and self._supports_thinking(model)
|
||||||
messages=messages,
|
logger.info(f"[DashScope] 深度思考: {thinking_enabled} (request={request.deep_thinking}, supports={self._supports_thinking(model)})")
|
||||||
stream=False,
|
|
||||||
max_tokens=request.max_tokens,
|
logger.info(f"[DashScope] 开始非流式多模态响应...")
|
||||||
temperature=request.temperature,
|
logger.info(f" - model: {model}")
|
||||||
|
logger.info(f" - max_tokens: {request.max_tokens}")
|
||||||
|
logger.info(f" - temperature: {request.temperature}")
|
||||||
|
|
||||||
|
# 打印 API 调用参数
|
||||||
|
api_params = {
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
"stream": False,
|
||||||
|
"max_tokens": request.max_tokens,
|
||||||
|
"enable_thinking": False,
|
||||||
|
"temperature": request.temperature,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 使用统一网络搜索配置
|
||||||
|
web_search_mode = get_web_search_mode(request)
|
||||||
|
if web_search_mode:
|
||||||
|
api_params["enable_search"] = True
|
||||||
|
if web_search_mode == "deep":
|
||||||
|
api_params["search_options"] = {"enable_search_extension": True}
|
||||||
|
|
||||||
|
# 添加深度思考参数
|
||||||
|
if thinking_enabled:
|
||||||
|
api_params["enable_thinking"] = True
|
||||||
|
|
||||||
|
logger.info(f"[DashScope] 非流式多模态 API 调用参数:")
|
||||||
|
logger.info(f" - model: {api_params['model']}")
|
||||||
|
logger.info(f" - stream: {api_params['stream']}")
|
||||||
|
logger.info(f" - max_tokens: {api_params['max_tokens']}")
|
||||||
|
logger.info(f" - temperature: {api_params['temperature']}")
|
||||||
|
logger.info(f" - enable_thinking: {api_params['enable_thinking']}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = MultiModalConversation.call(**api_params)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[DashScope] 多模态 API 调用异常: {str(e)}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"error": f"DashScope API 调用异常: {str(e)}"},
|
||||||
)
|
)
|
||||||
|
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
try:
|
try:
|
||||||
content_items = resp.output.choices[0]["message"]["content"]
|
message = resp.output.choices[0]["message"]
|
||||||
|
content_items = message.get("content", [])
|
||||||
text = ""
|
text = ""
|
||||||
for item in content_items:
|
for item in content_items:
|
||||||
if isinstance(item, dict) and "text" in item:
|
if isinstance(item, dict) and "text" in item:
|
||||||
text += item["text"]
|
text += item["text"]
|
||||||
|
|
||||||
|
# 构建响应消息
|
||||||
|
response_message = {"role": "assistant", "content": text}
|
||||||
|
|
||||||
|
# 处理深度思考内容
|
||||||
|
reasoning_content = message.get("reasoning_content")
|
||||||
|
if reasoning_content:
|
||||||
|
response_message["reasoning_content"] = reasoning_content
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
"id": f"chatcmpl-{generate_unique_id()}",
|
"id": f"chatcmpl-{generate_unique_id()}",
|
||||||
"object": "chat.completion",
|
"object": "chat.completion",
|
||||||
|
|
@ -448,18 +756,38 @@ class DashScopeAdapter(BaseAdapter):
|
||||||
"choices": [
|
"choices": [
|
||||||
{
|
{
|
||||||
"index": 0,
|
"index": 0,
|
||||||
"message": {"role": "assistant", "content": text},
|
"message": response_message,
|
||||||
"finish_reason": "stop",
|
"finish_reason": "stop",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 打印响应结果
|
||||||
|
logger.info(f"[DashScope] 多模态响应成功:")
|
||||||
|
logger.info(f" - status_code: {resp.status_code}")
|
||||||
|
logger.info(f" - content_length: {len(text)} 字符")
|
||||||
|
if reasoning_content:
|
||||||
|
logger.info(f" - reasoning_length: {len(reasoning_content)} 字符")
|
||||||
|
logger.info(
|
||||||
|
f" - content_preview: {text[:200]}..."
|
||||||
|
if len(text) > 200
|
||||||
|
else f" - content: {text}"
|
||||||
|
)
|
||||||
|
|
||||||
return JSONResponse(content=response)
|
return JSONResponse(content=response)
|
||||||
except (KeyError, IndexError, TypeError) as e:
|
except (KeyError, IndexError, TypeError) as e:
|
||||||
|
logger.error(f"[DashScope] 解析多模态响应异常: {str(e)}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
content={"error": f"Parse error: {str(e)}"},
|
content={"error": f"Parse error: {str(e)}"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.error(f"[DashScope] 多模态请求失败:")
|
||||||
|
logger.error(f" - status_code: {resp.status_code}")
|
||||||
|
logger.error(f" - code: {resp.code}")
|
||||||
|
logger.error(f" - message: {resp.message}")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
content={"error": f"DashScope Error: {resp.code} - {resp.message}"},
|
content={"error": f"DashScope Error: {resp.code} - {resp.message}"},
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,24 @@ from typing import Dict, List, Optional
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
|
|
||||||
from .base import BaseAdapter, ChatCompletionRequest, ModelInfo
|
from .base import BaseAdapter, ChatCompletionRequest, ModelInfo
|
||||||
from utils.logger import get_logger
|
from .plugins import get_web_search_mode, build_glm_search_tool
|
||||||
|
from core import get_logger
|
||||||
|
|
||||||
logger = get_logger()
|
logger = get_logger()
|
||||||
|
|
||||||
# GLM 模型配置
|
# GLM 模型配置
|
||||||
GLM_MODELS = [
|
GLM_MODELS = [
|
||||||
|
ModelInfo(
|
||||||
|
id="glm-5",
|
||||||
|
name="GLM-5",
|
||||||
|
description="Coding与长程Agent能力SOTA",
|
||||||
|
max_tokens=128000,
|
||||||
|
provider="ZhipuAI",
|
||||||
|
supports_thinking=True,
|
||||||
|
supports_web_search=True,
|
||||||
|
supports_vision=False,
|
||||||
|
supports_files=False,
|
||||||
|
),
|
||||||
ModelInfo(
|
ModelInfo(
|
||||||
id="glm-4.6v",
|
id="glm-4.6v",
|
||||||
name="GLM-4.6V(推荐)",
|
name="GLM-4.6V(推荐)",
|
||||||
|
|
@ -24,7 +36,7 @@ GLM_MODELS = [
|
||||||
max_tokens=128000,
|
max_tokens=128000,
|
||||||
provider="ZhipuAI",
|
provider="ZhipuAI",
|
||||||
supports_thinking=True,
|
supports_thinking=True,
|
||||||
supports_web_search=True,
|
supports_web_search=False,
|
||||||
supports_vision=True,
|
supports_vision=True,
|
||||||
supports_files=True,
|
supports_files=True,
|
||||||
),
|
),
|
||||||
|
|
@ -46,18 +58,18 @@ GLM_MODELS = [
|
||||||
max_tokens=128000,
|
max_tokens=128000,
|
||||||
provider="ZhipuAI",
|
provider="ZhipuAI",
|
||||||
supports_thinking=False,
|
supports_thinking=False,
|
||||||
supports_web_search=True,
|
supports_web_search=False,
|
||||||
supports_vision=True,
|
supports_vision=True,
|
||||||
supports_files=True,
|
supports_files=True,
|
||||||
),
|
),
|
||||||
ModelInfo(
|
ModelInfo(
|
||||||
id="glm-z1-flash",
|
id="glm-z1-flash",
|
||||||
name="GLM-Z1 Flash",
|
name="GLM-Z1 Flash",
|
||||||
description="深度思考推理模型",
|
description="深度思考推理模型,默认开启深度思考",
|
||||||
max_tokens=128000,
|
max_tokens=128000,
|
||||||
provider="ZhipuAI",
|
provider="ZhipuAI",
|
||||||
supports_thinking=True,
|
supports_thinking=True,
|
||||||
supports_web_search=False,
|
supports_web_search=True,
|
||||||
supports_vision=False,
|
supports_vision=False,
|
||||||
supports_files=False,
|
supports_files=False,
|
||||||
),
|
),
|
||||||
|
|
@ -118,10 +130,10 @@ class GLMAdapter(BaseAdapter):
|
||||||
|
|
||||||
# 构建额外参数
|
# 构建额外参数
|
||||||
extra_kwargs = {}
|
extra_kwargs = {}
|
||||||
web_search = self._get_web_search_mode(request)
|
web_search_mode = get_web_search_mode(request)
|
||||||
|
|
||||||
if web_search:
|
if web_search_mode:
|
||||||
extra_kwargs["tools"] = [self._build_web_search_tool(web_search)]
|
extra_kwargs["tools"] = [build_glm_search_tool(web_search_mode)]
|
||||||
extra_kwargs["tool_choice"] = "auto"
|
extra_kwargs["tool_choice"] = "auto"
|
||||||
|
|
||||||
# 深度思考:正向选择(True 时启用,False 时禁用)
|
# 深度思考:正向选择(True 时启用,False 时禁用)
|
||||||
|
|
@ -138,6 +150,11 @@ class GLMAdapter(BaseAdapter):
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[GLM] 深度思考已启用: extra_kwargs['thinking'] = {extra_kwargs['thinking']}"
|
f"[GLM] 深度思考已启用: extra_kwargs['thinking'] = {extra_kwargs['thinking']}"
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
extra_kwargs["thinking"] = {"type": "disabled"}
|
||||||
|
logger.info(
|
||||||
|
f"[GLM] 深度思考已禁用: extra_kwargs['thinking'] = {extra_kwargs['thinking']}"
|
||||||
|
)
|
||||||
|
|
||||||
if extra_kwargs:
|
if extra_kwargs:
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -244,46 +261,6 @@ class GLMAdapter(BaseAdapter):
|
||||||
"""检查模型是否支持深度思考"""
|
"""检查模型是否支持深度思考"""
|
||||||
return model.lower() in THINKING_MODELS
|
return model.lower() in THINKING_MODELS
|
||||||
|
|
||||||
def _get_web_search_mode(self, request: ChatCompletionRequest) -> str:
|
|
||||||
"""获取联网搜索模式"""
|
|
||||||
if request.deep_search:
|
|
||||||
return "deep"
|
|
||||||
elif request.web_search:
|
|
||||||
return "simple"
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def _build_web_search_tool(self, mode: str) -> Dict:
|
|
||||||
"""构建联网搜索工具"""
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
today = datetime.now().strftime("%Y年%m月%d日")
|
|
||||||
|
|
||||||
if mode == "deep":
|
|
||||||
# 深度搜索:返回搜索结果详情
|
|
||||||
return {
|
|
||||||
"type": "web_search",
|
|
||||||
"web_search": {
|
|
||||||
"enable": True,
|
|
||||||
"search_engine": "search_pro",
|
|
||||||
"search_result": True,
|
|
||||||
"search_prompt": f"你是一位智能助手。请用简洁的语言总结网络搜索{{search_result}}中的关键信息,按重要性排序并引用来源日期。今天的日期是{today}。",
|
|
||||||
"count": 5,
|
|
||||||
"search_recency_filter": "noLimit",
|
|
||||||
"content_size": "high",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
# 简单搜索
|
|
||||||
return {
|
|
||||||
"type": "web_search",
|
|
||||||
"web_search": {
|
|
||||||
"enable": True,
|
|
||||||
"search_engine": "search_pro",
|
|
||||||
"search_result": True,
|
|
||||||
"count": 5,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def _stream_chat(
|
def _stream_chat(
|
||||||
self, client, messages, model, request, extra_kwargs
|
self, client, messages, model, request, extra_kwargs
|
||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@ from typing import Dict, List, Optional
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
|
|
||||||
from .base import BaseAdapter, ChatCompletionRequest, ModelInfo
|
from .base import BaseAdapter, ChatCompletionRequest, ModelInfo
|
||||||
from utils.logger import get_logger
|
from .plugins import get_web_search_mode, build_openai_search_tool, execute_tavily_search, get_current_time_info
|
||||||
|
from core import get_logger
|
||||||
|
|
||||||
logger = get_logger()
|
logger = get_logger()
|
||||||
|
|
||||||
|
|
@ -155,6 +156,21 @@ class OpenAIAdapter(BaseAdapter):
|
||||||
|
|
||||||
# 构建消息
|
# 构建消息
|
||||||
messages = self._build_messages(request)
|
messages = self._build_messages(request)
|
||||||
|
|
||||||
|
# 统一添加联网搜索插件参数
|
||||||
|
web_search_mode = get_web_search_mode(request)
|
||||||
|
if web_search_mode:
|
||||||
|
# 注入当前时间信息到 System Prompt 中,以便模型拥有时间感知能力
|
||||||
|
time_info = get_current_time_info()
|
||||||
|
has_system = False
|
||||||
|
for msg in messages:
|
||||||
|
if msg.get("role") == "system":
|
||||||
|
msg["content"] = f"当前系统时间:{time_info}\n" + str(msg.get("content", ""))
|
||||||
|
has_system = True
|
||||||
|
break
|
||||||
|
if not has_system:
|
||||||
|
messages.insert(0, {"role": "system", "content": f"当前系统时间:{time_info}"})
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f" - messages: {json.dumps(messages, ensure_ascii=False, indent=2)}"
|
f" - messages: {json.dumps(messages, ensure_ascii=False, indent=2)}"
|
||||||
)
|
)
|
||||||
|
|
@ -168,6 +184,10 @@ class OpenAIAdapter(BaseAdapter):
|
||||||
"stream": request.stream,
|
"stream": request.stream,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if web_search_mode:
|
||||||
|
search_tool = build_openai_search_tool(web_search_mode)
|
||||||
|
kwargs["tools"] = [search_tool]
|
||||||
|
|
||||||
# DeepSeek 深度思考支持
|
# DeepSeek 深度思考支持
|
||||||
extra_body = None
|
extra_body = None
|
||||||
if self._provider_type == "deepseek" and request.deep_thinking:
|
if self._provider_type == "deepseek" and request.deep_thinking:
|
||||||
|
|
@ -220,16 +240,26 @@ class OpenAIAdapter(BaseAdapter):
|
||||||
def generator():
|
def generator():
|
||||||
from utils.helpers import generate_unique_id, get_current_timestamp
|
from utils.helpers import generate_unique_id, get_current_timestamp
|
||||||
|
|
||||||
resp = client.chat.completions.create(**kwargs)
|
nonlocal kwargs
|
||||||
|
|
||||||
|
# 可能需要执行多轮对话(当发生工具调用时)
|
||||||
|
while True:
|
||||||
|
resp = client.chat.completions.create(**kwargs)
|
||||||
full_content = ""
|
full_content = ""
|
||||||
full_reasoning = ""
|
full_reasoning = ""
|
||||||
chunk_count = 0
|
chunk_count = 0
|
||||||
|
|
||||||
|
tool_calls = []
|
||||||
|
current_tool_call = None
|
||||||
|
|
||||||
for chunk in resp:
|
for chunk in resp:
|
||||||
if chunk.choices:
|
if not chunk.choices:
|
||||||
|
continue
|
||||||
|
|
||||||
chunk_count += 1
|
chunk_count += 1
|
||||||
delta = chunk.choices[0].delta
|
delta = chunk.choices[0].delta
|
||||||
|
|
||||||
|
# 1. 收集可能有内容/推理
|
||||||
delta_content = {}
|
delta_content = {}
|
||||||
if hasattr(delta, "content") and delta.content:
|
if hasattr(delta, "content") and delta.content:
|
||||||
delta_content["content"] = delta.content
|
delta_content["content"] = delta.content
|
||||||
|
|
@ -238,7 +268,27 @@ class OpenAIAdapter(BaseAdapter):
|
||||||
delta_content["reasoning_content"] = delta.reasoning_content
|
delta_content["reasoning_content"] = delta.reasoning_content
|
||||||
full_reasoning += delta.reasoning_content
|
full_reasoning += delta.reasoning_content
|
||||||
|
|
||||||
if delta_content:
|
# 2. 收集可能产生的 tool_calls (流式)
|
||||||
|
if hasattr(delta, "tool_calls") and delta.tool_calls:
|
||||||
|
for tool_call_chunk in delta.tool_calls:
|
||||||
|
idx = tool_call_chunk.index
|
||||||
|
# 确保 tool_calls 列表足够长
|
||||||
|
while len(tool_calls) <= idx:
|
||||||
|
tool_calls.append({"id": "", "type": "function", "function": {"name": "", "arguments": ""}})
|
||||||
|
|
||||||
|
if tool_call_chunk.id:
|
||||||
|
tool_calls[idx]["id"] += tool_call_chunk.id
|
||||||
|
if tool_call_chunk.type:
|
||||||
|
# 对于 type, 因为 OpenAI 可能会传 chunks, 但通常只在第一块或者每块传, 为了避免 functionfunction, 使用赋值而非累加
|
||||||
|
tool_calls[idx]["type"] = tool_call_chunk.type
|
||||||
|
if tool_call_chunk.function:
|
||||||
|
if tool_call_chunk.function.name:
|
||||||
|
tool_calls[idx]["function"]["name"] += tool_call_chunk.function.name
|
||||||
|
if tool_call_chunk.function.arguments:
|
||||||
|
tool_calls[idx]["function"]["arguments"] += tool_call_chunk.function.arguments
|
||||||
|
|
||||||
|
# 3. 输出给前端普通文本
|
||||||
|
if delta_content and not tool_calls:
|
||||||
data = {
|
data = {
|
||||||
"id": f"chatcmpl-{generate_unique_id()}",
|
"id": f"chatcmpl-{generate_unique_id()}",
|
||||||
"object": "chat.completion.chunk",
|
"object": "chat.completion.chunk",
|
||||||
|
|
@ -254,6 +304,47 @@ class OpenAIAdapter(BaseAdapter):
|
||||||
}
|
}
|
||||||
yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
|
yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
# 检查此轮请求是否收到了完整工具调用,若是则执行搜索逻辑并追加继续请求,不再让外部函数退出
|
||||||
|
if tool_calls:
|
||||||
|
logger.info(f"[{provider_name}] 检测到流式中包含了工具调用进行拦截并处理: {json.dumps(tool_calls, ensure_ascii=False)}")
|
||||||
|
|
||||||
|
# 把大模型的工具调用请求也追加进去
|
||||||
|
assistant_msg = {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": full_content or None, # 如果工具和普通内容同时存在也保留
|
||||||
|
"tool_calls": tool_calls
|
||||||
|
}
|
||||||
|
if full_reasoning:
|
||||||
|
assistant_msg["reasoning_content"] = full_reasoning
|
||||||
|
elif self._provider_type == "deepseek" and self._supports_thinking(kwargs["model"]):
|
||||||
|
# DeepSeek 推理模型在有工具调用时必须有 reasoning_content 字段
|
||||||
|
assistant_msg["reasoning_content"] = ""
|
||||||
|
kwargs["messages"].append(assistant_msg)
|
||||||
|
|
||||||
|
for tc in tool_calls:
|
||||||
|
if tc["function"]["name"] == "web_search":
|
||||||
|
try:
|
||||||
|
args = json.loads(tc["function"]["arguments"])
|
||||||
|
query = args.get("query", "")
|
||||||
|
mode = "deep" if "advanced" in str(kwargs.get("tools", [])) else "simple"
|
||||||
|
logger.info(f"[{provider_name}] 执行搜索插件: {query}")
|
||||||
|
search_result = execute_tavily_search(query, mode=mode)
|
||||||
|
except Exception as e:
|
||||||
|
search_result = f"获取搜索参数或执行搜索失败: {str(e)}"
|
||||||
|
logger.error(search_result)
|
||||||
|
|
||||||
|
# 把执行结果告诉大模型
|
||||||
|
kwargs["messages"].append({
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": tc["id"],
|
||||||
|
"name": "web_search",
|
||||||
|
"content": search_result
|
||||||
|
})
|
||||||
|
|
||||||
|
# 工具执行完毕,继续发起下一轮请求大模型归纳总结输出
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 如果没有工具调用或者全部分发完毕,正常结束给前端
|
||||||
finish = {
|
finish = {
|
||||||
"id": f"chatcmpl-{generate_unique_id()}",
|
"id": f"chatcmpl-{generate_unique_id()}",
|
||||||
"object": "chat.completion.chunk",
|
"object": "chat.completion.chunk",
|
||||||
|
|
@ -276,6 +367,9 @@ class OpenAIAdapter(BaseAdapter):
|
||||||
else f" - content: {full_content}"
|
else f" - content: {full_content}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 结束外层循环退出生成器
|
||||||
|
break
|
||||||
|
|
||||||
return StreamingResponse(generator(), media_type="text/event-stream")
|
return StreamingResponse(generator(), media_type="text/event-stream")
|
||||||
|
|
||||||
def _sync_chat(
|
def _sync_chat(
|
||||||
|
|
@ -284,9 +378,57 @@ class OpenAIAdapter(BaseAdapter):
|
||||||
"""非流式聊天"""
|
"""非流式聊天"""
|
||||||
from utils.helpers import generate_unique_id, get_current_timestamp
|
from utils.helpers import generate_unique_id, get_current_timestamp
|
||||||
|
|
||||||
|
while True:
|
||||||
resp = client.chat.completions.create(**kwargs)
|
resp = client.chat.completions.create(**kwargs)
|
||||||
|
|
||||||
message = resp.choices[0].message
|
message = resp.choices[0].message
|
||||||
|
|
||||||
|
# 判断是否涉及工具调用
|
||||||
|
if hasattr(message, "tool_calls") and message.tool_calls:
|
||||||
|
# 记录这轮的助手回复
|
||||||
|
assistant_msg = {"role": "assistant", "content": message.content or None}
|
||||||
|
# openai sdk 对象转 dict 存储 tool_calls
|
||||||
|
tool_calls_dict = []
|
||||||
|
for tc in message.tool_calls:
|
||||||
|
tc_dict = {
|
||||||
|
"id": tc.id,
|
||||||
|
"type": tc.type,
|
||||||
|
"function": {
|
||||||
|
"name": tc.function.name,
|
||||||
|
"arguments": tc.function.arguments
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tool_calls_dict.append(tc_dict)
|
||||||
|
assistant_msg["tool_calls"] = tool_calls_dict
|
||||||
|
if hasattr(message, "reasoning_content") and message.reasoning_content:
|
||||||
|
assistant_msg["reasoning_content"] = message.reasoning_content
|
||||||
|
elif self._provider_type == "deepseek" and self._supports_thinking(kwargs["model"]):
|
||||||
|
# DeepSeek 推理模型在有工具调用时必须有 reasoning_content 字段
|
||||||
|
assistant_msg["reasoning_content"] = ""
|
||||||
|
kwargs["messages"].append(assistant_msg)
|
||||||
|
|
||||||
|
# 执行所有的工具调用
|
||||||
|
for tc in tool_calls_dict:
|
||||||
|
if tc["function"]["name"] == "web_search":
|
||||||
|
try:
|
||||||
|
args = json.loads(tc["function"]["arguments"])
|
||||||
|
query = args.get("query", "")
|
||||||
|
mode = "deep" if "advanced" in str(kwargs.get("tools", [])) else "simple"
|
||||||
|
search_result = execute_tavily_search(query, mode=mode)
|
||||||
|
except Exception as e:
|
||||||
|
search_result = f"执行搜索失败: {str(e)}"
|
||||||
|
|
||||||
|
# 把执行结果追加到消息中
|
||||||
|
kwargs["messages"].append({
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": tc["id"],
|
||||||
|
"name": "web_search",
|
||||||
|
"content": search_result
|
||||||
|
})
|
||||||
|
# 工具调用完成,发起下一轮请求获取归纳答案
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 处理普通的文本回复
|
||||||
content = message.content or ""
|
content = message.content or ""
|
||||||
response = {
|
response = {
|
||||||
"id": f"chatcmpl-{generate_unique_id()}",
|
"id": f"chatcmpl-{generate_unique_id()}",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
import os
|
||||||
|
import urllib.request
|
||||||
|
import json
|
||||||
|
from typing import Dict
|
||||||
|
from datetime import datetime
|
||||||
|
from .base import ChatCompletionRequest
|
||||||
|
|
||||||
|
def get_current_time_info() -> str:
|
||||||
|
"""获取当前时间信息"""
|
||||||
|
now = datetime.now()
|
||||||
|
weekdays = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]
|
||||||
|
return f"{now.strftime('%Y年%m月%d日 %H:%M:%S')} {weekdays[now.weekday()]}"
|
||||||
|
|
||||||
|
def get_web_search_mode(request: ChatCompletionRequest) -> str:
|
||||||
|
"""获取联网搜索模式"""
|
||||||
|
if getattr(request, 'deep_search', False):
|
||||||
|
return "deep"
|
||||||
|
elif getattr(request, 'web_search', False):
|
||||||
|
return "simple"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def execute_tavily_search(query: str, mode: str = "simple") -> str:
|
||||||
|
"""真实调用 Tavily 搜索 API"""
|
||||||
|
api_key = os.getenv("TAVILY_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
return "本地环境变量 TAVILY_API_KEY 未配置,无法进行搜索。"
|
||||||
|
|
||||||
|
url = "https://api.tavily.com/search"
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
data = {
|
||||||
|
"api_key": api_key,
|
||||||
|
"query": query,
|
||||||
|
"search_depth": "advanced" if mode == "deep" else "basic",
|
||||||
|
"include_answer": False,
|
||||||
|
"max_results": 5 if mode == "deep" else 3
|
||||||
|
}
|
||||||
|
|
||||||
|
req = urllib.request.Request(url, data=json.dumps(data).encode('utf-8'), headers=headers, method='POST')
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as response:
|
||||||
|
result = json.loads(response.read().decode('utf-8'))
|
||||||
|
results = result.get("results", [])
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
return "搜索未返回结果。"
|
||||||
|
|
||||||
|
formatted_res = []
|
||||||
|
for i, res in enumerate(results):
|
||||||
|
formatted_res.append(f"[{i+1}] {res.get('title')}\n{res.get('content')}\n链接: {res.get('url')}")
|
||||||
|
|
||||||
|
return "\n\n".join(formatted_res)
|
||||||
|
except Exception as e:
|
||||||
|
return f"搜索请求失败,错误: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def build_openai_search_tool(mode: str) -> Dict:
|
||||||
|
"""
|
||||||
|
构建兼容型联网搜索插件工具结构 (供 DeepSeek / OpenAI SDK 使用)
|
||||||
|
注意:此类提供标准的 Tool/Function Function calling 模板。
|
||||||
|
深度思考通常结合内置联网或者其他外挂流程实现。
|
||||||
|
"""
|
||||||
|
if mode == "deep":
|
||||||
|
return {
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "web_search",
|
||||||
|
"description": "深度互联网搜索插件(查找并阅读网页内容)",
|
||||||
|
"parameters": {"type": "object", "properties": {"query": {"type": "string"}}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "web_search",
|
||||||
|
"description": "进行互联网搜索并获取实时信息或资料以辅助回答。",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "要搜索的准确关键词或短语"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["query"]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def build_glm_search_tool(mode: str) -> Dict:
|
||||||
|
"""构建 GLM 联网搜索工具"""
|
||||||
|
today = get_current_time_info()
|
||||||
|
|
||||||
|
if mode == "deep":
|
||||||
|
# 深度搜索:返回搜索结果详情
|
||||||
|
return {
|
||||||
|
"type": "web_search",
|
||||||
|
"web_search": {
|
||||||
|
"enable": True,
|
||||||
|
"search_engine": "search_pro",
|
||||||
|
"search_result": True,
|
||||||
|
"search_prompt": f"你是一位智能助手。请用简洁的语言总结网络搜索{{search_result}}中的关键信息,按重要性排序并引用来源日期。今天的日期是{today}。",
|
||||||
|
"count": 5,
|
||||||
|
"search_recency_filter": "noLimit",
|
||||||
|
"content_size": "high",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# 简单搜索
|
||||||
|
return {
|
||||||
|
"type": "web_search",
|
||||||
|
"web_search": {
|
||||||
|
"enable": True,
|
||||||
|
"search_engine": "search_pro",
|
||||||
|
"search_result": True,
|
||||||
|
"count": 5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -11,15 +11,15 @@ from .base import BaseAdapter
|
||||||
# 模型前缀到平台名称的映射
|
# 模型前缀到平台名称的映射
|
||||||
MODEL_PREFIX_MAP = {
|
MODEL_PREFIX_MAP = {
|
||||||
# 智谱 GLM
|
# 智谱 GLM
|
||||||
"glm-": "glm",
|
"glm": "glm",
|
||||||
# 阿里云百炼(Qwen 系列)
|
# 阿里云百炼(Qwen 系列)
|
||||||
"qwen-": "dashscope",
|
"qwen": "dashscope",
|
||||||
# OpenAI
|
# OpenAI
|
||||||
"gpt-": "openai",
|
"gpt": "openai",
|
||||||
"o1-": "openai",
|
"o1": "openai",
|
||||||
"o3-": "openai",
|
"o3": "openai",
|
||||||
# Deepseek
|
# Deepseek
|
||||||
"deepseek-": "deepseek",
|
"deepseek": "deepseek",
|
||||||
}
|
}
|
||||||
|
|
||||||
# 已注册的适配器实例
|
# 已注册的适配器实例
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ sys.path.append(str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from utils.helpers import generate_unique_id
|
from utils.helpers import generate_unique_id
|
||||||
from utils.logger import log_error, log_exception, log_info
|
from core import log_error, log_exception, log_info
|
||||||
|
|
||||||
# 配置上传目录
|
# 配置上传目录
|
||||||
upload_dir = Path(__file__).parent.parent / "uploads"
|
upload_dir = Path(__file__).parent.parent / "uploads"
|
||||||
|
|
@ -28,10 +28,10 @@ upload_dir.mkdir(exist_ok=True)
|
||||||
# ── 会话管理 ─────────────────────────────────────────────────────
|
# ── 会话管理 ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def get_conversations_handler():
|
async def get_conversations_handler(user_id: str = "default"):
|
||||||
"""获取所有对话处理器"""
|
"""获取所有对话处理器"""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
return db.list_conversations()
|
return db.list_conversations(user_id)
|
||||||
|
|
||||||
|
|
||||||
async def get_conversation_handler(conversation_id: str):
|
async def get_conversation_handler(conversation_id: str):
|
||||||
|
|
@ -65,8 +65,38 @@ async def save_conversation_handler(data: dict):
|
||||||
|
|
||||||
|
|
||||||
async def delete_conversation_handler(conversation_id: str):
|
async def delete_conversation_handler(conversation_id: str):
|
||||||
"""删除对话处理器"""
|
"""删除对话处理器(同时删除关联的 OSS 文件)"""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
|
|
||||||
|
# 先获取会话数据,提取 OSS 文件 URL
|
||||||
|
conversation = db.get_conversation(conversation_id)
|
||||||
|
if not conversation:
|
||||||
|
raise HTTPException(status_code=404, detail="对话不存在")
|
||||||
|
|
||||||
|
# 提取所有 OSS 文件 URL
|
||||||
|
oss_urls = _extract_oss_urls_from_conversation(conversation)
|
||||||
|
|
||||||
|
# 删除 OSS 文件
|
||||||
|
if oss_urls:
|
||||||
|
try:
|
||||||
|
from utils.oss_uploader import delete_files, extract_object_key_from_url
|
||||||
|
|
||||||
|
object_keys = []
|
||||||
|
for url in oss_urls:
|
||||||
|
key = extract_object_key_from_url(url)
|
||||||
|
if key:
|
||||||
|
object_keys.append(key)
|
||||||
|
|
||||||
|
if object_keys:
|
||||||
|
result = delete_files(object_keys)
|
||||||
|
log_info(f"[删除会话] OSS 文件清理结果: 删除 {len(result['deleted'])} 个, 失败 {len(result['failed'])} 个")
|
||||||
|
if result['failed']:
|
||||||
|
log_error(f"[删除会话] OSS 文件删除失败: {result['failed']}")
|
||||||
|
except Exception as e:
|
||||||
|
log_error(f"[删除会话] OSS 文件删除异常: {e}")
|
||||||
|
# 继续删除会话,即使 OSS 删除失败
|
||||||
|
|
||||||
|
# 删除数据库记录
|
||||||
success = db.delete_conversation(conversation_id)
|
success = db.delete_conversation(conversation_id)
|
||||||
if success:
|
if success:
|
||||||
return {"success": True, "message": "删除成功"}
|
return {"success": True, "message": "删除成功"}
|
||||||
|
|
@ -74,6 +104,85 @@ async def delete_conversation_handler(conversation_id: str):
|
||||||
raise HTTPException(status_code=404, detail="对话不存在")
|
raise HTTPException(status_code=404, detail="对话不存在")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_oss_urls_from_conversation(conversation: dict) -> list:
|
||||||
|
"""
|
||||||
|
从会话消息中提取所有 OSS 文件 URL
|
||||||
|
|
||||||
|
消息结构:
|
||||||
|
- content.images: 图片附件列表
|
||||||
|
- content.files: 文件附件列表
|
||||||
|
每个附件包含 url 字段
|
||||||
|
"""
|
||||||
|
urls = []
|
||||||
|
messages = conversation.get("messages", [])
|
||||||
|
|
||||||
|
for message in messages:
|
||||||
|
content = message.get("content")
|
||||||
|
if not content:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# content 可能是字符串(需要解析)或已解析的字典
|
||||||
|
if isinstance(content, str):
|
||||||
|
try:
|
||||||
|
content = json.loads(content)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 提取图片附件
|
||||||
|
images = content.get("images", [])
|
||||||
|
for img in images:
|
||||||
|
url = img.get("url")
|
||||||
|
if url and url not in urls:
|
||||||
|
urls.append(url)
|
||||||
|
|
||||||
|
# 提取文件附件
|
||||||
|
files = content.get("files", [])
|
||||||
|
for f in files:
|
||||||
|
url = f.get("url")
|
||||||
|
if url and url not in urls:
|
||||||
|
urls.append(url)
|
||||||
|
|
||||||
|
return urls
|
||||||
|
|
||||||
|
|
||||||
|
async def update_conversation_handler(conversation_id: str, data: dict):
|
||||||
|
"""部分更新对话处理器"""
|
||||||
|
db = get_db()
|
||||||
|
result = db.update_conversation(conversation_id, data)
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=404, detail="对话不存在")
|
||||||
|
|
||||||
|
|
||||||
|
# ── 消息管理 ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def add_message_handler(conversation_id: str, message: dict):
|
||||||
|
"""添加消息到对话处理器"""
|
||||||
|
db = get_db()
|
||||||
|
# 检查对话是否存在
|
||||||
|
existing = db.get_conversation(conversation_id)
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(status_code=404, detail="对话不存在")
|
||||||
|
return db.add_message(conversation_id, message)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_message_handler(conversation_id: str, message_id: str, data: dict):
|
||||||
|
"""更新消息处理器"""
|
||||||
|
db = get_db()
|
||||||
|
# 检查对话是否存在
|
||||||
|
existing = db.get_conversation(conversation_id)
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(status_code=404, detail="对话不存在")
|
||||||
|
|
||||||
|
result = db.update_message(message_id, data)
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=404, detail="消息不存在")
|
||||||
|
|
||||||
|
|
||||||
# ── 文件上传 ─────────────────────────────────────────────────────
|
# ── 文件上传 ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -198,3 +307,25 @@ async def stop_generation_handler(message_id: str = None):
|
||||||
f"已发出停止指令,消息ID: {message_id}" if message_id else "已发出停止指令"
|
f"已发出停止指令,消息ID: {message_id}" if message_id else "已发出停止指令"
|
||||||
)
|
)
|
||||||
return {"success": True, "message": message}
|
return {"success": True, "message": message}
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_attachment_handler(url: str):
|
||||||
|
"""删除附件处理器 - 从 OSS 删除文件"""
|
||||||
|
try:
|
||||||
|
from utils.oss_uploader import delete_file, extract_object_key_from_url
|
||||||
|
|
||||||
|
object_key = extract_object_key_from_url(url)
|
||||||
|
if not object_key:
|
||||||
|
raise HTTPException(status_code=400, detail="无效的文件 URL")
|
||||||
|
|
||||||
|
success = delete_file(object_key)
|
||||||
|
if success:
|
||||||
|
return {"success": True, "message": "文件删除成功"}
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=500, detail="文件删除失败")
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
log_error(f"删除附件失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"删除失败: {str(e)}")
|
||||||
|
|
@ -10,7 +10,7 @@ from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from adapters import get_adapter, get_provider_from_model
|
from adapters import get_adapter, get_provider_from_model
|
||||||
from adapters.base import ChatCompletionRequest
|
from adapters.base import ChatCompletionRequest
|
||||||
from utils.logger import get_logger
|
from core import get_logger
|
||||||
|
|
||||||
logger = get_logger()
|
logger = get_logger()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,230 @@
|
||||||
|
"""
|
||||||
|
分享功能路由 - 创建分享、验证密码、获取分享内容
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.append(str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from core import log_error, log_info
|
||||||
|
|
||||||
|
|
||||||
|
# ── 请求/响应模型 ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class CreateShareRequest(BaseModel):
|
||||||
|
"""创建分享请求"""
|
||||||
|
conversationIds: List[str] = []
|
||||||
|
messages: Optional[List[dict]] = None # 直接传递消息数据(消息分享模式)
|
||||||
|
passwordHash: str
|
||||||
|
expiresIn: Optional[int] = 604800 # 默认7天(秒)
|
||||||
|
|
||||||
|
|
||||||
|
class VerifyShareRequest(BaseModel):
|
||||||
|
"""验证分享请求"""
|
||||||
|
passwordHash: str
|
||||||
|
|
||||||
|
|
||||||
|
# ── 分享处理器 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def create_share_handler(data: dict):
|
||||||
|
"""
|
||||||
|
创建分享
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
{
|
||||||
|
"conversationIds": ["conv-1", "conv-2"],
|
||||||
|
"messages": [...], // 可选,消息分享模式
|
||||||
|
"passwordHash": "sha256-hash",
|
||||||
|
"expiresIn": 604800
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
conversation_ids = data.get("conversationIds", [])
|
||||||
|
messages = data.get("messages") # 消息分享模式
|
||||||
|
password_hash = data.get("passwordHash", "")
|
||||||
|
expires_in = data.get("expiresIn", 604800)
|
||||||
|
|
||||||
|
if not password_hash:
|
||||||
|
raise HTTPException(status_code=400, detail="请设置访问密码")
|
||||||
|
|
||||||
|
# 获取数据库实例
|
||||||
|
db = get_db()
|
||||||
|
|
||||||
|
# 计算过期时间
|
||||||
|
now = int(datetime.now(timezone.utc).timestamp() * 1000)
|
||||||
|
expires_at = now + (expires_in * 1000)
|
||||||
|
|
||||||
|
# 消息分享模式
|
||||||
|
if messages:
|
||||||
|
if len(messages) == 0:
|
||||||
|
raise HTTPException(status_code=400, detail="请选择要分享的消息")
|
||||||
|
|
||||||
|
# 创建虚拟对话
|
||||||
|
virtual_conv_id = str(uuid.uuid4())
|
||||||
|
virtual_conversation = {
|
||||||
|
"id": virtual_conv_id,
|
||||||
|
"title": f"分享的消息 ({len(messages)}条)",
|
||||||
|
"messages": messages,
|
||||||
|
"createdAt": now,
|
||||||
|
"updatedAt": now,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 创建分享记录
|
||||||
|
share_data = {
|
||||||
|
"conversationIds": [virtual_conv_id],
|
||||||
|
"conversations": [virtual_conversation],
|
||||||
|
"passwordHash": password_hash,
|
||||||
|
"expiresAt": expires_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 保存到数据库
|
||||||
|
share = db.create_share(share_data)
|
||||||
|
|
||||||
|
log_info(f"[分享] 消息分享创建成功: {share['id']}, 消息数: {len(messages)}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": share["id"],
|
||||||
|
"shareUrl": f"/chat-ui/share/{share['id']}",
|
||||||
|
"expiresAt": share["expiresAt"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# 对话分享模式
|
||||||
|
if not conversation_ids:
|
||||||
|
raise HTTPException(status_code=400, detail="请选择要分享的对话")
|
||||||
|
|
||||||
|
if len(conversation_ids) > 10:
|
||||||
|
raise HTTPException(status_code=400, detail="最多分享10个对话")
|
||||||
|
|
||||||
|
# 获取对话数据(快照)
|
||||||
|
conversations = []
|
||||||
|
for conv_id in conversation_ids:
|
||||||
|
conv = db.get_conversation(conv_id)
|
||||||
|
if conv:
|
||||||
|
# 创建对话快照(去除敏感信息)
|
||||||
|
conv_snapshot = {
|
||||||
|
"id": conv["id"],
|
||||||
|
"title": conv["title"],
|
||||||
|
"messages": conv.get("messages", []),
|
||||||
|
"createdAt": conv["createdAt"],
|
||||||
|
"updatedAt": conv["updatedAt"],
|
||||||
|
}
|
||||||
|
conversations.append(conv_snapshot)
|
||||||
|
|
||||||
|
if not conversations:
|
||||||
|
raise HTTPException(status_code=404, detail="未找到有效的对话")
|
||||||
|
|
||||||
|
# 创建分享记录
|
||||||
|
share_data = {
|
||||||
|
"conversationIds": conversation_ids,
|
||||||
|
"conversations": conversations,
|
||||||
|
"passwordHash": password_hash,
|
||||||
|
"expiresAt": expires_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 保存到数据库
|
||||||
|
share = db.create_share(share_data)
|
||||||
|
|
||||||
|
log_info(f"[分享] 创建成功: {share['id']}, 对话数: {len(conversations)}")
|
||||||
|
|
||||||
|
# 返回分享信息
|
||||||
|
return {
|
||||||
|
"id": share["id"],
|
||||||
|
"shareUrl": f"/chat-ui/share/{share['id']}",
|
||||||
|
"expiresAt": share["expiresAt"],
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
log_error(f"[分享] 创建失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"创建分享失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_share_handler(share_id: str):
|
||||||
|
"""
|
||||||
|
获取分享基本信息(不含内容)
|
||||||
|
|
||||||
|
用于显示分享预览、检查是否过期等
|
||||||
|
"""
|
||||||
|
db = get_db()
|
||||||
|
share = db.get_share(share_id)
|
||||||
|
|
||||||
|
if not share:
|
||||||
|
raise HTTPException(status_code=404, detail="分享不存在")
|
||||||
|
|
||||||
|
# 检查是否过期
|
||||||
|
now = int(datetime.now(timezone.utc).timestamp() * 1000)
|
||||||
|
is_expired = now > share["expiresAt"]
|
||||||
|
|
||||||
|
if is_expired:
|
||||||
|
# 删除过期分享
|
||||||
|
db.delete_share(share_id)
|
||||||
|
raise HTTPException(status_code=404, detail="分享已过期")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": share["id"],
|
||||||
|
"hasPassword": bool(share["passwordHash"]),
|
||||||
|
"expiresAt": share["expiresAt"],
|
||||||
|
"isExpired": is_expired,
|
||||||
|
"conversationCount": len(share["conversationIds"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_share_handler(share_id: str, data: dict):
|
||||||
|
"""
|
||||||
|
验证密码并获取分享内容
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
{
|
||||||
|
"passwordHash": "sha256-hash"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
db = get_db()
|
||||||
|
share = db.get_share(share_id)
|
||||||
|
|
||||||
|
if not share:
|
||||||
|
raise HTTPException(status_code=404, detail="分享不存在")
|
||||||
|
|
||||||
|
# 检查是否过期
|
||||||
|
now = int(datetime.now(timezone.utc).timestamp() * 1000)
|
||||||
|
if now > share["expiresAt"]:
|
||||||
|
db.delete_share(share_id)
|
||||||
|
raise HTTPException(status_code=404, detail="分享已过期")
|
||||||
|
|
||||||
|
# 验证密码
|
||||||
|
password_hash = data.get("passwordHash", "")
|
||||||
|
|
||||||
|
if password_hash != share["passwordHash"]:
|
||||||
|
log_info(f"[分享] 密码验证失败: {share_id}")
|
||||||
|
return {
|
||||||
|
"valid": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 增加访问计数
|
||||||
|
view_count = db.update_share_view_count(share_id)
|
||||||
|
|
||||||
|
log_info(f"[分享] 验证成功: {share_id}, 访问次数: {view_count}")
|
||||||
|
|
||||||
|
# 返回分享内容
|
||||||
|
return {
|
||||||
|
"valid": True,
|
||||||
|
"share": {
|
||||||
|
"id": share["id"],
|
||||||
|
"conversationIds": share["conversationIds"],
|
||||||
|
"conversations": share["conversations"],
|
||||||
|
"createdAt": share["createdAt"],
|
||||||
|
"expiresAt": share["expiresAt"],
|
||||||
|
"viewCount": view_count,
|
||||||
|
"hasPassword": bool(share["passwordHash"]),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
"""
|
||||||
|
配置模块
|
||||||
|
|
||||||
|
提供统一的配置管理功能,包括平台配置、API密钥管理等。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .settings import (
|
||||||
|
ProviderConfig,
|
||||||
|
PROVIDERS,
|
||||||
|
get_provider_config,
|
||||||
|
is_provider_available,
|
||||||
|
get_available_providers,
|
||||||
|
DEFAULT_PROVIDER,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ProviderConfig",
|
||||||
|
"PROVIDERS",
|
||||||
|
"get_provider_config",
|
||||||
|
"is_provider_available",
|
||||||
|
"get_available_providers",
|
||||||
|
"DEFAULT_PROVIDER",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
"""
|
||||||
|
日志模块
|
||||||
|
|
||||||
|
提供统一的日志管理功能,支持结构化日志、文件轮转、多级别日志等。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .logger import (
|
||||||
|
LoggerSetup,
|
||||||
|
setup_global_logger,
|
||||||
|
get_logger,
|
||||||
|
log_debug,
|
||||||
|
log_info,
|
||||||
|
log_warning,
|
||||||
|
log_error,
|
||||||
|
log_critical,
|
||||||
|
log_exception,
|
||||||
|
log_structured,
|
||||||
|
log_request_info,
|
||||||
|
log_response_info,
|
||||||
|
log_error_detail,
|
||||||
|
log_chat_interaction,
|
||||||
|
log_system_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"LoggerSetup",
|
||||||
|
"setup_global_logger",
|
||||||
|
"get_logger",
|
||||||
|
"log_debug",
|
||||||
|
"log_info",
|
||||||
|
"log_warning",
|
||||||
|
"log_error",
|
||||||
|
"log_critical",
|
||||||
|
"log_exception",
|
||||||
|
"log_structured",
|
||||||
|
"log_request_info",
|
||||||
|
"log_response_info",
|
||||||
|
"log_error_detail",
|
||||||
|
"log_chat_interaction",
|
||||||
|
"log_system_status",
|
||||||
|
]
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from utils.logger import setup_global_logger
|
from .logger import setup_global_logger
|
||||||
|
|
||||||
|
|
||||||
def init_logging_system():
|
def init_logging_system():
|
||||||
Binary file not shown.
|
|
@ -76,11 +76,62 @@ class Database:
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_conversation
|
CREATE INDEX IF NOT EXISTS idx_messages_conversation
|
||||||
ON messages(conversation_id)
|
ON messages(conversation_id)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# 检查并添加缺失的列(迁移旧数据库 - conversations 表)
|
||||||
|
cursor.execute("PRAGMA table_info(conversations)")
|
||||||
|
conv_columns = [col[1] for col in cursor.fetchall()]
|
||||||
|
|
||||||
|
conv_migrations = [
|
||||||
|
('user_id', "TEXT DEFAULT 'default'"),
|
||||||
|
('pinned', "INTEGER DEFAULT 0"),
|
||||||
|
('archived', "INTEGER DEFAULT 0"),
|
||||||
|
('settings', "TEXT"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for col_name, col_def in conv_migrations:
|
||||||
|
if col_name not in conv_columns:
|
||||||
|
cursor.execute(f"ALTER TABLE conversations ADD COLUMN {col_name} {col_def}")
|
||||||
|
print(f"[数据库] conversations 表已添加 {col_name} 列")
|
||||||
|
|
||||||
|
# 检查并添加缺失的列(迁移旧数据库 - messages 表)
|
||||||
|
cursor.execute("PRAGMA table_info(messages)")
|
||||||
|
msg_columns = [col[1] for col in cursor.fetchall()]
|
||||||
|
|
||||||
|
msg_migrations = [
|
||||||
|
('timestamp', "INTEGER"),
|
||||||
|
('feedback', "TEXT"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for col_name, col_def in msg_migrations:
|
||||||
|
if col_name not in msg_columns:
|
||||||
|
cursor.execute(f"ALTER TABLE messages ADD COLUMN {col_name} {col_def}")
|
||||||
|
print(f"[数据库] messages 表已添加 {col_name} 列")
|
||||||
|
|
||||||
|
# 创建 user_id 索引(在确保列存在后)
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
CREATE INDEX IF NOT EXISTS idx_conversations_user
|
CREATE INDEX IF NOT EXISTS idx_conversations_user
|
||||||
ON conversations(user_id)
|
ON conversations(user_id)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# 创建分享表
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS shares (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
conversation_ids TEXT NOT NULL,
|
||||||
|
conversations TEXT NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at INTEGER,
|
||||||
|
expires_at INTEGER,
|
||||||
|
view_count INTEGER DEFAULT 0
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# 创建分享过期时间索引
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shares_expires
|
||||||
|
ON shares(expires_at)
|
||||||
|
""")
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
# ── 会话 CRUD ─────────────────────────────────────────────────────
|
# ── 会话 CRUD ─────────────────────────────────────────────────────
|
||||||
|
|
@ -352,6 +403,106 @@ class Database:
|
||||||
import uuid
|
import uuid
|
||||||
return str(uuid.uuid4())
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
# ── 分享 CRUD ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def create_share(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""创建分享"""
|
||||||
|
conn = self._get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
now = int(datetime.now(timezone.utc).timestamp() * 1000)
|
||||||
|
share_id = data.get("id") or self._generate_share_id()
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO shares (id, conversation_ids, conversations, password_hash, created_at, expires_at, view_count)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 0)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
share_id,
|
||||||
|
json.dumps(data.get("conversationIds", [])),
|
||||||
|
json.dumps(data.get("conversations", [])),
|
||||||
|
data.get("passwordHash", ""),
|
||||||
|
now,
|
||||||
|
data.get("expiresAt", now + 604800000), # 默认7天
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
return self.get_share(share_id)
|
||||||
|
|
||||||
|
def get_share(self, share_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""获取分享"""
|
||||||
|
conn = self._get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("SELECT * FROM shares WHERE id = ?", (share_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self._row_to_share(row)
|
||||||
|
|
||||||
|
def update_share_view_count(self, share_id: str) -> int:
|
||||||
|
"""增加分享访问计数"""
|
||||||
|
conn = self._get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE shares SET view_count = view_count + 1 WHERE id = ?",
|
||||||
|
(share_id,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# 返回更新后的计数
|
||||||
|
cursor.execute("SELECT view_count FROM shares WHERE id = ?", (share_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return row["view_count"] if row else 0
|
||||||
|
|
||||||
|
def delete_share(self, share_id: str) -> bool:
|
||||||
|
"""删除分享"""
|
||||||
|
conn = self._get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("DELETE FROM shares WHERE id = ?", (share_id,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def cleanup_expired_shares(self) -> int:
|
||||||
|
"""清理过期分享"""
|
||||||
|
conn = self._get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
now = int(datetime.now(timezone.utc).timestamp() * 1000)
|
||||||
|
|
||||||
|
cursor.execute("DELETE FROM shares WHERE expires_at < ?", (now,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
deleted_count = cursor.rowcount
|
||||||
|
if deleted_count > 0:
|
||||||
|
print(f"[数据库] 已清理 {deleted_count} 个过期分享")
|
||||||
|
|
||||||
|
return deleted_count
|
||||||
|
|
||||||
|
def _row_to_share(self, row: sqlite3.Row) -> Dict[str, Any]:
|
||||||
|
"""将数据库行转换为分享字典"""
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"conversationIds": json.loads(row["conversation_ids"]),
|
||||||
|
"conversations": json.loads(row["conversations"]),
|
||||||
|
"passwordHash": row["password_hash"],
|
||||||
|
"createdAt": row["created_at"],
|
||||||
|
"expiresAt": row["expires_at"],
|
||||||
|
"viewCount": row["view_count"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _generate_share_id(self) -> str:
|
||||||
|
"""生成分享 ID(8位短链接)"""
|
||||||
|
import uuid
|
||||||
|
return uuid.uuid4().hex[:8]
|
||||||
|
|
||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
"""初始化数据库(应用启动时调用)"""
|
"""初始化数据库(应用启动时调用)"""
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
# 日志配置文件
|
|
||||||
# 可以在 .env 文件中设置以下环境变量来控制日志行为
|
|
||||||
|
|
||||||
# 日志级别: DEBUG, INFO, WARNING, ERROR, CRITICAL
|
|
||||||
LOG_LEVEL=INFO
|
|
||||||
|
|
||||||
# 日志文件目录
|
|
||||||
LOG_DIR=logs
|
|
||||||
|
|
||||||
# 日志文件最大大小 (字节)
|
|
||||||
LOG_MAX_BYTES=10485760 # 10MB
|
|
||||||
|
|
||||||
# 保留的备份日志文件数量
|
|
||||||
LOG_BACKUP_COUNT=5
|
|
||||||
|
|
@ -41,7 +41,7 @@ sys.path.append("/home/mt/project/ai-chat-ui/server")
|
||||||
|
|
||||||
# ── 工具/日志(与平台无关)───────────────────────────────────────────
|
# ── 工具/日志(与平台无关)───────────────────────────────────────────
|
||||||
from utils.helpers import log_response
|
from utils.helpers import log_response
|
||||||
from utils.logger import get_logger
|
from core import get_logger
|
||||||
|
|
||||||
logger = get_logger()
|
logger = get_logger()
|
||||||
|
|
||||||
|
|
@ -54,14 +54,23 @@ init_db()
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
# ── 会话管理路由处理器 ────────────────────────────────────────────────
|
# ── 会话管理路由处理器 ────────────────────────────────────────────────
|
||||||
from api.conversation_routes import (delete_conversation_handler,
|
from api.conversation_routes import (add_message_handler,
|
||||||
|
delete_attachment_handler,
|
||||||
|
delete_conversation_handler,
|
||||||
get_conversation_handler,
|
get_conversation_handler,
|
||||||
get_conversations_handler,
|
get_conversations_handler,
|
||||||
save_conversation_handler,
|
save_conversation_handler,
|
||||||
serve_upload_handler,
|
serve_upload_handler,
|
||||||
stop_generation_handler,
|
stop_generation_handler,
|
||||||
|
update_conversation_handler,
|
||||||
|
update_message_handler,
|
||||||
upload_file_handler)
|
upload_file_handler)
|
||||||
|
|
||||||
|
# ── 分享功能路由处理器 ────────────────────────────────────────────────
|
||||||
|
from api.share_routes import (create_share_handler,
|
||||||
|
get_share_handler,
|
||||||
|
verify_share_handler)
|
||||||
|
|
||||||
# ── OpenAI 兼容网关初始化 ───────────────────────────────────────────────
|
# ── OpenAI 兼容网关初始化 ───────────────────────────────────────────────
|
||||||
from api.openai_gateway import init_adapters, router as openai_router
|
from api.openai_gateway import init_adapters, router as openai_router
|
||||||
|
|
||||||
|
|
@ -99,7 +108,7 @@ async def logging_middleware(request: Request, call_next):
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health_check():
|
async def health_check():
|
||||||
from config import get_available_providers
|
from config.settings import get_available_providers
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
|
|
@ -109,6 +118,7 @@ async def health_check():
|
||||||
"openai_compatible": "/v1/chat/completions",
|
"openai_compatible": "/v1/chat/completions",
|
||||||
"models": "/v1/models",
|
"models": "/v1/models",
|
||||||
"conversations": "/api/chat-ui/conversations",
|
"conversations": "/api/chat-ui/conversations",
|
||||||
|
"shares": "/api/chat-ui/shares",
|
||||||
},
|
},
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
}
|
}
|
||||||
|
|
@ -170,8 +180,8 @@ async def get_models():
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/chat-ui/conversations")
|
@app.get("/api/chat-ui/conversations")
|
||||||
async def get_conversations():
|
async def get_conversations(user_id: str = "default"):
|
||||||
return await get_conversations_handler()
|
return await get_conversations_handler(user_id)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/chat-ui/conversations/{conversation_id}")
|
@app.get("/api/chat-ui/conversations/{conversation_id}")
|
||||||
|
|
@ -189,6 +199,21 @@ async def delete_conversation(conversation_id: str):
|
||||||
return await delete_conversation_handler(conversation_id)
|
return await delete_conversation_handler(conversation_id)
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/chat-ui/conversations/{conversation_id}")
|
||||||
|
async def update_conversation(conversation_id: str, request: Request):
|
||||||
|
return await update_conversation_handler(conversation_id, await request.json())
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/chat-ui/conversations/{conversation_id}/messages")
|
||||||
|
async def add_message(conversation_id: str, request: Request):
|
||||||
|
return await add_message_handler(conversation_id, await request.json())
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/chat-ui/conversations/{conversation_id}/messages/{message_id}")
|
||||||
|
async def update_message(conversation_id: str, message_id: str, request: Request):
|
||||||
|
return await update_message_handler(conversation_id, message_id, await request.json())
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/chat-ui/upload")
|
@app.post("/api/chat-ui/upload")
|
||||||
async def upload_file(file: UploadFile = File(...)):
|
async def upload_file(file: UploadFile = File(...)):
|
||||||
return await upload_file_handler(file=file)
|
return await upload_file_handler(file=file)
|
||||||
|
|
@ -209,14 +234,40 @@ async def stop_generation_by_id(message_id: str):
|
||||||
return await stop_generation_handler(message_id)
|
return await stop_generation_handler(message_id)
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/chat-ui/attachment")
|
||||||
|
async def delete_attachment(url: str):
|
||||||
|
return await delete_attachment_handler(url)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 分享功能路由 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/chat-ui/shares")
|
||||||
|
async def create_share(request: Request):
|
||||||
|
"""创建分享"""
|
||||||
|
return await create_share_handler(await request.json())
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/chat-ui/shares/{share_id}")
|
||||||
|
async def get_share(share_id: str):
|
||||||
|
"""获取分享基本信息"""
|
||||||
|
return await get_share_handler(share_id)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/chat-ui/shares/{share_id}/verify")
|
||||||
|
async def verify_share(share_id: str, request: Request):
|
||||||
|
"""验证密码并获取分享内容"""
|
||||||
|
return await verify_share_handler(share_id, await request.json())
|
||||||
|
|
||||||
|
|
||||||
# ── 程序入口 ──────────────────────────────────────────────────────────
|
# ── 程序入口 ──────────────────────────────────────────────────────────
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
port = int(os.getenv("PORT", 8000))
|
port = int(os.getenv("PORT", 8002))
|
||||||
|
|
||||||
# 获取可用平台
|
# 获取可用平台
|
||||||
from config import get_available_providers
|
from config.settings import get_available_providers
|
||||||
|
|
||||||
available = get_available_providers()
|
available = get_available_providers()
|
||||||
|
|
||||||
|
|
@ -229,7 +280,7 @@ if __name__ == "__main__":
|
||||||
print(f" 可用平台 : {', '.join(available) or '无(请配置 API Key)'}")
|
print(f" 可用平台 : {', '.join(available) or '无(请配置 API Key)'}")
|
||||||
print("-" * 60)
|
print("-" * 60)
|
||||||
print(" 使用方法:")
|
print(" 使用方法:")
|
||||||
print(" curl -X POST http://localhost:8000/v1/chat/completions \\")
|
print(f" curl -X POST http://localhost:{port}/v1/chat/completions \\")
|
||||||
print(' -H "Content-Type: application/json" \\')
|
print(' -H "Content-Type: application/json" \\')
|
||||||
print(' -d \'{"model":"glm-4-flash","messages":[{"role":"user","content":"hi"}]}\'')
|
print(' -d \'{"model":"glm-4-flash","messages":[{"role":"user","content":"hi"}]}\'')
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
"""
|
||||||
|
认证中间件 - 预留接口
|
||||||
|
|
||||||
|
当前返回默认用户,未来可集成 JWT、OAuth 等认证系统。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user_id(request) -> str:
|
||||||
|
"""
|
||||||
|
从请求中获取当前用户 ID(预留)
|
||||||
|
|
||||||
|
当前返回默认用户 'default'
|
||||||
|
未来可集成 JWT、OAuth 等
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI Request 对象
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
用户 ID 字符串
|
||||||
|
"""
|
||||||
|
# TODO: 实现 token 验证逻辑
|
||||||
|
# 示例:
|
||||||
|
# auth_header = request.headers.get("Authorization")
|
||||||
|
# if auth_header and auth_header.startswith("Bearer "):
|
||||||
|
# token = auth_header[7:]
|
||||||
|
# user_id = verify_token(token)
|
||||||
|
# return user_id
|
||||||
|
|
||||||
|
return "default"
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(request) -> dict:
|
||||||
|
"""
|
||||||
|
获取当前用户完整信息(预留)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
用户信息字典
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"id": get_current_user_id(request),
|
||||||
|
"name": None,
|
||||||
|
"email": None
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,103 @@
|
||||||
fastapi==0.115.4
|
|
||||||
uvicorn==0.32.0
|
|
||||||
dashscope==1.20.12
|
|
||||||
python-multipart==0.0.18
|
|
||||||
python-dotenv==1.0.1
|
|
||||||
aiofiles==24.1.0
|
aiofiles==24.1.0
|
||||||
pydantic==2.9.2
|
aiohappyeyeballs==2.6.1
|
||||||
typing-extensions==4.12.2
|
aiohttp==3.13.3
|
||||||
# 路线二:阿里云文档智能解析(doc/docx/pdf)
|
aiosignal==1.4.0
|
||||||
llama-index-core>=0.10.0
|
aiosqlite==0.22.1
|
||||||
llama-index-readers-dashscope>=0.1.0
|
alibabacloud-oss-v2==1.2.4
|
||||||
# 阿里云 OSS 上传
|
aliyun-python-sdk-core==2.16.0
|
||||||
alibabacloud-oss-v2
|
aliyun-python-sdk-kms==2.16.5
|
||||||
|
annotated-types==0.7.0
|
||||||
|
anyio==4.12.1
|
||||||
|
argcomplete==3.6.3
|
||||||
|
attrs==25.4.0
|
||||||
|
banks==2.4.1
|
||||||
|
black==26.1.0
|
||||||
|
cachetools==7.0.2
|
||||||
|
certifi==2026.2.25
|
||||||
|
cffi==2.0.0
|
||||||
|
charset-normalizer==3.4.4
|
||||||
|
click==8.3.1
|
||||||
|
colorama==0.4.6
|
||||||
|
colorlog==6.10.1
|
||||||
|
crcmod==1.7
|
||||||
|
crcmod-plus==2.3.1
|
||||||
|
cryptography==46.0.5
|
||||||
|
dashscope==1.20.12
|
||||||
|
dataclasses-json==0.6.7
|
||||||
|
dependency-groups==1.3.1
|
||||||
|
Deprecated==1.3.1
|
||||||
|
dirtyjson==1.0.8
|
||||||
|
distlib==0.4.0
|
||||||
|
distro==1.9.0
|
||||||
|
fastapi==0.115.4
|
||||||
|
filelock==3.25.0
|
||||||
|
filetype==1.2.0
|
||||||
|
frozenlist==1.8.0
|
||||||
|
fsspec==2026.2.0
|
||||||
|
greenlet==3.3.2
|
||||||
|
griffe==2.0.0
|
||||||
|
griffecli==2.0.0
|
||||||
|
griffelib==2.0.0
|
||||||
|
h11==0.16.0
|
||||||
|
httpcore==1.0.9
|
||||||
|
httpx==0.28.1
|
||||||
|
humanize==4.15.0
|
||||||
|
idna==3.11
|
||||||
|
isort==8.0.1
|
||||||
|
Jinja2==3.1.6
|
||||||
|
jiter==0.13.0
|
||||||
|
jmespath==0.10.0
|
||||||
|
joblib==1.5.3
|
||||||
|
llama-index-core==0.14.15
|
||||||
|
llama-index-instrumentation==0.4.2
|
||||||
|
llama-index-readers-dashscope==0.4.1
|
||||||
|
llama-index-workflows==2.15.0
|
||||||
|
MarkupSafe==3.0.3
|
||||||
|
marshmallow==3.26.2
|
||||||
|
multidict==6.7.1
|
||||||
|
mypy_extensions==1.1.0
|
||||||
|
nest-asyncio==1.6.0
|
||||||
|
networkx==3.6.1
|
||||||
|
nltk==3.9.3
|
||||||
|
nox==2026.2.9
|
||||||
|
numpy==2.4.2
|
||||||
|
openai==2.26.0
|
||||||
|
oss2==2.19.1
|
||||||
|
packaging==26.0
|
||||||
|
pathspec==1.0.4
|
||||||
|
pillow==12.1.1
|
||||||
|
platformdirs==4.9.2
|
||||||
|
propcache==0.4.1
|
||||||
|
pycparser==3.0
|
||||||
|
pycryptodome==3.23.0
|
||||||
|
pydantic==2.12.5
|
||||||
|
pydantic_core==2.41.5
|
||||||
|
PyJWT==2.11.0
|
||||||
|
python-discovery==1.1.0
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
python-multipart==0.0.18
|
||||||
|
pytokens==0.4.1
|
||||||
|
PyYAML==6.0.3
|
||||||
|
regex==2026.2.28
|
||||||
|
requests==2.32.5
|
||||||
|
retrying==1.4.2
|
||||||
|
setuptools==82.0.0
|
||||||
|
six==1.17.0
|
||||||
|
sniffio==1.3.1
|
||||||
|
SQLAlchemy==2.0.48
|
||||||
|
starlette==0.41.3
|
||||||
|
tenacity==9.1.4
|
||||||
|
tiktoken==0.12.0
|
||||||
|
tinytag==2.2.0
|
||||||
|
tqdm==4.67.3
|
||||||
|
typing-inspect==0.9.0
|
||||||
|
typing-inspection==0.4.2
|
||||||
|
typing_extensions==4.15.0
|
||||||
|
urllib3==2.6.3
|
||||||
|
uvicorn==0.32.0
|
||||||
|
virtualenv==21.1.0
|
||||||
|
websocket-client==1.9.0
|
||||||
|
wrapt==2.1.1
|
||||||
|
yarl==1.23.0
|
||||||
|
# zai-sdk==0.2.2
|
||||||
|
zhipuai==2.1.5.20250825
|
||||||
|
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
"""
|
|
||||||
GLM 文件 ID 缓存(基于磁盘的简单 KV,sha256 → file_id,3天有效期)
|
|
||||||
"""
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
_CACHE_FILE = Path(__file__).parent.parent / "uploads" / ".glm_file_cache.json"
|
|
||||||
_lock = threading.Lock()
|
|
||||||
_TTL = 3 * 24 * 3600 # 3天
|
|
||||||
|
|
||||||
|
|
||||||
def _load() -> dict:
|
|
||||||
try:
|
|
||||||
if _CACHE_FILE.exists():
|
|
||||||
return json.loads(_CACHE_FILE.read_text(encoding="utf-8"))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _save(data: dict) -> None:
|
|
||||||
try:
|
|
||||||
_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
_CACHE_FILE.write_text(
|
|
||||||
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[file_cache] 写入失败:{e}")
|
|
||||||
|
|
||||||
|
|
||||||
def sha256_of_file(file_path: Path) -> str:
|
|
||||||
h = hashlib.sha256()
|
|
||||||
with open(file_path, "rb") as f:
|
|
||||||
for chunk in iter(lambda: f.read(65536), b""):
|
|
||||||
h.update(chunk)
|
|
||||||
return h.hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def get(file_hash: str) -> dict | None:
|
|
||||||
with _lock:
|
|
||||||
data = _load()
|
|
||||||
entry = data.get(file_hash)
|
|
||||||
if not entry:
|
|
||||||
return None
|
|
||||||
if entry.get("expires_at", 0) <= time.time():
|
|
||||||
data.pop(file_hash, None)
|
|
||||||
_save(data)
|
|
||||||
return None
|
|
||||||
return entry
|
|
||||||
|
|
||||||
|
|
||||||
def set(file_hash: str, file_id: str) -> None:
|
|
||||||
with _lock:
|
|
||||||
data = _load()
|
|
||||||
data[file_hash] = {"file_id": file_id, "expires_at": time.time() + _TTL}
|
|
||||||
_save(data)
|
|
||||||
|
|
||||||
|
|
||||||
def delete(file_hash: str) -> None:
|
|
||||||
with _lock:
|
|
||||||
data = _load()
|
|
||||||
data.pop(file_hash, None)
|
|
||||||
_save(data)
|
|
||||||
|
|
@ -1,523 +0,0 @@
|
||||||
"""
|
|
||||||
GLM-4.6V 适配层(基于 zai-sdk)
|
|
||||||
SDK:pip install zai-sdk
|
|
||||||
模型:glm-4.6v(支持文本/图像/文档/深度思考)
|
|
||||||
"""
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import AsyncGenerator
|
|
||||||
|
|
||||||
|
|
||||||
# ── 自动注入 venv site-packages ───────────────────────────────────────
|
|
||||||
def _ensure_venv():
|
|
||||||
server_dir = Path(__file__).parent.parent
|
|
||||||
for sp in sorted(
|
|
||||||
(server_dir / ".venv" / "lib").glob("python*/site-packages"), reverse=True
|
|
||||||
):
|
|
||||||
if sp.exists() and str(sp) not in sys.path:
|
|
||||||
sys.path.insert(0, str(sp))
|
|
||||||
print(f"[GLM] venv 注入:{sp}")
|
|
||||||
break
|
|
||||||
|
|
||||||
|
|
||||||
# ── 客户端单例 ────────────────────────────────────────────────────────
|
|
||||||
_client = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_client():
|
|
||||||
global _client
|
|
||||||
if _client is None:
|
|
||||||
_ensure_venv()
|
|
||||||
try:
|
|
||||||
from zai import ZhipuAiClient
|
|
||||||
except ImportError:
|
|
||||||
raise ImportError("GLM 模式需要安装 zai-sdk:.venv/bin/pip install zai-sdk")
|
|
||||||
api_key = os.getenv("ZHIPU_API_KEY").strip() or os.getenv("GLM_API_KEY").strip()
|
|
||||||
if not api_key:
|
|
||||||
raise ValueError("GLM 模式需要设置环境变量 ZHIPU_API_KEY")
|
|
||||||
_client = ZhipuAiClient(api_key=api_key)
|
|
||||||
print("[GLM] ZhipuAiClient 初始化完成(zai-sdk)")
|
|
||||||
return _client
|
|
||||||
|
|
||||||
|
|
||||||
# ── 模型映射 ──────────────────────────────────────────────────────────
|
|
||||||
DEFAULT_TEXT_MODEL = "glm-4-flash" # 默认文本模型
|
|
||||||
DEFAULT_VISION_MODEL = "glm-4.6v" # 图片/附件识别用 glm-4.6v
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_model(model: str, has_vision: bool = False) -> str:
|
|
||||||
# 当消息包含图片或附件时,使用视觉模型
|
|
||||||
if has_vision:
|
|
||||||
print(f"[GLM] 检测到图片/附件,使用视觉模型:{model} → {DEFAULT_VISION_MODEL}")
|
|
||||||
return DEFAULT_VISION_MODEL
|
|
||||||
# 普通文本对话,保持原模型不变
|
|
||||||
print(f"[GLM] 使用模型:{model}")
|
|
||||||
return model
|
|
||||||
|
|
||||||
|
|
||||||
# ── 文件上传(含 file_id 缓存)───────────────────────────────────────
|
|
||||||
def upload_file_for_extract(local_path: Path) -> str:
|
|
||||||
from utils.file_cache import get as cache_get
|
|
||||||
from utils.file_cache import set as cache_set
|
|
||||||
from utils.file_cache import sha256_of_file
|
|
||||||
|
|
||||||
file_hash = sha256_of_file(local_path)
|
|
||||||
cached = cache_get(file_hash)
|
|
||||||
if cached:
|
|
||||||
print(f"[GLM] file_id 缓存命中:{local_path.name} → {cached['file_id']}")
|
|
||||||
return cached["file_id"]
|
|
||||||
|
|
||||||
client = get_client()
|
|
||||||
mime_map = {
|
|
||||||
".pdf": "application/pdf",
|
|
||||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
||||||
".doc": "application/msword",
|
|
||||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
||||||
".xls": "application/vnd.ms-excel",
|
|
||||||
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
||||||
".ppt": "application/vnd.ms-powerpoint",
|
|
||||||
}
|
|
||||||
mime = mime_map.get(local_path.suffix.lower(), "application/octet-stream")
|
|
||||||
print(f"[GLM] 上传文件:{local_path.name}({mime})")
|
|
||||||
with open(local_path, "rb") as f:
|
|
||||||
file_obj = client.files.create(
|
|
||||||
file=(local_path.name, f, mime), purpose="file-extract"
|
|
||||||
)
|
|
||||||
file_id = file_obj.id
|
|
||||||
cache_set(file_hash, file_id)
|
|
||||||
print(f"[GLM] 上传成功:file_id={file_id}")
|
|
||||||
return file_id
|
|
||||||
|
|
||||||
|
|
||||||
# ── 图像编码 ─────────────────────────────────────────────────────────
|
|
||||||
def encode_image(image_source: str) -> dict:
|
|
||||||
"""将图像来源统一转为 OpenAI image_url 格式"""
|
|
||||||
if image_source.startswith("data:image") or image_source.startswith(
|
|
||||||
("http://", "https://")
|
|
||||||
):
|
|
||||||
return {"type": "image_url", "image_url": {"url": image_source}}
|
|
||||||
# 本地路径 → base64
|
|
||||||
local = Path(image_source.replace("file://", "").lstrip("/"))
|
|
||||||
if not local.exists():
|
|
||||||
local = Path.cwd() / local
|
|
||||||
ext = local.suffix.lstrip(".")
|
|
||||||
with open(local, "rb") as f:
|
|
||||||
b64 = base64.b64encode(f.read()).decode()
|
|
||||||
return {"type": "image_url", "image_url": {"url": f"data:image/{ext};base64,{b64}"}}
|
|
||||||
|
|
||||||
|
|
||||||
# ── 消息格式转换 ──────────────────────────────────────────────────────
|
|
||||||
def build_glm_messages(messages: list, files: list | None = None) -> tuple[list, bool]:
|
|
||||||
"""
|
|
||||||
将 OpenAI 格式的 messages + files 转换为 zai-sdk 所需格式。
|
|
||||||
返回 (glm_messages, has_vision)。
|
|
||||||
"""
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
glm_messages = []
|
|
||||||
has_vision = False
|
|
||||||
|
|
||||||
for msg in messages:
|
|
||||||
if not isinstance(msg, dict):
|
|
||||||
glm_messages.append({"role": "user", "content": str(msg)})
|
|
||||||
continue
|
|
||||||
role = msg.get("role", "user")
|
|
||||||
content = msg.get("content", "")
|
|
||||||
|
|
||||||
if isinstance(content, str):
|
|
||||||
glm_messages.append({"role": role, "content": content})
|
|
||||||
elif isinstance(content, list):
|
|
||||||
new_content = []
|
|
||||||
for item in content:
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
new_content.append({"type": "text", "text": str(item)})
|
|
||||||
continue
|
|
||||||
t = item.get("type")
|
|
||||||
if t == "text":
|
|
||||||
new_content.append({"type": "text", "text": item.get("text", "")})
|
|
||||||
elif t == "image_url":
|
|
||||||
has_vision = True
|
|
||||||
img_val = item.get("image_url", "")
|
|
||||||
img_src = (
|
|
||||||
img_val.get("url", "") if isinstance(img_val, dict) else img_val
|
|
||||||
)
|
|
||||||
new_content.append(encode_image(img_src))
|
|
||||||
elif t == "file_url":
|
|
||||||
# file_url 类型(PDF/DOCX/TXT 等文档链接)原样透传
|
|
||||||
has_vision = True
|
|
||||||
new_content.append(item)
|
|
||||||
else:
|
|
||||||
new_content.append({"type": "text", "text": str(item)})
|
|
||||||
glm_messages.append({"role": role, "content": new_content})
|
|
||||||
else:
|
|
||||||
glm_messages.append({"role": role, "content": str(content)})
|
|
||||||
|
|
||||||
# 处理独立附件列表
|
|
||||||
if files:
|
|
||||||
doc_exts = {
|
|
||||||
".pdf",
|
|
||||||
".doc",
|
|
||||||
".docx",
|
|
||||||
".xlsx",
|
|
||||||
".xls",
|
|
||||||
".pptx",
|
|
||||||
".ppt",
|
|
||||||
".txt",
|
|
||||||
".md",
|
|
||||||
".csv",
|
|
||||||
".json",
|
|
||||||
".log",
|
|
||||||
}
|
|
||||||
img_exts = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"}
|
|
||||||
inserts = []
|
|
||||||
|
|
||||||
for file_url in files:
|
|
||||||
parsed = urlparse(file_url)
|
|
||||||
filename = parsed.path.split("/")[-1]
|
|
||||||
suffix = Path(filename).suffix.lower()
|
|
||||||
|
|
||||||
# ── 远程 URL(OSS 等)→ 直接透传 ─────────────────
|
|
||||||
if file_url.startswith(("http://", "https://")):
|
|
||||||
has_vision = True
|
|
||||||
if suffix in img_exts:
|
|
||||||
inserts.append(
|
|
||||||
{"type": "image_url", "image_url": {"url": file_url}}
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# 文档/文本类统一走 file_url
|
|
||||||
inserts.append({"type": "file_url", "file_url": {"url": file_url}})
|
|
||||||
continue
|
|
||||||
|
|
||||||
# ── 本地文件回退逻辑 ──────────────────────────────
|
|
||||||
rel = parsed.path.lstrip("/")
|
|
||||||
local = Path(rel)
|
|
||||||
|
|
||||||
if suffix in img_exts:
|
|
||||||
has_vision = True
|
|
||||||
try:
|
|
||||||
inserts.append(encode_image(f"file://{rel}"))
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[GLM] 图像编码失败:{e}")
|
|
||||||
elif suffix in doc_exts:
|
|
||||||
has_vision = True
|
|
||||||
if local.exists():
|
|
||||||
try:
|
|
||||||
fid = upload_file_for_extract(local)
|
|
||||||
inserts.append({"type": "file", "file": {"file_id": fid}})
|
|
||||||
except Exception as e:
|
|
||||||
inserts.append(
|
|
||||||
{"type": "text", "text": f"[文件上传失败:{filename},{e}]"}
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
inserts.append(
|
|
||||||
{"type": "text", "text": f"[附件:{filename},类型:{suffix}]"}
|
|
||||||
)
|
|
||||||
|
|
||||||
if inserts:
|
|
||||||
for i in range(len(glm_messages) - 1, -1, -1):
|
|
||||||
if glm_messages[i].get("role") == "user":
|
|
||||||
old = glm_messages[i]["content"]
|
|
||||||
if isinstance(old, str):
|
|
||||||
new_content = inserts + [{"type": "text", "text": old}]
|
|
||||||
elif isinstance(old, list):
|
|
||||||
new_content = inserts + old
|
|
||||||
else:
|
|
||||||
new_content = inserts
|
|
||||||
glm_messages[i] = {"role": "user", "content": new_content}
|
|
||||||
break
|
|
||||||
|
|
||||||
return glm_messages, has_vision
|
|
||||||
|
|
||||||
|
|
||||||
# ── 网络搜索 tool 构建 ──────────────────────────────────────────────
|
|
||||||
def _build_web_search_tool(mode: str | bool) -> dict:
|
|
||||||
"""
|
|
||||||
根据搜索模式构建 web_search tool 配置。
|
|
||||||
|
|
||||||
mode:
|
|
||||||
- True / "simple" : 简单搜索(search_std + medium, 10条)
|
|
||||||
- "deep" : 深度搜索(search_pro + high, 20条)
|
|
||||||
"""
|
|
||||||
if mode == "deep":
|
|
||||||
# 深度搜索:高阶搜索引擎 + 详细内容 + 更多结果
|
|
||||||
return {
|
|
||||||
"type": "web_search",
|
|
||||||
"web_search": {
|
|
||||||
"enable": True,
|
|
||||||
"search_result": True,
|
|
||||||
"search_engine": "search_pro",
|
|
||||||
"content_size": "high",
|
|
||||||
"count": 20,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
# 简单搜索(默认):基础搜索引擎 + 摘要内容
|
|
||||||
return {
|
|
||||||
"type": "web_search",
|
|
||||||
"web_search": {
|
|
||||||
"enable": True,
|
|
||||||
"search_result": True,
|
|
||||||
"search_engine": "search_std",
|
|
||||||
"content_size": "medium",
|
|
||||||
"count": 10,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ── 哨兵对象 ─────────────────────────────────────────────────────────
|
|
||||||
_SENTINEL = object()
|
|
||||||
|
|
||||||
|
|
||||||
# ── 流式调用 ────────────────────────────────────────────────────────
|
|
||||||
async def glm_stream_generator(
|
|
||||||
messages: list,
|
|
||||||
model: str,
|
|
||||||
temperature: float,
|
|
||||||
max_tokens: int,
|
|
||||||
files: list | None = None,
|
|
||||||
web_search: str | bool = False,
|
|
||||||
deep_thinking: bool = False,
|
|
||||||
) -> AsyncGenerator[str, None]:
|
|
||||||
"""
|
|
||||||
GLM 流式 SSE 生成器。
|
|
||||||
使用 queue.Queue + 专用线程(生产者)+ asyncio 消费者模式,
|
|
||||||
让 zai-sdk 同步迭代器在单一线程内安全运行。
|
|
||||||
|
|
||||||
web_search:
|
|
||||||
- False / "" : 不启用联网搜索
|
|
||||||
- True / "simple" : 简单搜索(search_std + medium)
|
|
||||||
- "deep" : 深度搜索(search_pro + high + 更多结果)
|
|
||||||
"""
|
|
||||||
import asyncio
|
|
||||||
import queue
|
|
||||||
|
|
||||||
from utils.helpers import generate_unique_id, get_current_timestamp
|
|
||||||
|
|
||||||
glm_msgs, has_vision = build_glm_messages(messages, files)
|
|
||||||
actual_model = resolve_model(model, has_vision)
|
|
||||||
|
|
||||||
extra_kwargs: dict = {}
|
|
||||||
if web_search:
|
|
||||||
extra_kwargs["tools"] = [_build_web_search_tool(web_search)]
|
|
||||||
if not deep_thinking:
|
|
||||||
# 智普默认开启思考模式,所以要用非门(不知道“非门”描述是否准确。前端选择开启思考模式,这里不做变动。前端选择关闭思考模式,这里关闭。)
|
|
||||||
extra_kwargs["thinking"] = {"type": "disabled"}
|
|
||||||
print(
|
|
||||||
f"[GLM] 流式请求:model={actual_model} vision={has_vision} "
|
|
||||||
f"web_search={web_search} thinking={deep_thinking}"
|
|
||||||
)
|
|
||||||
# ── 调试:打印发送给 GLM 的完整消息结构 ──
|
|
||||||
for i, msg in enumerate(glm_msgs):
|
|
||||||
role = msg.get("role", "?")
|
|
||||||
content = msg.get("content", "")
|
|
||||||
if isinstance(content, list):
|
|
||||||
for j, part in enumerate(content):
|
|
||||||
if not isinstance(part, dict):
|
|
||||||
print(f"[GLM-DEBUG] msg[{i}].content[{j}]: {type(part).__name__}")
|
|
||||||
continue
|
|
||||||
part_type = part.get("type", "?")
|
|
||||||
if part_type == "image_url":
|
|
||||||
img_val = part.get("image_url", "")
|
|
||||||
img_url = (
|
|
||||||
img_val.get("url", "")
|
|
||||||
if isinstance(img_val, dict)
|
|
||||||
else str(img_val)
|
|
||||||
)
|
|
||||||
display = img_url[:120] + "..." if len(img_url) > 120 else img_url
|
|
||||||
print(
|
|
||||||
f"[GLM-DEBUG] msg[{i}].content[{j}]: type=image_url, url={display}"
|
|
||||||
)
|
|
||||||
elif part_type == "text":
|
|
||||||
preview = (part.get("text", "") or "")[:100]
|
|
||||||
print(
|
|
||||||
f"[GLM-DEBUG] msg[{i}].content[{j}]: type=text, text={preview}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
print(f"[GLM-DEBUG] msg[{i}].content[{j}]: {part}")
|
|
||||||
else:
|
|
||||||
print(f"[GLM-DEBUG] msg[{i}]: role={role}, content={str(content)[:150]}")
|
|
||||||
if extra_kwargs:
|
|
||||||
print(f"[GLM-DEBUG] extra_kwargs={extra_kwargs}")
|
|
||||||
# 原始 JSON 转储(用于排查结构问题)
|
|
||||||
import json as _json
|
|
||||||
|
|
||||||
print(
|
|
||||||
f"[GLM-RAW] messages={_json.dumps(glm_msgs, ensure_ascii=False, default=str)[:2000]}"
|
|
||||||
)
|
|
||||||
|
|
||||||
chunk_queue: queue.Queue = queue.Queue(maxsize=128)
|
|
||||||
|
|
||||||
def _producer():
|
|
||||||
try:
|
|
||||||
client = get_client()
|
|
||||||
resp = client.chat.completions.create(
|
|
||||||
model=actual_model,
|
|
||||||
messages=glm_msgs,
|
|
||||||
stream=True,
|
|
||||||
temperature=temperature,
|
|
||||||
max_tokens=max_tokens,
|
|
||||||
**extra_kwargs,
|
|
||||||
)
|
|
||||||
for chunk in resp:
|
|
||||||
chunk_queue.put(chunk)
|
|
||||||
except Exception as exc:
|
|
||||||
chunk_queue.put(exc)
|
|
||||||
finally:
|
|
||||||
chunk_queue.put(_SENTINEL)
|
|
||||||
|
|
||||||
t = threading.Thread(target=_producer, daemon=True)
|
|
||||||
t.start()
|
|
||||||
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
|
|
||||||
full_reasoning = "" # 累计思考内容(用于判断是否首次)
|
|
||||||
full_content = "" # 累计正式回答(用于判断是否首次)
|
|
||||||
|
|
||||||
while True:
|
|
||||||
item = await loop.run_in_executor(None, chunk_queue.get)
|
|
||||||
|
|
||||||
if item is _SENTINEL:
|
|
||||||
break
|
|
||||||
|
|
||||||
if isinstance(item, Exception):
|
|
||||||
print(f"[GLM] 生产者异常:{item}")
|
|
||||||
yield f"data: {json.dumps({'error': {'message': str(item), 'type': 'glm_error'}}, ensure_ascii=False)}\n\n"
|
|
||||||
break
|
|
||||||
|
|
||||||
try:
|
|
||||||
delta = item.choices[0].delta
|
|
||||||
reasoning = getattr(delta, "reasoning_content", "") or ""
|
|
||||||
text = getattr(delta, "content", "") or ""
|
|
||||||
|
|
||||||
delta_str = ""
|
|
||||||
|
|
||||||
# ── 思考过程(reasoning_content)────────────────────────
|
|
||||||
if reasoning:
|
|
||||||
if not full_reasoning:
|
|
||||||
# 首个思考片段:添加 <think> 开始标签
|
|
||||||
delta_str += "<think>"
|
|
||||||
full_reasoning += reasoning
|
|
||||||
delta_str += reasoning
|
|
||||||
|
|
||||||
# ── 正式回答(content)──────────────────────────────────
|
|
||||||
if text:
|
|
||||||
if not full_content and full_reasoning:
|
|
||||||
# 思考结束后首次出现正式回答:关闭 </think> 标签
|
|
||||||
delta_str += "</think>\n\n"
|
|
||||||
full_content += text
|
|
||||||
delta_str += text
|
|
||||||
|
|
||||||
if not delta_str:
|
|
||||||
continue
|
|
||||||
|
|
||||||
data = {
|
|
||||||
"id": f"chatcmpl-{generate_unique_id()}",
|
|
||||||
"object": "chat.completion.chunk",
|
|
||||||
"created": get_current_timestamp(),
|
|
||||||
"model": actual_model,
|
|
||||||
"choices": [
|
|
||||||
{"index": 0, "delta": {"content": delta_str}, "finish_reason": None}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[GLM] chunk 解析异常:{e}")
|
|
||||||
|
|
||||||
finish = {
|
|
||||||
"id": f"chatcmpl-{generate_unique_id()}",
|
|
||||||
"object": "chat.completion.chunk",
|
|
||||||
"created": get_current_timestamp(),
|
|
||||||
"model": actual_model,
|
|
||||||
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
|
|
||||||
}
|
|
||||||
yield f"data: {json.dumps(finish, ensure_ascii=False)}\n\n"
|
|
||||||
yield "data: [DONE]\n\n"
|
|
||||||
|
|
||||||
|
|
||||||
# ── 非流式调用 ────────────────────────────────────────────────────────
|
|
||||||
def glm_chat_sync(
|
|
||||||
messages: list,
|
|
||||||
model: str,
|
|
||||||
temperature: float,
|
|
||||||
max_tokens: int,
|
|
||||||
files: list | None = None,
|
|
||||||
web_search: str | bool = False,
|
|
||||||
deep_thinking: bool = False,
|
|
||||||
) -> dict:
|
|
||||||
"""
|
|
||||||
web_search:
|
|
||||||
- False / "" : 不启用联网搜索
|
|
||||||
- True / "simple" : 简单搜索(search_std + medium)
|
|
||||||
- "deep" : 深度搜索(search_pro + high + 更多结果)
|
|
||||||
"""
|
|
||||||
glm_msgs, has_vision = build_glm_messages(messages, files)
|
|
||||||
actual_model = resolve_model(model, has_vision)
|
|
||||||
|
|
||||||
extra_kwargs: dict = {}
|
|
||||||
if web_search:
|
|
||||||
extra_kwargs["tools"] = [_build_web_search_tool(web_search)]
|
|
||||||
if deep_thinking:
|
|
||||||
extra_kwargs["thinking"] = {"type": "enabled"}
|
|
||||||
|
|
||||||
client = get_client()
|
|
||||||
print(f"[GLM] 非流式请求:model={actual_model}")
|
|
||||||
# ── 调试:打印发送给 GLM 的完整消息结构 ──
|
|
||||||
for i, msg in enumerate(glm_msgs):
|
|
||||||
role = msg.get("role", "?")
|
|
||||||
content = msg.get("content", "")
|
|
||||||
if isinstance(content, list):
|
|
||||||
for j, part in enumerate(content):
|
|
||||||
if not isinstance(part, dict):
|
|
||||||
print(f"[GLM-DEBUG] msg[{i}].content[{j}]: {type(part).__name__}")
|
|
||||||
continue
|
|
||||||
part_type = part.get("type", "?")
|
|
||||||
if part_type == "image_url":
|
|
||||||
img_val = part.get("image_url", "")
|
|
||||||
img_url = (
|
|
||||||
img_val.get("url", "")
|
|
||||||
if isinstance(img_val, dict)
|
|
||||||
else str(img_val)
|
|
||||||
)
|
|
||||||
display = img_url[:120] + "..." if len(img_url) > 120 else img_url
|
|
||||||
print(
|
|
||||||
f"[GLM-DEBUG] msg[{i}].content[{j}]: type=image_url, url={display}"
|
|
||||||
)
|
|
||||||
elif part_type == "text":
|
|
||||||
preview = (part.get("text", "") or "")[:100]
|
|
||||||
print(
|
|
||||||
f"[GLM-DEBUG] msg[{i}].content[{j}]: type=text, text={preview}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
print(f"[GLM-DEBUG] msg[{i}].content[{j}]: {part}")
|
|
||||||
else:
|
|
||||||
print(f"[GLM-DEBUG] msg[{i}]: role={role}, content={str(content)[:150]}")
|
|
||||||
if extra_kwargs:
|
|
||||||
print(f"[GLM-DEBUG] extra_kwargs={extra_kwargs}")
|
|
||||||
# 原始 JSON 转储(用于排查结构问题)
|
|
||||||
import json as _json
|
|
||||||
|
|
||||||
print(
|
|
||||||
f"[GLM-RAW] messages={_json.dumps(glm_msgs, ensure_ascii=False, default=str)[:2000]}"
|
|
||||||
)
|
|
||||||
resp = client.chat.completions.create(
|
|
||||||
model=actual_model,
|
|
||||||
messages=glm_msgs,
|
|
||||||
stream=False,
|
|
||||||
temperature=temperature,
|
|
||||||
max_tokens=max_tokens,
|
|
||||||
**extra_kwargs,
|
|
||||||
)
|
|
||||||
content = resp.choices[0].message.content or ""
|
|
||||||
usage = None
|
|
||||||
if hasattr(resp, "usage") and resp.usage:
|
|
||||||
usage = {
|
|
||||||
"promptTokens": resp.usage.prompt_tokens,
|
|
||||||
"completionTokens": resp.usage.completion_tokens,
|
|
||||||
"totalTokens": resp.usage.total_tokens,
|
|
||||||
}
|
|
||||||
return {"content": content, "model": actual_model, "usage": usage}
|
|
||||||
|
|
@ -8,7 +8,7 @@ import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
from .logger import (log_chat_interaction, log_error_detail, log_request_info,
|
from core import (log_chat_interaction, log_error_detail, log_request_info,
|
||||||
log_response_info)
|
log_response_info)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,10 +33,9 @@ from dotenv import load_dotenv
|
||||||
# ── 加载环境变量 ──────────────────────────────────────────────
|
# ── 加载环境变量 ──────────────────────────────────────────────
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
# AccessKey 从系统环境变量读取(~/.bashrc 中 export 设置)
|
# 所有配置从 .env 文件读取
|
||||||
OSS_ACCESS_KEY_ID = os.environ.get("OSS_ACCESS_KEY_ID", "")
|
OSS_ACCESS_KEY_ID = os.getenv("OSS_ACCESS_KEY_ID", "")
|
||||||
OSS_ACCESS_KEY_SECRET = os.environ.get("OSS_ACCESS_KEY_SECRET", "")
|
OSS_ACCESS_KEY_SECRET = os.getenv("OSS_ACCESS_KEY_SECRET", "")
|
||||||
# 以下配置从 .env 文件读取
|
|
||||||
OSS_BUCKET_NAME = os.getenv("OSS_BUCKET_NAME", "")
|
OSS_BUCKET_NAME = os.getenv("OSS_BUCKET_NAME", "")
|
||||||
OSS_ENDPOINT = os.getenv("OSS_ENDPOINT", "")
|
OSS_ENDPOINT = os.getenv("OSS_ENDPOINT", "")
|
||||||
OSS_REGION = os.getenv("OSS_REGION", "")
|
OSS_REGION = os.getenv("OSS_REGION", "")
|
||||||
|
|
@ -57,11 +56,12 @@ def _get_client() -> oss.Client:
|
||||||
return oss.Client(cfg)
|
return oss.Client(cfg)
|
||||||
|
|
||||||
|
|
||||||
def _generate_object_key(filename: str, prefix: str = "uploads") -> str:
|
def _generate_object_key(filename: str, prefix: str = "chat-ui") -> str:
|
||||||
"""
|
"""
|
||||||
根据文件名生成唯一的 OSS 对象 Key
|
根据文件名生成唯一的 OSS 对象 Key
|
||||||
格式: {prefix}/{日期}/{uuid}_{原始文件名}
|
格式: {prefix}/{日期}/{uuid}_{原始文件名}
|
||||||
"""
|
"""
|
||||||
|
# TODO: 需要按用户ID分目录
|
||||||
date_str = datetime.now().strftime("%Y%m%d")
|
date_str = datetime.now().strftime("%Y%m%d")
|
||||||
unique_id = uuid.uuid4().hex[:8]
|
unique_id = uuid.uuid4().hex[:8]
|
||||||
safe_name = Path(filename).name # 只取文件名,去掉路径
|
safe_name = Path(filename).name # 只取文件名,去掉路径
|
||||||
|
|
@ -80,7 +80,7 @@ def _build_url(object_key: str) -> str:
|
||||||
def upload_file(
|
def upload_file(
|
||||||
file_path: str,
|
file_path: str,
|
||||||
object_key: Optional[str] = None,
|
object_key: Optional[str] = None,
|
||||||
prefix: str = "uploads",
|
prefix: str = "chat-ui",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
上传本地文件到 OSS
|
上传本地文件到 OSS
|
||||||
|
|
@ -204,6 +204,99 @@ def upload_fileobj(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_file(object_key: str) -> bool:
|
||||||
|
"""
|
||||||
|
删除 OSS 上的单个文件
|
||||||
|
|
||||||
|
参数:
|
||||||
|
object_key: OSS 对象路径(如 "uploads/20240301/abc123_file.jpg")
|
||||||
|
|
||||||
|
返回:
|
||||||
|
True 表示删除成功,False 表示失败
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = _get_client()
|
||||||
|
result = client.delete_object(
|
||||||
|
oss.DeleteObjectRequest(
|
||||||
|
bucket=OSS_BUCKET_NAME,
|
||||||
|
key=object_key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.status_code == 204
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[OSS] 删除文件失败: {object_key}, 错误: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def delete_files(object_keys: list) -> dict:
|
||||||
|
"""
|
||||||
|
批量删除 OSS 上的文件
|
||||||
|
|
||||||
|
参数:
|
||||||
|
object_keys: OSS 对象路径列表
|
||||||
|
|
||||||
|
返回:
|
||||||
|
{
|
||||||
|
"deleted": ["成功删除的 object_key 列表"],
|
||||||
|
"failed": ["删除失败的 object_key 列表"],
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
deleted = []
|
||||||
|
failed = []
|
||||||
|
|
||||||
|
for key in object_keys:
|
||||||
|
if delete_file(key):
|
||||||
|
deleted.append(key)
|
||||||
|
else:
|
||||||
|
failed.append(key)
|
||||||
|
|
||||||
|
return {"deleted": deleted, "failed": failed}
|
||||||
|
|
||||||
|
|
||||||
|
def extract_object_key_from_url(url: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
从 OSS URL 中提取 object_key
|
||||||
|
|
||||||
|
参数:
|
||||||
|
url: OSS 文件的完整 URL
|
||||||
|
|
||||||
|
返回:
|
||||||
|
object_key 或 None(如果不是有效的 OSS URL)
|
||||||
|
"""
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 支持两种 URL 格式:
|
||||||
|
# 1. 自定义域名: OSS_URL_PREFIX/object_key
|
||||||
|
# 2. 默认域名: https://bucket.endpoint/object_key
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 移除查询参数
|
||||||
|
url_path = url.split("?")[0]
|
||||||
|
|
||||||
|
if OSS_URL_PREFIX:
|
||||||
|
# 自定义域名格式
|
||||||
|
prefix = OSS_URL_PREFIX.rstrip("/")
|
||||||
|
if url_path.startswith(prefix):
|
||||||
|
return url_path[len(prefix) + 1:] # +1 去掉开头的 /
|
||||||
|
|
||||||
|
# 默认域名格式: https://bucket.endpoint/object_key
|
||||||
|
endpoint = OSS_ENDPOINT.replace("https://", "").replace("http://", "")
|
||||||
|
default_prefix = f"https://{OSS_BUCKET_NAME}.{endpoint}/"
|
||||||
|
|
||||||
|
if url_path.startswith(default_prefix):
|
||||||
|
return url_path[len(default_prefix):]
|
||||||
|
|
||||||
|
# 也尝试匹配 http 版本
|
||||||
|
http_prefix = f"http://{OSS_BUCKET_NAME}.{endpoint}/"
|
||||||
|
if url_path.startswith(http_prefix):
|
||||||
|
return url_path[len(http_prefix):]
|
||||||
|
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────
|
||||||
# 命令行入口:python -m utils.oss_uploader --file <路径>
|
# 命令行入口:python -m utils.oss_uploader --file <路径>
|
||||||
# ────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,84 @@
|
||||||
|
"""
|
||||||
|
GLM 适配器测试脚本
|
||||||
|
测试 GLMAdapter 的流式和非流式调用,包括联网搜索功能
|
||||||
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Add project root to sys.path
|
# Add project root to sys.path
|
||||||
root_dir = Path(__file__).parent
|
root_dir = Path(__file__).parent.parent
|
||||||
sys.path.insert(0, str(root_dir))
|
sys.path.insert(0, str(root_dir))
|
||||||
|
|
||||||
# Set API key from .env if needed
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from utils.glm_adapter import _ensure_venv, glm_chat_sync, glm_stream_generator
|
from adapters.glm_adapter import GLMAdapter
|
||||||
|
from adapters.base import ChatCompletionRequest
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
async def test_stream():
|
async def test_stream():
|
||||||
msgs = [{"role": "user", "content": "今天北京天气怎样?"}]
|
"""测试流式调用(联网搜索)"""
|
||||||
print("Testing stream...")
|
adapter = GLMAdapter()
|
||||||
async for chunk in glm_stream_generator(
|
|
||||||
msgs, "glm-4.5-air", 0.7, 1024, web_search=True
|
if not adapter.is_available():
|
||||||
):
|
print("错误:未配置 ZHIPU_API_KEY 或 GLM_API_KEY")
|
||||||
|
return
|
||||||
|
|
||||||
|
request = ChatCompletionRequest(
|
||||||
|
model="glm-4.6v",
|
||||||
|
messages=[{"role": "user", "content": "今天北京天气怎样?"}],
|
||||||
|
stream=True,
|
||||||
|
temperature=0.7,
|
||||||
|
max_tokens=1024,
|
||||||
|
web_search=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
print("Testing stream with web_search...")
|
||||||
|
response = await adapter.chat(request)
|
||||||
|
|
||||||
|
# 流式响应是 StreamingResponse,需要手动读取
|
||||||
|
async for chunk in response.body_iterator:
|
||||||
|
# body_iterator 已经返回字符串
|
||||||
print(chunk, end="")
|
print(chunk, end="")
|
||||||
|
|
||||||
|
|
||||||
def test_sync():
|
async def test_sync():
|
||||||
msgs = [{"role": "user", "content": "今天几号?武汉天气怎样?"}]
|
"""测试非流式调用(联网搜索)"""
|
||||||
print("Testing sync...")
|
adapter = GLMAdapter()
|
||||||
res = glm_chat_sync(msgs, "glm-4.5-air", 0.7, 1024, web_search=True)
|
|
||||||
print(res)
|
if not adapter.is_available():
|
||||||
|
print("错误:未配置 ZHIPU_API_KEY 或 GLM_API_KEY")
|
||||||
|
return
|
||||||
|
|
||||||
|
request = ChatCompletionRequest(
|
||||||
|
model="glm-4-flash",
|
||||||
|
messages=[{"role": "user", "content": "今天几号?武汉天气怎样?"}],
|
||||||
|
stream=False,
|
||||||
|
temperature=0.7,
|
||||||
|
max_tokens=1024,
|
||||||
|
web_search=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
print("Testing sync with web_search...")
|
||||||
|
response = await adapter.chat(request)
|
||||||
|
|
||||||
|
# 非流式响应返回 JSONResponse
|
||||||
|
if hasattr(response, "body"):
|
||||||
|
import json
|
||||||
|
|
||||||
|
data = json.loads(response.body)
|
||||||
|
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||||
|
print(f"Response: {content}")
|
||||||
|
else:
|
||||||
|
print(f"Response: {response}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
_ensure_venv()
|
# 运行流式测试
|
||||||
# test_sync()
|
# asyncio.run(test_stream())
|
||||||
asyncio.run(test_stream())
|
# 运行非流式测试
|
||||||
|
asyncio.run(test_sync())
|
||||||
|
|
@ -1,171 +0,0 @@
|
||||||
"""
|
|
||||||
测试脚本:上传 PDF / DOCX / TXT 文件到阿里云 OSS → 获取 URL → 发送给 GLM-4.6V 识别
|
|
||||||
|
|
||||||
支持的文件类型:
|
|
||||||
- .pdf → 上传 OSS 后以 file_url 类型发送 URL 给 GLM
|
|
||||||
- .docx → 上传 OSS 后以 file_url 类型发送 URL 给 GLM
|
|
||||||
- .txt → 上传 OSS 后以 file_url 类型发送 URL 给 GLM
|
|
||||||
|
|
||||||
用法:
|
|
||||||
cd server
|
|
||||||
source ~/.bashrc && source .venv/bin/activate
|
|
||||||
python -m utils.test_oss_doc_glm --file <本地文件路径> [--prompt "请总结这份文件"]
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# 确保 server 目录在 sys.path
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from utils.oss_uploader import upload_file
|
|
||||||
from utils.glm_adapter import glm_chat_sync
|
|
||||||
|
|
||||||
|
|
||||||
# 文件类型分组
|
|
||||||
DOC_EXTS = {".pdf", ".doc", ".docx", ".xlsx", ".xls", ".pptx", ".ppt"}
|
|
||||||
TXT_EXTS = {".txt", ".md", ".csv", ".json", ".log"}
|
|
||||||
IMG_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"}
|
|
||||||
# 所有可通过 file_url 发送的类型
|
|
||||||
FILE_URL_EXTS = DOC_EXTS | TXT_EXTS
|
|
||||||
|
|
||||||
|
|
||||||
def detect_file_type(suffix: str) -> str:
|
|
||||||
"""根据后缀判断文件类别: 'file_url' / 'image' / 'unknown'"""
|
|
||||||
suffix = suffix.lower()
|
|
||||||
if suffix in FILE_URL_EXTS:
|
|
||||||
return "file_url"
|
|
||||||
elif suffix in IMG_EXTS:
|
|
||||||
return "image"
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
def build_messages_for_file_url(file_url: str, prompt: str) -> list:
|
|
||||||
"""
|
|
||||||
为文档/文本文件构建消息。
|
|
||||||
使用 file_url 类型,直接传递 OSS 的 URL 给 GLM。
|
|
||||||
"""
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": [
|
|
||||||
{
|
|
||||||
"type": "file_url",
|
|
||||||
"file_url": {"url": file_url},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"text": prompt,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def build_messages_for_image(file_url: str, prompt: str) -> list:
|
|
||||||
"""为图片文件构建消息,使用 image_url 类型。"""
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": [
|
|
||||||
{"type": "image_url", "image_url": {"url": file_url}},
|
|
||||||
{"type": "text", "text": prompt},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="上传 PDF/DOCX/TXT 文件到 OSS 并让 GLM-4.6V 识别"
|
|
||||||
)
|
|
||||||
parser.add_argument("--file", required=True, help="要上传的本地文件路径")
|
|
||||||
parser.add_argument(
|
|
||||||
"--prompt", default="请总结这份文件的主要内容", help="发给 GLM 的提示词"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--model", default="glm-4.6v", help="GLM 模型名称(默认: glm-4.6v)"
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
file_path = Path(args.file).resolve()
|
|
||||||
if not file_path.exists():
|
|
||||||
print(f"❌ 文件不存在: {file_path}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
suffix = file_path.suffix.lower()
|
|
||||||
file_type = detect_file_type(suffix)
|
|
||||||
print(f"📂 文件信息:")
|
|
||||||
print(f" 路径: {file_path}")
|
|
||||||
print(f" 后缀: {suffix}")
|
|
||||||
print(f" 类型: {file_type}")
|
|
||||||
print(f" 大小: {file_path.stat().st_size / 1024:.1f} KB")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# ── 第一步:上传文件到 OSS ────────────────────────────────
|
|
||||||
print(f"📤 正在上传文件到阿里云 OSS...")
|
|
||||||
oss_result = upload_file(str(file_path))
|
|
||||||
file_url = oss_result["url"]
|
|
||||||
print(f"✅ OSS 上传成功!")
|
|
||||||
print(f" URL: {file_url}")
|
|
||||||
print(f" ETag: {oss_result['etag']}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# ── 第二步:根据文件类型构建消息 ──────────────────────────
|
|
||||||
print(f"🔧 正在构建 GLM 消息...")
|
|
||||||
|
|
||||||
if file_type == "file_url":
|
|
||||||
# PDF / DOCX / TXT 等:使用 file_url 类型发送 OSS URL
|
|
||||||
print(f" 策略: 使用 file_url 发送 OSS 链接")
|
|
||||||
messages = build_messages_for_file_url(file_url, args.prompt)
|
|
||||||
elif file_type == "image":
|
|
||||||
# 图片:使用 image_url
|
|
||||||
print(f" 策略: 使用 image_url 发送 OSS 链接")
|
|
||||||
messages = build_messages_for_image(file_url, args.prompt)
|
|
||||||
else:
|
|
||||||
print(f"❌ 不支持的文件类型: {suffix}")
|
|
||||||
print(f" 支持: {', '.join(sorted(FILE_URL_EXTS | IMG_EXTS))}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print()
|
|
||||||
|
|
||||||
# ── 第三步:发送给 GLM 识别 ──────────────────────────────
|
|
||||||
print(f"🤖 正在请求 GLM ({args.model}) 识别文件...")
|
|
||||||
print(f" 提示词: {args.prompt}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = glm_chat_sync(
|
|
||||||
messages=messages,
|
|
||||||
model=args.model,
|
|
||||||
temperature=0.7,
|
|
||||||
max_tokens=4096,
|
|
||||||
)
|
|
||||||
|
|
||||||
print("─" * 60)
|
|
||||||
print("📝 GLM 回复:")
|
|
||||||
print("─" * 60)
|
|
||||||
print(result["content"])
|
|
||||||
print("─" * 60)
|
|
||||||
|
|
||||||
if result.get("usage"):
|
|
||||||
usage = result["usage"]
|
|
||||||
print(
|
|
||||||
f"\n📊 Token 用量: 输入 {usage['promptTokens']} | "
|
|
||||||
f"输出 {usage['completionTokens']} | "
|
|
||||||
f"总计 {usage['totalTokens']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
print(f"\n✅ 测试完成! 使用模型: {result.get('model', args.model)}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"\n❌ GLM 请求失败: {e}")
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
traceback.print_exc()
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
"""
|
|
||||||
测试脚本:上传文件到 OSS → 获取 URL → 发送给 GLM 进行识别
|
|
||||||
|
|
||||||
用法:
|
|
||||||
cd server
|
|
||||||
source ~/.bashrc && source .venv/bin/activate
|
|
||||||
python -m utils.test_oss_glm --file <本地文件路径> [--prompt "描述一下这张图片"]
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# 确保 server 目录在 sys.path
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from utils.oss_uploader import upload_file
|
|
||||||
from utils.glm_adapter import glm_chat_sync
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(description="上传文件到 OSS 并让 GLM 读取")
|
|
||||||
parser.add_argument("--file", required=True, help="要上传的本地文件路径")
|
|
||||||
parser.add_argument(
|
|
||||||
"--prompt", default="请描述一下这张图片的内容", help="发给 GLM 的提示词"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--model", default="glm-4.6v", help="GLM 模型名称(默认: glm-4.6v)"
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
# ── 第一步:上传文件到 OSS ────────────────────────────────
|
|
||||||
file_path = args.file
|
|
||||||
if not Path(file_path).exists():
|
|
||||||
print(f"❌ 文件不存在: {file_path}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print(f"📤 正在上传文件: {file_path}")
|
|
||||||
oss_result = upload_file(file_path)
|
|
||||||
file_url = oss_result["url"]
|
|
||||||
print(f"✅ 上传成功!")
|
|
||||||
print(f" URL: {file_url}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# ── 第二步:构建消息,把 URL 发给 GLM ──────────────────────
|
|
||||||
messages = [
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": [
|
|
||||||
{
|
|
||||||
"type": "image_url",
|
|
||||||
"image_url": {"url": file_url},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"text": args.prompt,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
print(f"🤖 正在请求 GLM ({args.model}) 识别图片...")
|
|
||||||
print(f" 提示词: {args.prompt}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
result = glm_chat_sync(
|
|
||||||
messages=messages,
|
|
||||||
model=args.model,
|
|
||||||
temperature=0.7,
|
|
||||||
max_tokens=2048,
|
|
||||||
)
|
|
||||||
|
|
||||||
print("─" * 60)
|
|
||||||
print("📝 GLM 回复:")
|
|
||||||
print("─" * 60)
|
|
||||||
print(result["content"])
|
|
||||||
print("─" * 60)
|
|
||||||
|
|
||||||
if result.get("usage"):
|
|
||||||
usage = result["usage"]
|
|
||||||
print(
|
|
||||||
f"\n📊 Token 用量: 输入 {usage['promptTokens']} | "
|
|
||||||
f"输出 {usage['completionTokens']} | "
|
|
||||||
f"总计 {usage['totalTokens']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
import json
|
||||||
|
|
||||||
|
def test_tavily(api_key: str):
|
||||||
|
url = "https://api.tavily.com/search"
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
data = {
|
||||||
|
"api_key": api_key,
|
||||||
|
"query": "武汉明天的天气",
|
||||||
|
"search_depth": "basic",
|
||||||
|
"include_answer": False,
|
||||||
|
"max_results": 3
|
||||||
|
}
|
||||||
|
|
||||||
|
# 模拟请求
|
||||||
|
req = urllib.request.Request(url, data=json.dumps(data).encode('utf-8'), headers=headers, method='POST')
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as response:
|
||||||
|
result = json.loads(response.read().decode('utf-8'))
|
||||||
|
print("✅ Tavily API Key 测试成功!成功获取以下搜索结果:\n")
|
||||||
|
|
||||||
|
for i, res in enumerate(result.get("results", [])):
|
||||||
|
print(f"[{i+1}] 标题: {res.get('title')}")
|
||||||
|
print(f" 内容: {res.get('content')}")
|
||||||
|
print(f" 链接: {res.get('url')}\n")
|
||||||
|
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(f"❌ 请求失败,HTTP 错误代码: {e.code}")
|
||||||
|
print("这通常意味着您的 API Key 错误或无效。详细信息:")
|
||||||
|
error_msg = e.read().decode('utf-8')
|
||||||
|
print(error_msg)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 发生其他错误: {str(e)}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
key = input("请输入您的 Tavily API Key (以 tvly- 开头): ").strip()
|
||||||
|
if not key:
|
||||||
|
print("未输入 Key,程序退出。")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("\n正在连接 Tavily 进行测试搜索...")
|
||||||
|
test_tavily(key)
|
||||||
76
src/App.vue
76
src/App.vue
|
|
@ -1,16 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="app" :class="{ dark: isDark }">
|
<div class="app" :class="{ dark: isDark }">
|
||||||
<!-- 侧边栏 -->
|
<router-view />
|
||||||
<ChatSidebar />
|
|
||||||
|
|
||||||
<!-- 主内容区 -->
|
|
||||||
<ChatMain ref="chatMainRef" @toggle-sidebar="toggleSidebar" />
|
|
||||||
|
|
||||||
<!-- 模态框 -->
|
|
||||||
<SearchModal />
|
|
||||||
<ShortcutsModal />
|
|
||||||
<SettingsModal />
|
|
||||||
<ConversationSettingsModal />
|
|
||||||
|
|
||||||
<!-- Toast 通知 -->
|
<!-- Toast 通知 -->
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
|
|
@ -32,28 +22,14 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from "vue";
|
import { ref, computed } from "vue";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
import { useChatStore } from "@/stores/chat";
|
|
||||||
import { useSettingsStore } from "@/stores/settings";
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
import { useKeyboard, getDefaultShortcuts } from "@/composables/useKeyboard";
|
|
||||||
import ChatSidebar from "@/components/sidebar/ChatSidebar.vue";
|
|
||||||
import ChatMain from "@/components/chat/ChatMain.vue";
|
|
||||||
import SearchModal from "@/components/modals/SearchModal.vue";
|
|
||||||
import ShortcutsModal from "@/components/modals/ShortcutsModal.vue";
|
|
||||||
import SettingsModal from "@/components/modals/SettingsModal.vue";
|
|
||||||
import ConversationSettingsModal from "@/components/modals/ConversationSettingsModal.vue";
|
|
||||||
import { Check, AlertCircle, Info } from "@/components/icons";
|
import { Check, AlertCircle, Info } from "@/components/icons";
|
||||||
|
|
||||||
// Stores
|
|
||||||
const chatStore = useChatStore();
|
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
|
|
||||||
const { settings } = storeToRefs(settingsStore);
|
const { settings } = storeToRefs(settingsStore);
|
||||||
|
|
||||||
// Refs
|
|
||||||
const chatMainRef = ref<InstanceType<typeof ChatMain> | null>(null);
|
|
||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
const isDark = computed(() => {
|
const isDark = computed(() => {
|
||||||
if (settings.value.theme === "system") {
|
if (settings.value.theme === "system") {
|
||||||
|
|
@ -84,54 +60,6 @@ function showToast(message: string, type: Toast["type"] = "info") {
|
||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 方法
|
|
||||||
function toggleSidebar() {
|
|
||||||
settingsStore.toggleSidebar();
|
|
||||||
}
|
|
||||||
|
|
||||||
function newChat() {
|
|
||||||
chatStore.createConversation();
|
|
||||||
showToast("已创建新对话", "success");
|
|
||||||
}
|
|
||||||
|
|
||||||
function focusInput() {
|
|
||||||
chatMainRef.value?.focusInput();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 快捷键
|
|
||||||
useKeyboard(
|
|
||||||
getDefaultShortcuts({
|
|
||||||
newChat,
|
|
||||||
toggleSidebar,
|
|
||||||
focusInput,
|
|
||||||
sendMessage: () => {}, // 由 ChatInput 内部处理
|
|
||||||
cancelStream: () => {
|
|
||||||
if (chatStore.isStreaming) {
|
|
||||||
chatStore.stopStreaming();
|
|
||||||
showToast("已停止生成", "info");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
toggleTheme: () => {
|
|
||||||
settingsStore.toggleTheme();
|
|
||||||
showToast(`主题已切换为 ${settings.value.theme}`, "success");
|
|
||||||
},
|
|
||||||
showShortcuts: () => {
|
|
||||||
settingsStore.openShortcutsModal();
|
|
||||||
},
|
|
||||||
searchConversations: () => {
|
|
||||||
settingsStore.openSearchModal();
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 初始化
|
|
||||||
onMounted(() => {
|
|
||||||
// 如果没有对话,创建一个
|
|
||||||
if (chatStore.conversations.length === 0) {
|
|
||||||
chatStore.createConversation();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 暴露给全局使用
|
// 暴露给全局使用
|
||||||
window.$toast = showToast;
|
window.$toast = showToast;
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import ShareButton from '@/components/sidebar/ShareButton.vue'
|
||||||
|
|
||||||
|
// Mock window.$toast
|
||||||
|
vi.stubGlobal('$toast', vi.fn())
|
||||||
|
|
||||||
|
describe('ShareButton 组件测试', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('应该渲染分享按钮', () => {
|
||||||
|
const wrapper = mount(ShareButton)
|
||||||
|
|
||||||
|
expect(wrapper.find('.share-btn').exists()).toBe(true)
|
||||||
|
expect(wrapper.text()).toContain('分享对话')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('点击按钮应该进入选择模式', async () => {
|
||||||
|
const wrapper = mount(ShareButton)
|
||||||
|
const pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
|
|
||||||
|
await wrapper.find('.share-btn').trigger('click')
|
||||||
|
|
||||||
|
// 检查是否切换到选择模式
|
||||||
|
// 这里需要通过 store 来验证
|
||||||
|
})
|
||||||
|
|
||||||
|
it('在选择模式下应该显示选择信息', async () => {
|
||||||
|
const pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
|
|
||||||
|
const wrapper = mount(ShareButton)
|
||||||
|
|
||||||
|
// 模拟进入选择模式
|
||||||
|
const { useChatStore } = await import('@/stores/chat')
|
||||||
|
const store = useChatStore()
|
||||||
|
store.enterSelectMode()
|
||||||
|
store.selectedConversationIds = ['conv-1', 'conv-2']
|
||||||
|
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('.select-actions').exists()).toBe(true)
|
||||||
|
expect(wrapper.text()).toContain('已选择 2 个对话')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('选择数量为 0 时确认按钮应该禁用', async () => {
|
||||||
|
const pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
|
|
||||||
|
const wrapper = mount(ShareButton)
|
||||||
|
|
||||||
|
const { useChatStore } = await import('@/stores/chat')
|
||||||
|
const store = useChatStore()
|
||||||
|
store.enterSelectMode()
|
||||||
|
// selectedConversationIds 为空
|
||||||
|
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
const confirmBtn = wrapper.find('.action-btn.confirm')
|
||||||
|
expect(confirmBtn.attributes('disabled')).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { useChatStore } from '@/stores/chat'
|
||||||
|
import type { Conversation } from '@/types/chat'
|
||||||
|
|
||||||
|
describe('Chat Store - 多选模式测试', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// 每个测试前创建新的 Pinia 实例
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('多选模式状态', () => {
|
||||||
|
it('初始状态应该是未选择模式', () => {
|
||||||
|
const store = useChatStore()
|
||||||
|
expect(store.isSelectMode).toBe(false)
|
||||||
|
expect(store.selectedConversationIds).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toggleSelectMode 应该切换选择模式', () => {
|
||||||
|
const store = useChatStore()
|
||||||
|
expect(store.isSelectMode).toBe(false)
|
||||||
|
|
||||||
|
store.toggleSelectMode()
|
||||||
|
expect(store.isSelectMode).toBe(true)
|
||||||
|
|
||||||
|
store.toggleSelectMode()
|
||||||
|
expect(store.isSelectMode).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('enterSelectMode 应该进入选择模式', () => {
|
||||||
|
const store = useChatStore()
|
||||||
|
store.enterSelectMode()
|
||||||
|
expect(store.isSelectMode).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exitSelectMode 应该退出选择模式并清除选择', () => {
|
||||||
|
const store = useChatStore()
|
||||||
|
store.enterSelectMode()
|
||||||
|
store.selectedConversationIds.push('conv-1', 'conv-2')
|
||||||
|
|
||||||
|
store.exitSelectMode()
|
||||||
|
|
||||||
|
expect(store.isSelectMode).toBe(false)
|
||||||
|
expect(store.selectedConversationIds).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('对话选择操作', () => {
|
||||||
|
it('toggleConversationSelection 应该切换对话选择状态', () => {
|
||||||
|
const store = useChatStore()
|
||||||
|
|
||||||
|
// 选择对话
|
||||||
|
store.toggleConversationSelection('conv-1')
|
||||||
|
expect(store.selectedConversationIds).toContain('conv-1')
|
||||||
|
|
||||||
|
// 取消选择
|
||||||
|
store.toggleConversationSelection('conv-1')
|
||||||
|
expect(store.selectedConversationIds).not.toContain('conv-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selectAllConversations 应该选择所有非归档对话', () => {
|
||||||
|
const store = useChatStore()
|
||||||
|
// 手动添加一些对话用于测试
|
||||||
|
store.conversations = [
|
||||||
|
{ id: 'conv-1', archived: false } as Conversation,
|
||||||
|
{ id: 'conv-2', archived: false } as Conversation,
|
||||||
|
{ id: 'conv-3', archived: true } as Conversation, // 归档的不应该被选择
|
||||||
|
]
|
||||||
|
|
||||||
|
store.selectAllConversations()
|
||||||
|
|
||||||
|
expect(store.selectedConversationIds).toContain('conv-1')
|
||||||
|
expect(store.selectedConversationIds).toContain('conv-2')
|
||||||
|
expect(store.selectedConversationIds).not.toContain('conv-3')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clearSelection 应该清除所有选择', () => {
|
||||||
|
const store = useChatStore()
|
||||||
|
store.selectedConversationIds = ['conv-1', 'conv-2', 'conv-3']
|
||||||
|
|
||||||
|
store.clearSelection()
|
||||||
|
|
||||||
|
expect(store.selectedConversationIds).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isConversationSelected 应该正确判断对话是否被选中', () => {
|
||||||
|
const store = useChatStore()
|
||||||
|
store.selectedConversationIds = ['conv-1', 'conv-2']
|
||||||
|
|
||||||
|
expect(store.isConversationSelected('conv-1')).toBe(true)
|
||||||
|
expect(store.isConversationSelected('conv-2')).toBe(true)
|
||||||
|
expect(store.isConversationSelected('conv-3')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('计算属性', () => {
|
||||||
|
it('selectedCount 应该返回选中的对话数量', () => {
|
||||||
|
const store = useChatStore()
|
||||||
|
store.selectedConversationIds = ['conv-1', 'conv-2', 'conv-3']
|
||||||
|
expect(store.selectedCount).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selectedConversations 应该返回选中的对话对象数组', () => {
|
||||||
|
const store = useChatStore()
|
||||||
|
store.conversations = [
|
||||||
|
{ id: 'conv-1', title: '对话1' } as Conversation,
|
||||||
|
{ id: 'conv-2', title: '对话2' } as Conversation,
|
||||||
|
{ id: 'conv-3', title: '对话3' } as Conversation,
|
||||||
|
]
|
||||||
|
store.selectedConversationIds = ['conv-1', 'conv-3']
|
||||||
|
|
||||||
|
expect(store.selectedConversations).toHaveLength(2)
|
||||||
|
expect(store.selectedConversations.map(c => c.id)).toEqual(['conv-1', 'conv-3'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { sha256, hashPassword, verifyPassword } from '@/utils/crypto'
|
||||||
|
|
||||||
|
describe('crypto 工具测试', () => {
|
||||||
|
describe('sha256', () => {
|
||||||
|
it('应该正确计算字符串的 SHA-256 哈希值', async () => {
|
||||||
|
const text = 'hello'
|
||||||
|
const hash = await sha256(text)
|
||||||
|
// SHA-256('hello') 的已知结果
|
||||||
|
expect(hash).toBe('2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('空字符串应该返回正确的哈希值', async () => {
|
||||||
|
const hash = await sha256('')
|
||||||
|
// SHA-256('') 的已知结果
|
||||||
|
expect(hash).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('不同字符串应该产生不同的哈希值', async () => {
|
||||||
|
const hash1 = await sha256('password1')
|
||||||
|
const hash2 = await sha256('password2')
|
||||||
|
expect(hash1).not.toBe(hash2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('hashPassword', () => {
|
||||||
|
it('应该对密码进行加盐哈希', async () => {
|
||||||
|
const password = 'mypassword'
|
||||||
|
const hash = await hashPassword(password)
|
||||||
|
// 验证返回的是64字符的十六进制字符串
|
||||||
|
expect(hash).toHaveLength(64)
|
||||||
|
expect(/^[a-f0-9]{64}$/.test(hash)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('相同密码应该产生相同的哈希值', async () => {
|
||||||
|
const password = 'test123'
|
||||||
|
const hash1 = await hashPassword(password)
|
||||||
|
const hash2 = await hashPassword(password)
|
||||||
|
expect(hash1).toBe(hash2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('verifyPassword', () => {
|
||||||
|
it('正确的密码应该验证通过', async () => {
|
||||||
|
const password = 'correctpassword'
|
||||||
|
const hash = await hashPassword(password)
|
||||||
|
const isValid = await verifyPassword(password, hash)
|
||||||
|
expect(isValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('错误的密码应该验证失败', async () => {
|
||||||
|
const password = 'correctpassword'
|
||||||
|
const hash = await hashPassword(password)
|
||||||
|
const isValid = await verifyPassword('wrongpassword', hash)
|
||||||
|
expect(isValid).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('空密码应该验证失败', async () => {
|
||||||
|
const hash = await hashPassword('somepassword')
|
||||||
|
const isValid = await verifyPassword('', hash)
|
||||||
|
expect(isValid).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
// 测试环境全局配置
|
||||||
|
import { vi } from 'vitest'
|
||||||
|
|
||||||
|
// Mock window.$toast
|
||||||
|
vi.stubGlobal('$toast', vi.fn())
|
||||||
|
|
||||||
|
// Mock 所有 API 请求,避免测试时连接后端
|
||||||
|
const mockFetch = vi.fn((url: string) => {
|
||||||
|
// 根据 URL 返回不同的 mock 数据
|
||||||
|
if (url.includes('/api/chat-ui/conversations')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve([]), // 返回空对话列表
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes('/api/chat-ui/models')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
object: 'list',
|
||||||
|
data: [
|
||||||
|
{ id: 'glm-4-flash', name: 'GLM-4-Flash', description: '测试模型', maxTokens: 8192, provider: 'Zhipu', supports_thinking: false, supports_web_search: false, supports_vision: false, supports_files: false }
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认返回空响应
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.stubGlobal('fetch', mockFetch)
|
||||||
|
|
||||||
|
// Mock localStorage
|
||||||
|
const localStorageMock = (() => {
|
||||||
|
let store: Record<string, string> = {}
|
||||||
|
return {
|
||||||
|
getItem: (key: string) => store[key] || null,
|
||||||
|
setItem: (key: string, value: string) => {
|
||||||
|
store[key] = value
|
||||||
|
},
|
||||||
|
removeItem: (key: string) => {
|
||||||
|
delete store[key]
|
||||||
|
},
|
||||||
|
clear: () => {
|
||||||
|
store = {}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
vi.stubGlobal('localStorage', localStorageMock)
|
||||||
|
|
||||||
|
// Mock matchMedia
|
||||||
|
vi.stubGlobal('matchMedia', vi.fn(() => ({
|
||||||
|
matches: false,
|
||||||
|
media: '',
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})))
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { shareApi } from '@/services/shareApi'
|
||||||
|
import type { ShareCreateResponse, ShareVerifyResponse, ShareGetResponse } from '@/types/share'
|
||||||
|
|
||||||
|
// Mock fetch
|
||||||
|
const mockFetch = vi.fn()
|
||||||
|
vi.stubGlobal('fetch', mockFetch)
|
||||||
|
|
||||||
|
// Mock getAuthHeaders
|
||||||
|
vi.mock('@/services/request', () => ({
|
||||||
|
getAuthHeaders: () => ({ 'Authorization': 'Bearer test-token' }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('分享 API 测试', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFetch.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('createShare', () => {
|
||||||
|
it('应该成功创建分享', async () => {
|
||||||
|
const mockResponse: ShareCreateResponse = {
|
||||||
|
id: 'share-123',
|
||||||
|
shareUrl: 'https://example.com/chat-ui/share/share-123',
|
||||||
|
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockResponse),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await shareApi.createShare({
|
||||||
|
conversationIds: ['conv-1', 'conv-2'],
|
||||||
|
passwordHash: 'hashed-password',
|
||||||
|
expiresIn: 604800,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
'/api/chat-ui/shares',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.id).toBe('share-123')
|
||||||
|
expect(result.shareUrl).toContain('share-123')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('创建失败应该抛出错误', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
text: () => Promise.resolve('Internal Server Error'),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
shareApi.createShare({
|
||||||
|
conversationIds: ['conv-1'],
|
||||||
|
passwordHash: 'hash',
|
||||||
|
})
|
||||||
|
).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getShare', () => {
|
||||||
|
it('应该获取分享基本信息', async () => {
|
||||||
|
const mockResponse: ShareGetResponse = {
|
||||||
|
id: 'share-123',
|
||||||
|
hasPassword: true,
|
||||||
|
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000,
|
||||||
|
isExpired: false,
|
||||||
|
conversationCount: 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockResponse),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await shareApi.getShare('share-123')
|
||||||
|
|
||||||
|
expect(result.id).toBe('share-123')
|
||||||
|
expect(result.hasPassword).toBe(true)
|
||||||
|
expect(result.isExpired).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('分享不存在应该抛出错误', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(shareApi.getShare('invalid-id')).rejects.toThrow('分享不存在或已过期')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('verifyShare', () => {
|
||||||
|
it('正确密码应该验证成功', async () => {
|
||||||
|
const mockResponse: ShareVerifyResponse = {
|
||||||
|
valid: true,
|
||||||
|
share: {
|
||||||
|
id: 'share-123',
|
||||||
|
conversationIds: ['conv-1'],
|
||||||
|
conversations: [],
|
||||||
|
createdAt: Date.now(),
|
||||||
|
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000,
|
||||||
|
viewCount: 0,
|
||||||
|
hasPassword: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockResponse),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await shareApi.verifyShare('share-123', {
|
||||||
|
passwordHash: 'correct-hash',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
expect(result.share).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('错误密码应该验证失败', async () => {
|
||||||
|
const mockResponse: ShareVerifyResponse = {
|
||||||
|
valid: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockResponse),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await shareApi.verifyShare('share-123', {
|
||||||
|
passwordHash: 'wrong-hash',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.share).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -42,14 +42,14 @@
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- 导出对话 -->
|
<!-- 导出对话 -->
|
||||||
<button
|
<!-- <button
|
||||||
class="header-btn"
|
class="header-btn"
|
||||||
title="导出对话"
|
title="导出对话"
|
||||||
:disabled="messageCount === 0"
|
:disabled="messageCount === 0"
|
||||||
@click="$emit('export')"
|
@click="$emit('export')"
|
||||||
>
|
>
|
||||||
<Download :size="18" />
|
<Download :size="18" />
|
||||||
</button>
|
</button> -->
|
||||||
|
|
||||||
<!-- 更多操作 -->
|
<!-- 更多操作 -->
|
||||||
<!-- <button
|
<!-- <button
|
||||||
|
|
@ -90,7 +90,6 @@ import { ref } from "vue";
|
||||||
import {
|
import {
|
||||||
Menu,
|
Menu,
|
||||||
Trash2,
|
Trash2,
|
||||||
Download,
|
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Pin,
|
Pin,
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,13 @@
|
||||||
|
|
||||||
<!-- 输入区域 -->
|
<!-- 输入区域 -->
|
||||||
<div class="input-wrapper">
|
<div class="input-wrapper">
|
||||||
|
<!-- 附件预览区 -->
|
||||||
|
<div v-if="hasAttachments" class="attachments-preview-container">
|
||||||
|
<AttachmentPreview
|
||||||
|
:attachments="currentAttachments"
|
||||||
|
@remove="handleRemoveAttachment"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div class="input-container" :class="{ wide: isWideMode }">
|
<div class="input-container" :class="{ wide: isWideMode }">
|
||||||
<ChatInput
|
<ChatInput
|
||||||
ref="chatInputRef"
|
ref="chatInputRef"
|
||||||
|
|
@ -52,9 +59,11 @@ import { ref, computed, watch, nextTick, onMounted } from "vue";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
import { useChatStore } from "@/stores/chat";
|
import { useChatStore } from "@/stores/chat";
|
||||||
import { useSettingsStore } from "@/stores/settings";
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import ChatHeader from "./ChatHeader.vue";
|
import ChatHeader from "./ChatHeader.vue";
|
||||||
import MessageList from "./MessageList.vue";
|
import MessageList from "./MessageList.vue";
|
||||||
import ChatInput from "@/components/input/ChatInput.vue";
|
import ChatInput from "@/components/input/ChatInput.vue";
|
||||||
|
import AttachmentPreview from "@/components/input/AttachmentPreview.vue";
|
||||||
import { MessageType, MessageRole } from "@/types/chat";
|
import { MessageType, MessageRole } from "@/types/chat";
|
||||||
import type { Attachment, Suggestion } from "@/types/chat";
|
import type { Attachment, Suggestion } from "@/types/chat";
|
||||||
import { chatApi, type ModelInfo } from "@/services/api";
|
import { chatApi, type ModelInfo } from "@/services/api";
|
||||||
|
|
@ -65,6 +74,7 @@ defineEmits<{
|
||||||
|
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
const { currentConversation, isStreaming } = storeToRefs(chatStore);
|
const { currentConversation, isStreaming } = storeToRefs(chatStore);
|
||||||
const { settings, sidebarCollapsed } = storeToRefs(settingsStore);
|
const { settings, sidebarCollapsed } = storeToRefs(settingsStore);
|
||||||
|
|
@ -117,6 +127,14 @@ const inputPlaceholder = computed(() => {
|
||||||
return "输入你的问题,按 Ctrl+Enter 发送";
|
return "输入你的问题,按 Ctrl+Enter 发送";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 附件相关
|
||||||
|
const currentAttachments = computed(() => chatInputRef.value?.attachments || []);
|
||||||
|
const hasAttachments = computed(() => currentAttachments.value.length > 0);
|
||||||
|
|
||||||
|
function handleRemoveAttachment(id: string) {
|
||||||
|
chatInputRef.value?.removeAttachment(id);
|
||||||
|
}
|
||||||
|
|
||||||
function toggleWideMode() {
|
function toggleWideMode() {
|
||||||
isWideMode.value = !isWideMode.value;
|
isWideMode.value = !isWideMode.value;
|
||||||
}
|
}
|
||||||
|
|
@ -162,8 +180,16 @@ async function handleSend(
|
||||||
webSearch?: boolean;
|
webSearch?: boolean;
|
||||||
deepThinking?: boolean;
|
deepThinking?: boolean;
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
|
skipUserMessage?: boolean;
|
||||||
|
conversationTitle?: string;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
// 检查认证状态
|
||||||
|
if (!authStore.isAuthenticated) {
|
||||||
|
window.$toast?.('请先登录', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.log("handleSend", text, attachments, options);
|
console.log("handleSend", text, attachments, options);
|
||||||
// 检查是否还有正在上传的附件
|
// 检查是否还有正在上传的附件
|
||||||
const uploadingAttachments = attachments.filter((a) => a.uploading);
|
const uploadingAttachments = attachments.filter((a) => a.uploading);
|
||||||
|
|
@ -196,31 +222,49 @@ async function handleSend(
|
||||||
|
|
||||||
// 如果没有当前对话,创建新对话
|
// 如果没有当前对话,创建新对话
|
||||||
if (!currentConversation.value) {
|
if (!currentConversation.value) {
|
||||||
chatStore.createConversation();
|
await chatStore.createConversation(options?.conversationTitle || text);
|
||||||
|
} else if (currentConversation.value.title === "新对话") {
|
||||||
|
// 如果当前对话是"新对话",用传入的标题或用户输入重命名
|
||||||
|
chatStore.renameConversation(
|
||||||
|
currentConversation.value.id,
|
||||||
|
options?.conversationTitle || text
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取系统提示词(优先使用传入的,否则使用会话设置)
|
||||||
|
const systemPrompt = options?.systemPrompt || currentConversation.value?.settings?.systemPrompt;
|
||||||
|
|
||||||
|
// 检查是否需要添加系统消息
|
||||||
|
const existingMessages = currentConversation.value?.messages || [];
|
||||||
|
const hasSystemMessage = existingMessages.some((m: any) => m.role === MessageRole.SYSTEM);
|
||||||
|
|
||||||
|
// 如果有系统提示词且对话中没有系统消息,添加系统消息
|
||||||
|
if (systemPrompt && !hasSystemMessage) {
|
||||||
|
await chatStore.addMessage(MessageRole.SYSTEM, {
|
||||||
|
type: MessageType.TEXT,
|
||||||
|
text: systemPrompt,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从当前会话中提取历史消息(用于上下文记忆),在添加新消息之前提取
|
// 从当前会话中提取历史消息(用于上下文记忆),在添加新消息之前提取
|
||||||
const existingMessages = currentConversation.value?.messages || [];
|
const updatedMessages = currentConversation.value?.messages || [];
|
||||||
const MAX_HISTORY_ROUNDS = 20; // 最多保留最近 20 轮(40 条消息)
|
const MAX_HISTORY_ROUNDS = 20; // 最多保留最近 20 轮(40 条消息)
|
||||||
const historyMessages = existingMessages
|
const historyMessages = updatedMessages.filter((m: any) => m.content?.text) // 过滤掉空消息
|
||||||
.filter(
|
|
||||||
(m: any) =>
|
|
||||||
m.role === MessageRole.USER || m.role === MessageRole.ASSISTANT,
|
|
||||||
)
|
|
||||||
.filter((m: any) => m.content?.text) // 过滤掉空消息
|
|
||||||
.slice(-(MAX_HISTORY_ROUNDS * 2))
|
.slice(-(MAX_HISTORY_ROUNDS * 2))
|
||||||
.map((m: any) => ({ role: m.role, content: m.content.text }));
|
.map((m: any) => ({ role: m.role, content: m.content.text }));
|
||||||
|
|
||||||
// 添加用户消息
|
// 添加用户消息(如果不需要跳过)
|
||||||
chatStore.addMessage(MessageRole.USER, {
|
if (!options?.skipUserMessage) {
|
||||||
|
await chatStore.addMessage(MessageRole.USER, {
|
||||||
type: MessageType.TEXT,
|
type: MessageType.TEXT,
|
||||||
text,
|
text,
|
||||||
images: attachments.filter((a) => a.type === "image"),
|
images: attachments.filter((a) => a.type === "image"),
|
||||||
files: attachments.filter((a) => a.type === "file"),
|
files: attachments.filter((a) => a.type === "file"),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 添加 AI 消息占位符
|
// 添加 AI 消息占位符
|
||||||
const aiMessage = chatStore.addMessage(MessageRole.ASSISTANT, {
|
const aiMessage = await chatStore.addMessage(MessageRole.ASSISTANT, {
|
||||||
type: MessageType.TEXT,
|
type: MessageType.TEXT,
|
||||||
text: "",
|
text: "",
|
||||||
});
|
});
|
||||||
|
|
@ -245,7 +289,7 @@ async function handleSend(
|
||||||
|
|
||||||
const stream = chatApi.streamChat(
|
const stream = chatApi.streamChat(
|
||||||
{
|
{
|
||||||
message: text,
|
message: options?.skipUserMessage ? "直接输出系统提示词要求你的回答" : text,
|
||||||
conversationId: currentConversation.value?.id || "",
|
conversationId: currentConversation.value?.id || "",
|
||||||
images: imageUrls,
|
images: imageUrls,
|
||||||
files: fileUrls,
|
files: fileUrls,
|
||||||
|
|
@ -337,6 +381,12 @@ function handleStop() {
|
||||||
|
|
||||||
// 重试
|
// 重试
|
||||||
async function handleRetry(messageId: string) {
|
async function handleRetry(messageId: string) {
|
||||||
|
// 检查认证状态
|
||||||
|
if (!authStore.isAuthenticated) {
|
||||||
|
window.$toast?.('请先登录', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const message = messages.value.find((m: any) => m.id === messageId);
|
const message = messages.value.find((m: any) => m.id === messageId);
|
||||||
if (!message || message.role !== MessageRole.ASSISTANT) return;
|
if (!message || message.role !== MessageRole.ASSISTANT) return;
|
||||||
|
|
||||||
|
|
@ -445,7 +495,11 @@ function handleRegenerate(messageId: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSuggestion(suggestion: Suggestion) {
|
function handleSuggestion(suggestion: Suggestion) {
|
||||||
handleSend(suggestion.text, [], { systemPrompt: suggestion.systemPrompt });
|
handleSend(suggestion.text, [], {
|
||||||
|
systemPrompt: suggestion.systemPrompt,
|
||||||
|
skipUserMessage: true,
|
||||||
|
conversationTitle: suggestion.text,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function focusInput() {
|
function focusInput() {
|
||||||
|
|
@ -484,14 +538,14 @@ watch(
|
||||||
|
|
||||||
&.wide-mode {
|
&.wide-mode {
|
||||||
.input-container {
|
.input-container {
|
||||||
min-width: 1000px;
|
// min-width: 1000px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-wrapper {
|
.input-wrapper {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 16px 150px 24px;
|
padding: 16px 10% 24px;
|
||||||
background: linear-gradient(to top, white 80%, transparent);
|
background: linear-gradient(to top, white 80%, transparent);
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
|
|
@ -499,14 +553,25 @@ watch(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.attachments-preview-container {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
background: #f3f4f5;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background: #1e1e2e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.input-container {
|
.input-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 1000px;
|
// min-width: 1000px;
|
||||||
// margin: 0 auto;
|
// margin: 0 auto;
|
||||||
transition: max-width 0.3s ease;
|
transition: max-width 0.3s ease;
|
||||||
|
|
||||||
&.wide {
|
&.wide {
|
||||||
min-width: 1000px;
|
// min-width: 1000px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,33 @@
|
||||||
<template>
|
<template>
|
||||||
<div ref="boxRef" class="message-list-container">
|
<div class="message-list-container">
|
||||||
|
<!-- 消息选择操作栏 -->
|
||||||
|
<Transition name="slide-down">
|
||||||
|
<div v-if="isMessageSelectMode" class="message-select-bar">
|
||||||
|
<div class="select-info">
|
||||||
|
<span class="select-count">已选择 {{ selectedMessageCount }} 条消息</span>
|
||||||
|
</div>
|
||||||
|
<div class="select-actions">
|
||||||
|
<button class="action-btn select-all" @click="handleSelectAll">
|
||||||
|
全选
|
||||||
|
</button>
|
||||||
|
<button class="action-btn cancel" @click="handleCancelSelect">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="action-btn confirm"
|
||||||
|
:disabled="selectedMessageCount === 0"
|
||||||
|
@click="handleConfirmShare"
|
||||||
|
>
|
||||||
|
确认分享
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
<div ref="containerRef" class="message-list" @scroll="handleScroll">
|
<div ref="containerRef" class="message-list" @scroll="handleScroll">
|
||||||
<!-- 欢迎界面 -->
|
<!-- 欢迎界面 -->
|
||||||
<WelcomeScreen
|
<WelcomeScreen
|
||||||
v-if="messages.length === 0"
|
v-if="visibleMessages.length === 0"
|
||||||
@select="$emit('select-suggestion', $event)"
|
@select="$emit('select-suggestion', $event)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -12,12 +36,14 @@
|
||||||
<div class="messages-wrapper">
|
<div class="messages-wrapper">
|
||||||
<TransitionGroup name="message">
|
<TransitionGroup name="message">
|
||||||
<MessageBubble
|
<MessageBubble
|
||||||
v-for="(message, index) in messages"
|
v-for="(message, index) in visibleMessages"
|
||||||
:key="message.id"
|
:key="message.id"
|
||||||
:message="message"
|
:message="message"
|
||||||
:show-timestamp="showTimestamp"
|
:show-timestamp="showTimestamp"
|
||||||
:compact="compact"
|
:compact="compact"
|
||||||
:is-New="index === messages.length - 1"
|
:is-New="index === visibleMessages.length - 1"
|
||||||
|
:is-message-select-mode="isMessageSelectMode"
|
||||||
|
:is-selected="isMessageSelected(message.id)"
|
||||||
@retry="$emit('retry', message.id)"
|
@retry="$emit('retry', message.id)"
|
||||||
@regenerate="$emit('regenerate', message.id)"
|
@regenerate="$emit('regenerate', message.id)"
|
||||||
@copy="handleCopy(message)"
|
@copy="handleCopy(message)"
|
||||||
|
|
@ -27,6 +53,8 @@
|
||||||
@preview-image="handlePreviewImage"
|
@preview-image="handlePreviewImage"
|
||||||
@play-video="handlePlayVideo"
|
@play-video="handlePlayVideo"
|
||||||
@download-file="handleDownloadFile"
|
@download-file="handleDownloadFile"
|
||||||
|
@toggle-select="handleToggleMessageSelect(message.id)"
|
||||||
|
@enter-select-mode="handleEnterSelectMode(message.id)"
|
||||||
/>
|
/>
|
||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
|
|
||||||
|
|
@ -62,12 +90,14 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, nextTick, onMounted } from "vue";
|
import { ref, watch, nextTick, onMounted, computed } from "vue";
|
||||||
import { useChatStore } from "@/stores/chat";
|
import { useChatStore } from "@/stores/chat";
|
||||||
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
import MessageBubble from "@/components/message/MessageBubble.vue";
|
import MessageBubble from "@/components/message/MessageBubble.vue";
|
||||||
import WelcomeScreen from "./WelcomeScreen.vue";
|
import WelcomeScreen from "./WelcomeScreen.vue";
|
||||||
import { Bot, ChevronDown } from "@/components/icons";
|
import { Bot, ChevronDown } from "@/components/icons";
|
||||||
import type { Message, Attachment, VideoInfo, Suggestion } from "@/types/chat";
|
import type { Message, Attachment, VideoInfo, Suggestion } from "@/types/chat";
|
||||||
|
import { MessageRole } from "@/types/chat";
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
|
@ -83,6 +113,13 @@ const props = withDefaults(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 过滤掉系统消息,不显示在前端
|
||||||
|
const visibleMessages = computed(() => {
|
||||||
|
return props.messages.filter(
|
||||||
|
(message) => message.role !== MessageRole.SYSTEM
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
retry: [messageId: string];
|
retry: [messageId: string];
|
||||||
regenerate: [messageId: string];
|
regenerate: [messageId: string];
|
||||||
|
|
@ -93,10 +130,14 @@ const emit = defineEmits<{
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
|
||||||
|
// 消息选择模式状态
|
||||||
|
const isMessageSelectMode = computed(() => chatStore.isMessageSelectMode);
|
||||||
|
const selectedMessageCount = computed(() => chatStore.selectedMessageCount);
|
||||||
|
|
||||||
// 响应式状态
|
// 响应式状态
|
||||||
const boxRef: any = ref<HTMLElement | null>(null);
|
const containerRef = ref<HTMLElement | null>(null);
|
||||||
const containerRef: any = ref<HTMLElement | null>(null);
|
|
||||||
const showScrollButton = ref(false);
|
const showScrollButton = ref(false);
|
||||||
const newMessageCount = ref(0);
|
const newMessageCount = ref(0);
|
||||||
const isAutoScrolling = ref(true);
|
const isAutoScrolling = ref(true);
|
||||||
|
|
@ -173,9 +214,36 @@ function handleDownloadFile(file: Attachment) {
|
||||||
emit("download-file", file);
|
emit("download-file", file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 消息选择模式方法
|
||||||
|
function handleEnterSelectMode(messageId: string) {
|
||||||
|
chatStore.enterMessageSelectMode(chatStore.currentConversationId || '', messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleMessageSelect(messageId: string) {
|
||||||
|
chatStore.toggleMessageSelection(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectAll() {
|
||||||
|
chatStore.selectAllMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancelSelect() {
|
||||||
|
chatStore.exitMessageSelectMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirmShare() {
|
||||||
|
if (chatStore.selectedMessageCount > 0) {
|
||||||
|
settingsStore.openShareModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMessageSelected(messageId: string): boolean {
|
||||||
|
return chatStore.isMessageSelected(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
// 监听消息变化
|
// 监听消息变化
|
||||||
watch(
|
watch(
|
||||||
() => props.messages.length,
|
() => visibleMessages.value.length,
|
||||||
(newLen, oldLen) => {
|
(newLen, oldLen) => {
|
||||||
if (newLen > oldLen) {
|
if (newLen > oldLen) {
|
||||||
if (isAutoScrolling.value) {
|
if (isAutoScrolling.value) {
|
||||||
|
|
@ -191,7 +259,7 @@ watch(
|
||||||
|
|
||||||
// 监听最后一条消息的内容变化
|
// 监听最后一条消息的内容变化
|
||||||
watch(
|
watch(
|
||||||
() => props.messages[props.messages.length - 1]?.content.text,
|
() => visibleMessages.value[visibleMessages.value.length - 1]?.content.text,
|
||||||
() => {
|
() => {
|
||||||
if (isAutoScrolling.value) {
|
if (isAutoScrolling.value) {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
|
|
@ -222,7 +290,10 @@ defineExpose({
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
// 只有当有消息时才滚动到底部,否则保持在顶部显示欢迎界面
|
||||||
|
if (visibleMessages.value.length > 0) {
|
||||||
scrollToBottom(false);
|
scrollToBottom(false);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -398,4 +469,97 @@ onMounted(() => {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 消息选择操作栏
|
||||||
|
.message-select-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: white;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background: #1e1e2e;
|
||||||
|
border-bottom-color: #2d2d3d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-count {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&.select-all,
|
||||||
|
&.cancel {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #6b7280;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background: #2d2d3d;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background: #374151;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.confirm {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滑入动画
|
||||||
|
.slide-down-enter-active,
|
||||||
|
.slide-down-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-down-enter-from,
|
||||||
|
.slide-down-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-100%);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,7 @@
|
||||||
<div class="welcome-screen">
|
<div class="welcome-screen">
|
||||||
<!-- Logo 和标题 -->
|
<!-- Logo 和标题 -->
|
||||||
<div class="welcome-header">
|
<div class="welcome-header">
|
||||||
<div class="logo-wrapper">
|
<h1 class="title">学习、教学、科研聊天助手</h1>
|
||||||
<div class="logo-icon">
|
|
||||||
<Bot :size="40" />
|
|
||||||
</div>
|
|
||||||
<div class="logo-glow"></div>
|
|
||||||
</div>
|
|
||||||
<h1 class="title">Kexue AI 智能助手</h1>
|
|
||||||
<p class="subtitle">
|
|
||||||
大学生用GPT,把自己学废了? Study模式拒绝直接给答案,引导学生思考。
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 功能卡片 -->
|
<!-- 功能卡片 -->
|
||||||
|
|
@ -63,7 +54,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import {
|
import {
|
||||||
Bot,
|
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Code,
|
Code,
|
||||||
Image,
|
Image,
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,7 @@ export {
|
||||||
|
|
||||||
// 其他
|
// 其他
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
|
CheckCircle,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Lock,
|
Lock,
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,6 @@
|
||||||
class="chat-input-container"
|
class="chat-input-container"
|
||||||
:class="{ 'is-focused': isFocused, 'is-expanded': isExpanded }"
|
:class="{ 'is-focused': isFocused, 'is-expanded': isExpanded }"
|
||||||
>
|
>
|
||||||
<!-- 附件预览区 -->
|
|
||||||
<AttachmentPreview
|
|
||||||
v-if="attachments.length > 0"
|
|
||||||
:attachments="attachments"
|
|
||||||
@remove="removeAttachment"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 输入区域 -->
|
<!-- 输入区域 -->
|
||||||
<div class="input-area">
|
<div class="input-area">
|
||||||
<!-- 左侧功能按钮 -->
|
<!-- 左侧功能按钮 -->
|
||||||
|
|
@ -61,6 +54,7 @@
|
||||||
v-model="inputText"
|
v-model="inputText"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:rows="1"
|
:rows="1"
|
||||||
|
@beforeinput="handleBeforeInput"
|
||||||
@input="autoResize"
|
@input="autoResize"
|
||||||
@focus="isFocused = true"
|
@focus="isFocused = true"
|
||||||
@blur="isFocused = false"
|
@blur="isFocused = false"
|
||||||
|
|
@ -97,13 +91,18 @@
|
||||||
<!-- 底部工具栏 -->
|
<!-- 底部工具栏 -->
|
||||||
<div class="input-toolbar">
|
<div class="input-toolbar">
|
||||||
<div class="toolbar-left">
|
<div class="toolbar-left">
|
||||||
|
<!-- 展开/收起 -->
|
||||||
|
<button class="toolbar-btn" title="展开输入框" @click="toggleExpand">
|
||||||
|
<Maximize2 v-if="!isExpanded" :size="16" />
|
||||||
|
<Minimize2 v-else :size="16" />
|
||||||
|
</button>
|
||||||
<!-- 深度思考开关 -->
|
<!-- 深度思考开关 -->
|
||||||
<button
|
<button
|
||||||
class="toolbar-btn"
|
class="toolbar-btn"
|
||||||
:class="{ active: isDeepThinking, disabled: !supports_thinking }"
|
:class="{ active: isDeepThinking, disabled: isForceDeepThinkingModel || !supports_thinking }"
|
||||||
:disabled="!supports_thinking"
|
:disabled="isForceDeepThinkingModel || !supports_thinking"
|
||||||
:title="supports_thinking ? '深度思考' : '当前模型不支持深度思考'"
|
:title="isForceDeepThinkingModel ? '当前模型强制开启深度思考' : (supports_thinking ? '深度思考' : '当前模型不支持深度思考')"
|
||||||
@click="supports_thinking && toggleDeepThink()"
|
@click="!isForceDeepThinkingModel && supports_thinking && toggleDeepThink()"
|
||||||
>
|
>
|
||||||
<Brain :size="16" />
|
<Brain :size="16" />
|
||||||
<span>深度思考</span>
|
<span>深度思考</span>
|
||||||
|
|
@ -132,24 +131,6 @@
|
||||||
<Globe :size="16" />
|
<Globe :size="16" />
|
||||||
<span>联网搜索</span>
|
<span>联网搜索</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- 展开/收起 -->
|
|
||||||
<button class="toolbar-btn" title="展开输入框" @click="toggleExpand">
|
|
||||||
<Maximize2 v-if="!isExpanded" :size="16" />
|
|
||||||
<Minimize2 v-else :size="16" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="toolbar-right">
|
|
||||||
<span
|
|
||||||
class="char-count"
|
|
||||||
:class="{ warning: charCount > maxChars * 0.9 }"
|
|
||||||
>
|
|
||||||
{{ charCount }} / {{ maxChars }}
|
|
||||||
</span>
|
|
||||||
<span class="send-hint">
|
|
||||||
{{ sendOnEnter ? "Enter 发送, Shift+Enter 换行" : "Ctrl+Enter 发送" }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -169,10 +150,11 @@ import {
|
||||||
Brain,
|
Brain,
|
||||||
Loader2,
|
Loader2,
|
||||||
} from "@/components/icons";
|
} from "@/components/icons";
|
||||||
import AttachmentPreview from "./AttachmentPreview.vue";
|
|
||||||
import { generateId } from "@/utils/helpers";
|
import { generateId } from "@/utils/helpers";
|
||||||
import type { Attachment } from "@/types/chat";
|
import type { Attachment } from "@/types/chat";
|
||||||
import { chatApi } from "@/services/api";
|
import { chatApi } from "@/services/api";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
|
|
||||||
interface AttachmentWithProgress extends Attachment {
|
interface AttachmentWithProgress extends Attachment {
|
||||||
uploading?: boolean;
|
uploading?: boolean;
|
||||||
|
|
@ -196,7 +178,7 @@ const props = withDefaults(
|
||||||
placeholder: "输入你的问题...",
|
placeholder: "输入你的问题...",
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
sendOnEnter: false,
|
sendOnEnter: false,
|
||||||
maxChars: 4000,
|
maxChars: 10000,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
// 默认全部支持
|
// 默认全部支持
|
||||||
supports_thinking: true,
|
supports_thinking: true,
|
||||||
|
|
@ -206,6 +188,14 @@ const props = withDefaults(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 强制深度思考的模型列表(这些模型默认开启深度思考且不可关闭)
|
||||||
|
const FORCE_DEEP_THINKING_MODELS = ["deepseek-reasoner", "glm-z1-flash"];
|
||||||
|
|
||||||
|
// 判断当前模型是否强制开启深度思考
|
||||||
|
const isForceDeepThinkingModel = computed(() => {
|
||||||
|
return FORCE_DEEP_THINKING_MODELS.includes(modelName.value.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
send: [
|
send: [
|
||||||
text: string,
|
text: string,
|
||||||
|
|
@ -216,6 +206,10 @@ const emit = defineEmits<{
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// 响应式状态
|
// 响应式状态
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
const modelName = computed(() => settingsStore.settings.defaultModel);
|
||||||
|
|
||||||
const inputText = ref("");
|
const inputText = ref("");
|
||||||
const attachments = ref<AttachmentWithProgress[]>([]);
|
const attachments = ref<AttachmentWithProgress[]>([]);
|
||||||
const isFocused = ref(false);
|
const isFocused = ref(false);
|
||||||
|
|
@ -234,6 +228,18 @@ const textareaRef = ref<HTMLTextAreaElement | null>(null);
|
||||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||||
const imageInputRef = ref<HTMLInputElement | null>(null);
|
const imageInputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
// toast 节流
|
||||||
|
let lastToastTime = 0;
|
||||||
|
const toastThrottleMs = 2000;
|
||||||
|
|
||||||
|
function showThrottledToast(message: string, type: "error" = "error") {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastToastTime >= toastThrottleMs) {
|
||||||
|
lastToastTime = now;
|
||||||
|
window.$toast?.(message, type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
const charCount = computed(() => inputText.value.length);
|
const charCount = computed(() => inputText.value.length);
|
||||||
const isUploading = computed(() => attachments.value.some((a) => a.uploading));
|
const isUploading = computed(() => attachments.value.some((a) => a.uploading));
|
||||||
|
|
@ -253,7 +259,30 @@ function autoResize() {
|
||||||
|
|
||||||
textarea.style.height = "auto";
|
textarea.style.height = "auto";
|
||||||
const maxHeight = isExpanded.value ? 400 : 160;
|
const maxHeight = isExpanded.value ? 400 : 160;
|
||||||
textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`;
|
// 增加1px是为了避免滚动条出现
|
||||||
|
textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight) + 1}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理输入前事件,限制字数
|
||||||
|
function handleBeforeInput(event: InputEvent) {
|
||||||
|
// 如果不是插入文本的操作(如删除、退格等),允许
|
||||||
|
if (!event.data) return;
|
||||||
|
|
||||||
|
// 检查输入后是否会超过限制
|
||||||
|
const currentLength = inputText.value.length;
|
||||||
|
const insertLength = event.data?.length || 0;
|
||||||
|
const selectionStart =
|
||||||
|
(event.target as HTMLTextAreaElement).selectionStart || 0;
|
||||||
|
const selectionEnd = (event.target as HTMLTextAreaElement).selectionEnd || 0;
|
||||||
|
const selectedLength = selectionEnd - selectionStart;
|
||||||
|
|
||||||
|
// 计算输入后的长度
|
||||||
|
const newLength = currentLength - selectedLength + insertLength;
|
||||||
|
|
||||||
|
if (newLength > props.maxChars) {
|
||||||
|
event.preventDefault();
|
||||||
|
showThrottledToast(`已超${props.maxChars}字上限,请删除部分内容`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理键盘事件
|
// 处理键盘事件
|
||||||
|
|
@ -300,6 +329,22 @@ async function handlePaste(event: ClipboardEvent) {
|
||||||
const items = event.clipboardData?.items;
|
const items = event.clipboardData?.items;
|
||||||
if (!items) return;
|
if (!items) return;
|
||||||
|
|
||||||
|
// 检查粘贴文本是否会超过字数限制
|
||||||
|
const text = event.clipboardData?.getData("text");
|
||||||
|
if (text) {
|
||||||
|
const textarea = event.target as HTMLTextAreaElement;
|
||||||
|
const selectionStart = textarea.selectionStart || 0;
|
||||||
|
const selectionEnd = textarea.selectionEnd || 0;
|
||||||
|
const selectedLength = selectionEnd - selectionStart;
|
||||||
|
const newLength = inputText.value.length - selectedLength + text.length;
|
||||||
|
|
||||||
|
if (newLength > props.maxChars) {
|
||||||
|
event.preventDefault();
|
||||||
|
showThrottledToast(`已超${props.maxChars}字上限,请删除部分内容`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.type.startsWith("image/")) {
|
if (item.type.startsWith("image/")) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
@ -350,6 +395,12 @@ async function addFileAsAttachment(
|
||||||
file: File,
|
file: File,
|
||||||
type: "image" | "file" | "video",
|
type: "image" | "file" | "video",
|
||||||
) {
|
) {
|
||||||
|
// 检查认证状态
|
||||||
|
if (!authStore.isAuthenticated) {
|
||||||
|
window.$toast?.("请先登录", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const id = generateId();
|
const id = generateId();
|
||||||
|
|
||||||
// 创建本地预览URL
|
// 创建本地预览URL
|
||||||
|
|
@ -403,13 +454,28 @@ async function uploadFileToServer(id: string, file: File) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移除附件
|
// 移除附件
|
||||||
function removeAttachment(id: string) {
|
async function removeAttachment(id: string) {
|
||||||
const index = attachments.value.findIndex((a) => a.id === id);
|
const index = attachments.value.findIndex((a) => a.id === id);
|
||||||
if (index !== -1) {
|
if (index === -1) return;
|
||||||
// 释放 blob URL
|
|
||||||
URL.revokeObjectURL(attachments.value[index].url);
|
const attachment = attachments.value[index];
|
||||||
attachments.value.splice(index, 1);
|
|
||||||
|
// 如果已上传到 OSS(不是本地 blob URL),则从 OSS 删除
|
||||||
|
if (attachment.url && !attachment.url.startsWith("blob:")) {
|
||||||
|
try {
|
||||||
|
await chatApi.deleteAttachment(attachment.url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("删除 OSS 文件失败:", error);
|
||||||
|
// 即使删除失败也继续移除本地引用
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 释放 blob URL(如果是本地的)
|
||||||
|
if (attachment.url.startsWith("blob:")) {
|
||||||
|
URL.revokeObjectURL(attachment.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
attachments.value.splice(index, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 切换功能(深度搜索与联网搜索互斥)
|
// 切换功能(深度搜索与联网搜索互斥)
|
||||||
|
|
@ -459,6 +525,8 @@ function clear() {
|
||||||
defineExpose({
|
defineExpose({
|
||||||
focus,
|
focus,
|
||||||
clear,
|
clear,
|
||||||
|
attachments,
|
||||||
|
removeAttachment,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听文本变化,自动调整高度
|
// 监听文本变化,自动调整高度
|
||||||
|
|
@ -467,6 +535,25 @@ watch(inputText, () => {
|
||||||
autoResize();
|
autoResize();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
// 监听模型变化,重置选项功能
|
||||||
|
watch(
|
||||||
|
() => settingsStore.settings.defaultModel,
|
||||||
|
(newModel) => {
|
||||||
|
// 如果是强制深度思考的模型,自动开启深度思考
|
||||||
|
if (FORCE_DEEP_THINKING_MODELS.includes(newModel.toLowerCase())) {
|
||||||
|
isDeepThinking.value = true;
|
||||||
|
localStorage.setItem("isDeepThinking", "true");
|
||||||
|
} else {
|
||||||
|
isDeepThinking.value = false;
|
||||||
|
localStorage.setItem("isDeepThinking", "false");
|
||||||
|
}
|
||||||
|
// 重置搜索相关选项
|
||||||
|
isDeepSearch.value = false;
|
||||||
|
isWebSearch.value = false;
|
||||||
|
localStorage.setItem("isDeepSearch", "false");
|
||||||
|
localStorage.setItem("isWebSearch", "false");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
autoResize();
|
autoResize();
|
||||||
|
|
@ -601,7 +688,7 @@ onMounted(() => {
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 24px;
|
min-height: 25px;
|
||||||
max-height: 160px;
|
max-height: 160px;
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
border: none;
|
border: none;
|
||||||
|
|
@ -688,26 +775,6 @@ onMounted(() => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.char-count {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #9ca3af;
|
|
||||||
|
|
||||||
&.warning {
|
|
||||||
color: #f59e0b;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.send-hint {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,21 @@
|
||||||
'is-end': !message.isEnd && message.role !== 'user',
|
'is-end': !message.isEnd && message.role !== 'user',
|
||||||
'is-error': message.isError,
|
'is-error': message.isError,
|
||||||
compact: compact,
|
compact: compact,
|
||||||
|
'message-select-mode': isMessageSelectMode,
|
||||||
|
'message-selected': isSelected,
|
||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
|
@click="handleBubbleClick"
|
||||||
@mouseenter="isHovered = true"
|
@mouseenter="isHovered = true"
|
||||||
@mouseleave="isHovered = false"
|
@mouseleave="isHovered = false"
|
||||||
>
|
>
|
||||||
|
<!-- 消息选择模式复选框 -->
|
||||||
|
<div v-if="isMessageSelectMode" class="message-checkbox" @click.stop="handleToggleSelect">
|
||||||
|
<div class="checkbox" :class="{ checked: isSelected }">
|
||||||
|
<Check v-if="isSelected" :size="14" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 头像 -->
|
<!-- 头像 -->
|
||||||
<div class="avatar">
|
<div class="avatar">
|
||||||
<div class="avatar-inner" :class="message.role">
|
<div class="avatar-inner" :class="message.role">
|
||||||
|
|
@ -24,14 +34,14 @@
|
||||||
<!-- 消息内容区域 -->
|
<!-- 消息内容区域 -->
|
||||||
<div class="message-content-wrapper">
|
<div class="message-content-wrapper">
|
||||||
<!-- 角色名称 -->
|
<!-- 角色名称 -->
|
||||||
<div class="message-header">
|
<!-- <div class="message-header">
|
||||||
<span class="role-name">
|
<span class="role-name">
|
||||||
{{ message.role === "assistant" ? "AI 助手" : "你" }}
|
{{ message.role === "assistant" ? "AI 助手" : "你" }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="showTimestamp" class="timestamp">
|
<span v-if="showTimestamp" class="timestamp">
|
||||||
{{ formattedTime }}
|
{{ formattedTime }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div> -->
|
||||||
|
|
||||||
<!-- 消息主体 -->
|
<!-- 消息主体 -->
|
||||||
<div class="message-body">
|
<div class="message-body">
|
||||||
|
|
@ -164,7 +174,9 @@
|
||||||
v-if="
|
v-if="
|
||||||
message.role === 'assistant' &&
|
message.role === 'assistant' &&
|
||||||
!message.isStreaming &&
|
!message.isStreaming &&
|
||||||
!message.isError
|
!message.isError &&
|
||||||
|
!readonly &&
|
||||||
|
!isMessageSelectMode
|
||||||
"
|
"
|
||||||
:content="message.content.text || ''"
|
:content="message.content.text || ''"
|
||||||
:feedback="message.feedback"
|
:feedback="message.feedback"
|
||||||
|
|
@ -176,6 +188,7 @@
|
||||||
@like="handleLike"
|
@like="handleLike"
|
||||||
@dislike="handleDislike"
|
@dislike="handleDislike"
|
||||||
@regenerate="$emit('regenerate')"
|
@regenerate="$emit('regenerate')"
|
||||||
|
@share="handleShareClick"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -183,7 +196,7 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useClipboard } from "@vueuse/core";
|
import { useClipboard } from "@vueuse/core";
|
||||||
import { ref, computed } from "vue";
|
import { ref } from "vue";
|
||||||
// 正确导入 markstream-vue
|
// 正确导入 markstream-vue
|
||||||
import MarkdownRender from "markstream-vue";
|
import MarkdownRender from "markstream-vue";
|
||||||
import { setCustomComponents } from "markstream-vue";
|
import { setCustomComponents } from "markstream-vue";
|
||||||
|
|
@ -195,10 +208,10 @@ import {
|
||||||
Zap,
|
Zap,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Play,
|
Play,
|
||||||
Download,
|
Check,
|
||||||
} from "@/components/icons";
|
} from "@/components/icons";
|
||||||
import MessageActions from "./MessageActions.vue";
|
import MessageActions from "./MessageActions.vue";
|
||||||
import { formatTimestamp, formatFileSize, getFileIcon } from "@/utils/helpers";
|
import { formatFileSize, getFileIcon } from "@/utils/helpers";
|
||||||
import type { Message, Suggestion, Attachment, VideoInfo } from "@/types/chat";
|
import type { Message, Suggestion, Attachment, VideoInfo } from "@/types/chat";
|
||||||
import ThinkingNode from "./components/ThinkingNode.vue";
|
import ThinkingNode from "./components/ThinkingNode.vue";
|
||||||
import EChartsContainerNode from "./components/EChartsContainerNode.vue";
|
import EChartsContainerNode from "./components/EChartsContainerNode.vue";
|
||||||
|
|
@ -209,10 +222,16 @@ const props = withDefaults(
|
||||||
showTimestamp?: boolean;
|
showTimestamp?: boolean;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
isNew?: boolean;
|
isNew?: boolean;
|
||||||
|
isMessageSelectMode?: boolean;
|
||||||
|
isSelected?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
showTimestamp: true,
|
showTimestamp: true,
|
||||||
compact: false,
|
compact: false,
|
||||||
|
isMessageSelectMode: false,
|
||||||
|
isSelected: false,
|
||||||
|
readonly: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const { copy } = useClipboard({ legacy: true });
|
const { copy } = useClipboard({ legacy: true });
|
||||||
|
|
@ -226,13 +245,28 @@ const emit = defineEmits<{
|
||||||
"preview-image": [image: Attachment, index: number];
|
"preview-image": [image: Attachment, index: number];
|
||||||
"play-video": [video: VideoInfo];
|
"play-video": [video: VideoInfo];
|
||||||
"download-file": [file: Attachment];
|
"download-file": [file: Attachment];
|
||||||
|
"toggle-select": [];
|
||||||
|
"enter-select-mode": [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const isHovered = ref(false);
|
const isHovered = ref(false);
|
||||||
|
|
||||||
const formattedTime = computed(() => {
|
// 处理消息气泡点击
|
||||||
return formatTimestamp(props.message.timestamp);
|
function handleBubbleClick() {
|
||||||
});
|
if (props.isMessageSelectMode) {
|
||||||
|
emit("toggle-select");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理复选框点击
|
||||||
|
function handleToggleSelect() {
|
||||||
|
emit("toggle-select");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理分享按钮点击(进入选择模式)
|
||||||
|
function handleShareClick() {
|
||||||
|
emit("enter-select-mode");
|
||||||
|
}
|
||||||
|
|
||||||
function getFileEmoji(mimeType?: string) {
|
function getFileEmoji(mimeType?: string) {
|
||||||
return getFileIcon(mimeType || "");
|
return getFileIcon(mimeType || "");
|
||||||
|
|
@ -276,9 +310,31 @@ setCustomComponents("playground-demo", {
|
||||||
.message-bubble {
|
.message-bubble {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 20px 150px;
|
padding: 20px 10%;
|
||||||
animation: fadeIn 0.3s ease;
|
animation: fadeIn 0.3s ease;
|
||||||
|
|
||||||
|
// 消息选择模式
|
||||||
|
&.message-select-mode {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.05);
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.message-selected {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.role-user {
|
&.role-user {
|
||||||
flex-direction: row-reverse;
|
flex-direction: row-reverse;
|
||||||
|
|
||||||
|
|
@ -611,15 +667,16 @@ setCustomComponents("playground-demo", {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.images-grid {
|
.images-flex {
|
||||||
display: grid;
|
display: inline-flex;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 7px;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-item {
|
.image-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
width: 130px;
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
@ -835,4 +892,41 @@ setCustomComponents("playground-demo", {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 消息选择复选框
|
||||||
|
.message-checkbox {
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: 2px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
border-color: #4b5563;
|
||||||
|
background: #2d2d3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.checked {
|
||||||
|
background: #3b82f6;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -163,7 +163,7 @@ async function textCopy(data: any) {
|
||||||
/* 可折叠内容 */
|
/* 可折叠内容 */
|
||||||
.thinking-content {
|
.thinking-content {
|
||||||
max-height: 2000px;
|
max-height: 2000px;
|
||||||
overflow: hidden;
|
overflow: auto;
|
||||||
transition:
|
transition:
|
||||||
max-height 0.35s ease,
|
max-height 0.35s ease,
|
||||||
opacity 0.25s ease;
|
opacity 0.25s ease;
|
||||||
|
|
|
||||||
|
|
@ -200,8 +200,8 @@ import FormSwitch from "@/components/ui/FormSwitch.vue";
|
||||||
import FormSlider from "@/components/ui/FormSlider.vue";
|
import FormSlider from "@/components/ui/FormSlider.vue";
|
||||||
import FormSelect from "@/components/ui/FormSelect.vue";
|
import FormSelect from "@/components/ui/FormSelect.vue";
|
||||||
import { MessageSquare, X, Check, Trash2 } from "@/components/icons";
|
import { MessageSquare, X, Check, Trash2 } from "@/components/icons";
|
||||||
import { chatApi } from "@/services/api.ts";
|
|
||||||
import type { ConversationSettings } from "@/types/chat";
|
import type { ConversationSettings } from "@/types/chat";
|
||||||
|
import { MessageRole, MessageType } from "@/types/chat";
|
||||||
|
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
|
|
@ -215,20 +215,20 @@ const modelSelect = ref(localStorage.getItem("modelSelect") || "");
|
||||||
const currentModelId = ref(settingsStore.getSelectedModelId());
|
const currentModelId = ref(settingsStore.getSelectedModelId());
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
chatApi.getModels().then((res: any) => {
|
// chatApi.getModels().then((res: any) => {
|
||||||
availableModels.value = res;
|
// availableModels.value = res;
|
||||||
// 初始化模型显示名称
|
// // 初始化模型显示名称
|
||||||
const model = availableModels.value?.find(
|
// const model = availableModels.value?.find(
|
||||||
(m: any) => m.id === currentModelId.value,
|
// (m: any) => m.id === currentModelId.value,
|
||||||
);
|
// );
|
||||||
if (model) {
|
// if (model) {
|
||||||
modelSelect.value = model.name;
|
// modelSelect.value = model.name;
|
||||||
} else if (availableModels.value.length > 0) {
|
// } else if (availableModels.value.length > 0) {
|
||||||
modelSelect.value = availableModels.value[0].name;
|
// modelSelect.value = availableModels.value[0].name;
|
||||||
currentModelId.value = availableModels.value[0].id;
|
// currentModelId.value = availableModels.value[0].id;
|
||||||
}
|
// }
|
||||||
localStorage.setItem("modelSelect", modelSelect.value);
|
// localStorage.setItem("modelSelect", modelSelect.value);
|
||||||
});
|
// });
|
||||||
});
|
});
|
||||||
|
|
||||||
// 本地设置副本
|
// 本地设置副本
|
||||||
|
|
@ -359,6 +359,25 @@ function handleSave() {
|
||||||
// 调用更新对话设置的方法
|
// 调用更新对话设置的方法
|
||||||
chatStore.updateConversationSettings(conversation.value.id, convSettings);
|
chatStore.updateConversationSettings(conversation.value.id, convSettings);
|
||||||
|
|
||||||
|
// 如果系统提示词有变化,更新消息列表中的系统消息
|
||||||
|
if (localSettings.value.systemPrompt) {
|
||||||
|
const messages = conversation.value.messages || [];
|
||||||
|
const systemMsgIndex = messages.findIndex((m: any) => m.role === MessageRole.SYSTEM);
|
||||||
|
|
||||||
|
if (systemMsgIndex >= 0) {
|
||||||
|
// 更新已有的系统消息
|
||||||
|
chatStore.updateMessage(messages[systemMsgIndex].id, {
|
||||||
|
content: {
|
||||||
|
type: MessageType.TEXT,
|
||||||
|
text: localSettings.value.systemPrompt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 添加新的系统消息(放在消息列表开头)
|
||||||
|
chatStore.addSystemMessage(conversation.value.id, localSettings.value.systemPrompt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
close();
|
close();
|
||||||
|
|
||||||
// 显示成功提示
|
// 显示成功提示
|
||||||
|
|
|
||||||
|
|
@ -400,7 +400,6 @@ import {
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
} from "@/components/icons";
|
} from "@/components/icons";
|
||||||
import type { AppSettings } from "@/types/chat";
|
import type { AppSettings } from "@/types/chat";
|
||||||
import { chatApi } from "@/services/api.ts";
|
|
||||||
|
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
|
|
||||||
|
|
@ -409,10 +408,10 @@ const availableModels: any = ref([]);
|
||||||
const defaultModel: any = ref(localStorage.getItem("defaultModel"));
|
const defaultModel: any = ref(localStorage.getItem("defaultModel"));
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
chatApi.getModels().then((res: any) => {
|
// chatApi.getModels().then((res: any) => {
|
||||||
availableModels.value = res;
|
// availableModels.value = res;
|
||||||
if (!defaultModel.value) defaultModel.value = res[0].name;
|
// if (!defaultModel.value) defaultModel.value = res[0].name;
|
||||||
});
|
// });
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeTab = ref("appearance");
|
const activeTab = ref("appearance");
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,579 @@
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="modal">
|
||||||
|
<div v-if="show" class="modal-overlay" @click.self="handleClose">
|
||||||
|
<div class="modal-container">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>{{ isMessageShare ? '分享消息' : '分享对话' }}</h3>
|
||||||
|
<button class="close-btn" @click="handleClose">
|
||||||
|
<X :size="20" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-content">
|
||||||
|
<!-- 消息分享预览 -->
|
||||||
|
<div v-if="isMessageShare" class="selected-preview">
|
||||||
|
<div class="preview-header">
|
||||||
|
<span class="preview-title">已选择 {{ selectedMessageCount }} 条消息</span>
|
||||||
|
<span class="preview-hint">来自当前对话</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-list messages-preview">
|
||||||
|
<div
|
||||||
|
v-for="msg in selectedMessages"
|
||||||
|
:key="msg.id"
|
||||||
|
class="preview-item message-item"
|
||||||
|
>
|
||||||
|
<span class="message-role" :class="msg.role">
|
||||||
|
{{ msg.role === 'user' ? '用户' : 'AI' }}
|
||||||
|
</span>
|
||||||
|
<span class="item-title">{{ getMessagePreview(msg) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 对话分享预览 -->
|
||||||
|
<div v-else class="selected-preview">
|
||||||
|
<div class="preview-header">
|
||||||
|
<span class="preview-title">已选择 {{ selectedCount }} 个对话</span>
|
||||||
|
<span class="preview-hint">最多分享 10 个对话</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-list">
|
||||||
|
<div
|
||||||
|
v-for="conv in selectedConversations"
|
||||||
|
:key="conv.id"
|
||||||
|
class="preview-item"
|
||||||
|
>
|
||||||
|
<MessageSquare :size="14" />
|
||||||
|
<span class="item-title">{{ conv.title }}</span>
|
||||||
|
<span class="item-count">{{ conv.messages.length }} 条消息</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 密码设置 -->
|
||||||
|
<div class="password-section">
|
||||||
|
<label class="password-label">
|
||||||
|
<Lock :size="16" />
|
||||||
|
设置访问密码
|
||||||
|
</label>
|
||||||
|
<div class="password-input-wrapper">
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
class="password-input"
|
||||||
|
placeholder="请输入 4-20 位密码"
|
||||||
|
maxlength="20"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="toggle-visibility"
|
||||||
|
@click="togglePasswordVisibility"
|
||||||
|
>
|
||||||
|
<Eye v-if="!showPassword" :size="18" />
|
||||||
|
<EyeOff v-else :size="18" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="password-hint">查看者需要输入此密码才能访问分享内容</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 过期时间提示 -->
|
||||||
|
<div class="expire-info">
|
||||||
|
<Clock :size="16" />
|
||||||
|
<span>分享链接将在 7 天后自动失效</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn-cancel" @click="handleClose">取消</button>
|
||||||
|
<button
|
||||||
|
class="btn-confirm"
|
||||||
|
:disabled="!isValidPassword || isCreating"
|
||||||
|
@click="handleCreateShare"
|
||||||
|
>
|
||||||
|
{{ isCreating ? '创建中...' : '创建分享链接' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from "vue";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
import { useChatStore } from "@/stores/chat";
|
||||||
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
|
import { hashPassword } from "@/utils/crypto";
|
||||||
|
import { shareApi } from "@/services/shareApi";
|
||||||
|
import { SHARE_LIMITS } from "@/types/share";
|
||||||
|
import type { Message } from "@/types/chat";
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
Lock,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Clock,
|
||||||
|
MessageSquare,
|
||||||
|
} from "@/components/icons";
|
||||||
|
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
|
||||||
|
const show = computed(() => settingsStore.showShareModal);
|
||||||
|
const { selectedConversations, selectedCount, isMessageSelectMode, selectedMessages, selectedMessageCount } = storeToRefs(chatStore);
|
||||||
|
|
||||||
|
// 是否是消息分享模式
|
||||||
|
const isMessageShare = computed(() => isMessageSelectMode.value);
|
||||||
|
|
||||||
|
const password = ref("");
|
||||||
|
const showPassword = ref(false);
|
||||||
|
const isCreating = ref(false);
|
||||||
|
|
||||||
|
const isValidPassword = computed(() => {
|
||||||
|
return password.value.length >= 4 && password.value.length <= 20;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取消息预览文本
|
||||||
|
function getMessagePreview(message: Message): string {
|
||||||
|
const text = message.content.text || '';
|
||||||
|
return text.length > 50 ? text.substring(0, 50) + '...' : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePasswordVisibility() {
|
||||||
|
showPassword.value = !showPassword.value;
|
||||||
|
const input = document.querySelector('.password-input') as HTMLInputElement;
|
||||||
|
if (input) {
|
||||||
|
input.type = showPassword.value ? 'text' : 'password';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateShare() {
|
||||||
|
if (!isValidPassword.value || isCreating.value) return;
|
||||||
|
|
||||||
|
isCreating.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 哈希密码
|
||||||
|
const passwordHash = await hashPassword(password.value);
|
||||||
|
|
||||||
|
let result;
|
||||||
|
|
||||||
|
if (isMessageShare.value) {
|
||||||
|
// 消息分享模式
|
||||||
|
result = await shareApi.createShare({
|
||||||
|
conversationIds: [],
|
||||||
|
messages: selectedMessages.value.map(m => ({
|
||||||
|
id: m.id,
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
timestamp: m.timestamp,
|
||||||
|
})),
|
||||||
|
passwordHash,
|
||||||
|
expiresIn: SHARE_LIMITS.DEFAULT_EXPIRE_SECONDS,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 对话分享模式
|
||||||
|
// 检查数量限制
|
||||||
|
if (selectedCount.value > SHARE_LIMITS.MAX_CONVERSATIONS) {
|
||||||
|
window.$toast?.(`最多分享 ${SHARE_LIMITS.MAX_CONVERSATIONS} 个对话`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversationIds = selectedConversations.value.map(c => c.id);
|
||||||
|
|
||||||
|
result = await shareApi.createShare({
|
||||||
|
conversationIds,
|
||||||
|
passwordHash,
|
||||||
|
expiresIn: SHARE_LIMITS.DEFAULT_EXPIRE_SECONDS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭当前模态框,打开结果模态框
|
||||||
|
handleClose()
|
||||||
|
|
||||||
|
// 优先使用后端返回的路径,避免前后端各自拼接导致不一致
|
||||||
|
const shareUrl = new URL(result.shareUrl, window.location.origin).toString()
|
||||||
|
|
||||||
|
// 存储分享信息并打开结果模态框
|
||||||
|
settingsStore.setShareResult({
|
||||||
|
shareId: result.id,
|
||||||
|
shareUrl,
|
||||||
|
password: password.value,
|
||||||
|
expiresAt: result.expiresAt,
|
||||||
|
})
|
||||||
|
settingsStore.openShareResultModal();
|
||||||
|
|
||||||
|
// 清空密码和退出选择模式
|
||||||
|
password.value = "";
|
||||||
|
if (isMessageShare.value) {
|
||||||
|
chatStore.exitMessageSelectMode();
|
||||||
|
} else {
|
||||||
|
chatStore.exitSelectMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create share:', error);
|
||||||
|
window.$toast?.('创建分享失败,请重试', 'error');
|
||||||
|
} finally {
|
||||||
|
isCreating.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
settingsStore.closeShareModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听模态框关闭,重置状态
|
||||||
|
watch(show, (newVal: boolean) => {
|
||||||
|
if (!newVal) {
|
||||||
|
password.value = "";
|
||||||
|
showPassword.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 480px;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background: #1e1e2e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
border-bottom-color: #2d2d3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
color: #f3f4f6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
color: #374151;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-preview {
|
||||||
|
.preview-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 150px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 10px;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background: #2d2d3d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #4b5563;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-title {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.messages-preview {
|
||||||
|
max-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.message-role {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&.user {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.assistant {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-title {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-section {
|
||||||
|
.password-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 44px 12px 14px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #1f2937;
|
||||||
|
background: white;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background: #2d2d3d;
|
||||||
|
border-color: #374151;
|
||||||
|
color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-visibility {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
color: #374151;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-hint {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.expire-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(59, 130, 246, 0.05);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #3b82f6;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
border-top-color: #2d2d3d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: transparent;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
color: #374151;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动画
|
||||||
|
.modal-enter-active,
|
||||||
|
.modal-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-enter-from,
|
||||||
|
.modal-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
|
||||||
|
.modal-container {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,406 @@
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="modal">
|
||||||
|
<div v-if="show" class="modal-overlay" @click.self="handleClose">
|
||||||
|
<div class="modal-container">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>分享创建成功</h3>
|
||||||
|
<button class="close-btn" @click="handleClose">
|
||||||
|
<X :size="20" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="success-icon">
|
||||||
|
<CheckCircle :size="48" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="success-message">
|
||||||
|
您的对话已成功分享,将链接和密码发送给朋友即可查看
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- 分享链接 -->
|
||||||
|
<div class="share-section">
|
||||||
|
<label class="share-label">分享链接</label>
|
||||||
|
<div class="share-input-wrapper">
|
||||||
|
<input
|
||||||
|
ref="linkInput"
|
||||||
|
:value="shareUrl"
|
||||||
|
readonly
|
||||||
|
class="share-input"
|
||||||
|
/>
|
||||||
|
<button class="copy-btn" @click="copyLink">
|
||||||
|
<Copy :size="16" />
|
||||||
|
{{ linkCopied ? '已复制' : '复制' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 访问密码 -->
|
||||||
|
<div class="share-section">
|
||||||
|
<label class="share-label">访问密码</label>
|
||||||
|
<div class="share-input-wrapper">
|
||||||
|
<input
|
||||||
|
ref="passwordInput"
|
||||||
|
:value="password"
|
||||||
|
readonly
|
||||||
|
class="share-input password"
|
||||||
|
/>
|
||||||
|
<button class="copy-btn" @click="copyPassword">
|
||||||
|
<Copy :size="16" />
|
||||||
|
{{ passwordCopied ? '已复制' : '复制' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 过期时间 -->
|
||||||
|
<div class="expire-info">
|
||||||
|
<Clock :size="16" />
|
||||||
|
<span>链接将于 {{ formattedExpiresAt }} 过期</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn-copy-all" @click="copyAll">
|
||||||
|
<Copy :size="16" />
|
||||||
|
复制链接和密码
|
||||||
|
</button>
|
||||||
|
<button class="btn-close" @click="handleClose">
|
||||||
|
完成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from "vue";
|
||||||
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
|
import { formatTimestamp } from "@/utils/helpers";
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
CheckCircle,
|
||||||
|
Copy,
|
||||||
|
Clock,
|
||||||
|
} from "@/components/icons";
|
||||||
|
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
|
||||||
|
const show = computed(() => settingsStore.showShareResultModal);
|
||||||
|
const shareResult = computed(() => settingsStore.shareResult);
|
||||||
|
|
||||||
|
const shareUrl = computed(() => shareResult.value?.shareUrl || '');
|
||||||
|
const password = computed(() => shareResult.value?.password || '');
|
||||||
|
const expiresAt = computed(() => shareResult.value?.expiresAt || 0);
|
||||||
|
|
||||||
|
const linkCopied = ref(false);
|
||||||
|
const passwordCopied = ref(false);
|
||||||
|
|
||||||
|
const formattedExpiresAt = computed(() => {
|
||||||
|
return formatTimestamp(expiresAt.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function copyToClipboard(text: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// Fallback for older browsers
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.value = text;
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(input);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyLink() {
|
||||||
|
if (await copyToClipboard(shareUrl.value)) {
|
||||||
|
linkCopied.value = true;
|
||||||
|
window.$toast?.('链接已复制到剪贴板', 'success');
|
||||||
|
setTimeout(() => {
|
||||||
|
linkCopied.value = false;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyPassword() {
|
||||||
|
if (await copyToClipboard(password.value)) {
|
||||||
|
passwordCopied.value = true;
|
||||||
|
window.$toast?.('密码已复制到剪贴板', 'success');
|
||||||
|
setTimeout(() => {
|
||||||
|
passwordCopied.value = false;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyAll() {
|
||||||
|
const text = `分享链接: ${shareUrl.value}\n访问密码: ${password.value}`;
|
||||||
|
if (await copyToClipboard(text)) {
|
||||||
|
window.$toast?.('链接和密码已复制到剪贴板', 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
settingsStore.closeShareResultModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听模态框关闭,重置状态
|
||||||
|
watch(show, (newVal: boolean) => {
|
||||||
|
if (!newVal) {
|
||||||
|
linkCopied.value = false;
|
||||||
|
passwordCopied.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 480px;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background: #1e1e2e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
border-bottom-color: #2d2d3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
color: #f3f4f6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
color: #374151;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-icon {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-message {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-section {
|
||||||
|
.share-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-input-wrapper {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1f2937;
|
||||||
|
background: #f9fafb;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background: #2d2d3d;
|
||||||
|
border-color: #374151;
|
||||||
|
color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.password {
|
||||||
|
font-family: monospace;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background: #374151;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background: #4b5563;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.expire-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
border-top-color: #2d2d3d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy-all {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #3b82f6;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: transparent;
|
||||||
|
color: #3b82f6;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动画
|
||||||
|
.modal-enter-active,
|
||||||
|
.modal-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-enter-from,
|
||||||
|
.modal-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
|
||||||
|
.modal-container {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -59,6 +59,9 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 分享按钮 -->
|
||||||
|
<ShareButton />
|
||||||
|
|
||||||
<!-- 搜索框 -->
|
<!-- 搜索框 -->
|
||||||
<!-- <div class="search-section">
|
<!-- <div class="search-section">
|
||||||
<div class="search-box" @click="openSearch">
|
<div class="search-box" @click="openSearch">
|
||||||
|
|
@ -82,10 +85,13 @@
|
||||||
:key="conv.id"
|
:key="conv.id"
|
||||||
:conversation="conv"
|
:conversation="conv"
|
||||||
:is-active="conv.id === currentConversationId"
|
:is-active="conv.id === currentConversationId"
|
||||||
|
:is-select-mode="isSelectMode"
|
||||||
|
:is-selected="isConversationSelected(conv.id)"
|
||||||
@select="selectConversation"
|
@select="selectConversation"
|
||||||
@delete="deleteConversation"
|
@delete="deleteConversation"
|
||||||
@rename="renameConversation"
|
@rename="renameConversation"
|
||||||
@toggle-pin="togglePinConversation"
|
@toggle-pin="togglePinConversation"
|
||||||
|
@toggle-select="toggleConversationSelection"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -102,10 +108,13 @@
|
||||||
:key="conv.id"
|
:key="conv.id"
|
||||||
:conversation="conv"
|
:conversation="conv"
|
||||||
:is-active="conv.id === currentConversationId"
|
:is-active="conv.id === currentConversationId"
|
||||||
|
:is-select-mode="isSelectMode"
|
||||||
|
:is-selected="isConversationSelected(conv.id)"
|
||||||
@select="selectConversation"
|
@select="selectConversation"
|
||||||
@delete="deleteConversation"
|
@delete="deleteConversation"
|
||||||
@rename="renameConversation"
|
@rename="renameConversation"
|
||||||
@toggle-pin="togglePinConversation"
|
@toggle-pin="togglePinConversation"
|
||||||
|
@toggle-select="toggleConversationSelection"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -124,21 +133,21 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 底部操作 -->
|
<!-- 底部操作 -->
|
||||||
<div class="sidebar-footer">
|
<!-- <div class="sidebar-footer">
|
||||||
<button class="footer-btn" @click="toggleTheme" title="切换主题">
|
<button class="footer-btn" @click="toggleTheme" title="切换主题">
|
||||||
<Sun v-if="currentTheme === 'light'" :size="18" />
|
<Sun v-if="currentTheme === 'light'" :size="18" />
|
||||||
<Moon v-else-if="currentTheme === 'dark'" :size="18" />
|
<Moon v-else-if="currentTheme === 'dark'" :size="18" />
|
||||||
<Monitor v-else :size="18" />
|
<Monitor v-else :size="18" />
|
||||||
</button>
|
</button>
|
||||||
<!-- 键盘快捷键 -->
|
键盘快捷键
|
||||||
<!-- <button class="footer-btn" @click="openShortcuts" title="快捷键">
|
<button class="footer-btn" @click="openShortcuts" title="快捷键">
|
||||||
<Keyboard :size="18" />
|
<Keyboard :size="18" />
|
||||||
</button> -->
|
</button>
|
||||||
<!-- dev人员可用 -->
|
dev人员可用
|
||||||
<!-- <button class="footer-btn" @click="openSettings" title="设置">
|
<button class="footer-btn" @click="openSettings" title="设置">
|
||||||
<Settings :size="18" />
|
<Settings :size="18" />
|
||||||
</button> -->
|
</button>
|
||||||
</div>
|
</div> -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 拖拽调整宽度 -->
|
<!-- 拖拽调整宽度 -->
|
||||||
|
|
@ -147,25 +156,17 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from "vue";
|
import { onMounted, ref } from "vue";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
import { useChatStore } from "@/stores/chat";
|
import { useChatStore } from "@/stores/chat";
|
||||||
import { useSettingsStore } from "@/stores/settings";
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
import { chatApi } from "@/services/api.ts";
|
import { chatApi } from "@/services/api.ts";
|
||||||
import ConversationItem from "./ConversationItem.vue";
|
import ConversationItem from "./ConversationItem.vue";
|
||||||
|
import ShareButton from "./ShareButton.vue";
|
||||||
import {
|
import {
|
||||||
Bot,
|
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
|
||||||
Pin,
|
Pin,
|
||||||
Clock,
|
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Sun,
|
|
||||||
Moon,
|
|
||||||
Monitor,
|
|
||||||
Keyboard,
|
|
||||||
Settings,
|
|
||||||
ChevronLeft,
|
|
||||||
Sparkles,
|
Sparkles,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Check,
|
Check,
|
||||||
|
|
@ -174,17 +175,14 @@ import {
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
|
|
||||||
const { currentConversationId, pinnedConversations, recentConversations } =
|
const { currentConversationId, pinnedConversations, recentConversations, isSelectMode, selectedConversationIds } =
|
||||||
storeToRefs(chatStore);
|
storeToRefs(chatStore);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
sidebarCollapsed: isCollapsed,
|
sidebarCollapsed: isCollapsed,
|
||||||
sidebarWidth,
|
sidebarWidth,
|
||||||
settings,
|
|
||||||
} = storeToRefs(settingsStore);
|
} = storeToRefs(settingsStore);
|
||||||
|
|
||||||
const currentTheme = computed(() => settings.value.theme);
|
|
||||||
|
|
||||||
// 模型选择相关
|
// 模型选择相关
|
||||||
const showModelMenu = ref(false);
|
const showModelMenu = ref(false);
|
||||||
const currentModel = ref(localStorage.getItem("modelSelect") || "");
|
const currentModel = ref(localStorage.getItem("modelSelect") || "");
|
||||||
|
|
@ -212,6 +210,11 @@ function selectModel(modelId: string, modelName: string) {
|
||||||
localStorage.setItem("modelSelect", modelName);
|
localStorage.setItem("modelSelect", modelName);
|
||||||
settingsStore.setSelectedModelId(modelId); // 更新选中的模型 ID
|
settingsStore.setSelectedModelId(modelId); // 更新选中的模型 ID
|
||||||
showModelMenu.value = false;
|
showModelMenu.value = false;
|
||||||
|
|
||||||
|
// 切换模型时重置搜索和思考参数
|
||||||
|
localStorage.setItem("isDeepSearch", "false");
|
||||||
|
localStorage.setItem("isDeepThinking", "false");
|
||||||
|
localStorage.setItem("isWebSearch", "false");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 方法
|
// 方法
|
||||||
|
|
@ -235,24 +238,12 @@ function togglePinConversation(id: string) {
|
||||||
chatStore.togglePinConversation(id);
|
chatStore.togglePinConversation(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSidebar() {
|
function toggleConversationSelection(id: string) {
|
||||||
settingsStore.toggleSidebar();
|
chatStore.toggleConversationSelection(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTheme() {
|
function isConversationSelected(id: string): boolean {
|
||||||
settingsStore.toggleTheme();
|
return selectedConversationIds.value.includes(id);
|
||||||
}
|
|
||||||
|
|
||||||
function openShortcuts() {
|
|
||||||
settingsStore.openShortcutsModal();
|
|
||||||
}
|
|
||||||
|
|
||||||
function openSettings() {
|
|
||||||
settingsStore.openSettingsModal();
|
|
||||||
}
|
|
||||||
|
|
||||||
function openSearch() {
|
|
||||||
settingsStore.openSearchModal();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 拖拽调整宽度
|
// 拖拽调整宽度
|
||||||
|
|
@ -296,7 +287,6 @@ if (typeof window !== "undefined") {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-right: 1px solid #e2e8f0;
|
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
@ -706,9 +696,5 @@ if (typeof window !== "undefined") {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
cursor: col-resize;
|
cursor: col-resize;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(59, 130, 246, 0.3);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,21 @@
|
||||||
:class="{
|
:class="{
|
||||||
active: isActive,
|
active: isActive,
|
||||||
pinned: conversation.pinned,
|
pinned: conversation.pinned,
|
||||||
|
selected: isSelected,
|
||||||
|
'select-mode': isSelectMode,
|
||||||
}"
|
}"
|
||||||
@click="handleSelect"
|
@click="handleClick"
|
||||||
@dblclick="handleRename"
|
@dblclick="handleRename"
|
||||||
>
|
>
|
||||||
|
<!-- 选择模式复选框 -->
|
||||||
|
<div v-if="isSelectMode" class="item-checkbox" @click.stop="handleToggleSelect">
|
||||||
|
<div class="checkbox" :class="{ checked: isSelected }">
|
||||||
|
<Check v-if="isSelected" :size="14" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 图标 -->
|
<!-- 图标 -->
|
||||||
<div class="item-icon">
|
<div v-if="!isSelectMode" class="item-icon">
|
||||||
<MessageSquare :size="18" />
|
<MessageSquare :size="18" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -35,12 +44,12 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 置顶标识 -->
|
<!-- 置顶标识 -->
|
||||||
<div v-if="conversation.pinned" class="pin-indicator">
|
<div v-if="conversation.pinned && !isSelectMode" class="pin-indicator">
|
||||||
<Pin :size="12" />
|
<Pin :size="12" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 操作按钮 -->
|
<!-- 操作按钮 (非选择模式显示) -->
|
||||||
<div class="item-actions" @click.stop>
|
<div v-if="!isSelectMode" class="item-actions" @click.stop>
|
||||||
<button
|
<button
|
||||||
class="action-btn"
|
class="action-btn"
|
||||||
:title="conversation.pinned ? '取消置顶' : '置顶'"
|
:title="conversation.pinned ? '取消置顶' : '置顶'"
|
||||||
|
|
@ -68,6 +77,7 @@ import {
|
||||||
Edit3,
|
Edit3,
|
||||||
Trash2,
|
Trash2,
|
||||||
Clock,
|
Clock,
|
||||||
|
Check,
|
||||||
} from "@/components/icons";
|
} from "@/components/icons";
|
||||||
import { formatTimestamp } from "@/utils/helpers";
|
import { formatTimestamp } from "@/utils/helpers";
|
||||||
import type { Conversation } from "@/types/chat";
|
import type { Conversation } from "@/types/chat";
|
||||||
|
|
@ -75,6 +85,8 @@ import type { Conversation } from "@/types/chat";
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
conversation: Conversation;
|
conversation: Conversation;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
isSelectMode?: boolean;
|
||||||
|
isSelected?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|
@ -82,6 +94,7 @@ const emit = defineEmits<{
|
||||||
delete: [id: string];
|
delete: [id: string];
|
||||||
rename: [id: string, title: string];
|
rename: [id: string, title: string];
|
||||||
togglePin: [id: string];
|
togglePin: [id: string];
|
||||||
|
toggleSelect: [id: string];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const isEditing = ref(false);
|
const isEditing = ref(false);
|
||||||
|
|
@ -92,17 +105,24 @@ const formattedTime = computed(() => {
|
||||||
return formatTimestamp(props.conversation.updatedAt);
|
return formatTimestamp(props.conversation.updatedAt);
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleSelect() {
|
function handleClick() {
|
||||||
if (!isEditing.value) {
|
if (props.isSelectMode) {
|
||||||
|
emit("toggleSelect", props.conversation.id);
|
||||||
|
} else if (!isEditing.value) {
|
||||||
emit("select", props.conversation.id);
|
emit("select", props.conversation.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleToggleSelect() {
|
||||||
|
emit("toggleSelect", props.conversation.id);
|
||||||
|
}
|
||||||
|
|
||||||
function handleTogglePin() {
|
function handleTogglePin() {
|
||||||
emit("togglePin", props.conversation.id);
|
emit("togglePin", props.conversation.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRename() {
|
function handleRename() {
|
||||||
|
if (props.isSelectMode) return;
|
||||||
isEditing.value = true;
|
isEditing.value = true;
|
||||||
editTitle.value = props.conversation.title;
|
editTitle.value = props.conversation.title;
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
|
|
@ -274,4 +294,57 @@ function handleDelete() {
|
||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 选择模式样式
|
||||||
|
.item-checkbox {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 2px solid #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
border-color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.checked {
|
||||||
|
background: #3b82f6;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-item.select-mode {
|
||||||
|
&:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.05);
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
<template>
|
||||||
|
<div class="share-button-wrapper">
|
||||||
|
<button
|
||||||
|
v-if="!isSelectMode"
|
||||||
|
class="share-btn"
|
||||||
|
:disabled="conversations.length === 0"
|
||||||
|
@click="handleStartSelect"
|
||||||
|
>
|
||||||
|
<Share2 :size="16" />
|
||||||
|
<span>分享对话</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-else class="select-actions">
|
||||||
|
<span class="select-info">
|
||||||
|
已选择 {{ selectedCount }} 个对话
|
||||||
|
</span>
|
||||||
|
<div class="action-buttons">
|
||||||
|
<button class="action-btn cancel" @click="handleCancel">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="action-btn confirm"
|
||||||
|
:disabled="selectedCount === 0"
|
||||||
|
@click="handleConfirm"
|
||||||
|
>
|
||||||
|
确认分享
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
import { useChatStore } from "@/stores/chat";
|
||||||
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
|
import { Share2 } from "@/components/icons";
|
||||||
|
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
|
||||||
|
const { isSelectMode, selectedCount, conversations } = storeToRefs(chatStore);
|
||||||
|
|
||||||
|
function handleStartSelect() {
|
||||||
|
chatStore.enterSelectMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
chatStore.exitSelectMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
if (chatStore.selectedCount > 0) {
|
||||||
|
// 打开分享设置模态框
|
||||||
|
settingsStore.openShareModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.share-button-wrapper {
|
||||||
|
padding: 6px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f3f4f5;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background: #2d2d3d;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #000f33;
|
||||||
|
color: #e5e7eb;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background: #0475ed;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-info {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
&.cancel {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
color: #6b7280;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.confirm {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { createApp } from "vue";
|
import { createApp } from "vue";
|
||||||
import { createPinia } from "pinia";
|
import { createPinia } from "pinia";
|
||||||
|
import router from "./router";
|
||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
|
|
||||||
// 样式
|
// 样式
|
||||||
|
|
@ -15,6 +16,9 @@ const app = createApp(App);
|
||||||
const pinia = createPinia();
|
const pinia = createPinia();
|
||||||
app.use(pinia);
|
app.use(pinia);
|
||||||
|
|
||||||
|
// 使用 Router
|
||||||
|
app.use(router);
|
||||||
|
|
||||||
// 挂载应用
|
// 挂载应用
|
||||||
app.mount("#app");
|
app.mount("#app");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import HomeView from '@/views/HomeView.vue'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory('/'),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'home',
|
||||||
|
component: HomeView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/share/:id',
|
||||||
|
name: 'share',
|
||||||
|
component: () => import('@/views/ShareView.vue'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
* Chat UI API 服务
|
* Chat UI API 服务
|
||||||
* 所有端点都是固定的,后端需要实现这些端点
|
* 所有端点都是固定的,后端需要实现这些端点
|
||||||
*/
|
*/
|
||||||
|
import { getAuthHeaders } from './request';
|
||||||
|
|
||||||
// API 端点定义(固定)
|
// API 端点定义(固定)
|
||||||
const API_ENDPOINTS = {
|
const API_ENDPOINTS = {
|
||||||
// 发送消息(流式)
|
// 发送消息(流式)
|
||||||
|
|
@ -119,21 +121,36 @@ class ChatApi {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将前端简化的请求翻译为 OpenAI 兼容的规范请求体
|
// 将前端简化的请求翻译为 OpenAI 兼容的规范请求体
|
||||||
// 构建 messages 数组:system + 历史消息 + 当前用户消息
|
// 检查历史消息中是否已有系统消息
|
||||||
|
const historyHasSystem = request.history?.some((m) => m.role === "system");
|
||||||
|
|
||||||
|
// 构建 messages 数组
|
||||||
|
let allMessages: Array<{ role: string; content: any }>;
|
||||||
|
|
||||||
|
if (request.history && request.history.length > 0) {
|
||||||
|
// 如果历史中有系统消息,直接使用历史消息
|
||||||
|
if (historyHasSystem) {
|
||||||
|
allMessages = [...request.history, { role: "user", content: userContent }];
|
||||||
|
} else {
|
||||||
|
// 否则添加系统消息
|
||||||
const systemMessage = {
|
const systemMessage = {
|
||||||
role: "system",
|
role: "system",
|
||||||
content:
|
content:
|
||||||
request.systemPrompt ||
|
request.systemPrompt ||
|
||||||
"你是一个智能助手,可以分析用户发送的文字,文件或图片内容,并进行回答。",
|
"你是一个智能助手,可以分析用户发送的文字,文件或图片内容,并进行回答。",
|
||||||
};
|
};
|
||||||
const currentUserMessage = {
|
allMessages = [systemMessage, ...request.history, { role: "user", content: userContent }];
|
||||||
role: "user",
|
}
|
||||||
content: userContent,
|
} else {
|
||||||
|
// 没有历史消息,添加系统消息
|
||||||
|
const systemMessage = {
|
||||||
|
role: "system",
|
||||||
|
content:
|
||||||
|
request.systemPrompt ||
|
||||||
|
"你是一个智能助手,可以分析用户发送的文字,文件或图片内容,并进行回答。",
|
||||||
};
|
};
|
||||||
const allMessages =
|
allMessages = [systemMessage, { role: "user", content: userContent }];
|
||||||
request.history && request.history.length > 0
|
}
|
||||||
? [systemMessage, ...request.history, currentUserMessage]
|
|
||||||
: [systemMessage, currentUserMessage];
|
|
||||||
|
|
||||||
const openAiRequest = {
|
const openAiRequest = {
|
||||||
model: request.model || "glm-4-flash",
|
model: request.model || "glm-4-flash",
|
||||||
|
|
@ -153,7 +170,7 @@ class ChatApi {
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
...getAuthHeaders(),
|
||||||
Accept: "text/event-stream",
|
Accept: "text/event-stream",
|
||||||
},
|
},
|
||||||
body: JSON.stringify(openAiRequest),
|
body: JSON.stringify(openAiRequest),
|
||||||
|
|
@ -244,9 +261,7 @@ class ChatApi {
|
||||||
|
|
||||||
const response = await fetch(`${this.baseUrl}${API_ENDPOINTS.CHAT}`, {
|
const response = await fetch(`${this.baseUrl}${API_ENDPOINTS.CHAT}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: getAuthHeaders(),
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(requestBody),
|
body: JSON.stringify(requestBody),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -264,9 +279,7 @@ class ChatApi {
|
||||||
async stopChat(messageId?: string) {
|
async stopChat(messageId?: string) {
|
||||||
await fetch(`${this.baseUrl}${API_ENDPOINTS.STOP}/${messageId}`, {
|
await fetch(`${this.baseUrl}${API_ENDPOINTS.STOP}/${messageId}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: getAuthHeaders(),
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -326,8 +339,13 @@ class ChatApi {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
|
|
||||||
|
// 获取认证 headers,但不包含 Content-Type(让浏览器为 FormData 自动设置)
|
||||||
|
const authHeaders = getAuthHeaders();
|
||||||
|
const { 'Content-Type': _, ...headersWithoutContentType } = authHeaders;
|
||||||
|
|
||||||
const response = await fetch(`${this.baseUrl}${API_ENDPOINTS.UPLOAD}`, {
|
const response = await fetch(`${this.baseUrl}${API_ENDPOINTS.UPLOAD}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
headers: headersWithoutContentType,
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -337,6 +355,23 @@ class ChatApi {
|
||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除附件(从 OSS 删除)
|
||||||
|
*/
|
||||||
|
async deleteAttachment(url: string): Promise<void> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${this.baseUrl}/api/chat-ui/attachment?url=${encodeURIComponent(url)}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`删除附件失败: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导出单例
|
// 导出单例
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
/**
|
||||||
|
* 认证服务模块 - 预留接口
|
||||||
|
*
|
||||||
|
* 当前返回默认用户,未来可集成 JWT、OAuth 等认证系统
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token 存储 key
|
||||||
|
const AUTH_TOKEN_KEY = 'auth_token';
|
||||||
|
|
||||||
|
export const authService = {
|
||||||
|
/**
|
||||||
|
* 获取当前用户(预留,目前返回默认用户)
|
||||||
|
*/
|
||||||
|
getCurrentUser(): AuthUser | null {
|
||||||
|
// TODO: 从 token 解析用户信息
|
||||||
|
return { id: 'default' };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取认证 token(预留)
|
||||||
|
*/
|
||||||
|
getToken(): string | null {
|
||||||
|
return localStorage.getItem(AUTH_TOKEN_KEY);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置 token(预留)
|
||||||
|
*/
|
||||||
|
setToken(token: string): void {
|
||||||
|
localStorage.setItem(AUTH_TOKEN_KEY, token);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除认证信息(预留)
|
||||||
|
*/
|
||||||
|
clearAuth(): void {
|
||||||
|
localStorage.removeItem(AUTH_TOKEN_KEY);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否已认证(预留,目前始终返回 true)
|
||||||
|
*/
|
||||||
|
isAuthenticated(): boolean {
|
||||||
|
// TODO: 实现真实的认证检查
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Authorization header 值
|
||||||
|
*/
|
||||||
|
getAuthHeader(): Record<string, string> {
|
||||||
|
const token = this.getToken();
|
||||||
|
if (token) {
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,319 @@
|
||||||
|
/**
|
||||||
|
* 对话 API 服务层
|
||||||
|
*
|
||||||
|
* 封装所有对话相关的后端 API 调用
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getAuthHeaders } from './request';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import type { Conversation, Message, MessageContent, ConversationSettings } from '@/types/chat';
|
||||||
|
|
||||||
|
// API 端点
|
||||||
|
const API_BASE = '/api/chat-ui';
|
||||||
|
const ENDPOINTS = {
|
||||||
|
CONVERSATIONS: `${API_BASE}/conversations`,
|
||||||
|
CONVERSATION: (id: string) => `${API_BASE}/conversations/${id}`,
|
||||||
|
CONVERSATION_MESSAGES: (id: string) => `${API_BASE}/conversations/${id}/messages`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 后端返回的对话数据格式
|
||||||
|
interface BackendConversation {
|
||||||
|
id: string;
|
||||||
|
userId?: string;
|
||||||
|
title: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
pinned: boolean;
|
||||||
|
archived: boolean;
|
||||||
|
settings?: ConversationSettings;
|
||||||
|
messages?: BackendMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后端返回的消息数据格式
|
||||||
|
interface BackendMessage {
|
||||||
|
id: string;
|
||||||
|
role: string;
|
||||||
|
content: MessageContent;
|
||||||
|
timestamp: number;
|
||||||
|
feedback?: {
|
||||||
|
liked?: boolean;
|
||||||
|
disliked?: boolean;
|
||||||
|
copied?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取请求头(包含认证信息)
|
||||||
|
*/
|
||||||
|
function getHeaders(): Record<string, string> {
|
||||||
|
return getAuthHeaders();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将后端对话格式转换为前端格式
|
||||||
|
*/
|
||||||
|
function transformConversation(backendConv: BackendConversation): Conversation {
|
||||||
|
return {
|
||||||
|
id: backendConv.id,
|
||||||
|
title: backendConv.title,
|
||||||
|
createdAt: backendConv.createdAt,
|
||||||
|
updatedAt: backendConv.updatedAt,
|
||||||
|
pinned: backendConv.pinned,
|
||||||
|
archived: backendConv.archived,
|
||||||
|
settings: backendConv.settings,
|
||||||
|
messages: (backendConv.messages || []).map(transformMessage),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将后端消息格式转换为前端格式
|
||||||
|
*/
|
||||||
|
function transformMessage(backendMsg: BackendMessage): Message {
|
||||||
|
return {
|
||||||
|
id: backendMsg.id,
|
||||||
|
role: backendMsg.role as 'user' | 'assistant' | 'system',
|
||||||
|
content: backendMsg.content,
|
||||||
|
timestamp: backendMsg.timestamp,
|
||||||
|
feedback: backendMsg.feedback,
|
||||||
|
isStreaming: false,
|
||||||
|
} as Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将前端对话格式转换为后端格式
|
||||||
|
*/
|
||||||
|
function toBackendFormat(conversation: Partial<Conversation>, userId?: string): Record<string, unknown> {
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (conversation.id !== undefined) data.id = conversation.id;
|
||||||
|
if (userId !== undefined) data.user_id = userId; // 后端使用下划线命名
|
||||||
|
if (conversation.title !== undefined) data.title = conversation.title;
|
||||||
|
if (conversation.createdAt !== undefined) data.createdAt = conversation.createdAt;
|
||||||
|
if (conversation.updatedAt !== undefined) data.updatedAt = conversation.updatedAt;
|
||||||
|
if (conversation.pinned !== undefined) data.pinned = conversation.pinned;
|
||||||
|
if (conversation.archived !== undefined) data.archived = conversation.archived;
|
||||||
|
if (conversation.settings !== undefined) data.settings = conversation.settings;
|
||||||
|
if (conversation.messages !== undefined) {
|
||||||
|
data.messages = conversation.messages.map(msg => ({
|
||||||
|
id: msg.id,
|
||||||
|
role: msg.role,
|
||||||
|
content: msg.content,
|
||||||
|
timestamp: msg.timestamp,
|
||||||
|
feedback: msg.feedback,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对话 API 服务
|
||||||
|
*/
|
||||||
|
export const conversationApi = {
|
||||||
|
/**
|
||||||
|
* 获取所有对话列表(不含消息内容)
|
||||||
|
*/
|
||||||
|
async fetchConversations(): Promise<Conversation[]> {
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
// 等待 authStore 初始化完成
|
||||||
|
if (!authStore.isInitialized) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const unwatch = authStore.$subscribe(() => {
|
||||||
|
if (authStore.isInitialized) {
|
||||||
|
unwatch();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 如果已经初始化了,立即 resolve
|
||||||
|
if (authStore.isInitialized) {
|
||||||
|
unwatch();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = authStore.userId;
|
||||||
|
|
||||||
|
// 构建 URL,添加 user_id 查询参数
|
||||||
|
const url = userId
|
||||||
|
? `${ENDPOINTS.CONVERSATIONS}?user_id=${encodeURIComponent(userId)}`
|
||||||
|
: ENDPOINTS.CONVERSATIONS;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: getHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`获取对话列表失败: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: BackendConversation[] = await response.json();
|
||||||
|
return data.map(transformConversation);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单个对话(含消息内容)
|
||||||
|
*/
|
||||||
|
async fetchConversation(id: string): Promise<Conversation> {
|
||||||
|
const response = await fetch(ENDPOINTS.CONVERSATION(id), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: getHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
throw new Error('对话不存在');
|
||||||
|
}
|
||||||
|
throw new Error(`获取对话失败: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: BackendConversation = await response.json();
|
||||||
|
return transformConversation(data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建新对话
|
||||||
|
*/
|
||||||
|
async createConversation(data: Partial<Conversation>): Promise<Conversation> {
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const userId = authStore.userId || undefined;
|
||||||
|
|
||||||
|
const response = await fetch(ENDPOINTS.CONVERSATIONS, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(toBackendFormat(data, userId)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`创建对话失败: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: BackendConversation = await response.json();
|
||||||
|
return transformConversation(result);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新对话(部分更新)
|
||||||
|
*/
|
||||||
|
async updateConversation(id: string, data: Partial<Conversation>): Promise<Conversation> {
|
||||||
|
const response = await fetch(ENDPOINTS.CONVERSATION(id), {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(toBackendFormat(data)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
throw new Error('对话不存在');
|
||||||
|
}
|
||||||
|
throw new Error(`更新对话失败: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: BackendConversation = await response.json();
|
||||||
|
return transformConversation(result);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存对话(创建或更新)
|
||||||
|
*/
|
||||||
|
async saveConversation(conversation: Conversation): Promise<Conversation> {
|
||||||
|
const data = toBackendFormat(conversation);
|
||||||
|
|
||||||
|
const response = await fetch(ENDPOINTS.CONVERSATIONS, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`保存对话失败: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: BackendConversation = await response.json();
|
||||||
|
return transformConversation(result);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除对话
|
||||||
|
*/
|
||||||
|
async deleteConversation(id: string): Promise<void> {
|
||||||
|
const response = await fetch(ENDPOINTS.CONVERSATION(id), {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
// 对话已不存在,视为成功
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`删除对话失败: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加消息到对话(增量更新)
|
||||||
|
*/
|
||||||
|
async addMessage(conversationId: string, message: Partial<Message>): Promise<Message> {
|
||||||
|
const response = await fetch(ENDPOINTS.CONVERSATION_MESSAGES(conversationId), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: message.id,
|
||||||
|
role: message.role,
|
||||||
|
content: message.content,
|
||||||
|
timestamp: message.timestamp,
|
||||||
|
feedback: message.feedback,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`添加消息失败: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: BackendMessage = await response.json();
|
||||||
|
return transformMessage(result);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新消息
|
||||||
|
*/
|
||||||
|
async updateMessage(conversationId: string, messageId: string, data: Partial<Message>): Promise<Message> {
|
||||||
|
const response = await fetch(`${ENDPOINTS.CONVERSATION(conversationId)}/messages/${messageId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: data.content,
|
||||||
|
feedback: data.feedback,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`更新消息失败: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: BackendMessage = await response.json();
|
||||||
|
return transformMessage(result);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量迁移对话数据
|
||||||
|
*/
|
||||||
|
async migrateConversations(conversations: Conversation[]): Promise<{ success: number; failed: number }> {
|
||||||
|
let success = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const conversation of conversations) {
|
||||||
|
try {
|
||||||
|
await this.saveConversation(conversation);
|
||||||
|
success++;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`迁移对话失败 [${conversation.id}]:`, e);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success, failed };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
/**
|
||||||
|
* 统一请求封装
|
||||||
|
*
|
||||||
|
* 自动从 Pinia store 获取认证 token
|
||||||
|
*/
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取认证 token(从 Pinia store)
|
||||||
|
*/
|
||||||
|
function getToken(): string | null {
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
return authStore.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一的请求封装函数
|
||||||
|
*
|
||||||
|
* @param url - 请求地址
|
||||||
|
* @param options - fetch 选项
|
||||||
|
* @returns Response 对象
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // GET 请求
|
||||||
|
* const response = await apiRequest('/api/users');
|
||||||
|
* const data = await response.json();
|
||||||
|
*
|
||||||
|
* // POST 请求
|
||||||
|
* const response = await apiRequest('/api/users', {
|
||||||
|
* method: 'POST',
|
||||||
|
* body: JSON.stringify({ name: 'John' })
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export async function apiRequest(
|
||||||
|
url: string,
|
||||||
|
options: RequestInit = {}
|
||||||
|
): Promise<Response> {
|
||||||
|
const token = getToken();
|
||||||
|
|
||||||
|
// 判断是否为 FormData,不设置 Content-Type 让浏览器自动处理
|
||||||
|
const isFormData = options.body instanceof FormData;
|
||||||
|
|
||||||
|
// 合并默认配置
|
||||||
|
const config: RequestInit = {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
|
||||||
|
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(url, config);
|
||||||
|
|
||||||
|
// 401 认证失败提示
|
||||||
|
if (response.status === 401) {
|
||||||
|
window.$toast?.('认证失败,请重新登录', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON 请求封装(自动解析响应)
|
||||||
|
*
|
||||||
|
* @param url - 请求地址
|
||||||
|
* @param options - fetch 选项
|
||||||
|
* @returns 解析后的 JSON 数据
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const users = await apiRequestJson<User[]>('/api/users');
|
||||||
|
*/
|
||||||
|
export async function apiRequestJson<T = unknown>(
|
||||||
|
url: string,
|
||||||
|
options: RequestInit = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const response = await apiRequest(url, options);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(errorText || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取带认证的 headers
|
||||||
|
* 用于需要手动构建 headers 的场景
|
||||||
|
*/
|
||||||
|
export function getAuthHeaders(): Record<string, string> {
|
||||||
|
const token = getToken();
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
/**
|
||||||
|
* 分享 API 服务
|
||||||
|
*/
|
||||||
|
import { getAuthHeaders } from './request';
|
||||||
|
import type {
|
||||||
|
ShareCreateRequest,
|
||||||
|
ShareCreateResponse,
|
||||||
|
ShareVerifyRequest,
|
||||||
|
ShareVerifyResponse,
|
||||||
|
ShareGetResponse,
|
||||||
|
} from '@/types/share';
|
||||||
|
|
||||||
|
const API_BASE = '/api/chat-ui';
|
||||||
|
|
||||||
|
export const shareApi = {
|
||||||
|
/**
|
||||||
|
* 创建分享
|
||||||
|
*/
|
||||||
|
async createShare(request: ShareCreateRequest): Promise<ShareCreateResponse> {
|
||||||
|
const response = await fetch(`${API_BASE}/shares`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...getAuthHeaders(),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
throw new Error(error || `创建分享失败: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取分享基本信息(不含内容)
|
||||||
|
*/
|
||||||
|
async getShare(shareId: string): Promise<ShareGetResponse> {
|
||||||
|
const response = await fetch(`${API_BASE}/shares/${shareId}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
throw new Error('分享不存在或已过期');
|
||||||
|
}
|
||||||
|
throw new Error(`获取分享失败: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证密码并获取分享内容
|
||||||
|
*/
|
||||||
|
async verifyShare(shareId: string, request: ShareVerifyRequest): Promise<ShareVerifyResponse> {
|
||||||
|
const response = await fetch(`${API_BASE}/shares/${shareId}/verify`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
throw new Error(error || `验证失败: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
/**
|
||||||
|
* 用户认证状态管理
|
||||||
|
*/
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import type { UserInfo } from '@/types/chat';
|
||||||
|
|
||||||
|
// MARK: dev 默认 token(当 URL 无 token 参数时使用)
|
||||||
|
const DEV_DEFAULT_TOKEN = '';
|
||||||
|
|
||||||
|
// 认证接口返回格式
|
||||||
|
interface AuthResponse {
|
||||||
|
code: string;
|
||||||
|
msg: string;
|
||||||
|
success: boolean;
|
||||||
|
timestamp: number;
|
||||||
|
data: UserInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 认证接口
|
||||||
|
const AUTH_CHECK_URL = '/api/auth/check/checkTokenRn';
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
// 状态
|
||||||
|
const token = ref<string | null>(null);
|
||||||
|
const user = ref<UserInfo | null>(null);
|
||||||
|
const isInitialized = ref(false);
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const isAuthenticated = computed(() => !!token.value);
|
||||||
|
const userId = computed(() => user.value?.username || null); // username 用于 OSS 路径和数据库 user_id
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 token 并获取用户信息
|
||||||
|
*/
|
||||||
|
async function checkToken(tokenToCheck: string): Promise<UserInfo | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${AUTH_CHECK_URL}/${tokenToCheck}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data: AuthResponse = await response.json();
|
||||||
|
|
||||||
|
|
||||||
|
if (data.success && data.data) {
|
||||||
|
return data.data;
|
||||||
|
}else{
|
||||||
|
window.$toast?.('[Auth] Token 验证失败:Token无效');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
|
||||||
|
console.error('[Auth] Token 验证失败:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化 - 从 URL 参数获取 token,验证后设置用户信息
|
||||||
|
*/
|
||||||
|
async function init() {
|
||||||
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
|
const urlToken = searchParams.get('token');
|
||||||
|
|
||||||
|
// 获取 token:URL > localStorage > 默认值
|
||||||
|
const tokenValue = urlToken
|
||||||
|
|| localStorage.getItem('DEV_DEFAULT_TOKEN')
|
||||||
|
|| DEV_DEFAULT_TOKEN;
|
||||||
|
|
||||||
|
if (!tokenValue) {
|
||||||
|
isInitialized.value = true;
|
||||||
|
window.$toast?.('未登录,请先登录', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 token
|
||||||
|
const userInfo = await checkToken(tokenValue);
|
||||||
|
|
||||||
|
if (userInfo) {
|
||||||
|
|
||||||
|
token.value = tokenValue;
|
||||||
|
user.value = userInfo;
|
||||||
|
} else {
|
||||||
|
// 验证失败,清空
|
||||||
|
token.value = null;
|
||||||
|
user.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
isInitialized.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置用户信息
|
||||||
|
*/
|
||||||
|
function setUser(userInfo: UserInfo) {
|
||||||
|
user.value = userInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取认证 header
|
||||||
|
*/
|
||||||
|
function getAuthHeader(): Record<string, string> {
|
||||||
|
if (token.value) {
|
||||||
|
return { Authorization: `Bearer ${token.value}` };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化(不等待,让调用方通过 isInitialized 判断)
|
||||||
|
init();
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
token,
|
||||||
|
user,
|
||||||
|
isAuthenticated,
|
||||||
|
userId,
|
||||||
|
isInitialized,
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
setUser,
|
||||||
|
getAuthHeader,
|
||||||
|
init,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
@ -8,6 +8,7 @@ import type {
|
||||||
} from "@/types/chat";
|
} from "@/types/chat";
|
||||||
import { MessageRole } from "@/types/chat";
|
import { MessageRole } from "@/types/chat";
|
||||||
import { generateId, extractTitleFromMessage } from "@/utils/helpers";
|
import { generateId, extractTitleFromMessage } from "@/utils/helpers";
|
||||||
|
import { conversationApi } from "@/services/conversationApi";
|
||||||
|
|
||||||
export const useChatStore = defineStore("chat", () => {
|
export const useChatStore = defineStore("chat", () => {
|
||||||
// 状态
|
// 状态
|
||||||
|
|
@ -15,6 +16,17 @@ export const useChatStore = defineStore("chat", () => {
|
||||||
const currentConversationId = ref<string | null>(null);
|
const currentConversationId = ref<string | null>(null);
|
||||||
const isStreaming = ref(false);
|
const isStreaming = ref(false);
|
||||||
const streamController = ref<AbortController | null>(null);
|
const streamController = ref<AbortController | null>(null);
|
||||||
|
const isInitialized = ref(false);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
|
||||||
|
// 分享多选模式状态(对话级别)
|
||||||
|
const isSelectMode = ref(false);
|
||||||
|
const selectedConversationIds = ref<string[]>([]);
|
||||||
|
|
||||||
|
// 消息分享选择模式状态
|
||||||
|
const isMessageSelectMode = ref(false);
|
||||||
|
const selectedMessageIds = ref<string[]>([]);
|
||||||
|
const sourceConversationId = ref<string | null>(null);
|
||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
const currentConversation = computed(() => {
|
const currentConversation = computed(() => {
|
||||||
|
|
@ -40,11 +52,64 @@ export const useChatStore = defineStore("chat", () => {
|
||||||
return sortedConversations.value.filter((c) => !c.pinned && !c.archived);
|
return sortedConversations.value.filter((c) => !c.pinned && !c.archived);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 方法
|
// 选中的对话列表
|
||||||
function createConversation(): string {
|
const selectedConversations = computed(() => {
|
||||||
|
return conversations.value.filter((c) => selectedConversationIds.value.includes(c.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 选中的对话数量
|
||||||
|
const selectedCount = computed(() => selectedConversationIds.value.length);
|
||||||
|
|
||||||
|
// 选中的消息列表
|
||||||
|
const selectedMessages = computed(() => {
|
||||||
|
const conv = conversations.value.find((c) => c.id === sourceConversationId.value);
|
||||||
|
if (!conv) return [];
|
||||||
|
return conv.messages.filter((m) => selectedMessageIds.value.includes(m.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 选中的消息数量
|
||||||
|
const selectedMessageCount = computed(() => selectedMessageIds.value.length);
|
||||||
|
|
||||||
|
// 初始化方法 - 从后端 API 加载数据
|
||||||
|
async function initializeFromApi() {
|
||||||
|
if (isInitialized.value || isLoading.value) return;
|
||||||
|
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
const loadedConversations = await conversationApi.fetchConversations();
|
||||||
|
conversations.value = loadedConversations;
|
||||||
|
|
||||||
|
// 恢复当前对话 ID(从 localStorage 或选择第一个)
|
||||||
|
const storedId = localStorage.getItem("chat-current-id");
|
||||||
|
if (storedId && conversations.value.find((c) => c.id === storedId)) {
|
||||||
|
currentConversationId.value = storedId;
|
||||||
|
} else if (conversations.value.length > 0) {
|
||||||
|
currentConversationId.value = conversations.value[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
isInitialized.value = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to initialize from API:", error);
|
||||||
|
// 如果 API 失败,尝试从 localStorage 加载(降级处理)
|
||||||
|
loadFromStorage();
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存当前对话 ID 到 localStorage
|
||||||
|
function saveCurrentId() {
|
||||||
|
localStorage.setItem(
|
||||||
|
"chat-current-id",
|
||||||
|
currentConversationId.value || ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建对话
|
||||||
|
async function createConversation(title?: string): Promise<string> {
|
||||||
const newConversation: Conversation = {
|
const newConversation: Conversation = {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
title: "新对话",
|
title: title || "新对话",
|
||||||
messages: [],
|
messages: [],
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
|
|
@ -53,89 +118,171 @@ export const useChatStore = defineStore("chat", () => {
|
||||||
settings: undefined,
|
settings: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 乐观更新
|
||||||
conversations.value.unshift(newConversation);
|
conversations.value.unshift(newConversation);
|
||||||
currentConversationId.value = newConversation.id;
|
currentConversationId.value = newConversation.id;
|
||||||
saveToStorage();
|
saveCurrentId();
|
||||||
|
|
||||||
|
// 异步保存到后端
|
||||||
|
try {
|
||||||
|
const saved = await conversationApi.createConversation(newConversation);
|
||||||
|
// 更新本地数据(以防后端修改了某些字段)
|
||||||
|
const index = conversations.value.findIndex((c) => c.id === newConversation.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
conversations.value[index] = saved;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create conversation:", error);
|
||||||
|
// 回滚乐观更新
|
||||||
|
const index = conversations.value.findIndex((c) => c.id === newConversation.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
conversations.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
return newConversation.id;
|
return newConversation.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteConversation(id: string) {
|
// 删除对话
|
||||||
|
async function deleteConversation(id: string) {
|
||||||
const index = conversations.value.findIndex((c) => c.id === id);
|
const index = conversations.value.findIndex((c) => c.id === id);
|
||||||
if (index !== -1) {
|
if (index === -1) return;
|
||||||
conversations.value.splice(index, 1);
|
|
||||||
|
|
||||||
|
// 保存引用以便回滚
|
||||||
|
const deletedConversation = conversations.value[index];
|
||||||
|
|
||||||
|
// 乐观更新
|
||||||
|
conversations.value.splice(index, 1);
|
||||||
if (currentConversationId.value === id) {
|
if (currentConversationId.value === id) {
|
||||||
currentConversationId.value = conversations.value[0]?.id || null;
|
currentConversationId.value = conversations.value[0]?.id || null;
|
||||||
|
saveCurrentId();
|
||||||
}
|
}
|
||||||
|
|
||||||
saveToStorage();
|
// 异步删除
|
||||||
|
try {
|
||||||
|
await conversationApi.deleteConversation(id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete conversation:", error);
|
||||||
|
// 回滚
|
||||||
|
conversations.value.splice(index, 0, deletedConversation);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectConversation(id: string) {
|
// 选择对话
|
||||||
|
async function selectConversation(id: string) {
|
||||||
currentConversationId.value = id;
|
currentConversationId.value = id;
|
||||||
|
saveCurrentId();
|
||||||
|
|
||||||
|
// 如果对话没有加载消息,从后端加载
|
||||||
|
const conversation = conversations.value.find((c) => c.id === id);
|
||||||
|
if (conversation && (!conversation.messages || conversation.messages.length === 0)) {
|
||||||
|
try {
|
||||||
|
const loaded = await conversationApi.fetchConversation(id);
|
||||||
|
const index = conversations.value.findIndex((c) => c.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
conversations.value[index] = loaded;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load conversation:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePinConversation(id: string) {
|
// 置顶对话
|
||||||
|
async function togglePinConversation(id: string) {
|
||||||
const conversation = conversations.value.find((c) => c.id === id);
|
const conversation = conversations.value.find((c) => c.id === id);
|
||||||
if (conversation) {
|
if (!conversation) return;
|
||||||
|
|
||||||
|
// 乐观更新
|
||||||
conversation.pinned = !conversation.pinned;
|
conversation.pinned = !conversation.pinned;
|
||||||
saveToStorage();
|
|
||||||
|
// 异步保存
|
||||||
|
try {
|
||||||
|
await conversationApi.updateConversation(id, { pinned: conversation.pinned });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to toggle pin:", error);
|
||||||
|
// 回滚
|
||||||
|
conversation.pinned = !conversation.pinned;
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renameConversation(id: string, newTitle: string) {
|
// 重命名对话
|
||||||
|
async function renameConversation(id: string, newTitle: string) {
|
||||||
const conversation = conversations.value.find((c) => c.id === id);
|
const conversation = conversations.value.find((c) => c.id === id);
|
||||||
if (conversation) {
|
if (!conversation) return;
|
||||||
|
|
||||||
|
const oldTitle = conversation.title;
|
||||||
conversation.title = newTitle;
|
conversation.title = newTitle;
|
||||||
conversation.updatedAt = Date.now();
|
conversation.updatedAt = Date.now();
|
||||||
saveToStorage();
|
|
||||||
|
// 异步保存
|
||||||
|
try {
|
||||||
|
await conversationApi.updateConversation(id, { title: newTitle });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to rename conversation:", error);
|
||||||
|
// 回滚
|
||||||
|
conversation.title = oldTitle;
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateConversationSettings(
|
// 更新对话设置
|
||||||
|
async function updateConversationSettings(
|
||||||
id: string,
|
id: string,
|
||||||
convSettings: ConversationSettings,
|
convSettings: ConversationSettings
|
||||||
) {
|
) {
|
||||||
const conversation = conversations.value.find((c) => c.id === id);
|
const conversation = conversations.value.find((c) => c.id === id);
|
||||||
if (conversation) {
|
if (!conversation) return;
|
||||||
|
|
||||||
|
const oldSettings = conversation.settings;
|
||||||
conversation.settings = { ...conversation.settings, ...convSettings };
|
conversation.settings = { ...conversation.settings, ...convSettings };
|
||||||
conversation.updatedAt = Date.now();
|
conversation.updatedAt = Date.now();
|
||||||
saveToStorage();
|
|
||||||
|
// 异步保存
|
||||||
|
try {
|
||||||
|
await conversationApi.updateConversation(id, { settings: conversation.settings });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update settings:", error);
|
||||||
|
// 回滚
|
||||||
|
conversation.settings = oldSettings;
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addMessage(
|
// 添加消息
|
||||||
|
async function addMessage(
|
||||||
role: MessageRole,
|
role: MessageRole,
|
||||||
content: MessageContent,
|
content: MessageContent,
|
||||||
conversationId?: string,
|
conversationId?: string
|
||||||
): Message {
|
): Promise<Message> {
|
||||||
const targetId = conversationId || currentConversationId.value;
|
let targetId = conversationId || currentConversationId.value;
|
||||||
|
|
||||||
if (!targetId) {
|
if (!targetId) {
|
||||||
createConversation();
|
await createConversation();
|
||||||
|
targetId = currentConversationId.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const conversation = conversations.value.find(
|
const conversation = conversations.value.find((c) => c.id === targetId);
|
||||||
(c) => c.id === (targetId || currentConversationId.value),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
throw new Error("Conversation not found");
|
throw new Error("Conversation not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const message: any = {
|
const message: Message = {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
role,
|
role,
|
||||||
content,
|
content,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
};
|
} as Message;
|
||||||
|
|
||||||
|
// 乐观更新
|
||||||
conversation.messages.push(message);
|
conversation.messages.push(message);
|
||||||
conversation.updatedAt = Date.now();
|
conversation.updatedAt = Date.now();
|
||||||
|
|
||||||
|
// 如果是第一条用户消息,更新标题
|
||||||
if (
|
if (
|
||||||
role === MessageRole.USER &&
|
role === MessageRole.USER &&
|
||||||
conversation.messages.length === 1 &&
|
conversation.messages.length === 1 &&
|
||||||
|
|
@ -144,21 +291,96 @@ export const useChatStore = defineStore("chat", () => {
|
||||||
conversation.title = extractTitleFromMessage(content.text);
|
conversation.title = extractTitleFromMessage(content.text);
|
||||||
}
|
}
|
||||||
|
|
||||||
saveToStorage();
|
// 异步保存(使用增量更新)
|
||||||
|
try {
|
||||||
|
// 确保 targetId 不为空
|
||||||
|
if (targetId) {
|
||||||
|
// 发送消息到后端,不等待完成
|
||||||
|
conversationApi.addMessage(targetId, message).catch((error) => {
|
||||||
|
console.error("Failed to save message:", error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果标题更新了,也保存标题
|
||||||
|
if (
|
||||||
|
role === MessageRole.USER &&
|
||||||
|
conversation.messages.length === 1
|
||||||
|
) {
|
||||||
|
conversationApi.updateConversation(targetId, { title: conversation.title }).catch((error) => {
|
||||||
|
console.error("Failed to update title:", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to add message:", error);
|
||||||
|
}
|
||||||
|
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMessage(messageId: string, updates: Partial<Message>) {
|
// 添加系统消息(放在消息列表开头)
|
||||||
|
async function addSystemMessage(
|
||||||
|
conversationId: string,
|
||||||
|
systemPrompt: string
|
||||||
|
): Promise<Message> {
|
||||||
|
const conversation = conversations.value.find((c) => c.id === conversationId);
|
||||||
|
if (!conversation) {
|
||||||
|
throw new Error("Conversation not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const message: Message = {
|
||||||
|
id: generateId(),
|
||||||
|
role: MessageRole.SYSTEM,
|
||||||
|
content: { type: "text" as const, text: systemPrompt },
|
||||||
|
timestamp: Date.now(),
|
||||||
|
isStreaming: false,
|
||||||
|
} as Message;
|
||||||
|
|
||||||
|
// 将系统消息插入到消息列表开头
|
||||||
|
conversation.messages.unshift(message);
|
||||||
|
conversation.updatedAt = Date.now();
|
||||||
|
|
||||||
|
// 异步保存
|
||||||
|
try {
|
||||||
|
await conversationApi.addMessage(conversationId, message);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save system message:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新消息
|
||||||
|
async function updateMessage(messageId: string, updates: Partial<Message>) {
|
||||||
const conversation = currentConversation.value;
|
const conversation = currentConversation.value;
|
||||||
if (!conversation) return;
|
if (!conversation) {
|
||||||
|
console.warn("[updateMessage] No current conversation");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const message = conversation.messages.find((m) => m.id === messageId);
|
const message = conversation.messages.find((m) => m.id === messageId);
|
||||||
if (message) {
|
if (!message) {
|
||||||
|
console.warn("[updateMessage] Message not found:", messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 乐观更新
|
||||||
Object.assign(message, updates);
|
Object.assign(message, updates);
|
||||||
saveToStorage();
|
|
||||||
|
// 异步保存
|
||||||
|
try {
|
||||||
|
console.log("[updateMessage] Saving to backend:", {
|
||||||
|
conversationId: conversation.id,
|
||||||
|
messageId,
|
||||||
|
content: updates.content,
|
||||||
|
});
|
||||||
|
await conversationApi.updateMessage(conversation.id, messageId, updates);
|
||||||
|
console.log("[updateMessage] Save successful");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update message:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新消息内容(流式更新时使用,不触发 API 调用)
|
||||||
function updateMessageContent(messageId: string, text: string) {
|
function updateMessageContent(messageId: string, text: string) {
|
||||||
const conversation = currentConversation.value;
|
const conversation = currentConversation.value;
|
||||||
if (!conversation) return;
|
if (!conversation) return;
|
||||||
|
|
@ -169,24 +391,49 @@ export const useChatStore = defineStore("chat", () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setMessageFeedback(
|
// 保存整个对话(用于流式结束后)
|
||||||
|
async function saveConversation(conversationId: string) {
|
||||||
|
const conversation = conversations.value.find((c) => c.id === conversationId);
|
||||||
|
if (!conversation) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await conversationApi.updateConversation(conversationId, {
|
||||||
|
messages: conversation.messages,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save conversation:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置消息反馈
|
||||||
|
async function setMessageFeedback(
|
||||||
messageId: string,
|
messageId: string,
|
||||||
feedback: "like" | "dislike" | null,
|
feedback: "like" | "dislike" | null
|
||||||
) {
|
) {
|
||||||
const conversation = currentConversation.value;
|
const conversation = currentConversation.value;
|
||||||
if (!conversation) return;
|
if (!conversation) return;
|
||||||
|
|
||||||
const message = conversation.messages.find((m) => m.id === messageId);
|
const message = conversation.messages.find((m) => m.id === messageId);
|
||||||
if (message) {
|
if (!message) return;
|
||||||
|
|
||||||
message.feedback = {
|
message.feedback = {
|
||||||
liked: feedback === "like",
|
liked: feedback === "like",
|
||||||
disliked: feedback === "dislike",
|
disliked: feedback === "dislike",
|
||||||
copied: message.feedback?.copied,
|
copied: message.feedback?.copied,
|
||||||
};
|
};
|
||||||
saveToStorage();
|
|
||||||
|
// 异步保存
|
||||||
|
try {
|
||||||
|
await conversationApi.updateMessage(conversation.id, messageId, {
|
||||||
|
feedback: message.feedback
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save feedback:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 设置消息已复制
|
||||||
function setMessageCopied(messageId: string) {
|
function setMessageCopied(messageId: string) {
|
||||||
const conversation = currentConversation.value;
|
const conversation = currentConversation.value;
|
||||||
if (!conversation) return;
|
if (!conversation) return;
|
||||||
|
|
@ -200,11 +447,13 @@ export const useChatStore = defineStore("chat", () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 开始流式输出
|
||||||
function startStreaming() {
|
function startStreaming() {
|
||||||
isStreaming.value = true;
|
isStreaming.value = true;
|
||||||
streamController.value = new AbortController();
|
streamController.value = new AbortController();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 停止流式输出
|
||||||
function stopStreaming() {
|
function stopStreaming() {
|
||||||
isStreaming.value = false;
|
isStreaming.value = false;
|
||||||
if (streamController.value) {
|
if (streamController.value) {
|
||||||
|
|
@ -213,30 +462,23 @@ export const useChatStore = defineStore("chat", () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearConversation(id: string) {
|
// 清空对话消息
|
||||||
|
async function clearConversation(id: string) {
|
||||||
const conversation = conversations.value.find((c) => c.id === id);
|
const conversation = conversations.value.find((c) => c.id === id);
|
||||||
if (conversation) {
|
if (!conversation) return;
|
||||||
|
|
||||||
conversation.messages = [];
|
conversation.messages = [];
|
||||||
conversation.updatedAt = Date.now();
|
conversation.updatedAt = Date.now();
|
||||||
saveToStorage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveToStorage() {
|
// 异步保存
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(
|
await conversationApi.updateConversation(id, { messages: [] });
|
||||||
"chat-conversations",
|
} catch (error) {
|
||||||
JSON.stringify(conversations.value),
|
console.error("Failed to clear conversation:", error);
|
||||||
);
|
|
||||||
localStorage.setItem(
|
|
||||||
"chat-current-id",
|
|
||||||
currentConversationId.value || "",
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to save to storage:", e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 降级:从 localStorage 加载(仅在 API 不可用时使用)
|
||||||
function loadFromStorage() {
|
function loadFromStorage() {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem("chat-conversations");
|
const stored = localStorage.getItem("chat-conversations");
|
||||||
|
|
@ -255,17 +497,140 @@ export const useChatStore = defineStore("chat", () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadFromStorage();
|
// 保存到 localStorage(降级模式使用)
|
||||||
|
function saveToStorage() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
"chat-conversations",
|
||||||
|
JSON.stringify(conversations.value)
|
||||||
|
);
|
||||||
|
localStorage.setItem(
|
||||||
|
"chat-current-id",
|
||||||
|
currentConversationId.value || ""
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to save to storage:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 分享多选模式方法 ==========
|
||||||
|
|
||||||
|
// 切换选择模式
|
||||||
|
function toggleSelectMode() {
|
||||||
|
isSelectMode.value = !isSelectMode.value;
|
||||||
|
if (!isSelectMode.value) {
|
||||||
|
clearSelection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 进入选择模式
|
||||||
|
function enterSelectMode() {
|
||||||
|
isSelectMode.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 退出选择模式
|
||||||
|
function exitSelectMode() {
|
||||||
|
isSelectMode.value = false;
|
||||||
|
clearSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换对话选择状态
|
||||||
|
function toggleConversationSelection(id: string) {
|
||||||
|
const index = selectedConversationIds.value.indexOf(id);
|
||||||
|
if (index === -1) {
|
||||||
|
selectedConversationIds.value.push(id);
|
||||||
|
} else {
|
||||||
|
selectedConversationIds.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全选
|
||||||
|
function selectAllConversations() {
|
||||||
|
selectedConversationIds.value = conversations.value
|
||||||
|
.filter((c) => !c.archived)
|
||||||
|
.map((c) => c.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除选择
|
||||||
|
function clearSelection() {
|
||||||
|
selectedConversationIds.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查对话是否被选中
|
||||||
|
function isConversationSelected(id: string): boolean {
|
||||||
|
return selectedConversationIds.value.includes(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 消息分享选择模式方法 ==========
|
||||||
|
|
||||||
|
// 进入消息选择模式
|
||||||
|
function enterMessageSelectMode(conversationId: string, initialMessageId?: string) {
|
||||||
|
isMessageSelectMode.value = true;
|
||||||
|
sourceConversationId.value = conversationId;
|
||||||
|
selectedMessageIds.value = initialMessageId ? [initialMessageId] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 退出消息选择模式
|
||||||
|
function exitMessageSelectMode() {
|
||||||
|
isMessageSelectMode.value = false;
|
||||||
|
sourceConversationId.value = null;
|
||||||
|
selectedMessageIds.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换消息选择状态
|
||||||
|
function toggleMessageSelection(messageId: string) {
|
||||||
|
const index = selectedMessageIds.value.indexOf(messageId);
|
||||||
|
if (index === -1) {
|
||||||
|
selectedMessageIds.value.push(messageId);
|
||||||
|
} else {
|
||||||
|
selectedMessageIds.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全选当前对话消息
|
||||||
|
function selectAllMessages() {
|
||||||
|
const conv = conversations.value.find((c) => c.id === sourceConversationId.value);
|
||||||
|
if (conv) {
|
||||||
|
selectedMessageIds.value = conv.messages
|
||||||
|
.filter((m) => m.role !== MessageRole.SYSTEM)
|
||||||
|
.map((m) => m.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查消息是否被选中
|
||||||
|
function isMessageSelected(messageId: string): boolean {
|
||||||
|
return selectedMessageIds.value.includes(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
initializeFromApi();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
// 状态
|
||||||
conversations,
|
conversations,
|
||||||
currentConversationId,
|
currentConversationId,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
streamController,
|
streamController,
|
||||||
|
isInitialized,
|
||||||
|
isLoading,
|
||||||
|
// 分享多选模式状态
|
||||||
|
isSelectMode,
|
||||||
|
selectedConversationIds,
|
||||||
|
// 消息分享选择模式状态
|
||||||
|
isMessageSelectMode,
|
||||||
|
selectedMessageIds,
|
||||||
|
sourceConversationId,
|
||||||
|
selectedMessages,
|
||||||
|
selectedMessageCount,
|
||||||
|
// 计算属性
|
||||||
currentConversation,
|
currentConversation,
|
||||||
sortedConversations,
|
sortedConversations,
|
||||||
pinnedConversations,
|
pinnedConversations,
|
||||||
recentConversations,
|
recentConversations,
|
||||||
|
selectedConversations,
|
||||||
|
selectedCount,
|
||||||
|
// 方法
|
||||||
|
initializeFromApi,
|
||||||
createConversation,
|
createConversation,
|
||||||
deleteConversation,
|
deleteConversation,
|
||||||
selectConversation,
|
selectConversation,
|
||||||
|
|
@ -273,13 +638,30 @@ export const useChatStore = defineStore("chat", () => {
|
||||||
renameConversation,
|
renameConversation,
|
||||||
updateConversationSettings,
|
updateConversationSettings,
|
||||||
addMessage,
|
addMessage,
|
||||||
|
addSystemMessage,
|
||||||
updateMessage,
|
updateMessage,
|
||||||
updateMessageContent,
|
updateMessageContent,
|
||||||
|
saveConversation,
|
||||||
setMessageFeedback,
|
setMessageFeedback,
|
||||||
setMessageCopied,
|
setMessageCopied,
|
||||||
startStreaming,
|
startStreaming,
|
||||||
stopStreaming,
|
stopStreaming,
|
||||||
clearConversation,
|
clearConversation,
|
||||||
loadFromStorage,
|
loadFromStorage,
|
||||||
|
saveToStorage,
|
||||||
|
// 分享多选模式方法
|
||||||
|
toggleSelectMode,
|
||||||
|
enterSelectMode,
|
||||||
|
exitSelectMode,
|
||||||
|
toggleConversationSelection,
|
||||||
|
selectAllConversations,
|
||||||
|
clearSelection,
|
||||||
|
isConversationSelected,
|
||||||
|
// 消息分享选择模式方法
|
||||||
|
enterMessageSelectMode,
|
||||||
|
exitMessageSelectMode,
|
||||||
|
toggleMessageSelection,
|
||||||
|
selectAllMessages,
|
||||||
|
isMessageSelected,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
@ -2,6 +2,14 @@ import { defineStore } from "pinia";
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import type { AppSettings, AIModel } from "@/types/chat";
|
import type { AppSettings, AIModel } from "@/types/chat";
|
||||||
|
|
||||||
|
// 分享结果类型
|
||||||
|
export interface ShareResult {
|
||||||
|
shareId: string;
|
||||||
|
shareUrl: string;
|
||||||
|
password: string;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const useSettingsStore = defineStore("settings", () => {
|
export const useSettingsStore = defineStore("settings", () => {
|
||||||
// 默认设置
|
// 默认设置
|
||||||
const defaultSettings: AppSettings = {
|
const defaultSettings: AppSettings = {
|
||||||
|
|
@ -11,12 +19,12 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||||
fontSize: "medium",
|
fontSize: "medium",
|
||||||
|
|
||||||
// 对话设置
|
// 对话设置
|
||||||
sendOnEnter: false,
|
sendOnEnter: true,
|
||||||
showTimestamp: true,
|
showTimestamp: true,
|
||||||
compactMode: false,
|
compactMode: false,
|
||||||
|
|
||||||
// AI 默认设置
|
// AI 默认设置
|
||||||
defaultModel: "glm-4.6",
|
defaultModel: "glm-4.6v",
|
||||||
defaultTemperature: 0.7,
|
defaultTemperature: 0.7,
|
||||||
defaultMaxTokens: 4096,
|
defaultMaxTokens: 4096,
|
||||||
defaultSystemPrompt: "你是一个有帮助的 AI 助手。",
|
defaultSystemPrompt: "你是一个有帮助的 AI 助手。",
|
||||||
|
|
@ -86,6 +94,11 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||||
const showSettingsModal = ref(false);
|
const showSettingsModal = ref(false);
|
||||||
const showConversationSettingsModal = ref(false);
|
const showConversationSettingsModal = ref(false);
|
||||||
|
|
||||||
|
// 分享相关状态
|
||||||
|
const showShareModal = ref(false);
|
||||||
|
const showShareResultModal = ref(false);
|
||||||
|
const shareResult = ref<ShareResult | null>(null);
|
||||||
|
|
||||||
// 主题相关
|
// 主题相关
|
||||||
function applyTheme(theme: AppSettings["theme"]) {
|
function applyTheme(theme: AppSettings["theme"]) {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
|
|
@ -175,6 +188,31 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||||
showConversationSettingsModal.value = false;
|
showConversationSettingsModal.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 分享模态框
|
||||||
|
function openShareModal() {
|
||||||
|
showShareModal.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeShareModal() {
|
||||||
|
showShareModal.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openShareResultModal() {
|
||||||
|
showShareResultModal.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeShareResultModal() {
|
||||||
|
showShareResultModal.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setShareResult(result: ShareResult) {
|
||||||
|
shareResult.value = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearShareResult() {
|
||||||
|
shareResult.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
// 更新设置
|
// 更新设置
|
||||||
function updateSettings(updates: Partial<AppSettings>) {
|
function updateSettings(updates: Partial<AppSettings>) {
|
||||||
Object.assign(settings.value, updates);
|
Object.assign(settings.value, updates);
|
||||||
|
|
@ -298,6 +336,10 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||||
showSettingsModal,
|
showSettingsModal,
|
||||||
showConversationSettingsModal,
|
showConversationSettingsModal,
|
||||||
availableModels,
|
availableModels,
|
||||||
|
// 分享相关状态
|
||||||
|
showShareModal,
|
||||||
|
showShareResultModal,
|
||||||
|
shareResult,
|
||||||
|
|
||||||
// 方法
|
// 方法
|
||||||
toggleTheme,
|
toggleTheme,
|
||||||
|
|
@ -313,6 +355,13 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||||
closeSettingsModal,
|
closeSettingsModal,
|
||||||
openConversationSettingsModal,
|
openConversationSettingsModal,
|
||||||
closeConversationSettingsModal,
|
closeConversationSettingsModal,
|
||||||
|
// 分享模态框方法
|
||||||
|
openShareModal,
|
||||||
|
closeShareModal,
|
||||||
|
openShareResultModal,
|
||||||
|
closeShareResultModal,
|
||||||
|
setShareResult,
|
||||||
|
clearShareResult,
|
||||||
updateSettings,
|
updateSettings,
|
||||||
resetSettings,
|
resetSettings,
|
||||||
exportSettings,
|
exportSettings,
|
||||||
|
|
|
||||||
|
|
@ -146,3 +146,13 @@ export interface AIModel {
|
||||||
provider: string;
|
provider: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 用户信息
|
||||||
|
export interface UserInfo {
|
||||||
|
id: string;
|
||||||
|
username?: string;
|
||||||
|
nickname?: string;
|
||||||
|
email?: string;
|
||||||
|
avatar?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
import type { Conversation } from "./chat";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分享数据结构
|
||||||
|
*/
|
||||||
|
export interface Share {
|
||||||
|
id: string; // 分享ID
|
||||||
|
conversationIds: string[]; // 分享的对话ID列表
|
||||||
|
conversations: Conversation[]; // 完整对话数据(快照)
|
||||||
|
createdAt: number; // 创建时间
|
||||||
|
expiresAt: number; // 过期时间
|
||||||
|
viewCount: number; // 访问次数
|
||||||
|
hasPassword: boolean; // 是否有密码保护
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建分享请求
|
||||||
|
*/
|
||||||
|
export interface ShareCreateRequest {
|
||||||
|
conversationIds: string[]; // 要分享的对话ID列表
|
||||||
|
messages?: MessageData[]; // 直接传递消息数据(消息分享模式)
|
||||||
|
passwordHash: string; // 密码哈希值
|
||||||
|
expiresIn?: number; // 过期时间(秒),默认7天
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息数据(用于分享)
|
||||||
|
*/
|
||||||
|
export interface MessageData {
|
||||||
|
id: string;
|
||||||
|
role: string;
|
||||||
|
content: any;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建分享响应
|
||||||
|
*/
|
||||||
|
export interface ShareCreateResponse {
|
||||||
|
id: string; // 分享ID
|
||||||
|
shareUrl: string; // 分享链接
|
||||||
|
expiresAt: number; // 过期时间
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证密码请求
|
||||||
|
*/
|
||||||
|
export interface ShareVerifyRequest {
|
||||||
|
passwordHash: string; // 密码哈希值
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证密码响应
|
||||||
|
*/
|
||||||
|
export interface ShareVerifyResponse {
|
||||||
|
valid: boolean; // 密码是否正确
|
||||||
|
share?: Share; // 分享数据(验证成功时返回)
|
||||||
|
error?: string; // 错误信息
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取分享响应
|
||||||
|
*/
|
||||||
|
export interface ShareGetResponse {
|
||||||
|
id: string;
|
||||||
|
hasPassword: boolean;
|
||||||
|
expiresAt: number;
|
||||||
|
isExpired: boolean;
|
||||||
|
conversationCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分享限制配置
|
||||||
|
*/
|
||||||
|
export const SHARE_LIMITS = {
|
||||||
|
MAX_CONVERSATIONS: 10, // 单次分享最多对话数
|
||||||
|
MAX_MESSAGES_PER_CONVERSATION: 100, // 单个对话最多消息数
|
||||||
|
DEFAULT_EXPIRE_SECONDS: 7 * 24 * 60 * 60, // 默认过期时间:7天
|
||||||
|
} as const;
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
/**
|
||||||
|
* 密码哈希工具
|
||||||
|
* 使用 SHA-256 算法对密码进行哈希处理
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 SHA-256 对字符串进行哈希处理
|
||||||
|
* @param text 要哈希的文本
|
||||||
|
* @returns 哈希后的十六进制字符串
|
||||||
|
*/
|
||||||
|
export async function sha256(text: string): Promise<string> {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(text);
|
||||||
|
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||||
|
const hashHex = hashArray
|
||||||
|
.map((b) => b.toString(16).padStart(2, "0"))
|
||||||
|
.join("");
|
||||||
|
return hashHex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 哈希密码
|
||||||
|
* @param password 原始密码
|
||||||
|
* @returns 哈希后的密码
|
||||||
|
*/
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
// 添加前缀盐值增加安全性
|
||||||
|
const saltedPassword = `share_${password}_chat`;
|
||||||
|
return sha256(saltedPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证密码
|
||||||
|
* @param password 用户输入的密码
|
||||||
|
* @param storedHash 存储的哈希值
|
||||||
|
* @returns 是否匹配
|
||||||
|
*/
|
||||||
|
export async function verifyPassword(
|
||||||
|
password: string,
|
||||||
|
storedHash: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const hash = await hashPassword(password);
|
||||||
|
return hash === storedHash;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
/**
|
||||||
|
* 数据迁移工具
|
||||||
|
*
|
||||||
|
* 将 localStorage 中的旧对话数据迁移到后端 SQLite
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { conversationApi } from '@/services/conversationApi';
|
||||||
|
import type { Conversation } from '@/types/chat';
|
||||||
|
|
||||||
|
const OLD_CONVERSATIONS_KEY = 'chat-conversations';
|
||||||
|
const MIGRATION_FLAG_KEY = 'chat-migration-completed';
|
||||||
|
|
||||||
|
export interface MigrationResult {
|
||||||
|
success: boolean;
|
||||||
|
total: number;
|
||||||
|
migrated: number;
|
||||||
|
failed: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否已完成迁移
|
||||||
|
*/
|
||||||
|
export function isMigrationCompleted(): boolean {
|
||||||
|
return localStorage.getItem(MIGRATION_FLAG_KEY) === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记迁移已完成
|
||||||
|
*/
|
||||||
|
function markMigrationCompleted() {
|
||||||
|
localStorage.setItem(MIGRATION_FLAG_KEY, 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 localStorage 读取旧数据
|
||||||
|
*/
|
||||||
|
function getOldConversations(): Conversation[] {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(OLD_CONVERSATIONS_KEY);
|
||||||
|
if (stored) {
|
||||||
|
return JSON.parse(stored);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to read old conversations:', e);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 迁移单个对话
|
||||||
|
*/
|
||||||
|
async function migrateConversation(conversation: Conversation): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await conversationApi.saveConversation(conversation);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to migrate conversation ${conversation.id}:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行迁移
|
||||||
|
*/
|
||||||
|
export async function migrateData(): Promise<MigrationResult> {
|
||||||
|
// 检查是否已迁移
|
||||||
|
if (isMigrationCompleted()) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
total: 0,
|
||||||
|
migrated: 0,
|
||||||
|
failed: 0,
|
||||||
|
message: '迁移已完成,无需重复执行',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取旧数据
|
||||||
|
const oldConversations = getOldConversations();
|
||||||
|
|
||||||
|
if (oldConversations.length === 0) {
|
||||||
|
markMigrationCompleted();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
total: 0,
|
||||||
|
migrated: 0,
|
||||||
|
failed: 0,
|
||||||
|
message: '没有需要迁移的数据',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 迁移数据
|
||||||
|
let migrated = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const conversation of oldConversations) {
|
||||||
|
const success = await migrateConversation(conversation);
|
||||||
|
if (success) {
|
||||||
|
migrated++;
|
||||||
|
} else {
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 迁移完成后清理
|
||||||
|
if (migrated === oldConversations.length) {
|
||||||
|
// 全部成功,清理旧数据
|
||||||
|
localStorage.removeItem(OLD_CONVERSATIONS_KEY);
|
||||||
|
markMigrationCompleted();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: failed === 0,
|
||||||
|
total: oldConversations.length,
|
||||||
|
migrated,
|
||||||
|
failed,
|
||||||
|
message: failed === 0
|
||||||
|
? `成功迁移 ${migrated} 条对话`
|
||||||
|
: `迁移完成:成功 ${migrated} 条,失败 ${failed} 条`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理 localStorage 中的旧数据
|
||||||
|
*/
|
||||||
|
export function cleanupOldData() {
|
||||||
|
localStorage.removeItem(OLD_CONVERSATIONS_KEY);
|
||||||
|
// 保留 chat-current-id,因为它仍在使用
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出迁移状态
|
||||||
|
*/
|
||||||
|
export function getMigrationStatus() {
|
||||||
|
return {
|
||||||
|
completed: isMigrationCompleted(),
|
||||||
|
hasOldData: localStorage.getItem(OLD_CONVERSATIONS_KEY) !== null,
|
||||||
|
oldDataCount: getOldConversations().length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
<template>
|
||||||
|
<div class="home-view">
|
||||||
|
<!-- 侧边栏 -->
|
||||||
|
<ChatSidebar />
|
||||||
|
|
||||||
|
<!-- 主内容区 -->
|
||||||
|
<ChatMain ref="chatMainRef" @toggle-sidebar="toggleSidebar" />
|
||||||
|
|
||||||
|
<!-- 模态框 -->
|
||||||
|
<SearchModal />
|
||||||
|
<ShortcutsModal />
|
||||||
|
<SettingsModal />
|
||||||
|
<ConversationSettingsModal />
|
||||||
|
<ShareModal />
|
||||||
|
<ShareResultModal />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useChatStore } from '@/stores/chat'
|
||||||
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
|
import { useKeyboard, getDefaultShortcuts } from '@/composables/useKeyboard'
|
||||||
|
import ChatSidebar from '@/components/sidebar/ChatSidebar.vue'
|
||||||
|
import ChatMain from '@/components/chat/ChatMain.vue'
|
||||||
|
import SearchModal from '@/components/modals/SearchModal.vue'
|
||||||
|
import ShortcutsModal from '@/components/modals/ShortcutsModal.vue'
|
||||||
|
import SettingsModal from '@/components/modals/SettingsModal.vue'
|
||||||
|
import ConversationSettingsModal from '@/components/modals/ConversationSettingsModal.vue'
|
||||||
|
import ShareModal from '@/components/modals/ShareModal.vue'
|
||||||
|
import ShareResultModal from '@/components/modals/ShareResultModal.vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const chatStore = useChatStore()
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
|
||||||
|
const chatMainRef = ref<InstanceType<typeof ChatMain> | null>(null)
|
||||||
|
|
||||||
|
function toggleSidebar() {
|
||||||
|
settingsStore.toggleSidebar()
|
||||||
|
}
|
||||||
|
|
||||||
|
function newChat() {
|
||||||
|
chatStore.createConversation()
|
||||||
|
window.$toast?.('已创建新对话', 'success')
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusInput() {
|
||||||
|
chatMainRef.value?.focusInput()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 快捷键
|
||||||
|
useKeyboard(
|
||||||
|
getDefaultShortcuts({
|
||||||
|
newChat,
|
||||||
|
toggleSidebar,
|
||||||
|
focusInput,
|
||||||
|
sendMessage: () => {},
|
||||||
|
cancelStream: () => {
|
||||||
|
if (chatStore.isStreaming) {
|
||||||
|
chatStore.stopStreaming()
|
||||||
|
window.$toast?.('已停止生成', 'info')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toggleTheme: () => {
|
||||||
|
settingsStore.toggleTheme()
|
||||||
|
window.$toast?.(`主题已切换`, 'success')
|
||||||
|
},
|
||||||
|
showShortcuts: () => {
|
||||||
|
settingsStore.openShortcutsModal()
|
||||||
|
},
|
||||||
|
searchConversations: () => {
|
||||||
|
settingsStore.openSearchModal()
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 初始化认证
|
||||||
|
authStore.init()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.home-view {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,566 @@
|
||||||
|
<template>
|
||||||
|
<div class="share-view" :class="{ dark: isDark }">
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<div v-if="isLoading" class="loading-state">
|
||||||
|
<Loader2 :size="32" class="spin" />
|
||||||
|
<span>加载中...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 错误状态 -->
|
||||||
|
<div v-else-if="error" class="error-state">
|
||||||
|
<AlertCircle :size="48" />
|
||||||
|
<p class="error-message">{{ error }}</p>
|
||||||
|
<button class="retry-btn" @click="loadShareInfo">重试</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 密码验证 -->
|
||||||
|
<div v-else-if="!isVerified" class="password-verify">
|
||||||
|
<div class="verify-container">
|
||||||
|
<div class="verify-icon">
|
||||||
|
<Lock :size="48" />
|
||||||
|
</div>
|
||||||
|
<h2 class="verify-title">查看分享内容</h2>
|
||||||
|
<p class="verify-hint">请输入访问密码查看分享内容</p>
|
||||||
|
|
||||||
|
<div v-if="shareInfo" class="share-info">
|
||||||
|
<span class="info-item">
|
||||||
|
<MessageSquare :size="14" />
|
||||||
|
{{ shareInfo.conversationCount }} 个对话
|
||||||
|
</span>
|
||||||
|
<span v-if="shareInfo.isExpired" class="info-item expired">
|
||||||
|
<AlertCircle :size="14" />
|
||||||
|
已过期
|
||||||
|
</span>
|
||||||
|
<span v-else class="info-item">
|
||||||
|
<Clock :size="14" />
|
||||||
|
{{ formatExpiresAt }} 过期
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="password-input-wrapper">
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
:type="showPassword ? 'text' : 'password'"
|
||||||
|
class="password-input"
|
||||||
|
placeholder="请输入访问密码"
|
||||||
|
@keydown.enter="handleVerify"
|
||||||
|
/>
|
||||||
|
<button class="toggle-visibility" @click="showPassword = !showPassword">
|
||||||
|
<Eye v-if="!showPassword" :size="18" />
|
||||||
|
<EyeOff v-else :size="18" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="verifyError" class="verify-error">{{ verifyError }}</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="verify-btn"
|
||||||
|
:disabled="!password || isVerifying"
|
||||||
|
@click="handleVerify"
|
||||||
|
>
|
||||||
|
{{ isVerifying ? '验证中...' : '查看内容' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分享内容 -->
|
||||||
|
<div v-else class="share-content">
|
||||||
|
<!-- 顶部导航栏 -->
|
||||||
|
<header class="share-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<h1 class="share-title">分享的对话</h1>
|
||||||
|
<span class="conversation-count">
|
||||||
|
共 {{ shareData?.conversations?.length || 0 }} 个对话
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- <button class="theme-toggle" @click="toggleTheme">
|
||||||
|
<Sun v-if="isDark" :size="20" />
|
||||||
|
<Moon v-else :size="20" />
|
||||||
|
</button> -->
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 对话切换标签 -->
|
||||||
|
<div v-if="shareData?.conversations && shareData.conversations.length > 1" class="conversation-tabs">
|
||||||
|
<button
|
||||||
|
v-for="(conv, index) in shareData.conversations"
|
||||||
|
:key="conv.id"
|
||||||
|
class="tab-btn"
|
||||||
|
:class="{ active: activeConversationIndex === index }"
|
||||||
|
@click="activeConversationIndex = index"
|
||||||
|
>
|
||||||
|
{{ conv.title || '未命名对话' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 消息列表 -->
|
||||||
|
<div class="message-list">
|
||||||
|
<template v-if="activeConversation">
|
||||||
|
<MessageBubble
|
||||||
|
v-for="message in visibleMessages"
|
||||||
|
:key="message.id"
|
||||||
|
:message="message"
|
||||||
|
:show-timestamp="true"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { shareApi } from '@/services/shareApi'
|
||||||
|
import { hashPassword } from '@/utils/crypto'
|
||||||
|
import { formatTimestamp } from '@/utils/helpers'
|
||||||
|
import { MessageRole } from '@/types/chat'
|
||||||
|
import type { Share, ShareGetResponse } from '@/types/share'
|
||||||
|
import MessageBubble from '@/components/message/MessageBubble.vue'
|
||||||
|
import {
|
||||||
|
Lock,
|
||||||
|
Loader2,
|
||||||
|
MessageSquare,
|
||||||
|
Clock,
|
||||||
|
AlertCircle,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
} from '@/components/icons'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
const { settings } = storeToRefs(settingsStore)
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const isVerifying = ref(false)
|
||||||
|
const isVerified = ref(false)
|
||||||
|
const password = ref('')
|
||||||
|
const showPassword = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const verifyError = ref('')
|
||||||
|
const shareInfo = ref<ShareGetResponse | null>(null)
|
||||||
|
const shareData = ref<Share | null>(null)
|
||||||
|
const activeConversationIndex = ref(0)
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const isDark = computed(() => {
|
||||||
|
if (settings.value.theme === 'system') {
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
}
|
||||||
|
return settings.value.theme === 'dark'
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatExpiresAt = computed(() => {
|
||||||
|
if (!shareInfo.value) return ''
|
||||||
|
return formatTimestamp(shareInfo.value.expiresAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeConversation = computed(() => {
|
||||||
|
return shareData.value?.conversations?.[activeConversationIndex.value]
|
||||||
|
})
|
||||||
|
|
||||||
|
const visibleMessages = computed(() => {
|
||||||
|
if (!activeConversation.value?.messages) return []
|
||||||
|
return activeConversation.value.messages.filter(
|
||||||
|
(message) => message.role !== MessageRole.SYSTEM
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
async function loadShareInfo() {
|
||||||
|
const shareId = route.params.id as string
|
||||||
|
if (!shareId) {
|
||||||
|
error.value = '无效的分享链接'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
shareInfo.value = await shareApi.getShare(shareId)
|
||||||
|
|
||||||
|
if (shareInfo.value.isExpired) {
|
||||||
|
error.value = '分享链接已过期'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有密码保护,直接验证
|
||||||
|
if (!shareInfo.value.hasPassword) {
|
||||||
|
await verifyWithPassword('')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load share info:', err)
|
||||||
|
error.value = '分享不存在或已过期'
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVerify() {
|
||||||
|
if (!password.value || isVerifying.value) return
|
||||||
|
await verifyWithPassword(password.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyWithPassword(pwd: string) {
|
||||||
|
const shareId = route.params.id as string
|
||||||
|
if (!shareId) return
|
||||||
|
|
||||||
|
isVerifying.value = true
|
||||||
|
verifyError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const passwordHash = pwd ? await hashPassword(pwd) : ''
|
||||||
|
const result = await shareApi.verifyShare(shareId, { passwordHash })
|
||||||
|
|
||||||
|
if (result.valid && result.share) {
|
||||||
|
shareData.value = result.share
|
||||||
|
isVerified.value = true
|
||||||
|
} else {
|
||||||
|
verifyError.value = '密码错误,请重试'
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to verify share:', err)
|
||||||
|
verifyError.value = '验证失败,请重试'
|
||||||
|
} finally {
|
||||||
|
isVerifying.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadShareInfo()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.share-view {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #ffffff;
|
||||||
|
|
||||||
|
&.dark {
|
||||||
|
background: #11111b;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载状态
|
||||||
|
.loading-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
gap: 16px;
|
||||||
|
color: #6b7280;
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 错误状态
|
||||||
|
.error-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
gap: 16px;
|
||||||
|
color: #6b7280;
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #ef4444;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-btn {
|
||||||
|
padding: 10px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 密码验证
|
||||||
|
.password-verify {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-icon {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
color: #f3f4f6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-hint {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-info {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 8px;
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
|
||||||
|
&.expired {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 48px 14px 16px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background: #2d2d3d;
|
||||||
|
border-color: #374151;
|
||||||
|
color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-visibility {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-error {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #ef4444;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-btn {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
padding: 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分享内容
|
||||||
|
.share-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
background: white;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background: #1e1e2e;
|
||||||
|
border-bottom-color: #2d2d3d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
color: #f3f4f6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-count {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background: #2d2d3d;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background: #374151;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
overflow-x: auto;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background: #1e1e2e;
|
||||||
|
border-bottom-color: #2d2d3d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: white;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background: #2d2d3d;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background: #374151;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-list {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"root":["./src/main.ts","./src/components/icons/index.ts","./src/composables/useKeyboard.ts","./src/services/api.ts","./src/stores/chat.ts","./src/stores/settings.ts","./src/types/chat.ts","./src/utils/helpers.ts","./src/App.vue","./src/components/chat/ChatHeader.vue","./src/components/chat/ChatMain.vue","./src/components/chat/MessageList.vue","./src/components/chat/WelcomeScreen.vue","./src/components/input/AttachmentPreview.vue","./src/components/input/ChatInput.vue","./src/components/message/CodeBlock.vue","./src/components/message/MessageActions.vue","./src/components/message/MessageBubble.vue","./src/components/message/components/EChartsContainerNode.vue","./src/components/message/components/Loading.vue","./src/components/message/components/ThinkingNode.vue","./src/components/modals/ConversationSettingsModal.vue","./src/components/modals/SearchModal.vue","./src/components/modals/SettingsModal.vue","./src/components/modals/ShortcutsModal.vue","./src/components/sidebar/ChatSidebar.vue","./src/components/sidebar/ConversationItem.vue","./src/components/ui/FormSelect.vue","./src/components/ui/FormSlider.vue","./src/components/ui/FormSwitch.vue"],"errors":true,"version":"5.9.3"}
|
|
||||||
|
|
@ -7,7 +7,7 @@ export default defineConfig({
|
||||||
plugins: [vue(), UnoCSS()],
|
plugins: [vue(), UnoCSS()],
|
||||||
|
|
||||||
// 基础路径
|
// 基础路径
|
||||||
base: "/chat-ui/",
|
base: "/",
|
||||||
|
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|
@ -18,7 +18,11 @@ export default defineConfig({
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api/chat-ui": {
|
"/api/chat-ui": {
|
||||||
target: "http://localhost:8000", // Python服务器端口
|
target: "http://localhost:8002", // Python服务器端口
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
"/api/auth": {
|
||||||
|
target: "https://sxwz.xueai.art",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
test: {
|
||||||
|
// 使用 happy-dom 作为测试环境
|
||||||
|
environment: 'happy-dom',
|
||||||
|
// 全局变量
|
||||||
|
globals: true,
|
||||||
|
// 测试文件匹配模式
|
||||||
|
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||||
|
// 测试 setup 文件
|
||||||
|
setupFiles: ['src/__tests__/setup.ts'],
|
||||||
|
// 覆盖率配置
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'json', 'html'],
|
||||||
|
include: ['src/**/*.{ts,vue}'],
|
||||||
|
exclude: [
|
||||||
|
'src/**/*.d.ts',
|
||||||
|
'src/main.ts',
|
||||||
|
'src/**/*.test.ts',
|
||||||
|
'src/**/*.spec.ts',
|
||||||
|
'src/__tests__/setup.ts',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue