Compare commits

...

54 Commits

Author SHA1 Message Date
肖应宇 b66bdaedd2 Merge branch 'feat/share' 2026-03-26 10:45:08 +08:00
肖应宇 1df9ee3cf2 fix: 移除路由 base 路径并优化图片展示布局
- Vue Router base 路径从 /chat-ui/ 改为 /
- MessageBubble 添加 readonly prop,分享页面隐藏操作栏
- 图片网格改用 inline-flex 布局,按内容宽度收缩

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 10:17:58 +08:00
肖应宇 379e033e17 fix(oss): 统一从 .env 文件读取所有配置
将 AccessKey 从系统环境变量改为从 .env 文件读取,
与其他 OSS 配置保持一致。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 09:15:33 +08:00
肖应宇 eff089c7ad fix(share): 修复分享链接路径不一致并移除对话消息截断 2026-03-25 16:41:32 +08:00
肖应宇 7b4fb72cdc feat: 消息级别分享功能
- 新增消息选择模式,支持在当前对话内多选消息分享
- MessageBubble 添加选择模式UI(复选框、选中样式)
- MessageList 添加选择操作栏(全选、取消、确认分享)
- ShareModal 支持消息分享和对话分享两种模式
- 后端分享API支持直接传递消息数据
- chat store 新增消息选择状态和方法

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 16:20:47 +08:00
肖应宇 9566c6e0c4 feat: 分享页面改为独立路由渲染
- 引入 vue-router,配置 / 和 /share/:id 两个路由
- 新增 ShareView.vue 独立页面,复用 MessageBubble 组件渲染消息
- 新增 HomeView.vue 提取主应用逻辑
- 分享链接格式改为 /chat-ui/share/{id}
- 删除废弃的 ShareViewModal.vue 对话框组件
- 清理 settingsStore 中废弃的 showShareViewModal 状态

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 15:38:38 +08:00
肖应宇 4d2caddeee feat: 分享对话功能;需要优化:不能分享单独几条对话,适用范围窄;在Dialog中展示对话,记录没有样式,很难看。 2026-03-25 15:12:50 +08:00
肖应宇 b51831dd15 build: build 通过 2026-03-12 14:23:23 +08:00
SuperManTouX 3b7a831840 Merge branch 'fix/web-search-and-css' 2026-03-12 14:13:22 +08:00
肖应宇 d08ddaadfd feat: 默认深度思考的模型就默认显示激活 2026-03-12 14:12:37 +08:00
肖应宇 0ff9f1fd74 feat: 联网搜索功能初步通过 2026-03-12 13:56:51 +08:00
肖应宇 7569e588b9 feat: ds搜索功能初步完成,但是时间不是实时的 2026-03-12 13:49:36 +08:00
肖应宇 ecff6edd61 feat: glm-z1-flash只能深度思考,隐藏深度思考按钮 2026-03-12 11:59:20 +08:00
肖应宇 bb44134e08 feat: 修改助手名字,删除黑暗模式按钮 2026-03-12 11:36:52 +08:00
肖应宇 2417a769bb feat: 自动重命名会话 2026-03-12 11:11:41 +08:00
肖应宇 fb7f72fcb9 feat: 不渲染Kexue角色提示词的首句显示。 2026-03-12 11:01:25 +08:00
肖应宇 7922227f46 debug: 更改测试的10字上限为10000上限 2026-03-12 11:00:33 +08:00
肖应宇 f5b6a499ed feat: 删除登陆toast 2026-03-12 10:58:32 +08:00
肖应宇 bdba44c0a1 feat:默认回车发送 2026-03-12 10:58:08 +08:00
肖应宇 39b5e30d4c feat: 删除对话的header 2026-03-12 10:45:02 +08:00
肖应宇 8b8f77cfcc fix: 修复错误滚动到底部的问题。删除字数显示,添加toast提示超过字数。输入框扩大按钮移动至左侧。删除侧边栏边框。 2026-03-12 10:24:22 +08:00
肖应宇 965514b7b4 build: 直接在https://llm.xueai.
art/代理而不是https://llm.xueai.art/chat-ui/
2026-03-12 09:28:58 +08:00
肖应宇 8c2bfbb214 build: 8000端口被占用,使用8002端口;禁用zai-sdk,使用的是旧版本智谱sdk 2026-03-11 17:28:36 +08:00
肖应宇 072eb95b4b Merge branch 'main' of https://git.xueai.art/xiaoyingyu/ai-chat-ui 2026-03-11 16:53:07 +08:00
肖应宇 0160e0e8fa feat: 更改为教研聊天助手;删除图标;删除助手描述 2026-03-11 16:51:03 +08:00
肖应宇 b9d302e543 feat: 更改为教研聊天助手;删除图标;删除助手描述 2026-03-11 16:49:56 +08:00
肖应宇 f3650da7e1 build: 更新服务器依赖项至最新版本
更新 requirements.txt 中的所有依赖包至最新稳定版本,确保安全性和兼容性
2026-03-11 16:47:30 +08:00
肖应宇 5232b1482f chore: 移除未使用的导入和变量
- ChatHeader.vue: 移除未使用的 Download 图标
- MessageList.vue: 移除未使用的 boxRef 变量
- ChatInput.vue: 移除未使用的 AttachmentPreview 组件导入
- MessageBubble.vue: 移除未使用的 Download 图标
- ConversationSettingsModal.vue: 移除未使用的 chatApi 导入
- SettingsModal.vue: 移除未使用的 chatApi 导入
- ChatSidebar.vue: 移除未使用的图标导入和注释代码对应的函数

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:21:28 +08:00
肖应宇 0da4e06050 Merge branch 'fix/more-model-and-prompt-lost' into main 2026-03-11 14:55:18 +08:00
肖应宇 92d5f78e1d feat: 隐藏Header的导出按钮 2026-03-11 14:09:43 +08:00
肖应宇 3471e8552f feat: 将系统提示词加入会话上下文中,系统提示词不会显示在前端。 2026-03-11 13:39:41 +08:00
肖应宇 38faeeb46d debug: 修复qwen3.5系列模型思考模式输出内容错乱的问题;修复错误显示GLM5能识别图片和文件的问题。 2026-03-11 13:38:51 +08:00
肖应宇 3c53e89b43 feat: 新增qwen3.5系列模型 2026-03-11 11:32:45 +08:00
SuperManTouX d81fb4d0a0 Merge branch 'feat/kexue-ui' 2026-03-09 17:22:16 +08:00
SuperManTouX 7b40e60872 feat: 附件列表向上, 点击删除按钮oss的附件 2026-03-09 17:21:45 +08:00
肖应宇 ffe7642165 Merge branch 'feat/kexue-ui' into main 2026-03-09 17:03:25 +08:00
肖应宇 9a141585a1 test kexue-ui 2026-03-09 16:54:32 +08:00
肖应宇 9ca89209af debug: 修复时序问题导致的获取会话列表为空的问题 2026-03-09 16:45:01 +08:00
SuperManTouX b2bbba8db6 Merge branch 'feat/openai-compatible' 2026-03-09 16:17:05 +08:00
SuperManTouX 34e0bc4de7 Merge branch 'feat/database' 2026-03-09 16:15:21 +08:00
SuperManTouX 18cedb5d14 feat: 更换模型时,重置所有选项 2026-03-09 15:53:58 +08:00
SuperManTouX 354c6f4cc7 debug: 修改错误标识可网络搜索模型的错误 2026-03-09 15:30:52 +08:00
肖应宇 244907b152 feat: 接入数据库的用户概念,写入查找都要使用user_id 2026-03-09 14:55:40 +08:00
肖应宇 3421d0db47 feat: 阻止未登录用户使用任何功能 2026-03-09 14:25:38 +08:00
肖应宇 ec96a424c4 feat: 接入平台用户验证 2026-03-09 13:51:15 +08:00
肖应宇 b6ee6c949b feat: 删除对话时要oss删除文件 2026-03-09 11:14:27 +08:00
肖应宇 fe4ee53c38 feat(storage): 更换持久存储策略为 SQLite 数据库,提升数据存储性能与结构化能力 [原因:localStorage 存储能力有限,SQLite 支持更复杂的数据结构] 2026-03-09 10:13:11 +08:00
肖应宇 633e5101a2 feat: 忽略skill相关文件 2026-03-08 17:31:13 +08:00
肖应宇 3f2c964731 feat: 扩大对话区域内边距 2026-03-08 17:28:41 +08:00
肖应宇 2706a0c732 feat: 扩大对话区域内边距 2026-03-08 17:27:28 +08:00
肖应宇 d8a6f696e7 refactor: 优化后端目录结构和代码组织
- 重构目录结构,将配置和日志模块分离到独立目录
- 创建 config/ 目录,统一管理平台配置和 API 密钥
- 创建 core/ 目录,集中管理日志系统功能
- 创建 database/ 目录,添加 SQLite 数据库初始化和管理
- 删除不必要的文件:测试文件、缓存文件、重复代码文件
- 更新所有导入路径,确保模块引用正确

主要变更:
- config.py → config/settings.py
- utils/logger.py → core/logger.py
- init_logging.py → core/init.py
- 删除 logging.conf(配置已整合到代码中)
- 新增 database/__init__.py 提供数据库连接管理

改进点:
- 更清晰的模块划分,便于维护和扩展
- 避免命名冲突(logging 模块与 Python 标准库冲突)
- 统一的配置和日志管理接口
2026-03-08 17:12:08 +08:00
SuperManTouX 547ba742b7 Merge branch 'feat/openai-compatible' 2026-03-08 12:24:43 +08:00
肖应宇 c33067fbcb chore: 添加数据库文件到 .gitignore 并清理已提交的数据库文件 2026-03-07 19:48:09 +08:00
SuperManTouX e9c563ce77 feat(database): 初始化 SQLite 数据库模块
- 创建数据库连接管理模块 (server/database/db.py)
- 添加数据库初始化和表结构定义
- 实现 CRUD 基础操作接口
2026-03-07 19:47:58 +08:00
72 changed files with 9344 additions and 2695 deletions

15
.gitignore vendored
View File

