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 防止日志文件过大
|
||||||
11
index.html
|
|
@ -1,13 +1,16 @@
|
||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vue.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vue.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>AI-CHAT-UI - 企业级智能对话</title>
|
<title>AI-CHAT-UI - 企业级智能对话</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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">
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #ffffff;
|
background: #f5f5f5;
|
||||||
|
|
||||||
&.dark {
|
&.dark {
|
||||||
background: #11111b;
|
background: #11111b;
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,15 @@ function handlePin() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送消息 - 使用真实 API
|
// 发送消息 - 使用真实 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);
|
console.log("handleSend", text, attachments, options);
|
||||||
// 检查是否还有正在上传的附件
|
// 检查是否还有正在上传的附件
|
||||||
const uploadingAttachments = attachments.filter((a) => a.uploading);
|
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 existingMessages = currentConversation.value?.messages || [];
|
||||||
const MAX_HISTORY_ROUNDS = 20; // 最多保留最近 20 轮(40 条消息)
|
const MAX_HISTORY_ROUNDS = 20; // 最多保留最近 20 轮(40 条消息)
|
||||||
const historyMessages = existingMessages
|
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) // 过滤掉空消息
|
.filter((m: any) => m.content?.text) // 过滤掉空消息
|
||||||
.slice(-(MAX_HISTORY_ROUNDS * 2))
|
.slice(-(MAX_HISTORY_ROUNDS * 2))
|
||||||
.map((m: any) => ({ role: m.role, content: m.content.text }));
|
.map((m: any) => ({ role: m.role, content: m.content.text }));
|
||||||
|
|
@ -273,7 +284,10 @@ async function handleRetry(messageId: string) {
|
||||||
const MAX_HISTORY_ROUNDS = 20;
|
const MAX_HISTORY_ROUNDS = 20;
|
||||||
const priorMessages = messages.value
|
const priorMessages = messages.value
|
||||||
.slice(0, messageIndex - 1) // 不包含当前 user 消息和要重试的 assistant 消息
|
.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)
|
.filter((m: any) => m.content?.text)
|
||||||
.slice(-(MAX_HISTORY_ROUNDS * 2))
|
.slice(-(MAX_HISTORY_ROUNDS * 2))
|
||||||
.map((m: any) => ({ role: m.role, content: m.content.text }));
|
.map((m: any) => ({ role: m.role, content: m.content.text }));
|
||||||
|
|
@ -369,6 +383,7 @@ watch(
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
border-radius: 15px;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: #11111b;
|
background: #11111b;
|
||||||
|
|
|
||||||
|
|
@ -329,7 +329,7 @@
|
||||||
<div class="app-logo">
|
<div class="app-logo">
|
||||||
<Bot :size="40" />
|
<Bot :size="40" />
|
||||||
</div>
|
</div>
|
||||||
<h4>AI Chat</h4>
|
<h4>Kexue AI Chat</h4>
|
||||||
<p class="version">版本 1.0.0</p>
|
<p class="version">版本 1.0.0</p>
|
||||||
<p class="desc">企业级 AI 对话聊天界面</p>
|
<p class="desc">企业级 AI 对话聊天界面</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<Bot :size="24" class="logo-icon" />
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="collapse-btn"
|
class="collapse-btn"
|
||||||
|
|
@ -81,7 +81,9 @@
|
||||||
|
|
||||||
<!-- 空状态 -->
|
<!-- 空状态 -->
|
||||||
<div
|
<div
|
||||||
v-if="pinnedConversations.length === 0 && recentConversations.length === 0"
|
v-if="
|
||||||
|
pinnedConversations.length === 0 && recentConversations.length === 0
|
||||||
|
"
|
||||||
class="empty-state"
|
class="empty-state"
|
||||||
>
|
>
|
||||||
<MessageSquare :size="40" class="empty-icon" />
|
<MessageSquare :size="40" class="empty-icon" />
|
||||||
|
|
@ -107,19 +109,16 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 拖拽调整宽度 -->
|
<!-- 拖拽调整宽度 -->
|
||||||
<div
|
<div class="resize-handle" @mousedown="startResize" />
|
||||||
class="resize-handle"
|
|
||||||
@mousedown="startResize"
|
|
||||||
/>
|
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from "vue";
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from "pinia";
|
||||||
import { useChatStore } from '@/stores/chat'
|
import { useChatStore } from "@/stores/chat";
|
||||||
import { useSettingsStore } from '@/stores/settings'
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
import ConversationItem from './ConversationItem.vue'
|
import ConversationItem from "./ConversationItem.vue";
|
||||||
import {
|
import {
|
||||||
Bot,
|
Bot,
|
||||||
Plus,
|
Plus,
|
||||||
|
|
@ -133,87 +132,84 @@ import {
|
||||||
Keyboard,
|
Keyboard,
|
||||||
Settings,
|
Settings,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
} from '@/components/icons'
|
} from "@/components/icons";
|
||||||
|
|
||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore();
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore();
|
||||||
|
|
||||||
const {
|
const { currentConversationId, pinnedConversations, recentConversations } =
|
||||||
currentConversationId,
|
storeToRefs(chatStore);
|
||||||
pinnedConversations,
|
|
||||||
recentConversations
|
|
||||||
} = storeToRefs(chatStore)
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
sidebarCollapsed: isCollapsed,
|
sidebarCollapsed: isCollapsed,
|
||||||
sidebarWidth,
|
sidebarWidth,
|
||||||
settings
|
settings,
|
||||||
} = storeToRefs(settingsStore)
|
} = storeToRefs(settingsStore);
|
||||||
|
|
||||||
const currentTheme = computed(() => settings.value.theme)
|
const currentTheme = computed(() => settings.value.theme);
|
||||||
|
|
||||||
// 方法
|
// 方法
|
||||||
function handleNewChat() {
|
function handleNewChat() {
|
||||||
chatStore.createConversation()
|
chatStore.createConversation();
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectConversation(id: string) {
|
function selectConversation(id: string) {
|
||||||
chatStore.selectConversation(id)
|
chatStore.selectConversation(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteConversation(id: string) {
|
function deleteConversation(id: string) {
|
||||||
chatStore.deleteConversation(id)
|
chatStore.deleteConversation(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renameConversation(id: string, title: string) {
|
function renameConversation(id: string, title: string) {
|
||||||
chatStore.renameConversation(id, title)
|
chatStore.renameConversation(id, title);
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePinConversation(id: string) {
|
function togglePinConversation(id: string) {
|
||||||
chatStore.togglePinConversation(id)
|
chatStore.togglePinConversation(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSidebar() {
|
function toggleSidebar() {
|
||||||
settingsStore.toggleSidebar()
|
settingsStore.toggleSidebar();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTheme() {
|
function toggleTheme() {
|
||||||
settingsStore.toggleTheme()
|
settingsStore.toggleTheme();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openShortcuts() {
|
function openShortcuts() {
|
||||||
settingsStore.openShortcutsModal()
|
settingsStore.openShortcutsModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSettings() {
|
function openSettings() {
|
||||||
settingsStore.openSettingsModal()
|
settingsStore.openSettingsModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSearch() {
|
function openSearch() {
|
||||||
settingsStore.openSearchModal()
|
settingsStore.openSearchModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 拖拽调整宽度
|
// 拖拽调整宽度
|
||||||
const isResizing = ref(false)
|
const isResizing = ref(false);
|
||||||
|
|
||||||
function startResize(e: MouseEvent) {
|
function startResize(e: MouseEvent) {
|
||||||
isResizing.value = true
|
isResizing.value = true;
|
||||||
const startX = e.clientX
|
const startX = e.clientX;
|
||||||
const startWidth = sidebarWidth.value
|
const startWidth = sidebarWidth.value;
|
||||||
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
const diff = e.clientX - startX
|
const diff = e.clientX - startX;
|
||||||
settingsStore.setSidebarWidth(startWidth + diff)
|
settingsStore.setSidebarWidth(startWidth + diff);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
isResizing.value = false
|
isResizing.value = false;
|
||||||
document.removeEventListener('mousemove', handleMouseMove)
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
document.removeEventListener('mouseup', handleMouseUp)
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
}
|
};
|
||||||
|
|
||||||
document.addEventListener('mousemove', handleMouseMove)
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
document.addEventListener('mouseup', handleMouseUp)
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -226,6 +222,8 @@ function startResize(e: MouseEvent) {
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
margin-right: 10px;
|
||||||
|
border-radius: 15px;
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
background: #1e1e2e;
|
background: #1e1e2e;
|
||||||
|
|
|
||||||