Compare commits

...

33 Commits
v0.9.0 ... main

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:21:28 +08:00
肖应宇 0da4e06050 Merge branch 'fix/more-model-and-prompt-lost' into main 2026-03-11 14:55:18 +08:00
肖应宇 92d5f78e1d feat: 隐藏Header的导出按钮 2026-03-11 14:09:43 +08:00
肖应宇 3471e8552f feat: 将系统提示词加入会话上下文中,系统提示词不会显示在前端。 2026-03-11 13:39:41 +08:00
肖应宇 38faeeb46d debug: 修复qwen3.5系列模型思考模式输出内容错乱的问题;修复错误显示GLM5能识别图片和文件的问题。 2026-03-11 13:38:51 +08:00
肖应宇 3c53e89b43 feat: 新增qwen3.5系列模型 2026-03-11 11:32:45 +08:00
49 changed files with 6064 additions and 693 deletions

8
.gitignore vendored
View File

@ -16,13 +16,10 @@ uploads
.venv
__pycache__
.claude
<<<<<<< HEAD
*.db
=======
.trae
.agent
.agents
>>>>>>> feat/database
# Editor directories and files
.vscode/*
@ -34,7 +31,6 @@ __pycache__
*.njsproj
*.sln
*.sw?
<<<<<<< HEAD
# Skills
.skills
@ -42,9 +38,7 @@ __pycache__
.agents
.trae
skills-lock.json
=======
*.db
server/data/*.db
skills-lock.json
>>>>>>> feat/database
tsconfig.tsbuildinfo

1705
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -10,42 +10,49 @@ 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
logger = get_logger()
# 支持深度思考的模型
THINKING_MODELS = {"qwen3-max", "qwen3.5-plus"}
# 需要使用多模态接口的模型qwen3.5 系列)
MULTIMODAL_API_MODELS = {"qwen3.5-plus", "qwen3.5-flash"}
# 百炼模型配置
DASHSCOPE_MODELS = [
ModelInfo(
id="qwen-max",
name="通义千问 Max",
description="最强大的模型",
id="qwen3-max",
name="Qwen3-Max",
description="千问系列效果最好的模型,适合复杂、多步骤的任务。",
max_tokens=8192,
provider="Aliyun",
supports_thinking=True,
supports_web_search=True,
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计费更合理。",
max_tokens=8192,
provider="Aliyun",
supports_thinking=False,
supports_web_search=False,
supports_vision=False,
supports_files=False,
),
ModelInfo(
id="qwen-plus",
name="通义千问 Plus",
description="能力均衡",
max_tokens=8192,
provider="Aliyun",
supports_thinking=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=True,
supports_web_search=False,
supports_web_search=True,
supports_vision=False,
supports_files=False,
),
@ -60,17 +67,6 @@ DASHSCOPE_MODELS = [
supports_vision=True,
supports_files=False,
),
ModelInfo(
id="qwen-vl-plus",
name="通义万相 VL-Plus",
description="支持视觉理解的多模态模型",
max_tokens=8192,
provider="Aliyun",
supports_thinking=False,
supports_web_search=False,
supports_vision=True,
supports_files=False,
),
]
@ -89,6 +85,14 @@ class DashScopeAdapter(BaseAdapter):
"""获取 API Key"""
return os.getenv("ALIYUN_API_KEY") or os.getenv("DASHSCOPE_API_KEY", "")
def _needs_multimodal_api(self, model: str) -> bool:
"""检查模型是否需要使用多模态 API"""
return model.lower() in MULTIMODAL_API_MODELS
def _supports_thinking(self, model: str) -> bool:
"""检查模型是否支持深度思考"""
return model.lower() in THINKING_MODELS
def list_models(self) -> List[ModelInfo]:
return DASHSCOPE_MODELS
@ -104,6 +108,7 @@ class DashScopeAdapter(BaseAdapter):
logger.info(f" - temperature: {request.temperature}")
logger.info(f" - max_tokens: {request.max_tokens}")
logger.info(f" - files: {request.files}")
logger.info(f" - deep_thinking: {request.deep_thinking}")
logger.info(
f" - messages: {json.dumps(request.messages, ensure_ascii=False, indent=2)}"
)
@ -112,7 +117,11 @@ class DashScopeAdapter(BaseAdapter):
has_multimodal = self._has_multimodal_content(request)
logger.info(f" - has_multimodal: {has_multimodal}")
if has_multimodal:
# 检查是否需要使用多模态接口qwen3.5 系列)
needs_multimodal_api = self._needs_multimodal_api(request.model)
logger.info(f" - needs_multimodal_api: {needs_multimodal_api}")
if has_multimodal or needs_multimodal_api:
return await self._multimodal_chat(request)
else:
return await self._text_chat(request)
@ -136,6 +145,9 @@ class DashScopeAdapter(BaseAdapter):
# 转换消息格式
messages = self._build_text_messages(request)
logger.info(f"[DashScope] 文本聊天 - 转换后的消息:")
logger.info(f" - messages_count: {len(messages)}")
logger.info(f" - messages: {json.dumps(messages, ensure_ascii=False, indent=2)}")
if request.stream:
return self._stream_text_chat(messages, request)
@ -163,26 +175,104 @@ class DashScopeAdapter(BaseAdapter):
"""流式文本聊天"""
logger.info(f"[DashScope] 开始流式文本响应...")
# 检查是否启用深度思考
thinking_enabled = request.deep_thinking and self._supports_thinking(request.model)
logger.info(f"[DashScope] 深度思考: {thinking_enabled} (request={request.deep_thinking}, supports={self._supports_thinking(request.model)})")
def generator():
from utils.helpers import generate_unique_id, get_current_timestamp
from dashscope import Generation
full_content = ""
full_reasoning = ""
chunk_count = 0
responses = Generation.call(
model=request.model,
messages=messages,
stream=True,
temperature=request.temperature,
max_tokens=request.max_tokens,
result_format="message",
)
error_occurred = False
# 打印 API 调用参数
api_params = {
"model": request.model,
"messages": messages,
"stream": True,
"temperature": request.temperature,
"max_tokens": request.max_tokens,
"result_format": "message",
}
# 使用统一网络搜索配置
web_search_mode = get_web_search_mode(request)
if web_search_mode:
api_params["enable_search"] = True
if web_search_mode == "deep":
api_params["search_options"] = {"enable_search_extension": True}
# 添加深度思考参数
if thinking_enabled:
api_params["enable_thinking"] = True
# 打印 API 调用参数
logger.info(f"[DashScope] API 调用参数:")
logger.info(f" - model: {api_params['model']}")
logger.info(f" - stream: {api_params['stream']}")
logger.info(f" - temperature: {api_params['temperature']}")
logger.info(f" - max_tokens: {api_params['max_tokens']}")
logger.info(f" - result_format: {api_params['result_format']}")
if thinking_enabled:
logger.info(f" - enable_thinking: True")
try:
responses = Generation.call(**api_params)
except Exception as e:
error_occurred = True
logger.error(f"[DashScope] API 调用异常: {str(e)}")
import traceback
logger.error(traceback.format_exc())
# 返回错误响应
error_data = {
"id": f"chatcmpl-{generate_unique_id()}",
"object": "chat.completion.chunk",
"created": get_current_timestamp(),
"model": request.model,
"choices": [{
"index": 0,
"delta": {"content": f"API 调用失败: {str(e)}"},
"finish_reason": "stop",
}],
}
yield f"data: {json.dumps(error_data, ensure_ascii=False)}\n\n"
yield "data: [DONE]\n\n"
return
for resp in responses:
if resp.status_code == 200:
chunk_count += 1
content = resp.output.choices[0].message.content
choice = resp.output.choices[0]
# 处理深度思考内容reasoning_content
reasoning_content = getattr(choice.message, "reasoning_content", None)
if reasoning_content:
# 计算增量
if len(reasoning_content) > len(full_reasoning):
delta_reasoning = reasoning_content[len(full_reasoning):]
full_reasoning = reasoning_content
data = {
"id": f"chatcmpl-{generate_unique_id()}",
"object": "chat.completion.chunk",
"created": get_current_timestamp(),
"model": request.model,
"choices": [
{
"index": 0,
"delta": {"reasoning_content": delta_reasoning},
"finish_reason": None,
}
],
}
yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
continue
# 处理普通内容
content = choice.message.content
if content and len(content) > len(full_content):
# DashScope 流式响应返回完整内容,计算增量
delta = content[len(full_content) :]
@ -201,6 +291,9 @@ class DashScopeAdapter(BaseAdapter):
],
}
yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
else:
# 记录非200响应
logger.warning(f"[DashScope] 非200响应: status_code={resp.status_code}, code={resp.code}, message={resp.message}")
finish = {
"id": f"chatcmpl-{generate_unique_id()}",
@ -216,6 +309,8 @@ class DashScopeAdapter(BaseAdapter):
logger.info(f"[DashScope] 流式文本响应完成:")
logger.info(f" - chunks: {chunk_count}")
logger.info(f" - content_length: {len(full_content)} 字符")
if full_reasoning:
logger.info(f" - reasoning_length: {len(full_reasoning)} 字符")
logger.info(
f" - content_preview: {full_content[:200]}..."
if len(full_content) > 200
@ -230,17 +325,64 @@ class DashScopeAdapter(BaseAdapter):
from dashscope import Generation
resp = Generation.call(
model=request.model,
messages=messages,
stream=False,
temperature=request.temperature,
max_tokens=request.max_tokens,
result_format="message",
# 检查是否启用深度思考
thinking_enabled = request.deep_thinking and self._supports_thinking(request.model)
logger.info(f"[DashScope] 深度思考: {thinking_enabled} (request={request.deep_thinking}, supports={self._supports_thinking(request.model)})")
# 构建 API 调用参数
api_params = {
"model": request.model,
"messages": messages,
"stream": False,
"temperature": request.temperature,
"max_tokens": request.max_tokens,
"result_format": "message",
}
# 使用统一网络搜索配置
web_search_mode = get_web_search_mode(request)
if web_search_mode:
api_params["enable_search"] = True
if web_search_mode == "deep":
api_params["search_options"] = {"enable_search_extension": True}
# 添加深度思考参数
if thinking_enabled:
api_params["enable_thinking"] = True
# 打印 API 调用参数
logger.info(f"[DashScope] API 调用参数:")
logger.info(f" - model: {api_params['model']}")
logger.info(f" - stream: {api_params['stream']}")
logger.info(f" - temperature: {api_params['temperature']}")
logger.info(f" - max_tokens: {api_params['max_tokens']}")
logger.info(f" - result_format: {api_params['result_format']}")
if thinking_enabled:
logger.info(f" - enable_thinking: True")
try:
resp = Generation.call(**api_params)
except Exception as e:
logger.error(f"[DashScope] API 调用异常: {str(e)}")
import traceback
logger.error(traceback.format_exc())
return JSONResponse(
status_code=500,
content={"error": f"DashScope API 调用异常: {str(e)}"},
)
if resp.status_code == 200:
content = resp.output.choices[0].message.content
message = resp.output.choices[0].message
content = message.content or ""
# 构建响应消息
response_message = {"role": "assistant", "content": content}
# 处理深度思考内容
reasoning_content = getattr(message, "reasoning_content", None)
if reasoning_content:
response_message["reasoning_content"] = reasoning_content
response = {
"id": f"chatcmpl-{generate_unique_id()}",
"object": "chat.completion",
@ -249,7 +391,7 @@ class DashScopeAdapter(BaseAdapter):
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": content},
"message": response_message,
"finish_reason": "stop",
}
],
@ -263,8 +405,11 @@ class DashScopeAdapter(BaseAdapter):
}
# 打印响应结果
logger.info(f"[DashScope] 响应结果:")
logger.info(f"[DashScope] 响应成功:")
logger.info(f" - status_code: {resp.status_code}")
logger.info(f" - content_length: {len(content)} 字符")
if reasoning_content:
logger.info(f" - reasoning_length: {len(reasoning_content)} 字符")
logger.info(
f" - content_preview: {content[:200]}..."
if len(content) > 200
@ -275,7 +420,10 @@ class DashScopeAdapter(BaseAdapter):
return JSONResponse(content=response)
logger.error(f"[DashScope] 请求失败: {resp.code} - {resp.message}")
logger.error(f"[DashScope] 请求失败:")
logger.error(f" - status_code: {resp.status_code}")
logger.error(f" - code: {resp.code}")
logger.error(f" - message: {resp.message}")
return JSONResponse(
status_code=500,
content={"error": f"DashScope Error: {resp.code} - {resp.message}"},
@ -288,13 +436,20 @@ class DashScopeAdapter(BaseAdapter):
dashscope.api_key = self._get_api_key()
logger.info(f"[DashScope] 开始多模态聊天...")
# 转换消息格式
messages = self._build_multimodal_messages(request)
logger.info(f"[DashScope] 多模态消息转换完成:")
logger.info(f" - messages_count: {len(messages)}")
logger.info(f" - messages: {json.dumps(messages, ensure_ascii=False, indent=2)}")
# 选择多模态模型
model = request.model
if "qwen-" in model and "vl" not in model:
original_model = model
model = model.replace("qwen-", "qwen-vl-")
logger.info(f"[DashScope] 模型自动切换: {original_model} -> {model}")
if request.stream:
return self._stream_multimodal_chat(messages, model, request)
@ -338,6 +493,8 @@ class DashScopeAdapter(BaseAdapter):
else:
img_url = ""
logger.info(f"[DashScope] 原始图片URL: {img_url}")
# 转换 http URL 为 file:// 格式(如果是本地文件)
if img_url.startswith(("http://", "https://")):
from urllib.parse import urlparse
@ -350,42 +507,129 @@ class DashScopeAdapter(BaseAdapter):
img_url = f"file://{'/'.join(path_parts[uploads_idx:])}"
except ValueError:
pass
elif not img_url.startswith("file://"):
elif not img_url.startswith("file://") and not img_url.startswith(("http://", "https://")):
img_url = f"file://{img_url}"
logger.info(f"[DashScope] 转换后图片URL: {img_url}")
return img_url
def _stream_multimodal_chat(
self, messages: List[Dict], model: str, request: ChatCompletionRequest
):
"""流式多模态聊天"""
logger.info(f"[DashScope] 开始流式多模态响应...")
logger.info(f" - model: {model}")
logger.info(f" - max_tokens: {request.max_tokens}")
logger.info(f" - temperature: {request.temperature}")
# 检查是否启用深度思考
thinking_enabled = request.deep_thinking and self._supports_thinking(model)
logger.info(f"[DashScope] 深度思考: {thinking_enabled} (request={request.deep_thinking}, supports={self._supports_thinking(model)})")
def generator():
from utils.helpers import generate_unique_id, get_current_timestamp
from dashscope import MultiModalConversation
responses = MultiModalConversation.call(
model=model,
messages=messages,
stream=True,
max_tokens=request.max_tokens,
temperature=request.temperature,
)
full_content = ""
full_reasoning = ""
chunk_count = 0
error_occurred = False
# 打印 API 调用参数
api_params = {
"model": model,
"messages": messages,
"stream": True,
"enable_thinking": False,
"max_tokens": request.max_tokens,
"temperature": request.temperature,
}
# 使用统一网络搜索配置
web_search_mode = get_web_search_mode(request)
if web_search_mode:
api_params["enable_search"] = True
if web_search_mode == "deep":
api_params["search_options"] = {"enable_search_extension": True}
# 添加深度思考参数
if thinking_enabled:
api_params["enable_thinking"] = True
logger.info(f"[DashScope] 流式多模态 API 调用参数:")
logger.info(f" - model: {api_params['model']}")
logger.info(f" - stream: {api_params['stream']}")
logger.info(f" - max_tokens: {api_params['max_tokens']}")
logger.info(f" - temperature: {api_params['temperature']}")
logger.info(f" - enable_thinking: {api_params['enable_thinking']}")
logger.info(f" - messages: {json.dumps(messages, ensure_ascii=False, indent=2)}")
try:
responses = MultiModalConversation.call(**api_params)
except Exception as e:
error_occurred = True
logger.error(f"[DashScope] 多模态 API 调用异常: {str(e)}")
import traceback
logger.error(traceback.format_exc())
error_data = {
"id": f"chatcmpl-{generate_unique_id()}",
"object": "chat.completion.chunk",
"created": get_current_timestamp(),
"model": model,
"choices": [{
"index": 0,
"delta": {"content": f"API 调用失败: {str(e)}"},
"finish_reason": "stop",
}],
}
yield f"data: {json.dumps(error_data, ensure_ascii=False)}\n\n"
yield "data: [DONE]\n\n"
return
for resp in responses:
chunk_count += 1
if resp.status_code == 200:
try:
content_items = resp.output.choices[0]["message"]["content"]
choice = resp.output.choices[0]
message = choice["message"]
# 处理深度思考内容reasoning_content
# 多模态 API 返回的 reasoning_content 也是独立的片段
reasoning_content = message.get("reasoning_content", "")
if reasoning_content:
delta_reasoning = reasoning_content
full_reasoning += reasoning_content
data = {
"id": f"chatcmpl-{generate_unique_id()}",
"object": "chat.completion.chunk",
"created": get_current_timestamp(),
"model": model,
"choices": [
{
"index": 0,
"delta": {"reasoning_content": delta_reasoning},
"finish_reason": None,
}
],
}
yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
continue
# 处理普通内容
content_items = message.get("content", [])
text = ""
for item in content_items:
if isinstance(item, dict) and "text" in item:
text += item["text"]
if len(text) > len(full_content):
delta = text[len(full_content) :]
full_content = text
# 多模态 API 返回的 content 是独立的片段(不是累积的),直接作为 delta
if text:
delta = text
full_content += text
data = {
"id": f"chatcmpl-{generate_unique_id()}",
@ -401,8 +645,10 @@ class DashScopeAdapter(BaseAdapter):
],
}
yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
except (KeyError, IndexError, TypeError):
pass
except (KeyError, IndexError, TypeError) as e:
logger.warning(f"[DashScope] 解析多模态响应异常: {str(e)}")
else:
logger.warning(f"[DashScope] 非200响应: status_code={resp.status_code}, code={resp.code}, message={resp.message}")
finish = {
"id": f"chatcmpl-{generate_unique_id()}",
@ -414,6 +660,19 @@ class DashScopeAdapter(BaseAdapter):
yield f"data: {json.dumps(finish, ensure_ascii=False)}\n\n"
yield "data: [DONE]\n\n"
# 打印流式响应结果
logger.info(f"[DashScope] 流式多模态响应完成:")
logger.info(f" - chunks: {chunk_count}")
if full_reasoning:
logger.info(f" - reasoning_length: {len(full_reasoning)} 字符")
logger.info(f" - reasoning: {full_reasoning[:500]}..." if len(full_reasoning) > 500 else f" - reasoning: {full_reasoning}")
logger.info(f" - content_length: {len(full_content)} 字符")
logger.info(
f" - content: {full_content[:500]}..."
if len(full_content) > 500
else f" - content: {full_content}"
)
return StreamingResponse(generator(), media_type="text/event-stream")
def _sync_multimodal_chat(
@ -424,22 +683,71 @@ class DashScopeAdapter(BaseAdapter):
from dashscope import MultiModalConversation
resp = MultiModalConversation.call(
model=model,
messages=messages,
stream=False,
max_tokens=request.max_tokens,
temperature=request.temperature,
# 检查是否启用深度思考
thinking_enabled = request.deep_thinking and self._supports_thinking(model)
logger.info(f"[DashScope] 深度思考: {thinking_enabled} (request={request.deep_thinking}, supports={self._supports_thinking(model)})")
logger.info(f"[DashScope] 开始非流式多模态响应...")
logger.info(f" - model: {model}")
logger.info(f" - max_tokens: {request.max_tokens}")
logger.info(f" - temperature: {request.temperature}")
# 打印 API 调用参数
api_params = {
"model": model,
"messages": messages,
"stream": False,
"max_tokens": request.max_tokens,
"enable_thinking": False,
"temperature": request.temperature,
}
# 使用统一网络搜索配置
web_search_mode = get_web_search_mode(request)
if web_search_mode:
api_params["enable_search"] = True
if web_search_mode == "deep":
api_params["search_options"] = {"enable_search_extension": True}
# 添加深度思考参数
if thinking_enabled:
api_params["enable_thinking"] = True
logger.info(f"[DashScope] 非流式多模态 API 调用参数:")
logger.info(f" - model: {api_params['model']}")
logger.info(f" - stream: {api_params['stream']}")
logger.info(f" - max_tokens: {api_params['max_tokens']}")
logger.info(f" - temperature: {api_params['temperature']}")
logger.info(f" - enable_thinking: {api_params['enable_thinking']}")
try:
resp = MultiModalConversation.call(**api_params)
except Exception as e:
logger.error(f"[DashScope] 多模态 API 调用异常: {str(e)}")
import traceback
logger.error(traceback.format_exc())
return JSONResponse(
status_code=500,
content={"error": f"DashScope API 调用异常: {str(e)}"},
)
if resp.status_code == 200:
try:
content_items = resp.output.choices[0]["message"]["content"]
message = resp.output.choices[0]["message"]
content_items = message.get("content", [])
text = ""
for item in content_items:
if isinstance(item, dict) and "text" in item:
text += item["text"]
# 构建响应消息
response_message = {"role": "assistant", "content": text}
# 处理深度思考内容
reasoning_content = message.get("reasoning_content")
if reasoning_content:
response_message["reasoning_content"] = reasoning_content
response = {
"id": f"chatcmpl-{generate_unique_id()}",
"object": "chat.completion",
@ -448,18 +756,38 @@ class DashScopeAdapter(BaseAdapter):
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": text},
"message": response_message,
"finish_reason": "stop",
}
],
}
# 打印响应结果
logger.info(f"[DashScope] 多模态响应成功:")
logger.info(f" - status_code: {resp.status_code}")
logger.info(f" - content_length: {len(text)} 字符")
if reasoning_content:
logger.info(f" - reasoning_length: {len(reasoning_content)} 字符")
logger.info(
f" - content_preview: {text[:200]}..."
if len(text) > 200
else f" - content: {text}"
)
return JSONResponse(content=response)
except (KeyError, IndexError, TypeError) as e:
logger.error(f"[DashScope] 解析多模态响应异常: {str(e)}")
import traceback
logger.error(traceback.format_exc())
return JSONResponse(
status_code=500,
content={"error": f"Parse error: {str(e)}"},
)
logger.error(f"[DashScope] 多模态请求失败:")
logger.error(f" - status_code: {resp.status_code}")
logger.error(f" - code: {resp.code}")
logger.error(f" - message: {resp.message}")
return JSONResponse(
status_code=500,
content={"error": f"DashScope Error: {resp.code} - {resp.message}"},

View File

@ -11,12 +11,24 @@ from typing import Dict, List, Optional
from fastapi.responses import JSONResponse, StreamingResponse
from .base import BaseAdapter, ChatCompletionRequest, ModelInfo
from .plugins import get_web_search_mode, build_glm_search_tool
from core import get_logger
logger = get_logger()
# GLM 模型配置
GLM_MODELS = [
ModelInfo(
id="glm-5",
name="GLM-5",
description="Coding与长程Agent能力SOTA",
max_tokens=128000,
provider="ZhipuAI",
supports_thinking=True,
supports_web_search=True,
supports_vision=False,
supports_files=False,
),
ModelInfo(
id="glm-4.6v",
name="GLM-4.6V(推荐)",
@ -53,11 +65,11 @@ GLM_MODELS = [
ModelInfo(
id="glm-z1-flash",
name="GLM-Z1 Flash",
description="深度思考推理模型",
description="深度思考推理模型,默认开启深度思考",
max_tokens=128000,
provider="ZhipuAI",
supports_thinking=True,
supports_web_search=False,
supports_web_search=True,
supports_vision=False,
supports_files=False,
),
@ -118,10 +130,10 @@ class GLMAdapter(BaseAdapter):
# 构建额外参数
extra_kwargs = {}
web_search = self._get_web_search_mode(request)
web_search_mode = get_web_search_mode(request)
if web_search:
extra_kwargs["tools"] = [self._build_web_search_tool(web_search)]
if web_search_mode:
extra_kwargs["tools"] = [build_glm_search_tool(web_search_mode)]
extra_kwargs["tool_choice"] = "auto"
# 深度思考正向选择True 时启用False 时禁用)
@ -249,46 +261,6 @@ class GLMAdapter(BaseAdapter):
"""检查模型是否支持深度思考"""
return model.lower() in THINKING_MODELS
def _get_web_search_mode(self, request: ChatCompletionRequest) -> str:
"""获取联网搜索模式"""
if request.deep_search:
return "deep"
elif request.web_search:
return "simple"
return ""
def _build_web_search_tool(self, mode: str) -> Dict:
"""构建联网搜索工具"""
from datetime import datetime
today = datetime.now().strftime("%Y年%m月%d")
if mode == "deep":
# 深度搜索:返回搜索结果详情
return {
"type": "web_search",
"web_search": {
"enable": True,
"search_engine": "search_pro",
"search_result": True,
"search_prompt": f"你是一位智能助手。请用简洁的语言总结网络搜索{{search_result}}中的关键信息,按重要性排序并引用来源日期。今天的日期是{today}",
"count": 5,
"search_recency_filter": "noLimit",
"content_size": "high",
},
}
else:
# 简单搜索
return {
"type": "web_search",
"web_search": {
"enable": True,
"search_engine": "search_pro",
"search_result": True,
"count": 5,
},
}
def _stream_chat(
self, client, messages, model, request, extra_kwargs
) -> StreamingResponse:

View File

@ -10,6 +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
logger = get_logger()
@ -155,6 +156,21 @@ class OpenAIAdapter(BaseAdapter):
# 构建消息
messages = self._build_messages(request)
# 统一添加联网搜索插件参数
web_search_mode = get_web_search_mode(request)
if web_search_mode:
# 注入当前时间信息到 System Prompt 中,以便模型拥有时间感知能力
time_info = get_current_time_info()
has_system = False
for msg in messages:
if msg.get("role") == "system":
msg["content"] = f"当前系统时间:{time_info}\n" + str(msg.get("content", ""))
has_system = True
break
if not has_system:
messages.insert(0, {"role": "system", "content": f"当前系统时间:{time_info}"})
logger.info(
f" - messages: {json.dumps(messages, ensure_ascii=False, indent=2)}"
)
@ -168,6 +184,10 @@ class OpenAIAdapter(BaseAdapter):
"stream": request.stream,
}
if web_search_mode:
search_tool = build_openai_search_tool(web_search_mode)
kwargs["tools"] = [search_tool]
# DeepSeek 深度思考支持
extra_body = None
if self._provider_type == "deepseek" and request.deep_thinking:
@ -220,16 +240,26 @@ class OpenAIAdapter(BaseAdapter):
def generator():
from utils.helpers import generate_unique_id, get_current_timestamp
resp = client.chat.completions.create(**kwargs)
nonlocal kwargs
# 可能需要执行多轮对话(当发生工具调用时)
while True:
resp = client.chat.completions.create(**kwargs)
full_content = ""
full_reasoning = ""
chunk_count = 0
tool_calls = []
current_tool_call = None
for chunk in resp:
if chunk.choices:
if not chunk.choices:
continue
chunk_count += 1
delta = chunk.choices[0].delta
# 1. 收集可能有内容/推理
delta_content = {}
if hasattr(delta, "content") and delta.content:
delta_content["content"] = delta.content
@ -238,7 +268,27 @@ class OpenAIAdapter(BaseAdapter):
delta_content["reasoning_content"] = delta.reasoning_content
full_reasoning += delta.reasoning_content
if delta_content:
# 2. 收集可能产生的 tool_calls (流式)
if hasattr(delta, "tool_calls") and delta.tool_calls:
for tool_call_chunk in delta.tool_calls:
idx = tool_call_chunk.index
# 确保 tool_calls 列表足够长
while len(tool_calls) <= idx:
tool_calls.append({"id": "", "type": "function", "function": {"name": "", "arguments": ""}})
if tool_call_chunk.id:
tool_calls[idx]["id"] += tool_call_chunk.id
if tool_call_chunk.type:
# 对于 type, 因为 OpenAI 可能会传 chunks, 但通常只在第一块或者每块传, 为了避免 functionfunction, 使用赋值而非累加
tool_calls[idx]["type"] = tool_call_chunk.type
if tool_call_chunk.function:
if tool_call_chunk.function.name:
tool_calls[idx]["function"]["name"] += tool_call_chunk.function.name
if tool_call_chunk.function.arguments:
tool_calls[idx]["function"]["arguments"] += tool_call_chunk.function.arguments
# 3. 输出给前端普通文本
if delta_content and not tool_calls:
data = {
"id": f"chatcmpl-{generate_unique_id()}",
"object": "chat.completion.chunk",
@ -254,6 +304,47 @@ 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",
@ -276,6 +367,9 @@ class OpenAIAdapter(BaseAdapter):
else f" - content: {full_content}"
)
# 结束外层循环退出生成器
break
return StreamingResponse(generator(), media_type="text/event-stream")
def _sync_chat(
@ -284,9 +378,57 @@ class OpenAIAdapter(BaseAdapter):
"""非流式聊天"""
from utils.helpers import generate_unique_id, get_current_timestamp
while True:
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 ""
response = {
"id": f"chatcmpl-{generate_unique_id()}",

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

@ -0,0 +1,119 @@
import os
import urllib.request
import json
from typing import Dict
from datetime import datetime
from .base import ChatCompletionRequest
def get_current_time_info() -> str:
"""获取当前时间信息"""
now = datetime.now()
weekdays = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]
return f"{now.strftime('%Y年%m月%d%H:%M:%S')} {weekdays[now.weekday()]}"
def get_web_search_mode(request: ChatCompletionRequest) -> str:
"""获取联网搜索模式"""
if getattr(request, 'deep_search', False):
return "deep"
elif getattr(request, 'web_search', False):
return "simple"
return ""
def execute_tavily_search(query: str, mode: str = "simple") -> str:
"""真实调用 Tavily 搜索 API"""
api_key = os.getenv("TAVILY_API_KEY")
if not api_key:
return "本地环境变量 TAVILY_API_KEY 未配置,无法进行搜索。"
url = "https://api.tavily.com/search"
headers = {"Content-Type": "application/json"}
data = {
"api_key": api_key,
"query": query,
"search_depth": "advanced" if mode == "deep" else "basic",
"include_answer": False,
"max_results": 5 if mode == "deep" else 3
}
req = urllib.request.Request(url, data=json.dumps(data).encode('utf-8'), headers=headers, method='POST')
try:
with urllib.request.urlopen(req) as response:
result = json.loads(response.read().decode('utf-8'))
results = result.get("results", [])
if not results:
return "搜索未返回结果。"
formatted_res = []
for i, res in enumerate(results):
formatted_res.append(f"[{i+1}] {res.get('title')}\n{res.get('content')}\n链接: {res.get('url')}")
return "\n\n".join(formatted_res)
except Exception as e:
return f"搜索请求失败,错误: {str(e)}"
def build_openai_search_tool(mode: str) -> Dict:
"""
构建兼容型联网搜索插件工具结构 ( DeepSeek / OpenAI SDK 使用)
注意此类提供标准的 Tool/Function Function calling 模板
深度思考通常结合内置联网或者其他外挂流程实现
"""
if mode == "deep":
return {
"type": "function",
"function": {
"name": "web_search",
"description": "深度互联网搜索插件(查找并阅读网页内容)",
"parameters": {"type": "object", "properties": {"query": {"type": "string"}}},
}
}
else:
return {
"type": "function",
"function": {
"name": "web_search",
"description": "进行互联网搜索并获取实时信息或资料以辅助回答。",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "要搜索的准确关键词或短语"
}
},
"required": ["query"]
},
}
}
def build_glm_search_tool(mode: str) -> Dict:
"""构建 GLM 联网搜索工具"""
today = get_current_time_info()
if mode == "deep":
# 深度搜索:返回搜索结果详情
return {
"type": "web_search",
"web_search": {
"enable": True,
"search_engine": "search_pro",
"search_result": True,
"search_prompt": f"你是一位智能助手。请用简洁的语言总结网络搜索{{search_result}}中的关键信息,按重要性排序并引用来源日期。今天的日期是{today}",
"count": 5,
"search_recency_filter": "noLimit",
"content_size": "high",
},
}
else:
# 简单搜索
return {
"type": "web_search",
"web_search": {
"enable": True,
"search_engine": "search_pro",
"search_result": True,
"count": 5,
},
}

View File

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

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

@ -0,0 +1,230 @@
"""
分享功能路由 - 创建分享验证密码获取分享内容
"""
import json
import time
import uuid
from datetime import datetime, timezone
from typing import Dict, List, Optional
from fastapi import HTTPException
from pydantic import BaseModel
import sys
from pathlib import Path
sys.path.append(str(Path(__file__).parent.parent))
from database import get_db
from core import log_error, log_info
# ── 请求/响应模型 ─────────────────────────────────────────────────────
class CreateShareRequest(BaseModel):
"""创建分享请求"""
conversationIds: List[str] = []
messages: Optional[List[dict]] = None # 直接传递消息数据(消息分享模式)
passwordHash: str
expiresIn: Optional[int] = 604800 # 默认7天
class VerifyShareRequest(BaseModel):
"""验证分享请求"""
passwordHash: str
# ── 分享处理器 ──────────────────────────────────────────────────────────
async def create_share_handler(data: dict):
"""
创建分享
请求体:
{
"conversationIds": ["conv-1", "conv-2"],
"messages": [...], // 可选消息分享模式
"passwordHash": "sha256-hash",
"expiresIn": 604800
}
"""
try:
conversation_ids = data.get("conversationIds", [])
messages = data.get("messages") # 消息分享模式
password_hash = data.get("passwordHash", "")
expires_in = data.get("expiresIn", 604800)
if not password_hash:
raise HTTPException(status_code=400, detail="请设置访问密码")
# 获取数据库实例
db = get_db()
# 计算过期时间
now = int(datetime.now(timezone.utc).timestamp() * 1000)
expires_at = now + (expires_in * 1000)
# 消息分享模式
if messages:
if len(messages) == 0:
raise HTTPException(status_code=400, detail="请选择要分享的消息")
# 创建虚拟对话
virtual_conv_id = str(uuid.uuid4())
virtual_conversation = {
"id": virtual_conv_id,
"title": f"分享的消息 ({len(messages)}条)",
"messages": messages,
"createdAt": now,
"updatedAt": now,
}
# 创建分享记录
share_data = {
"conversationIds": [virtual_conv_id],
"conversations": [virtual_conversation],
"passwordHash": password_hash,
"expiresAt": expires_at,
}
# 保存到数据库
share = db.create_share(share_data)
log_info(f"[分享] 消息分享创建成功: {share['id']}, 消息数: {len(messages)}")
return {
"id": share["id"],
"shareUrl": f"/chat-ui/share/{share['id']}",
"expiresAt": share["expiresAt"],
}
# 对话分享模式
if not conversation_ids:
raise HTTPException(status_code=400, detail="请选择要分享的对话")
if len(conversation_ids) > 10:
raise HTTPException(status_code=400, detail="最多分享10个对话")
# 获取对话数据(快照)
conversations = []
for conv_id in conversation_ids:
conv = db.get_conversation(conv_id)
if conv:
# 创建对话快照(去除敏感信息)
conv_snapshot = {
"id": conv["id"],
"title": conv["title"],
"messages": conv.get("messages", []),
"createdAt": conv["createdAt"],
"updatedAt": conv["updatedAt"],
}
conversations.append(conv_snapshot)
if not conversations:
raise HTTPException(status_code=404, detail="未找到有效的对话")
# 创建分享记录
share_data = {
"conversationIds": conversation_ids,
"conversations": conversations,
"passwordHash": password_hash,
"expiresAt": expires_at,
}
# 保存到数据库
share = db.create_share(share_data)
log_info(f"[分享] 创建成功: {share['id']}, 对话数: {len(conversations)}")
# 返回分享信息
return {
"id": share["id"],
"shareUrl": f"/chat-ui/share/{share['id']}",
"expiresAt": share["expiresAt"],
}
except HTTPException:
raise
except Exception as e:
log_error(f"[分享] 创建失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"创建分享失败: {str(e)}")
async def get_share_handler(share_id: str):
"""
获取分享基本信息不含内容
用于显示分享预览检查是否过期等
"""
db = get_db()
share = db.get_share(share_id)
if not share:
raise HTTPException(status_code=404, detail="分享不存在")
# 检查是否过期
now = int(datetime.now(timezone.utc).timestamp() * 1000)
is_expired = now > share["expiresAt"]
if is_expired:
# 删除过期分享
db.delete_share(share_id)
raise HTTPException(status_code=404, detail="分享已过期")
return {
"id": share["id"],
"hasPassword": bool(share["passwordHash"]),
"expiresAt": share["expiresAt"],
"isExpired": is_expired,
"conversationCount": len(share["conversationIds"]),
}
async def verify_share_handler(share_id: str, data: dict):
"""
验证密码并获取分享内容
请求体:
{
"passwordHash": "sha256-hash"
}
"""
db = get_db()
share = db.get_share(share_id)
if not share:
raise HTTPException(status_code=404, detail="分享不存在")
# 检查是否过期
now = int(datetime.now(timezone.utc).timestamp() * 1000)
if now > share["expiresAt"]:
db.delete_share(share_id)
raise HTTPException(status_code=404, detail="分享已过期")
# 验证密码
password_hash = data.get("passwordHash", "")
if password_hash != share["passwordHash"]:
log_info(f"[分享] 密码验证失败: {share_id}")
return {
"valid": False,
}
# 增加访问计数
view_count = db.update_share_view_count(share_id)
log_info(f"[分享] 验证成功: {share_id}, 访问次数: {view_count}")
# 返回分享内容
return {
"valid": True,
"share": {
"id": share["id"],
"conversationIds": share["conversationIds"],
"conversations": share["conversations"],
"createdAt": share["createdAt"],
"expiresAt": share["expiresAt"],
"viewCount": view_count,
"hasPassword": bool(share["passwordHash"]),
},
}

View File

@ -113,6 +113,25 @@ class Database:
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 ─────────────────────────────────────────────────────
@ -384,6 +403,106 @@ 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

@ -66,6 +66,11 @@ from api.conversation_routes import (add_message_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
@ -113,6 +118,7 @@ async def health_check():
"openai_compatible": "/v1/chat/completions",
"models": "/v1/models",
"conversations": "/api/chat-ui/conversations",
"shares": "/api/chat-ui/shares",
},
"timestamp": datetime.now(timezone.utc).isoformat(),
}
@ -233,11 +239,32 @@ async def delete_attachment(url: str):
return await delete_attachment_handler(url)
# ── 分享功能路由 ──────────────────────────────────────────────────────
@app.post("/api/chat-ui/shares")
async def create_share(request: Request):
"""创建分享"""
return await create_share_handler(await request.json())
@app.get("/api/chat-ui/shares/{share_id}")
async def get_share(share_id: str):
"""获取分享基本信息"""
return await get_share_handler(share_id)
@app.post("/api/chat-ui/shares/{share_id}/verify")
async def verify_share(share_id: str, request: Request):
"""验证密码并获取分享内容"""
return await verify_share_handler(share_id, await request.json())
# ── 程序入口 ──────────────────────────────────────────────────────────
if __name__ == "__main__":
import uvicorn
port = int(os.getenv("PORT", 8000))
port = int(os.getenv("PORT", 8002))
# 获取可用平台
from config.settings import get_available_providers
@ -253,7 +280,7 @@ if __name__ == "__main__":
print(f" 可用平台 : {', '.join(available) or '无(请配置 API Key'}")
print("-" * 60)
print(" 使用方法:")
print(" curl -X POST http://localhost:8000/v1/chat/completions \\")
print(f" curl -X POST http://localhost:{port}/v1/chat/completions \\")
print(' -H "Content-Type: application/json" \\')
print(' -d \'{"model":"glm-4-flash","messages":[{"role":"user","content":"hi"}]}\'')
print("=" * 60)

View File

@ -1,13 +1,103 @@
fastapi==0.115.4
uvicorn==0.32.0
dashscope==1.20.12
python-multipart==0.0.18
python-dotenv==1.0.1
aiofiles==24.1.0
pydantic==2.9.2
typing-extensions==4.12.2
# 路线二阿里云文档智能解析doc/docx/pdf
llama-index-core>=0.10.0
llama-index-readers-dashscope>=0.1.0
# 阿里云 OSS 上传
alibabacloud-oss-v2
aiohappyeyeballs==2.6.1
aiohttp==3.13.3
aiosignal==1.4.0
aiosqlite==0.22.1
alibabacloud-oss-v2==1.2.4
aliyun-python-sdk-core==2.16.0
aliyun-python-sdk-kms==2.16.5
annotated-types==0.7.0
anyio==4.12.1
argcomplete==3.6.3
attrs==25.4.0
banks==2.4.1
black==26.1.0
cachetools==7.0.2
certifi==2026.2.25
cffi==2.0.0
charset-normalizer==3.4.4
click==8.3.1
colorama==0.4.6
colorlog==6.10.1
crcmod==1.7
crcmod-plus==2.3.1
cryptography==46.0.5
dashscope==1.20.12
dataclasses-json==0.6.7
dependency-groups==1.3.1
Deprecated==1.3.1
dirtyjson==1.0.8
distlib==0.4.0
distro==1.9.0
fastapi==0.115.4
filelock==3.25.0
filetype==1.2.0
frozenlist==1.8.0
fsspec==2026.2.0
greenlet==3.3.2
griffe==2.0.0
griffecli==2.0.0
griffelib==2.0.0
h11==0.16.0
httpcore==1.0.9
httpx==0.28.1
humanize==4.15.0
idna==3.11
isort==8.0.1
Jinja2==3.1.6
jiter==0.13.0
jmespath==0.10.0
joblib==1.5.3
llama-index-core==0.14.15
llama-index-instrumentation==0.4.2
llama-index-readers-dashscope==0.4.1
llama-index-workflows==2.15.0
MarkupSafe==3.0.3
marshmallow==3.26.2
multidict==6.7.1
mypy_extensions==1.1.0
nest-asyncio==1.6.0
networkx==3.6.1
nltk==3.9.3
nox==2026.2.9
numpy==2.4.2
openai==2.26.0
oss2==2.19.1
packaging==26.0
pathspec==1.0.4
pillow==12.1.1
platformdirs==4.9.2
propcache==0.4.1
pycparser==3.0
pycryptodome==3.23.0
pydantic==2.12.5
pydantic_core==2.41.5
PyJWT==2.11.0
python-discovery==1.1.0
python-dotenv==1.0.1
python-multipart==0.0.18
pytokens==0.4.1
PyYAML==6.0.3
regex==2026.2.28
requests==2.32.5
retrying==1.4.2
setuptools==82.0.0
six==1.17.0
sniffio==1.3.1
SQLAlchemy==2.0.48
starlette==0.41.3
tenacity==9.1.4
tiktoken==0.12.0
tinytag==2.2.0
tqdm==4.67.3
typing-inspect==0.9.0
typing-inspection==0.4.2
typing_extensions==4.15.0
urllib3==2.6.3
uvicorn==0.32.0
virtualenv==21.1.0
websocket-client==1.9.0
wrapt==2.1.1
yarl==1.23.0
# zai-sdk==0.2.2
zhipuai==2.1.5.20250825

View File

@ -33,10 +33,9 @@ from dotenv import load_dotenv
# ── 加载环境变量 ──────────────────────────────────────────────
load_dotenv()
# AccessKey 从系统环境变量读取(~/.bashrc 中 export 设置)
OSS_ACCESS_KEY_ID = os.environ.get("OSS_ACCESS_KEY_ID", "")
OSS_ACCESS_KEY_SECRET = os.environ.get("OSS_ACCESS_KEY_SECRET", "")
# 以下配置从 .env 文件读取
# 所有配置从 .env 文件读取
OSS_ACCESS_KEY_ID = os.getenv("OSS_ACCESS_KEY_ID", "")
OSS_ACCESS_KEY_SECRET = os.getenv("OSS_ACCESS_KEY_SECRET", "")
OSS_BUCKET_NAME = os.getenv("OSS_BUCKET_NAME", "")
OSS_ENDPOINT = os.getenv("OSS_ENDPOINT", "")
OSS_REGION = os.getenv("OSS_REGION", "")

View File

@ -0,0 +1,46 @@
import os
import sys
import urllib.request
import json
def test_tavily(api_key: str):
url = "https://api.tavily.com/search"
headers = {
"Content-Type": "application/json"
}
data = {
"api_key": api_key,
"query": "武汉明天的天气",
"search_depth": "basic",
"include_answer": False,
"max_results": 3
}
# 模拟请求
req = urllib.request.Request(url, data=json.dumps(data).encode('utf-8'), headers=headers, method='POST')
try:
with urllib.request.urlopen(req) as response:
result = json.loads(response.read().decode('utf-8'))
print("✅ Tavily API Key 测试成功!成功获取以下搜索结果:\n")
for i, res in enumerate(result.get("results", [])):
print(f"[{i+1}] 标题: {res.get('title')}")
print(f" 内容: {res.get('content')}")
print(f" 链接: {res.get('url')}\n")
except urllib.error.HTTPError as e:
print(f"❌ 请求失败HTTP 错误代码: {e.code}")
print("这通常意味着您的 API Key 错误或无效。详细信息:")
error_msg = e.read().decode('utf-8')
print(error_msg)
except Exception as e:
print(f"❌ 发生其他错误: {str(e)}")
if __name__ == "__main__":
key = input("请输入您的 Tavily API Key (以 tvly- 开头): ").strip()
if not key:
print("未输入 Key程序退出。")
sys.exit(1)
print("\n正在连接 Tavily 进行测试搜索...")
test_tavily(key)

View File

@ -1,16 +1,6 @@
<template>
<div class="app" :class="{ dark: isDark }">
<!-- 侧边栏 -->
<ChatSidebar />
<!-- 主内容区 -->
<ChatMain ref="chatMainRef" @toggle-sidebar="toggleSidebar" />
<!-- 模态框 -->
<SearchModal />
<ShortcutsModal />
<SettingsModal />
<ConversationSettingsModal />
<router-view />
<!-- Toast 通知 -->
<Teleport to="body">
@ -32,29 +22,14 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { ref, computed } from "vue";
import { storeToRefs } from "pinia";
import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings";
import { useKeyboard, getDefaultShortcuts } from "@/composables/useKeyboard";
import ChatSidebar from "@/components/sidebar/ChatSidebar.vue";
import ChatMain from "@/components/chat/ChatMain.vue";
import SearchModal from "@/components/modals/SearchModal.vue";
import ShortcutsModal from "@/components/modals/ShortcutsModal.vue";
import SettingsModal from "@/components/modals/SettingsModal.vue";
import ConversationSettingsModal from "@/components/modals/ConversationSettingsModal.vue";
import { Check, AlertCircle, Info } from "@/components/icons";
import { useAuthStore } from "./stores/auth";
const authStore = useAuthStore();
// 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") {
@ -85,57 +60,6 @@ function showToast(message: string, type: Toast["type"] = "info") {
}, 3000);
}
//
function toggleSidebar() {
settingsStore.toggleSidebar();
}
function newChat() {
chatStore.createConversation();
showToast("已创建新对话", "success");
}
function focusInput() {
chatMainRef.value?.focusInput();
}
//
useKeyboard(
getDefaultShortcuts({
newChat,
toggleSidebar,
focusInput,
sendMessage: () => {}, // ChatInput
cancelStream: () => {
if (chatStore.isStreaming) {
chatStore.stopStreaming();
showToast("已停止生成", "info");
}
},
toggleTheme: () => {
settingsStore.toggleTheme();
showToast(`主题已切换为 ${settings.value.theme}`, "success");
},
showShortcuts: () => {
settingsStore.openShortcutsModal();
},
searchConversations: () => {
settingsStore.openSearchModal();
},
}),
);
//
onMounted(() => {
authStore.init();
console.log(authStore.token);
// //
// if (chatStore.conversations.length === 0) {
// chatStore.createConversation();
// }
});
// 使
window.$toast = showToast;
</script>

View File

@ -0,0 +1,66 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import ShareButton from '@/components/sidebar/ShareButton.vue'
// Mock window.$toast
vi.stubGlobal('$toast', vi.fn())
describe('ShareButton 组件测试', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('应该渲染分享按钮', () => {
const wrapper = mount(ShareButton)
expect(wrapper.find('.share-btn').exists()).toBe(true)
expect(wrapper.text()).toContain('分享对话')
})
it('点击按钮应该进入选择模式', async () => {
const wrapper = mount(ShareButton)
const pinia = createPinia()
setActivePinia(pinia)
await wrapper.find('.share-btn').trigger('click')
// 检查是否切换到选择模式
// 这里需要通过 store 来验证
})
it('在选择模式下应该显示选择信息', async () => {
const pinia = createPinia()
setActivePinia(pinia)
const wrapper = mount(ShareButton)
// 模拟进入选择模式
const { useChatStore } = await import('@/stores/chat')
const store = useChatStore()
store.enterSelectMode()
store.selectedConversationIds = ['conv-1', 'conv-2']
await wrapper.vm.$nextTick()
expect(wrapper.find('.select-actions').exists()).toBe(true)
expect(wrapper.text()).toContain('已选择 2 个对话')
})
it('选择数量为 0 时确认按钮应该禁用', async () => {
const pinia = createPinia()
setActivePinia(pinia)
const wrapper = mount(ShareButton)
const { useChatStore } = await import('@/stores/chat')
const store = useChatStore()
store.enterSelectMode()
// selectedConversationIds 为空
await wrapper.vm.$nextTick()
const confirmBtn = wrapper.find('.action-btn.confirm')
expect(confirmBtn.attributes('disabled')).toBeDefined()
})
})

View File

@ -0,0 +1,116 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useChatStore } from '@/stores/chat'
import type { Conversation } from '@/types/chat'
describe('Chat Store - 多选模式测试', () => {
beforeEach(() => {
// 每个测试前创建新的 Pinia 实例
setActivePinia(createPinia())
})
describe('多选模式状态', () => {
it('初始状态应该是未选择模式', () => {
const store = useChatStore()
expect(store.isSelectMode).toBe(false)
expect(store.selectedConversationIds).toEqual([])
})
it('toggleSelectMode 应该切换选择模式', () => {
const store = useChatStore()
expect(store.isSelectMode).toBe(false)
store.toggleSelectMode()
expect(store.isSelectMode).toBe(true)
store.toggleSelectMode()
expect(store.isSelectMode).toBe(false)
})
it('enterSelectMode 应该进入选择模式', () => {
const store = useChatStore()
store.enterSelectMode()
expect(store.isSelectMode).toBe(true)
})
it('exitSelectMode 应该退出选择模式并清除选择', () => {
const store = useChatStore()
store.enterSelectMode()
store.selectedConversationIds.push('conv-1', 'conv-2')
store.exitSelectMode()
expect(store.isSelectMode).toBe(false)
expect(store.selectedConversationIds).toEqual([])
})
})
describe('对话选择操作', () => {
it('toggleConversationSelection 应该切换对话选择状态', () => {
const store = useChatStore()
// 选择对话
store.toggleConversationSelection('conv-1')
expect(store.selectedConversationIds).toContain('conv-1')
// 取消选择
store.toggleConversationSelection('conv-1')
expect(store.selectedConversationIds).not.toContain('conv-1')
})
it('selectAllConversations 应该选择所有非归档对话', () => {
const store = useChatStore()
// 手动添加一些对话用于测试
store.conversations = [
{ id: 'conv-1', archived: false } as Conversation,
{ id: 'conv-2', archived: false } as Conversation,
{ id: 'conv-3', archived: true } as Conversation, // 归档的不应该被选择
]
store.selectAllConversations()
expect(store.selectedConversationIds).toContain('conv-1')
expect(store.selectedConversationIds).toContain('conv-2')
expect(store.selectedConversationIds).not.toContain('conv-3')
})
it('clearSelection 应该清除所有选择', () => {
const store = useChatStore()
store.selectedConversationIds = ['conv-1', 'conv-2', 'conv-3']
store.clearSelection()
expect(store.selectedConversationIds).toEqual([])
})
it('isConversationSelected 应该正确判断对话是否被选中', () => {
const store = useChatStore()
store.selectedConversationIds = ['conv-1', 'conv-2']
expect(store.isConversationSelected('conv-1')).toBe(true)
expect(store.isConversationSelected('conv-2')).toBe(true)
expect(store.isConversationSelected('conv-3')).toBe(false)
})
})
describe('计算属性', () => {
it('selectedCount 应该返回选中的对话数量', () => {
const store = useChatStore()
store.selectedConversationIds = ['conv-1', 'conv-2', 'conv-3']
expect(store.selectedCount).toBe(3)
})
it('selectedConversations 应该返回选中的对话对象数组', () => {
const store = useChatStore()
store.conversations = [
{ id: 'conv-1', title: '对话1' } as Conversation,
{ id: 'conv-2', title: '对话2' } as Conversation,
{ id: 'conv-3', title: '对话3' } as Conversation,
]
store.selectedConversationIds = ['conv-1', 'conv-3']
expect(store.selectedConversations).toHaveLength(2)
expect(store.selectedConversations.map(c => c.id)).toEqual(['conv-1', 'conv-3'])
})
})
})

View File

@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest'
import { sha256, hashPassword, verifyPassword } from '@/utils/crypto'
describe('crypto 工具测试', () => {
describe('sha256', () => {
it('应该正确计算字符串的 SHA-256 哈希值', async () => {
const text = 'hello'
const hash = await sha256(text)
// SHA-256('hello') 的已知结果
expect(hash).toBe('2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824')
})
it('空字符串应该返回正确的哈希值', async () => {
const hash = await sha256('')
// SHA-256('') 的已知结果
expect(hash).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
})
it('不同字符串应该产生不同的哈希值', async () => {
const hash1 = await sha256('password1')
const hash2 = await sha256('password2')
expect(hash1).not.toBe(hash2)
})
})
describe('hashPassword', () => {
it('应该对密码进行加盐哈希', async () => {
const password = 'mypassword'
const hash = await hashPassword(password)
// 验证返回的是64字符的十六进制字符串
expect(hash).toHaveLength(64)
expect(/^[a-f0-9]{64}$/.test(hash)).toBe(true)
})
it('相同密码应该产生相同的哈希值', async () => {
const password = 'test123'
const hash1 = await hashPassword(password)
const hash2 = await hashPassword(password)
expect(hash1).toBe(hash2)
})
})
describe('verifyPassword', () => {
it('正确的密码应该验证通过', async () => {
const password = 'correctpassword'
const hash = await hashPassword(password)
const isValid = await verifyPassword(password, hash)
expect(isValid).toBe(true)
})
it('错误的密码应该验证失败', async () => {
const password = 'correctpassword'
const hash = await hashPassword(password)
const isValid = await verifyPassword('wrongpassword', hash)
expect(isValid).toBe(false)
})
it('空密码应该验证失败', async () => {
const hash = await hashPassword('somepassword')
const isValid = await verifyPassword('', hash)
expect(isValid).toBe(false)
})
})
})

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

@ -0,0 +1,67 @@
// 测试环境全局配置
import { vi } from 'vitest'
// Mock window.$toast
vi.stubGlobal('$toast', vi.fn())
// Mock 所有 API 请求,避免测试时连接后端
const mockFetch = vi.fn((url: string) => {
// 根据 URL 返回不同的 mock 数据
if (url.includes('/api/chat-ui/conversations')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve([]), // 返回空对话列表
})
}
if (url.includes('/api/chat-ui/models')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
object: 'list',
data: [
{ id: 'glm-4-flash', name: 'GLM-4-Flash', description: '测试模型', maxTokens: 8192, provider: 'Zhipu', supports_thinking: false, supports_web_search: false, supports_vision: false, supports_files: false }
]
}),
})
}
// 默认返回空响应
return Promise.resolve({
ok: true,
json: () => Promise.resolve({}),
})
})
vi.stubGlobal('fetch', mockFetch)
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => {
store[key] = value
},
removeItem: (key: string) => {
delete store[key]
},
clear: () => {
store = {}
},
}
})()
vi.stubGlobal('localStorage', localStorageMock)
// Mock matchMedia
vi.stubGlobal('matchMedia', vi.fn(() => ({
matches: false,
media: '',
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})))

View File

@ -0,0 +1,150 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { shareApi } from '@/services/shareApi'
import type { ShareCreateResponse, ShareVerifyResponse, ShareGetResponse } from '@/types/share'
// Mock fetch
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
// Mock getAuthHeaders
vi.mock('@/services/request', () => ({
getAuthHeaders: () => ({ 'Authorization': 'Bearer test-token' }),
}))
describe('分享 API 测试', () => {
beforeEach(() => {
mockFetch.mockReset()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('createShare', () => {
it('应该成功创建分享', async () => {
const mockResponse: ShareCreateResponse = {
id: 'share-123',
shareUrl: 'https://example.com/chat-ui/share/share-123',
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000,
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockResponse),
})
const result = await shareApi.createShare({
conversationIds: ['conv-1', 'conv-2'],
passwordHash: 'hashed-password',
expiresIn: 604800,
})
expect(mockFetch).toHaveBeenCalledWith(
'/api/chat-ui/shares',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/json',
}),
})
)
expect(result.id).toBe('share-123')
expect(result.shareUrl).toContain('share-123')
})
it('创建失败应该抛出错误', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
text: () => Promise.resolve('Internal Server Error'),
})
await expect(
shareApi.createShare({
conversationIds: ['conv-1'],
passwordHash: 'hash',
})
).rejects.toThrow()
})
})
describe('getShare', () => {
it('应该获取分享基本信息', async () => {
const mockResponse: ShareGetResponse = {
id: 'share-123',
hasPassword: true,
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000,
isExpired: false,
conversationCount: 2,
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockResponse),
})
const result = await shareApi.getShare('share-123')
expect(result.id).toBe('share-123')
expect(result.hasPassword).toBe(true)
expect(result.isExpired).toBe(false)
})
it('分享不存在应该抛出错误', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
})
await expect(shareApi.getShare('invalid-id')).rejects.toThrow('分享不存在或已过期')
})
})
describe('verifyShare', () => {
it('正确密码应该验证成功', async () => {
const mockResponse: ShareVerifyResponse = {
valid: true,
share: {
id: 'share-123',
conversationIds: ['conv-1'],
conversations: [],
createdAt: Date.now(),
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000,
viewCount: 0,
hasPassword: true,
},
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockResponse),
})
const result = await shareApi.verifyShare('share-123', {
passwordHash: 'correct-hash',
})
expect(result.valid).toBe(true)
expect(result.share).toBeDefined()
})
it('错误密码应该验证失败', async () => {
const mockResponse: ShareVerifyResponse = {
valid: false,
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockResponse),
})
const result = await shareApi.verifyShare('share-123', {
passwordHash: 'wrong-hash',
})
expect(result.valid).toBe(false)
expect(result.share).toBeUndefined()
})
})
})

View File

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

View File

@ -180,6 +180,8 @@ async function handleSend(
webSearch?: boolean;
deepThinking?: boolean;
systemPrompt?: string;
skipUserMessage?: boolean;
conversationTitle?: string;
},
) {
//
@ -220,28 +222,46 @@ async function handleSend(
//
if (!currentConversation.value) {
await chatStore.createConversation();
await chatStore.createConversation(options?.conversationTitle || text);
} else if (currentConversation.value.title === "新对话") {
// ""
chatStore.renameConversation(
currentConversation.value.id,
options?.conversationTitle || text
);
}
// 使使
const systemPrompt = options?.systemPrompt || currentConversation.value?.settings?.systemPrompt;
//
const existingMessages = currentConversation.value?.messages || [];
const hasSystemMessage = existingMessages.some((m: any) => m.role === MessageRole.SYSTEM);
//
if (systemPrompt && !hasSystemMessage) {
await chatStore.addMessage(MessageRole.SYSTEM, {
type: MessageType.TEXT,
text: systemPrompt,
});
}
//
const existingMessages = currentConversation.value?.messages || [];
const updatedMessages = currentConversation.value?.messages || [];
const MAX_HISTORY_ROUNDS = 20; // 20 40
const historyMessages = existingMessages
.filter(
(m: any) =>
m.role === MessageRole.USER || m.role === MessageRole.ASSISTANT,
)
.filter((m: any) => m.content?.text) //
const historyMessages = updatedMessages.filter((m: any) => m.content?.text) //
.slice(-(MAX_HISTORY_ROUNDS * 2))
.map((m: any) => ({ role: m.role, content: m.content.text }));
//
//
if (!options?.skipUserMessage) {
await chatStore.addMessage(MessageRole.USER, {
type: MessageType.TEXT,
text,
images: attachments.filter((a) => a.type === "image"),
files: attachments.filter((a) => a.type === "file"),
});
}
// AI
const aiMessage = await chatStore.addMessage(MessageRole.ASSISTANT, {
@ -269,7 +289,7 @@ async function handleSend(
const stream = chatApi.streamChat(
{
message: text,
message: options?.skipUserMessage ? "直接输出系统提示词要求你的回答" : text,
conversationId: currentConversation.value?.id || "",
images: imageUrls,
files: fileUrls,
@ -475,7 +495,11 @@ function handleRegenerate(messageId: string) {
}
function handleSuggestion(suggestion: Suggestion) {
handleSend(suggestion.text, [], { systemPrompt: suggestion.systemPrompt });
handleSend(suggestion.text, [], {
systemPrompt: suggestion.systemPrompt,
skipUserMessage: true,
conversationTitle: suggestion.text,
});
}
function focusInput() {

View File

@ -1,9 +1,33 @@
<template>
<div ref="boxRef" class="message-list-container">
<div class="message-list-container">
<!-- 消息选择操作栏 -->
<Transition name="slide-down">
<div v-if="isMessageSelectMode" class="message-select-bar">
<div class="select-info">
<span class="select-count">已选择 {{ selectedMessageCount }} 条消息</span>
</div>
<div class="select-actions">
<button class="action-btn select-all" @click="handleSelectAll">
全选
</button>
<button class="action-btn cancel" @click="handleCancelSelect">
取消
</button>
<button
class="action-btn confirm"
:disabled="selectedMessageCount === 0"
@click="handleConfirmShare"
>
确认分享
</button>
</div>
</div>
</Transition>
<div ref="containerRef" class="message-list" @scroll="handleScroll">
<!-- 欢迎界面 -->
<WelcomeScreen
v-if="messages.length === 0"
v-if="visibleMessages.length === 0"
@select="$emit('select-suggestion', $event)"
/>
@ -12,12 +36,14 @@
<div class="messages-wrapper">
<TransitionGroup name="message">
<MessageBubble
v-for="(message, index) in messages"
v-for="(message, index) in visibleMessages"
:key="message.id"
:message="message"
:show-timestamp="showTimestamp"
:compact="compact"
:is-New="index === messages.length - 1"
:is-New="index === visibleMessages.length - 1"
:is-message-select-mode="isMessageSelectMode"
:is-selected="isMessageSelected(message.id)"
@retry="$emit('retry', message.id)"
@regenerate="$emit('regenerate', message.id)"
@copy="handleCopy(message)"
@ -27,6 +53,8 @@
@preview-image="handlePreviewImage"
@play-video="handlePlayVideo"
@download-file="handleDownloadFile"
@toggle-select="handleToggleMessageSelect(message.id)"
@enter-select-mode="handleEnterSelectMode(message.id)"
/>
</TransitionGroup>
@ -62,12 +90,14 @@
</template>
<script setup lang="ts">
import { ref, watch, nextTick, onMounted } from "vue";
import { ref, watch, nextTick, onMounted, computed } from "vue";
import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings";
import MessageBubble from "@/components/message/MessageBubble.vue";
import WelcomeScreen from "./WelcomeScreen.vue";
import { Bot, ChevronDown } from "@/components/icons";
import type { Message, Attachment, VideoInfo, Suggestion } from "@/types/chat";
import { MessageRole } from "@/types/chat";
const props = withDefaults(
defineProps<{
@ -83,6 +113,13 @@ const props = withDefaults(
},
);
//
const visibleMessages = computed(() => {
return props.messages.filter(
(message) => message.role !== MessageRole.SYSTEM
);
});
const emit = defineEmits<{
retry: [messageId: string];
regenerate: [messageId: string];
@ -93,10 +130,14 @@ const emit = defineEmits<{
}>();
const chatStore = useChatStore();
const settingsStore = useSettingsStore();
//
const isMessageSelectMode = computed(() => chatStore.isMessageSelectMode);
const selectedMessageCount = computed(() => chatStore.selectedMessageCount);
//
const boxRef: any = ref<HTMLElement | null>(null);
const containerRef: any = ref<HTMLElement | null>(null);
const containerRef = ref<HTMLElement | null>(null);
const showScrollButton = ref(false);
const newMessageCount = ref(0);
const isAutoScrolling = ref(true);
@ -173,9 +214,36 @@ function handleDownloadFile(file: Attachment) {
emit("download-file", file);
}
//
function handleEnterSelectMode(messageId: string) {
chatStore.enterMessageSelectMode(chatStore.currentConversationId || '', messageId);
}
function handleToggleMessageSelect(messageId: string) {
chatStore.toggleMessageSelection(messageId);
}
function handleSelectAll() {
chatStore.selectAllMessages();
}
function handleCancelSelect() {
chatStore.exitMessageSelectMode();
}
function handleConfirmShare() {
if (chatStore.selectedMessageCount > 0) {
settingsStore.openShareModal();
}
}
function isMessageSelected(messageId: string): boolean {
return chatStore.isMessageSelected(messageId);
}
//
watch(
() => props.messages.length,
() => visibleMessages.value.length,
(newLen, oldLen) => {
if (newLen > oldLen) {
if (isAutoScrolling.value) {
@ -191,7 +259,7 @@ watch(
//
watch(
() => props.messages[props.messages.length - 1]?.content.text,
() => visibleMessages.value[visibleMessages.value.length - 1]?.content.text,
() => {
if (isAutoScrolling.value) {
nextTick(() => {
@ -222,7 +290,10 @@ defineExpose({
});
onMounted(() => {
//
if (visibleMessages.value.length > 0) {
scrollToBottom(false);
}
});
</script>
@ -398,4 +469,97 @@ onMounted(() => {
opacity: 1;
}
}
//
.message-select-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
background: white;
border-bottom: 1px solid #e5e7eb;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
.dark & {
background: #1e1e2e;
border-bottom-color: #2d2d3d;
}
}
.select-info {
display: flex;
align-items: center;
gap: 12px;
}
.select-count {
font-size: 14px;
font-weight: 500;
color: #374151;
.dark & {
color: #e5e7eb;
}
}
.select-actions {
display: flex;
align-items: center;
gap: 8px;
}
.action-btn {
padding: 8px 16px;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&.select-all,
&.cancel {
background: #f3f4f6;
color: #6b7280;
.dark & {
background: #2d2d3d;
color: #9ca3af;
}
&:hover {
background: #e5e7eb;
.dark & {
background: #374151;
}
}
}
&.confirm {
background: #3b82f6;
color: white;
&:hover:not(:disabled) {
background: #2563eb;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
//
.slide-down-enter-active,
.slide-down-leave-active {
transition: all 0.3s ease;
}
.slide-down-enter-from,
.slide-down-leave-to {
opacity: 0;
transform: translateY(-100%);
}
</style>

View File

@ -2,16 +2,7 @@
<div class="welcome-screen">
<!-- Logo 和标题 -->
<div class="welcome-header">
<div class="logo-wrapper">
<div class="logo-icon">
<Bot :size="40" />
</div>
<div class="logo-glow"></div>
</div>
<h1 class="title">Kexue AI 智能助手</h1>
<p class="subtitle">
大学生用GPT把自己学废了? Study模式拒绝直接给答案引导学生思考
</p>
<h1 class="title">学习教学科研聊天助手</h1>
</div>
<!-- 功能卡片 -->
@ -63,7 +54,6 @@
<script setup lang="ts">
import { computed } from "vue";
import {
Bot,
MessageSquare,
Code,
Image,

View File

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

View File

@ -54,6 +54,7 @@
v-model="inputText"
:placeholder="placeholder"
:rows="1"
@beforeinput="handleBeforeInput"
@input="autoResize"
@focus="isFocused = true"
@blur="isFocused = false"
@ -90,13 +91,18 @@
<!-- 底部工具栏 -->
<div class="input-toolbar">
<div class="toolbar-left">
<!-- 展开/收起 -->
<button class="toolbar-btn" title="展开输入框" @click="toggleExpand">
<Maximize2 v-if="!isExpanded" :size="16" />
<Minimize2 v-else :size="16" />
</button>
<!-- 深度思考开关 -->
<button
class="toolbar-btn"
:class="{ active: isDeepThinking, disabled: !supports_thinking }"
:disabled="!supports_thinking"
:title="supports_thinking ? '深度思考' : '当前模型不支持深度思考'"
@click="supports_thinking && toggleDeepThink()"
:class="{ active: isDeepThinking, disabled: isForceDeepThinkingModel || !supports_thinking }"
:disabled="isForceDeepThinkingModel || !supports_thinking"
:title="isForceDeepThinkingModel ? '当前模型强制开启深度思考' : (supports_thinking ? '深度思考' : '当前模型不支持深度思考')"
@click="!isForceDeepThinkingModel && supports_thinking && toggleDeepThink()"
>
<Brain :size="16" />
<span>深度思考</span>
@ -125,24 +131,6 @@
<Globe :size="16" />
<span>联网搜索</span>
</button>
<!-- 展开/收起 -->
<button class="toolbar-btn" title="展开输入框" @click="toggleExpand">
<Maximize2 v-if="!isExpanded" :size="16" />
<Minimize2 v-else :size="16" />
</button>
</div>
<div class="toolbar-right">
<span
class="char-count"
:class="{ warning: charCount > maxChars * 0.9 }"
>
{{ charCount }} / {{ maxChars }}
</span>
<span class="send-hint">
{{ sendOnEnter ? "Enter 发送, Shift+Enter 换行" : "Ctrl+Enter 发送" }}
</span>
</div>
</div>
</div>
@ -162,7 +150,6 @@ import {
Brain,
Loader2,
} from "@/components/icons";
import AttachmentPreview from "./AttachmentPreview.vue";
import { generateId } from "@/utils/helpers";
import type { Attachment } from "@/types/chat";
import { chatApi } from "@/services/api";
@ -191,7 +178,7 @@ const props = withDefaults(
placeholder: "输入你的问题...",
isStreaming: false,
sendOnEnter: false,
maxChars: 4000,
maxChars: 10000,
disabled: false,
//
supports_thinking: true,
@ -201,6 +188,14 @@ const props = withDefaults(
},
);
//
const FORCE_DEEP_THINKING_MODELS = ["deepseek-reasoner", "glm-z1-flash"];
//
const isForceDeepThinkingModel = computed(() => {
return FORCE_DEEP_THINKING_MODELS.includes(modelName.value.toLowerCase());
});
const emit = defineEmits<{
send: [
text: string,
@ -213,6 +208,7 @@ const emit = defineEmits<{
//
const authStore = useAuthStore();
const settingsStore = useSettingsStore();
const modelName = computed(() => settingsStore.settings.defaultModel);
const inputText = ref("");
const attachments = ref<AttachmentWithProgress[]>([]);
@ -232,6 +228,18 @@ const textareaRef = ref<HTMLTextAreaElement | null>(null);
const fileInputRef = ref<HTMLInputElement | null>(null);
const imageInputRef = ref<HTMLInputElement | null>(null);
// toast
let lastToastTime = 0;
const toastThrottleMs = 2000;
function showThrottledToast(message: string, type: "error" = "error") {
const now = Date.now();
if (now - lastToastTime >= toastThrottleMs) {
lastToastTime = now;
window.$toast?.(message, type);
}
}
//
const charCount = computed(() => inputText.value.length);
const isUploading = computed(() => attachments.value.some((a) => a.uploading));
@ -251,7 +259,30 @@ function autoResize() {
textarea.style.height = "auto";
const maxHeight = isExpanded.value ? 400 : 160;
textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`;
// 1px
textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight) + 1}px`;
}
//
function handleBeforeInput(event: InputEvent) {
// 退
if (!event.data) return;
//
const currentLength = inputText.value.length;
const insertLength = event.data?.length || 0;
const selectionStart =
(event.target as HTMLTextAreaElement).selectionStart || 0;
const selectionEnd = (event.target as HTMLTextAreaElement).selectionEnd || 0;
const selectedLength = selectionEnd - selectionStart;
//
const newLength = currentLength - selectedLength + insertLength;
if (newLength > props.maxChars) {
event.preventDefault();
showThrottledToast(`已超${props.maxChars}字上限,请删除部分内容`);
}
}
//
@ -298,6 +329,22 @@ async function handlePaste(event: ClipboardEvent) {
const items = event.clipboardData?.items;
if (!items) return;
//
const text = event.clipboardData?.getData("text");
if (text) {
const textarea = event.target as HTMLTextAreaElement;
const selectionStart = textarea.selectionStart || 0;
const selectionEnd = textarea.selectionEnd || 0;
const selectedLength = selectionEnd - selectionStart;
const newLength = inputText.value.length - selectedLength + text.length;
if (newLength > props.maxChars) {
event.preventDefault();
showThrottledToast(`已超${props.maxChars}字上限,请删除部分内容`);
return;
}
}
for (const item of items) {
if (item.type.startsWith("image/")) {
event.preventDefault();
@ -350,7 +397,7 @@ async function addFileAsAttachment(
) {
//
if (!authStore.isAuthenticated) {
window.$toast?.('请先登录', 'error');
window.$toast?.("请先登录", "error");
return;
}
@ -414,17 +461,17 @@ async function removeAttachment(id: string) {
const attachment = attachments.value[index];
// OSS blob URL OSS
if (attachment.url && !attachment.url.startsWith('blob:')) {
if (attachment.url && !attachment.url.startsWith("blob:")) {
try {
await chatApi.deleteAttachment(attachment.url);
} catch (error) {
console.error('删除 OSS 文件失败:', error);
console.error("删除 OSS 文件失败:", error);
// 使
}
}
// blob URL
if (attachment.url.startsWith('blob:')) {
if (attachment.url.startsWith("blob:")) {
URL.revokeObjectURL(attachment.url);
}
@ -491,10 +538,20 @@ watch(inputText, () => {
//
watch(
() => settingsStore.settings.defaultModel,
() => {
isDeepSearch.value = false;
(newModel) => {
//
if (FORCE_DEEP_THINKING_MODELS.includes(newModel.toLowerCase())) {
isDeepThinking.value = true;
localStorage.setItem("isDeepThinking", "true");
} else {
isDeepThinking.value = false;
localStorage.setItem("isDeepThinking", "false");
}
//
isDeepSearch.value = false;
isWebSearch.value = false;
localStorage.setItem("isDeepSearch", "false");
localStorage.setItem("isWebSearch", "false");
},
);
@ -631,7 +688,7 @@ onMounted(() => {
textarea {
width: 100%;
min-height: 24px;
min-height: 25px;
max-height: 160px;
padding: 8px 0;
border: none;
@ -718,26 +775,6 @@ onMounted(() => {
}
}
.toolbar-right {
display: flex;
align-items: center;
gap: 16px;
}
.char-count {
font-size: 12px;
color: #9ca3af;
&.warning {
color: #f59e0b;
}
}
.send-hint {
font-size: 12px;
color: #9ca3af;
}
@keyframes pulse {
0%,
100% {

View File

@ -8,11 +8,21 @@
'is-end': !message.isEnd && message.role !== 'user',
'is-error': message.isError,
compact: compact,
'message-select-mode': isMessageSelectMode,
'message-selected': isSelected,
},
]"
@click="handleBubbleClick"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
>
<!-- 消息选择模式复选框 -->
<div v-if="isMessageSelectMode" class="message-checkbox" @click.stop="handleToggleSelect">
<div class="checkbox" :class="{ checked: isSelected }">
<Check v-if="isSelected" :size="14" />
</div>
</div>
<!-- 头像 -->
<div class="avatar">
<div class="avatar-inner" :class="message.role">
@ -24,14 +34,14 @@
<!-- 消息内容区域 -->
<div class="message-content-wrapper">
<!-- 角色名称 -->
<div class="message-header">
<!-- <div class="message-header">
<span class="role-name">
{{ message.role === "assistant" ? "AI 助手" : "你" }}
</span>
<span v-if="showTimestamp" class="timestamp">
{{ formattedTime }}
</span>
</div>
</div> -->
<!-- 消息主体 -->
<div class="message-body">
@ -164,7 +174,9 @@
v-if="
message.role === 'assistant' &&
!message.isStreaming &&
!message.isError
!message.isError &&
!readonly &&
!isMessageSelectMode
"
:content="message.content.text || ''"
:feedback="message.feedback"
@ -176,6 +188,7 @@
@like="handleLike"
@dislike="handleDislike"
@regenerate="$emit('regenerate')"
@share="handleShareClick"
/>
</div>
</div>
@ -183,7 +196,7 @@
<script setup lang="ts">
import { useClipboard } from "@vueuse/core";
import { ref, computed } from "vue";
import { ref } from "vue";
// markstream-vue
import MarkdownRender from "markstream-vue";
import { setCustomComponents } from "markstream-vue";
@ -195,10 +208,10 @@ import {
Zap,
Maximize2,
Play,
Download,
Check,
} from "@/components/icons";
import MessageActions from "./MessageActions.vue";
import { formatTimestamp, formatFileSize, getFileIcon } from "@/utils/helpers";
import { formatFileSize, getFileIcon } from "@/utils/helpers";
import type { Message, Suggestion, Attachment, VideoInfo } from "@/types/chat";
import ThinkingNode from "./components/ThinkingNode.vue";
import EChartsContainerNode from "./components/EChartsContainerNode.vue";
@ -209,10 +222,16 @@ const props = withDefaults(
showTimestamp?: boolean;
compact?: boolean;
isNew?: boolean;
isMessageSelectMode?: boolean;
isSelected?: boolean;
readonly?: boolean;
}>(),
{
showTimestamp: true,
compact: false,
isMessageSelectMode: false,
isSelected: false,
readonly: false,
},
);
const { copy } = useClipboard({ legacy: true });
@ -226,13 +245,28 @@ const emit = defineEmits<{
"preview-image": [image: Attachment, index: number];
"play-video": [video: VideoInfo];
"download-file": [file: Attachment];
"toggle-select": [];
"enter-select-mode": [];
}>();
const isHovered = ref(false);
const formattedTime = computed(() => {
return formatTimestamp(props.message.timestamp);
});
//
function handleBubbleClick() {
if (props.isMessageSelectMode) {
emit("toggle-select");
}
}
//
function handleToggleSelect() {
emit("toggle-select");
}
//
function handleShareClick() {
emit("enter-select-mode");
}
function getFileEmoji(mimeType?: string) {
return getFileIcon(mimeType || "");
@ -279,6 +313,28 @@ setCustomComponents("playground-demo", {
padding: 20px 10%;
animation: fadeIn 0.3s ease;
//
&.message-select-mode {
cursor: pointer;
transition: background 0.2s ease;
&:hover {
background: rgba(59, 130, 246, 0.05);
.dark & {
background: rgba(59, 130, 246, 0.1);
}
}
}
&.message-selected {
background: rgba(59, 130, 246, 0.1);
.dark & {
background: rgba(59, 130, 246, 0.15);
}
}
&.role-user {
flex-direction: row-reverse;
@ -611,15 +667,16 @@ setCustomComponents("playground-demo", {
}
}
.images-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 8px;
.images-flex {
display: inline-flex;
flex-wrap: wrap;
gap: 7px;
margin-top: 12px;
}
.image-item {
position: relative;
width: 130px;
aspect-ratio: 1;
border-radius: 12px;
overflow: hidden;
@ -835,4 +892,41 @@ setCustomComponents("playground-demo", {
opacity: 1;
}
}
//
.message-checkbox {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
z-index: 10;
.checkbox {
width: 22px;
height: 22px;
border: 2px solid #d1d5db;
border-radius: 6px;
background: white;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
cursor: pointer;
.dark & {
border-color: #4b5563;
background: #2d2d3d;
}
&.checked {
background: #3b82f6;
border-color: #3b82f6;
color: white;
}
&:hover {
border-color: #3b82f6;
}
}
}
</style>

View File

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

View File

@ -200,8 +200,8 @@ import FormSwitch from "@/components/ui/FormSwitch.vue";
import FormSlider from "@/components/ui/FormSlider.vue";
import FormSelect from "@/components/ui/FormSelect.vue";
import { MessageSquare, X, Check, Trash2 } from "@/components/icons";
import { chatApi } from "@/services/api.ts";
import type { ConversationSettings } from "@/types/chat";
import { MessageRole, MessageType } from "@/types/chat";
const chatStore = useChatStore();
const settingsStore = useSettingsStore();
@ -359,6 +359,25 @@ function handleSave() {
//
chatStore.updateConversationSettings(conversation.value.id, convSettings);
//
if (localSettings.value.systemPrompt) {
const messages = conversation.value.messages || [];
const systemMsgIndex = messages.findIndex((m: any) => m.role === MessageRole.SYSTEM);
if (systemMsgIndex >= 0) {
//
chatStore.updateMessage(messages[systemMsgIndex].id, {
content: {
type: MessageType.TEXT,
text: localSettings.value.systemPrompt,
},
});
} else {
//
chatStore.addSystemMessage(conversation.value.id, localSettings.value.systemPrompt);
}
}
close();
//

View File

@ -400,7 +400,6 @@ import {
RotateCcw,
} from "@/components/icons";
import type { AppSettings } from "@/types/chat";
import { chatApi } from "@/services/api.ts";
const settingsStore = useSettingsStore();

View File

@ -0,0 +1,579 @@
<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="show" class="modal-overlay" @click.self="handleClose">
<div class="modal-container">
<div class="modal-header">
<h3>{{ isMessageShare ? '分享消息' : '分享对话' }}</h3>
<button class="close-btn" @click="handleClose">
<X :size="20" />
</button>
</div>
<div class="modal-content">
<!-- 消息分享预览 -->
<div v-if="isMessageShare" class="selected-preview">
<div class="preview-header">
<span class="preview-title">已选择 {{ selectedMessageCount }} 条消息</span>
<span class="preview-hint">来自当前对话</span>
</div>
<div class="preview-list messages-preview">
<div
v-for="msg in selectedMessages"
:key="msg.id"
class="preview-item message-item"
>
<span class="message-role" :class="msg.role">
{{ msg.role === 'user' ? '用户' : 'AI' }}
</span>
<span class="item-title">{{ getMessagePreview(msg) }}</span>
</div>
</div>
</div>
<!-- 对话分享预览 -->
<div v-else class="selected-preview">
<div class="preview-header">
<span class="preview-title">已选择 {{ selectedCount }} 个对话</span>
<span class="preview-hint">最多分享 10 个对话</span>
</div>
<div class="preview-list">
<div
v-for="conv in selectedConversations"
:key="conv.id"
class="preview-item"
>
<MessageSquare :size="14" />
<span class="item-title">{{ conv.title }}</span>
<span class="item-count">{{ conv.messages.length }} 条消息</span>
</div>
</div>
</div>
<!-- 密码设置 -->
<div class="password-section">
<label class="password-label">
<Lock :size="16" />
设置访问密码
</label>
<div class="password-input-wrapper">
<input
v-model="password"
type="password"
class="password-input"
placeholder="请输入 4-20 位密码"
maxlength="20"
/>
<button
class="toggle-visibility"
@click="togglePasswordVisibility"
>
<Eye v-if="!showPassword" :size="18" />
<EyeOff v-else :size="18" />
</button>
</div>
<p class="password-hint">查看者需要输入此密码才能访问分享内容</p>
</div>
<!-- 过期时间提示 -->
<div class="expire-info">
<Clock :size="16" />
<span>分享链接将在 7 天后自动失效</span>
</div>
</div>
<div class="modal-footer">
<button class="btn-cancel" @click="handleClose">取消</button>
<button
class="btn-confirm"
:disabled="!isValidPassword || isCreating"
@click="handleCreateShare"
>
{{ isCreating ? '创建中...' : '创建分享链接' }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { storeToRefs } from "pinia";
import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings";
import { hashPassword } from "@/utils/crypto";
import { shareApi } from "@/services/shareApi";
import { SHARE_LIMITS } from "@/types/share";
import type { Message } from "@/types/chat";
import {
X,
Lock,
Eye,
EyeOff,
Clock,
MessageSquare,
} from "@/components/icons";
const settingsStore = useSettingsStore();
const chatStore = useChatStore();
const show = computed(() => settingsStore.showShareModal);
const { selectedConversations, selectedCount, isMessageSelectMode, selectedMessages, selectedMessageCount } = storeToRefs(chatStore);
//
const isMessageShare = computed(() => isMessageSelectMode.value);
const password = ref("");
const showPassword = ref(false);
const isCreating = ref(false);
const isValidPassword = computed(() => {
return password.value.length >= 4 && password.value.length <= 20;
});
//
function getMessagePreview(message: Message): string {
const text = message.content.text || '';
return text.length > 50 ? text.substring(0, 50) + '...' : text;
}
function togglePasswordVisibility() {
showPassword.value = !showPassword.value;
const input = document.querySelector('.password-input') as HTMLInputElement;
if (input) {
input.type = showPassword.value ? 'text' : 'password';
}
}
async function handleCreateShare() {
if (!isValidPassword.value || isCreating.value) return;
isCreating.value = true;
try {
//
const passwordHash = await hashPassword(password.value);
let result;
if (isMessageShare.value) {
//
result = await shareApi.createShare({
conversationIds: [],
messages: selectedMessages.value.map(m => ({
id: m.id,
role: m.role,
content: m.content,
timestamp: m.timestamp,
})),
passwordHash,
expiresIn: SHARE_LIMITS.DEFAULT_EXPIRE_SECONDS,
});
} else {
//
//
if (selectedCount.value > SHARE_LIMITS.MAX_CONVERSATIONS) {
window.$toast?.(`最多分享 ${SHARE_LIMITS.MAX_CONVERSATIONS} 个对话`, 'error');
return;
}
const conversationIds = selectedConversations.value.map(c => c.id);
result = await shareApi.createShare({
conversationIds,
passwordHash,
expiresIn: SHARE_LIMITS.DEFAULT_EXPIRE_SECONDS,
});
}
//
handleClose()
// 使
const shareUrl = new URL(result.shareUrl, window.location.origin).toString()
//
settingsStore.setShareResult({
shareId: result.id,
shareUrl,
password: password.value,
expiresAt: result.expiresAt,
})
settingsStore.openShareResultModal();
// 退
password.value = "";
if (isMessageShare.value) {
chatStore.exitMessageSelectMode();
} else {
chatStore.exitSelectMode();
}
} catch (error) {
console.error('Failed to create share:', error);
window.$toast?.('创建分享失败,请重试', 'error');
} finally {
isCreating.value = false;
}
}
function handleClose() {
settingsStore.closeShareModal();
}
//
watch(show, (newVal: boolean) => {
if (!newVal) {
password.value = "";
showPassword.value = false;
}
});
</script>
<style lang="scss" scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.modal-container {
background: white;
border-radius: 16px;
width: 90%;
max-width: 480px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
.dark & {
background: #1e1e2e;
}
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid #e5e7eb;
.dark & {
border-bottom-color: #2d2d3d;
}
h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #1f2937;
.dark & {
color: #f3f4f6;
}
}
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 8px;
background: transparent;
color: #6b7280;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(0, 0, 0, 0.05);
color: #374151;
.dark & {
background: rgba(255, 255, 255, 0.05);
color: #e5e7eb;
}
}
}
.modal-content {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.selected-preview {
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.preview-title {
font-size: 14px;
font-weight: 500;
color: #374151;
.dark & {
color: #e5e7eb;
}
}
.preview-hint {
font-size: 12px;
color: #9ca3af;
}
.preview-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 150px;
overflow-y: auto;
padding: 12px;
background: #f9fafb;
border-radius: 10px;
.dark & {
background: #2d2d3d;
}
}
.preview-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #4b5563;
.dark & {
color: #9ca3af;
}
.item-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-count {
font-size: 12px;
color: #9ca3af;
}
}
&.messages-preview {
max-height: 200px;
}
.message-item {
flex-direction: column;
align-items: flex-start;
gap: 4px;
.message-role {
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: 4px;
&.user {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
&.assistant {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
}
.item-title {
font-size: 12px;
line-height: 1.4;
}
}
}
.password-section {
.password-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 500;
color: #374151;
margin-bottom: 10px;
.dark & {
color: #e5e7eb;
}
}
.password-input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.password-input {
width: 100%;
padding: 12px 44px 12px 14px;
border: 1px solid #e5e7eb;
border-radius: 10px;
font-size: 14px;
color: #1f2937;
background: white;
transition: all 0.2s ease;
.dark & {
background: #2d2d3d;
border-color: #374151;
color: #f3f4f6;
}
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
&::placeholder {
color: #9ca3af;
}
}
.toggle-visibility {
position: absolute;
right: 10px;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 6px;
background: transparent;
color: #6b7280;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(0, 0, 0, 0.05);
color: #374151;
.dark & {
background: rgba(255, 255, 255, 0.05);
color: #e5e7eb;
}
}
}
.password-hint {
margin: 8px 0 0;
font-size: 12px;
color: #6b7280;
}
}
.expire-info {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background: rgba(59, 130, 246, 0.05);
border-radius: 10px;
font-size: 13px;
color: #3b82f6;
.dark & {
background: rgba(59, 130, 246, 0.1);
}
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 20px 24px;
border-top: 1px solid #e5e7eb;
.dark & {
border-top-color: #2d2d3d;
}
}
.btn-cancel {
padding: 10px 20px;
border: none;
border-radius: 10px;
background: transparent;
color: #6b7280;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(0, 0, 0, 0.05);
color: #374151;
.dark & {
background: rgba(255, 255, 255, 0.05);
color: #e5e7eb;
}
}
}
.btn-confirm {
padding: 10px 20px;
border: none;
border-radius: 10px;
background: #3b82f6;
color: white;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover:not(:disabled) {
background: #2563eb;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
//
.modal-enter-active,
.modal-leave-active {
transition: all 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
.modal-container {
transform: scale(0.95);
}
}
</style>

View File

@ -0,0 +1,406 @@
<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="show" class="modal-overlay" @click.self="handleClose">
<div class="modal-container">
<div class="modal-header">
<h3>分享创建成功</h3>
<button class="close-btn" @click="handleClose">
<X :size="20" />
</button>
</div>
<div class="modal-content">
<div class="success-icon">
<CheckCircle :size="48" />
</div>
<p class="success-message">
您的对话已成功分享将链接和密码发送给朋友即可查看
</p>
<!-- 分享链接 -->
<div class="share-section">
<label class="share-label">分享链接</label>
<div class="share-input-wrapper">
<input
ref="linkInput"
:value="shareUrl"
readonly
class="share-input"
/>
<button class="copy-btn" @click="copyLink">
<Copy :size="16" />
{{ linkCopied ? '已复制' : '复制' }}
</button>
</div>
</div>
<!-- 访问密码 -->
<div class="share-section">
<label class="share-label">访问密码</label>
<div class="share-input-wrapper">
<input
ref="passwordInput"
:value="password"
readonly
class="share-input password"
/>
<button class="copy-btn" @click="copyPassword">
<Copy :size="16" />
{{ passwordCopied ? '已复制' : '复制' }}
</button>
</div>
</div>
<!-- 过期时间 -->
<div class="expire-info">
<Clock :size="16" />
<span>链接将于 {{ formattedExpiresAt }} 过期</span>
</div>
</div>
<div class="modal-footer">
<button class="btn-copy-all" @click="copyAll">
<Copy :size="16" />
复制链接和密码
</button>
<button class="btn-close" @click="handleClose">
完成
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { useSettingsStore } from "@/stores/settings";
import { formatTimestamp } from "@/utils/helpers";
import {
X,
CheckCircle,
Copy,
Clock,
} from "@/components/icons";
const settingsStore = useSettingsStore();
const show = computed(() => settingsStore.showShareResultModal);
const shareResult = computed(() => settingsStore.shareResult);
const shareUrl = computed(() => shareResult.value?.shareUrl || '');
const password = computed(() => shareResult.value?.password || '');
const expiresAt = computed(() => shareResult.value?.expiresAt || 0);
const linkCopied = ref(false);
const passwordCopied = ref(false);
const formattedExpiresAt = computed(() => {
return formatTimestamp(expiresAt.value);
});
async function copyToClipboard(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// Fallback for older browsers
const input = document.createElement('input');
input.value = text;
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.body.removeChild(input);
return true;
}
}
async function copyLink() {
if (await copyToClipboard(shareUrl.value)) {
linkCopied.value = true;
window.$toast?.('链接已复制到剪贴板', 'success');
setTimeout(() => {
linkCopied.value = false;
}, 2000);
}
}
async function copyPassword() {
if (await copyToClipboard(password.value)) {
passwordCopied.value = true;
window.$toast?.('密码已复制到剪贴板', 'success');
setTimeout(() => {
passwordCopied.value = false;
}, 2000);
}
}
async function copyAll() {
const text = `分享链接: ${shareUrl.value}\n访问密码: ${password.value}`;
if (await copyToClipboard(text)) {
window.$toast?.('链接和密码已复制到剪贴板', 'success');
}
}
function handleClose() {
settingsStore.closeShareResultModal();
}
//
watch(show, (newVal: boolean) => {
if (!newVal) {
linkCopied.value = false;
passwordCopied.value = false;
}
});
</script>
<style lang="scss" scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.modal-container {
background: white;
border-radius: 16px;
width: 90%;
max-width: 480px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
.dark & {
background: #1e1e2e;
}
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid #e5e7eb;
.dark & {
border-bottom-color: #2d2d3d;
}
h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #1f2937;
.dark & {
color: #f3f4f6;
}
}
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 8px;
background: transparent;
color: #6b7280;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(0, 0, 0, 0.05);
color: #374151;
.dark & {
background: rgba(255, 255, 255, 0.05);
color: #e5e7eb;
}
}
}
.modal-content {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.success-icon {
display: flex;
justify-content: center;
color: #10b981;
}
.success-message {
text-align: center;
font-size: 14px;
color: #6b7280;
margin: 0;
.dark & {
color: #9ca3af;
}
}
.share-section {
.share-label {
display: block;
font-size: 13px;
font-weight: 500;
color: #374151;
margin-bottom: 8px;
.dark & {
color: #e5e7eb;
}
}
.share-input-wrapper {
display: flex;
gap: 8px;
}
.share-input {
flex: 1;
padding: 12px 14px;
border: 1px solid #e5e7eb;
border-radius: 10px;
font-size: 13px;
color: #1f2937;
background: #f9fafb;
.dark & {
background: #2d2d3d;
border-color: #374151;
color: #f3f4f6;
}
&.password {
font-family: monospace;
letter-spacing: 2px;
}
}
.copy-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 0 16px;
border: none;
border-radius: 10px;
background: #f3f4f6;
color: #374151;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
.dark & {
background: #374151;
color: #e5e7eb;
}
&:hover {
background: #e5e7eb;
.dark & {
background: #4b5563;
}
}
}
}
.expire-info {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
background: rgba(245, 158, 11, 0.1);
border-radius: 10px;
font-size: 13px;
color: #f59e0b;
}
.modal-footer {
display: flex;
gap: 12px;
padding: 20px 24px;
border-top: 1px solid #e5e7eb;
.dark & {
border-top-color: #2d2d3d;
}
}
.btn-copy-all {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
border: 1px solid #3b82f6;
border-radius: 10px;
background: transparent;
color: #3b82f6;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(59, 130, 246, 0.1);
}
}
.btn-close {
flex: 1;
padding: 12px;
border: none;
border-radius: 10px;
background: #3b82f6;
color: white;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #2563eb;
}
}
//
.modal-enter-active,
.modal-leave-active {
transition: all 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
.modal-container {
transform: scale(0.95);
}
}
</style>

View File

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

View File

@ -4,12 +4,21 @@
:class="{
active: isActive,
pinned: conversation.pinned,
selected: isSelected,
'select-mode': isSelectMode,
}"
@click="handleSelect"
@click="handleClick"
@dblclick="handleRename"
>
<!-- 选择模式复选框 -->
<div v-if="isSelectMode" class="item-checkbox" @click.stop="handleToggleSelect">
<div class="checkbox" :class="{ checked: isSelected }">
<Check v-if="isSelected" :size="14" />
</div>
</div>
<!-- 图标 -->
<div class="item-icon">
<div v-if="!isSelectMode" class="item-icon">
<MessageSquare :size="18" />
</div>
@ -35,12 +44,12 @@
</div>
<!-- 置顶标识 -->
<div v-if="conversation.pinned" class="pin-indicator">
<div v-if="conversation.pinned && !isSelectMode" class="pin-indicator">
<Pin :size="12" />
</div>
<!-- 操作按钮 -->
<div class="item-actions" @click.stop>
<!-- 操作按钮 (非选择模式显示) -->
<div v-if="!isSelectMode" class="item-actions" @click.stop>
<button
class="action-btn"
:title="conversation.pinned ? '取消置顶' : '置顶'"
@ -68,6 +77,7 @@ import {
Edit3,
Trash2,
Clock,
Check,
} from "@/components/icons";
import { formatTimestamp } from "@/utils/helpers";
import type { Conversation } from "@/types/chat";
@ -75,6 +85,8 @@ import type { Conversation } from "@/types/chat";
const props = defineProps<{
conversation: Conversation;
isActive: boolean;
isSelectMode?: boolean;
isSelected?: boolean;
}>();
const emit = defineEmits<{
@ -82,6 +94,7 @@ const emit = defineEmits<{
delete: [id: string];
rename: [id: string, title: string];
togglePin: [id: string];
toggleSelect: [id: string];
}>();
const isEditing = ref(false);
@ -92,17 +105,24 @@ const formattedTime = computed(() => {
return formatTimestamp(props.conversation.updatedAt);
});
function handleSelect() {
if (!isEditing.value) {
function handleClick() {
if (props.isSelectMode) {
emit("toggleSelect", props.conversation.id);
} else if (!isEditing.value) {
emit("select", props.conversation.id);
}
}
function handleToggleSelect() {
emit("toggleSelect", props.conversation.id);
}
function handleTogglePin() {
emit("togglePin", props.conversation.id);
}
function handleRename() {
if (props.isSelectMode) return;
isEditing.value = true;
editTitle.value = props.conversation.title;
nextTick(() => {
@ -274,4 +294,57 @@ function handleDelete() {
color: #ef4444;
}
}
//
.item-checkbox {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
padding-right: 4px;
}
.checkbox {
width: 18px;
height: 18px;
border: 2px solid #d1d5db;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
cursor: pointer;
.dark & {
border-color: #4b5563;
}
&:hover {
border-color: #3b82f6;
}
&.checked {
background: #3b82f6;
border-color: #3b82f6;
color: white;
}
}
.conversation-item.select-mode {
&:hover {
background: rgba(59, 130, 246, 0.05);
.dark & {
background: rgba(59, 130, 246, 0.1);
}
}
&.selected {
background: rgba(59, 130, 246, 0.1);
.dark & {
background: rgba(59, 130, 246, 0.15);
}
}
}
</style>

View File

@ -0,0 +1,165 @@
<template>
<div class="share-button-wrapper">
<button
v-if="!isSelectMode"
class="share-btn"
:disabled="conversations.length === 0"
@click="handleStartSelect"
>
<Share2 :size="16" />
<span>分享对话</span>
</button>
<div v-else class="select-actions">
<span class="select-info">
已选择 {{ selectedCount }} 个对话
</span>
<div class="action-buttons">
<button class="action-btn cancel" @click="handleCancel">
取消
</button>
<button
class="action-btn confirm"
:disabled="selectedCount === 0"
@click="handleConfirm"
>
确认分享
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from "pinia";
import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings";
import { Share2 } from "@/components/icons";
const chatStore = useChatStore();
const settingsStore = useSettingsStore();
const { isSelectMode, selectedCount, conversations } = storeToRefs(chatStore);
function handleStartSelect() {
chatStore.enterSelectMode();
}
function handleCancel() {
chatStore.exitSelectMode();
}
function handleConfirm() {
if (chatStore.selectedCount > 0) {
//
settingsStore.openShareModal();
}
}
</script>
<style lang="scss" scoped>
.share-button-wrapper {
padding: 6px 16px;
}
.share-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 6px 12px;
border-radius: 12px;
background: #f3f4f5;
color: #374151;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: none;
.dark & {
background: #2d2d3d;
color: #e5e7eb;
}
&:hover:not(:disabled) {
background: #000f33;
color: #e5e7eb;
.dark & {
background: #0475ed;
color: #e5e7eb;
}
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.select-actions {
display: flex;
flex-direction: column;
gap: 8px;
}
.select-info {
font-size: 13px;
color: #6b7280;
text-align: center;
.dark & {
color: #9ca3af;
}
}
.action-buttons {
display: flex;
gap: 8px;
}
.action-btn {
flex: 1;
padding: 8px 12px;
border-radius: 10px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: none;
&.cancel {
background: rgba(0, 0, 0, 0.05);
color: #6b7280;
.dark & {
background: rgba(255, 255, 255, 0.05);
color: #9ca3af;
}
&:hover {
background: rgba(0, 0, 0, 0.1);
.dark & {
background: rgba(255, 255, 255, 0.1);
}
}
}
&.confirm {
background: #3b82f6;
color: white;
&:hover:not(:disabled) {
background: #2563eb;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
</style>

View File

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

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

@ -0,0 +1,20 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '@/views/HomeView.vue'
const router = createRouter({
history: createWebHistory('/'),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
},
{
path: '/share/:id',
name: 'share',
component: () => import('@/views/ShareView.vue'),
},
],
})
export default router

View File

@ -121,21 +121,36 @@ class ChatApi {
}
// 将前端简化的请求翻译为 OpenAI 兼容的规范请求体
// 构建 messages 数组system + 历史消息 + 当前用户消息
// 检查历史消息中是否已有系统消息
const historyHasSystem = request.history?.some((m) => m.role === "system");
// 构建 messages 数组
let allMessages: Array<{ role: string; content: any }>;
if (request.history && request.history.length > 0) {
// 如果历史中有系统消息,直接使用历史消息
if (historyHasSystem) {
allMessages = [...request.history, { role: "user", content: userContent }];
} else {
// 否则添加系统消息
const systemMessage = {
role: "system",
content:
request.systemPrompt ||
"你是一个智能助手,可以分析用户发送的文字,文件或图片内容,并进行回答。",
};
const currentUserMessage = {
role: "user",
content: userContent,
allMessages = [systemMessage, ...request.history, { role: "user", content: userContent }];
}
} else {
// 没有历史消息,添加系统消息
const systemMessage = {
role: "system",
content:
request.systemPrompt ||
"你是一个智能助手,可以分析用户发送的文字,文件或图片内容,并进行回答。",
};
const allMessages =
request.history && request.history.length > 0
? [systemMessage, ...request.history, currentUserMessage]
: [systemMessage, currentUserMessage];
allMessages = [systemMessage, { role: "user", content: userContent }];
}
const openAiRequest = {
model: request.model || "glm-4-flash",

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

@ -0,0 +1,77 @@
/**
* API
*/
import { getAuthHeaders } from './request';
import type {
ShareCreateRequest,
ShareCreateResponse,
ShareVerifyRequest,
ShareVerifyResponse,
ShareGetResponse,
} from '@/types/share';
const API_BASE = '/api/chat-ui';
export const shareApi = {
/**
*
*/
async createShare(request: ShareCreateRequest): Promise<ShareCreateResponse> {
const response = await fetch(`${API_BASE}/shares`, {
method: 'POST',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
});
if (!response.ok) {
const error = await response.text();
throw new Error(error || `创建分享失败: HTTP ${response.status}`);
}
return response.json();
},
/**
*
*/
async getShare(shareId: string): Promise<ShareGetResponse> {
const response = await fetch(`${API_BASE}/shares/${shareId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
if (response.status === 404) {
throw new Error('分享不存在或已过期');
}
throw new Error(`获取分享失败: HTTP ${response.status}`);
}
return response.json();
},
/**
*
*/
async verifyShare(shareId: string, request: ShareVerifyRequest): Promise<ShareVerifyResponse> {
const response = await fetch(`${API_BASE}/shares/${shareId}/verify`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
});
if (!response.ok) {
const error = await response.text();
throw new Error(error || `验证失败: HTTP ${response.status}`);
}
return response.json();
},
};

View File

@ -43,8 +43,6 @@ export const useAuthStore = defineStore('auth', () => {
if (data.success && data.data) {
window.$toast?.(`登录成功, 欢迎 ${data.data.nickname || data.data.username}`, 'success');
return data.data;
}else{
window.$toast?.('[Auth] Token 验证失败:Token无效');

View File

@ -19,6 +19,15 @@ export const useChatStore = defineStore("chat", () => {
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(() => {
return (
@ -43,6 +52,24 @@ 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;
@ -79,10 +106,10 @@ export const useChatStore = defineStore("chat", () => {
}
// 创建对话
async function createConversation(): Promise<string> {
async function createConversation(title?: string): Promise<string> {
const newConversation: Conversation = {
id: generateId(),
title: "新对话",
title: title || "新对话",
messages: [],
createdAt: Date.now(),
updatedAt: Date.now(),
@ -290,6 +317,38 @@ export const useChatStore = defineStore("chat", () => {
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>) {
const conversation = currentConversation.value;
@ -454,6 +513,95 @@ export const useChatStore = defineStore("chat", () => {
}
}
// ========== 分享多选模式方法 ==========
// 切换选择模式
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();
@ -465,11 +613,22 @@ export const useChatStore = defineStore("chat", () => {
streamController,
isInitialized,
isLoading,
// 分享多选模式状态
isSelectMode,
selectedConversationIds,
// 消息分享选择模式状态
isMessageSelectMode,
selectedMessageIds,
sourceConversationId,
selectedMessages,
selectedMessageCount,
// 计算属性
currentConversation,
sortedConversations,
pinnedConversations,
recentConversations,
selectedConversations,
selectedCount,
// 方法
initializeFromApi,
createConversation,
@ -479,6 +638,7 @@ export const useChatStore = defineStore("chat", () => {
renameConversation,
updateConversationSettings,
addMessage,
addSystemMessage,
updateMessage,
updateMessageContent,
saveConversation,
@ -489,5 +649,19 @@ export const useChatStore = defineStore("chat", () => {
clearConversation,
loadFromStorage,
saveToStorage,
// 分享多选模式方法
toggleSelectMode,
enterSelectMode,
exitSelectMode,
toggleConversationSelection,
selectAllConversations,
clearSelection,
isConversationSelected,
// 消息分享选择模式方法
enterMessageSelectMode,
exitMessageSelectMode,
toggleMessageSelection,
selectAllMessages,
isMessageSelected,
};
});

View File

@ -2,6 +2,14 @@ import { defineStore } from "pinia";
import { ref } from "vue";
import type { AppSettings, AIModel } from "@/types/chat";
// 分享结果类型
export interface ShareResult {
shareId: string;
shareUrl: string;
password: string;
expiresAt: number;
}
export const useSettingsStore = defineStore("settings", () => {
// 默认设置
const defaultSettings: AppSettings = {
@ -11,7 +19,7 @@ export const useSettingsStore = defineStore("settings", () => {
fontSize: "medium",
// 对话设置
sendOnEnter: false,
sendOnEnter: true,
showTimestamp: true,
compactMode: false,
@ -86,6 +94,11 @@ export const useSettingsStore = defineStore("settings", () => {
const showSettingsModal = ref(false);
const showConversationSettingsModal = ref(false);
// 分享相关状态
const showShareModal = ref(false);
const showShareResultModal = ref(false);
const shareResult = ref<ShareResult | null>(null);
// 主题相关
function applyTheme(theme: AppSettings["theme"]) {
const root = document.documentElement;
@ -175,6 +188,31 @@ export const useSettingsStore = defineStore("settings", () => {
showConversationSettingsModal.value = false;
}
// 分享模态框
function openShareModal() {
showShareModal.value = true;
}
function closeShareModal() {
showShareModal.value = false;
}
function openShareResultModal() {
showShareResultModal.value = true;
}
function closeShareResultModal() {
showShareResultModal.value = false;
}
function setShareResult(result: ShareResult) {
shareResult.value = result;
}
function clearShareResult() {
shareResult.value = null;
}
// 更新设置
function updateSettings(updates: Partial<AppSettings>) {
Object.assign(settings.value, updates);
@ -298,6 +336,10 @@ export const useSettingsStore = defineStore("settings", () => {
showSettingsModal,
showConversationSettingsModal,
availableModels,
// 分享相关状态
showShareModal,
showShareResultModal,
shareResult,
// 方法
toggleTheme,
@ -313,6 +355,13 @@ export const useSettingsStore = defineStore("settings", () => {
closeSettingsModal,
openConversationSettingsModal,
closeConversationSettingsModal,
// 分享模态框方法
openShareModal,
closeShareModal,
openShareResultModal,
closeShareResultModal,
setShareResult,
clearShareResult,
updateSettings,
resetSettings,
exportSettings,

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

@ -0,0 +1,79 @@
import type { Conversation } from "./chat";
/**
*
*/
export interface Share {
id: string; // 分享ID
conversationIds: string[]; // 分享的对话ID列表
conversations: Conversation[]; // 完整对话数据(快照)
createdAt: number; // 创建时间
expiresAt: number; // 过期时间
viewCount: number; // 访问次数
hasPassword: boolean; // 是否有密码保护
}
/**
*
*/
export interface ShareCreateRequest {
conversationIds: string[]; // 要分享的对话ID列表
messages?: MessageData[]; // 直接传递消息数据(消息分享模式)
passwordHash: string; // 密码哈希值
expiresIn?: number; // 过期时间默认7天
}
/**
*
*/
export interface MessageData {
id: string;
role: string;
content: any;
timestamp: number;
}
/**
*
*/
export interface ShareCreateResponse {
id: string; // 分享ID
shareUrl: string; // 分享链接
expiresAt: number; // 过期时间
}
/**
*
*/
export interface ShareVerifyRequest {
passwordHash: string; // 密码哈希值
}
/**
*
*/
export interface ShareVerifyResponse {
valid: boolean; // 密码是否正确
share?: Share; // 分享数据(验证成功时返回)
error?: string; // 错误信息
}
/**
*
*/
export interface ShareGetResponse {
id: string;
hasPassword: boolean;
expiresAt: number;
isExpired: boolean;
conversationCount: number;
}
/**
*
*/
export const SHARE_LIMITS = {
MAX_CONVERSATIONS: 10, // 单次分享最多对话数
MAX_MESSAGES_PER_CONVERSATION: 100, // 单个对话最多消息数
DEFAULT_EXPIRE_SECONDS: 7 * 24 * 60 * 60, // 默认过期时间7天
} as const;

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

@ -0,0 +1,45 @@
/**
*
* 使 SHA-256
*/
/**
* 使 SHA-256
* @param text
* @returns
*/
export async function sha256(text: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(text);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return hashHex;
}
/**
*
* @param password
* @returns
*/
export async function hashPassword(password: string): Promise<string> {
// 添加前缀盐值增加安全性
const saltedPassword = `share_${password}_chat`;
return sha256(saltedPassword);
}
/**
*
* @param password
* @param storedHash
* @returns
*/
export async function verifyPassword(
password: string,
storedHash: string
): Promise<boolean> {
const hash = await hashPassword(password);
return hash === storedHash;
}

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

@ -0,0 +1,90 @@
<template>
<div class="home-view">
<!-- 侧边栏 -->
<ChatSidebar />
<!-- 主内容区 -->
<ChatMain ref="chatMainRef" @toggle-sidebar="toggleSidebar" />
<!-- 模态框 -->
<SearchModal />
<ShortcutsModal />
<SettingsModal />
<ConversationSettingsModal />
<ShareModal />
<ShareResultModal />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useChatStore } from '@/stores/chat'
import { useSettingsStore } from '@/stores/settings'
import { useKeyboard, getDefaultShortcuts } from '@/composables/useKeyboard'
import ChatSidebar from '@/components/sidebar/ChatSidebar.vue'
import ChatMain from '@/components/chat/ChatMain.vue'
import SearchModal from '@/components/modals/SearchModal.vue'
import ShortcutsModal from '@/components/modals/ShortcutsModal.vue'
import SettingsModal from '@/components/modals/SettingsModal.vue'
import ConversationSettingsModal from '@/components/modals/ConversationSettingsModal.vue'
import ShareModal from '@/components/modals/ShareModal.vue'
import ShareResultModal from '@/components/modals/ShareResultModal.vue'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const chatStore = useChatStore()
const settingsStore = useSettingsStore()
const chatMainRef = ref<InstanceType<typeof ChatMain> | null>(null)
function toggleSidebar() {
settingsStore.toggleSidebar()
}
function newChat() {
chatStore.createConversation()
window.$toast?.('已创建新对话', 'success')
}
function focusInput() {
chatMainRef.value?.focusInput()
}
//
useKeyboard(
getDefaultShortcuts({
newChat,
toggleSidebar,
focusInput,
sendMessage: () => {},
cancelStream: () => {
if (chatStore.isStreaming) {
chatStore.stopStreaming()
window.$toast?.('已停止生成', 'info')
}
},
toggleTheme: () => {
settingsStore.toggleTheme()
window.$toast?.(`主题已切换`, 'success')
},
showShortcuts: () => {
settingsStore.openShortcutsModal()
},
searchConversations: () => {
settingsStore.openSearchModal()
},
}),
)
//
authStore.init()
</script>
<style lang="scss" scoped>
.home-view {
display: flex;
width: 100%;
height: 100%;
overflow: hidden;
}
</style>

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

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

View File

@ -1 +0,0 @@
{"root":["./src/main.ts","./src/components/icons/index.ts","./src/composables/useKeyboard.ts","./src/services/api.ts","./src/services/authService.ts","./src/services/conversationApi.ts","./src/services/request.ts","./src/stores/auth.ts","./src/stores/chat.ts","./src/stores/settings.ts","./src/types/chat.ts","./src/utils/helpers.ts","./src/utils/migrateData.ts","./src/App.vue","./src/components/chat/ChatHeader.vue","./src/components/chat/ChatMain.vue","./src/components/chat/MessageList.vue","./src/components/chat/WelcomeScreen.vue","./src/components/input/AttachmentPreview.vue","./src/components/input/ChatInput.vue","./src/components/message/CodeBlock.vue","./src/components/message/MessageActions.vue","./src/components/message/MessageBubble.vue","./src/components/message/components/EChartsContainerNode.vue","./src/components/message/components/Loading.vue","./src/components/message/components/ThinkingNode.vue","./src/components/modals/ConversationSettingsModal.vue","./src/components/modals/SearchModal.vue","./src/components/modals/SettingsModal.vue","./src/components/modals/ShortcutsModal.vue","./src/components/sidebar/ChatSidebar.vue","./src/components/sidebar/ConversationItem.vue","./src/components/ui/FormSelect.vue","./src/components/ui/FormSlider.vue","./src/components/ui/FormSwitch.vue"],"errors":true,"version":"5.9.3"}

View File

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

35
vitest.config.ts Normal file
View File

@ -0,0 +1,35 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
test: {
// 使用 happy-dom 作为测试环境
environment: 'happy-dom',
// 全局变量
globals: true,
// 测试文件匹配模式
include: ['src/**/*.{test,spec}.{js,ts}'],
// 测试 setup 文件
setupFiles: ['src/__tests__/setup.ts'],
// 覆盖率配置
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.{ts,vue}'],
exclude: [
'src/**/*.d.ts',
'src/main.ts',
'src/**/*.test.ts',
'src/**/*.spec.ts',
'src/__tests__/setup.ts',
],
},
},
resolve: {
alias: {
'@': resolve(__dirname, './src'),
},
},
})