feat:外层容器样式
|
|
@ -0,0 +1,199 @@
|
|||
# 多并发支持规划 (100 持续在线用户)
|
||||
|
||||
## Context
|
||||
|
||||
用户需要为 AI Chat UI 项目支持 100 个持续在线用户,需要数据持久化,使用裸机部署 (systemd 服务管理)。
|
||||
|
||||
## 需求确认
|
||||
- **并发类型**: 持续在线 (日常稳定有100个用户在线使用)
|
||||
- **会话存储**: 需要持久化 (支持多实例部署)
|
||||
- **部署方式**: 裸机部署 (systemd 管理)
|
||||
|
||||
---
|
||||
|
||||
## 实现方案
|
||||
|
||||
### 阶段 1: 后端多进程支持 (核心)
|
||||
|
||||
**目标**: 使用 Gunicorn + Uvicorn workers 实现多进程
|
||||
|
||||
**修改文件**:
|
||||
1. `server/requirements.txt` - 添加 gunicorn
|
||||
2. 新建 `server/gunicorn.conf.py` - Gunicorn 配置文件
|
||||
3. 新建 `server/start.sh` - 启动脚本
|
||||
|
||||
**关键配置** (`server/gunicorn.conf.py`):
|
||||
```python
|
||||
import multiprocessing
|
||||
|
||||
workers = multiprocessing.cpu_count() * 2 + 1 # 推荐配置
|
||||
worker_class = "uvicorn.workers.UvicornWorker"
|
||||
bind = "0.0.0.0:8000"
|
||||
keepalive = 120
|
||||
timeout = 300 # AI 响应可能较慢
|
||||
graceful_timeout = 30
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 阶段 2: 会话持久化 (Redis)
|
||||
|
||||
**目标**: 使用 Redis 替代内存存储,支持多进程共享数据
|
||||
|
||||
**修改文件**:
|
||||
1. `server/requirements.txt` - 添加 redis
|
||||
2. 新建 `server/utils/redis_store.py` - Redis 存储层
|
||||
3. 修改 `server/api/chat_routes.py` - 替换 `conversations_db` 字典
|
||||
|
||||
**Redis 数据结构设计**:
|
||||
```
|
||||
conversations:{conversation_id} -> JSON (单个对话详情)
|
||||
conversations:list -> Sorted Set (按更新时间排序的ID列表)
|
||||
```
|
||||
|
||||
**安装 Redis (Ubuntu/Debian)**:
|
||||
```bash
|
||||
sudo apt install redis-server
|
||||
sudo systemctl enable redis-server
|
||||
sudo systemctl start redis-server
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 阶段 3: 请求限流
|
||||
|
||||
**目标**: 防止 API 过载
|
||||
|
||||
**修改文件**:
|
||||
1. `server/requirements.txt` - 添加 slowapi
|
||||
2. 新建 `server/middleware/rate_limit.py`
|
||||
3. 修改 `server/main.py` - 注册限流中间件
|
||||
|
||||
**限流策略**:
|
||||
- 聊天接口: 30 请求/分钟/IP (长连接场景)
|
||||
- 其他接口: 100 请求/分钟/IP
|
||||
|
||||
---
|
||||
|
||||
### 阶段 4: Systemd 服务配置
|
||||
|
||||
**新建文件**: `server/ai-chat.service`
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=AI Chat Backend Service
|
||||
After=network.target redis.service
|
||||
|
||||
[Service]
|
||||
Type=exec
|
||||
User=mt
|
||||
WorkingDirectory=/home/mt/project/ai-chat-ui/server
|
||||
Environment="PATH=/home/mt/project/ai-chat-ui/server/.venv/bin"
|
||||
Environment="LLM_BACKEND=glm"
|
||||
Environment="REDIS_URL=redis://localhost:6379"
|
||||
ExecStart=/home/mt/project/ai-chat-ui/server/.venv/bin/gunicorn main:app -c gunicorn.conf.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
**安装服务**:
|
||||
```bash
|
||||
sudo cp server/ai-chat.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable ai-chat
|
||||
sudo systemctl start ai-chat
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 阶段 5: Nginx 反向代理 (可选但推荐)
|
||||
|
||||
**新建文件**: `nginx/ai-chat.conf`
|
||||
|
||||
```nginx
|
||||
upstream backend {
|
||||
server 127.0.0.1:8000;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
|
||||
# 前端静态文件
|
||||
location /chat-ui/ {
|
||||
root /home/mt/project/ai-chat-ui/dist;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API 代理
|
||||
location /api/ {
|
||||
proxy_pass http://backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
# SSE 支持
|
||||
proxy_set_header Connection '';
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键文件修改清单
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| `server/requirements.txt` | 修改 | 添加 gunicorn, redis, slowapi |
|
||||
| `server/gunicorn.conf.py` | 新建 | 多进程配置 |
|
||||
| `server/start.sh` | 新建 | 启动脚本 |
|
||||
| `server/utils/redis_store.py` | 新建 | Redis 存储层 |
|
||||
| `server/api/chat_routes.py` | 修改 | 集成 Redis 存储 |
|
||||
| `server/middleware/rate_limit.py` | 新建 | 限流中间件 |
|
||||
| `server/main.py` | 修改 | 注册中间件 |
|
||||
| `server/ai-chat.service` | 新建 | Systemd 服务配置 |
|
||||
|
||||
---
|
||||
|
||||
## 验证方案
|
||||
|
||||
1. **服务状态检查**:
|
||||
```bash
|
||||
sudo systemctl status ai-chat
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
2. **并发压测**:
|
||||
```bash
|
||||
# 安装 wrk
|
||||
sudo apt install wrk
|
||||
# 测试 100 并发
|
||||
wrk -t4 -c100 -d30s http://localhost:8000/api/chat-ui/models
|
||||
```
|
||||
|
||||
3. **Redis 验证**:
|
||||
```bash
|
||||
redis-cli ping
|
||||
redis-cli keys "conversations:*"
|
||||
```
|
||||
|
||||
4. **日志监控**:
|
||||
```bash
|
||||
sudo journalctl -u ai-chat -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 风险与注意事项
|
||||
|
||||
1. **SSE 长连接** - Gunicorn 需要配置 `keepalive` 和 `timeout`
|
||||
2. **Redis 内存** - 根据对话数量规划,建议至少 512MB
|
||||
3. **API 配额** - 智谱/阿里云 API 有速率限制,注意监控
|
||||
4. **日志轮转** - 配置 logrotate 防止日志文件过大
|
||||
25
index.html
|
|
@ -1,16 +1,19 @@
|
|||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vue.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AI-CHAT-UI - 企业级智能对话</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
<div className={styles.chooseModel}>
|
||||
<Select className="choose-model"
|
||||
onChange={(value) => chooseModel(value)}
|
||||
options={modelOptionList}
|
||||
style={{
|
||||
width: "100%",
|
||||
border: "none",
|
||||
borderRadius: "10px",
|
||||
}}
|
||||
value={currentModel?.value}
|
||||
/>
|
||||
{/* <Button style={{float:"right",marginLeft:"auto"}}
|
||||
type="text"
|
||||
icon={menuCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
onClick={toggleMenuCollapsed}
|
||||
/> */}
|
||||
</div>
|
||||
|
||||
|
||||
<Button
|
||||
key={item?.key}
|
||||
onClick={handleNewChat}
|
||||
type="primary"
|
||||
className={styles.functionMenuItem}
|
||||
icon={<PlusOutlined />}
|
||||
block
|
||||
>
|
||||
新对话
|
||||
</Button>
|
||||
|
||||
|
||||
.choose-model .ant-select-selector {
|
||||
border: none !important;
|
||||
background-color: #f3f4f5 !important;
|
||||
box-shadow: none !important;
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
import { createStyles } from "antd-style";
|
||||
|
||||
export const useStyle = createStyles(({ token, css }) => {
|
||||
return {
|
||||
menuCollapsed: css`
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: none;
|
||||
|
||||
transform: translateX(-999px);
|
||||
.functionMenu,
|
||||
.chooseModel,
|
||||
.conversationsContainer {
|
||||
display: none;
|
||||
}
|
||||
`,
|
||||
menu: css`
|
||||
background: #fff;
|
||||
max-width: 320px;
|
||||
min-width: 280px;
|
||||
width: 320px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 15px 0 0;
|
||||
padding: 0 20px;
|
||||
border-radius: 15px;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
transition: width 0.8s ease-in-out;
|
||||
`,
|
||||
userProfile: css`
|
||||
display: flex;
|
||||
height: 30px;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
gap: 168px;
|
||||
align-items: center;
|
||||
padding: 16px 0 16px 8px;
|
||||
border-bottom: 1px solid ${token.colorBorderSecondary};
|
||||
margin-bottom: 16px;
|
||||
img {
|
||||
height: 88px;
|
||||
width: 88px;
|
||||
object-fit: contain;
|
||||
}
|
||||
`,
|
||||
logoClickable: css`
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease-in-out;
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
|
||||
&:hover {
|
||||
background-color: ${token.colorBgTextHover};
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
`,
|
||||
functionMenu: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
padding: 10px 0;
|
||||
`,
|
||||
functionMenuItem: css`
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s;
|
||||
background-color: #f3f4f5 !important;
|
||||
box-shadow: none !important;
|
||||
color: #000F33 !important;
|
||||
|
||||
&:hover {
|
||||
background-color: #000F33 !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.anticon {
|
||||
font-size: 14px;
|
||||
margin-right: 0px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
}
|
||||
`,
|
||||
chooseModel: css`
|
||||
padding-top: 15px;
|
||||
display: flex;
|
||||
// flex-direction: column;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
margin: 0 0 12px;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`,
|
||||
conversationsContainer: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
`,
|
||||
conversationsScrollContainer: css`
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
margin-bottom: 48px;
|
||||
`,
|
||||
conversationItem: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
margin: 2px 0;
|
||||
cursor: pointer;
|
||||
border-radius: 10px;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: ${token.colorBgTextHover};
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #EBF0FC;
|
||||
}
|
||||
`,
|
||||
conversationTitle: css`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 15px;
|
||||
flex: 1;
|
||||
`,
|
||||
actionButtonsContainer: css`
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
visibility: hidden;
|
||||
|
||||
.active & {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.conversationItem:hover & {
|
||||
visibility: visible;
|
||||
}
|
||||
`,
|
||||
editButton: css`
|
||||
&.ant-btn {
|
||||
padding: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
`,
|
||||
deleteButton: css`
|
||||
&.ant-btn {
|
||||
padding: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
`,
|
||||
titleEditContainer: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 4px;
|
||||
`,
|
||||
titleInput: css`
|
||||
flex: 1;
|
||||
font-size: 15px;
|
||||
`,
|
||||
titleEditButton: css`
|
||||
padding: 0 4px;
|
||||
font-size: 16px;
|
||||
height: 22px;
|
||||
min-width: 22px;
|
||||
`,
|
||||
collapsedMenuBtn: css`
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
z-index: 1000;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: ${token.boxShadowSecondary};
|
||||
cursor: pointer;
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
`,
|
||||
bottomLinkWrapper: css`
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
bottom: 8px;
|
||||
z-index: 10;
|
||||
`,
|
||||
menuTitle: css`
|
||||
color: ${token.colorTextTertiary};
|
||||
font-size: 14px;
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
|
@ -145,7 +145,7 @@ window.$toast = showToast
|
|||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
background: #f5f5f5;
|
||||
|
||||
&.dark {
|
||||
background: #11111b;
|
||||
|
|
|
|||
|
|
@ -116,7 +116,15 @@ function handlePin() {
|
|||
}
|
||||
|
||||
// 发送消息 - 使用真实 API
|
||||
async function handleSend(text: string, attachments: Attachment[], options?: { deepSearch?: boolean; webSearch?: boolean; deepThinking?: boolean }) {
|
||||
async function handleSend(
|
||||
text: string,
|
||||
attachments: Attachment[],
|
||||
options?: {
|
||||
deepSearch?: boolean;
|
||||
webSearch?: boolean;
|
||||
deepThinking?: boolean;
|
||||
},
|
||||
) {
|
||||
console.log("handleSend", text, attachments, options);
|
||||
// 检查是否还有正在上传的附件
|
||||
const uploadingAttachments = attachments.filter((a) => a.uploading);
|
||||
|
|
@ -156,7 +164,10 @@ async function handleSend(text: string, attachments: Attachment[], options?: { d
|
|||
const existingMessages = 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.role === MessageRole.USER || m.role === MessageRole.ASSISTANT,
|
||||
)
|
||||
.filter((m: any) => m.content?.text) // 过滤掉空消息
|
||||
.slice(-(MAX_HISTORY_ROUNDS * 2))
|
||||
.map((m: any) => ({ role: m.role, content: m.content.text }));
|
||||
|
|
@ -273,7 +284,10 @@ async function handleRetry(messageId: string) {
|
|||
const MAX_HISTORY_ROUNDS = 20;
|
||||
const priorMessages = messages.value
|
||||
.slice(0, messageIndex - 1) // 不包含当前 user 消息和要重试的 assistant 消息
|
||||
.filter((m: any) => m.role === MessageRole.USER || m.role === MessageRole.ASSISTANT)
|
||||
.filter(
|
||||
(m: any) =>
|
||||
m.role === MessageRole.USER || m.role === MessageRole.ASSISTANT,
|
||||
)
|
||||
.filter((m: any) => m.content?.text)
|
||||
.slice(-(MAX_HISTORY_ROUNDS * 2))
|
||||
.map((m: any) => ({ role: m.role, content: m.content.text }));
|
||||
|
|
@ -369,6 +383,7 @@ watch(
|
|||
height: 100vh;
|
||||
background: #ffffff;
|
||||
overflow: hidden;
|
||||
border-radius: 15px;
|
||||
|
||||
.dark & {
|
||||
background: #11111b;
|
||||
|
|
|
|||
|
|
@ -329,7 +329,7 @@
|
|||
<div class="app-logo">
|
||||
<Bot :size="40" />
|
||||
</div>
|
||||
<h4>AI Chat</h4>
|
||||
<h4>Kexue AI Chat</h4>
|
||||
<p class="version">版本 1.0.0</p>
|
||||
<p class="desc">企业级 AI 对话聊天界面</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
<template>
|
||||
<aside
|
||||
class="chat-sidebar"
|
||||
:class="{ collapsed: isCollapsed }"
|
||||
:style="{ width: isCollapsed ? '0px' : `${sidebarWidth}px` }"
|
||||
class="chat-sidebar"
|
||||
:class="{ collapsed: isCollapsed }"
|
||||
:style="{ width: isCollapsed ? '0px' : `${sidebarWidth}px` }"
|
||||
>
|
||||
<div class="sidebar-inner">
|
||||
<!-- 头部 -->
|
||||
<div class="sidebar-header">
|
||||
<div class="logo">
|
||||
<Bot :size="24" class="logo-icon" />
|
||||
<span v-show="!isCollapsed" class="logo-text">AI Chat</span>
|
||||
<span v-show="!isCollapsed" class="logo-text">Kexue AI Chat</span>
|
||||
</div>
|
||||
<button
|
||||
class="collapse-btn"
|
||||
@click="toggleSidebar"
|
||||
:title="isCollapsed ? '展开侧边栏' : '收起侧边栏'"
|
||||
class="collapse-btn"
|
||||
@click="toggleSidebar"
|
||||
:title="isCollapsed ? '展开侧边栏' : '收起侧边栏'"
|
||||
>
|
||||
<ChevronLeft :size="18" :class="{ rotated: isCollapsed }" />
|
||||
</button>
|
||||
|
|
@ -47,14 +47,14 @@
|
|||
</div>
|
||||
<div class="group-list">
|
||||
<ConversationItem
|
||||
v-for="conv in pinnedConversations"
|
||||
:key="conv.id"
|
||||
:conversation="conv"
|
||||
:is-active="conv.id === currentConversationId"
|
||||
@select="selectConversation"
|
||||
@delete="deleteConversation"
|
||||
@rename="renameConversation"
|
||||
@toggle-pin="togglePinConversation"
|
||||
v-for="conv in pinnedConversations"
|
||||
:key="conv.id"
|
||||
:conversation="conv"
|
||||
:is-active="conv.id === currentConversationId"
|
||||
@select="selectConversation"
|
||||
@delete="deleteConversation"
|
||||
@rename="renameConversation"
|
||||
@toggle-pin="togglePinConversation"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -67,22 +67,24 @@
|
|||
</div>
|
||||
<div class="group-list">
|
||||
<ConversationItem
|
||||
v-for="conv in recentConversations"
|
||||
:key="conv.id"
|
||||
:conversation="conv"
|
||||
:is-active="conv.id === currentConversationId"
|
||||
@select="selectConversation"
|
||||
@delete="deleteConversation"
|
||||
@rename="renameConversation"
|
||||
@toggle-pin="togglePinConversation"
|
||||
v-for="conv in recentConversations"
|
||||
:key="conv.id"
|
||||
:conversation="conv"
|
||||
:is-active="conv.id === currentConversationId"
|
||||
@select="selectConversation"
|
||||
@delete="deleteConversation"
|
||||
@rename="renameConversation"
|
||||
@toggle-pin="togglePinConversation"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-if="pinnedConversations.length === 0 && recentConversations.length === 0"
|
||||
class="empty-state"
|
||||
v-if="
|
||||
pinnedConversations.length === 0 && recentConversations.length === 0
|
||||
"
|
||||
class="empty-state"
|
||||
>
|
||||
<MessageSquare :size="40" class="empty-icon" />
|
||||
<p>暂无对话</p>
|
||||
|
|
@ -107,19 +109,16 @@
|
|||
</div>
|
||||
|
||||
<!-- 拖拽调整宽度 -->
|
||||
<div
|
||||
class="resize-handle"
|
||||
@mousedown="startResize"
|
||||
/>
|
||||
<div class="resize-handle" @mousedown="startResize" />
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import ConversationItem from './ConversationItem.vue'
|
||||
import { computed, ref } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import ConversationItem from "./ConversationItem.vue";
|
||||
import {
|
||||
Bot,
|
||||
Plus,
|
||||
|
|
@ -133,87 +132,84 @@ import {
|
|||
Keyboard,
|
||||
Settings,
|
||||
ChevronLeft,
|
||||
} from '@/components/icons'
|
||||
} from "@/components/icons";
|
||||
|
||||
const chatStore = useChatStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const chatStore = useChatStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const {
|
||||
currentConversationId,
|
||||
pinnedConversations,
|
||||
recentConversations
|
||||
} = storeToRefs(chatStore)
|
||||
const { currentConversationId, pinnedConversations, recentConversations } =
|
||||
storeToRefs(chatStore);
|
||||
|
||||
const {
|
||||
sidebarCollapsed: isCollapsed,
|
||||
sidebarWidth,
|
||||
settings
|
||||
} = storeToRefs(settingsStore)
|
||||
settings,
|
||||
} = storeToRefs(settingsStore);
|
||||
|
||||
const currentTheme = computed(() => settings.value.theme)
|
||||
const currentTheme = computed(() => settings.value.theme);
|
||||
|
||||
// 方法
|
||||
function handleNewChat() {
|
||||
chatStore.createConversation()
|
||||
chatStore.createConversation();
|
||||
}
|
||||
|
||||
function selectConversation(id: string) {
|
||||
chatStore.selectConversation(id)
|
||||
chatStore.selectConversation(id);
|
||||
}
|
||||
|
||||
function deleteConversation(id: string) {
|
||||
chatStore.deleteConversation(id)
|
||||
chatStore.deleteConversation(id);
|
||||
}
|
||||
|
||||
function renameConversation(id: string, title: string) {
|
||||
chatStore.renameConversation(id, title)
|
||||
chatStore.renameConversation(id, title);
|
||||
}
|
||||
|
||||
function togglePinConversation(id: string) {
|
||||
chatStore.togglePinConversation(id)
|
||||
chatStore.togglePinConversation(id);
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
settingsStore.toggleSidebar()
|
||||
settingsStore.toggleSidebar();
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
settingsStore.toggleTheme()
|
||||
settingsStore.toggleTheme();
|
||||
}
|
||||
|
||||
function openShortcuts() {
|
||||
settingsStore.openShortcutsModal()
|
||||
settingsStore.openShortcutsModal();
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
settingsStore.openSettingsModal()
|
||||
settingsStore.openSettingsModal();
|
||||
}
|
||||
|
||||
function openSearch() {
|
||||
settingsStore.openSearchModal()
|
||||
settingsStore.openSearchModal();
|
||||
}
|
||||
|
||||
// 拖拽调整宽度
|
||||
const isResizing = ref(false)
|
||||
const isResizing = ref(false);
|
||||
|
||||
function startResize(e: MouseEvent) {
|
||||
isResizing.value = true
|
||||
const startX = e.clientX
|
||||
const startWidth = sidebarWidth.value
|
||||
isResizing.value = true;
|
||||
const startX = e.clientX;
|
||||
const startWidth = sidebarWidth.value;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const diff = e.clientX - startX
|
||||
settingsStore.setSidebarWidth(startWidth + diff)
|
||||
}
|
||||
const diff = e.clientX - startX;
|
||||
settingsStore.setSidebarWidth(startWidth + diff);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isResizing.value = false
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
isResizing.value = false;
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -226,6 +222,8 @@ function startResize(e: MouseEvent) {
|
|||
transition: width 0.3s ease;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
margin-right: 10px;
|
||||
border-radius: 15px;
|
||||
|
||||
.dark & {
|
||||
background: #1e1e2e;
|
||||
|
|
@ -494,4 +492,4 @@ function startResize(e: MouseEvent) {
|
|||
background: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||