@ -16,6 +16,10 @@ uploads
.venv
__pycache__
.claude
*.db
.trae
.agent
.agents
# Editor directories and files
.vscode/*
@ -27,3 +31,14 @@ __pycache__
*.njsproj
*.sln
*.sw?
# Skills
.skills
.agent
.agents
.trae
skills-lock.json
*.db
server/data/*.db
tsconfig.tsbuildinfo

269
README.md
View File

@ -1,6 +1,6 @@
# 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
- **类型系统**: TypeScript
- **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
# 进入项目目录
cd ai-chat-ui
```
# 安装依赖
### 2. 安装前端依赖
```bash
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
```
# 构建生产版本
### 6. 构建生产版本
```bash
# 构建前端
npm run build
# 后端服务可以直接运行
python server/main.py
```
## 📋 使用说明
@ -71,11 +145,8 @@ npm run build
### 基础操作
- **新建对话**: `Ctrl+N` 快捷键或点击页面右上角 "+" 按钮
- **切换布局**: 点击页面右下角布局切换按钮
- **主题切换**: 设置面板中选择浅色/深色/跟随系统
- **搜索对话**: 使用页面顶部搜索框或快捷键 `Ctrl+K`
### 快捷键一览
@ -87,7 +158,146 @@ npm run build
| 复制当前消息 | Ctrl+C (消息 hover 时) |
| 切换布局 | 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) 文件
@ -95,10 +305,35 @@ npm run build
欢迎提交 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">
<sub>Made with ❤️ using Vue & markstream-vue</sub>
<sub>Made with ❤️ using Vue & FastAPI</sub>
</div>

1705
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,10 @@
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@microsoft/fetch-event-source": "^2.0.1",
@ -30,14 +33,18 @@
"devDependencies": {
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.1",
"@vitest/coverage-v8": "^4.1.1",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
"autoprefixer": "^10.4.24",
"happy-dom": "^20.8.8",
"postcss": "^8.5.6",
"prettier": "^3.8.1",
"sass": "^1.97.3",
"tailwindcss": "^4.1.18",
"typescript": "~5.9.3",
"vite": "^7.2.4",
"vitest": "^4.1.1",
"vue-tsc": "^3.1.4"
}
}

View File

@ -30,7 +30,7 @@ class ModelInfo:
"maxTokens": self.max_tokens,
"provider": self.provider,
"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_files": self.supports_files,
}

View File

@ -10,42 +10,49 @@ from typing import Dict, List
from fastapi.responses import JSONResponse, StreamingResponse
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()
# 支持深度思考的模型
THINKING_MODELS = {"qwen3-max", "qwen3.5-plus"}
# 需要使用多模态接口的模型qwen3.5 系列)
MULTIMODAL_API_MODELS = {"qwen3.5-plus", "qwen3.5-flash"}
# 百炼模型配置
DASHSCOPE_MODELS = [
ModelInfo(
id="qwen-max",
name="通义千问 Max",
description="最强大的模型",
id="qwen3-max",
name="Qwen3-Max",
description="千问系列效果最好的模型,适合复杂、多步骤的任务。",
max_tokens=8192,
provider="Aliyun",
supports_thinking=True,
supports_web_search=False,
supports_web_search=True,
supports_vision=False,
supports_files=False,
),
ModelInfo(
id="qwen-plus",
name="通义千问 Plus",
description="能力均衡",
id="qwen3.5-plus",
name="Qwen3.5-Plus",
description="能力均衡推理效果、成本和速度介于千问Max和千问Flash之间适合中等复杂任务。",
max_tokens=8192,
provider="Aliyun",
supports_thinking=False,
supports_web_search=False,
supports_vision=False,
supports_thinking=True,
supports_web_search=True,
supports_vision=True,
supports_files=False,
),
ModelInfo(
id="qwen-turbo",
name="通义千问 Turbo",
description="速度更快、成本更低",
id="qwen3.5-flash",
name="Qwen3.5-Flash",
description="千问系列速度最快、成本极低的模型适合简单任务。千问Flash采用灵活的阶梯定价相比千问Turbo计费更合理。",
max_tokens=8192,
provider="Aliyun",
supports_thinking=False,
supports_web_search=False,
supports_web_search=True,
supports_vision=False,
supports_files=False,
),
@ -60,17 +67,6 @@ DASHSCOPE_MODELS = [
supports_vision=True,
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"""
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]:
return DASHSCOPE_MODELS
@ -104,6 +108,7 @@ class DashScopeAdapter(BaseAdapter):
logger.info(f" - temperature: {request.temperature}")
logger.info(f" - max_tokens: {request.max_tokens}")
logger.info(f" - files: {request.files}")
logger.info(f" - deep_thinking: {request.deep_thinking}")
logger.info(
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)
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)
else:
return await self._text_chat(request)
@ -136,6 +145,9 @@ class DashScopeAdapter(BaseAdapter):
# 转换消息格式
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:
return self._stream_text_chat(messages, request)
@ -163,26 +175,104 @@ class DashScopeAdapter(BaseAdapter):
"""流式文本聊天"""
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():
from utils.helpers import generate_unique_id, get_current_timestamp
from dashscope import Generation
full_content = ""
full_reasoning = ""
chunk_count = 0
responses = Generation.call(
model=request.model,
messages=messages,
stream=True,
temperature=request.temperature,
max_tokens=request.max_tokens,
result_format="message",
)
error_occurred = False
# 打印 API 调用参数
api_params = {
"model": request.model,
"messages": messages,
"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:
if resp.status_code == 200:
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):
# DashScope 流式响应返回完整内容,计算增量
delta = content[len(full_content) :]
@ -201,6 +291,9 @@ class DashScopeAdapter(BaseAdapter):
],
}
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 = {
"id": f"chatcmpl-{generate_unique_id()}",
@ -216,6 +309,8 @@ class DashScopeAdapter(BaseAdapter):
logger.info(f"[DashScope] 流式文本响应完成:")
logger.info(f" - chunks: {chunk_count}")
logger.info(f" - content_length: {len(full_content)} 字符")
if full_reasoning:
logger.info(f" - reasoning_length: {len(full_reasoning)} 字符")
logger.info(
f" - content_preview: {full_content[:200]}..."
if len(full_content) > 200
@ -230,17 +325,64 @@ class DashScopeAdapter(BaseAdapter):
from dashscope import Generation
resp = Generation.call(
model=request.model,
messages=messages,
stream=False,
temperature=request.temperature,
max_tokens=request.max_tokens,
result_format="message",
)
# 检查是否启用深度思考
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)})")
# 构建 API 调用参数
api_params = {
"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:
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 = {
"id": f"chatcmpl-{generate_unique_id()}",
"object": "chat.completion",
@ -249,7 +391,7 @@ class DashScopeAdapter(BaseAdapter):
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": content},
"message": response_message,
"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)} 字符")
if reasoning_content:
logger.info(f" - reasoning_length: {len(reasoning_content)} 字符")
logger.info(
f" - content_preview: {content[:200]}..."
if len(content) > 200
@ -275,7 +420,10 @@ class DashScopeAdapter(BaseAdapter):
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(
status_code=500,
content={"error": f"DashScope Error: {resp.code} - {resp.message}"},
@ -288,13 +436,20 @@ class DashScopeAdapter(BaseAdapter):
dashscope.api_key = self._get_api_key()
logger.info(f"[DashScope] 开始多模态聊天...")
# 转换消息格式
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
if "qwen-" in model and "vl" not in model:
original_model = model
model = model.replace("qwen-", "qwen-vl-")
logger.info(f"[DashScope] 模型自动切换: {original_model} -> {model}")
if request.stream:
return self._stream_multimodal_chat(messages, model, request)
@ -338,6 +493,8 @@ class DashScopeAdapter(BaseAdapter):
else:
img_url = ""
logger.info(f"[DashScope] 原始图片URL: {img_url}")
# 转换 http URL 为 file:// 格式(如果是本地文件)
if img_url.startswith(("http://", "https://")):
from urllib.parse import urlparse
@ -350,42 +507,129 @@ class DashScopeAdapter(BaseAdapter):
img_url = f"file://{'/'.join(path_parts[uploads_idx:])}"
except ValueError:
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}"
logger.info(f"[DashScope] 转换后图片URL: {img_url}")
return img_url
def _stream_multimodal_chat(
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():
from utils.helpers import generate_unique_id, get_current_timestamp
from dashscope import MultiModalConversation
responses = MultiModalConversation.call(
model=model,
messages=messages,
stream=True,
max_tokens=request.max_tokens,
temperature=request.temperature,
)
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:
chunk_count += 1
if resp.status_code == 200:
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 = ""
for item in content_items:
if isinstance(item, dict) and "text" in item:
text += item["text"]
if len(text) > len(full_content):
delta = text[len(full_content) :]
full_content = text
# 多模态 API 返回的 content 是独立的片段(不是累积的),直接作为 delta
if text:
delta = text
full_content += text
data = {
"id": f"chatcmpl-{generate_unique_id()}",
@ -401,8 +645,10 @@ class DashScopeAdapter(BaseAdapter):
],
}
yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
except (KeyError, IndexError, TypeError):
pass
except (KeyError, IndexError, TypeError) as e:
logger.warning(f"[DashScope] 解析多模态响应异常: {str(e)}")
else:
logger.warning(f"[DashScope] 非200响应: status_code={resp.status_code}, code={resp.code}, message={resp.message}")
finish = {
"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 "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")
def _sync_multimodal_chat(
@ -424,22 +683,71 @@ class DashScopeAdapter(BaseAdapter):
from dashscope import MultiModalConversation
resp = MultiModalConversation.call(
model=model,
messages=messages,
stream=False,
max_tokens=request.max_tokens,
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)})")
logger.info(f"[DashScope] 开始非流式多模态响应...")
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:
try:
content_items = resp.output.choices[0]["message"]["content"]
message = resp.output.choices[0]["message"]
content_items = message.get("content", [])
text = ""
for item in content_items:
if isinstance(item, dict) and "text" in item:
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 = {
"id": f"chatcmpl-{generate_unique_id()}",
"object": "chat.completion",
@ -448,18 +756,38 @@ class DashScopeAdapter(BaseAdapter):
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": text},
"message": response_message,
"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)
except (KeyError, IndexError, TypeError) as e:
logger.error(f"[DashScope] 解析多模态响应异常: {str(e)}")
import traceback
logger.error(traceback.format_exc())
return JSONResponse(
status_code=500,
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(
status_code=500,
content={"error": f"DashScope Error: {resp.code} - {resp.message}"},

View File

@ -11,12 +11,24 @@ from typing import Dict, List, Optional
from fastapi.responses import JSONResponse, StreamingResponse
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()
# GLM 模型配置
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(
id="glm-4.6v",
name="GLM-4.6V(推荐)",
@ -24,7 +36,7 @@ GLM_MODELS = [
max_tokens=128000,
provider="ZhipuAI",
supports_thinking=True,
supports_web_search=True,
supports_web_search=False,
supports_vision=True,
supports_files=True,
),
@ -46,18 +58,18 @@ GLM_MODELS = [
max_tokens=128000,
provider="ZhipuAI",
supports_thinking=False,
supports_web_search=True,
supports_web_search=False,
supports_vision=True,
supports_files=True,
),
ModelInfo(
id="glm-z1-flash",
name="GLM-Z1 Flash",
description="深度思考推理模型",
description="深度思考推理模型,默认开启深度思考",
max_tokens=128000,
provider="ZhipuAI",
supports_thinking=True,
supports_web_search=False,
supports_web_search=True,
supports_vision=False,
supports_files=False,
),
@ -118,10 +130,10 @@ class GLMAdapter(BaseAdapter):
# 构建额外参数
extra_kwargs = {}
web_search = self._get_web_search_mode(request)
web_search_mode = get_web_search_mode(request)
if web_search:
extra_kwargs["tools"] = [self._build_web_search_tool(web_search)]
if web_search_mode:
extra_kwargs["tools"] = [build_glm_search_tool(web_search_mode)]
extra_kwargs["tool_choice"] = "auto"
# 深度思考正向选择True 时启用False 时禁用)
@ -138,6 +150,11 @@ class GLMAdapter(BaseAdapter):
logger.info(
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:
logger.info(
@ -244,46 +261,6 @@ class GLMAdapter(BaseAdapter):
"""检查模型是否支持深度思考"""
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(
self, client, messages, model, request, extra_kwargs
) -> StreamingResponse:

View File

@ -10,7 +10,8 @@ from typing import Dict, List, Optional
from fastapi.responses import JSONResponse, StreamingResponse
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()
@ -155,6 +156,21 @@ class OpenAIAdapter(BaseAdapter):
# 构建消息
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(
f" - messages: {json.dumps(messages, ensure_ascii=False, indent=2)}"
)
@ -168,6 +184,10 @@ class OpenAIAdapter(BaseAdapter):
"stream": request.stream,
}
if web_search_mode:
search_tool = build_openai_search_tool(web_search_mode)
kwargs["tools"] = [search_tool]
# DeepSeek 深度思考支持
extra_body = None
if self._provider_type == "deepseek" and request.deep_thinking:
@ -220,16 +240,26 @@ class OpenAIAdapter(BaseAdapter):
def generator():
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_reasoning = ""
chunk_count = 0
tool_calls = []
current_tool_call = None
for chunk in resp:
if not chunk.choices:
continue
full_content = ""
full_reasoning = ""
chunk_count = 0
for chunk in resp:
if chunk.choices:
chunk_count += 1
delta = chunk.choices[0].delta
# 1. 收集可能有内容/推理
delta_content = {}
if hasattr(delta, "content") and delta.content:
delta_content["content"] = delta.content
@ -238,7 +268,27 @@ class OpenAIAdapter(BaseAdapter):
delta_content["reasoning_content"] = 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 = {
"id": f"chatcmpl-{generate_unique_id()}",
"object": "chat.completion.chunk",
@ -254,27 +304,71 @@ class OpenAIAdapter(BaseAdapter):
}
yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
finish = {
"id": f"chatcmpl-{generate_unique_id()}",
"object": "chat.completion.chunk",
"created": get_current_timestamp(),
"model": kwargs["model"],
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
}
yield f"data: {json.dumps(finish, ensure_ascii=False)}\n\n"
yield "data: [DONE]\n\n"
# 检查此轮请求是否收到了完整工具调用,若是则执行搜索逻辑并追加继续请求,不再让外部函数退出
if tool_calls:
logger.info(f"[{provider_name}] 检测到流式中包含了工具调用进行拦截并处理: {json.dumps(tool_calls, ensure_ascii=False)}")
# 打印流式响应结果
logger.info(f"[{provider_name}] 流式响应完成:")
logger.info(f" - chunks: {chunk_count}")
logger.info(f" - content_length: {len(full_content)} 字符")
if full_reasoning:
logger.info(f" - reasoning_length: {len(full_reasoning)} 字符")
logger.info(
f" - content_preview: {full_content[:200]}..."
if len(full_content) > 200
else f" - content: {full_content}"
)
# 把大模型的工具调用请求也追加进去
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 = {
"id": f"chatcmpl-{generate_unique_id()}",
"object": "chat.completion.chunk",
"created": get_current_timestamp(),
"model": kwargs["model"],
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
}
yield f"data: {json.dumps(finish, ensure_ascii=False)}\n\n"
yield "data: [DONE]\n\n"
# 打印流式响应结果
logger.info(f"[{provider_name}] 流式响应完成:")
logger.info(f" - chunks: {chunk_count}")
logger.info(f" - content_length: {len(full_content)} 字符")
if full_reasoning:
logger.info(f" - reasoning_length: {len(full_reasoning)} 字符")
logger.info(
f" - content_preview: {full_content[:200]}..."
if len(full_content) > 200
else f" - content: {full_content}"
)
# 结束外层循环退出生成器
break
return StreamingResponse(generator(), media_type="text/event-stream")
@ -284,10 +378,58 @@ class OpenAIAdapter(BaseAdapter):
"""非流式聊天"""
from utils.helpers import generate_unique_id, get_current_timestamp
resp = client.chat.completions.create(**kwargs)
while True:
resp = client.chat.completions.create(**kwargs)
message = resp.choices[0].message
content = message.content or ""
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 ""
response = {
"id": f"chatcmpl-{generate_unique_id()}",
"object": "chat.completion",

119
server/adapters/plugins.py Normal file
View File

@ -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,
},
}

View File

@ -11,15 +11,15 @@ from .base import BaseAdapter
# 模型前缀到平台名称的映射
MODEL_PREFIX_MAP = {
# 智谱 GLM
"glm-": "glm",
"glm": "glm",
# 阿里云百炼Qwen 系列)
"qwen-": "dashscope",
"qwen": "dashscope",
# OpenAI
"gpt-": "openai",
"o1-": "openai",
"o3-": "openai",
"gpt": "openai",
"o1": "openai",
"o3": "openai",
# Deepseek
"deepseek-": "deepseek",
"deepseek": "deepseek",
}
# 已注册的适配器实例

View File

@ -18,7 +18,7 @@ sys.path.append(str(Path(__file__).parent.parent))
from database import get_db
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"
@ -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()
return db.list_conversations()
return db.list_conversations(user_id)
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):
"""删除对话处理器"""
"""删除对话处理器(同时删除关联的 OSS 文件)"""
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)
if success:
return {"success": True, "message": "删除成功"}
@ -74,6 +104,85 @@ async def delete_conversation_handler(conversation_id: str):
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 "已发出停止指令"
)
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)}")

View File

@ -10,7 +10,7 @@ from fastapi.responses import JSONResponse
from adapters import get_adapter, get_provider_from_model
from adapters.base import ChatCompletionRequest
from utils.logger import get_logger
from core import get_logger
logger = get_logger()

230
server/api/share_routes.py Normal file
View File

@ -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"]),
},
}

23
server/config/__init__.py Normal file
View File

@ -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",
]

41
server/core/__init__.py Normal file
View File

@ -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",
]

View File

@ -5,7 +5,7 @@
import os
from utils.logger import setup_global_logger
from .logger import setup_global_logger
def init_logging_system():

View File

@ -0,0 +1,3 @@
from .db import Database, get_db, init_db
__all__ = ["Database", "get_db", "init_db"]

520
server/database/db.py Normal file
View File

@ -0,0 +1,520 @@
"""
SQLite 数据库模块 - 会话持久化存储
提供会话和消息的 CRUD 操作支持多用户预留 user_id 字段
"""
import json
import sqlite3
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
# 数据库文件路径
DB_PATH = Path(__file__).parent.parent / "data" / "chat.db"
# 线程本地存储,确保每个线程使用独立的连接
_thread_local = threading.local()
# 全局数据库实例
_db_instance: Optional["Database"] = None
class Database:
"""SQLite 数据库管理类"""
def __init__(self, db_path: Path):
self.db_path = db_path
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._init_tables()
def _get_connection(self) -> sqlite3.Connection:
"""获取当前线程的数据库连接"""
if not hasattr(_thread_local, "connection"):
conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
conn.row_factory = sqlite3.Row
# 启用外键约束
conn.execute("PRAGMA foreign_keys = ON")
_thread_local.connection = conn
return _thread_local.connection
def _init_tables(self):
"""初始化数据库表结构"""
conn = self._get_connection()
cursor = conn.cursor()
# 创建会话表
cursor.execute("""
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
user_id TEXT DEFAULT 'default',
title TEXT DEFAULT '新对话',
created_at INTEGER,
updated_at INTEGER,
pinned INTEGER DEFAULT 0,
archived INTEGER DEFAULT 0,
settings TEXT
)
""")
# 创建消息表
cursor.execute("""
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
timestamp INTEGER,
feedback TEXT,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
)
""")
# 创建索引
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_messages_conversation
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("""
CREATE INDEX IF NOT EXISTS idx_conversations_user
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()
# ── 会话 CRUD ─────────────────────────────────────────────────────
def create_conversation(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""创建新会话"""
conn = self._get_connection()
cursor = conn.cursor()
now = int(datetime.now(timezone.utc).timestamp() * 1000)
conv_id = data.get("id") or self._generate_id()
cursor.execute(
"""
INSERT INTO conversations (id, user_id, title, created_at, updated_at, pinned, archived, settings)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
conv_id,
data.get("user_id", "default"),
data.get("title", "新对话"),
data.get("createdAt", now),
now,
1 if data.get("pinned") else 0,
1 if data.get("archived") else 0,
json.dumps(data.get("settings")) if data.get("settings") else None,
),
)
# 插入消息(如果有)
messages = data.get("messages", [])
for msg in messages:
self._insert_message(cursor, conv_id, msg)
conn.commit()
return self.get_conversation(conv_id)
def get_conversation(self, conversation_id: str) -> Optional[Dict[str, Any]]:
"""获取单个会话(包含消息)"""
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute(
"SELECT * FROM conversations WHERE id = ?", (conversation_id,)
)
row = cursor.fetchone()
if not row:
return None
return self._row_to_conversation(row, cursor)
def list_conversations(self, user_id: str = "default") -> List[Dict[str, Any]]:
"""列出用户的所有会话"""
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute(
"""
SELECT * FROM conversations
WHERE user_id = ?
ORDER BY updated_at DESC
""",
(user_id,),
)
conversations = []
for row in cursor.fetchall():
conv = self._row_to_conversation(row, cursor, include_messages=False)
conversations.append(conv)
return conversations
def update_conversation(
self, conversation_id: str, data: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
"""更新会话"""
conn = self._get_connection()
cursor = conn.cursor()
# 检查会话是否存在
cursor.execute(
"SELECT id FROM conversations WHERE id = ?", (conversation_id,)
)
if not cursor.fetchone():
return None
now = int(datetime.now(timezone.utc).timestamp() * 1000)
# 更新会话字段
update_fields = ["updated_at = ?"]
update_values = [now]
if "title" in data:
update_fields.append("title = ?")
update_values.append(data["title"])
if "pinned" in data:
update_fields.append("pinned = ?")
update_values.append(1 if data["pinned"] else 0)
if "archived" in data:
update_fields.append("archived = ?")
update_values.append(1 if data["archived"] else 0)
if "settings" in data:
update_fields.append("settings = ?")
update_values.append(json.dumps(data["settings"]))
update_values.append(conversation_id)
cursor.execute(
f"UPDATE conversations SET {', '.join(update_fields)} WHERE id = ?",
update_values,
)
# 更新消息(如果提供了 messages 字段)
if "messages" in data:
# 删除旧消息
cursor.execute(
"DELETE FROM messages WHERE conversation_id = ?", (conversation_id,)
)
# 插入新消息
for msg in data["messages"]:
self._insert_message(cursor, conversation_id, msg)
conn.commit()
return self.get_conversation(conversation_id)
def delete_conversation(self, conversation_id: str) -> bool:
"""删除会话(级联删除消息)"""
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute(
"DELETE FROM conversations WHERE id = ?", (conversation_id,)
)
conn.commit()
return cursor.rowcount > 0
# ── 消息操作 ───────────────────────────────────────────────────────
def add_message(self, conversation_id: str, message: Dict[str, Any]) -> Dict[str, Any]:
"""添加消息到会话"""
conn = self._get_connection()
cursor = conn.cursor()
msg = self._insert_message(cursor, conversation_id, message)
# 更新会话的 updated_at
now = int(datetime.now(timezone.utc).timestamp() * 1000)
cursor.execute(
"UPDATE conversations SET updated_at = ? WHERE id = ?",
(now, conversation_id),
)
conn.commit()
return msg
def update_message(self, message_id: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""更新消息"""
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("SELECT id FROM messages WHERE id = ?", (message_id,))
if not cursor.fetchone():
return None
update_fields = []
update_values = []
if "content" in data:
update_fields.append("content = ?")
update_values.append(json.dumps(data["content"]))
if "feedback" in data:
update_fields.append("feedback = ?")
update_values.append(json.dumps(data["feedback"]))
if not update_fields:
return self._get_message_by_id(message_id, cursor)
update_values.append(message_id)
cursor.execute(
f"UPDATE messages SET {', '.join(update_fields)} WHERE id = ?",
update_values,
)
conn.commit()
return self._get_message_by_id(message_id, cursor)
# ── 内部方法 ───────────────────────────────────────────────────────
def _insert_message(
self, cursor: sqlite3.Cursor, conversation_id: str, message: Dict[str, Any]
) -> Dict[str, Any]:
"""插入消息(内部方法)"""
msg_id = message.get("id") or self._generate_id()
timestamp = message.get("timestamp") or int(
datetime.now(timezone.utc).timestamp() * 1000
)
cursor.execute(
"""
INSERT INTO messages (id, conversation_id, role, content, timestamp, feedback)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
msg_id,
conversation_id,
message.get("role", "user"),
json.dumps(message.get("content", "")),
timestamp,
json.dumps(message.get("feedback")) if message.get("feedback") else None,
),
)
return {
"id": msg_id,
"role": message.get("role", "user"),
"content": message.get("content", ""),
"timestamp": timestamp,
"feedback": message.get("feedback"),
}
def _get_message_by_id(
self, message_id: str, cursor: sqlite3.Cursor
) -> Optional[Dict[str, Any]]:
"""根据 ID 获取消息"""
cursor.execute("SELECT * FROM messages WHERE id = ?", (message_id,))
row = cursor.fetchone()
return self._row_to_message(row) if row else None
def _row_to_conversation(
self, row: sqlite3.Row, cursor: sqlite3.Cursor, include_messages: bool = True
) -> Dict[str, Any]:
"""将数据库行转换为会话字典"""
conv = {
"id": row["id"],
"userId": row["user_id"],
"title": row["title"],
"createdAt": row["created_at"],
"updatedAt": row["updated_at"],
"pinned": bool(row["pinned"]),
"archived": bool(row["archived"]),
"settings": json.loads(row["settings"]) if row["settings"] else None,
}
if include_messages:
cursor.execute(
"SELECT * FROM messages WHERE conversation_id = ? ORDER BY timestamp",
(row["id"],),
)
conv["messages"] = [
self._row_to_message(msg_row) for msg_row in cursor.fetchall()
]
return conv
def _row_to_message(self, row: sqlite3.Row) -> Dict[str, Any]:
"""将数据库行转换为消息字典"""
return {
"id": row["id"],
"role": row["role"],
"content": json.loads(row["content"]),
"timestamp": row["timestamp"],
"feedback": json.loads(row["feedback"]) if row["feedback"] else None,
}
def _generate_id(self) -> str:
"""生成唯一 ID"""
import uuid
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:
"""生成分享 ID8位短链接"""
import uuid
return uuid.uuid4().hex[:8]
def init_db():
"""初始化数据库(应用启动时调用)"""
global _db_instance
if _db_instance is None:
_db_instance = Database(DB_PATH)
print(f"[数据库] SQLite 初始化完成: {DB_PATH}")
def get_db() -> Database:
"""获取数据库实例"""
global _db_instance
if _db_instance is None:
init_db()
return _db_instance

View File

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

View File

@ -41,7 +41,7 @@ sys.path.append("/home/mt/project/ai-chat-ui/server")
# ── 工具/日志(与平台无关)───────────────────────────────────────────
from utils.helpers import log_response
from utils.logger import get_logger
from core import get_logger
logger = get_logger()
@ -54,14 +54,23 @@ init_db()
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_conversations_handler,
save_conversation_handler,
serve_upload_handler,
stop_generation_handler,
update_conversation_handler,
update_message_handler,
upload_file_handler)
# ── 分享功能路由处理器 ────────────────────────────────────────────────
from api.share_routes import (create_share_handler,
get_share_handler,
verify_share_handler)
# ── OpenAI 兼容网关初始化 ───────────────────────────────────────────────
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")
async def health_check():
from config import get_available_providers
from config.settings import get_available_providers
return {
"status": "healthy",
@ -109,6 +118,7 @@ async def health_check():
"openai_compatible": "/v1/chat/completions",
"models": "/v1/models",
"conversations": "/api/chat-ui/conversations",
"shares": "/api/chat-ui/shares",
},
"timestamp": datetime.now(timezone.utc).isoformat(),
}
@ -170,8 +180,8 @@ async def get_models():
@app.get("/api/chat-ui/conversations")
async def get_conversations():
return await get_conversations_handler()
async def get_conversations(user_id: str = "default"):
return await get_conversations_handler(user_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)
@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")
async def upload_file(file: UploadFile = 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)
@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__":
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()
@ -229,7 +280,7 @@ if __name__ == "__main__":
print(f" 可用平台 : {', '.join(available) or '无(请配置 API Key'}")
print("-" * 60)
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(' -d \'{"model":"glm-4-flash","messages":[{"role":"user","content":"hi"}]}\'')
print("=" * 60)

45
server/middleware/auth.py Normal file
View File

@ -0,0 +1,45 @@
"""
认证中间件 - 预留接口
当前返回默认用户未来可集成 JWTOAuth 等认证系统
"""
from typing import Optional
def get_current_user_id(request) -> str:
"""
从请求中获取当前用户 ID预留
当前返回默认用户 'default'
未来可集成 JWTOAuth
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
}

View File

@ -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
pydantic==2.9.2
typing-extensions==4.12.2
# 路线二阿里云文档智能解析doc/docx/pdf
llama-index-core>=0.10.0
llama-index-readers-dashscope>=0.1.0
# 阿里云 OSS 上传
alibabacloud-oss-v2
aiohappyeyeballs==2.6.1
aiohttp==3.13.3
aiosignal==1.4.0
aiosqlite==0.22.1
alibabacloud-oss-v2==1.2.4
aliyun-python-sdk-core==2.16.0
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

View File

@ -1,67 +0,0 @@
"""
GLM 文件 ID 缓存基于磁盘的简单 KVsha256 file_id3天有效期
"""
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)

View File

@ -1,523 +0,0 @@
"""
GLM-4.6V 适配层基于 zai-sdk
SDKpip 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()
# ── 远程 URLOSS 等)→ 直接透传 ─────────────────
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}

View File

@ -8,7 +8,7 @@ import uuid
from datetime import datetime
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)

View File

@ -33,10 +33,9 @@ from dotenv import load_dotenv
# ── 加载环境变量 ──────────────────────────────────────────────
load_dotenv()
# AccessKey 从系统环境变量读取(~/.bashrc 中 export 设置)
OSS_ACCESS_KEY_ID = os.environ.get("OSS_ACCESS_KEY_ID", "")
OSS_ACCESS_KEY_SECRET = os.environ.get("OSS_ACCESS_KEY_SECRET", "")
# 以下配置从 .env 文件读取
# 所有配置从 .env 文件读取
OSS_ACCESS_KEY_ID = os.getenv("OSS_ACCESS_KEY_ID", "")
OSS_ACCESS_KEY_SECRET = os.getenv("OSS_ACCESS_KEY_SECRET", "")
OSS_BUCKET_NAME = os.getenv("OSS_BUCKET_NAME", "")
OSS_ENDPOINT = os.getenv("OSS_ENDPOINT", "")
OSS_REGION = os.getenv("OSS_REGION", "")
@ -57,11 +56,12 @@ def _get_client() -> oss.Client:
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
格式: {prefix}/{日期}/{uuid}_{原始文件名}
"""
# TODO: 需要按用户ID分目录
date_str = datetime.now().strftime("%Y%m%d")
unique_id = uuid.uuid4().hex[:8]
safe_name = Path(filename).name # 只取文件名,去掉路径
@ -80,7 +80,7 @@ def _build_url(object_key: str) -> str:
def upload_file(
file_path: str,
object_key: Optional[str] = None,
prefix: str = "uploads",
prefix: str = "chat-ui",
) -> dict:
"""
上传本地文件到 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 <路径>
# ────────────────────────────────────────────────────────────────

View File

@ -1,37 +1,84 @@
"""
GLM 适配器测试脚本
测试 GLMAdapter 的流式和非流式调用包括联网搜索功能
"""
import asyncio
import os
import sys
from pathlib import 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))
# Set API key from .env if needed
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()
async def test_stream():
msgs = [{"role": "user", "content": "今天北京天气怎样?"}]
print("Testing stream...")
async for chunk in glm_stream_generator(
msgs, "glm-4.5-air", 0.7, 1024, web_search=True
):
"""测试流式调用(联网搜索)"""
adapter = GLMAdapter()
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="")
def test_sync():
msgs = [{"role": "user", "content": "今天几号?武汉天气怎样?"}]
print("Testing sync...")
res = glm_chat_sync(msgs, "glm-4.5-air", 0.7, 1024, web_search=True)
print(res)
async def test_sync():
"""测试非流式调用(联网搜索)"""
adapter = GLMAdapter()
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__":
_ensure_venv()
# test_sync()
asyncio.run(test_stream())
# 运行流式测试
# asyncio.run(test_stream())
# 运行非流式测试
asyncio.run(test_sync())

View File

@ -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()

View File

@ -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()

View File

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

View File

@ -1,16 +1,6 @@
<template>
<div class="app" :class="{ dark: isDark }">
<!-- 侧边栏 -->
<ChatSidebar />
<!-- 主内容区 -->
<ChatMain ref="chatMainRef" @toggle-sidebar="toggleSidebar" />
<!-- 模态框 -->
<SearchModal />
<ShortcutsModal />
<SettingsModal />
<ConversationSettingsModal />
<router-view />
<!-- Toast 通知 -->
<Teleport to="body">
@ -32,28 +22,14 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { ref, computed } from "vue";
import { storeToRefs } from "pinia";
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 { Check, AlertCircle, Info } from "@/components/icons";
// Stores
const chatStore = useChatStore();
const settingsStore = useSettingsStore();
const { settings } = storeToRefs(settingsStore);
// Refs
const chatMainRef = ref<InstanceType<typeof ChatMain> | null>(null);
//
const isDark = computed(() => {
if (settings.value.theme === "system") {
@ -84,54 +60,6 @@ function showToast(message: string, type: Toast["type"] = "info") {
}, 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;
</script>

View File

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

View File

@ -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'])
})
})
})

View File

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

67
src/__tests__/setup.ts Normal file
View File

@ -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(),
})))

View File

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

View File

@ -42,14 +42,14 @@
</button>
<!-- 导出对话 -->
<button
<!-- <button
class="header-btn"
title="导出对话"
:disabled="messageCount === 0"
@click="$emit('export')"
>
<Download :size="18" />
</button>
</button> -->
<!-- 更多操作 -->
<!-- <button
@ -90,7 +90,6 @@ import { ref } from "vue";
import {
Menu,
Trash2,
Download,
ChevronLeft,
ExternalLink,
Pin,

View File

@ -28,6 +28,13 @@
<!-- 输入区域 -->
<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 }">
<ChatInput
ref="chatInputRef"
@ -52,9 +59,11 @@ import { ref, computed, watch, nextTick, onMounted } from "vue";
import { storeToRefs } from "pinia";
import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import ChatHeader from "./ChatHeader.vue";
import MessageList from "./MessageList.vue";
import ChatInput from "@/components/input/ChatInput.vue";
import AttachmentPreview from "@/components/input/AttachmentPreview.vue";
import { MessageType, MessageRole } from "@/types/chat";
import type { Attachment, Suggestion } from "@/types/chat";
import { chatApi, type ModelInfo } from "@/services/api";
@ -65,6 +74,7 @@ defineEmits<{
const chatStore = useChatStore();
const settingsStore = useSettingsStore();
const authStore = useAuthStore();
const { currentConversation, isStreaming } = storeToRefs(chatStore);
const { settings, sidebarCollapsed } = storeToRefs(settingsStore);
@ -117,6 +127,14 @@ const inputPlaceholder = computed(() => {
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() {
isWideMode.value = !isWideMode.value;
}
@ -162,8 +180,16 @@ async function handleSend(
webSearch?: boolean;
deepThinking?: boolean;
systemPrompt?: string;
skipUserMessage?: boolean;
conversationTitle?: string;
},
) {
//
if (!authStore.isAuthenticated) {
window.$toast?.('请先登录', 'error');
return;
}
console.log("handleSend", text, attachments, options);
//
const uploadingAttachments = attachments.filter((a) => a.uploading);
@ -196,31 +222,49 @@ async function handleSend(
//
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 historyMessages = existingMessages
.filter(
(m: any) =>
m.role === MessageRole.USER || m.role === MessageRole.ASSISTANT,
)
.filter((m: any) => m.content?.text) //
const historyMessages = updatedMessages.filter((m: any) => m.content?.text) //
.slice(-(MAX_HISTORY_ROUNDS * 2))
.map((m: any) => ({ role: m.role, content: m.content.text }));
//
chatStore.addMessage(MessageRole.USER, {
type: MessageType.TEXT,
text,
images: attachments.filter((a) => a.type === "image"),
files: attachments.filter((a) => a.type === "file"),
});
//
if (!options?.skipUserMessage) {
await chatStore.addMessage(MessageRole.USER, {
type: MessageType.TEXT,
text,
images: attachments.filter((a) => a.type === "image"),
files: attachments.filter((a) => a.type === "file"),
});
}
// AI
const aiMessage = chatStore.addMessage(MessageRole.ASSISTANT, {
const aiMessage = await chatStore.addMessage(MessageRole.ASSISTANT, {
type: MessageType.TEXT,
text: "",
});
@ -245,7 +289,7 @@ async function handleSend(
const stream = chatApi.streamChat(
{
message: text,
message: options?.skipUserMessage ? "直接输出系统提示词要求你的回答" : text,
conversationId: currentConversation.value?.id || "",
images: imageUrls,
files: fileUrls,
@ -337,6 +381,12 @@ function handleStop() {
//
async function handleRetry(messageId: string) {
//
if (!authStore.isAuthenticated) {
window.$toast?.('请先登录', 'error');
return;
}
const message = messages.value.find((m: any) => m.id === messageId);
if (!message || message.role !== MessageRole.ASSISTANT) return;
@ -445,7 +495,11 @@ function handleRegenerate(messageId: string) {
}
function handleSuggestion(suggestion: Suggestion) {
handleSend(suggestion.text, [], { systemPrompt: suggestion.systemPrompt });
handleSend(suggestion.text, [], {
systemPrompt: suggestion.systemPrompt,
skipUserMessage: true,
conversationTitle: suggestion.text,
});
}
function focusInput() {
@ -484,14 +538,14 @@ watch(
&.wide-mode {
.input-container {
min-width: 1000px;
// min-width: 1000px;
}
}
}
.input-wrapper {
flex-shrink: 0;
padding: 16px 150px 24px;
padding: 16px 10% 24px;
background: linear-gradient(to top, white 80%, transparent);
.dark & {
@ -499,14 +553,25 @@ watch(
}
}
.attachments-preview-container {
margin-bottom: 12px;
background: #f3f4f5;
border-radius: 16px;
overflow: hidden;
.dark & {
background: #1e1e2e;
}
}
.input-container {
width: 100%;
min-width: 1000px;
// min-width: 1000px;
// margin: 0 auto;
transition: max-width 0.3s ease;
&.wide {
min-width: 1000px;
// min-width: 1000px;
}
}
</style>

View File

@ -1,9 +1,33 @@
<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">
<!-- 欢迎界面 -->
<WelcomeScreen
v-if="messages.length === 0"
v-if="visibleMessages.length === 0"
@select="$emit('select-suggestion', $event)"
/>
@ -12,12 +36,14 @@
<div class="messages-wrapper">
<TransitionGroup name="message">
<MessageBubble
v-for="(message, index) in messages"
v-for="(message, index) in visibleMessages"
:key="message.id"
:message="message"
:show-timestamp="showTimestamp"
: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)"
@regenerate="$emit('regenerate', message.id)"
@copy="handleCopy(message)"
@ -27,6 +53,8 @@
@preview-image="handlePreviewImage"
@play-video="handlePlayVideo"
@download-file="handleDownloadFile"
@toggle-select="handleToggleMessageSelect(message.id)"
@enter-select-mode="handleEnterSelectMode(message.id)"
/>
</TransitionGroup>
@ -62,12 +90,14 @@
</template>
<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 { useSettingsStore } from "@/stores/settings";
import MessageBubble from "@/components/message/MessageBubble.vue";
import WelcomeScreen from "./WelcomeScreen.vue";
import { Bot, ChevronDown } from "@/components/icons";
import type { Message, Attachment, VideoInfo, Suggestion } from "@/types/chat";
import { MessageRole } from "@/types/chat";
const props = withDefaults(
defineProps<{
@ -83,6 +113,13 @@ const props = withDefaults(
},
);
//
const visibleMessages = computed(() => {
return props.messages.filter(
(message) => message.role !== MessageRole.SYSTEM
);
});
const emit = defineEmits<{
retry: [messageId: string];
regenerate: [messageId: string];
@ -93,10 +130,14 @@ const emit = defineEmits<{
}>();
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: any = ref<HTMLElement | null>(null);
const containerRef = ref<HTMLElement | null>(null);
const showScrollButton = ref(false);
const newMessageCount = ref(0);
const isAutoScrolling = ref(true);
@ -173,9 +214,36 @@ function handleDownloadFile(file: Attachment) {
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(
() => props.messages.length,
() => visibleMessages.value.length,
(newLen, oldLen) => {
if (newLen > oldLen) {
if (isAutoScrolling.value) {
@ -191,7 +259,7 @@ watch(
//
watch(
() => props.messages[props.messages.length - 1]?.content.text,
() => visibleMessages.value[visibleMessages.value.length - 1]?.content.text,
() => {
if (isAutoScrolling.value) {
nextTick(() => {
@ -222,7 +290,10 @@ defineExpose({
});
onMounted(() => {
scrollToBottom(false);
//
if (visibleMessages.value.length > 0) {
scrollToBottom(false);
}
});
</script>
@ -398,4 +469,97 @@ onMounted(() => {
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>

View File

@ -2,16 +2,7 @@
<div class="welcome-screen">
<!-- Logo 和标题 -->
<div class="welcome-header">
<div class="logo-wrapper">
<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>
<h1 class="title">学习教学科研聊天助手</h1>
</div>
<!-- 功能卡片 -->
@ -63,7 +54,6 @@
<script setup lang="ts">
import { computed } from "vue";
import {
Bot,
MessageSquare,
Code,
Image,

View File

@ -123,6 +123,7 @@ export {
// 其他
HelpCircle,
CheckCircle,
Eye,
EyeOff,
Lock,

View File

@ -3,13 +3,6 @@
class="chat-input-container"
:class="{ 'is-focused': isFocused, 'is-expanded': isExpanded }"
>
<!-- 附件预览区 -->
<AttachmentPreview
v-if="attachments.length > 0"
:attachments="attachments"
@remove="removeAttachment"
/>
<!-- 输入区域 -->
<div class="input-area">
<!-- 左侧功能按钮 -->
@ -61,6 +54,7 @@
v-model="inputText"
:placeholder="placeholder"
:rows="1"
@beforeinput="handleBeforeInput"
@input="autoResize"
@focus="isFocused = true"
@blur="isFocused = false"
@ -97,13 +91,18 @@
<!-- 底部工具栏 -->
<div class="input-toolbar">
<div class="toolbar-left">
<!-- 展开/收起 -->
<button class="toolbar-btn" title="展开输入框" @click="toggleExpand">
<Maximize2 v-if="!isExpanded" :size="16" />
<Minimize2 v-else :size="16" />
</button>
<!-- 深度思考开关 -->
<button
class="toolbar-btn"
:class="{ active: isDeepThinking, disabled: !supports_thinking }"
:disabled="!supports_thinking"
:title="supports_thinking ? '深度思考' : '当前模型不支持深度思考'"
@click="supports_thinking && toggleDeepThink()"
:class="{ active: isDeepThinking, disabled: isForceDeepThinkingModel || !supports_thinking }"
:disabled="isForceDeepThinkingModel || !supports_thinking"
:title="isForceDeepThinkingModel ? '当前模型强制开启深度思考' : (supports_thinking ? '深度思考' : '当前模型不支持深度思考')"
@click="!isForceDeepThinkingModel && supports_thinking && toggleDeepThink()"
>
<Brain :size="16" />
<span>深度思考</span>
@ -132,24 +131,6 @@
<Globe :size="16" />
<span>联网搜索</span>
</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>
@ -169,10 +150,11 @@ import {
Brain,
Loader2,
} from "@/components/icons";
import AttachmentPreview from "./AttachmentPreview.vue";
import { generateId } from "@/utils/helpers";
import type { Attachment } from "@/types/chat";
import { chatApi } from "@/services/api";
import { useAuthStore } from "@/stores/auth";
import { useSettingsStore } from "@/stores/settings";
interface AttachmentWithProgress extends Attachment {
uploading?: boolean;
@ -196,7 +178,7 @@ const props = withDefaults(
placeholder: "输入你的问题...",
isStreaming: false,
sendOnEnter: false,
maxChars: 4000,
maxChars: 10000,
disabled: false,
//
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<{
send: [
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 attachments = ref<AttachmentWithProgress[]>([]);
const isFocused = ref(false);
@ -234,6 +228,18 @@ const textareaRef = ref<HTMLTextAreaElement | null>(null);
const fileInputRef = 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 isUploading = computed(() => attachments.value.some((a) => a.uploading));
@ -253,7 +259,30 @@ function autoResize() {
textarea.style.height = "auto";
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;
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) {
if (item.type.startsWith("image/")) {
event.preventDefault();
@ -350,6 +395,12 @@ async function addFileAsAttachment(
file: File,
type: "image" | "file" | "video",
) {
//
if (!authStore.isAuthenticated) {
window.$toast?.("请先登录", "error");
return;
}
const id = generateId();
// 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);
if (index !== -1) {
// blob URL
URL.revokeObjectURL(attachments.value[index].url);
attachments.value.splice(index, 1);
if (index === -1) return;
const attachment = attachments.value[index];
// 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({
focus,
clear,
attachments,
removeAttachment,
});
//
@ -467,6 +535,25 @@ watch(inputText, () => {
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(() => {
autoResize();
@ -601,7 +688,7 @@ onMounted(() => {
textarea {
width: 100%;
min-height: 24px;
min-height: 25px;
max-height: 160px;
padding: 8px 0;
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 {
0%,
100% {

View File

@ -8,11 +8,21 @@
'is-end': !message.isEnd && message.role !== 'user',
'is-error': message.isError,
compact: compact,
'message-select-mode': isMessageSelectMode,
'message-selected': isSelected,
},
]"
@click="handleBubbleClick"
@mouseenter="isHovered = true"
@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-inner" :class="message.role">
@ -24,14 +34,14 @@
<!-- 消息内容区域 -->
<div class="message-content-wrapper">
<!-- 角色名称 -->
<div class="message-header">
<!-- <div class="message-header">
<span class="role-name">
{{ message.role === "assistant" ? "AI 助手" : "你" }}
</span>
<span v-if="showTimestamp" class="timestamp">
{{ formattedTime }}
</span>
</div>
</div> -->
<!-- 消息主体 -->
<div class="message-body">
@ -164,7 +174,9 @@
v-if="
message.role === 'assistant' &&
!message.isStreaming &&
!message.isError
!message.isError &&
!readonly &&
!isMessageSelectMode
"
:content="message.content.text || ''"
:feedback="message.feedback"
@ -176,6 +188,7 @@
@like="handleLike"
@dislike="handleDislike"
@regenerate="$emit('regenerate')"
@share="handleShareClick"
/>
</div>
</div>
@ -183,7 +196,7 @@
<script setup lang="ts">
import { useClipboard } from "@vueuse/core";
import { ref, computed } from "vue";
import { ref } from "vue";
// markstream-vue
import MarkdownRender from "markstream-vue";
import { setCustomComponents } from "markstream-vue";
@ -195,10 +208,10 @@ import {
Zap,
Maximize2,
Play,
Download,
Check,
} from "@/components/icons";
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 ThinkingNode from "./components/ThinkingNode.vue";
import EChartsContainerNode from "./components/EChartsContainerNode.vue";
@ -209,10 +222,16 @@ const props = withDefaults(
showTimestamp?: boolean;
compact?: boolean;
isNew?: boolean;
isMessageSelectMode?: boolean;
isSelected?: boolean;
readonly?: boolean;
}>(),
{
showTimestamp: true,
compact: false,
isMessageSelectMode: false,
isSelected: false,
readonly: false,
},
);
const { copy } = useClipboard({ legacy: true });
@ -226,13 +245,28 @@ const emit = defineEmits<{
"preview-image": [image: Attachment, index: number];
"play-video": [video: VideoInfo];
"download-file": [file: Attachment];
"toggle-select": [];
"enter-select-mode": [];
}>();
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) {
return getFileIcon(mimeType || "");
@ -276,9 +310,31 @@ setCustomComponents("playground-demo", {
.message-bubble {
display: flex;
gap: 16px;
padding: 20px 150px;
padding: 20px 10%;
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 {
flex-direction: row-reverse;
@ -435,7 +491,7 @@ setCustomComponents("playground-demo", {
// markstream-vue
.text-content {
:deep(p) {
:deep(p) {
margin: 0 0 12px;
&:last-child {
@ -611,15 +667,16 @@ setCustomComponents("playground-demo", {
}
}
.images-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 8px;
.images-flex {
display: inline-flex;
flex-wrap: wrap;
gap: 7px;
margin-top: 12px;
}
.image-item {
position: relative;
width: 130px;
aspect-ratio: 1;
border-radius: 12px;
overflow: hidden;
@ -835,4 +892,41 @@ setCustomComponents("playground-demo", {
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>

View File

@ -163,7 +163,7 @@ async function textCopy(data: any) {
/* 可折叠内容 */
.thinking-content {
max-height: 2000px;
overflow: hidden;
overflow: auto;
transition:
max-height 0.35s ease,
opacity 0.25s ease;

View File

@ -200,8 +200,8 @@ import FormSwitch from "@/components/ui/FormSwitch.vue";
import FormSlider from "@/components/ui/FormSlider.vue";
import FormSelect from "@/components/ui/FormSelect.vue";
import { MessageSquare, X, Check, Trash2 } from "@/components/icons";
import { chatApi } from "@/services/api.ts";
import type { ConversationSettings } from "@/types/chat";
import { MessageRole, MessageType } from "@/types/chat";
const chatStore = useChatStore();
const settingsStore = useSettingsStore();
@ -215,20 +215,20 @@ const modelSelect = ref(localStorage.getItem("modelSelect") || "");
const currentModelId = ref(settingsStore.getSelectedModelId());
onMounted(() => {
chatApi.getModels().then((res: any) => {
availableModels.value = res;
//
const model = availableModels.value?.find(
(m: any) => m.id === currentModelId.value,
);
if (model) {
modelSelect.value = model.name;
} else if (availableModels.value.length > 0) {
modelSelect.value = availableModels.value[0].name;
currentModelId.value = availableModels.value[0].id;
}
localStorage.setItem("modelSelect", modelSelect.value);
});
// chatApi.getModels().then((res: any) => {
// availableModels.value = res;
// //
// const model = availableModels.value?.find(
// (m: any) => m.id === currentModelId.value,
// );
// if (model) {
// modelSelect.value = model.name;
// } else if (availableModels.value.length > 0) {
// modelSelect.value = availableModels.value[0].name;
// currentModelId.value = availableModels.value[0].id;
// }
// localStorage.setItem("modelSelect", modelSelect.value);
// });
});
//
@ -359,6 +359,25 @@ function handleSave() {
//
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();
//

View File

@ -400,7 +400,6 @@ import {
RotateCcw,
} from "@/components/icons";
import type { AppSettings } from "@/types/chat";
import { chatApi } from "@/services/api.ts";
const settingsStore = useSettingsStore();
@ -409,10 +408,10 @@ const availableModels: any = ref([]);
const defaultModel: any = ref(localStorage.getItem("defaultModel"));
onMounted(() => {
chatApi.getModels().then((res: any) => {
availableModels.value = res;
if (!defaultModel.value) defaultModel.value = res[0].name;
});
// chatApi.getModels().then((res: any) => {
// availableModels.value = res;
// if (!defaultModel.value) defaultModel.value = res[0].name;
// });
});
const activeTab = ref("appearance");

View File

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

View File

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

View File

@ -59,6 +59,9 @@
</button>
</div>
<!-- 分享按钮 -->
<ShareButton />
<!-- 搜索框 -->
<!-- <div class="search-section">
<div class="search-box" @click="openSearch">
@ -82,10 +85,13 @@
:key="conv.id"
:conversation="conv"
:is-active="conv.id === currentConversationId"
:is-select-mode="isSelectMode"
:is-selected="isConversationSelected(conv.id)"
@select="selectConversation"
@delete="deleteConversation"
@rename="renameConversation"
@toggle-pin="togglePinConversation"
@toggle-select="toggleConversationSelection"
/>
</div>
</div>
@ -102,10 +108,13 @@
:key="conv.id"
:conversation="conv"
:is-active="conv.id === currentConversationId"
:is-select-mode="isSelectMode"
:is-selected="isConversationSelected(conv.id)"
@select="selectConversation"
@delete="deleteConversation"
@rename="renameConversation"
@toggle-pin="togglePinConversation"
@toggle-select="toggleConversationSelection"
/>
</div>
</div>
@ -124,21 +133,21 @@
</div>
<!-- 底部操作 -->
<div class="sidebar-footer">
<!-- <div class="sidebar-footer">
<button class="footer-btn" @click="toggleTheme" title="切换主题">
<Sun v-if="currentTheme === 'light'" :size="18" />
<Moon v-else-if="currentTheme === 'dark'" :size="18" />
<Monitor v-else :size="18" />
</button>
<!-- 键盘快捷键 -->
<!-- <button class="footer-btn" @click="openShortcuts" title="快捷键">
键盘快捷键
<button class="footer-btn" @click="openShortcuts" title="快捷键">
<Keyboard :size="18" />
</button> -->
<!-- dev人员可用 -->
<!-- <button class="footer-btn" @click="openSettings" title="设置">
</button>
dev人员可用
<button class="footer-btn" @click="openSettings" title="设置">
<Settings :size="18" />
</button> -->
</div>
</button>
</div> -->
</div>
<!-- 拖拽调整宽度 -->
@ -147,25 +156,17 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { onMounted, ref } from "vue";
import { storeToRefs } from "pinia";
import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings";
import { chatApi } from "@/services/api.ts";
import ConversationItem from "./ConversationItem.vue";
import ShareButton from "./ShareButton.vue";
import {
Bot,
Plus,
Search,
Pin,
Clock,
MessageSquare,
Sun,
Moon,
Monitor,
Keyboard,
Settings,
ChevronLeft,
Sparkles,
ChevronDown,
Check,
@ -174,17 +175,14 @@ import {
const chatStore = useChatStore();
const settingsStore = useSettingsStore();
const { currentConversationId, pinnedConversations, recentConversations } =
const { currentConversationId, pinnedConversations, recentConversations, isSelectMode, selectedConversationIds } =
storeToRefs(chatStore);
const {
sidebarCollapsed: isCollapsed,
sidebarWidth,
settings,
} = storeToRefs(settingsStore);
const currentTheme = computed(() => settings.value.theme);
//
const showModelMenu = ref(false);
const currentModel = ref(localStorage.getItem("modelSelect") || "");
@ -212,6 +210,11 @@ function selectModel(modelId: string, modelName: string) {
localStorage.setItem("modelSelect", modelName);
settingsStore.setSelectedModelId(modelId); // ID
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);
}
function toggleSidebar() {
settingsStore.toggleSidebar();
function toggleConversationSelection(id: string) {
chatStore.toggleConversationSelection(id);
}
function toggleTheme() {
settingsStore.toggleTheme();
}
function openShortcuts() {
settingsStore.openShortcutsModal();
}
function openSettings() {
settingsStore.openSettingsModal();
}
function openSearch() {
settingsStore.openSearchModal();
function isConversationSelected(id: string): boolean {
return selectedConversationIds.value.includes(id);
}
//
@ -296,7 +287,6 @@ if (typeof window !== "undefined") {
position: relative;
height: 100vh;
background: #ffffff;
border-right: 1px solid #e2e8f0;
transition: width 0.3s ease;
overflow: hidden;
flex-shrink: 0;
@ -706,9 +696,5 @@ if (typeof window !== "undefined") {
height: 100%;
cursor: col-resize;
z-index: 10;
&:hover {
background: rgba(59, 130, 246, 0.3);
}
}
</style>

View File

@ -4,12 +4,21 @@
:class="{
active: isActive,
pinned: conversation.pinned,
selected: isSelected,
'select-mode': isSelectMode,
}"
@click="handleSelect"
@click="handleClick"
@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" />
</div>
@ -35,12 +44,12 @@
</div>
<!-- 置顶标识 -->
<div v-if="conversation.pinned" class="pin-indicator">
<div v-if="conversation.pinned && !isSelectMode" class="pin-indicator">
<Pin :size="12" />
</div>
<!-- 操作按钮 -->
<div class="item-actions" @click.stop>
<!-- 操作按钮 (非选择模式显示) -->
<div v-if="!isSelectMode" class="item-actions" @click.stop>
<button
class="action-btn"
:title="conversation.pinned ? '取消置顶' : '置顶'"
@ -68,6 +77,7 @@ import {
Edit3,
Trash2,
Clock,
Check,
} from "@/components/icons";
import { formatTimestamp } from "@/utils/helpers";
import type { Conversation } from "@/types/chat";
@ -75,6 +85,8 @@ import type { Conversation } from "@/types/chat";
const props = defineProps<{
conversation: Conversation;
isActive: boolean;
isSelectMode?: boolean;
isSelected?: boolean;
}>();
const emit = defineEmits<{
@ -82,6 +94,7 @@ const emit = defineEmits<{
delete: [id: string];
rename: [id: string, title: string];
togglePin: [id: string];
toggleSelect: [id: string];
}>();
const isEditing = ref(false);
@ -92,17 +105,24 @@ const formattedTime = computed(() => {
return formatTimestamp(props.conversation.updatedAt);
});
function handleSelect() {
if (!isEditing.value) {
function handleClick() {
if (props.isSelectMode) {
emit("toggleSelect", props.conversation.id);
} else if (!isEditing.value) {
emit("select", props.conversation.id);
}
}
function handleToggleSelect() {
emit("toggleSelect", props.conversation.id);
}
function handleTogglePin() {
emit("togglePin", props.conversation.id);
}
function handleRename() {
if (props.isSelectMode) return;
isEditing.value = true;
editTitle.value = props.conversation.title;
nextTick(() => {
@ -274,4 +294,57 @@ function handleDelete() {
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>

View File

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

View File

@ -1,5 +1,6 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import router from "./router";
import App from "./App.vue";
// 样式
@ -15,6 +16,9 @@ const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
// 使用 Router
app.use(router);
// 挂载应用
app.mount("#app");

20
src/router/index.ts Normal file
View File

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

View File

@ -2,6 +2,8 @@
* Chat UI API
*
*/
import { getAuthHeaders } from './request';
// API 端点定义(固定)
const API_ENDPOINTS = {
// 发送消息(流式)
@ -119,21 +121,36 @@ class ChatApi {
}
// 将前端简化的请求翻译为 OpenAI 兼容的规范请求体
// 构建 messages 数组system + 历史消息 + 当前用户消息
const systemMessage = {
role: "system",
content:
request.systemPrompt ||
"你是一个智能助手,可以分析用户发送的文字,文件或图片内容,并进行回答。",
};
const currentUserMessage = {
role: "user",
content: userContent,
};
const allMessages =
request.history && request.history.length > 0
? [systemMessage, ...request.history, currentUserMessage]
: [systemMessage, currentUserMessage];
// 检查历史消息中是否已有系统消息
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 = {
role: "system",
content:
request.systemPrompt ||
"你是一个智能助手,可以分析用户发送的文字,文件或图片内容,并进行回答。",
};
allMessages = [systemMessage, ...request.history, { role: "user", content: userContent }];
}
} else {
// 没有历史消息,添加系统消息
const systemMessage = {
role: "system",
content:
request.systemPrompt ||
"你是一个智能助手,可以分析用户发送的文字,文件或图片内容,并进行回答。",
};
allMessages = [systemMessage, { role: "user", content: userContent }];
}
const openAiRequest = {
model: request.model || "glm-4-flash",
@ -153,7 +170,7 @@ class ChatApi {
{
method: "POST",
headers: {
"Content-Type": "application/json",
...getAuthHeaders(),
Accept: "text/event-stream",
},
body: JSON.stringify(openAiRequest),
@ -244,9 +261,7 @@ class ChatApi {
const response = await fetch(`${this.baseUrl}${API_ENDPOINTS.CHAT}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
headers: getAuthHeaders(),
body: JSON.stringify(requestBody),
});
@ -264,9 +279,7 @@ class ChatApi {
async stopChat(messageId?: string) {
await fetch(`${this.baseUrl}${API_ENDPOINTS.STOP}/${messageId}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
headers: getAuthHeaders(),
});
}
@ -326,8 +339,13 @@ class ChatApi {
const formData = new FormData();
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}`, {
method: "POST",
headers: headersWithoutContentType,
body: formData,
});
@ -337,6 +355,23 @@ class ChatApi {
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}`);
}
}
}
// 导出单例

View File

@ -0,0 +1,64 @@
/**
* -
*
* JWTOAuth
*/
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 {};
}
};

View File

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

100
src/services/request.ts Normal file
View File

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

77
src/services/shareApi.ts Normal file
View File

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

125
src/stores/auth.ts Normal file
View File

@ -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');
// 获取 tokenURL > 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,
};
});

View File

@ -8,6 +8,7 @@ import type {
} from "@/types/chat";
import { MessageRole } from "@/types/chat";
import { generateId, extractTitleFromMessage } from "@/utils/helpers";
import { conversationApi } from "@/services/conversationApi";
export const useChatStore = defineStore("chat", () => {
// 状态
@ -15,6 +16,17 @@ export const useChatStore = defineStore("chat", () => {
const currentConversationId = ref<string | null>(null);
const isStreaming = ref(false);
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(() => {
@ -40,11 +52,64 @@ export const useChatStore = defineStore("chat", () => {
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 = {
id: generateId(),
title: "新对话",
title: title || "新对话",
messages: [],
createdAt: Date.now(),
updatedAt: Date.now(),
@ -53,89 +118,171 @@ export const useChatStore = defineStore("chat", () => {
settings: undefined,
};
// 乐观更新
conversations.value.unshift(newConversation);
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;
}
function deleteConversation(id: string) {
// 删除对话
async function deleteConversation(id: string) {
const index = conversations.value.findIndex((c) => c.id === id);
if (index !== -1) {
conversations.value.splice(index, 1);
if (index === -1) return;
if (currentConversationId.value === id) {
currentConversationId.value = conversations.value[0]?.id || null;
}
// 保存引用以便回滚
const deletedConversation = conversations.value[index];
saveToStorage();
// 乐观更新
conversations.value.splice(index, 1);
if (currentConversationId.value === id) {
currentConversationId.value = conversations.value[0]?.id || null;
saveCurrentId();
}
// 异步删除
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;
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);
if (conversation) {
if (!conversation) return;
// 乐观更新
conversation.pinned = !conversation.pinned;
// 异步保存
try {
await conversationApi.updateConversation(id, { pinned: conversation.pinned });
} catch (error) {
console.error("Failed to toggle pin:", error);
// 回滚
conversation.pinned = !conversation.pinned;
saveToStorage();
throw error;
}
}
function renameConversation(id: string, newTitle: string) {
// 重命名对话
async function renameConversation(id: string, newTitle: string) {
const conversation = conversations.value.find((c) => c.id === id);
if (conversation) {
conversation.title = newTitle;
conversation.updatedAt = Date.now();
saveToStorage();
if (!conversation) return;
const oldTitle = conversation.title;
conversation.title = newTitle;
conversation.updatedAt = Date.now();
// 异步保存
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,
convSettings: ConversationSettings,
convSettings: ConversationSettings
) {
const conversation = conversations.value.find((c) => c.id === id);
if (conversation) {
conversation.settings = { ...conversation.settings, ...convSettings };
conversation.updatedAt = Date.now();
saveToStorage();
if (!conversation) return;
const oldSettings = conversation.settings;
conversation.settings = { ...conversation.settings, ...convSettings };
conversation.updatedAt = Date.now();
// 异步保存
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,
content: MessageContent,
conversationId?: string,
): Message {
const targetId = conversationId || currentConversationId.value;
conversationId?: string
): Promise<Message> {
let targetId = conversationId || currentConversationId.value;
if (!targetId) {
createConversation();
await createConversation();
targetId = currentConversationId.value;
}
const conversation = conversations.value.find(
(c) => c.id === (targetId || currentConversationId.value),
);
const conversation = conversations.value.find((c) => c.id === targetId);
if (!conversation) {
throw new Error("Conversation not found");
}
const message: any = {
const message: Message = {
id: generateId(),
role,
content,
timestamp: Date.now(),
isStreaming: false,
};
} as Message;
// 乐观更新
conversation.messages.push(message);
conversation.updatedAt = Date.now();
// 如果是第一条用户消息,更新标题
if (
role === MessageRole.USER &&
conversation.messages.length === 1 &&
@ -144,21 +291,96 @@ export const useChatStore = defineStore("chat", () => {
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;
}
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;
if (!conversation) return;
if (!conversation) {
console.warn("[updateMessage] No current conversation");
return;
}
const message = conversation.messages.find((m) => m.id === messageId);
if (message) {
Object.assign(message, updates);
saveToStorage();
if (!message) {
console.warn("[updateMessage] Message not found:", messageId);
return;
}
// 乐观更新
Object.assign(message, updates);
// 异步保存
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) {
const conversation = currentConversation.value;
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,
feedback: "like" | "dislike" | null,
feedback: "like" | "dislike" | null
) {
const conversation = currentConversation.value;
if (!conversation) return;
const message = conversation.messages.find((m) => m.id === messageId);
if (message) {
message.feedback = {
liked: feedback === "like",
disliked: feedback === "dislike",
copied: message.feedback?.copied,
};
saveToStorage();
if (!message) return;
message.feedback = {
liked: feedback === "like",
disliked: feedback === "dislike",
copied: message.feedback?.copied,
};
// 异步保存
try {
await conversationApi.updateMessage(conversation.id, messageId, {
feedback: message.feedback
});
} catch (error) {
console.error("Failed to save feedback:", error);
}
}
// 设置消息已复制
function setMessageCopied(messageId: string) {
const conversation = currentConversation.value;
if (!conversation) return;
@ -200,11 +447,13 @@ export const useChatStore = defineStore("chat", () => {
}
}
// 开始流式输出
function startStreaming() {
isStreaming.value = true;
streamController.value = new AbortController();
}
// 停止流式输出
function stopStreaming() {
isStreaming.value = false;
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);
if (conversation) {
conversation.messages = [];
conversation.updatedAt = Date.now();
saveToStorage();
}
}
if (!conversation) return;
function saveToStorage() {
conversation.messages = [];
conversation.updatedAt = Date.now();
// 异步保存
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);
await conversationApi.updateConversation(id, { messages: [] });
} catch (error) {
console.error("Failed to clear conversation:", error);
}
}
// 降级:从 localStorage 加载(仅在 API 不可用时使用)
function loadFromStorage() {
try {
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 {
// 状态
conversations,
currentConversationId,
isStreaming,
streamController,
isInitialized,
isLoading,
// 分享多选模式状态
isSelectMode,
selectedConversationIds,
// 消息分享选择模式状态
isMessageSelectMode,
selectedMessageIds,
sourceConversationId,
selectedMessages,
selectedMessageCount,
// 计算属性
currentConversation,
sortedConversations,
pinnedConversations,
recentConversations,
selectedConversations,
selectedCount,
// 方法
initializeFromApi,
createConversation,
deleteConversation,
selectConversation,
@ -273,13 +638,30 @@ export const useChatStore = defineStore("chat", () => {
renameConversation,
updateConversationSettings,
addMessage,
addSystemMessage,
updateMessage,
updateMessageContent,
saveConversation,
setMessageFeedback,
setMessageCopied,
startStreaming,
stopStreaming,
clearConversation,
loadFromStorage,
saveToStorage,
// 分享多选模式方法
toggleSelectMode,
enterSelectMode,
exitSelectMode,
toggleConversationSelection,
selectAllConversations,
clearSelection,
isConversationSelected,
// 消息分享选择模式方法
enterMessageSelectMode,
exitMessageSelectMode,
toggleMessageSelection,
selectAllMessages,
isMessageSelected,
};
});

View File

@ -2,6 +2,14 @@ import { defineStore } from "pinia";
import { ref } from "vue";
import type { AppSettings, AIModel } from "@/types/chat";
// 分享结果类型
export interface ShareResult {
shareId: string;
shareUrl: string;
password: string;
expiresAt: number;
}
export const useSettingsStore = defineStore("settings", () => {
// 默认设置
const defaultSettings: AppSettings = {
@ -11,12 +19,12 @@ export const useSettingsStore = defineStore("settings", () => {
fontSize: "medium",
// 对话设置
sendOnEnter: false,
sendOnEnter: true,
showTimestamp: true,
compactMode: false,
// AI 默认设置
defaultModel: "glm-4.6",
defaultModel: "glm-4.6v",
defaultTemperature: 0.7,
defaultMaxTokens: 4096,
defaultSystemPrompt: "你是一个有帮助的 AI 助手。",
@ -86,6 +94,11 @@ export const useSettingsStore = defineStore("settings", () => {
const showSettingsModal = 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"]) {
const root = document.documentElement;
@ -175,6 +188,31 @@ export const useSettingsStore = defineStore("settings", () => {
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>) {
Object.assign(settings.value, updates);
@ -298,6 +336,10 @@ export const useSettingsStore = defineStore("settings", () => {
showSettingsModal,
showConversationSettingsModal,
availableModels,
// 分享相关状态
showShareModal,
showShareResultModal,
shareResult,
// 方法
toggleTheme,
@ -313,6 +355,13 @@ export const useSettingsStore = defineStore("settings", () => {
closeSettingsModal,
openConversationSettingsModal,
closeConversationSettingsModal,
// 分享模态框方法
openShareModal,
closeShareModal,
openShareResultModal,
closeShareResultModal,
setShareResult,
clearShareResult,
updateSettings,
resetSettings,
exportSettings,

View File

@ -146,3 +146,13 @@ export interface AIModel {
provider: string;
icon?: string;
}
// 用户信息
export interface UserInfo {
id: string;
username?: string;
nickname?: string;
email?: string;
avatar?: string;
[key: string]: unknown;
}

79
src/types/share.ts Normal file
View File

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

45
src/utils/crypto.ts Normal file
View File

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

140
src/utils/migrateData.ts Normal file
View File

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

90
src/views/HomeView.vue Normal file
View File

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

566
src/views/ShareView.vue Normal file
View File

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

View File

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

View File

@ -7,7 +7,7 @@ export default defineConfig({
plugins: [vue(), UnoCSS()],
// 基础路径
base: "/chat-ui/",
base: "/",
resolve: {
alias: {
@ -18,7 +18,11 @@ export default defineConfig({
host: "0.0.0.0",
proxy: {
"/api/chat-ui": {
target: "http://localhost:8000", // Python服务器端口
target: "http://localhost:8002", // Python服务器端口
changeOrigin: true,
},
"/api/auth": {
target: "https://sxwz.xueai.art",
changeOrigin: true,
},
},

35
vitest.config.ts Normal file
View File

@ -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'),
},
},
})