feat:外层容器样式

This commit is contained in:
SuperManTouX 2026-03-06 09:18:18 +08:00
parent bd60a15acb
commit 972d92ba1a
15 changed files with 556 additions and 85 deletions

199
cosmic-greeting-lantern.md Normal file
View File

@ -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 防止日志文件过大

View File

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

37
need-1.txt Normal file
View File

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

219
need-style.ts Normal file
View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

View File

@ -145,7 +145,7 @@ window.$toast = showToast
width: 100vw;
height: 100vh;
overflow: hidden;
background: #ffffff;
background: #f5f5f5;
&.dark {
background: #11111b;

View File

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

View File

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

View File

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