Compare commits

..

2 Commits

Author SHA1 Message Date
SuperManTouX 06ebc8cdb2 Merge branch 'main' into feat/database 2026-03-06 18:00:58 +08:00
SuperManTouX 4bff571f2b feat: 创建SQLite数据库 2026-03-06 17:59:03 +08:00
73 changed files with 2707 additions and 8984 deletions

15
.gitignore vendored
View File

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

271
README.md
View File

@ -1,6 +1,6 @@
# AI-CHAT-UI
一个基于 Vue 和 markstream-vue 构建的现代化 AI 对话界面,提供丰富的交互功能和精美的视觉体验。支持多模型集成,包括智谱 GLM、阿里云百炼、OpenAI/Deepseek 等。
一个基于 Vue 和 markstream-vue 构建的现代化 AI 对话界面,提供丰富的交互功能和精美的视觉体验。
## 页面展示
@ -14,8 +14,6 @@
## ✨ 核心功能
### 前端功能
| 功能 | 详细描述 |
|-------|-------------------------------------|
| 对话历史 | 支持多对话管理、置顶、重命名、删除 |
@ -38,106 +36,34 @@
| 数据管理 | 导入/导出设置、清除数据,保障数据安全 |
| 预设提示词 | 快速选择常用角色设定,提升对话效率 |
### 后端功能
| 功能 | 详细描述 |
|-------|-------------------------------------|
| OpenAI 兼容 API | 提供标准 OpenAI 兼容接口,支持多模型路由 |
| 多模型支持 | 集成智谱 GLM、阿里云百炼、OpenAI/Deepseek 等平台 |
| 会话管理 | 支持对话历史的保存、加载、删除 |
| 文件上传 | 支持图片、文档等文件的上传和管理 |
| 健康检查 | 提供系统状态和可用模型检查 |
| 日志系统 | 详细的请求日志和响应时间记录 |
## 🛠 技术栈
### 前端
- **核心框架**: Vue 3
- **核心框架**: Vue
- **流式渲染**: 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, 等
## 🚀 快速开始
### 1. 克隆仓库
```Bash
```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
```
## 📋 使用说明
@ -145,8 +71,11 @@ python server/main.py
### 基础操作
- **新建对话**: `Ctrl+N` 快捷键或点击页面右上角 "+" 按钮
- **切换布局**: 点击页面右下角布局切换按钮
- **主题切换**: 设置面板中选择浅色/深色/跟随系统
- **搜索对话**: 使用页面顶部搜索框或快捷键 `Ctrl+K`
### 快捷键一览
@ -158,146 +87,7 @@ python server/main.py
| 复制当前消息 | 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) 文件
@ -305,35 +95,10 @@ ai-chat-ui/
欢迎提交 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 & FastAPI</sub>
<sub>Made with ❤️ using Vue & markstream-vue</sub>
</div>
</div>

1705
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,10 +6,7 @@
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
"preview": "vite preview"
},
"dependencies": {
"@microsoft/fetch-event-source": "^2.0.1",
@ -33,18 +30,14 @@
"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,49 +10,42 @@ from typing import Dict, List
from fastapi.responses import JSONResponse, StreamingResponse
from .base import BaseAdapter, ChatCompletionRequest, ModelInfo
from .plugins import get_web_search_mode
from core import get_logger
from utils.logger 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="qwen3-max",
name="Qwen3-Max",
description="千问系列效果最好的模型,适合复杂、多步骤的任务。",
id="qwen-max",
name="通义千问 Max",
description="最强大的模型",
max_tokens=8192,
provider="Aliyun",
supports_thinking=True,
supports_web_search=True,
supports_web_search=False,
supports_vision=False,
supports_files=False,
),
ModelInfo(
id="qwen3.5-plus",
name="Qwen3.5-Plus",
description="能力均衡推理效果、成本和速度介于千问Max和千问Flash之间适合中等复杂任务。",
max_tokens=8192,
provider="Aliyun",
supports_thinking=True,
supports_web_search=True,
supports_vision=True,
supports_files=False,
),
ModelInfo(
id="qwen3.5-flash",
name="Qwen3.5-Flash",
description="千问系列速度最快、成本极低的模型适合简单任务。千问Flash采用灵活的阶梯定价相比千问Turbo计费更合理。",
id="qwen-plus",
name="通义千问 Plus",
description="能力均衡",
max_tokens=8192,
provider="Aliyun",
supports_thinking=False,
supports_web_search=True,
supports_web_search=False,
supports_vision=False,
supports_files=False,
),
ModelInfo(
id="qwen-turbo",
name="通义千问 Turbo",
description="速度更快、成本更低",
max_tokens=8192,
provider="Aliyun",
supports_thinking=False,
supports_web_search=False,
supports_vision=False,
supports_files=False,
),
@ -67,6 +60,17 @@ 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,
),
]
@ -85,14 +89,6 @@ 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
@ -108,7 +104,6 @@ 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)}"
)
@ -117,11 +112,7 @@ class DashScopeAdapter(BaseAdapter):
has_multimodal = self._has_multimodal_content(request)
logger.info(f" - has_multimodal: {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:
if has_multimodal:
return await self._multimodal_chat(request)
else:
return await self._text_chat(request)
@ -145,9 +136,6 @@ 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)
@ -175,104 +163,26 @@ 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
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
responses = Generation.call(
model=request.model,
messages=messages,
stream=True,
temperature=request.temperature,
max_tokens=request.max_tokens,
result_format="message",
)
for resp in responses:
if resp.status_code == 200:
chunk_count += 1
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
content = resp.output.choices[0].message.content
if content and len(content) > len(full_content):
# DashScope 流式响应返回完整内容,计算增量
delta = content[len(full_content) :]
@ -291,9 +201,6 @@ 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()}",
@ -309,8 +216,6 @@ 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
@ -325,64 +230,17 @@ class DashScopeAdapter(BaseAdapter):
from dashscope import Generation
# 检查是否启用深度思考
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)}"},
)
resp = Generation.call(
model=request.model,
messages=messages,
stream=False,
temperature=request.temperature,
max_tokens=request.max_tokens,
result_format="message",
)
if resp.status_code == 200:
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
content = resp.output.choices[0].message.content
response = {
"id": f"chatcmpl-{generate_unique_id()}",
"object": "chat.completion",
@ -391,7 +249,7 @@ class DashScopeAdapter(BaseAdapter):
"choices": [
{
"index": 0,
"message": response_message,
"message": {"role": "assistant", "content": content},
"finish_reason": "stop",
}
],
@ -405,11 +263,8 @@ class DashScopeAdapter(BaseAdapter):
}
# 打印响应结果
logger.info(f"[DashScope] 响应成功:")
logger.info(f" - status_code: {resp.status_code}")
logger.info(f"[DashScope] 响应结果:")
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
@ -420,10 +275,7 @@ class DashScopeAdapter(BaseAdapter):
return JSONResponse(content=response)
logger.error(f"[DashScope] 请求失败:")
logger.error(f" - status_code: {resp.status_code}")
logger.error(f" - code: {resp.code}")
logger.error(f" - message: {resp.message}")
logger.error(f"[DashScope] 请求失败: {resp.code} - {resp.message}")
return JSONResponse(
status_code=500,
content={"error": f"DashScope Error: {resp.code} - {resp.message}"},
@ -436,20 +288,13 @@ 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)
@ -493,8 +338,6 @@ 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
@ -507,129 +350,42 @@ class DashScopeAdapter(BaseAdapter):
img_url = f"file://{'/'.join(path_parts[uploads_idx:])}"
except ValueError:
pass
elif not img_url.startswith("file://") and not img_url.startswith(("http://", "https://")):
elif not img_url.startswith("file://"):
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:
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", [])
content_items = resp.output.choices[0]["message"]["content"]
text = ""
for item in content_items:
if isinstance(item, dict) and "text" in item:
text += item["text"]
# 多模态 API 返回的 content 是独立的片段(不是累积的),直接作为 delta
if text:
delta = text
full_content += text
if len(text) > len(full_content):
delta = text[len(full_content) :]
full_content = text
data = {
"id": f"chatcmpl-{generate_unique_id()}",
@ -645,10 +401,8 @@ class DashScopeAdapter(BaseAdapter):
],
}
yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
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}")
except (KeyError, IndexError, TypeError):
pass
finish = {
"id": f"chatcmpl-{generate_unique_id()}",
@ -660,19 +414,6 @@ 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(
@ -683,71 +424,22 @@ class DashScopeAdapter(BaseAdapter):
from dashscope import MultiModalConversation
# 检查是否启用深度思考
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)}"},
)
resp = MultiModalConversation.call(
model=model,
messages=messages,
stream=False,
max_tokens=request.max_tokens,
temperature=request.temperature,
)
if resp.status_code == 200:
try:
message = resp.output.choices[0]["message"]
content_items = message.get("content", [])
content_items = resp.output.choices[0]["message"]["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",
@ -756,38 +448,18 @@ class DashScopeAdapter(BaseAdapter):
"choices": [
{
"index": 0,
"message": response_message,
"message": {"role": "assistant", "content": text},
"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,24 +11,12 @@ from typing import Dict, List, Optional
from fastapi.responses import JSONResponse, StreamingResponse
from .base import BaseAdapter, ChatCompletionRequest, ModelInfo
from .plugins import get_web_search_mode, build_glm_search_tool
from core import get_logger
from utils.logger 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(推荐)",
@ -36,7 +24,7 @@ GLM_MODELS = [
max_tokens=128000,
provider="ZhipuAI",
supports_thinking=True,
supports_web_search=False,
supports_web_search=True,
supports_vision=True,
supports_files=True,
),
@ -58,18 +46,18 @@ GLM_MODELS = [
max_tokens=128000,
provider="ZhipuAI",
supports_thinking=False,
supports_web_search=False,
supports_web_search=True,
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=True,
supports_web_search=False,
supports_vision=False,
supports_files=False,
),
@ -130,10 +118,10 @@ class GLMAdapter(BaseAdapter):
# 构建额外参数
extra_kwargs = {}
web_search_mode = get_web_search_mode(request)
web_search = self._get_web_search_mode(request)
if web_search_mode:
extra_kwargs["tools"] = [build_glm_search_tool(web_search_mode)]
if web_search:
extra_kwargs["tools"] = [self._build_web_search_tool(web_search)]
extra_kwargs["tool_choice"] = "auto"
# 深度思考正向选择True 时启用False 时禁用)
@ -150,11 +138,6 @@ 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(
@ -261,6 +244,46 @@ 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,8 +10,7 @@ from typing import Dict, List, Optional
from fastapi.responses import JSONResponse, StreamingResponse
from .base import BaseAdapter, ChatCompletionRequest, ModelInfo
from .plugins import get_web_search_mode, build_openai_search_tool, execute_tavily_search, get_current_time_info
from core import get_logger
from utils.logger import get_logger
logger = get_logger()
@ -156,21 +155,6 @@ 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)}"
)
@ -183,10 +167,6 @@ class OpenAIAdapter(BaseAdapter):
"max_tokens": request.max_tokens,
"stream": request.stream,
}
if web_search_mode:
search_tool = build_openai_search_tool(web_search_mode)
kwargs["tools"] = [search_tool]
# DeepSeek 深度思考支持
extra_body = None
@ -239,27 +219,17 @@ class OpenAIAdapter(BaseAdapter):
def generator():
from utils.helpers import generate_unique_id, get_current_timestamp
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
resp = client.chat.completions.create(**kwargs)
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
@ -268,27 +238,7 @@ class OpenAIAdapter(BaseAdapter):
delta_content["reasoning_content"] = delta.reasoning_content
full_reasoning += delta.reasoning_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:
if delta_content:
data = {
"id": f"chatcmpl-{generate_unique_id()}",
"object": "chat.completion.chunk",
@ -303,72 +253,28 @@ class OpenAIAdapter(BaseAdapter):
],
}
yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
# 检查此轮请求是否收到了完整工具调用,若是则执行搜索逻辑并追加继续请求,不再让外部函数退出
if tool_calls:
logger.info(f"[{provider_name}] 检测到流式中包含了工具调用进行拦截并处理: {json.dumps(tool_calls, ensure_ascii=False)}")
# 把大模型的工具调用请求也追加进去
assistant_msg = {
"role": "assistant",
"content": full_content or None, # 如果工具和普通内容同时存在也保留
"tool_calls": tool_calls
}
if full_reasoning:
assistant_msg["reasoning_content"] = full_reasoning
elif self._provider_type == "deepseek" and self._supports_thinking(kwargs["model"]):
# DeepSeek 推理模型在有工具调用时必须有 reasoning_content 字段
assistant_msg["reasoning_content"] = ""
kwargs["messages"].append(assistant_msg)
for tc in tool_calls:
if tc["function"]["name"] == "web_search":
try:
args = json.loads(tc["function"]["arguments"])
query = args.get("query", "")
mode = "deep" if "advanced" in str(kwargs.get("tools", [])) else "simple"
logger.info(f"[{provider_name}] 执行搜索插件: {query}")
search_result = execute_tavily_search(query, mode=mode)
except Exception as e:
search_result = f"获取搜索参数或执行搜索失败: {str(e)}"
logger.error(search_result)
# 把执行结果告诉大模型
kwargs["messages"].append({
"role": "tool",
"tool_call_id": tc["id"],
"name": "web_search",
"content": search_result
})
# 工具执行完毕,继续发起下一轮请求大模型归纳总结输出
continue
# 如果没有工具调用或者全部分发完毕,正常结束给前端
finish = {
"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"
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
# 打印流式响应结果
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}"
)
return StreamingResponse(generator(), media_type="text/event-stream")
@ -378,58 +284,10 @@ class OpenAIAdapter(BaseAdapter):
"""非流式聊天"""
from utils.helpers import generate_unique_id, get_current_timestamp
while True:
resp = client.chat.completions.create(**kwargs)
resp = client.chat.completions.create(**kwargs)
message = resp.choices[0].message
# 判断是否涉及工具调用
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 ""
message = resp.choices[0].message
content = message.content or ""
response = {
"id": f"chatcmpl-{generate_unique_id()}",
"object": "chat.completion",

View File

@ -1,119 +0,0 @@
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 core import log_error, log_exception, log_info
from utils.logger 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(user_id: str = "default"):
async def get_conversations_handler():
"""获取所有对话处理器"""
db = get_db()
return db.list_conversations(user_id)
return db.list_conversations()
async def get_conversation_handler(conversation_id: str):
@ -65,38 +65,8 @@ 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": "删除成功"}
@ -104,85 +74,6 @@ 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="消息不存在")
# ── 文件上传 ─────────────────────────────────────────────────────
@ -306,26 +197,4 @@ async def stop_generation_handler(message_id: str = None):
message = (
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)}")
return {"success": True, "message": message}

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 core import get_logger
from utils.logger import get_logger
logger = get_logger()

View File

@ -1,230 +0,0 @@
"""
分享功能路由 - 创建分享验证密码获取分享内容
"""
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"]),
},
}

View File

@ -56,4 +56,4 @@ def get_available_providers() -> list:
# 默认平台
DEFAULT_PROVIDER = os.getenv("DEFAULT_PROVIDER", "glm")
DEFAULT_PROVIDER = os.getenv("DEFAULT_PROVIDER", "glm")

View File

@ -1,23 +0,0 @@
"""
配置模块
提供统一的配置管理功能包括平台配置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",
]

View File

@ -1,41 +0,0 @@
"""
日志模块
提供统一的日志管理功能支持结构化日志文件轮转多级别日志等
"""
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",
]

BIN
server/data/chat.db Normal file

Binary file not shown.

View File

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

View File

@ -76,62 +76,11 @@ class Database:
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 ─────────────────────────────────────────────────────
@ -403,106 +352,6 @@ class Database:
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():
"""初始化数据库(应用启动时调用)"""

View File

@ -1,40 +1,40 @@
#!/usr/bin/env python
"""
初始化日志系统
"""
import os
from .logger import setup_global_logger
def init_logging_system():
"""
初始化日志系统
"""
# 从环境变量获取日志配置,如果没有则使用默认值
log_level = os.getenv("LOG_LEVEL", "INFO")
log_dir = os.getenv("LOG_DIR", "logs")
# 尝试从配置文件读取值
try:
with open("logging.conf", "r", encoding="utf-8") as f:
for line in f:
if line.startswith("LOG_LEVEL="):
log_level = line.split("=", 1)[1].strip()
elif line.startswith("LOG_DIR="):
log_dir = line.split("=", 1)[1].strip()
except FileNotFoundError:
pass # 如果配置文件不存在,则使用环境变量或默认值
# 设置全局日志系统
logger = setup_global_logger(
name="ai-chat-api", log_level=log_level, log_dir=log_dir
)
return logger
if __name__ == "__main__":
logger = init_logging_system()
logger.info("Logging system initialized successfully")
#!/usr/bin/env python
"""
初始化日志系统
"""
import os
from utils.logger import setup_global_logger
def init_logging_system():
"""
初始化日志系统
"""
# 从环境变量获取日志配置,如果没有则使用默认值
log_level = os.getenv("LOG_LEVEL", "INFO")
log_dir = os.getenv("LOG_DIR", "logs")
# 尝试从配置文件读取值
try:
with open("logging.conf", "r", encoding="utf-8") as f:
for line in f:
if line.startswith("LOG_LEVEL="):
log_level = line.split("=", 1)[1].strip()
elif line.startswith("LOG_DIR="):
log_dir = line.split("=", 1)[1].strip()
except FileNotFoundError:
pass # 如果配置文件不存在,则使用环境变量或默认值
# 设置全局日志系统
logger = setup_global_logger(
name="ai-chat-api", log_level=log_level, log_dir=log_dir
)
return logger
if __name__ == "__main__":
logger = init_logging_system()
logger.info("Logging system initialized successfully")

14
server/logging.conf Normal file
View File

@ -0,0 +1,14 @@
# 日志配置文件
# 可以在 .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 core import get_logger
from utils.logger import get_logger
logger = get_logger()
@ -54,23 +54,14 @@ init_db()
load_dotenv()
# ── 会话管理路由处理器 ────────────────────────────────────────────────
from api.conversation_routes import (add_message_handler,
delete_attachment_handler,
delete_conversation_handler,
from api.conversation_routes import (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
@ -108,7 +99,7 @@ async def logging_middleware(request: Request, call_next):
@app.get("/health")
async def health_check():
from config.settings import get_available_providers
from config import get_available_providers
return {
"status": "healthy",
@ -118,7 +109,6 @@ 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(),
}
@ -180,8 +170,8 @@ async def get_models():
@app.get("/api/chat-ui/conversations")
async def get_conversations(user_id: str = "default"):
return await get_conversations_handler(user_id)
async def get_conversations():
return await get_conversations_handler()
@app.get("/api/chat-ui/conversations/{conversation_id}")
@ -199,21 +189,6 @@ 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)
@ -234,40 +209,14 @@ 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", 8002))
port = int(os.getenv("PORT", 8000))
# 获取可用平台
from config.settings import get_available_providers
from config import get_available_providers
available = get_available_providers()
@ -280,7 +229,7 @@ if __name__ == "__main__":
print(f" 可用平台 : {', '.join(available) or '无(请配置 API Key'}")
print("-" * 60)
print(" 使用方法:")
print(f" curl -X POST http://localhost:{port}/v1/chat/completions \\")
print(" curl -X POST http://localhost:8000/v1/chat/completions \\")
print(' -H "Content-Type: application/json" \\')
print(' -d \'{"model":"glm-4-flash","messages":[{"role":"user","content":"hi"}]}\'')
print("=" * 60)

View File

@ -1,45 +0,0 @@
"""
认证中间件 - 预留接口
当前返回默认用户未来可集成 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,103 +1,13 @@
aiofiles==24.1.0
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
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

View File

@ -0,0 +1,67 @@
"""
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)

523
server/utils/glm_adapter.py Normal file
View File

@ -0,0 +1,523 @@
"""
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 core import (log_chat_interaction, log_error_detail, log_request_info,
from .logger import (log_chat_interaction, log_error_detail, log_request_info,
log_response_info)

View File

@ -1,277 +1,277 @@
"""
统一日志管理系统
提供结构化日志记录功能支持不同日志级别文件输出轮转等
"""
import json
import logging
import os
import sys
from datetime import datetime
from logging.handlers import RotatingFileHandler
from pathlib import Path
class LoggerSetup:
"""日志系统配置类"""
def __init__(
self,
name: str = "ai-chat-server",
log_level: str = "INFO",
log_dir: str = "logs",
max_bytes: int = 10 * 1024 * 1024,
backup_count: int = 5,
):
"""
初始化日志系统
Args:
name: 日志记录器名称
log_level: 日志级别 ('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL')
log_dir: 日志文件存储目录
max_bytes: 单个日志文件最大大小字节
backup_count: 保留的备份文件数量
"""
self.name = name
self.log_level = getattr(logging, log_level.upper(), logging.INFO)
self.log_dir = Path(log_dir)
self.max_bytes = max_bytes
self.backup_count = backup_count
# 创建日志目录
self.log_dir.mkdir(exist_ok=True)
# 设置日志格式(去掉 funcName:lineno保持人类可读性
self.formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
# 创建logger实例
self.logger = self._setup_logger()
def _setup_logger(self):
"""设置logger实例"""
logger = logging.getLogger(self.name)
logger.setLevel(self.log_level)
# 避免重复添加处理器
if logger.handlers:
logger.handlers.clear()
# 控制台处理器
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(self.log_level)
console_handler.setFormatter(self.formatter)
logger.addHandler(console_handler)
# 文件处理器 - 按日期分割
date_str = datetime.now().strftime("%Y-%m-%d")
log_file = self.log_dir / f"{self.name}_{date_str}.log"
file_handler = RotatingFileHandler(
str(log_file),
maxBytes=self.max_bytes,
backupCount=self.backup_count,
encoding="utf-8",
)
file_handler.setLevel(self.log_level)
file_handler.setFormatter(self.formatter)
logger.addHandler(file_handler)
return logger
def get_logger(self):
"""获取配置好的logger实例"""
return self.logger
# 全局日志实例
_logger_instance = None
def setup_global_logger(
name: str = "ai-chat-server",
log_level: str = "INFO",
log_dir: str = "logs",
max_bytes: int = 10 * 1024 * 1024,
backup_count: int = 5,
):
"""
设置全局日志系统
Args:
name: 日志记录器名称
log_level: 日志级别
log_dir: 日志文件目录
max_bytes: 最大文件大小
backup_count: 备份文件数
"""
global _logger_instance
logger_setup = LoggerSetup(name, log_level, log_dir, max_bytes, backup_count)
_logger_instance = logger_setup.get_logger()
return _logger_instance
def get_logger(name: str = None):
"""
获取日志记录器实例
Args:
name: 如果提供返回子记录器否则返回全局记录器
"""
global _logger_instance
if _logger_instance is None:
# 如果没有初始化,默认创建一个
_logger_instance = setup_global_logger()
if name and name != _logger_instance.name:
return _logger_instance.getChild(name)
return _logger_instance
# 便捷的日志记录函数
def log_debug(message: str, *args, **kwargs):
"""记录DEBUG级别日志"""
logger = get_logger()
logger.debug(message, *args, **kwargs)
def log_info(message: str, *args, **kwargs):
"""记录INFO级别日志"""
logger = get_logger()
logger.info(message, *args, **kwargs)
def log_warning(message: str, *args, **kwargs):
"""记录WARNING级别日志"""
logger = get_logger()
logger.warning(message, *args, **kwargs)
def log_error(message: str, *args, **kwargs):
"""记录ERROR级别日志"""
logger = get_logger()
logger.error(message, *args, **kwargs)
def log_critical(message: str, *args, **kwargs):
"""记录CRITICAL级别日志"""
logger = get_logger()
logger.critical(message, *args, **kwargs)
def log_exception(message: str = ""):
"""记录异常信息"""
logger = get_logger()
logger.exception(message)
def log_structured(level: str, message: str, **details):
"""
记录结构化日志
Args:
level: 日志级别
message: 日志消息
**details: 额外的结构化数据
"""
logger = get_logger()
# 为了开发时的可读性,不再使用单行 JSON 打印全结构
# 转换为更易读的格式
detail_str = ", ".join(f"{k}={v}" for k, v in details.items() if v)
formatted_msg = f"[{message}] {detail_str}"
getattr(logger, level.lower())(formatted_msg)
def log_request_info(
method: str,
path: str,
client_ip: str = "unknown",
user_agent: str = "",
referer: str = "",
):
"""记录请求信息日志"""
log_structured(
"info",
"API Request",
method=method,
path=path,
client_ip=client_ip,
user_agent=user_agent,
referer=referer,
)
def log_response_info(
status_code: int,
process_time: float,
path: str = "",
method: str = "",
client_ip: str = "",
):
"""记录响应信息日志"""
log_structured(
"info",
"API Response",
status_code=status_code,
process_time_ms=process_time,
path=path,
method=method,
client_ip=client_ip,
)
def log_error_detail(
error_type: str, error_message: str, traceback_info: str = "", context: dict = None
):
"""记录详细的错误信息"""
log_structured(
"error",
f"{error_type}: {error_message}",
traceback=traceback_info,
context=context or {},
)
def log_chat_interaction(
user_input: str,
ai_response: str,
model: str = "",
conversation_id: str = "",
tokens_used: dict = None,
):
"""记录聊天交互日志"""
log_structured(
"info",
"Chat Interaction",
user_input=(
user_input[:100] + "..." if len(user_input) > 100 else user_input
), # 截断长输入
ai_response=(
ai_response[:100] + "..." if len(ai_response) > 100 else ai_response
),
model=model,
conversation_id=conversation_id,
tokens_used=tokens_used,
)
def log_system_status(
status: str,
uptime: float = 0,
cpu_usage: float = 0,
memory_usage: float = 0,
disk_usage: float = 0,
):
"""记录系统状态日志"""
log_structured(
"info",
"System Status",
status=status,
uptime_seconds=uptime,
cpu_percent=cpu_usage,
memory_percent=memory_usage,
disk_percent=disk_usage,
)
"""
统一日志管理系统
提供结构化日志记录功能支持不同日志级别文件输出轮转等
"""
import json
import logging
import os
import sys
from datetime import datetime
from logging.handlers import RotatingFileHandler
from pathlib import Path
class LoggerSetup:
"""日志系统配置类"""
def __init__(
self,
name: str = "ai-chat-server",
log_level: str = "INFO",
log_dir: str = "logs",
max_bytes: int = 10 * 1024 * 1024,
backup_count: int = 5,
):
"""
初始化日志系统
Args:
name: 日志记录器名称
log_level: 日志级别 ('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL')
log_dir: 日志文件存储目录
max_bytes: 单个日志文件最大大小字节
backup_count: 保留的备份文件数量
"""
self.name = name
self.log_level = getattr(logging, log_level.upper(), logging.INFO)
self.log_dir = Path(log_dir)
self.max_bytes = max_bytes
self.backup_count = backup_count
# 创建日志目录
self.log_dir.mkdir(exist_ok=True)
# 设置日志格式(去掉 funcName:lineno保持人类可读性
self.formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
# 创建logger实例
self.logger = self._setup_logger()
def _setup_logger(self):
"""设置logger实例"""
logger = logging.getLogger(self.name)
logger.setLevel(self.log_level)
# 避免重复添加处理器
if logger.handlers:
logger.handlers.clear()
# 控制台处理器
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(self.log_level)
console_handler.setFormatter(self.formatter)
logger.addHandler(console_handler)
# 文件处理器 - 按日期分割
date_str = datetime.now().strftime("%Y-%m-%d")
log_file = self.log_dir / f"{self.name}_{date_str}.log"
file_handler = RotatingFileHandler(
str(log_file),
maxBytes=self.max_bytes,
backupCount=self.backup_count,
encoding="utf-8",
)
file_handler.setLevel(self.log_level)
file_handler.setFormatter(self.formatter)
logger.addHandler(file_handler)
return logger
def get_logger(self):
"""获取配置好的logger实例"""
return self.logger
# 全局日志实例
_logger_instance = None
def setup_global_logger(
name: str = "ai-chat-server",
log_level: str = "INFO",
log_dir: str = "logs",
max_bytes: int = 10 * 1024 * 1024,
backup_count: int = 5,
):
"""
设置全局日志系统
Args:
name: 日志记录器名称
log_level: 日志级别
log_dir: 日志文件目录
max_bytes: 最大文件大小
backup_count: 备份文件数
"""
global _logger_instance
logger_setup = LoggerSetup(name, log_level, log_dir, max_bytes, backup_count)
_logger_instance = logger_setup.get_logger()
return _logger_instance
def get_logger(name: str = None):
"""
获取日志记录器实例
Args:
name: 如果提供返回子记录器否则返回全局记录器
"""
global _logger_instance
if _logger_instance is None:
# 如果没有初始化,默认创建一个
_logger_instance = setup_global_logger()
if name and name != _logger_instance.name:
return _logger_instance.getChild(name)
return _logger_instance
# 便捷的日志记录函数
def log_debug(message: str, *args, **kwargs):
"""记录DEBUG级别日志"""
logger = get_logger()
logger.debug(message, *args, **kwargs)
def log_info(message: str, *args, **kwargs):
"""记录INFO级别日志"""
logger = get_logger()
logger.info(message, *args, **kwargs)
def log_warning(message: str, *args, **kwargs):
"""记录WARNING级别日志"""
logger = get_logger()
logger.warning(message, *args, **kwargs)
def log_error(message: str, *args, **kwargs):
"""记录ERROR级别日志"""
logger = get_logger()
logger.error(message, *args, **kwargs)
def log_critical(message: str, *args, **kwargs):
"""记录CRITICAL级别日志"""
logger = get_logger()
logger.critical(message, *args, **kwargs)
def log_exception(message: str = ""):
"""记录异常信息"""
logger = get_logger()
logger.exception(message)
def log_structured(level: str, message: str, **details):
"""
记录结构化日志
Args:
level: 日志级别
message: 日志消息
**details: 额外的结构化数据
"""
logger = get_logger()
# 为了开发时的可读性,不再使用单行 JSON 打印全结构
# 转换为更易读的格式
detail_str = ", ".join(f"{k}={v}" for k, v in details.items() if v)
formatted_msg = f"[{message}] {detail_str}"
getattr(logger, level.lower())(formatted_msg)
def log_request_info(
method: str,
path: str,
client_ip: str = "unknown",
user_agent: str = "",
referer: str = "",
):
"""记录请求信息日志"""
log_structured(
"info",
"API Request",
method=method,
path=path,
client_ip=client_ip,
user_agent=user_agent,
referer=referer,
)
def log_response_info(
status_code: int,
process_time: float,
path: str = "",
method: str = "",
client_ip: str = "",
):
"""记录响应信息日志"""
log_structured(
"info",
"API Response",
status_code=status_code,
process_time_ms=process_time,
path=path,
method=method,
client_ip=client_ip,
)
def log_error_detail(
error_type: str, error_message: str, traceback_info: str = "", context: dict = None
):
"""记录详细的错误信息"""
log_structured(
"error",
f"{error_type}: {error_message}",
traceback=traceback_info,
context=context or {},
)
def log_chat_interaction(
user_input: str,
ai_response: str,
model: str = "",
conversation_id: str = "",
tokens_used: dict = None,
):
"""记录聊天交互日志"""
log_structured(
"info",
"Chat Interaction",
user_input=(
user_input[:100] + "..." if len(user_input) > 100 else user_input
), # 截断长输入
ai_response=(
ai_response[:100] + "..." if len(ai_response) > 100 else ai_response
),
model=model,
conversation_id=conversation_id,
tokens_used=tokens_used,
)
def log_system_status(
status: str,
uptime: float = 0,
cpu_usage: float = 0,
memory_usage: float = 0,
disk_usage: float = 0,
):
"""记录系统状态日志"""
log_structured(
"info",
"System Status",
status=status,
uptime_seconds=uptime,
cpu_percent=cpu_usage,
memory_percent=memory_usage,
disk_percent=disk_usage,
)

View File

@ -33,9 +33,10 @@ from dotenv import load_dotenv
# ── 加载环境变量 ──────────────────────────────────────────────
load_dotenv()
# 所有配置从 .env 文件读取
OSS_ACCESS_KEY_ID = os.getenv("OSS_ACCESS_KEY_ID", "")
OSS_ACCESS_KEY_SECRET = os.getenv("OSS_ACCESS_KEY_SECRET", "")
# 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 文件读取
OSS_BUCKET_NAME = os.getenv("OSS_BUCKET_NAME", "")
OSS_ENDPOINT = os.getenv("OSS_ENDPOINT", "")
OSS_REGION = os.getenv("OSS_REGION", "")
@ -56,12 +57,11 @@ def _get_client() -> oss.Client:
return oss.Client(cfg)
def _generate_object_key(filename: str, prefix: str = "chat-ui") -> str:
def _generate_object_key(filename: str, prefix: str = "uploads") -> 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 = "chat-ui",
prefix: str = "uploads",
) -> dict:
"""
上传本地文件到 OSS
@ -204,99 +204,6 @@ 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,84 +1,37 @@
"""
GLM 适配器测试脚本
测试 GLMAdapter 的流式和非流式调用包括联网搜索功能
"""
import asyncio
import os
import sys
from pathlib import Path
# Add project root to sys.path
root_dir = Path(__file__).parent.parent
sys.path.insert(0, str(root_dir))
from dotenv import load_dotenv
from adapters.glm_adapter import GLMAdapter
from adapters.base import ChatCompletionRequest
load_dotenv()
async def test_stream():
"""测试流式调用(联网搜索)"""
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="")
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__":
# 运行流式测试
# asyncio.run(test_stream())
# 运行非流式测试
asyncio.run(test_sync())
import asyncio
import os
import sys
from pathlib import Path
# Add project root to sys.path
root_dir = Path(__file__).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
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
):
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)
if __name__ == "__main__":
_ensure_venv()
# test_sync()
asyncio.run(test_stream())

View File

@ -0,0 +1,171 @@
"""
测试脚本上传 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

@ -0,0 +1,89 @@
"""
测试脚本上传文件到 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

@ -1,46 +0,0 @@
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,6 +1,16 @@
<template>
<div class="app" :class="{ dark: isDark }">
<router-view />
<!-- 侧边栏 -->
<ChatSidebar />
<!-- 主内容区 -->
<ChatMain ref="chatMainRef" @toggle-sidebar="toggleSidebar" />
<!-- 模态框 -->
<SearchModal />
<ShortcutsModal />
<SettingsModal />
<ConversationSettingsModal />
<!-- Toast 通知 -->
<Teleport to="body">
@ -22,14 +32,28 @@
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import { ref, computed, onMounted } 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") {
@ -60,6 +84,54 @@ 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>
@ -147,4 +219,4 @@ window.$toast = showToast;
.toast-move {
transition: transform 0.3s ease;
}
</style>
</style>

View File

@ -1,66 +0,0 @@
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

@ -1,116 +0,0 @@
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

@ -1,64 +0,0 @@
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)
})
})
})

View File

@ -1,67 +0,0 @@
// 测试环境全局配置
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

@ -1,150 +0,0 @@
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,6 +90,7 @@ import { ref } from "vue";
import {
Menu,
Trash2,
Download,
ChevronLeft,
ExternalLink,
Pin,

View File

@ -28,13 +28,6 @@
<!-- 输入区域 -->
<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"
@ -59,11 +52,9 @@ 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";
@ -74,7 +65,6 @@ defineEmits<{
const chatStore = useChatStore();
const settingsStore = useSettingsStore();
const authStore = useAuthStore();
const { currentConversation, isStreaming } = storeToRefs(chatStore);
const { settings, sidebarCollapsed } = storeToRefs(settingsStore);
@ -127,14 +117,6 @@ 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;
}
@ -180,16 +162,8 @@ 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);
@ -222,49 +196,31 @@ async function handleSend(
//
if (!currentConversation.value) {
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,
});
chatStore.createConversation();
}
//
const updatedMessages = currentConversation.value?.messages || [];
const existingMessages = currentConversation.value?.messages || [];
const MAX_HISTORY_ROUNDS = 20; // 20 40
const historyMessages = updatedMessages.filter((m: any) => m.content?.text) //
const historyMessages = existingMessages
.filter(
(m: any) =>
m.role === MessageRole.USER || m.role === MessageRole.ASSISTANT,
)
.filter((m: any) => m.content?.text) //
.slice(-(MAX_HISTORY_ROUNDS * 2))
.map((m: any) => ({ role: m.role, content: m.content.text }));
//
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"),
});
}
//
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 = await chatStore.addMessage(MessageRole.ASSISTANT, {
const aiMessage = chatStore.addMessage(MessageRole.ASSISTANT, {
type: MessageType.TEXT,
text: "",
});
@ -289,7 +245,7 @@ async function handleSend(
const stream = chatApi.streamChat(
{
message: options?.skipUserMessage ? "直接输出系统提示词要求你的回答" : text,
message: text,
conversationId: currentConversation.value?.id || "",
images: imageUrls,
files: fileUrls,
@ -381,12 +337,6 @@ 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;
@ -495,11 +445,7 @@ function handleRegenerate(messageId: string) {
}
function handleSuggestion(suggestion: Suggestion) {
handleSend(suggestion.text, [], {
systemPrompt: suggestion.systemPrompt,
skipUserMessage: true,
conversationTitle: suggestion.text,
});
handleSend(suggestion.text, [], { systemPrompt: suggestion.systemPrompt });
}
function focusInput() {
@ -538,14 +484,14 @@ watch(
&.wide-mode {
.input-container {
// min-width: 1000px;
min-width: 1000px;
}
}
}
.input-wrapper {
flex-shrink: 0;
padding: 16px 10% 24px;
padding: 16px 150px 24px;
background: linear-gradient(to top, white 80%, transparent);
.dark & {
@ -553,25 +499,14 @@ 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,33 +1,9 @@
<template>
<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="boxRef" class="message-list-container">
<div ref="containerRef" class="message-list" @scroll="handleScroll">
<!-- 欢迎界面 -->
<WelcomeScreen
v-if="visibleMessages.length === 0"
v-if="messages.length === 0"
@select="$emit('select-suggestion', $event)"
/>
@ -36,14 +12,12 @@
<div class="messages-wrapper">
<TransitionGroup name="message">
<MessageBubble
v-for="(message, index) in visibleMessages"
v-for="(message, index) in messages"
:key="message.id"
:message="message"
:show-timestamp="showTimestamp"
:compact="compact"
:is-New="index === visibleMessages.length - 1"
:is-message-select-mode="isMessageSelectMode"
:is-selected="isMessageSelected(message.id)"
:is-New="index === messages.length - 1"
@retry="$emit('retry', message.id)"
@regenerate="$emit('regenerate', message.id)"
@copy="handleCopy(message)"
@ -53,8 +27,6 @@
@preview-image="handlePreviewImage"
@play-video="handlePlayVideo"
@download-file="handleDownloadFile"
@toggle-select="handleToggleMessageSelect(message.id)"
@enter-select-mode="handleEnterSelectMode(message.id)"
/>
</TransitionGroup>
@ -90,14 +62,12 @@
</template>
<script setup lang="ts">
import { ref, watch, nextTick, onMounted, computed } from "vue";
import { ref, watch, nextTick, onMounted } 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<{
@ -113,13 +83,6 @@ const props = withDefaults(
},
);
//
const visibleMessages = computed(() => {
return props.messages.filter(
(message) => message.role !== MessageRole.SYSTEM
);
});
const emit = defineEmits<{
retry: [messageId: string];
regenerate: [messageId: string];
@ -130,14 +93,10 @@ const emit = defineEmits<{
}>();
const chatStore = useChatStore();
const settingsStore = useSettingsStore();
//
const isMessageSelectMode = computed(() => chatStore.isMessageSelectMode);
const selectedMessageCount = computed(() => chatStore.selectedMessageCount);
//
const containerRef = ref<HTMLElement | null>(null);
const boxRef: any = ref<HTMLElement | null>(null);
const containerRef: any = ref<HTMLElement | null>(null);
const showScrollButton = ref(false);
const newMessageCount = ref(0);
const isAutoScrolling = ref(true);
@ -214,36 +173,9 @@ 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(
() => visibleMessages.value.length,
() => props.messages.length,
(newLen, oldLen) => {
if (newLen > oldLen) {
if (isAutoScrolling.value) {
@ -259,7 +191,7 @@ watch(
//
watch(
() => visibleMessages.value[visibleMessages.value.length - 1]?.content.text,
() => props.messages[props.messages.length - 1]?.content.text,
() => {
if (isAutoScrolling.value) {
nextTick(() => {
@ -290,10 +222,7 @@ defineExpose({
});
onMounted(() => {
//
if (visibleMessages.value.length > 0) {
scrollToBottom(false);
}
scrollToBottom(false);
});
</script>
@ -469,97 +398,4 @@ 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,7 +2,16 @@
<div class="welcome-screen">
<!-- Logo 和标题 -->
<div class="welcome-header">
<h1 class="title">学习教学科研聊天助手</h1>
<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>
</div>
<!-- 功能卡片 -->
@ -54,6 +63,7 @@
<script setup lang="ts">
import { computed } from "vue";
import {
Bot,
MessageSquare,
Code,
Image,

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -8,21 +8,11 @@
'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">
@ -34,14 +24,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">
@ -174,9 +164,7 @@
v-if="
message.role === 'assistant' &&
!message.isStreaming &&
!message.isError &&
!readonly &&
!isMessageSelectMode
!message.isError
"
:content="message.content.text || ''"
:feedback="message.feedback"
@ -188,7 +176,6 @@
@like="handleLike"
@dislike="handleDislike"
@regenerate="$emit('regenerate')"
@share="handleShareClick"
/>
</div>
</div>
@ -196,7 +183,7 @@
<script setup lang="ts">
import { useClipboard } from "@vueuse/core";
import { ref } from "vue";
import { ref, computed } from "vue";
// markstream-vue
import MarkdownRender from "markstream-vue";
import { setCustomComponents } from "markstream-vue";
@ -208,10 +195,10 @@ import {
Zap,
Maximize2,
Play,
Check,
Download,
} from "@/components/icons";
import MessageActions from "./MessageActions.vue";
import { formatFileSize, getFileIcon } from "@/utils/helpers";
import { formatTimestamp, 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";
@ -222,16 +209,10 @@ 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 });
@ -245,28 +226,13 @@ 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);
//
function handleBubbleClick() {
if (props.isMessageSelectMode) {
emit("toggle-select");
}
}
//
function handleToggleSelect() {
emit("toggle-select");
}
//
function handleShareClick() {
emit("enter-select-mode");
}
const formattedTime = computed(() => {
return formatTimestamp(props.message.timestamp);
});
function getFileEmoji(mimeType?: string) {
return getFileIcon(mimeType || "");
@ -310,31 +276,9 @@ setCustomComponents("playground-demo", {
.message-bubble {
display: flex;
gap: 16px;
padding: 20px 10%;
padding: 20px 150px;
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;
@ -491,7 +435,7 @@ setCustomComponents("playground-demo", {
// markstream-vue
.text-content {
:deep(p) {
:deep(p) {
margin: 0 0 12px;
&:last-child {
@ -667,16 +611,15 @@ setCustomComponents("playground-demo", {
}
}
.images-flex {
display: inline-flex;
flex-wrap: wrap;
gap: 7px;
.images-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 8px;
margin-top: 12px;
}
.image-item {
position: relative;
width: 130px;
aspect-ratio: 1;
border-radius: 12px;
overflow: hidden;
@ -892,41 +835,4 @@ 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: auto;
overflow: hidden;
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,25 +359,6 @@ 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,6 +400,7 @@ import {
RotateCcw,
} from "@/components/icons";
import type { AppSettings } from "@/types/chat";
import { chatApi } from "@/services/api.ts";
const settingsStore = useSettingsStore();
@ -408,10 +409,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

@ -1,579 +0,0 @@
<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

@ -1,406 +0,0 @@
<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,9 +59,6 @@
</button>
</div>
<!-- 分享按钮 -->
<ShareButton />
<!-- 搜索框 -->
<!-- <div class="search-section">
<div class="search-box" @click="openSearch">
@ -85,13 +82,10 @@
: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>
@ -108,13 +102,10 @@
: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>
@ -133,21 +124,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>
<!-- 拖拽调整宽度 -->
@ -156,17 +147,25 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { computed, 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,
@ -175,14 +174,17 @@ import {
const chatStore = useChatStore();
const settingsStore = useSettingsStore();
const { currentConversationId, pinnedConversations, recentConversations, isSelectMode, selectedConversationIds } =
const { currentConversationId, pinnedConversations, recentConversations } =
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") || "");
@ -210,11 +212,6 @@ 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");
}
//
@ -238,12 +235,24 @@ function togglePinConversation(id: string) {
chatStore.togglePinConversation(id);
}
function toggleConversationSelection(id: string) {
chatStore.toggleConversationSelection(id);
function toggleSidebar() {
settingsStore.toggleSidebar();
}
function isConversationSelected(id: string): boolean {
return selectedConversationIds.value.includes(id);
function toggleTheme() {
settingsStore.toggleTheme();
}
function openShortcuts() {
settingsStore.openShortcutsModal();
}
function openSettings() {
settingsStore.openSettingsModal();
}
function openSearch() {
settingsStore.openSearchModal();
}
//
@ -287,6 +296,7 @@ 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;
@ -696,5 +706,9 @@ if (typeof window !== "undefined") {
height: 100%;
cursor: col-resize;
z-index: 10;
&:hover {
background: rgba(59, 130, 246, 0.3);
}
}
</style>

View File

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

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

View File

@ -1,20 +0,0 @@
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,8 +2,6 @@
* Chat UI API
*
*/
import { getAuthHeaders } from './request';
// API 端点定义(固定)
const API_ENDPOINTS = {
// 发送消息(流式)
@ -121,36 +119,21 @@ class ChatApi {
}
// 将前端简化的请求翻译为 OpenAI 兼容的规范请求体
// 检查历史消息中是否已有系统消息
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 }];
}
// 构建 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 openAiRequest = {
model: request.model || "glm-4-flash",
@ -170,7 +153,7 @@ class ChatApi {
{
method: "POST",
headers: {
...getAuthHeaders(),
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify(openAiRequest),
@ -261,7 +244,9 @@ class ChatApi {
const response = await fetch(`${this.baseUrl}${API_ENDPOINTS.CHAT}`, {
method: "POST",
headers: getAuthHeaders(),
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
});
@ -279,7 +264,9 @@ class ChatApi {
async stopChat(messageId?: string) {
await fetch(`${this.baseUrl}${API_ENDPOINTS.STOP}/${messageId}`, {
method: "POST",
headers: getAuthHeaders(),
headers: {
"Content-Type": "application/json",
},
});
}
@ -339,13 +326,8 @@ 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,
});
@ -355,23 +337,6 @@ 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

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

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

View File

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

View File

@ -1,77 +0,0 @@
/**
* 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();
},
};

View File

@ -1,125 +0,0 @@
/**
*
*/
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,7 +8,6 @@ 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", () => {
// 状态
@ -16,17 +15,6 @@ 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(() => {
@ -52,64 +40,11 @@ export const useChatStore = defineStore("chat", () => {
return sortedConversations.value.filter((c) => !c.pinned && !c.archived);
});
// 选中的对话列表
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> {
// 方法
function createConversation(): string {
const newConversation: Conversation = {
id: generateId(),
title: title || "新对话",
title: "新对话",
messages: [],
createdAt: Date.now(),
updatedAt: Date.now(),
@ -118,171 +53,89 @@ export const useChatStore = defineStore("chat", () => {
settings: undefined,
};
// 乐观更新
conversations.value.unshift(newConversation);
currentConversationId.value = newConversation.id;
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;
}
saveToStorage();
return newConversation.id;
}
// 删除对话
async function deleteConversation(id: string) {
function deleteConversation(id: string) {
const index = conversations.value.findIndex((c) => c.id === id);
if (index === -1) return;
if (index !== -1) {
conversations.value.splice(index, 1);
// 保存引用以便回滚
const deletedConversation = conversations.value[index];
// 乐观更新
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;
}
}
// 选择对话
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);
if (currentConversationId.value === id) {
currentConversationId.value = conversations.value[0]?.id || null;
}
saveToStorage();
}
}
// 置顶对话
async function togglePinConversation(id: string) {
function selectConversation(id: string) {
currentConversationId.value = id;
}
function togglePinConversation(id: string) {
const conversation = conversations.value.find((c) => c.id === id);
if (!conversation) return;
// 乐观更新
conversation.pinned = !conversation.pinned;
// 异步保存
try {
await conversationApi.updateConversation(id, { pinned: conversation.pinned });
} catch (error) {
console.error("Failed to toggle pin:", error);
// 回滚
if (conversation) {
conversation.pinned = !conversation.pinned;
throw error;
saveToStorage();
}
}
// 重命名对话
async function renameConversation(id: string, newTitle: string) {
function renameConversation(id: string, newTitle: string) {
const conversation = conversations.value.find((c) => c.id === id);
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;
if (conversation) {
conversation.title = newTitle;
conversation.updatedAt = Date.now();
saveToStorage();
}
}
// 更新对话设置
async function updateConversationSettings(
function updateConversationSettings(
id: string,
convSettings: ConversationSettings
convSettings: ConversationSettings,
) {
const conversation = conversations.value.find((c) => c.id === id);
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;
if (conversation) {
conversation.settings = { ...conversation.settings, ...convSettings };
conversation.updatedAt = Date.now();
saveToStorage();
}
}
// 添加消息
async function addMessage(
function addMessage(
role: MessageRole,
content: MessageContent,
conversationId?: string
): Promise<Message> {
let targetId = conversationId || currentConversationId.value;
conversationId?: string,
): Message {
const targetId = conversationId || currentConversationId.value;
if (!targetId) {
await createConversation();
targetId = currentConversationId.value;
createConversation();
}
const conversation = conversations.value.find((c) => c.id === targetId);
const conversation = conversations.value.find(
(c) => c.id === (targetId || currentConversationId.value),
);
if (!conversation) {
throw new Error("Conversation not found");
}
const message: Message = {
const message: any = {
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 &&
@ -291,96 +144,21 @@ export const useChatStore = defineStore("chat", () => {
conversation.title = extractTitleFromMessage(content.text);
}
// 异步保存(使用增量更新)
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);
}
saveToStorage();
return 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>) {
function updateMessage(messageId: string, updates: Partial<Message>) {
const conversation = currentConversation.value;
if (!conversation) {
console.warn("[updateMessage] No current conversation");
return;
}
if (!conversation) return;
const message = conversation.messages.find((m) => m.id === messageId);
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);
if (message) {
Object.assign(message, updates);
saveToStorage();
}
}
// 更新消息内容(流式更新时使用,不触发 API 调用)
function updateMessageContent(messageId: string, text: string) {
const conversation = currentConversation.value;
if (!conversation) return;
@ -391,49 +169,24 @@ export const useChatStore = defineStore("chat", () => {
}
}
// 保存整个对话(用于流式结束后)
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(
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) 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);
if (message) {
message.feedback = {
liked: feedback === "like",
disliked: feedback === "dislike",
copied: message.feedback?.copied,
};
saveToStorage();
}
}
// 设置消息已复制
function setMessageCopied(messageId: string) {
const conversation = currentConversation.value;
if (!conversation) return;
@ -447,13 +200,11 @@ export const useChatStore = defineStore("chat", () => {
}
}
// 开始流式输出
function startStreaming() {
isStreaming.value = true;
streamController.value = new AbortController();
}
// 停止流式输出
function stopStreaming() {
isStreaming.value = false;
if (streamController.value) {
@ -462,23 +213,30 @@ export const useChatStore = defineStore("chat", () => {
}
}
// 清空对话消息
async function clearConversation(id: string) {
function clearConversation(id: string) {
const conversation = conversations.value.find((c) => c.id === id);
if (!conversation) return;
conversation.messages = [];
conversation.updatedAt = Date.now();
// 异步保存
try {
await conversationApi.updateConversation(id, { messages: [] });
} catch (error) {
console.error("Failed to clear conversation:", error);
if (conversation) {
conversation.messages = [];
conversation.updatedAt = Date.now();
saveToStorage();
}
}
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);
}
}
// 降级:从 localStorage 加载(仅在 API 不可用时使用)
function loadFromStorage() {
try {
const stored = localStorage.getItem("chat-conversations");
@ -497,140 +255,17 @@ export const useChatStore = defineStore("chat", () => {
}
}
// 保存到 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();
loadFromStorage();
return {
// 状态
conversations,
currentConversationId,
isStreaming,
streamController,
isInitialized,
isLoading,
// 分享多选模式状态
isSelectMode,
selectedConversationIds,
// 消息分享选择模式状态
isMessageSelectMode,
selectedMessageIds,
sourceConversationId,
selectedMessages,
selectedMessageCount,
// 计算属性
currentConversation,
sortedConversations,
pinnedConversations,
recentConversations,
selectedConversations,
selectedCount,
// 方法
initializeFromApi,
createConversation,
deleteConversation,
selectConversation,
@ -638,30 +273,13 @@ 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,14 +2,6 @@ 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 = {
@ -19,12 +11,12 @@ export const useSettingsStore = defineStore("settings", () => {
fontSize: "medium",
// 对话设置
sendOnEnter: true,
sendOnEnter: false,
showTimestamp: true,
compactMode: false,
// AI 默认设置
defaultModel: "glm-4.6v",
defaultModel: "glm-4.6",
defaultTemperature: 0.7,
defaultMaxTokens: 4096,
defaultSystemPrompt: "你是一个有帮助的 AI 助手。",
@ -94,11 +86,6 @@ 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;
@ -188,31 +175,6 @@ 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);
@ -336,10 +298,6 @@ export const useSettingsStore = defineStore("settings", () => {
showSettingsModal,
showConversationSettingsModal,
availableModels,
// 分享相关状态
showShareModal,
showShareResultModal,
shareResult,
// 方法
toggleTheme,
@ -355,13 +313,6 @@ export const useSettingsStore = defineStore("settings", () => {
closeSettingsModal,
openConversationSettingsModal,
closeConversationSettingsModal,
// 分享模态框方法
openShareModal,
closeShareModal,
openShareResultModal,
closeShareResultModal,
setShareResult,
clearShareResult,
updateSettings,
resetSettings,
exportSettings,

View File

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

View File

@ -1,79 +0,0 @@
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;

View File

@ -1,45 +0,0 @@
/**
*
* 使 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;
}

View File

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

View File

@ -1,90 +0,0 @@
<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>

View File

@ -1,566 +0,0 @@
<template>
<div class="share-view" :class="{ dark: isDark }">
<!-- 加载状态 -->
<div v-if="isLoading" class="loading-state">
<Loader2 :size="32" class="spin" />
<span>加载中...</span>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-state">
<AlertCircle :size="48" />
<p class="error-message">{{ error }}</p>
<button class="retry-btn" @click="loadShareInfo">重试</button>
</div>
<!-- 密码验证 -->
<div v-else-if="!isVerified" class="password-verify">
<div class="verify-container">
<div class="verify-icon">
<Lock :size="48" />
</div>
<h2 class="verify-title">查看分享内容</h2>
<p class="verify-hint">请输入访问密码查看分享内容</p>
<div v-if="shareInfo" class="share-info">
<span class="info-item">
<MessageSquare :size="14" />
{{ shareInfo.conversationCount }} 个对话
</span>
<span v-if="shareInfo.isExpired" class="info-item expired">
<AlertCircle :size="14" />
已过期
</span>
<span v-else class="info-item">
<Clock :size="14" />
{{ formatExpiresAt }} 过期
</span>
</div>
<div class="password-input-wrapper">
<input
v-model="password"
:type="showPassword ? 'text' : 'password'"
class="password-input"
placeholder="请输入访问密码"
@keydown.enter="handleVerify"
/>
<button class="toggle-visibility" @click="showPassword = !showPassword">
<Eye v-if="!showPassword" :size="18" />
<EyeOff v-else :size="18" />
</button>
</div>
<p v-if="verifyError" class="verify-error">{{ verifyError }}</p>
<button
class="verify-btn"
:disabled="!password || isVerifying"
@click="handleVerify"
>
{{ isVerifying ? '验证中...' : '查看内容' }}
</button>
</div>
</div>
<!-- 分享内容 -->
<div v-else class="share-content">
<!-- 顶部导航栏 -->
<header class="share-header">
<div class="header-left">
<h1 class="share-title">分享的对话</h1>
<span class="conversation-count">
{{ shareData?.conversations?.length || 0 }} 个对话
</span>
</div>
<!-- <button class="theme-toggle" @click="toggleTheme">
<Sun v-if="isDark" :size="20" />
<Moon v-else :size="20" />
</button> -->
</header>
<!-- 对话切换标签 -->
<div v-if="shareData?.conversations && shareData.conversations.length > 1" class="conversation-tabs">
<button
v-for="(conv, index) in shareData.conversations"
:key="conv.id"
class="tab-btn"
:class="{ active: activeConversationIndex === index }"
@click="activeConversationIndex = index"
>
{{ conv.title || '未命名对话' }}
</button>
</div>
<!-- 消息列表 -->
<div class="message-list">
<template v-if="activeConversation">
<MessageBubble
v-for="message in visibleMessages"
:key="message.id"
:message="message"
:show-timestamp="true"
:readonly="true"
/>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useSettingsStore } from '@/stores/settings'
import { storeToRefs } from 'pinia'
import { shareApi } from '@/services/shareApi'
import { hashPassword } from '@/utils/crypto'
import { formatTimestamp } from '@/utils/helpers'
import { MessageRole } from '@/types/chat'
import type { Share, ShareGetResponse } from '@/types/share'
import MessageBubble from '@/components/message/MessageBubble.vue'
import {
Lock,
Loader2,
MessageSquare,
Clock,
AlertCircle,
Eye,
EyeOff,
} from '@/components/icons'
const route = useRoute()
const settingsStore = useSettingsStore()
const { settings } = storeToRefs(settingsStore)
//
const isLoading = ref(false)
const isVerifying = ref(false)
const isVerified = ref(false)
const password = ref('')
const showPassword = ref(false)
const error = ref('')
const verifyError = ref('')
const shareInfo = ref<ShareGetResponse | null>(null)
const shareData = ref<Share | null>(null)
const activeConversationIndex = ref(0)
//
const isDark = computed(() => {
if (settings.value.theme === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
return settings.value.theme === 'dark'
})
const formatExpiresAt = computed(() => {
if (!shareInfo.value) return ''
return formatTimestamp(shareInfo.value.expiresAt)
})
const activeConversation = computed(() => {
return shareData.value?.conversations?.[activeConversationIndex.value]
})
const visibleMessages = computed(() => {
if (!activeConversation.value?.messages) return []
return activeConversation.value.messages.filter(
(message) => message.role !== MessageRole.SYSTEM
)
})
//
async function loadShareInfo() {
const shareId = route.params.id as string
if (!shareId) {
error.value = '无效的分享链接'
return
}
isLoading.value = true
error.value = ''
try {
shareInfo.value = await shareApi.getShare(shareId)
if (shareInfo.value.isExpired) {
error.value = '分享链接已过期'
return
}
//
if (!shareInfo.value.hasPassword) {
await verifyWithPassword('')
}
} catch (err) {
console.error('Failed to load share info:', err)
error.value = '分享不存在或已过期'
} finally {
isLoading.value = false
}
}
async function handleVerify() {
if (!password.value || isVerifying.value) return
await verifyWithPassword(password.value)
}
async function verifyWithPassword(pwd: string) {
const shareId = route.params.id as string
if (!shareId) return
isVerifying.value = true
verifyError.value = ''
try {
const passwordHash = pwd ? await hashPassword(pwd) : ''
const result = await shareApi.verifyShare(shareId, { passwordHash })
if (result.valid && result.share) {
shareData.value = result.share
isVerified.value = true
} else {
verifyError.value = '密码错误,请重试'
}
} catch (err) {
console.error('Failed to verify share:', err)
verifyError.value = '验证失败,请重试'
} finally {
isVerifying.value = false
}
}
onMounted(() => {
loadShareInfo()
})
</script>
<style lang="scss" scoped>
.share-view {
width: 100%;
min-height: 100vh;
background: #ffffff;
&.dark {
background: #11111b;
color: #e5e7eb;
}
}
//
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
gap: 16px;
color: #6b7280;
.spin {
animation: spin 1s linear infinite;
}
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
//
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
gap: 16px;
color: #6b7280;
.error-message {
font-size: 16px;
color: #ef4444;
margin: 0;
}
.retry-btn {
padding: 10px 24px;
border: none;
border-radius: 10px;
background: #3b82f6;
color: white;
font-size: 14px;
cursor: pointer;
&:hover {
background: #2563eb;
}
}
}
//
.password-verify {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 20px;
}
.verify-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
width: 100%;
max-width: 400px;
}
.verify-icon {
color: #6b7280;
}
.verify-title {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #1f2937;
.dark & {
color: #f3f4f6;
}
}
.verify-hint {
font-size: 14px;
color: #6b7280;
margin: 0;
text-align: center;
}
.share-info {
display: flex;
gap: 16px;
margin-top: 8px;
.info-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #6b7280;
&.expired {
color: #ef4444;
}
}
}
.password-input-wrapper {
position: relative;
width: 100%;
max-width: 320px;
margin-top: 8px;
}
.password-input {
width: 100%;
padding: 14px 48px 14px 16px;
border: 1px solid #e5e7eb;
border-radius: 12px;
font-size: 15px;
text-align: center;
letter-spacing: 2px;
.dark & {
background: #2d2d3d;
border-color: #374151;
color: #f3f4f6;
}
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
}
.toggle-visibility {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 6px;
background: transparent;
color: #6b7280;
cursor: pointer;
&:hover {
background: rgba(0, 0, 0, 0.05);
.dark & {
background: rgba(255, 255, 255, 0.05);
}
}
}
.verify-error {
font-size: 13px;
color: #ef4444;
margin: 0;
}
.verify-btn {
width: 100%;
max-width: 320px;
padding: 14px;
border: none;
border-radius: 12px;
background: #3b82f6;
color: white;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover:not(:disabled) {
background: #2563eb;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
//
.share-content {
display: flex;
flex-direction: column;
min-height: 100vh;
height: 100%;
}
.share-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-bottom: 1px solid #e5e7eb;
background: white;
position: sticky;
top: 0;
z-index: 10;
.dark & {
background: #1e1e2e;
border-bottom-color: #2d2d3d;
}
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.share-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #1f2937;
.dark & {
color: #f3f4f6;
}
}
.conversation-count {
font-size: 13px;
color: #6b7280;
}
.theme-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: none;
border-radius: 10px;
background: #f3f4f6;
color: #6b7280;
cursor: pointer;
transition: all 0.2s ease;
.dark & {
background: #2d2d3d;
color: #e5e7eb;
}
&:hover {
background: #e5e7eb;
.dark & {
background: #374151;
}
}
}
.conversation-tabs {
display: flex;
gap: 8px;
padding: 12px 24px;
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
overflow-x: auto;
.dark & {
background: #1e1e2e;
border-bottom-color: #2d2d3d;
}
}
.tab-btn {
flex-shrink: 0;
padding: 10px 20px;
border: none;
border-radius: 10px;
background: white;
color: #6b7280;
font-size: 14px;
white-space: nowrap;
cursor: pointer;
transition: all 0.2s ease;
.dark & {
background: #2d2d3d;
color: #9ca3af;
}
&:hover {
background: #e5e7eb;
.dark & {
background: #374151;
}
}
&.active {
background: #3b82f6;
color: white;
}
}
.message-list {
flex: 1;
padding: 20px 0;
overflow-y: auto;
}
</style>

1
tsconfig.tsbuildinfo Normal file
View File

@ -0,0 +1 @@
{"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: "/",
base: "/chat-ui/",
resolve: {
alias: {
@ -18,11 +18,7 @@ export default defineConfig({
host: "0.0.0.0",
proxy: {
"/api/chat-ui": {
target: "http://localhost:8002", // Python服务器端口
changeOrigin: true,
},
"/api/auth": {
target: "https://sxwz.xueai.art",
target: "http://localhost:8000", // Python服务器端口
changeOrigin: true,
},
},

View File

@ -1,35 +0,0 @@
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'),
},
},
})