feat: 更新了UI组件和Tailwind配置,并同步了依赖项和新增了环境变量文件。新增代理服务器和通义百炼平台的。

This commit is contained in:
肖应宇 2026-03-01 13:31:54 +08:00
parent 83fbfc2c37
commit c97e227685
51 changed files with 20690 additions and 19414 deletions

50
.gitignore vendored
View File

@ -1,24 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.env
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

42
LICENSE
View File

@ -1,21 +1,21 @@
MIT License
Copyright (c) 2026 zll-it
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
MIT License
Copyright (c) 2026 zll-it
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

208
README.md
View File

@ -1,104 +1,104 @@
# AI-CHAT-UI
一个基于 Vue 和 markstream-vue 构建的现代化 AI 对话界面,提供丰富的交互功能和精美的视觉体验。
## 页面展示
![](screenshots/img.png)
![](screenshots/img_1.png)
![](screenshots/img_2.png)
![](screenshots/img_3.png)
![](screenshots/img_4.png)
![](screenshots/img_5.png)
![](screenshots/img_6.png)
## ✨ 核心功能
| 功能 | 详细描述 |
|-------|-------------------------------------|
| 对话历史 | 支持多对话管理、置顶、重命名、删除 |
| 新建对话 | 快捷键 Ctrl+N 或点击按钮快速创建 |
| 页面布局 | 支持宽屏/标准模式切换,适配不同使用场景 |
| 附件上传 | 支持图片、文件上传和在线预览 |
| 智能搜索 | 深度搜索/联网搜索,工具栏开关一键切换 |
| 消息操作 | 消息栏支持点赞、点踩、复制操作 |
| 精美UI | 现代化设计,支持暗色主题,视觉体验优秀 |
| 消息布局 | AI/用户消息左右分布用户右侧AI 左侧),符合使用习惯 |
| 多类型消息 | 支持文本、图片、视频、附件、推荐选项等多种消息类型展示 |
| 快捷键系统 | 完整的快捷键支持,提升操作效率 |
| 流式输出 | 基于 markstream-vue 实现流畅的AI回答流式展示 |
| 渲染展示 | 支持mermaid、代码块、ECharts、Thinking等流式展示 |
| 对话搜索 | 模态框快速搜索历史对话内容 |
| 全局设置 | 外观、对话默认值、功能开关、隐私设置等全局配置 |
| 对话设置 | 单个对话的模型、温度、提示词等个性化设置 |
| 主题切换 | 支持浅色/深色/跟随系统三种主题模式 |
| 字体大小 | 小/中/大三档字体大小可选,适配不同阅读习惯 |
| 数据管理 | 导入/导出设置、清除数据,保障数据安全 |
| 预设提示词 | 快速选择常用角色设定,提升对话效率 |
## 🛠 技术栈
- **核心框架**: Vue
- **流式渲染**: markstream-vue
- **UI 设计**: 现代化响应式设计,支持暗色主题
- **交互体验**: 丰富的快捷键系统和消息操作
## 🚀 快速开始
```Bash
# 克隆仓库
git clone https://github.com/zll-it/ai-chat-ui.git
# 进入项目目录
cd ai-chat-ui
# 安装依赖
npm install
# 启动开发服务器
npm run dev
# 构建生产版本
npm run build
```
## 📋 使用说明
### 基础操作
- **新建对话**: `Ctrl+N` 快捷键或点击页面右上角 "+" 按钮
- **切换布局**: 点击页面右下角布局切换按钮
- **主题切换**: 设置面板中选择浅色/深色/跟随系统
- **搜索对话**: 使用页面顶部搜索框或快捷键 `Ctrl+K`
### 快捷键一览
| 操作 | 快捷键 |
|--------|---------------------|
| 新建对话 | Ctrl+N |
| 搜索对话 | Ctrl+K |
| 复制当前消息 | Ctrl+C (消息 hover 时) |
| 切换布局 | Ctrl+Shift+L |
## 📄 许可证
本项目采用 MIT 许可证 - 详见 [LICENSE](LICENSE) 文件
## 💡 贡献
欢迎提交 Issue 和 Pull Request 来帮助改进这个项目!
---
<div align="center">
<sub>Made with ❤️ using Vue & markstream-vue</sub>
</div>
# AI-CHAT-UI
一个基于 Vue 和 markstream-vue 构建的现代化 AI 对话界面,提供丰富的交互功能和精美的视觉体验。
## 页面展示
![](screenshots/img.png)
![](screenshots/img_1.png)
![](screenshots/img_2.png)
![](screenshots/img_3.png)
![](screenshots/img_4.png)
![](screenshots/img_5.png)
![](screenshots/img_6.png)
## ✨ 核心功能
| 功能 | 详细描述 |
|-------|-------------------------------------|
| 对话历史 | 支持多对话管理、置顶、重命名、删除 |
| 新建对话 | 快捷键 Ctrl+N 或点击按钮快速创建 |
| 页面布局 | 支持宽屏/标准模式切换,适配不同使用场景 |
| 附件上传 | 支持图片、文件上传和在线预览 |
| 智能搜索 | 深度搜索/联网搜索,工具栏开关一键切换 |
| 消息操作 | 消息栏支持点赞、点踩、复制操作 |
| 精美UI | 现代化设计,支持暗色主题,视觉体验优秀 |
| 消息布局 | AI/用户消息左右分布用户右侧AI 左侧),符合使用习惯 |
| 多类型消息 | 支持文本、图片、视频、附件、推荐选项等多种消息类型展示 |
| 快捷键系统 | 完整的快捷键支持,提升操作效率 |
| 流式输出 | 基于 markstream-vue 实现流畅的AI回答流式展示 |
| 渲染展示 | 支持mermaid、代码块、ECharts、Thinking等流式展示 |
| 对话搜索 | 模态框快速搜索历史对话内容 |
| 全局设置 | 外观、对话默认值、功能开关、隐私设置等全局配置 |
| 对话设置 | 单个对话的模型、温度、提示词等个性化设置 |
| 主题切换 | 支持浅色/深色/跟随系统三种主题模式 |
| 字体大小 | 小/中/大三档字体大小可选,适配不同阅读习惯 |
| 数据管理 | 导入/导出设置、清除数据,保障数据安全 |
| 预设提示词 | 快速选择常用角色设定,提升对话效率 |
## 🛠 技术栈
- **核心框架**: Vue
- **流式渲染**: markstream-vue
- **UI 设计**: 现代化响应式设计,支持暗色主题
- **交互体验**: 丰富的快捷键系统和消息操作
## 🚀 快速开始
```Bash
# 克隆仓库
git clone https://github.com/zll-it/ai-chat-ui.git
# 进入项目目录
cd ai-chat-ui
# 安装依赖
npm install
# 启动开发服务器
npm run dev
# 构建生产版本
npm run build
```
## 📋 使用说明
### 基础操作
- **新建对话**: `Ctrl+N` 快捷键或点击页面右上角 "+" 按钮
- **切换布局**: 点击页面右下角布局切换按钮
- **主题切换**: 设置面板中选择浅色/深色/跟随系统
- **搜索对话**: 使用页面顶部搜索框或快捷键 `Ctrl+K`
### 快捷键一览
| 操作 | 快捷键 |
|--------|---------------------|
| 新建对话 | Ctrl+N |
| 搜索对话 | Ctrl+K |
| 复制当前消息 | Ctrl+C (消息 hover 时) |
| 切换布局 | Ctrl+Shift+L |
## 📄 许可证
本项目采用 MIT 许可证 - 详见 [LICENSE](LICENSE) 文件
## 💡 贡献
欢迎提交 Issue 和 Pull Request 来帮助改进这个项目!
---
<div align="center">
<sub>Made with ❤️ using Vue & markstream-vue</sub>
</div>

View File

@ -1,16 +1,16 @@
<!DOCTYPE html>
<html lang="zh-CN">
<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>
<!DOCTYPE html>
<html lang="zh-CN">
<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>

10768
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,42 +1,42 @@
{
"name": "ai-chat-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@microsoft/fetch-event-source": "^2.0.1",
"@terrastruct/d2": "^0.1.33",
"@unocss/reset": "^66.6.0",
"@vueuse/core": "^14.2.0",
"echarts": "^6.0.0",
"katex": "^0.16.28",
"lodash": "^4.17.23",
"lucide-vue-next": "^0.563.0",
"markstream-vue": "^0.0.7-beta.4",
"mermaid": "^11.12.2",
"pinia": "^3.0.4",
"shiki": "^3.22.0",
"stream-markdown": "^0.0.14",
"stream-monaco": "^0.0.18",
"unocss": "^66.6.0",
"vue": "^3.5.24",
"vue-router": "^5.0.2"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"autoprefixer": "^10.4.24",
"postcss": "^8.5.6",
"sass": "^1.97.3",
"tailwindcss": "^4.1.18",
"typescript": "~5.9.3",
"vite": "^7.2.4",
"vue-tsc": "^3.1.4"
}
}
{
"name": "ai-chat-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@microsoft/fetch-event-source": "^2.0.1",
"@terrastruct/d2": "^0.1.33",
"@unocss/reset": "^66.6.0",
"@vueuse/core": "^14.2.0",
"echarts": "^6.0.0",
"katex": "^0.16.28",
"lodash": "^4.17.23",
"lucide-vue-next": "^0.563.0",
"markstream-vue": "^0.0.7-beta.4",
"mermaid": "^11.12.2",
"pinia": "^3.0.4",
"shiki": "^3.22.0",
"stream-markdown": "^0.0.14",
"stream-monaco": "^0.0.18",
"unocss": "^66.6.0",
"vue": "^3.5.24",
"vue-router": "^5.0.2"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"autoprefixer": "^10.4.24",
"postcss": "^8.5.6",
"sass": "^1.97.3",
"tailwindcss": "^4.1.18",
"typescript": "~5.9.3",
"vite": "^7.2.4",
"vue-tsc": "^3.1.4"
}
}

File diff suppressed because it is too large Load Diff

6
server/.env.example Normal file
View File

@ -0,0 +1,6 @@
# 阿里云百炼 API Key
# 请在百炼控制台申请并填入此处
ALIYUN_API_KEY=your_api_key_here
# 本地中转服务器运行端口
PORT=3000

1304
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
server/package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "server",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"http-proxy-middleware": "^3.0.5",
"morgan": "^1.10.1",
"multer": "^2.1.0",
"uuid": "^13.0.0"
}
}

165
server/server.js Normal file
View File

@ -0,0 +1,165 @@
const express = require('express');
const cors = require('cors');
const { createProxyMiddleware } = require('http-proxy-middleware');
const multer = require('multer');
const { v4: uuidv4 } = require('uuid');
const path = require('path');
const fs = require('fs');
const morgan = require('morgan');
require('dotenv').config();
const app = express();
const PORT = process.env.PORT || 3000;
// 配置全局请求日志,可以在终端里看到每个到达 Node 端点的请求记录
app.use(morgan(':method :url :status :res[content-length] - :response-time ms'));
// 配置 CORS允许前端项目的跨域请求
app.use(cors());
// --- 1. 流式对话请求代理配置 ---
// 在请求交给代理组件之前,拦截所有的 /api/chat-ui/chat 并强行给 Header 加上 Bearer Token
app.use('/api/chat-ui/chat', (req, res, next) => {
const apiKey = process.env.ALIYUN_API_KEY;
if (!apiKey) {
console.error("【错误】发送代理请求前未配置 ALIYUN_API_KEY !");
} else {
req.headers['authorization'] = `Bearer ${apiKey}`;
}
next();
});
// 注意:代理中间件需要在 body-parser (express.json) 之前,不然代理会导致请求体丢失
app.use(
'/api/chat-ui/chat',
createProxyMiddleware({
// 阿里云百炼由于兼容 OpenAI 格式,所以代理到此接口
target: 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions',
changeOrigin: true,
// 去除路径前缀,确保发往阿里云的路径是纯正的 completions 路径
pathRewrite: {
'^/api/chat-ui/chat': '',
}
})
);
// --- 下面的路由专门走 Node.js 业务逻辑,因此需要解析 JSON Body ---
app.use(express.json());
// --- 2. 获取模型列表 ---
app.get('/api/chat-ui/models', (req, res) => {
res.json([
{
id: "qwen-max",
name: "通义千问 Max",
description: "最强大的模型",
maxTokens: 8192,
provider: "Aliyun"
},
{
id: "qwen-plus",
name: "通义千问 Plus",
description: "能力均衡",
maxTokens: 8192,
provider: "Aliyun"
}
]);
});
// 内存中暂时存放对话数据用于 Mock
const conversationsDB = {};
// --- 3. 获取所有对话历史 ---
app.get('/api/chat-ui/conversations', (req, res) => {
res.json(Object.values(conversationsDB));
});
// --- 4. 获取单个对话 ---
app.get('/api/chat-ui/conversations/:id', (req, res) => {
const { id } = req.params;
const conversation = conversationsDB[id];
if (conversation) {
res.json(conversation);
} else {
res.status(404).json({ error: '对话不存在' });
}
});
// --- 5. 保存或更新对话 ---
// 前端可能会在 /api/chat-ui/conversations/:id 用 POST 或 PUT 更新? 也可以直接提供一个保存接口
app.post('/api/chat-ui/conversations', (req, res) => {
const data = req.body;
if(!data.id) data.id = uuidv4();
conversationsDB[data.id] = data;
res.json(data);
})
// --- 6. 删除对话 ---
app.delete('/api/chat-ui/conversations/:id', (req, res) => {
const { id } = req.params;
if (conversationsDB[id]) {
delete conversationsDB[id];
res.json({ success: true, message: "删除成功" });
} else {
res.status(404).json({ error: '对话不存在' });
}
});
// 为了存储上传文件而建立临时目录
const uploadDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir);
}
// 配置文件上传
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, uploadDir);
},
filename: function (req, file, cb) {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, uniqueSuffix + '-' + file.originalname);
}
});
const upload = multer({ storage: storage });
// 提供静态文件访问支持
app.use('/uploads', express.static(uploadDir));
// --- 7. 上传文件 ---
app.post('/api/chat-ui/upload', upload.single('file'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: '没有文件上传' });
}
// 返回供前端使用和访问的 URL
res.json({
url: `http://localhost:${PORT}/uploads/${req.file.filename}`,
name: req.file.originalname,
size: req.file.size,
mimeType: req.file.mimetype
});
});
// --- 8. 停止生成 ---
// 这个接口对于本地代理没有实际效果,因为流的断开是通过底层 AbortController 控制的,此处直接返回成功
app.post(['/api/chat-ui/stop', '/api/chat-ui/stop/:id'], (req, res) => {
res.json({ success: true, message: "已发出停止指令" });
});
// 其他所有路由返回404
app.use((req, res) => {
res.status(404).json({ error: 'Endpoint not found' });
});
app.listen(PORT, () => {
console.log('====================================');
console.log(`本地代理服务器已启动,监听端口: ${PORT}`);
console.log('====================================');
if (!process.env.ALIYUN_API_KEY) {
console.log('⚠️ 警告: 未在 .env 文件中检测到 ALIYUN_API_KEY!');
console.log('请在 server/.env 中添加您的百炼 API Key。');
} else {
console.log('✅ 检测到了 API Key。');
}
});

View File

@ -1,225 +1,225 @@
<template>
<div class="app" :class="{ 'dark': isDark }">
<!-- 侧边栏 -->
<ChatSidebar />
<!-- 主内容区 -->
<ChatMain
ref="chatMainRef"
@toggle-sidebar="toggleSidebar"
/>
<!-- 模态框 -->
<SearchModal />
<ShortcutsModal />
<SettingsModal />
<ConversationSettingsModal />
<!-- Toast 通知 -->
<Teleport to="body">
<TransitionGroup name="toast" tag="div" class="toast-container">
<div
v-for="toast in toasts"
:key="toast.id"
class="toast"
:class="toast.type"
>
<Check v-if="toast.type === 'success'" :size="18" />
<AlertCircle v-else-if="toast.type === 'error'" :size="18" />
<Info v-else :size="18" />
<span>{{ toast.message }}</span>
</div>
</TransitionGroup>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useChatStore } from '@/stores/chat'
import { useSettingsStore } from '@/stores/settings'
import { useKeyboard, getDefaultShortcuts } from '@/composables/useKeyboard'
import ChatSidebar from '@/components/sidebar/ChatSidebar.vue'
import ChatMain from '@/components/chat/ChatMain.vue'
import SearchModal from '@/components/modals/SearchModal.vue'
import ShortcutsModal from '@/components/modals/ShortcutsModal.vue'
import SettingsModal from '@/components/modals/SettingsModal.vue'
import ConversationSettingsModal from '@/components/modals/ConversationSettingsModal.vue'
import { Check, AlertCircle, Info } from '@/components/icons'
// Stores
const chatStore = useChatStore()
const settingsStore = useSettingsStore()
const { settings } = storeToRefs(settingsStore)
// Refs
const chatMainRef = ref<InstanceType<typeof ChatMain> | null>(null)
//
const isDark = computed(() => {
if (settings.value.theme === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
return settings.value.theme === 'dark'
})
// Toast
interface Toast {
id: number
message: string
type: 'success' | 'error' | 'info'
}
const toasts = ref<Toast[]>([])
let toastId = 0
function showToast(message: string, type: Toast['type'] = 'info') {
const id = ++toastId
toasts.value.push({ id, message, type })
setTimeout(() => {
const index = toasts.value.findIndex(t => t.id === id)
if (index !== -1) {
toasts.value.splice(index, 1)
}
}, 3000)
}
//
function toggleSidebar() {
settingsStore.toggleSidebar()
}
function newChat() {
chatStore.createConversation()
showToast('已创建新对话', 'success')
}
function focusInput() {
chatMainRef.value?.focusInput()
}
//
useKeyboard(
getDefaultShortcuts({
newChat,
toggleSidebar,
focusInput,
sendMessage: () => {}, // ChatInput
cancelStream: () => {
if (chatStore.isStreaming) {
chatStore.stopStreaming()
showToast('已停止生成', 'info')
}
},
toggleTheme: () => {
settingsStore.toggleTheme()
showToast(`主题已切换为 ${settings.value.theme}`, 'success')
},
showShortcuts: () => {
settingsStore.openShortcutsModal()
},
searchConversations: () => {
settingsStore.openSearchModal()
},
})
)
//
onMounted(() => {
//
if (chatStore.conversations.length === 0) {
chatStore.createConversation()
}
})
// 使
window.$toast = showToast
</script>
<style lang="scss">
.app {
display: flex;
width: 100vw;
height: 100vh;
overflow: hidden;
background: #ffffff;
&.dark {
background: #11111b;
color: #e5e7eb;
}
}
// Toast
.toast-container {
position: fixed;
top: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 10px;
z-index: 9999;
pointer-events: none;
}
.toast {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 20px;
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
font-size: 14px;
font-weight: 500;
color: #374151;
pointer-events: auto;
.dark & {
background: #2d2d3d;
color: #e5e7eb;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
}
&.success {
svg {
color: #10b981;
}
}
&.error {
svg {
color: #ef4444;
}
}
&.info {
svg {
color: #3b82f6;
}
}
}
// Toast
.toast-enter-active,
.toast-leave-active {
transition: all 0.3s ease;
}
.toast-enter-from {
opacity: 0;
transform: translateX(100px);
}
.toast-leave-to {
opacity: 0;
transform: translateX(100px);
}
.toast-move {
transition: transform 0.3s ease;
}
<template>
<div class="app" :class="{ 'dark': isDark }">
<!-- 侧边栏 -->
<ChatSidebar />
<!-- 主内容区 -->
<ChatMain
ref="chatMainRef"
@toggle-sidebar="toggleSidebar"
/>
<!-- 模态框 -->
<SearchModal />
<ShortcutsModal />
<SettingsModal />
<ConversationSettingsModal />
<!-- Toast 通知 -->
<Teleport to="body">
<TransitionGroup name="toast" tag="div" class="toast-container">
<div
v-for="toast in toasts"
:key="toast.id"
class="toast"
:class="toast.type"
>
<Check v-if="toast.type === 'success'" :size="18" />
<AlertCircle v-else-if="toast.type === 'error'" :size="18" />
<Info v-else :size="18" />
<span>{{ toast.message }}</span>
</div>
</TransitionGroup>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useChatStore } from '@/stores/chat'
import { useSettingsStore } from '@/stores/settings'
import { useKeyboard, getDefaultShortcuts } from '@/composables/useKeyboard'
import ChatSidebar from '@/components/sidebar/ChatSidebar.vue'
import ChatMain from '@/components/chat/ChatMain.vue'
import SearchModal from '@/components/modals/SearchModal.vue'
import ShortcutsModal from '@/components/modals/ShortcutsModal.vue'
import SettingsModal from '@/components/modals/SettingsModal.vue'
import ConversationSettingsModal from '@/components/modals/ConversationSettingsModal.vue'
import { Check, AlertCircle, Info } from '@/components/icons'
// Stores
const chatStore = useChatStore()
const settingsStore = useSettingsStore()
const { settings } = storeToRefs(settingsStore)
// Refs
const chatMainRef = ref<InstanceType<typeof ChatMain> | null>(null)
//
const isDark = computed(() => {
if (settings.value.theme === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
return settings.value.theme === 'dark'
})
// Toast
interface Toast {
id: number
message: string
type: 'success' | 'error' | 'info'
}
const toasts = ref<Toast[]>([])
let toastId = 0
function showToast(message: string, type: Toast['type'] = 'info') {
const id = ++toastId
toasts.value.push({ id, message, type })
setTimeout(() => {
const index = toasts.value.findIndex(t => t.id === id)
if (index !== -1) {
toasts.value.splice(index, 1)
}
}, 3000)
}
//
function toggleSidebar() {
settingsStore.toggleSidebar()
}
function newChat() {
chatStore.createConversation()
showToast('已创建新对话', 'success')
}
function focusInput() {
chatMainRef.value?.focusInput()
}
//
useKeyboard(
getDefaultShortcuts({
newChat,
toggleSidebar,
focusInput,
sendMessage: () => {}, // ChatInput
cancelStream: () => {
if (chatStore.isStreaming) {
chatStore.stopStreaming()
showToast('已停止生成', 'info')
}
},
toggleTheme: () => {
settingsStore.toggleTheme()
showToast(`主题已切换为 ${settings.value.theme}`, 'success')
},
showShortcuts: () => {
settingsStore.openShortcutsModal()
},
searchConversations: () => {
settingsStore.openSearchModal()
},
})
)
//
onMounted(() => {
//
if (chatStore.conversations.length === 0) {
chatStore.createConversation()
}
})
// 使
window.$toast = showToast
</script>
<style lang="scss">
.app {
display: flex;
width: 100vw;
height: 100vh;
overflow: hidden;
background: #ffffff;
&.dark {
background: #11111b;
color: #e5e7eb;
}
}
// Toast
.toast-container {
position: fixed;
top: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 10px;
z-index: 9999;
pointer-events: none;
}
.toast {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 20px;
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
font-size: 14px;
font-weight: 500;
color: #374151;
pointer-events: auto;
.dark & {
background: #2d2d3d;
color: #e5e7eb;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
}
&.success {
svg {
color: #10b981;
}
}
&.error {
svg {
color: #ef4444;
}
}
&.info {
svg {
color: #3b82f6;
}
}
}
// Toast
.toast-enter-active,
.toast-leave-active {
transition: all 0.3s ease;
}
.toast-enter-from {
opacity: 0;
transform: translateX(100px);
}
.toast-leave-to {
opacity: 0;
transform: translateX(100px);
}
.toast-move {
transition: transform 0.3s ease;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -1,320 +1,338 @@
<template>
<main class="chat-main" :class="{ 'wide-mode': isWideMode }">
<!-- 头部 -->
<ChatHeader
:title="currentConversation?.title || '新对话'"
:message-count="messages.length"
:show-sidebar-toggle="sidebarCollapsed"
:is-wide-mode="isWideMode"
:is-pinned="currentConversation?.pinned"
@toggle-sidebar="$emit('toggle-sidebar')"
@toggle-wide-mode="toggleWideMode"
@clear="handleClear"
@export="handleExport"
@pin="handlePin"
/>
<!-- 消息列表 -->
<MessageList
ref="messageListRef"
:messages="messages"
:show-timestamp="settings.showTimestamp"
:compact="settings.compactMode"
:is-typing="isTyping"
@retry="handleRetry"
@regenerate="handleRegenerate"
@select-suggestion="handleSuggestion"
/>
<!-- 输入区域 -->
<div class="input-wrapper">
<div class="input-container" :class="{ wide: isWideMode }">
<ChatInput
ref="chatInputRef"
:placeholder="inputPlaceholder"
:is-streaming="isStreaming"
:send-on-enter="settings.sendOnEnter"
:disabled="false"
@send="handleSend"
@stop="handleStop"
/>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from "vue";
import { storeToRefs } from "pinia";
import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings";
import ChatHeader from "./ChatHeader.vue";
import MessageList from "./MessageList.vue";
import ChatInput from "@/components/input/ChatInput.vue";
import { MessageType, MessageRole } from "@/types/chat";
import type { Attachment } from "@/types/chat";
import { chatApi } from "@/services/api.ts";
import { streamAIResponse, generateSuggestions } from "@/services/mockAI";
defineEmits<{
"toggle-sidebar": [];
}>();
const chatStore = useChatStore();
const settingsStore = useSettingsStore();
const { currentConversation, isStreaming } = storeToRefs(chatStore);
const { settings, sidebarCollapsed } = storeToRefs(settingsStore);
const messageListRef = ref<InstanceType<typeof MessageList> | null>(null);
const chatInputRef = ref<InstanceType<typeof ChatInput> | null>(null);
const isWideMode = ref(true);
const isTyping = ref(false);
const currentStreamingMessageId = ref<string | null>(null);
const abortController: any = ref<AbortController | null>(null);
const messages: any = computed(() => currentConversation.value?.messages || []);
const inputPlaceholder = computed(() => {
if (isStreaming.value) return "正在生成回复...";
return "输入你的问题,按 Ctrl+Enter 发送";
});
function toggleWideMode() {
isWideMode.value = !isWideMode.value;
}
function handleClear() {
if (currentConversation.value) {
chatStore.clearConversation(currentConversation.value.id);
}
}
function handleExport() {
if (!currentConversation.value) return;
const data = {
title: currentConversation.value.title,
messages: currentConversation.value.messages,
exportedAt: new Date().toISOString(),
};
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${currentConversation.value.title}.json`;
a.click();
URL.revokeObjectURL(url);
}
function handlePin() {
if (currentConversation.value) {
chatStore.togglePinConversation(currentConversation.value.id);
}
}
// - 使 API
async function handleSend(text: string, attachments: Attachment[]) {
//
if (!currentConversation.value) {
chatStore.createConversation();
}
//
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 = chatStore.addMessage(MessageRole.ASSISTANT, {
type: MessageType.TEXT,
text: "",
});
currentStreamingMessageId.value = aiMessage.id;
chatStore.updateMessage(aiMessage.id, { isStreaming: true });
chatStore.startStreaming();
isTyping.value = true;
// AbortController
abortController.value = new AbortController();
await streamAIResponse(
text,
{
onStart: () => {
isTyping.value = false;
},
onToken: (_token, fullText) => {
chatStore.updateMessageContent(aiMessage.id, fullText);
},
onComplete: (fullText) => {
chatStore.updateMessage(aiMessage.id, {
isStreaming: false,
content: {
type: MessageType.TEXT,
text: fullText,
suggestions: generateSuggestions(),
},
});
chatStore.stopStreaming();
currentStreamingMessageId.value = null;
},
onError: (error) => {
chatStore.updateMessage(aiMessage.id, {
isStreaming: false,
isError: true,
errorMessage: error.message,
});
chatStore.stopStreaming();
currentStreamingMessageId.value = null;
},
},
chatStore.streamController?.signal,
);
}
//
function handleStop() {
if (abortController.value) {
abortController.value.abort();
abortController.value = null;
}
chatStore.stopStreaming();
chatApi.stopChat(messages.value.at(-1)["messageId"]);
if (currentStreamingMessageId.value) {
chatStore.updateMessage(currentStreamingMessageId.value, {
isStreaming: false,
});
currentStreamingMessageId.value = null;
}
}
//
async function handleRetry(messageId: string) {
const message = messages.value.find((m: any) => m.id === messageId);
if (!message || message.role !== MessageRole.ASSISTANT) return;
const messageIndex = messages.value.findIndex((m: any) => m.id === messageId);
if (messageIndex <= 0) return;
const userMessage = messages.value[messageIndex - 1];
if (userMessage.role !== MessageRole.USER) return;
//
chatStore.updateMessage(messageId, {
isError: false,
errorMessage: undefined,
isStreaming: true,
isEnd: true,
content: { type: MessageType.TEXT, text: "" },
});
currentStreamingMessageId.value = messageId;
chatStore.startStreaming();
abortController.value = new AbortController();
await streamAIResponse(
userMessage.content.text || "",
{
onToken: (_token, fullText) => {
chatStore.updateMessageContent(messageId, fullText);
},
onComplete: (fullText) => {
chatStore.updateMessage(messageId, {
isStreaming: false,
content: {
type: MessageType.TEXT,
text: fullText,
suggestions: generateSuggestions(),
},
});
chatStore.stopStreaming();
currentStreamingMessageId.value = null;
},
onError: (error) => {
chatStore.updateMessage(messageId, {
isStreaming: false,
isError: true,
errorMessage: error.message,
});
chatStore.stopStreaming();
currentStreamingMessageId.value = null;
},
},
chatStore.streamController?.signal,
);
}
function handleRegenerate(messageId: string) {
handleRetry(messageId);
}
function handleSuggestion(text: string) {
handleSend(text, []);
}
function focusInput() {
chatInputRef.value?.focus();
}
defineExpose({
focusInput,
messageListRef,
});
watch(
() => currentConversation.value?.id,
() => {
nextTick(() => {
focusInput();
});
},
);
</script>
<style lang="scss" scoped>
.chat-main {
display: flex;
flex-direction: column;
flex: 1;
height: 100vh;
background: #ffffff;
overflow: hidden;
.dark & {
background: #11111b;
}
&.wide-mode {
.input-container {
max-width: 1000px;
}
}
}
.input-wrapper {
flex-shrink: 0;
padding: 16px 24px 24px;
background: linear-gradient(to top, white 80%, transparent);
.dark & {
background: linear-gradient(to top, #11111b 80%, transparent);
}
}
.input-container {
max-width: 800px;
margin: 0 auto;
transition: max-width 0.3s ease;
&.wide {
max-width: 1000px;
}
}
</style>
<template>
<main class="chat-main" :class="{ 'wide-mode': isWideMode }">
<!-- 头部 -->
<ChatHeader
:title="currentConversation?.title || '新对话'"
:message-count="messages.length"
:show-sidebar-toggle="sidebarCollapsed"
:is-wide-mode="isWideMode"
:is-pinned="currentConversation?.pinned"
@toggle-sidebar="$emit('toggle-sidebar')"
@toggle-wide-mode="toggleWideMode"
@clear="handleClear"
@export="handleExport"
@pin="handlePin"
/>
<!-- 消息列表 -->
<MessageList
ref="messageListRef"
:messages="messages"
:show-timestamp="settings.showTimestamp"
:compact="settings.compactMode"
:is-typing="isTyping"
@retry="handleRetry"
@regenerate="handleRegenerate"
@select-suggestion="handleSuggestion"
/>
<!-- 输入区域 -->
<div class="input-wrapper">
<div class="input-container" :class="{ wide: isWideMode }">
<ChatInput
ref="chatInputRef"
:placeholder="inputPlaceholder"
:is-streaming="isStreaming"
:send-on-enter="settings.sendOnEnter"
:disabled="false"
@send="handleSend"
@stop="handleStop"
/>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from "vue";
import { storeToRefs } from "pinia";
import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings";
import ChatHeader from "./ChatHeader.vue";
import MessageList from "./MessageList.vue";
import ChatInput from "@/components/input/ChatInput.vue";
import { MessageType, MessageRole } from "@/types/chat";
import type { Attachment } from "@/types/chat";
import { chatApi } from "@/services/api";
defineEmits<{
"toggle-sidebar": [];
}>();
const chatStore = useChatStore();
const settingsStore = useSettingsStore();
const { currentConversation, isStreaming } = storeToRefs(chatStore);
const { settings, sidebarCollapsed } = storeToRefs(settingsStore);
const messageListRef = ref<InstanceType<typeof MessageList> | null>(null);
const chatInputRef = ref<InstanceType<typeof ChatInput> | null>(null);
const isWideMode = ref(true);
const isTyping = ref(false);
const currentStreamingMessageId = ref<string | null>(null);
const abortController: any = ref<AbortController | null>(null);
const messages: any = computed(() => currentConversation.value?.messages || []);
const inputPlaceholder = computed(() => {
if (isStreaming.value) return "正在生成回复...";
return "输入你的问题,按 Ctrl+Enter 发送";
});
function toggleWideMode() {
isWideMode.value = !isWideMode.value;
}
function handleClear() {
if (currentConversation.value) {
chatStore.clearConversation(currentConversation.value.id);
}
}
function handleExport() {
if (!currentConversation.value) return;
const data = {
title: currentConversation.value.title,
messages: currentConversation.value.messages,
exportedAt: new Date().toISOString(),
};
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${currentConversation.value.title}.json`;
a.click();
URL.revokeObjectURL(url);
}
function handlePin() {
if (currentConversation.value) {
chatStore.togglePinConversation(currentConversation.value.id);
}
}
// - 使 API
async function handleSend(text: string, attachments: Attachment[]) {
//
if (!currentConversation.value) {
chatStore.createConversation();
}
//
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 = chatStore.addMessage(MessageRole.ASSISTANT, {
type: MessageType.TEXT,
text: "",
});
currentStreamingMessageId.value = aiMessage.id;
chatStore.updateMessage(aiMessage.id, { isStreaming: true });
chatStore.startStreaming();
isTyping.value = true;
// AbortController
abortController.value = new AbortController();
try {
const stream = chatApi.streamChat(
{
message: text,
conversationId: currentConversation.value?.id || "",
model: settings.value.defaultModel,
stream: true,
},
abortController.value.signal,
);
let fullText = "";
isTyping.value = false;
for await (const chunk of stream) {
if (abortController.value?.signal.aborted) break;
fullText += chunk;
chatStore.updateMessageContent(aiMessage.id, fullText);
}
if (!abortController.value?.signal.aborted) {
chatStore.updateMessage(aiMessage.id, {
isStreaming: false,
content: {
type: MessageType.TEXT,
text: fullText,
},
});
}
} catch (error: any) {
if (error.name !== "AbortError") {
chatStore.updateMessage(aiMessage.id, {
isStreaming: false,
isError: true,
errorMessage: error.message || "请求失败",
});
}
} finally {
chatStore.stopStreaming();
currentStreamingMessageId.value = null;
}
}
//
function handleStop() {
if (abortController.value) {
abortController.value.abort();
abortController.value = null;
}
chatStore.stopStreaming();
chatApi.stopChat(messages.value.at(-1)["messageId"]);
if (currentStreamingMessageId.value) {
chatStore.updateMessage(currentStreamingMessageId.value, {
isStreaming: false,
});
currentStreamingMessageId.value = null;
}
}
//
async function handleRetry(messageId: string) {
const message = messages.value.find((m: any) => m.id === messageId);
if (!message || message.role !== MessageRole.ASSISTANT) return;
const messageIndex = messages.value.findIndex((m: any) => m.id === messageId);
if (messageIndex <= 0) return;
const userMessage = messages.value[messageIndex - 1];
if (userMessage.role !== MessageRole.USER) return;
//
chatStore.updateMessage(messageId, {
isError: false,
errorMessage: undefined,
isStreaming: true,
isEnd: true,
content: { type: MessageType.TEXT, text: "" },
});
currentStreamingMessageId.value = messageId;
chatStore.startStreaming();
abortController.value = new AbortController();
try {
const stream = chatApi.streamChat(
{
message: userMessage.content.text || "",
conversationId: currentConversation.value?.id,
model: settings.value.defaultModel,
stream: true,
},
abortController.value.signal,
);
let fullText = "";
for await (const chunk of stream) {
if (abortController.value?.signal.aborted) break;
fullText += chunk;
chatStore.updateMessageContent(messageId, fullText);
}
if (!abortController.value?.signal.aborted) {
chatStore.updateMessage(messageId, {
isStreaming: false,
content: {
type: MessageType.TEXT,
text: fullText,
},
});
}
} catch (error: any) {
if (error.name !== "AbortError") {
chatStore.updateMessage(messageId, {
isStreaming: false,
isError: true,
errorMessage: error.message || "请求失败",
});
}
} finally {
chatStore.stopStreaming();
currentStreamingMessageId.value = null;
}
}
function handleRegenerate(messageId: string) {
handleRetry(messageId);
}
function handleSuggestion(text: string) {
handleSend(text, []);
}
function focusInput() {
chatInputRef.value?.focus();
}
defineExpose({
focusInput,
messageListRef,
});
watch(
() => currentConversation.value?.id,
() => {
nextTick(() => {
focusInput();
});
},
);
</script>
<style lang="scss" scoped>
.chat-main {
display: flex;
flex-direction: column;
flex: 1;
height: 100vh;
background: #ffffff;
overflow: hidden;
.dark & {
background: #11111b;
}
&.wide-mode {
.input-container {
max-width: 1000px;
}
}
}
.input-wrapper {
flex-shrink: 0;
padding: 16px 24px 24px;
background: linear-gradient(to top, white 80%, transparent);
.dark & {
background: linear-gradient(to top, #11111b 80%, transparent);
}
}
.input-container {
max-width: 800px;
margin: 0 auto;
transition: max-width 0.3s ease;
&.wide {
max-width: 1000px;
}
}
</style>

View File

@ -1,397 +1,397 @@
<template>
<div ref="boxRef" style="flex: 1; position: relative">
<div ref="containerRef" class="message-list" @scroll="handleScroll">
<!-- 欢迎界面 -->
<WelcomeScreen
v-if="messages.length === 0"
@select="$emit('select-suggestion', $event)"
/>
<!-- 消息列表 -->
<template v-else>
<div class="messages-wrapper">
<TransitionGroup name="message">
<MessageBubble
v-for="(message, index) in messages"
:key="message.id"
:message="message"
:show-timestamp="showTimestamp"
:compact="compact"
:is-New="index === messages.length - 1"
@retry="$emit('retry', message.id)"
@regenerate="$emit('regenerate', message.id)"
@copy="handleCopy(message)"
@like="handleLike(message)"
@dislike="handleDislike(message)"
@select-suggestion="$emit('select-suggestion', $event.text)"
@preview-image="handlePreviewImage"
@play-video="handlePlayVideo"
@download-file="handleDownloadFile"
/>
</TransitionGroup>
<!-- 正在输入指示器 -->
<div v-if="isTyping" class="typing-indicator">
<div class="typing-avatar">
<Bot :size="20" />
</div>
<div class="typing-dots">
<span></span>
<span></span>
<span></span>
</div>
<span class="typing-text">AI 正在思考...</span>
</div>
</div>
</template>
</div>
<!-- 回到底部按钮 -->
<Transition name="fade">
<button
v-if="showScrollButton"
class="scroll-bottom-btn"
@click="handleScrollToBottom"
>
<ChevronDown :size="20" />
<span v-if="newMessageCount > 0" class="new-count">
{{ newMessageCount }}
</span>
</button>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, watch, nextTick, onMounted } from "vue";
import { useChatStore } from "@/stores/chat";
import MessageBubble from "@/components/message/MessageBubble.vue";
import WelcomeScreen from "./WelcomeScreen.vue";
import { Bot, ChevronDown } from "@/components/icons";
import type { Message, Attachment, VideoInfo } from "@/types/chat";
const props = withDefaults(
defineProps<{
messages: Message[];
showTimestamp?: boolean;
compact?: boolean;
isTyping?: boolean;
}>(),
{
showTimestamp: true,
compact: false,
isTyping: false,
},
);
const emit = defineEmits<{
retry: [messageId: string];
regenerate: [messageId: string];
"select-suggestion": [text: string];
"preview-image": [image: Attachment, index: number];
"play-video": [video: VideoInfo];
"download-file": [file: Attachment];
}>();
const chatStore = useChatStore();
//
const boxRef: any = ref<HTMLElement | null>(null);
const containerRef: any = ref<HTMLElement | null>(null);
const showScrollButton = ref(false);
const newMessageCount = ref(0);
const isAutoScrolling = ref(true);
const lastScrollTop = ref(0);
onMounted(() => {
containerRef.value.style.height = boxRef.value?.clientHeight + "px";
});
//
function handleScroll() {
const container = containerRef.value;
if (!container) return;
const { scrollTop, scrollHeight, clientHeight } = container;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 100;
if (scrollTop < lastScrollTop.value && !isAtBottom) {
isAutoScrolling.value = false;
showScrollButton.value = true;
}
if (isAtBottom) {
isAutoScrolling.value = true;
showScrollButton.value = false;
newMessageCount.value = 0;
}
lastScrollTop.value = scrollTop;
}
//
function scrollToBottom(smooth = true) {
const container = containerRef.value;
if (!container) return;
container.scrollTo({
top: container.scrollHeight,
behavior: smooth ? "smooth" : "auto",
});
isAutoScrolling.value = true;
showScrollButton.value = false;
newMessageCount.value = 0;
}
//
function handleScrollToBottom() {
scrollToBottom(true);
}
//
function handleCopy(message: Message) {
chatStore.setMessageCopied(message.id);
}
function handleLike(message: Message) {
const currentLiked = message.feedback?.liked;
chatStore.setMessageFeedback(message.id, currentLiked ? null : "like");
}
function handleDislike(message: Message) {
const currentDisliked = message.feedback?.disliked;
chatStore.setMessageFeedback(message.id, currentDisliked ? null : "dislike");
}
function handlePreviewImage(image: Attachment, index: number) {
emit("preview-image", image, index);
}
function handlePlayVideo(video: VideoInfo) {
emit("play-video", video);
}
function handleDownloadFile(file: Attachment) {
emit("download-file", file);
}
//
watch(
() => props.messages.length,
(newLen, oldLen) => {
if (newLen > oldLen) {
if (isAutoScrolling.value) {
nextTick(() => {
scrollToBottom(false);
});
} else {
newMessageCount.value++;
}
}
},
);
//
watch(
() => props.messages[props.messages.length - 1]?.content.text,
() => {
if (isAutoScrolling.value) {
nextTick(() => {
const container = containerRef.value;
if (container) {
container.scrollTop = container.scrollHeight;
}
});
}
},
);
// isTyping
watch(
() => props.isTyping,
(typing) => {
if (typing && isAutoScrolling.value) {
nextTick(() => {
scrollToBottom(true);
});
}
},
);
//
defineExpose({
scrollToBottom,
});
onMounted(() => {
scrollToBottom(false);
});
</script>
<style lang="scss" scoped>
.message-list {
height: 500px;
overflow-y: auto;
overflow-x: hidden;
position: relative;
}
.messages-wrapper {
display: flex;
flex-direction: column;
padding: 20px 0;
min-height: 100%;
}
.typing-indicator {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 24px;
animation: fadeIn 0.3s ease;
}
.typing-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
border-radius: 12px;
color: white;
}
.typing-dots {
display: flex;
align-items: center;
gap: 4px;
padding: 12px 16px;
background: #f3f4f6;
border-radius: 16px;
.dark & {
background: #2d2d3d;
}
span {
width: 8px;
height: 8px;
background: #9ca3af;
border-radius: 50%;
animation: typingBounce 1.4s infinite ease-in-out both;
&:nth-child(1) {
animation-delay: -0.32s;
}
&:nth-child(2) {
animation-delay: -0.16s;
}
}
}
.typing-text {
font-size: 13px;
color: #9ca3af;
}
.scroll-bottom-btn {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border: none;
border-radius: 50%;
background: white;
color: #374151;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
cursor: pointer;
transition: all 0.2s ease;
z-index: 10;
.dark & {
background: #2d2d3d;
color: #e5e7eb;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
&:hover {
transform: translateX(-50%) scale(1.1);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
.new-count {
position: absolute;
top: -4px;
right: -4px;
min-width: 20px;
height: 20px;
padding: 0 6px;
background: #ef4444;
border-radius: 10px;
color: white;
font-size: 11px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
}
//
.message-enter-active,
.message-leave-active {
transition: all 0.3s ease;
}
.message-enter-from {
opacity: 0;
transform: translateY(20px);
}
.message-leave-to {
opacity: 0;
transform: translateX(-20px);
}
//
.fade-enter-active,
.fade-leave-active {
transition: all 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(10px);
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes typingBounce {
0%,
80%,
100% {
transform: scale(0.7);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
</style>
<template>
<div ref="boxRef" style="flex: 1; position: relative">
<div ref="containerRef" class="message-list" @scroll="handleScroll">
<!-- 欢迎界面 -->
<WelcomeScreen
v-if="messages.length === 0"
@select="$emit('select-suggestion', $event)"
/>
<!-- 消息列表 -->
<template v-else>
<div class="messages-wrapper">
<TransitionGroup name="message">
<MessageBubble
v-for="(message, index) in messages"
:key="message.id"
:message="message"
:show-timestamp="showTimestamp"
:compact="compact"
:is-New="index === messages.length - 1"
@retry="$emit('retry', message.id)"
@regenerate="$emit('regenerate', message.id)"
@copy="handleCopy(message)"
@like="handleLike(message)"
@dislike="handleDislike(message)"
@select-suggestion="$emit('select-suggestion', $event.text)"
@preview-image="handlePreviewImage"
@play-video="handlePlayVideo"
@download-file="handleDownloadFile"
/>
</TransitionGroup>
<!-- 正在输入指示器 -->
<div v-if="isTyping" class="typing-indicator">
<div class="typing-avatar">
<Bot :size="20" />
</div>
<div class="typing-dots">
<span></span>
<span></span>
<span></span>
</div>
<span class="typing-text">AI 正在思考...</span>
</div>
</div>
</template>
</div>
<!-- 回到底部按钮 -->
<Transition name="fade">
<button
v-if="showScrollButton"
class="scroll-bottom-btn"
@click="handleScrollToBottom"
>
<ChevronDown :size="20" />
<span v-if="newMessageCount > 0" class="new-count">
{{ newMessageCount }}
</span>
</button>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, watch, nextTick, onMounted } from "vue";
import { useChatStore } from "@/stores/chat";
import MessageBubble from "@/components/message/MessageBubble.vue";
import WelcomeScreen from "./WelcomeScreen.vue";
import { Bot, ChevronDown } from "@/components/icons";
import type { Message, Attachment, VideoInfo } from "@/types/chat";
const props = withDefaults(
defineProps<{
messages: Message[];
showTimestamp?: boolean;
compact?: boolean;
isTyping?: boolean;
}>(),
{
showTimestamp: true,
compact: false,
isTyping: false,
},
);
const emit = defineEmits<{
retry: [messageId: string];
regenerate: [messageId: string];
"select-suggestion": [text: string];
"preview-image": [image: Attachment, index: number];
"play-video": [video: VideoInfo];
"download-file": [file: Attachment];
}>();
const chatStore = useChatStore();
//
const boxRef: any = ref<HTMLElement | null>(null);
const containerRef: any = ref<HTMLElement | null>(null);
const showScrollButton = ref(false);
const newMessageCount = ref(0);
const isAutoScrolling = ref(true);
const lastScrollTop = ref(0);
onMounted(() => {
containerRef.value.style.height = boxRef.value?.clientHeight + "px";
});
//
function handleScroll() {
const container = containerRef.value;
if (!container) return;
const { scrollTop, scrollHeight, clientHeight } = container;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 100;
if (scrollTop < lastScrollTop.value && !isAtBottom) {
isAutoScrolling.value = false;
showScrollButton.value = true;
}
if (isAtBottom) {
isAutoScrolling.value = true;
showScrollButton.value = false;
newMessageCount.value = 0;
}
lastScrollTop.value = scrollTop;
}
//
function scrollToBottom(smooth = true) {
const container = containerRef.value;
if (!container) return;
container.scrollTo({
top: container.scrollHeight,
behavior: smooth ? "smooth" : "auto",
});
isAutoScrolling.value = true;
showScrollButton.value = false;
newMessageCount.value = 0;
}
//
function handleScrollToBottom() {
scrollToBottom(true);
}
//
function handleCopy(message: Message) {
chatStore.setMessageCopied(message.id);
}
function handleLike(message: Message) {
const currentLiked = message.feedback?.liked;
chatStore.setMessageFeedback(message.id, currentLiked ? null : "like");
}
function handleDislike(message: Message) {
const currentDisliked = message.feedback?.disliked;
chatStore.setMessageFeedback(message.id, currentDisliked ? null : "dislike");
}
function handlePreviewImage(image: Attachment, index: number) {
emit("preview-image", image, index);
}
function handlePlayVideo(video: VideoInfo) {
emit("play-video", video);
}
function handleDownloadFile(file: Attachment) {
emit("download-file", file);
}
//
watch(
() => props.messages.length,
(newLen, oldLen) => {
if (newLen > oldLen) {
if (isAutoScrolling.value) {
nextTick(() => {
scrollToBottom(false);
});
} else {
newMessageCount.value++;
}
}
},
);
//
watch(
() => props.messages[props.messages.length - 1]?.content.text,
() => {
if (isAutoScrolling.value) {
nextTick(() => {
const container = containerRef.value;
if (container) {
container.scrollTop = container.scrollHeight;
}
});
}
},
);
// isTyping
watch(
() => props.isTyping,
(typing) => {
if (typing && isAutoScrolling.value) {
nextTick(() => {
scrollToBottom(true);
});
}
},
);
//
defineExpose({
scrollToBottom,
});
onMounted(() => {
scrollToBottom(false);
});
</script>
<style lang="scss" scoped>
.message-list {
height: 500px;
overflow-y: auto;
overflow-x: hidden;
position: relative;
}
.messages-wrapper {
display: flex;
flex-direction: column;
padding: 20px 0;
min-height: 100%;
}
.typing-indicator {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 24px;
animation: fadeIn 0.3s ease;
}
.typing-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
border-radius: 12px;
color: white;
}
.typing-dots {
display: flex;
align-items: center;
gap: 4px;
padding: 12px 16px;
background: #f3f4f6;
border-radius: 16px;
.dark & {
background: #2d2d3d;
}
span {
width: 8px;
height: 8px;
background: #9ca3af;
border-radius: 50%;
animation: typingBounce 1.4s infinite ease-in-out both;
&:nth-child(1) {
animation-delay: -0.32s;
}
&:nth-child(2) {
animation-delay: -0.16s;
}
}
}
.typing-text {
font-size: 13px;
color: #9ca3af;
}
.scroll-bottom-btn {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border: none;
border-radius: 50%;
background: white;
color: #374151;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
cursor: pointer;
transition: all 0.2s ease;
z-index: 10;
.dark & {
background: #2d2d3d;
color: #e5e7eb;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
&:hover {
transform: translateX(-50%) scale(1.1);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
.new-count {
position: absolute;
top: -4px;
right: -4px;
min-width: 20px;
height: 20px;
padding: 0 6px;
background: #ef4444;
border-radius: 10px;
color: white;
font-size: 11px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
}
//
.message-enter-active,
.message-leave-active {
transition: all 0.3s ease;
}
.message-enter-from {
opacity: 0;
transform: translateY(20px);
}
.message-leave-to {
opacity: 0;
transform: translateX(-20px);
}
//
.fade-enter-active,
.fade-leave-active {
transition: all 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(10px);
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes typingBounce {
0%,
80%,
100% {
transform: scale(0.7);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
</style>

View File

@ -1,374 +1,374 @@
<template>
<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">AI 智能助手</h1>
<p class="subtitle">我可以帮助你解答问题生成内容分析数据等</p>
</div>
<!-- 功能卡片 -->
<div class="feature-cards">
<div
v-for="feature in features"
:key="feature.title"
class="feature-card"
>
<div class="feature-icon" :style="{ background: feature.gradient }">
<component :is="feature.icon" :size="22" />
</div>
<h3>{{ feature.title }}</h3>
<p>{{ feature.description }}</p>
</div>
</div>
<!-- 快速开始建议 -->
<div class="quick-start">
<h4>试试这些问题</h4>
<div class="suggestions-grid">
<button
v-for="suggestion in suggestions"
:key="suggestion.text"
class="suggestion-card"
@click="$emit('select', suggestion.text)"
>
<component :is="suggestion.icon" :size="18" class="suggestion-icon" />
<span>{{ suggestion.text }}</span>
<ChevronRight :size="16" class="arrow-icon" />
</button>
</div>
</div>
<!-- 底部提示 -->
<div class="welcome-footer">
<div class="tip">
<Keyboard :size="14" />
<span> <kbd>Ctrl</kbd> + <kbd>/</kbd> 聚焦输入框</span>
</div>
<div class="tip">
<Zap :size="14" />
<span>支持 Markdown代码高亮LaTeX 公式</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
Bot,
MessageSquare,
Code,
Image,
FileText,
ChevronRight,
Keyboard,
Zap,
Globe,
Lightbulb,
PenTool,
} from '@/components/icons'
defineEmits<{
select: [text: string]
}>()
const features = computed(() => [
{
icon: MessageSquare,
title: '智能对话',
description: '自然流畅的对话体验,理解上下文',
gradient: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
},
{
icon: Code,
title: '代码助手',
description: '编写、解释、优化各种编程语言代码',
gradient: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
},
{
icon: Image,
title: '图像理解',
description: '分析图片内容,提取关键信息',
gradient: 'linear-gradient(135deg, #ec4899 0%, #d946ef 100%)',
},
{
icon: FileText,
title: '文档处理',
description: '阅读、总结、翻译各类文档',
gradient: 'linear-gradient(135deg, #f59e0b 0%, #f97316 100%)',
},
])
const suggestions = computed(() => [
{
icon: Lightbulb,
text: '帮我写一个 Vue 3 组件示例',
},
{
icon: Globe,
text: '解释一下什么是机器学习',
},
{
icon: PenTool,
text: '帮我写一封商务邮件',
},
{
icon: Code,
text: '如何优化 React 应用性能',
},
])
</script>
<style lang="scss" scoped>
.welcome-screen {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100%;
padding: 40px 24px;
animation: fadeIn 0.5s ease;
}
.welcome-header {
text-align: center;
margin-bottom: 48px;
}
.logo-wrapper {
position: relative;
display: inline-flex;
margin-bottom: 20px;
}
.logo-icon {
display: flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
border-radius: 24px;
color: white;
box-shadow: 0 20px 40px -12px rgba(59, 130, 246, 0.35);
}
.logo-glow {
position: absolute;
inset: -20px;
background: radial-gradient(circle, rgba(59, 130, 246, 0.2) 0%, transparent 70%);
pointer-events: none;
}
.title {
margin: 0 0 12px;
font-size: 32px;
font-weight: 700;
color: #1f2937;
.dark & {
color: #f3f4f6;
}
}
.subtitle {
margin: 0;
font-size: 16px;
color: #6b7280;
.dark & {
color: #9ca3af;
}
}
.feature-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
max-width: 900px;
width: 100%;
margin-bottom: 48px;
@media (max-width: 900px) {
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 500px) {
grid-template-columns: 1fr;
}
}
.feature-card {
padding: 24px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 16px;
text-align: center;
transition: all 0.3s ease;
.dark & {
background: #1e1e2e;
border-color: #2d2d3d;
}
&:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px -8px rgba(0, 0, 0, 0.1);
.dark & {
box-shadow: 0 12px 24px -8px rgba(0, 0, 0, 0.4);
}
}
.feature-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 14px;
color: white;
margin-bottom: 16px;
}
h3 {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
color: #1f2937;
.dark & {
color: #f3f4f6;
}
}
p {
margin: 0;
font-size: 13px;
color: #6b7280;
line-height: 1.5;
.dark & {
color: #9ca3af;
}
}
}
.quick-start {
max-width: 700px;
width: 100%;
margin-bottom: 40px;
h4 {
margin: 0 0 16px;
font-size: 14px;
font-weight: 600;
color: #6b7280;
text-align: center;
.dark & {
color: #9ca3af;
}
}
}
.suggestions-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
@media (max-width: 600px) {
grid-template-columns: 1fr;
}
}
.suggestion-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 14px;
color: #374151;
font-size: 14px;
text-align: left;
cursor: pointer;
transition: all 0.2s ease;
.dark & {
background: #1e1e2e;
border-color: #2d2d3d;
color: #e5e7eb;
}
&:hover {
border-color: #3b82f6;
background: rgba(59, 130, 246, 0.05);
.arrow-icon {
transform: translateX(4px);
color: #3b82f6;
}
}
.suggestion-icon {
flex-shrink: 0;
color: #3b82f6;
}
span {
flex: 1;
}
.arrow-icon {
flex-shrink: 0;
color: #9ca3af;
transition: all 0.2s ease;
}
}
.welcome-footer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 24px;
}
.tip {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #9ca3af;
kbd {
padding: 2px 8px;
background: #f3f4f6;
border-radius: 4px;
font-size: 12px;
.dark & {
background: #374151;
}
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
<template>
<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">AI 智能助手</h1>
<p class="subtitle">我可以帮助你解答问题生成内容分析数据等</p>
</div>
<!-- 功能卡片 -->
<div class="feature-cards">
<div
v-for="feature in features"
:key="feature.title"
class="feature-card"
>
<div class="feature-icon" :style="{ background: feature.gradient }">
<component :is="feature.icon" :size="22" />
</div>
<h3>{{ feature.title }}</h3>
<p>{{ feature.description }}</p>
</div>
</div>
<!-- 快速开始建议 -->
<div class="quick-start">
<h4>试试这些问题</h4>
<div class="suggestions-grid">
<button
v-for="suggestion in suggestions"
:key="suggestion.text"
class="suggestion-card"
@click="$emit('select', suggestion.text)"
>
<component :is="suggestion.icon" :size="18" class="suggestion-icon" />
<span>{{ suggestion.text }}</span>
<ChevronRight :size="16" class="arrow-icon" />
</button>
</div>
</div>
<!-- 底部提示 -->
<div class="welcome-footer">
<div class="tip">
<Keyboard :size="14" />
<span> <kbd>Ctrl</kbd> + <kbd>/</kbd> 聚焦输入框</span>
</div>
<div class="tip">
<Zap :size="14" />
<span>支持 Markdown代码高亮LaTeX 公式</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
Bot,
MessageSquare,
Code,
Image,
FileText,
ChevronRight,
Keyboard,
Zap,
Globe,
Lightbulb,
PenTool,
} from '@/components/icons'
defineEmits<{
select: [text: string]
}>()
const features = computed(() => [
{
icon: MessageSquare,
title: '智能对话',
description: '自然流畅的对话体验,理解上下文',
gradient: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
},
{
icon: Code,
title: '代码助手',
description: '编写、解释、优化各种编程语言代码',
gradient: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
},
{
icon: Image,
title: '图像理解',
description: '分析图片内容,提取关键信息',
gradient: 'linear-gradient(135deg, #ec4899 0%, #d946ef 100%)',
},
{
icon: FileText,
title: '文档处理',
description: '阅读、总结、翻译各类文档',
gradient: 'linear-gradient(135deg, #f59e0b 0%, #f97316 100%)',
},
])
const suggestions = computed(() => [
{
icon: Lightbulb,
text: '帮我写一个 Vue 3 组件示例',
},
{
icon: Globe,
text: '解释一下什么是机器学习',
},
{
icon: PenTool,
text: '帮我写一封商务邮件',
},
{
icon: Code,
text: '如何优化 React 应用性能',
},
])
</script>
<style lang="scss" scoped>
.welcome-screen {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100%;
padding: 40px 24px;
animation: fadeIn 0.5s ease;
}
.welcome-header {
text-align: center;
margin-bottom: 48px;
}
.logo-wrapper {
position: relative;
display: inline-flex;
margin-bottom: 20px;
}
.logo-icon {
display: flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
border-radius: 24px;
color: white;
box-shadow: 0 20px 40px -12px rgba(59, 130, 246, 0.35);
}
.logo-glow {
position: absolute;
inset: -20px;
background: radial-gradient(circle, rgba(59, 130, 246, 0.2) 0%, transparent 70%);
pointer-events: none;
}
.title {
margin: 0 0 12px;
font-size: 32px;
font-weight: 700;
color: #1f2937;
.dark & {
color: #f3f4f6;
}
}
.subtitle {
margin: 0;
font-size: 16px;
color: #6b7280;
.dark & {
color: #9ca3af;
}
}
.feature-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
max-width: 900px;
width: 100%;
margin-bottom: 48px;
@media (max-width: 900px) {
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 500px) {
grid-template-columns: 1fr;
}
}
.feature-card {
padding: 24px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 16px;
text-align: center;
transition: all 0.3s ease;
.dark & {
background: #1e1e2e;
border-color: #2d2d3d;
}
&:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px -8px rgba(0, 0, 0, 0.1);
.dark & {
box-shadow: 0 12px 24px -8px rgba(0, 0, 0, 0.4);
}
}
.feature-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 14px;
color: white;
margin-bottom: 16px;
}
h3 {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
color: #1f2937;
.dark & {
color: #f3f4f6;
}
}
p {
margin: 0;
font-size: 13px;
color: #6b7280;
line-height: 1.5;
.dark & {
color: #9ca3af;
}
}
}
.quick-start {
max-width: 700px;
width: 100%;
margin-bottom: 40px;
h4 {
margin: 0 0 16px;
font-size: 14px;
font-weight: 600;
color: #6b7280;
text-align: center;
.dark & {
color: #9ca3af;
}
}
}
.suggestions-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
@media (max-width: 600px) {
grid-template-columns: 1fr;
}
}
.suggestion-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 14px;
color: #374151;
font-size: 14px;
text-align: left;
cursor: pointer;
transition: all 0.2s ease;
.dark & {
background: #1e1e2e;
border-color: #2d2d3d;
color: #e5e7eb;
}
&:hover {
border-color: #3b82f6;
background: rgba(59, 130, 246, 0.05);
.arrow-icon {
transform: translateX(4px);
color: #3b82f6;
}
}
.suggestion-icon {
flex-shrink: 0;
color: #3b82f6;
}
span {
flex: 1;
}
.arrow-icon {
flex-shrink: 0;
color: #9ca3af;
transition: all 0.2s ease;
}
}
.welcome-footer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 24px;
}
.tip {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #9ca3af;
kbd {
padding: 2px 8px;
background: #f3f4f6;
border-radius: 4px;
font-size: 12px;
.dark & {
background: #374151;
}
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@ -1,133 +1,133 @@
export {
// 通用图标
Menu,
X,
Check,
Plus,
Minus,
Search,
Settings,
Info,
AlertCircle,
AlertTriangle,
Loader2,
MoreHorizontal,
MoreVertical,
// 主题图标
Moon,
Sun,
Monitor,
// 用户/角色
User,
Bot,
Users,
// 消息/对话
MessageSquare,
MessageCircle,
MessagesSquare,
Send,
SendHorizontal,
// 操作图标
Copy,
Clipboard,
ClipboardCheck,
Edit3,
Pencil,
Trash2,
Download,
Upload,
ExternalLink,
Link,
Share2,
RefreshCw,
RotateCcw,
Brain,
// 反馈图标
ThumbsUp,
ThumbsDown,
Heart,
Star,
// 导航图标
ChevronLeft,
ChevronRight,
ChevronDown,
ChevronUp,
ArrowLeft,
ArrowRight,
ArrowUp,
ArrowDown,
// 状态/标记
Pin,
PinOff,
Archive,
Bookmark,
Flag,
Clock,
Calendar,
History,
// 文件夹/文件
Folder,
FolderOpen,
File,
FileText,
FileCode,
FileImage,
Paperclip,
// 媒体图标
Image,
Video,
Play,
Pause,
Square,
StopCircle,
Mic,
MicOff,
Volume2,
VolumeX,
Camera,
// 功能图标
Sparkles,
Wand2,
Zap,
Globe,
Wifi,
Code,
Terminal,
Keyboard,
Command,
Hash,
AtSign,
Lightbulb,
PenTool,
Palette,
// 布局图标
Maximize2,
Minimize2,
Expand,
Shrink,
PanelLeft,
PanelRight,
LayoutGrid,
List,
// 其他
HelpCircle,
Eye,
EyeOff,
Lock,
Unlock,
Shield,
Bell,
BellOff,
} from "lucide-vue-next";
export {
// 通用图标
Menu,
X,
Check,
Plus,
Minus,
Search,
Settings,
Info,
AlertCircle,
AlertTriangle,
Loader2,
MoreHorizontal,
MoreVertical,
// 主题图标
Moon,
Sun,
Monitor,
// 用户/角色
User,
Bot,
Users,
// 消息/对话
MessageSquare,
MessageCircle,
MessagesSquare,
Send,
SendHorizontal,
// 操作图标
Copy,
Clipboard,
ClipboardCheck,
Edit3,
Pencil,
Trash2,
Download,
Upload,
ExternalLink,
Link,
Share2,
RefreshCw,
RotateCcw,
Brain,
// 反馈图标
ThumbsUp,
ThumbsDown,
Heart,
Star,
// 导航图标
ChevronLeft,
ChevronRight,
ChevronDown,
ChevronUp,
ArrowLeft,
ArrowRight,
ArrowUp,
ArrowDown,
// 状态/标记
Pin,
PinOff,
Archive,
Bookmark,
Flag,
Clock,
Calendar,
History,
// 文件夹/文件
Folder,
FolderOpen,
File,
FileText,
FileCode,
FileImage,
Paperclip,
// 媒体图标
Image,
Video,
Play,
Pause,
Square,
StopCircle,
Mic,
MicOff,
Volume2,
VolumeX,
Camera,
// 功能图标
Sparkles,
Wand2,
Zap,
Globe,
Wifi,
Code,
Terminal,
Keyboard,
Command,
Hash,
AtSign,
Lightbulb,
PenTool,
Palette,
// 布局图标
Maximize2,
Minimize2,
Expand,
Shrink,
PanelLeft,
PanelRight,
LayoutGrid,
List,
// 其他
HelpCircle,
Eye,
EyeOff,
Lock,
Unlock,
Shield,
Bell,
BellOff,
} from "lucide-vue-next";

View File

@ -1,266 +1,266 @@
<template>
<div class="attachment-preview">
<TransitionGroup name="attachment">
<div
v-for="attachment in attachments"
:key="attachment.id"
class="attachment-item"
:class="attachment.type"
>
<!-- 图片预览 -->
<template v-if="attachment.type === 'image'">
<img :src="attachment.url" :alt="attachment.name" class="preview-image" />
</template>
<!-- 视频预览 -->
<template v-else-if="attachment.type === 'video'">
<div class="preview-video">
<img
v-if="attachment.thumbnail"
:src="attachment.thumbnail"
:alt="attachment.name"
/>
<div v-else class="video-placeholder">
<Video :size="24" />
</div>
<div class="video-badge">
<Play :size="12" />
</div>
</div>
</template>
<!-- 文件预览 -->
<template v-else>
<div class="preview-file">
<span class="file-emoji">{{ getFileEmoji(attachment.mimeType) }}</span>
<div class="file-details">
<span class="file-name">{{ truncateName(attachment.name) }}</span>
<span class="file-size">{{ formatSize(attachment.size) }}</span>
</div>
</div>
</template>
<!-- 删除按钮 -->
<button
class="remove-btn"
@click="$emit('remove', attachment.id)"
>
<X :size="14" />
</button>
<!-- 上传进度 -->
<div v-if="attachment.uploading" class="upload-progress">
<div
class="progress-bar"
:style="{ width: `${attachment.progress || 0}%` }"
/>
</div>
</div>
</TransitionGroup>
</div>
</template>
<script setup lang="ts">
import { X, Video, Play } from '@/components/icons'
import { formatFileSize, getFileIcon, truncateText } from '@/utils/helpers'
interface AttachmentWithProgress {
id: string
name: string
type: 'image' | 'file' | 'video'
url: string
size?: number
mimeType?: string
thumbnail?: string
uploading?: boolean
progress?: number
}
defineProps<{
attachments: AttachmentWithProgress[]
}>()
defineEmits<{
remove: [id: string]
}>()
function getFileEmoji(mimeType?: string) {
return getFileIcon(mimeType || '')
}
function formatSize(size?: number) {
return size ? formatFileSize(size) : ''
}
function truncateName(name: string) {
return truncateText(name, 20)
}
</script>
<style lang="scss" scoped>
.attachment-preview {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid #e2e8f0;
.dark & {
border-bottom-color: #374151;
}
}
.attachment-item {
position: relative;
border-radius: 12px;
overflow: hidden;
background: #f3f4f6;
.dark & {
background: #374151;
}
&.image,
&.video {
width: 80px;
height: 80px;
}
&.file {
padding: 10px 40px 10px 12px;
}
}
.preview-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-video {
position: relative;
width: 100%;
height: 100%;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #e5e7eb;
color: #9ca3af;
.dark & {
background: #4b5563;
}
}
.video-badge {
position: absolute;
bottom: 6px;
left: 6px;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
background: rgba(0, 0, 0, 0.7);
border-radius: 50%;
color: white;
}
}
.preview-file {
display: flex;
align-items: center;
gap: 10px;
}
.file-emoji {
font-size: 24px;
}
.file-details {
display: flex;
flex-direction: column;
}
.file-name {
font-size: 13px;
font-weight: 500;
color: #374151;
.dark & {
color: #e5e7eb;
}
}
.file-size {
font-size: 11px;
color: #9ca3af;
}
.remove-btn {
position: absolute;
top: 4px;
right: 4px;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
color: white;
cursor: pointer;
opacity: 0;
transition: all 0.2s ease;
.attachment-item:hover & {
opacity: 1;
}
&:hover {
background: rgba(239, 68, 68, 0.9);
}
}
.upload-progress {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: rgba(0, 0, 0, 0.2);
.progress-bar {
height: 100%;
background: #3b82f6;
transition: width 0.3s ease;
}
}
//
.attachment-enter-active,
.attachment-leave-active {
transition: all 0.3s ease;
}
.attachment-enter-from {
opacity: 0;
transform: scale(0.8);
}
.attachment-leave-to {
opacity: 0;
transform: scale(0.8);
}
<template>
<div class="attachment-preview">
<TransitionGroup name="attachment">
<div
v-for="attachment in attachments"
:key="attachment.id"
class="attachment-item"
:class="attachment.type"
>
<!-- 图片预览 -->
<template v-if="attachment.type === 'image'">
<img :src="attachment.url" :alt="attachment.name" class="preview-image" />
</template>
<!-- 视频预览 -->
<template v-else-if="attachment.type === 'video'">
<div class="preview-video">
<img
v-if="attachment.thumbnail"
:src="attachment.thumbnail"
:alt="attachment.name"
/>
<div v-else class="video-placeholder">
<Video :size="24" />
</div>
<div class="video-badge">
<Play :size="12" />
</div>
</div>
</template>
<!-- 文件预览 -->
<template v-else>
<div class="preview-file">
<span class="file-emoji">{{ getFileEmoji(attachment.mimeType) }}</span>
<div class="file-details">
<span class="file-name">{{ truncateName(attachment.name) }}</span>
<span class="file-size">{{ formatSize(attachment.size) }}</span>
</div>
</div>
</template>
<!-- 删除按钮 -->
<button
class="remove-btn"
@click="$emit('remove', attachment.id)"
>
<X :size="14" />
</button>
<!-- 上传进度 -->
<div v-if="attachment.uploading" class="upload-progress">
<div
class="progress-bar"
:style="{ width: `${attachment.progress || 0}%` }"
/>
</div>
</div>
</TransitionGroup>
</div>
</template>
<script setup lang="ts">
import { X, Video, Play } from '@/components/icons'
import { formatFileSize, getFileIcon, truncateText } from '@/utils/helpers'
interface AttachmentWithProgress {
id: string
name: string
type: 'image' | 'file' | 'video'
url: string
size?: number
mimeType?: string
thumbnail?: string
uploading?: boolean
progress?: number
}
defineProps<{
attachments: AttachmentWithProgress[]
}>()
defineEmits<{
remove: [id: string]
}>()
function getFileEmoji(mimeType?: string) {
return getFileIcon(mimeType || '')
}
function formatSize(size?: number) {
return size ? formatFileSize(size) : ''
}
function truncateName(name: string) {
return truncateText(name, 20)
}
</script>
<style lang="scss" scoped>
.attachment-preview {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid #e2e8f0;
.dark & {
border-bottom-color: #374151;
}
}
.attachment-item {
position: relative;
border-radius: 12px;
overflow: hidden;
background: #f3f4f6;
.dark & {
background: #374151;
}
&.image,
&.video {
width: 80px;
height: 80px;
}
&.file {
padding: 10px 40px 10px 12px;
}
}
.preview-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-video {
position: relative;
width: 100%;
height: 100%;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #e5e7eb;
color: #9ca3af;
.dark & {
background: #4b5563;
}
}
.video-badge {
position: absolute;
bottom: 6px;
left: 6px;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
background: rgba(0, 0, 0, 0.7);
border-radius: 50%;
color: white;
}
}
.preview-file {
display: flex;
align-items: center;
gap: 10px;
}
.file-emoji {
font-size: 24px;
}
.file-details {
display: flex;
flex-direction: column;
}
.file-name {
font-size: 13px;
font-weight: 500;
color: #374151;
.dark & {
color: #e5e7eb;
}
}
.file-size {
font-size: 11px;
color: #9ca3af;
}
.remove-btn {
position: absolute;
top: 4px;
right: 4px;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
color: white;
cursor: pointer;
opacity: 0;
transition: all 0.2s ease;
.attachment-item:hover & {
opacity: 1;
}
&:hover {
background: rgba(239, 68, 68, 0.9);
}
}
.upload-progress {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: rgba(0, 0, 0, 0.2);
.progress-bar {
height: 100%;
background: #3b82f6;
transition: width 0.3s ease;
}
}
//
.attachment-enter-active,
.attachment-leave-active {
transition: all 0.3s ease;
}
.attachment-enter-from {
opacity: 0;
transform: scale(0.8);
}
.attachment-leave-to {
opacity: 0;
transform: scale(0.8);
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -1,212 +1,212 @@
<template>
<div class="code-block" :class="{ 'is-expanded': isExpanded }">
<!-- 代码块头部 -->
<div class="code-header">
<div class="code-language">
<Code :size="14" />
<span>{{ language || 'code' }}</span>
</div>
<div class="code-actions">
<button
v-if="canExpand"
class="action-btn"
:title="isExpanded ? '收起' : '展开'"
@click="toggleExpand"
>
<Maximize2 v-if="!isExpanded" :size="14" />
<Minimize2 v-else :size="14" />
</button>
<button
class="action-btn"
:class="{ copied: isCopied }"
title="复制代码"
@click="handleCopy"
>
<Check v-if="isCopied" :size="14" />
<Copy v-else :size="14" />
<span v-if="isCopied">已复制</span>
</button>
</div>
</div>
<!-- 代码内容 -->
<div class="code-content">
<pre><code :class="`language-${language}`">{{ code }}</code></pre>
</div>
<!-- 行号可选 -->
<div v-if="showLineNumbers" class="line-numbers">
<span v-for="n in lineCount" :key="n">{{ n }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Code, Copy, Check, Maximize2, Minimize2 } from '@/components/icons'
import { copyToClipboard } from '@/utils/helpers'
const props = withDefaults(defineProps<{
code: string
language?: string
showLineNumbers?: boolean
maxHeight?: number
}>(), {
language: 'plaintext',
showLineNumbers: true,
maxHeight: 400,
})
const emit = defineEmits<{
copy: []
}>()
const isCopied = ref(false)
const isExpanded = ref(false)
const lineCount = computed(() => {
return props.code.split('\n').length
})
const canExpand = computed(() => {
return lineCount.value > 15
})
async function handleCopy() {
const success = await copyToClipboard(props.code)
if (success) {
isCopied.value = true
emit('copy')
setTimeout(() => {
isCopied.value = false
}, 2000)
}
}
function toggleExpand() {
isExpanded.value = !isExpanded.value
}
</script>
<style lang="scss" scoped>
.code-block {
position: relative;
margin: 12px 0;
border-radius: 12px;
overflow: hidden;
background: #1e1e2e;
border: 1px solid #2d2d3d;
&.is-expanded {
.code-content {
max-height: none;
}
}
}
.code-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: #181825;
border-bottom: 1px solid #2d2d3d;
}
.code-language {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 500;
color: #a6adc8;
svg {
opacity: 0.7;
}
}
.code-actions {
display: flex;
align-items: center;
gap: 6px;
}
.action-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border: none;
border-radius: 6px;
background: rgba(255, 255, 255, 0.05);
color: #a6adc8;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.1);
color: #cdd6f4;
}
&.copied {
background: rgba(166, 227, 161, 0.2);
color: #a6e3a1;
}
}
.code-content {
max-height: v-bind('maxHeight + "px"');
overflow: auto;
pre {
margin: 0;
padding: 16px;
overflow-x: auto;
code {
font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', monospace;
font-size: 13px;
line-height: 1.6;
color: #cdd6f4;
tab-size: 2;
}
}
}
.line-numbers {
position: absolute;
left: 0;
top: 49px;
bottom: 0;
width: 50px;
padding: 16px 0;
display: flex;
flex-direction: column;
align-items: flex-end;
padding-right: 12px;
background: rgba(0, 0, 0, 0.2);
border-right: 1px solid #2d2d3d;
user-select: none;
pointer-events: none;
span {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
line-height: 1.6;
color: #585b70;
}
}
:deep(.code-content) {
.keyword { color: #cba6f7; }
.string { color: #a6e3a1; }
.number { color: #fab387; }
.comment { color: #6c7086; font-style: italic; }
.function { color: #89b4fa; }
.operator { color: #89dceb; }
.punctuation { color: #9399b2; }
.class-name { color: #f9e2af; }
}
<template>
<div class="code-block" :class="{ 'is-expanded': isExpanded }">
<!-- 代码块头部 -->
<div class="code-header">
<div class="code-language">
<Code :size="14" />
<span>{{ language || 'code' }}</span>
</div>
<div class="code-actions">
<button
v-if="canExpand"
class="action-btn"
:title="isExpanded ? '收起' : '展开'"
@click="toggleExpand"
>
<Maximize2 v-if="!isExpanded" :size="14" />
<Minimize2 v-else :size="14" />
</button>
<button
class="action-btn"
:class="{ copied: isCopied }"
title="复制代码"
@click="handleCopy"
>
<Check v-if="isCopied" :size="14" />
<Copy v-else :size="14" />
<span v-if="isCopied">已复制</span>
</button>
</div>
</div>
<!-- 代码内容 -->
<div class="code-content">
<pre><code :class="`language-${language}`">{{ code }}</code></pre>
</div>
<!-- 行号可选 -->
<div v-if="showLineNumbers" class="line-numbers">
<span v-for="n in lineCount" :key="n">{{ n }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Code, Copy, Check, Maximize2, Minimize2 } from '@/components/icons'
import { copyToClipboard } from '@/utils/helpers'
const props = withDefaults(defineProps<{
code: string
language?: string
showLineNumbers?: boolean
maxHeight?: number
}>(), {
language: 'plaintext',
showLineNumbers: true,
maxHeight: 400,
})
const emit = defineEmits<{
copy: []
}>()
const isCopied = ref(false)
const isExpanded = ref(false)
const lineCount = computed(() => {
return props.code.split('\n').length
})
const canExpand = computed(() => {
return lineCount.value > 15
})
async function handleCopy() {
const success = await copyToClipboard(props.code)
if (success) {
isCopied.value = true
emit('copy')
setTimeout(() => {
isCopied.value = false
}, 2000)
}
}
function toggleExpand() {
isExpanded.value = !isExpanded.value
}
</script>
<style lang="scss" scoped>
.code-block {
position: relative;
margin: 12px 0;
border-radius: 12px;
overflow: hidden;
background: #1e1e2e;
border: 1px solid #2d2d3d;
&.is-expanded {
.code-content {
max-height: none;
}
}
}
.code-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: #181825;
border-bottom: 1px solid #2d2d3d;
}
.code-language {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 500;
color: #a6adc8;
svg {
opacity: 0.7;
}
}
.code-actions {
display: flex;
align-items: center;
gap: 6px;
}
.action-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border: none;
border-radius: 6px;
background: rgba(255, 255, 255, 0.05);
color: #a6adc8;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.1);
color: #cdd6f4;
}
&.copied {
background: rgba(166, 227, 161, 0.2);
color: #a6e3a1;
}
}
.code-content {
max-height: v-bind('maxHeight + "px"');
overflow: auto;
pre {
margin: 0;
padding: 16px;
overflow-x: auto;
code {
font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', monospace;
font-size: 13px;
line-height: 1.6;
color: #cdd6f4;
tab-size: 2;
}
}
}
.line-numbers {
position: absolute;
left: 0;
top: 49px;
bottom: 0;
width: 50px;
padding: 16px 0;
display: flex;
flex-direction: column;
align-items: flex-end;
padding-right: 12px;
background: rgba(0, 0, 0, 0.2);
border-right: 1px solid #2d2d3d;
user-select: none;
pointer-events: none;
span {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
line-height: 1.6;
color: #585b70;
}
}
:deep(.code-content) {
.keyword { color: #cba6f7; }
.string { color: #a6e3a1; }
.number { color: #fab387; }
.comment { color: #6c7086; font-style: italic; }
.function { color: #89b4fa; }
.operator { color: #89dceb; }
.punctuation { color: #9399b2; }
.class-name { color: #f9e2af; }
}
</style>

View File

@ -1,320 +1,320 @@
<template>
<div class="message-actions" :class="{ visible: alwaysVisible || isHovered }">
<!-- 复制按钮 -->
<button
v-if="!isBreak"
class="action-btn"
:class="{ success: copied }"
title="复制内容"
@click="handleCopy"
>
<Check v-if="copied" :size="15" />
<Copy v-else :size="15" />
</button>
<!-- 点赞按钮 -->
<button
v-if="isNew && !isBreak"
class="action-btn"
:class="{ active: feedback?.liked }"
title="有帮助"
@click="handleLike"
>
<ThumbsUp :size="15" />
</button>
<!-- 点踩按钮 -->
<button
v-if="isNew && !isBreak"
class="action-btn"
:class="{ active: feedback?.disliked }"
title="没帮助"
@click="handleDislike"
>
<ThumbsDown :size="15" />
</button>
<!-- 重新生成仅AI消息 -->
<button
v-if="(showRegenerate && isNew) || isBreak"
class="action-btn"
title="重新生成"
@click="handleRegenerate"
>
<RefreshCw :size="15" />
</button>
<!-- 更多操作 -->
<div class="more-menu" v-if="showMore">
<button class="action-btn" title="更多" @click="toggleMoreMenu">
<MoreHorizontal :size="15" />
</button>
<Transition name="dropdown">
<div v-if="showMoreMenu" class="dropdown-menu">
<button
v-if="isNew && !isBreak"
class="dropdown-item"
@click="handleEdit"
>
<Edit3 :size="14" />
<span>编辑</span>
</button>
<button v-if="!isBreak" class="dropdown-item" @click="handleShare">
<ExternalLink :size="14" />
<span>分享</span>
</button>
<button class="dropdown-item danger" @click="handleDelete">
<Trash2 :size="14" />
<span>删除</span>
</button>
</div>
</Transition>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import {
Copy,
Check,
ThumbsUp,
ThumbsDown,
RefreshCw,
MoreHorizontal,
Edit3,
ExternalLink,
Trash2,
} from "@/components/icons";
import { copyToClipboard } from "@/utils/helpers";
import type { MessageFeedback } from "@/types/chat";
const props = withDefaults(
defineProps<{
content: string;
feedback?: MessageFeedback;
showRegenerate?: boolean;
showMore?: boolean;
alwaysVisible?: boolean;
isHovered?: boolean;
isNew?: boolean;
isBreak?: boolean;
}>(),
{
showRegenerate: false,
showMore: true,
alwaysVisible: false,
isHovered: false,
},
);
const emit = defineEmits<{
copy: [];
like: [];
dislike: [];
regenerate: [];
edit: [];
share: [];
delete: [];
}>();
const copied = ref(false);
const showMoreMenu = ref(false);
async function handleCopy() {
const success = await copyToClipboard(props.content);
if (success) {
copied.value = true;
emit("copy");
setTimeout(() => {
copied.value = false;
}, 2000);
}
}
function handleLike() {
emit("like");
}
function handleDislike() {
emit("dislike");
}
function handleRegenerate() {
emit("regenerate");
}
function toggleMoreMenu() {
showMoreMenu.value = !showMoreMenu.value;
}
function handleEdit() {
showMoreMenu.value = false;
emit("edit");
}
function handleShare() {
showMoreMenu.value = false;
emit("share");
}
function handleDelete() {
showMoreMenu.value = false;
emit("delete");
}
//
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest(".more-menu")) {
showMoreMenu.value = false;
}
}
//
if (typeof window !== "undefined") {
document.addEventListener("click", handleClickOutside);
}
</script>
<style lang="scss" scoped>
.message-actions {
display: flex;
align-items: center;
gap: 4px;
padding: 4px;
border-radius: 10px;
background: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
opacity: 0;
transform: translateY(4px);
transition: all 0.2s ease;
pointer-events: none;
.dark & {
background: #2d2d3d;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
&.visible {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
}
.action-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.15s ease;
&:hover {
background: #f3f4f6;
color: #374151;
.dark & {
background: #374151;
color: #e5e7eb;
}
}
&.active {
color: #3b82f6;
&:hover {
background: rgba(59, 130, 246, 0.1);
}
}
&.success {
color: #10b981;
&:hover {
background: rgba(16, 185, 129, 0.1);
}
}
}
.more-menu {
position: relative;
}
.dropdown-menu {
position: absolute;
bottom: 100%;
right: 0;
margin-bottom: 8px;
min-width: 140px;
padding: 6px;
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 100;
.dark & {
background: #2d2d3d;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
}
.dropdown-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 12px;
border: none;
border-radius: 8px;
background: transparent;
color: #374151;
font-size: 13px;
text-align: left;
cursor: pointer;
transition: all 0.15s ease;
.dark & {
color: #e5e7eb;
}
&:hover {
background: #f3f4f6;
.dark & {
background: #374151;
}
}
&.danger {
color: #ef4444;
&:hover {
background: rgba(239, 68, 68, 0.1);
}
}
svg {
flex-shrink: 0;
}
}
//
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.2s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(8px) scale(0.95);
}
</style>
<template>
<div class="message-actions" :class="{ visible: alwaysVisible || isHovered }">
<!-- 复制按钮 -->
<button
v-if="!isBreak"
class="action-btn"
:class="{ success: copied }"
title="复制内容"
@click="handleCopy"
>
<Check v-if="copied" :size="15" />
<Copy v-else :size="15" />
</button>
<!-- 点赞按钮 -->
<button
v-if="isNew && !isBreak"
class="action-btn"
:class="{ active: feedback?.liked }"
title="有帮助"
@click="handleLike"
>
<ThumbsUp :size="15" />
</button>
<!-- 点踩按钮 -->
<button
v-if="isNew && !isBreak"
class="action-btn"
:class="{ active: feedback?.disliked }"
title="没帮助"
@click="handleDislike"
>
<ThumbsDown :size="15" />
</button>
<!-- 重新生成仅AI消息 -->
<button
v-if="(showRegenerate && isNew) || isBreak"
class="action-btn"
title="重新生成"
@click="handleRegenerate"
>
<RefreshCw :size="15" />
</button>
<!-- 更多操作 -->
<div class="more-menu" v-if="showMore">
<button class="action-btn" title="更多" @click="toggleMoreMenu">
<MoreHorizontal :size="15" />
</button>
<Transition name="dropdown">
<div v-if="showMoreMenu" class="dropdown-menu">
<button
v-if="isNew && !isBreak"
class="dropdown-item"
@click="handleEdit"
>
<Edit3 :size="14" />
<span>编辑</span>
</button>
<button v-if="!isBreak" class="dropdown-item" @click="handleShare">
<ExternalLink :size="14" />
<span>分享</span>
</button>
<button class="dropdown-item danger" @click="handleDelete">
<Trash2 :size="14" />
<span>删除</span>
</button>
</div>
</Transition>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import {
Copy,
Check,
ThumbsUp,
ThumbsDown,
RefreshCw,
MoreHorizontal,
Edit3,
ExternalLink,
Trash2,
} from "@/components/icons";
import { copyToClipboard } from "@/utils/helpers";
import type { MessageFeedback } from "@/types/chat";
const props = withDefaults(
defineProps<{
content: string;
feedback?: MessageFeedback;
showRegenerate?: boolean;
showMore?: boolean;
alwaysVisible?: boolean;
isHovered?: boolean;
isNew?: boolean;
isBreak?: boolean;
}>(),
{
showRegenerate: false,
showMore: true,
alwaysVisible: false,
isHovered: false,
},
);
const emit = defineEmits<{
copy: [];
like: [];
dislike: [];
regenerate: [];
edit: [];
share: [];
delete: [];
}>();
const copied = ref(false);
const showMoreMenu = ref(false);
async function handleCopy() {
const success = await copyToClipboard(props.content);
if (success) {
copied.value = true;
emit("copy");
setTimeout(() => {
copied.value = false;
}, 2000);
}
}
function handleLike() {
emit("like");
}
function handleDislike() {
emit("dislike");
}
function handleRegenerate() {
emit("regenerate");
}
function toggleMoreMenu() {
showMoreMenu.value = !showMoreMenu.value;
}
function handleEdit() {
showMoreMenu.value = false;
emit("edit");
}
function handleShare() {
showMoreMenu.value = false;
emit("share");
}
function handleDelete() {
showMoreMenu.value = false;
emit("delete");
}
//
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest(".more-menu")) {
showMoreMenu.value = false;
}
}
//
if (typeof window !== "undefined") {
document.addEventListener("click", handleClickOutside);
}
</script>
<style lang="scss" scoped>
.message-actions {
display: flex;
align-items: center;
gap: 4px;
padding: 4px;
border-radius: 10px;
background: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
opacity: 0;
transform: translateY(4px);
transition: all 0.2s ease;
pointer-events: none;
.dark & {
background: #2d2d3d;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
&.visible {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
}
.action-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.15s ease;
&:hover {
background: #f3f4f6;
color: #374151;
.dark & {
background: #374151;
color: #e5e7eb;
}
}
&.active {
color: #3b82f6;
&:hover {
background: rgba(59, 130, 246, 0.1);
}
}
&.success {
color: #10b981;
&:hover {
background: rgba(16, 185, 129, 0.1);
}
}
}
.more-menu {
position: relative;
}
.dropdown-menu {
position: absolute;
bottom: 100%;
right: 0;
margin-bottom: 8px;
min-width: 140px;
padding: 6px;
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 100;
.dark & {
background: #2d2d3d;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
}
.dropdown-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 12px;
border: none;
border-radius: 8px;
background: transparent;
color: #374151;
font-size: 13px;
text-align: left;
cursor: pointer;
transition: all 0.15s ease;
.dark & {
color: #e5e7eb;
}
&:hover {
background: #f3f4f6;
.dark & {
background: #374151;
}
}
&.danger {
color: #ef4444;
&:hover {
background: rgba(239, 68, 68, 0.1);
}
}
svg {
flex-shrink: 0;
}
}
//
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.2s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(8px) scale(0.95);
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -1,90 +1,90 @@
<script setup lang="ts">
import * as echarts from "echarts";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
//@ts-ignore
import Loading from "./Loading.vue";
interface Props {
node: {
type: "vmr_container";
name: string;
children?: Array<{ type: string; raw: string }>;
};
isDark?: boolean;
}
const isLoading = ref(false);
const props = defineProps<Props>();
// echarts
const isEChartsContainer = computed(() => props.node.name === "echarts");
const chartRef = ref<HTMLElement>();
let chartInstance: echarts.ECharts | null = null;
// JSON
const chartOption = computed(() => {
if (!props.node.children || props.node.children.length === 0) {
return null;
}
const code = props.node.children[0].raw;
try {
return JSON.parse(code);
} catch {
return null;
}
});
function initChart() {
isLoading.value = true;
if (!isEChartsContainer.value || !chartRef.value || !chartOption.value)
return;
if (chartInstance) {
chartInstance.dispose();
}
const theme = props.isDark ? "dark" : undefined;
isLoading.value = false;
chartInstance = echarts.init(chartRef.value, theme);
chartInstance.setOption(chartOption.value, true);
}
watch(() => props.isDark, initChart);
watch(chartOption, (option) => {
if (chartInstance && option) {
chartInstance.setOption(option, true);
} else if (option) {
initChart();
}
});
onMounted(initChart);
onBeforeUnmount(() => {
chartInstance?.dispose();
});
</script>
<template>
<div v-if="isEChartsContainer" class="vmr-container vmr-container-echarts">
<div ref="chartRef" style="width: 100%; height: 400px" />
<Loading :loading="isLoading" text="正在渲染数据..." />
<slot v-if="!chartOption" />
</div>
<div v-else class="vmr-container" :class="`vmr-container-${node.name}`">
<slot />
</div>
</template>
<style scoped>
.vmr-container-echarts {
padding: 1rem;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
margin: 1rem 0;
}
.dark .vmr-container-echarts {
border-color: #374151;
}
</style>
<script setup lang="ts">
import * as echarts from "echarts";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
//@ts-ignore
import Loading from "./Loading.vue";
interface Props {
node: {
type: "vmr_container";
name: string;
children?: Array<{ type: string; raw: string }>;
};
isDark?: boolean;
}
const isLoading = ref(false);
const props = defineProps<Props>();
// echarts
const isEChartsContainer = computed(() => props.node.name === "echarts");
const chartRef = ref<HTMLElement>();
let chartInstance: echarts.ECharts | null = null;
// JSON
const chartOption = computed(() => {
if (!props.node.children || props.node.children.length === 0) {
return null;
}
const code = props.node.children[0].raw;
try {
return JSON.parse(code);
} catch {
return null;
}
});
function initChart() {
isLoading.value = true;
if (!isEChartsContainer.value || !chartRef.value || !chartOption.value)
return;
if (chartInstance) {
chartInstance.dispose();
}
const theme = props.isDark ? "dark" : undefined;
isLoading.value = false;
chartInstance = echarts.init(chartRef.value, theme);
chartInstance.setOption(chartOption.value, true);
}
watch(() => props.isDark, initChart);
watch(chartOption, (option) => {
if (chartInstance && option) {
chartInstance.setOption(option, true);
} else if (option) {
initChart();
}
});
onMounted(initChart);
onBeforeUnmount(() => {
chartInstance?.dispose();
});
</script>
<template>
<div v-if="isEChartsContainer" class="vmr-container vmr-container-echarts">
<div ref="chartRef" style="width: 100%; height: 400px" />
<Loading :loading="isLoading" text="正在渲染数据..." />
<slot v-if="!chartOption" />
</div>
<div v-else class="vmr-container" :class="`vmr-container-${node.name}`">
<slot />
</div>
</template>
<style scoped>
.vmr-container-echarts {
padding: 1rem;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
margin: 1rem 0;
}
.dark .vmr-container-echarts {
border-color: #374151;
}
</style>

View File

@ -1,70 +1,70 @@
<template>
<div v-if="loading" class="loading-overlay">
<div class="loading-spinner">
<div class="spinner-box">
<div class="spinner"></div>
</div>
<p v-if="text" class="loading-text">{{ text }}</p>
</div>
</div>
</template>
<script setup>
import { watch } from "vue";
const props = defineProps({
loading: {
type: Boolean,
default: false,
},
text: {
type: String,
default: "加载中...",
},
});
</script>
<style scoped>
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
backdrop-filter: blur(2px);
}
.spinner-box {
display: flex;
justify-content: center;
}
.spinner {
text-align: center;
width: 50px;
height: 50px;
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.loading-text {
padding-top: 15px;
color: #666;
font-size: 14px;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
<template>
<div v-if="loading" class="loading-overlay">
<div class="loading-spinner">
<div class="spinner-box">
<div class="spinner"></div>
</div>
<p v-if="text" class="loading-text">{{ text }}</p>
</div>
</div>
</template>
<script setup>
import { watch } from "vue";
const props = defineProps({
loading: {
type: Boolean,
default: false,
},
text: {
type: String,
default: "加载中...",
},
});
</script>
<style scoped>
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
backdrop-filter: blur(2px);
}
.spinner-box {
display: flex;
justify-content: center;
}
.spinner {
text-align: center;
width: 50px;
height: 50px;
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.loading-text {
padding-top: 15px;
color: #666;
font-size: 14px;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@ -1,182 +1,182 @@
<script setup lang="ts">
import { MarkdownRender } from "markstream-vue";
import { useClipboard } from "@vueuse/core";
defineProps<{
node: {
type: "think";
content: string;
children: any[];
loading?: boolean;
};
}>();
const { copy } = useClipboard({ legacy: true });
async function textCopy(data: any) {
if (typeof data === "string") {
copy(data);
}
}
</script>
<template>
<div
class="thinking-node p-4 my-4 bg-blue-50 dark:bg-blue-900/40 rounded-md border-l-4 border-blue-400 flex items-start gap-3"
>
<div class="flex-shrink-0 mt-1">
<!-- decorative thinking SVG icon -->
<div
class="w-9 h-9 rounded-full bg-blue-200 dark:bg-blue-700 flex items-center justify-center text-blue-700 dark:text-blue-100"
>
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M12 3C7.03 3 3 6.58 3 11c0 1.86.66 3.57 1.77 4.98L4 21l5.2-1.9C10.06 19.35 11 19.5 12 19.5c4.97 0 9-3.58 9-8.5S16.97 3 12 3z"
stroke="currentColor"
stroke-width="0.8"
fill="currentColor"
opacity="0.9"
/>
</svg>
</div>
</div>
<div class="flex-1">
<div class="flex items-baseline gap-3">
<strong class="text-sm">Thinking</strong>
<span class="text-xs text-slate-500 dark:text-slate-300"
>(assistant)</span
>
<!-- keep dots in DOM to avoid layout shift; toggle visibility with classes -->
<span class="ml-2" aria-hidden="true">
<span
class="thinking-dots"
:class="[node.loading ? 'visible' : 'hidden']"
aria-hidden="true"
>
<span class="dot dot-1" />
<span class="dot dot-2" />
<span class="dot dot-3" />
</span>
</span>
</div>
<div
class="mt-1 text-sm leading-relaxed text-slate-800 dark:text-slate-100"
>
<!-- sr-only live region only present when loading to announce change -->
<span v-if="node.loading" class="sr-only" aria-live="polite"
>Thinking</span
>
<transition name="fade" mode="out-in">
<div
key="{{ node.loading ? 'loading' : 'ready' }}"
class="content-area"
>
<MarkdownRender :content="node.content" @copy="textCopy" />
</div>
</transition>
</div>
</div>
</div>
</template>
<style scoped>
.thinking-node {
color: #0f172a;
}
.dark .thinking-node {
color: #e6f0ff;
}
/* Animated dots for thinking state */
.thinking-dots {
display: inline-flex;
align-items: center;
gap: 6px;
width: 36px;
justify-content: flex-start;
height: 12px; /* reserve vertical space so toggling doesn't collapse layout */
transition:
opacity 160ms linear,
transform 160ms linear;
opacity: 0;
}
.thinking-dots .dot {
width: 6px;
height: 6px;
border-radius: 9999px;
background: #1e3a8a; /* blue-800 */
opacity: 0.25;
transform: translateY(0);
}
.thinking-dots.visible {
opacity: 1;
}
.thinking-dots.hidden {
opacity: 0;
transform: translateY(0);
}
.thinking-dots.visible .dot-1 {
animation: think-bounce 1s infinite ease-in-out;
animation-delay: 0s;
}
.thinking-dots.visible .dot-2 {
animation: think-bounce 1s infinite ease-in-out;
animation-delay: 0.12s;
}
.thinking-dots.visible .dot-3 {
animation: think-bounce 1s infinite ease-in-out;
animation-delay: 0.24s;
}
.dark .thinking-dots .dot {
background: #bfdbfe;
opacity: 0.28;
}
@keyframes think-bounce {
0%,
80%,
100% {
transform: translateY(0);
opacity: 0.25;
}
40% {
transform: translateY(-6px);
opacity: 1;
}
}
/* ensure content area doesn't shift when dots appear */
.content-area {
min-height: 1.25rem;
}
.partial-content,
.full-content {
transition: opacity 140ms ease;
}
.partial-content {
opacity: 0.9;
}
.full-content {
opacity: 1;
}
/* Vue transition classes for fade */
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 160ms ease;
}
.fade-enter-to,
.fade-leave-from {
opacity: 1;
}
</style>
<script setup lang="ts">
import { MarkdownRender } from "markstream-vue";
import { useClipboard } from "@vueuse/core";
defineProps<{
node: {
type: "think";
content: string;
children: any[];
loading?: boolean;
};
}>();
const { copy } = useClipboard({ legacy: true });
async function textCopy(data: any) {
if (typeof data === "string") {
copy(data);
}
}
</script>
<template>
<div
class="thinking-node p-4 my-4 bg-blue-50 dark:bg-blue-900/40 rounded-md border-l-4 border-blue-400 flex items-start gap-3"
>
<div class="flex-shrink-0 mt-1">
<!-- decorative thinking SVG icon -->
<div
class="w-9 h-9 rounded-full bg-blue-200 dark:bg-blue-700 flex items-center justify-center text-blue-700 dark:text-blue-100"
>
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M12 3C7.03 3 3 6.58 3 11c0 1.86.66 3.57 1.77 4.98L4 21l5.2-1.9C10.06 19.35 11 19.5 12 19.5c4.97 0 9-3.58 9-8.5S16.97 3 12 3z"
stroke="currentColor"
stroke-width="0.8"
fill="currentColor"
opacity="0.9"
/>
</svg>
</div>
</div>
<div class="flex-1">
<div class="flex items-baseline gap-3">
<strong class="text-sm">Thinking</strong>
<span class="text-xs text-slate-500 dark:text-slate-300"
>(assistant)</span
>
<!-- keep dots in DOM to avoid layout shift; toggle visibility with classes -->
<span class="ml-2" aria-hidden="true">
<span
class="thinking-dots"
:class="[node.loading ? 'visible' : 'hidden']"
aria-hidden="true"
>
<span class="dot dot-1" />
<span class="dot dot-2" />
<span class="dot dot-3" />
</span>
</span>
</div>
<div
class="mt-1 text-sm leading-relaxed text-slate-800 dark:text-slate-100"
>
<!-- sr-only live region only present when loading to announce change -->
<span v-if="node.loading" class="sr-only" aria-live="polite"
>Thinking</span
>
<transition name="fade" mode="out-in">
<div
key="{{ node.loading ? 'loading' : 'ready' }}"
class="content-area"
>
<MarkdownRender :content="node.content" @copy="textCopy" />
</div>
</transition>
</div>
</div>
</div>
</template>
<style scoped>
.thinking-node {
color: #0f172a;
}
.dark .thinking-node {
color: #e6f0ff;
}
/* Animated dots for thinking state */
.thinking-dots {
display: inline-flex;
align-items: center;
gap: 6px;
width: 36px;
justify-content: flex-start;
height: 12px; /* reserve vertical space so toggling doesn't collapse layout */
transition:
opacity 160ms linear,
transform 160ms linear;
opacity: 0;
}
.thinking-dots .dot {
width: 6px;
height: 6px;
border-radius: 9999px;
background: #1e3a8a; /* blue-800 */
opacity: 0.25;
transform: translateY(0);
}
.thinking-dots.visible {
opacity: 1;
}
.thinking-dots.hidden {
opacity: 0;
transform: translateY(0);
}
.thinking-dots.visible .dot-1 {
animation: think-bounce 1s infinite ease-in-out;
animation-delay: 0s;
}
.thinking-dots.visible .dot-2 {
animation: think-bounce 1s infinite ease-in-out;
animation-delay: 0.12s;
}
.thinking-dots.visible .dot-3 {
animation: think-bounce 1s infinite ease-in-out;
animation-delay: 0.24s;
}
.dark .thinking-dots .dot {
background: #bfdbfe;
opacity: 0.28;
}
@keyframes think-bounce {
0%,
80%,
100% {
transform: translateY(0);
opacity: 0.25;
}
40% {
transform: translateY(-6px);
opacity: 1;
}
}
/* ensure content area doesn't shift when dots appear */
.content-area {
min-height: 1.25rem;
}
.partial-content,
.full-content {
transition: opacity 140ms ease;
}
.partial-content {
opacity: 0.9;
}
.full-content {
opacity: 1;
}
/* Vue transition classes for fade */
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 160ms ease;
}
.fade-enter-to,
.fade-leave-from {
opacity: 1;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -1,369 +1,369 @@
<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="visible" class="modal-overlay" @click.self="close">
<div class="search-modal">
<!-- 搜索输入 -->
<div class="search-header">
<Search :size="20" class="search-icon" />
<input
ref="inputRef"
v-model="searchQuery"
type="text"
class="search-input"
placeholder="搜索对话..."
@keydown.escape="close"
@keydown.down.prevent="navigateDown"
@keydown.up.prevent="navigateUp"
@keydown.enter="selectCurrent"
/>
<kbd class="esc-hint">ESC</kbd>
</div>
<!-- 搜索结果 -->
<div class="search-results">
<div v-if="filteredConversations.length === 0" class="no-results">
<FolderOpen :size="40" class="no-results-icon" />
<p>没有找到相关对话</p>
</div>
<div
v-for="(conv, index) in filteredConversations"
:key="conv.id"
class="result-item"
:class="{ active: index === selectedIndex }"
@click="selectConversation(conv.id)"
@mouseenter="selectedIndex = index"
>
<MessageSquare :size="18" class="result-icon" />
<div class="result-content">
<div class="result-title">{{ conv.title }}</div>
<div class="result-meta">
<span>{{ conv.messages.length }} 条消息</span>
<span class="dot">·</span>
<span>{{ formatTime(conv.updatedAt) }}</span>
</div>
</div>
<Pin v-if="conv.pinned" :size="14" class="pin-icon" />
</div>
</div>
<!-- 底部提示 -->
<div class="search-footer">
<div class="hint">
<kbd></kbd>
<span>导航</span>
</div>
<div class="hint">
<kbd></kbd>
<span>选择</span>
</div>
<div class="hint">
<kbd>ESC</kbd>
<span>关闭</span>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { storeToRefs } from 'pinia'
import { useChatStore } from '@/stores/chat'
import { useSettingsStore } from '@/stores/settings'
import { Search, MessageSquare, FolderOpen, Pin } from '@/components/icons'
import { formatTimestamp } from '@/utils/helpers'
const chatStore = useChatStore()
const settingsStore = useSettingsStore()
const { conversations } = storeToRefs(chatStore)
const { showSearchModal: visible } = storeToRefs(settingsStore)
const searchQuery = ref('')
const selectedIndex = ref(0)
const inputRef = ref<HTMLInputElement | null>(null)
const filteredConversations = computed(() => {
if (!searchQuery.value.trim()) {
return conversations.value.slice(0, 10)
}
const query = searchQuery.value.toLowerCase()
return conversations.value.filter(conv => {
//
if (conv.title.toLowerCase().includes(query)) return true
//
return conv.messages.some(msg =>
msg.content.text?.toLowerCase().includes(query)
)
}).slice(0, 10)
})
function formatTime(timestamp: number) {
return formatTimestamp(timestamp)
}
function close() {
settingsStore.closeSearchModal()
searchQuery.value = ''
selectedIndex.value = 0
}
function navigateDown() {
if (selectedIndex.value < filteredConversations.value.length - 1) {
selectedIndex.value++
}
}
function navigateUp() {
if (selectedIndex.value > 0) {
selectedIndex.value--
}
}
function selectCurrent() {
const conv = filteredConversations.value[selectedIndex.value]
if (conv) {
selectConversation(conv.id)
}
}
function selectConversation(id: string) {
chatStore.selectConversation(id)
close()
}
//
watch(visible, (val) => {
if (val) {
nextTick(() => {
inputRef.value?.focus()
})
}
})
//
watch(searchQuery, () => {
selectedIndex.value = 0
})
</script>
<style lang="scss" scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 100px;
z-index: 1000;
backdrop-filter: blur(4px);
}
.search-modal {
width: 560px;
max-height: 480px;
background: white;
border-radius: 16px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
overflow: hidden;
display: flex;
flex-direction: column;
.dark & {
background: #1e1e2e;
}
}
.search-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid #e2e8f0;
.dark & {
border-bottom-color: #2d2d3d;
}
}
.search-icon {
color: #9ca3af;
flex-shrink: 0;
}
.search-input {
flex: 1;
border: none;
outline: none;
font-size: 16px;
color: #1f2937;
background: transparent;
.dark & {
color: #f3f4f6;
}
&::placeholder {
color: #9ca3af;
}
}
.esc-hint {
font-size: 11px;
padding: 4px 8px;
border-radius: 4px;
background: #f3f4f6;
color: #6b7280;
.dark & {
background: #374151;
color: #9ca3af;
}
}
.search-results {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.no-results {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
color: #9ca3af;
.no-results-icon {
margin-bottom: 12px;
opacity: 0.5;
}
p {
margin: 0;
font-size: 14px;
}
}
.result-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: 10px;
cursor: pointer;
transition: background 0.15s ease;
&:hover,
&.active {
background: #f3f4f6;
.dark & {
background: #2d2d3d;
}
}
}
.result-icon {
color: #6b7280;
flex-shrink: 0;
}
.result-content {
flex: 1;
min-width: 0;
}
.result-title {
font-size: 14px;
font-weight: 500;
color: #1f2937;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.dark & {
color: #f3f4f6;
}
}
.result-meta {
display: flex;
align-items: center;
gap: 6px;
margin-top: 2px;
font-size: 12px;
color: #9ca3af;
.dot {
opacity: 0.5;
}
}
.pin-icon {
color: #f59e0b;
flex-shrink: 0;
}
.search-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
padding: 12px 20px;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
.dark & {
border-top-color: #2d2d3d;
background: #181825;
}
}
.hint {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #9ca3af;
kbd {
padding: 2px 6px;
border-radius: 4px;
background: white;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
.dark & {
background: #374151;
}
}
}
//
.modal-enter-active,
.modal-leave-active {
transition: all 0.2s ease;
.search-modal {
transition: all 0.2s ease;
}
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
.search-modal {
transform: scale(0.95) translateY(-20px);
opacity: 0;
}
}
<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="visible" class="modal-overlay" @click.self="close">
<div class="search-modal">
<!-- 搜索输入 -->
<div class="search-header">
<Search :size="20" class="search-icon" />
<input
ref="inputRef"
v-model="searchQuery"
type="text"
class="search-input"
placeholder="搜索对话..."
@keydown.escape="close"
@keydown.down.prevent="navigateDown"
@keydown.up.prevent="navigateUp"
@keydown.enter="selectCurrent"
/>
<kbd class="esc-hint">ESC</kbd>
</div>
<!-- 搜索结果 -->
<div class="search-results">
<div v-if="filteredConversations.length === 0" class="no-results">
<FolderOpen :size="40" class="no-results-icon" />
<p>没有找到相关对话</p>
</div>
<div
v-for="(conv, index) in filteredConversations"
:key="conv.id"
class="result-item"
:class="{ active: index === selectedIndex }"
@click="selectConversation(conv.id)"
@mouseenter="selectedIndex = index"
>
<MessageSquare :size="18" class="result-icon" />
<div class="result-content">
<div class="result-title">{{ conv.title }}</div>
<div class="result-meta">
<span>{{ conv.messages.length }} 条消息</span>
<span class="dot">·</span>
<span>{{ formatTime(conv.updatedAt) }}</span>
</div>
</div>
<Pin v-if="conv.pinned" :size="14" class="pin-icon" />
</div>
</div>
<!-- 底部提示 -->
<div class="search-footer">
<div class="hint">
<kbd></kbd>
<span>导航</span>
</div>
<div class="hint">
<kbd></kbd>
<span>选择</span>
</div>
<div class="hint">
<kbd>ESC</kbd>
<span>关闭</span>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { storeToRefs } from 'pinia'
import { useChatStore } from '@/stores/chat'
import { useSettingsStore } from '@/stores/settings'
import { Search, MessageSquare, FolderOpen, Pin } from '@/components/icons'
import { formatTimestamp } from '@/utils/helpers'
const chatStore = useChatStore()
const settingsStore = useSettingsStore()
const { conversations } = storeToRefs(chatStore)
const { showSearchModal: visible } = storeToRefs(settingsStore)
const searchQuery = ref('')
const selectedIndex = ref(0)
const inputRef = ref<HTMLInputElement | null>(null)
const filteredConversations = computed(() => {
if (!searchQuery.value.trim()) {
return conversations.value.slice(0, 10)
}
const query = searchQuery.value.toLowerCase()
return conversations.value.filter(conv => {
//
if (conv.title.toLowerCase().includes(query)) return true
//
return conv.messages.some(msg =>
msg.content.text?.toLowerCase().includes(query)
)
}).slice(0, 10)
})
function formatTime(timestamp: number) {
return formatTimestamp(timestamp)
}
function close() {
settingsStore.closeSearchModal()
searchQuery.value = ''
selectedIndex.value = 0
}
function navigateDown() {
if (selectedIndex.value < filteredConversations.value.length - 1) {
selectedIndex.value++
}
}
function navigateUp() {
if (selectedIndex.value > 0) {
selectedIndex.value--
}
}
function selectCurrent() {
const conv = filteredConversations.value[selectedIndex.value]
if (conv) {
selectConversation(conv.id)
}
}
function selectConversation(id: string) {
chatStore.selectConversation(id)
close()
}
//
watch(visible, (val) => {
if (val) {
nextTick(() => {
inputRef.value?.focus()
})
}
})
//
watch(searchQuery, () => {
selectedIndex.value = 0
})
</script>
<style lang="scss" scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 100px;
z-index: 1000;
backdrop-filter: blur(4px);
}
.search-modal {
width: 560px;
max-height: 480px;
background: white;
border-radius: 16px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
overflow: hidden;
display: flex;
flex-direction: column;
.dark & {
background: #1e1e2e;
}
}
.search-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid #e2e8f0;
.dark & {
border-bottom-color: #2d2d3d;
}
}
.search-icon {
color: #9ca3af;
flex-shrink: 0;
}
.search-input {
flex: 1;
border: none;
outline: none;
font-size: 16px;
color: #1f2937;
background: transparent;
.dark & {
color: #f3f4f6;
}
&::placeholder {
color: #9ca3af;
}
}
.esc-hint {
font-size: 11px;
padding: 4px 8px;
border-radius: 4px;
background: #f3f4f6;
color: #6b7280;
.dark & {
background: #374151;
color: #9ca3af;
}
}
.search-results {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.no-results {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
color: #9ca3af;
.no-results-icon {
margin-bottom: 12px;
opacity: 0.5;
}
p {
margin: 0;
font-size: 14px;
}
}
.result-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: 10px;
cursor: pointer;
transition: background 0.15s ease;
&:hover,
&.active {
background: #f3f4f6;
.dark & {
background: #2d2d3d;
}
}
}
.result-icon {
color: #6b7280;
flex-shrink: 0;
}
.result-content {
flex: 1;
min-width: 0;
}
.result-title {
font-size: 14px;
font-weight: 500;
color: #1f2937;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.dark & {
color: #f3f4f6;
}
}
.result-meta {
display: flex;
align-items: center;
gap: 6px;
margin-top: 2px;
font-size: 12px;
color: #9ca3af;
.dot {
opacity: 0.5;
}
}
.pin-icon {
color: #f59e0b;
flex-shrink: 0;
}
.search-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
padding: 12px 20px;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
.dark & {
border-top-color: #2d2d3d;
background: #181825;
}
}
.hint {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #9ca3af;
kbd {
padding: 2px 6px;
border-radius: 4px;
background: white;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
.dark & {
background: #374151;
}
}
}
//
.modal-enter-active,
.modal-leave-active {
transition: all 0.2s ease;
.search-modal {
transition: all 0.2s ease;
}
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
.search-modal {
transform: scale(0.95) translateY(-20px);
opacity: 0;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -1,307 +1,307 @@
<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="visible" class="modal-overlay" @click.self="close">
<div class="shortcuts-modal">
<!-- 头部 -->
<div class="modal-header">
<div class="header-title">
<Keyboard :size="22" />
<h3>键盘快捷键</h3>
</div>
<button class="close-btn" @click="close">
<X :size="20" />
</button>
</div>
<!-- 快捷键列表 -->
<div class="shortcuts-content">
<div
v-for="group in shortcutGroups"
:key="group.title"
class="shortcut-group"
>
<h4 class="group-title">{{ group.title }}</h4>
<div class="shortcuts-list">
<div
v-for="shortcut in group.shortcuts"
:key="shortcut.description"
class="shortcut-item"
>
<span class="shortcut-desc">{{ shortcut.description }}</span>
<div class="shortcut-keys">
<kbd v-for="key in shortcut.keys" :key="key">{{ key }}</kbd>
</div>
</div>
</div>
</div>
</div>
<!-- 底部 -->
<div class="modal-footer">
<span class="tip"> <kbd>ESC</kbd> <kbd>?</kbd> 关闭此窗口</span>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useSettingsStore } from '@/stores/settings'
import { Keyboard, X } from '@/components/icons'
const settingsStore = useSettingsStore()
const { showShortcutsModal: visible } = storeToRefs(settingsStore)
const shortcutGroups = computed(() => [
{
title: '通用',
shortcuts: [
{ description: '新建对话', keys: ['⌘', 'N'] },
{ description: '搜索对话', keys: ['⌘', 'K'] },
{ description: '切换侧边栏', keys: ['⌘', 'B'] },
{ description: '切换主题', keys: ['⌘', '⇧', 'D'] },
{ description: '显示快捷键', keys: ['⌘', '?'] },
],
},
{
title: '对话',
shortcuts: [
{ description: '发送消息', keys: ['⌘', '↵'] },
{ description: '换行', keys: ['⇧', '↵'] },
{ description: '聚焦输入框', keys: ['⌘', '/'] },
{ description: '停止生成', keys: ['ESC'] },
],
},
{
title: '消息操作',
shortcuts: [
{ description: '复制消息', keys: ['⌘', 'C'] },
{ description: '重新生成', keys: ['⌘', 'R'] },
],
},
])
function close() {
settingsStore.closeShortcutsModal()
}
</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);
}
.shortcuts-modal {
width: 480px;
max-height: 80vh;
background: white;
border-radius: 16px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
overflow: hidden;
display: flex;
flex-direction: column;
.dark & {
background: #1e1e2e;
}
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid #e2e8f0;
.dark & {
border-bottom-color: #2d2d3d;
}
}
.header-title {
display: flex;
align-items: center;
gap: 12px;
svg {
color: #3b82f6;
}
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: 36px;
height: 36px;
border: none;
border-radius: 10px;
background: transparent;
color: #6b7280;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #f3f4f6;
color: #1f2937;
.dark & {
background: #374151;
color: #f3f4f6;
}
}
}
.shortcuts-content {
flex: 1;
overflow-y: auto;
padding: 16px 24px;
}
.shortcut-group {
&:not(:last-child) {
margin-bottom: 24px;
}
}
.group-title {
margin: 0 0 12px;
font-size: 12px;
font-weight: 600;
color: #9ca3af;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.shortcuts-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.shortcut-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-radius: 10px;
background: #f8fafc;
.dark & {
background: #2d2d3d;
}
}
.shortcut-desc {
font-size: 14px;
color: #374151;
.dark & {
color: #e5e7eb;
}
}
.shortcut-keys {
display: flex;
align-items: center;
gap: 4px;
kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 28px;
padding: 0 8px;
font-size: 12px;
font-weight: 500;
color: #374151;
background: white;
border: 1px solid #e2e8f0;
border-radius: 6px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
.dark & {
color: #e5e7eb;
background: #1e1e2e;
border-color: #4b5563;
}
}
}
.modal-footer {
padding: 16px 24px;
border-top: 1px solid #e2e8f0;
text-align: center;
.dark & {
border-top-color: #2d2d3d;
}
}
.tip {
font-size: 13px;
color: #9ca3af;
kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 22px;
padding: 0 6px;
margin: 0 2px;
font-size: 11px;
color: #6b7280;
background: #f3f4f6;
border-radius: 4px;
.dark & {
background: #374151;
color: #9ca3af;
}
}
}
//
.modal-enter-active,
.modal-leave-active {
transition: all 0.25s ease;
.shortcuts-modal {
transition: all 0.25s ease;
}
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
.shortcuts-modal {
transform: scale(0.9);
opacity: 0;
}
}
<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="visible" class="modal-overlay" @click.self="close">
<div class="shortcuts-modal">
<!-- 头部 -->
<div class="modal-header">
<div class="header-title">
<Keyboard :size="22" />
<h3>键盘快捷键</h3>
</div>
<button class="close-btn" @click="close">
<X :size="20" />
</button>
</div>
<!-- 快捷键列表 -->
<div class="shortcuts-content">
<div
v-for="group in shortcutGroups"
:key="group.title"
class="shortcut-group"
>
<h4 class="group-title">{{ group.title }}</h4>
<div class="shortcuts-list">
<div
v-for="shortcut in group.shortcuts"
:key="shortcut.description"
class="shortcut-item"
>
<span class="shortcut-desc">{{ shortcut.description }}</span>
<div class="shortcut-keys">
<kbd v-for="key in shortcut.keys" :key="key">{{ key }}</kbd>
</div>
</div>
</div>
</div>
</div>
<!-- 底部 -->
<div class="modal-footer">
<span class="tip"> <kbd>ESC</kbd> <kbd>?</kbd> 关闭此窗口</span>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useSettingsStore } from '@/stores/settings'
import { Keyboard, X } from '@/components/icons'
const settingsStore = useSettingsStore()
const { showShortcutsModal: visible } = storeToRefs(settingsStore)
const shortcutGroups = computed(() => [
{
title: '通用',
shortcuts: [
{ description: '新建对话', keys: ['⌘', 'N'] },
{ description: '搜索对话', keys: ['⌘', 'K'] },
{ description: '切换侧边栏', keys: ['⌘', 'B'] },
{ description: '切换主题', keys: ['⌘', '⇧', 'D'] },
{ description: '显示快捷键', keys: ['⌘', '?'] },
],
},
{
title: '对话',
shortcuts: [
{ description: '发送消息', keys: ['⌘', '↵'] },
{ description: '换行', keys: ['⇧', '↵'] },
{ description: '聚焦输入框', keys: ['⌘', '/'] },
{ description: '停止生成', keys: ['ESC'] },
],
},
{
title: '消息操作',
shortcuts: [
{ description: '复制消息', keys: ['⌘', 'C'] },
{ description: '重新生成', keys: ['⌘', 'R'] },
],
},
])
function close() {
settingsStore.closeShortcutsModal()
}
</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);
}
.shortcuts-modal {
width: 480px;
max-height: 80vh;
background: white;
border-radius: 16px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
overflow: hidden;
display: flex;
flex-direction: column;
.dark & {
background: #1e1e2e;
}
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid #e2e8f0;
.dark & {
border-bottom-color: #2d2d3d;
}
}
.header-title {
display: flex;
align-items: center;
gap: 12px;
svg {
color: #3b82f6;
}
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: 36px;
height: 36px;
border: none;
border-radius: 10px;
background: transparent;
color: #6b7280;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #f3f4f6;
color: #1f2937;
.dark & {
background: #374151;
color: #f3f4f6;
}
}
}
.shortcuts-content {
flex: 1;
overflow-y: auto;
padding: 16px 24px;
}
.shortcut-group {
&:not(:last-child) {
margin-bottom: 24px;
}
}
.group-title {
margin: 0 0 12px;
font-size: 12px;
font-weight: 600;
color: #9ca3af;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.shortcuts-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.shortcut-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-radius: 10px;
background: #f8fafc;
.dark & {
background: #2d2d3d;
}
}
.shortcut-desc {
font-size: 14px;
color: #374151;
.dark & {
color: #e5e7eb;
}
}
.shortcut-keys {
display: flex;
align-items: center;
gap: 4px;
kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 28px;
padding: 0 8px;
font-size: 12px;
font-weight: 500;
color: #374151;
background: white;
border: 1px solid #e2e8f0;
border-radius: 6px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
.dark & {
color: #e5e7eb;
background: #1e1e2e;
border-color: #4b5563;
}
}
}
.modal-footer {
padding: 16px 24px;
border-top: 1px solid #e2e8f0;
text-align: center;
.dark & {
border-top-color: #2d2d3d;
}
}
.tip {
font-size: 13px;
color: #9ca3af;
kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 22px;
padding: 0 6px;
margin: 0 2px;
font-size: 11px;
color: #6b7280;
background: #f3f4f6;
border-radius: 4px;
.dark & {
background: #374151;
color: #9ca3af;
}
}
}
//
.modal-enter-active,
.modal-leave-active {
transition: all 0.25s ease;
.shortcuts-modal {
transition: all 0.25s ease;
}
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
.shortcuts-modal {
transform: scale(0.9);
opacity: 0;
}
}
</style>

View File

@ -1,497 +1,497 @@
<template>
<aside
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>
</div>
<button
class="collapse-btn"
@click="toggleSidebar"
:title="isCollapsed ? '展开侧边栏' : '收起侧边栏'"
>
<ChevronLeft :size="18" :class="{ rotated: isCollapsed }" />
</button>
</div>
<!-- 新建对话按钮 -->
<div class="new-chat-section">
<button class="new-chat-btn" @click="handleNewChat">
<Plus :size="18" />
<span>新建对话</span>
</button>
</div>
<!-- 搜索框 -->
<div class="search-section">
<div class="search-box" @click="openSearch">
<Search :size="16" />
<span class="search-placeholder">搜索对话...</span>
<kbd class="search-kbd">K</kbd>
</div>
</div>
<!-- 对话列表 -->
<div class="conversations-section">
<!-- 置顶对话 -->
<div v-if="pinnedConversations.length > 0" class="conversation-group">
<div class="group-header">
<Pin :size="14" />
<span>置顶</span>
</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"
/>
</div>
</div>
<!-- 最近对话 -->
<div class="conversation-group">
<div class="group-header">
<Clock :size="14" />
<span>最近</span>
</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"
/>
</div>
</div>
<!-- 空状态 -->
<div
v-if="pinnedConversations.length === 0 && recentConversations.length === 0"
class="empty-state"
>
<MessageSquare :size="40" class="empty-icon" />
<p>暂无对话</p>
<span>点击上方按钮开始新对话</span>
</div>
</div>
<!-- 底部操作 -->
<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="快捷键">
<Keyboard :size="18" />
</button>
<button class="footer-btn" @click="openSettings" title="设置">
<Settings :size="18" />
</button>
</div>
</div>
<!-- 拖拽调整宽度 -->
<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 {
Bot,
Plus,
Search,
Pin,
Clock,
MessageSquare,
Sun,
Moon,
Monitor,
Keyboard,
Settings,
ChevronLeft,
} from '@/components/icons'
const chatStore = useChatStore()
const settingsStore = useSettingsStore()
const {
currentConversationId,
pinnedConversations,
recentConversations
} = storeToRefs(chatStore)
const {
sidebarCollapsed: isCollapsed,
sidebarWidth,
settings
} = storeToRefs(settingsStore)
const currentTheme = computed(() => settings.value.theme)
//
function handleNewChat() {
chatStore.createConversation()
}
function selectConversation(id: string) {
chatStore.selectConversation(id)
}
function deleteConversation(id: string) {
chatStore.deleteConversation(id)
}
function renameConversation(id: string, title: string) {
chatStore.renameConversation(id, title)
}
function togglePinConversation(id: string) {
chatStore.togglePinConversation(id)
}
function toggleSidebar() {
settingsStore.toggleSidebar()
}
function toggleTheme() {
settingsStore.toggleTheme()
}
function openShortcuts() {
settingsStore.openShortcutsModal()
}
function openSettings() {
settingsStore.openSettingsModal()
}
function openSearch() {
settingsStore.openSearchModal()
}
//
const isResizing = ref(false)
function startResize(e: MouseEvent) {
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 handleMouseUp = () => {
isResizing.value = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
</script>
<style lang="scss" scoped>
.chat-sidebar {
position: relative;
height: 100vh;
background: #f8fafc;
border-right: 1px solid #e2e8f0;
transition: width 0.3s ease;
overflow: hidden;
flex-shrink: 0;
.dark & {
background: #1e1e2e;
border-right-color: #2d2d3d;
}
&.collapsed {
.sidebar-inner {
opacity: 0;
pointer-events: none;
}
}
}
.sidebar-inner {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
transition: opacity 0.2s ease;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid #e2e8f0;
.dark & {
border-bottom-color: #2d2d3d;
}
}
.logo {
display: flex;
align-items: center;
gap: 10px;
}
.logo-icon {
color: #3b82f6;
}
.logo-text {
font-size: 18px;
font-weight: 700;
color: #1f2937;
.dark & {
color: #f3f4f6;
}
}
.collapse-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;
}
}
svg {
transition: transform 0.3s ease;
&.rotated {
transform: rotate(180deg);
}
}
}
.new-chat-section {
padding: 12px 16px;
}
.new-chat-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 12px 16px;
border: 1px dashed #d1d5db;
border-radius: 12px;
background: transparent;
color: #374151;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
.dark & {
border-color: #4b5563;
color: #e5e7eb;
}
&:hover {
border-color: #3b82f6;
background: rgba(59, 130, 246, 0.05);
color: #3b82f6;
}
}
.search-section {
padding: 0 16px 12px;
}
.search-box {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.03);
color: #9ca3af;
cursor: pointer;
transition: all 0.2s ease;
.dark & {
background: rgba(255, 255, 255, 0.03);
}
&:hover {
background: rgba(0, 0, 0, 0.06);
.dark & {
background: rgba(255, 255, 255, 0.06);
}
}
}
.search-placeholder {
flex: 1;
font-size: 13px;
}
.search-kbd {
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.06);
.dark & {
background: rgba(255, 255, 255, 0.1);
}
}
.conversations-section {
flex: 1;
overflow-y: auto;
padding-bottom: 12px;
}
.conversation-group {
margin-bottom: 8px;
}
.group-header {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 20px;
font-size: 12px;
font-weight: 600;
color: #9ca3af;
text-transform: uppercase;
letter-spacing: 0.5px;
.dark & {
color: #6b7280;
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
text-align: center;
.empty-icon {
color: #d1d5db;
margin-bottom: 12px;
.dark & {
color: #4b5563;
}
}
p {
margin: 0 0 4px;
font-size: 14px;
font-weight: 500;
color: #6b7280;
}
span {
font-size: 12px;
color: #9ca3af;
}
}
.sidebar-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid #e2e8f0;
.dark & {
border-top-color: #2d2d3d;
}
}
.footer-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: none;
border-radius: 10px;
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;
}
}
}
.resize-handle {
position: absolute;
top: 0;
right: -3px;
width: 6px;
height: 100%;
cursor: col-resize;
z-index: 10;
&:hover {
background: rgba(59, 130, 246, 0.3);
}
}
<template>
<aside
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>
</div>
<button
class="collapse-btn"
@click="toggleSidebar"
:title="isCollapsed ? '展开侧边栏' : '收起侧边栏'"
>
<ChevronLeft :size="18" :class="{ rotated: isCollapsed }" />
</button>
</div>
<!-- 新建对话按钮 -->
<div class="new-chat-section">
<button class="new-chat-btn" @click="handleNewChat">
<Plus :size="18" />
<span>新建对话</span>
</button>
</div>
<!-- 搜索框 -->
<div class="search-section">
<div class="search-box" @click="openSearch">
<Search :size="16" />
<span class="search-placeholder">搜索对话...</span>
<kbd class="search-kbd">K</kbd>
</div>
</div>
<!-- 对话列表 -->
<div class="conversations-section">
<!-- 置顶对话 -->
<div v-if="pinnedConversations.length > 0" class="conversation-group">
<div class="group-header">
<Pin :size="14" />
<span>置顶</span>
</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"
/>
</div>
</div>
<!-- 最近对话 -->
<div class="conversation-group">
<div class="group-header">
<Clock :size="14" />
<span>最近</span>
</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"
/>
</div>
</div>
<!-- 空状态 -->
<div
v-if="pinnedConversations.length === 0 && recentConversations.length === 0"
class="empty-state"
>
<MessageSquare :size="40" class="empty-icon" />
<p>暂无对话</p>
<span>点击上方按钮开始新对话</span>
</div>
</div>
<!-- 底部操作 -->
<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="快捷键">
<Keyboard :size="18" />
</button>
<button class="footer-btn" @click="openSettings" title="设置">
<Settings :size="18" />
</button>
</div>
</div>
<!-- 拖拽调整宽度 -->
<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 {
Bot,
Plus,
Search,
Pin,
Clock,
MessageSquare,
Sun,
Moon,
Monitor,
Keyboard,
Settings,
ChevronLeft,
} from '@/components/icons'
const chatStore = useChatStore()
const settingsStore = useSettingsStore()
const {
currentConversationId,
pinnedConversations,
recentConversations
} = storeToRefs(chatStore)
const {
sidebarCollapsed: isCollapsed,
sidebarWidth,
settings
} = storeToRefs(settingsStore)
const currentTheme = computed(() => settings.value.theme)
//
function handleNewChat() {
chatStore.createConversation()
}
function selectConversation(id: string) {
chatStore.selectConversation(id)
}
function deleteConversation(id: string) {
chatStore.deleteConversation(id)
}
function renameConversation(id: string, title: string) {
chatStore.renameConversation(id, title)
}
function togglePinConversation(id: string) {
chatStore.togglePinConversation(id)
}
function toggleSidebar() {
settingsStore.toggleSidebar()
}
function toggleTheme() {
settingsStore.toggleTheme()
}
function openShortcuts() {
settingsStore.openShortcutsModal()
}
function openSettings() {
settingsStore.openSettingsModal()
}
function openSearch() {
settingsStore.openSearchModal()
}
//
const isResizing = ref(false)
function startResize(e: MouseEvent) {
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 handleMouseUp = () => {
isResizing.value = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
</script>
<style lang="scss" scoped>
.chat-sidebar {
position: relative;
height: 100vh;
background: #f8fafc;
border-right: 1px solid #e2e8f0;
transition: width 0.3s ease;
overflow: hidden;
flex-shrink: 0;
.dark & {
background: #1e1e2e;
border-right-color: #2d2d3d;
}
&.collapsed {
.sidebar-inner {
opacity: 0;
pointer-events: none;
}
}
}
.sidebar-inner {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
transition: opacity 0.2s ease;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid #e2e8f0;
.dark & {
border-bottom-color: #2d2d3d;
}
}
.logo {
display: flex;
align-items: center;
gap: 10px;
}
.logo-icon {
color: #3b82f6;
}
.logo-text {
font-size: 18px;
font-weight: 700;
color: #1f2937;
.dark & {
color: #f3f4f6;
}
}
.collapse-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;
}
}
svg {
transition: transform 0.3s ease;
&.rotated {
transform: rotate(180deg);
}
}
}
.new-chat-section {
padding: 12px 16px;
}
.new-chat-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 12px 16px;
border: 1px dashed #d1d5db;
border-radius: 12px;
background: transparent;
color: #374151;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
.dark & {
border-color: #4b5563;
color: #e5e7eb;
}
&:hover {
border-color: #3b82f6;
background: rgba(59, 130, 246, 0.05);
color: #3b82f6;
}
}
.search-section {
padding: 0 16px 12px;
}
.search-box {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.03);
color: #9ca3af;
cursor: pointer;
transition: all 0.2s ease;
.dark & {
background: rgba(255, 255, 255, 0.03);
}
&:hover {
background: rgba(0, 0, 0, 0.06);
.dark & {
background: rgba(255, 255, 255, 0.06);
}
}
}
.search-placeholder {
flex: 1;
font-size: 13px;
}
.search-kbd {
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.06);
.dark & {
background: rgba(255, 255, 255, 0.1);
}
}
.conversations-section {
flex: 1;
overflow-y: auto;
padding-bottom: 12px;
}
.conversation-group {
margin-bottom: 8px;
}
.group-header {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 20px;
font-size: 12px;
font-weight: 600;
color: #9ca3af;
text-transform: uppercase;
letter-spacing: 0.5px;
.dark & {
color: #6b7280;
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
text-align: center;
.empty-icon {
color: #d1d5db;
margin-bottom: 12px;
.dark & {
color: #4b5563;
}
}
p {
margin: 0 0 4px;
font-size: 14px;
font-weight: 500;
color: #6b7280;
}
span {
font-size: 12px;
color: #9ca3af;
}
}
.sidebar-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid #e2e8f0;
.dark & {
border-top-color: #2d2d3d;
}
}
.footer-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: none;
border-radius: 10px;
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;
}
}
}
.resize-handle {
position: absolute;
top: 0;
right: -3px;
width: 6px;
height: 100%;
cursor: col-resize;
z-index: 10;
&:hover {
background: rgba(59, 130, 246, 0.3);
}
}
</style>

View File

@ -1,278 +1,278 @@
<template>
<div
class="conversation-item group"
:class="{
'active': isActive,
'pinned': conversation.pinned
}"
@click="handleSelect"
@dblclick="handleRename"
>
<!-- 图标 -->
<div class="item-icon">
<MessageSquare :size="18" />
</div>
<!-- 内容 -->
<div class="item-content">
<div v-if="!isEditing" class="item-title">
{{ conversation.title }}
</div>
<input
v-else
ref="inputRef"
v-model="editTitle"
class="item-title-input"
@blur="handleSaveRename"
@keydown.enter="handleSaveRename"
@keydown.escape="handleCancelRename"
@click.stop
/>
<div class="item-meta">
<Clock :size="12" />
<span>{{ formattedTime }}</span>
</div>
</div>
<!-- 置顶标识 -->
<div v-if="conversation.pinned" class="pin-indicator">
<Pin :size="12" />
</div>
<!-- 操作按钮 -->
<div class="item-actions" @click.stop>
<button
class="action-btn"
:title="conversation.pinned ? '取消置顶' : '置顶'"
@click="handleTogglePin"
>
<PinOff v-if="conversation.pinned" :size="14" />
<Pin v-else :size="14" />
</button>
<button
class="action-btn"
title="重命名"
@click="handleRename"
>
<Edit3 :size="14" />
</button>
<button
class="action-btn delete"
title="删除"
@click="handleDelete"
>
<Trash2 :size="14" />
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { MessageSquare, Pin, PinOff, Edit3, Trash2, Clock } from '@/components/icons'
import { formatTimestamp } from '@/utils/helpers'
import type { Conversation } from '@/types/chat'
const props = defineProps<{
conversation: Conversation
isActive: boolean
}>()
const emit = defineEmits<{
select: [id: string]
delete: [id: string]
rename: [id: string, title: string]
togglePin: [id: string]
}>()
const isEditing = ref(false)
const editTitle = ref('')
const inputRef = ref<HTMLInputElement | null>(null)
const formattedTime = computed(() => {
return formatTimestamp(props.conversation.updatedAt)
})
function handleSelect() {
if (!isEditing.value) {
emit('select', props.conversation.id)
}
}
function handleTogglePin() {
emit('togglePin', props.conversation.id)
}
function handleRename() {
isEditing.value = true
editTitle.value = props.conversation.title
nextTick(() => {
inputRef.value?.focus()
inputRef.value?.select()
})
}
function handleSaveRename() {
if (editTitle.value.trim()) {
emit('rename', props.conversation.id, editTitle.value.trim())
}
isEditing.value = false
}
function handleCancelRename() {
isEditing.value = false
editTitle.value = ''
}
function handleDelete() {
if (confirm('确定要删除这个对话吗?')) {
emit('delete', props.conversation.id)
}
}
</script>
<style lang="scss" scoped>
.conversation-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
margin: 2px 8px;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
&:hover {
background: rgba(0, 0, 0, 0.05);
.dark & {
background: rgba(255, 255, 255, 0.05);
}
.item-actions {
opacity: 1;
pointer-events: auto;
}
.pin-indicator {
opacity: 0;
}
}
&.active {
background: rgba(59, 130, 246, 0.1);
.dark & {
background: rgba(59, 130, 246, 0.2);
}
.item-icon {
color: #3b82f6;
}
}
}
.item-icon {
flex-shrink: 0;
color: #6b7280;
.dark & {
color: #9ca3af;
}
}
.item-content {
flex: 1;
min-width: 0;
overflow: hidden;
}
.item-title {
font-size: 14px;
font-weight: 500;
color: #1f2937;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.dark & {
color: #f3f4f6;
}
}
.item-title-input {
width: 100%;
font-size: 14px;
font-weight: 500;
color: #1f2937;
background: white;
border: 1px solid #3b82f6;
border-radius: 4px;
padding: 2px 6px;
outline: none;
.dark & {
color: #f3f4f6;
background: #374151;
}
}
.item-meta {
display: flex;
align-items: center;
gap: 4px;
margin-top: 2px;
font-size: 11px;
color: #9ca3af;
.dark & {
color: #6b7280;
}
}
.pin-indicator {
position: absolute;
right: 12px;
color: #f59e0b;
transition: opacity 0.2s ease;
}
.item-actions {
display: flex;
align-items: center;
gap: 2px;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
border-radius: 6px;
background: transparent;
color: #6b7280;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: rgba(0, 0, 0, 0.1);
color: #374151;
.dark & {
background: rgba(255, 255, 255, 0.1);
color: #e5e7eb;
}
}
&.delete:hover {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
}
<template>
<div
class="conversation-item group"
:class="{
'active': isActive,
'pinned': conversation.pinned
}"
@click="handleSelect"
@dblclick="handleRename"
>
<!-- 图标 -->
<div class="item-icon">
<MessageSquare :size="18" />
</div>
<!-- 内容 -->
<div class="item-content">
<div v-if="!isEditing" class="item-title">
{{ conversation.title }}
</div>
<input
v-else
ref="inputRef"
v-model="editTitle"
class="item-title-input"
@blur="handleSaveRename"
@keydown.enter="handleSaveRename"
@keydown.escape="handleCancelRename"
@click.stop
/>
<div class="item-meta">
<Clock :size="12" />
<span>{{ formattedTime }}</span>
</div>
</div>
<!-- 置顶标识 -->
<div v-if="conversation.pinned" class="pin-indicator">
<Pin :size="12" />
</div>
<!-- 操作按钮 -->
<div class="item-actions" @click.stop>
<button
class="action-btn"
:title="conversation.pinned ? '取消置顶' : '置顶'"
@click="handleTogglePin"
>
<PinOff v-if="conversation.pinned" :size="14" />
<Pin v-else :size="14" />
</button>
<button
class="action-btn"
title="重命名"
@click="handleRename"
>
<Edit3 :size="14" />
</button>
<button
class="action-btn delete"
title="删除"
@click="handleDelete"
>
<Trash2 :size="14" />
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { MessageSquare, Pin, PinOff, Edit3, Trash2, Clock } from '@/components/icons'
import { formatTimestamp } from '@/utils/helpers'
import type { Conversation } from '@/types/chat'
const props = defineProps<{
conversation: Conversation
isActive: boolean
}>()
const emit = defineEmits<{
select: [id: string]
delete: [id: string]
rename: [id: string, title: string]
togglePin: [id: string]
}>()
const isEditing = ref(false)
const editTitle = ref('')
const inputRef = ref<HTMLInputElement | null>(null)
const formattedTime = computed(() => {
return formatTimestamp(props.conversation.updatedAt)
})
function handleSelect() {
if (!isEditing.value) {
emit('select', props.conversation.id)
}
}
function handleTogglePin() {
emit('togglePin', props.conversation.id)
}
function handleRename() {
isEditing.value = true
editTitle.value = props.conversation.title
nextTick(() => {
inputRef.value?.focus()
inputRef.value?.select()
})
}
function handleSaveRename() {
if (editTitle.value.trim()) {
emit('rename', props.conversation.id, editTitle.value.trim())
}
isEditing.value = false
}
function handleCancelRename() {
isEditing.value = false
editTitle.value = ''
}
function handleDelete() {
if (confirm('确定要删除这个对话吗?')) {
emit('delete', props.conversation.id)
}
}
</script>
<style lang="scss" scoped>
.conversation-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
margin: 2px 8px;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
&:hover {
background: rgba(0, 0, 0, 0.05);
.dark & {
background: rgba(255, 255, 255, 0.05);
}
.item-actions {
opacity: 1;
pointer-events: auto;
}
.pin-indicator {
opacity: 0;
}
}
&.active {
background: rgba(59, 130, 246, 0.1);
.dark & {
background: rgba(59, 130, 246, 0.2);
}
.item-icon {
color: #3b82f6;
}
}
}
.item-icon {
flex-shrink: 0;
color: #6b7280;
.dark & {
color: #9ca3af;
}
}
.item-content {
flex: 1;
min-width: 0;
overflow: hidden;
}
.item-title {
font-size: 14px;
font-weight: 500;
color: #1f2937;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.dark & {
color: #f3f4f6;
}
}
.item-title-input {
width: 100%;
font-size: 14px;
font-weight: 500;
color: #1f2937;
background: white;
border: 1px solid #3b82f6;
border-radius: 4px;
padding: 2px 6px;
outline: none;
.dark & {
color: #f3f4f6;
background: #374151;
}
}
.item-meta {
display: flex;
align-items: center;
gap: 4px;
margin-top: 2px;
font-size: 11px;
color: #9ca3af;
.dark & {
color: #6b7280;
}
}
.pin-indicator {
position: absolute;
right: 12px;
color: #f59e0b;
transition: opacity 0.2s ease;
}
.item-actions {
display: flex;
align-items: center;
gap: 2px;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
border-radius: 6px;
background: transparent;
color: #6b7280;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: rgba(0, 0, 0, 0.1);
color: #374151;
.dark & {
background: rgba(255, 255, 255, 0.1);
color: #e5e7eb;
}
}
&.delete:hover {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
}
</style>

View File

@ -1,242 +1,242 @@
<template>
<div class="form-select" :class="{ open: isOpen, disabled }">
<button class="select-trigger" :disabled="disabled" @click="toggleOpen">
<span class="select-value">
<slot name="selected" :option="selectedOption">
{{ selectedOption?.label || placeholder }}
</slot>
</span>
<ChevronDown :size="18" class="select-arrow" />
</button>
<Transition name="dropdown">
<div v-if="isOpen" class="select-dropdown">
<div
v-for="option in options"
:key="option.value"
class="select-option"
:class="{ active: option.value === modelValue }"
@click="selectOption(option)"
>
<slot name="option" :option="option">
<span class="option-label">{{ option.label }}</span>
<span v-if="option.description" class="option-desc">{{
option.description
}}</span>
</slot>
<Check
v-if="option.value === modelValue"
:size="16"
class="check-icon"
/>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import { ChevronDown, Check } from "@/components/icons";
export interface SelectOption {
value: string | number;
label: string;
description?: string;
icon?: string;
}
const props = withDefaults(
defineProps<{
modelValue: string | number;
options: SelectOption[];
placeholder?: string;
disabled?: boolean;
valueProp?: string;
}>(),
{
placeholder: "请选择",
disabled: false,
},
);
const emit = defineEmits<{
"update:modelValue": [value: string | number];
}>();
const isOpen = ref(false);
const selectedOption = computed(() => {
return props.options.find((opt) => opt.value === props.modelValue);
});
function toggleOpen() {
if (!props.disabled) {
isOpen.value = !isOpen.value;
}
}
function selectOption(option: any) {
emit("update:modelValue", option[props.valueProp || "value"]);
isOpen.value = false;
}
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest(".form-select")) {
isOpen.value = false;
}
}
onMounted(() => {
document.addEventListener("click", handleClickOutside);
});
onUnmounted(() => {
document.removeEventListener("click", handleClickOutside);
});
</script>
<style lang="scss" scoped>
.form-select {
position: relative;
width: 100%;
&.disabled {
opacity: 0.5;
pointer-events: none;
}
&.open {
.select-arrow {
transform: rotate(180deg);
}
}
}
.select-trigger {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 10px 14px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 10px;
font-size: 14px;
color: #1f2937;
cursor: pointer;
transition: all 0.2s ease;
.dark & {
background: #2d2d3d;
border-color: #374151;
color: #f3f4f6;
}
&:hover {
border-color: #3b82f6;
}
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
}
.select-value {
flex: 1;
text-align: left;
}
.select-arrow {
color: #9ca3af;
transition: transform 0.2s ease;
}
.select-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
max-height: 280px;
overflow-y: auto;
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
z-index: 100;
.dark & {
background: #1e1e2e;
border-color: #2d2d3d;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
}
}
.select-option {
display: flex;
align-items: center;
padding: 12px 14px;
cursor: pointer;
transition: background 0.15s ease;
&:first-child {
border-radius: 11px 11px 0 0;
}
&:last-child {
border-radius: 0 0 11px 11px;
}
&:hover {
background: #f3f4f6;
.dark & {
background: #2d2d3d;
}
}
&.active {
background: rgba(59, 130, 246, 0.1);
.option-label {
color: #3b82f6;
}
}
}
.option-label {
flex: 1;
font-size: 14px;
color: #1f2937;
.dark & {
color: #f3f4f6;
}
}
.option-desc {
font-size: 12px;
color: #9ca3af;
margin-left: 8px;
}
.check-icon {
color: #3b82f6;
margin-left: 8px;
}
//
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.2s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>
<template>
<div class="form-select" :class="{ open: isOpen, disabled }">
<button class="select-trigger" :disabled="disabled" @click="toggleOpen">
<span class="select-value">
<slot name="selected" :option="selectedOption">
{{ selectedOption?.label || placeholder }}
</slot>
</span>
<ChevronDown :size="18" class="select-arrow" />
</button>
<Transition name="dropdown">
<div v-if="isOpen" class="select-dropdown">
<div
v-for="option in options"
:key="option.value"
class="select-option"
:class="{ active: option.value === modelValue }"
@click="selectOption(option)"
>
<slot name="option" :option="option">
<span class="option-label">{{ option.label }}</span>
<span v-if="option.description" class="option-desc">{{
option.description
}}</span>
</slot>
<Check
v-if="option.value === modelValue"
:size="16"
class="check-icon"
/>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import { ChevronDown, Check } from "@/components/icons";
export interface SelectOption {
value: string | number;
label: string;
description?: string;
icon?: string;
}
const props = withDefaults(
defineProps<{
modelValue: string | number;
options: SelectOption[];
placeholder?: string;
disabled?: boolean;
valueProp?: string;
}>(),
{
placeholder: "请选择",
disabled: false,
},
);
const emit = defineEmits<{
"update:modelValue": [value: string | number];
}>();
const isOpen = ref(false);
const selectedOption = computed(() => {
return props.options.find((opt) => opt.value === props.modelValue);
});
function toggleOpen() {
if (!props.disabled) {
isOpen.value = !isOpen.value;
}
}
function selectOption(option: any) {
emit("update:modelValue", option[props.valueProp || "value"]);
isOpen.value = false;
}
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest(".form-select")) {
isOpen.value = false;
}
}
onMounted(() => {
document.addEventListener("click", handleClickOutside);
});
onUnmounted(() => {
document.removeEventListener("click", handleClickOutside);
});
</script>
<style lang="scss" scoped>
.form-select {
position: relative;
width: 100%;
&.disabled {
opacity: 0.5;
pointer-events: none;
}
&.open {
.select-arrow {
transform: rotate(180deg);
}
}
}
.select-trigger {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 10px 14px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 10px;
font-size: 14px;
color: #1f2937;
cursor: pointer;
transition: all 0.2s ease;
.dark & {
background: #2d2d3d;
border-color: #374151;
color: #f3f4f6;
}
&:hover {
border-color: #3b82f6;
}
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
}
.select-value {
flex: 1;
text-align: left;
}
.select-arrow {
color: #9ca3af;
transition: transform 0.2s ease;
}
.select-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
max-height: 280px;
overflow-y: auto;
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
z-index: 100;
.dark & {
background: #1e1e2e;
border-color: #2d2d3d;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
}
}
.select-option {
display: flex;
align-items: center;
padding: 12px 14px;
cursor: pointer;
transition: background 0.15s ease;
&:first-child {
border-radius: 11px 11px 0 0;
}
&:last-child {
border-radius: 0 0 11px 11px;
}
&:hover {
background: #f3f4f6;
.dark & {
background: #2d2d3d;
}
}
&.active {
background: rgba(59, 130, 246, 0.1);
.option-label {
color: #3b82f6;
}
}
}
.option-label {
flex: 1;
font-size: 14px;
color: #1f2937;
.dark & {
color: #f3f4f6;
}
}
.option-desc {
font-size: 12px;
color: #9ca3af;
margin-left: 8px;
}
.check-icon {
color: #3b82f6;
margin-left: 8px;
}
//
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.2s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>

View File

@ -1,116 +1,116 @@
<template>
<div class="form-slider">
<input
type="range"
:value="modelValue"
:min="min"
:max="max"
:step="step"
:disabled="disabled"
@input="handleInput"
/>
<div class="slider-track">
<div class="slider-fill" :style="{ width: fillPercent + '%' }"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
modelValue: number
min?: number
max?: number
step?: number
disabled?: boolean
}>(), {
min: 0,
max: 100,
step: 1,
disabled: false,
})
const emit = defineEmits<{
'update:modelValue': [value: number]
}>()
const fillPercent = computed(() => {
return ((props.modelValue - props.min) / (props.max - props.min)) * 100
})
function handleInput(event: Event) {
const value = parseFloat((event.target as HTMLInputElement).value)
emit('update:modelValue', value)
}
</script>
<style lang="scss" scoped>
.form-slider {
position: relative;
width: 100%;
height: 24px;
input[type="range"] {
position: absolute;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
z-index: 2;
&:disabled {
cursor: not-allowed;
}
}
}
.slider-track {
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 6px;
background: #e5e7eb;
border-radius: 3px;
transform: translateY(-50%);
overflow: hidden;
.dark & {
background: #374151;
}
}
.slider-fill {
height: 100%;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border-radius: 3px;
transition: width 0.1s ease;
}
//
.form-slider input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
background: white;
border-radius: 50%;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.1);
}
}
.form-slider input[type="range"]::-moz-range-thumb {
width: 18px;
height: 18px;
background: white;
border: none;
border-radius: 50%;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
cursor: pointer;
}
<template>
<div class="form-slider">
<input
type="range"
:value="modelValue"
:min="min"
:max="max"
:step="step"
:disabled="disabled"
@input="handleInput"
/>
<div class="slider-track">
<div class="slider-fill" :style="{ width: fillPercent + '%' }"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
modelValue: number
min?: number
max?: number
step?: number
disabled?: boolean
}>(), {
min: 0,
max: 100,
step: 1,
disabled: false,
})
const emit = defineEmits<{
'update:modelValue': [value: number]
}>()
const fillPercent = computed(() => {
return ((props.modelValue - props.min) / (props.max - props.min)) * 100
})
function handleInput(event: Event) {
const value = parseFloat((event.target as HTMLInputElement).value)
emit('update:modelValue', value)
}
</script>
<style lang="scss" scoped>
.form-slider {
position: relative;
width: 100%;
height: 24px;
input[type="range"] {
position: absolute;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
z-index: 2;
&:disabled {
cursor: not-allowed;
}
}
}
.slider-track {
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 6px;
background: #e5e7eb;
border-radius: 3px;
transform: translateY(-50%);
overflow: hidden;
.dark & {
background: #374151;
}
}
.slider-fill {
height: 100%;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border-radius: 3px;
transition: width 0.1s ease;
}
//
.form-slider input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
background: white;
border-radius: 50%;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.1);
}
}
.form-slider input[type="range"]::-moz-range-thumb {
width: 18px;
height: 18px;
background: white;
border: none;
border-radius: 50%;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
cursor: pointer;
}
</style>

View File

@ -1,80 +1,80 @@
<template>
<label class="form-switch" :class="{ disabled }">
<input
type="checkbox"
:checked="modelValue"
:disabled="disabled"
@change="$emit('update:modelValue', ($event.target as HTMLInputElement).checked)"
/>
<span class="switch-slider"></span>
</label>
</template>
<script setup lang="ts">
defineProps<{
modelValue: boolean
disabled?: boolean
}>()
defineEmits<{
'update:modelValue': [value: boolean]
}>()
</script>
<style lang="scss" scoped>
.form-switch {
position: relative;
display: inline-flex;
width: 44px;
height: 24px;
cursor: pointer;
&.disabled {
opacity: 0.5;
cursor: not-allowed;
}
input {
opacity: 0;
width: 0;
height: 0;
&:checked + .switch-slider {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
&::before {
transform: translateX(20px);
}
}
&:focus + .switch-slider {
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
}
}
}
.switch-slider {
position: absolute;
inset: 0;
background: #d1d5db;
border-radius: 24px;
transition: all 0.3s ease;
.dark & {
background: #4b5563;
}
&::before {
content: '';
position: absolute;
width: 20px;
height: 20px;
left: 2px;
top: 2px;
background: white;
border-radius: 50%;
transition: transform 0.3s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
}
<template>
<label class="form-switch" :class="{ disabled }">
<input
type="checkbox"
:checked="modelValue"
:disabled="disabled"
@change="$emit('update:modelValue', ($event.target as HTMLInputElement).checked)"
/>
<span class="switch-slider"></span>
</label>
</template>
<script setup lang="ts">
defineProps<{
modelValue: boolean
disabled?: boolean
}>()
defineEmits<{
'update:modelValue': [value: boolean]
}>()
</script>
<style lang="scss" scoped>
.form-switch {
position: relative;
display: inline-flex;
width: 44px;
height: 24px;
cursor: pointer;
&.disabled {
opacity: 0.5;
cursor: not-allowed;
}
input {
opacity: 0;
width: 0;
height: 0;
&:checked + .switch-slider {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
&::before {
transform: translateX(20px);
}
}
&:focus + .switch-slider {
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
}
}
}
.switch-slider {
position: absolute;
inset: 0;
background: #d1d5db;
border-radius: 24px;
transition: all 0.3s ease;
.dark & {
background: #4b5563;
}
&::before {
content: '';
position: absolute;
width: 20px;
height: 20px;
left: 2px;
top: 2px;
background: white;
border-radius: 50%;
transition: transform 0.3s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
}
</style>

View File

@ -1,128 +1,128 @@
import { onMounted, onUnmounted, ref } from 'vue'
export interface KeyboardShortcut {
key: string
ctrl?: boolean
shift?: boolean
alt?: boolean
meta?: boolean
description: string
action: () => void
}
// 快捷键管理组合式函数
export function useKeyboard(shortcuts: KeyboardShortcut[]) {
const activeKeys = ref<Set<string>>(new Set())
const handleKeyDown = (event: KeyboardEvent) => {
activeKeys.value.add(event.key.toLowerCase())
for (const shortcut of shortcuts) {
const keyMatch = event.key.toLowerCase() === shortcut.key.toLowerCase()
const ctrlMatch = !!shortcut.ctrl === (event.ctrlKey || event.metaKey)
const shiftMatch = !!shortcut.shift === event.shiftKey
const altMatch = !!shortcut.alt === event.altKey
if (keyMatch && ctrlMatch && shiftMatch && altMatch) {
// 排除在输入框中的部分快捷键
const target = event.target as HTMLElement
const isInput = target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
// 这些快捷键在输入框中也生效
const globalShortcuts = ['Escape', 'Enter']
const needsModifier = shortcut.ctrl || shortcut.alt || shortcut.meta
if (isInput && !globalShortcuts.includes(shortcut.key) && !needsModifier) {
continue
}
event.preventDefault()
shortcut.action()
break
}
}
}
const handleKeyUp = (event: KeyboardEvent) => {
activeKeys.value.delete(event.key.toLowerCase())
}
onMounted(() => {
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
})
return {
activeKeys,
}
}
// 预定义的快捷键配置
export function getDefaultShortcuts(actions: {
newChat: () => void
toggleSidebar: () => void
focusInput: () => void
sendMessage: () => void
cancelStream: () => void
toggleTheme: () => void
showShortcuts: () => void
searchConversations: () => void
}): KeyboardShortcut[] {
return [
{
key: 'n',
ctrl: true,
description: '新建对话',
action: actions.newChat,
},
{
key: 'b',
ctrl: true,
description: '切换侧边栏',
action: actions.toggleSidebar,
},
{
key: '/',
ctrl: true,
description: '聚焦输入框',
action: actions.focusInput,
},
{
key: 'Enter',
ctrl: true,
description: '发送消息',
action: actions.sendMessage,
},
{
key: 'Escape',
description: '取消生成',
action: actions.cancelStream,
},
{
key: 'd',
ctrl: true,
shift: true,
description: '切换主题',
action: actions.toggleTheme,
},
{
key: '?',
ctrl: true,
description: '显示快捷键',
action: actions.showShortcuts,
},
{
key: 'k',
ctrl: true,
description: '搜索对话',
action: actions.searchConversations,
},
]
import { onMounted, onUnmounted, ref } from 'vue'
export interface KeyboardShortcut {
key: string
ctrl?: boolean
shift?: boolean
alt?: boolean
meta?: boolean
description: string
action: () => void
}
// 快捷键管理组合式函数
export function useKeyboard(shortcuts: KeyboardShortcut[]) {
const activeKeys = ref<Set<string>>(new Set())
const handleKeyDown = (event: KeyboardEvent) => {
activeKeys.value.add(event.key.toLowerCase())
for (const shortcut of shortcuts) {
const keyMatch = event.key.toLowerCase() === shortcut.key.toLowerCase()
const ctrlMatch = !!shortcut.ctrl === (event.ctrlKey || event.metaKey)
const shiftMatch = !!shortcut.shift === event.shiftKey
const altMatch = !!shortcut.alt === event.altKey
if (keyMatch && ctrlMatch && shiftMatch && altMatch) {
// 排除在输入框中的部分快捷键
const target = event.target as HTMLElement
const isInput = target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
// 这些快捷键在输入框中也生效
const globalShortcuts = ['Escape', 'Enter']
const needsModifier = shortcut.ctrl || shortcut.alt || shortcut.meta
if (isInput && !globalShortcuts.includes(shortcut.key) && !needsModifier) {
continue
}
event.preventDefault()
shortcut.action()
break
}
}
}
const handleKeyUp = (event: KeyboardEvent) => {
activeKeys.value.delete(event.key.toLowerCase())
}
onMounted(() => {
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
})
return {
activeKeys,
}
}
// 预定义的快捷键配置
export function getDefaultShortcuts(actions: {
newChat: () => void
toggleSidebar: () => void
focusInput: () => void
sendMessage: () => void
cancelStream: () => void
toggleTheme: () => void
showShortcuts: () => void
searchConversations: () => void
}): KeyboardShortcut[] {
return [
{
key: 'n',
ctrl: true,
description: '新建对话',
action: actions.newChat,
},
{
key: 'b',
ctrl: true,
description: '切换侧边栏',
action: actions.toggleSidebar,
},
{
key: '/',
ctrl: true,
description: '聚焦输入框',
action: actions.focusInput,
},
{
key: 'Enter',
ctrl: true,
description: '发送消息',
action: actions.sendMessage,
},
{
key: 'Escape',
description: '取消生成',
action: actions.cancelStream,
},
{
key: 'd',
ctrl: true,
shift: true,
description: '切换主题',
action: actions.toggleTheme,
},
{
key: '?',
ctrl: true,
description: '显示快捷键',
action: actions.showShortcuts,
},
{
key: 'k',
ctrl: true,
description: '搜索对话',
action: actions.searchConversations,
},
]
}

View File

@ -1,26 +1,26 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
// 样式
import "@unocss/reset/tailwind.css";
import "virtual:uno.css";
import "./styles/main.scss";
import "markstream-vue/index.css";
// 创建应用
const app = createApp(App);
// 使用 Pinia
const pinia = createPinia();
app.use(pinia);
// 挂载应用
app.mount("#app");
// 类型声明
declare global {
interface Window {
$toast: (message: string, type?: "success" | "error" | "info") => void;
}
}
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
// 样式
import "@unocss/reset/tailwind.css";
import "virtual:uno.css";
import "./styles/main.scss";
import "markstream-vue/index.css";
// 创建应用
const app = createApp(App);
// 使用 Pinia
const pinia = createPinia();
app.use(pinia);
// 挂载应用
app.mount("#app");
// 类型声明
declare global {
interface Window {
$toast: (message: string, type?: "success" | "error" | "info") => void;
}
}

View File

@ -1,209 +1,238 @@
/**
* Chat UI API
*
*/
// API 端点定义(固定)
const API_ENDPOINTS = {
// 发送消息(流式)
CHAT_STREAM: "/api/chat-ui/chat",
// 发送消息(非流式)
CHAT: "/api/chat-ui/chat",
// 获取对话历史
CONVERSATIONS: "/api/chat-ui/conversations",
// 获取单个对话
CONVERSATION: "/api/chat-ui/conversations/:id",
// 删除对话
DELETE_CONVERSATION: "/api/chat-ui/conversations/:id",
// 上传文件
UPLOAD: "/api/chat-ui/upload",
// 获取模型列表
MODELS: "/api/chat-ui/models",
// 停止生成
STOP: "/api/chat-ui/stop",
};
// 请求类型定义
export interface ChatMessage {
role: "user" | "assistant" | "system";
content: string;
images?: string[];
files?: string[];
}
export interface ChatRequest {
conversationId?: string;
message: string;
images?: string[];
files?: string[];
model?: string;
temperature?: number;
maxTokens?: number;
systemPrompt?: string;
stream?: boolean;
// 扩展选项
deepSearch?: boolean;
webSearch?: boolean;
deepThinking?: boolean;
}
export interface ChatResponse {
id: string;
conversationId: string;
content: string;
model: string;
createdAt: number;
usage?: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
};
}
export interface ModelInfo {
id: string;
name: string;
description: string;
maxTokens: number;
provider: string;
}
export interface UploadResult {
url: string;
name: string;
size?: number;
mimeType?: string;
}
// API 调用类
class ChatApi {
private baseUrl: string;
constructor(baseUrl = "") {
this.baseUrl = baseUrl;
}
/**
*
*/
async *streamChat(
request: ChatRequest,
signal?: AbortSignal,
): AsyncGenerator<string> {
const response = await fetch(
`${this.baseUrl}${API_ENDPOINTS.CHAT_STREAM}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify(request),
signal,
},
);
if (!response.ok) {
const error = await response.text();
throw new Error(error || `HTTP ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error("Response body is not readable");
}
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
const match = text.match(/data:\s*(\{.*\})/);
if (match) {
yield JSON.parse(match[1])["message"];
}
}
}
/**
*
*/
async chat(request: ChatRequest): Promise<ChatResponse> {
const response = await fetch(`${this.baseUrl}${API_ENDPOINTS.CHAT}`, {
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();
}
/**
*
*/
async stopChat(messageId?: string) {
await fetch(`${this.baseUrl}${API_ENDPOINTS.STOP}/${messageId}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
}
/**
*
*/
async getModels(): Promise<ModelInfo[]> {
return [
{
id: "gpt-4",
name: "GPT-4",
description: "最强大的模型",
maxTokens: 8192,
provider: "OpenAI",
},
{
id: "gpt-3.5-turbo",
name: "GPT-3.5 Turbo",
description: "快速高效",
maxTokens: 16384,
provider: "OpenAI",
},
];
}
/**
*
*/
async uploadFile(file: File): Promise<UploadResult> {
const formData = new FormData();
formData.append("file", file);
const response = await fetch(`${this.baseUrl}${API_ENDPOINTS.UPLOAD}`, {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error(`上传失败: HTTP ${response.status}`);
}
return response.json();
}
}
// 导出单例
export const chatApi = new ChatApi();
// 导出类用于自定义配置
export { ChatApi, API_ENDPOINTS };
// 导出端点常量(供调试使用)
// export {API_ENDPOINTS}
/**
* Chat UI API
*
*/
// API 端点定义(固定)
const API_ENDPOINTS = {
// 发送消息(流式)
CHAT_STREAM: "/api/chat-ui/chat",
// 发送消息(非流式)
CHAT: "/api/chat-ui/chat",
// 获取对话历史
CONVERSATIONS: "/api/chat-ui/conversations",
// 获取单个对话
CONVERSATION: "/api/chat-ui/conversations/:id",
// 删除对话
DELETE_CONVERSATION: "/api/chat-ui/conversations/:id",
// 上传文件
UPLOAD: "/api/chat-ui/upload",
// 获取模型列表
MODELS: "/api/chat-ui/models",
// 停止生成
STOP: "/api/chat-ui/stop",
};
// 请求类型定义
export interface ChatMessage {
role: "user" | "assistant" | "system";
content: string;
images?: string[];
files?: string[];
}
export interface ChatRequest {
conversationId?: string;
message: string;
images?: string[];
files?: string[];
model?: string;
temperature?: number;
maxTokens?: number;
systemPrompt?: string;
stream?: boolean;
// 扩展选项
deepSearch?: boolean;
webSearch?: boolean;
deepThinking?: boolean;
}
export interface ChatResponse {
id: string;
conversationId: string;
content: string;
model: string;
createdAt: number;
usage?: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
};
}
export interface ModelInfo {
id: string;
name: string;
description: string;
maxTokens: number;
provider: string;
}
export interface UploadResult {
url: string;
name: string;
size?: number;
mimeType?: string;
}
// API 调用类
class ChatApi {
private baseUrl: string;
constructor(baseUrl = "") {
this.baseUrl = baseUrl;
}
/**
*
*/
async *streamChat(
request: ChatRequest,
signal?: AbortSignal,
): AsyncGenerator<string> {
// 将前端简化的请求翻译为 OpenAI 兼容的规范请求体
const openAiRequest = {
model: request.model || "qwen-plus",
messages: [
{ role: "system", content: request.systemPrompt || "你是一个有用的助手。" },
{ role: "user", content: request.message }
],
stream: true,
temperature: request.temperature,
max_tokens: request.maxTokens
};
const response = await fetch(
`${this.baseUrl}${API_ENDPOINTS.CHAT_STREAM}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify(openAiRequest),
signal,
},
);
if (!response.ok) {
const error = await response.text();
throw new Error(error || `HTTP ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error("Response body is not readable");
}
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
// 保留最后一行未完整的 JSON
buffer = lines.pop() || "";
for (const line of lines) {
if (line.trim() === "" || line.includes("[DONE]")) continue;
const match = line.match(/^data:\s*(.+)$/);
if (match) {
try {
const data = JSON.parse(match[1]);
const content = data.choices?.[0]?.delta?.content;
if (content) {
yield content;
}
} catch (e) {
console.warn("JSON解析错误", e, line);
}
}
}
}
}
/**
*
*/
async chat(request: ChatRequest): Promise<ChatResponse> {
const response = await fetch(`${this.baseUrl}${API_ENDPOINTS.CHAT}`, {
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();
}
/**
*
*/
async stopChat(messageId?: string) {
await fetch(`${this.baseUrl}${API_ENDPOINTS.STOP}/${messageId}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
}
/**
*
*/
async getModels(): Promise<ModelInfo[]> {
return [
{
id: "qwen-max",
name: "通义千问 Max",
description: "最强大的模型",
maxTokens: 8192,
provider: "Aliyun",
},
{
id: "qwen-plus",
name: "通义千问 Plus",
description: "能力均衡",
maxTokens: 8192,
provider: "Aliyun",
},
];
}
/**
*
*/
async uploadFile(file: File): Promise<UploadResult> {
const formData = new FormData();
formData.append("file", file);
const response = await fetch(`${this.baseUrl}${API_ENDPOINTS.UPLOAD}`, {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error(`上传失败: HTTP ${response.status}`);
}
return response.json();
}
}
// 导出单例
export const chatApi = new ChatApi();
// 导出类用于自定义配置
export { ChatApi, API_ENDPOINTS };
// 导出端点常量(供调试使用)
// export {API_ENDPOINTS}

View File

@ -1,270 +0,0 @@
import { generateId } from '@/utils/helpers'
// 模拟响应数据
const mockResponses: Record<string, string> = {
default: `你好!我是 AI 智能助手,很高兴为你服务。
-
-
-
-
`,
code: `好的,这是一个 Vue 3 组件示例:
\`\`\`vue
<template>
<div class="counter">
<h2>: {{ count }}</h2>
<button @click="increment"></button>
<button @click="decrement"></button>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
function increment() {
count.value++
}
function decrement() {
count.value--
}
</script>
<style scoped>
.counter {
padding: 20px;
text-align: center;
}
button {
margin: 0 8px;
padding: 8px 16px;
}
</style>
\`\`\`
Vue 3
1. **Composition API**: 使 \`<script setup>\` 语法
2. ****: 使 \`ref\` 创建响应式变量
3. ****: 使 \`@click\` 绑定事件
4. ****: 使 \`scoped\` 样式`,
ml: `**机器学习Machine Learning** 是人工智能的一个分支,它使计算机系统能够从数据中学习并改进,而无需进行明确的编程。
##
### 1.
- 使
-
### 2.
- 使
-
### 3.
-
- AI
##
| | |
|------|----------|
| | |
| | |
| | |
> 💡 `,
email: `好的,这是一封商务邮件模板:
---
****
[]
****
1. []
2. []
3. []
[]
[]
[]
---
`,
react: `# React 应用性能优化指南
## 1.
### 使 React.memo
\`\`\`jsx
const MyComponent = React.memo(({ data }) => {
return <div>{data.name}</div>
})
\`\`\`
### 使 useMemo useCallback
\`\`\`javascript
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b])
const memoizedCallback = useCallback(() => doSomething(a, b), [a, b])
\`\`\`
## 2.
\`\`\`javascript
const LazyComponent = React.lazy(() => import('./LazyComponent'))
function App() {
return (
<Suspense fallback={<Loading />}>
<LazyComponent />
</Suspense>
)
}
\`\`\`
## 3.
使 **react-window** **react-virtualized**
\`\`\`javascript
import { FixedSizeList } from 'react-window'
<FixedSizeList
height={400}
itemCount={1000}
itemSize={35}
>
{Row}
</FixedSizeList>
\`\`\`
## 4.
使 React DevTools Profiler
> 🚀 ****`,
}
// 根据输入内容匹配响应
function matchResponse(input: string): string {
const lowerInput = input.toLowerCase()
if (lowerInput.includes('vue') || lowerInput.includes('组件')) {
return mockResponses.code
}
if (lowerInput.includes('机器学习') || lowerInput.includes('ml') || lowerInput.includes('学习')) {
return mockResponses.ml
}
if (lowerInput.includes('邮件') || lowerInput.includes('商务')) {
return mockResponses.email
}
if (lowerInput.includes('react') || lowerInput.includes('性能') || lowerInput.includes('优化')) {
return mockResponses.react
}
return mockResponses.default
}
// 流式输出生成器
async function* streamText(text: string, signal?: AbortSignal): AsyncGenerator<string> {
const chars = text.split('')
let buffer = ''
for (let i = 0; i < chars.length; i++) {
if (signal?.aborted) {
break
}
buffer += chars[i]
const delay = Math.random() * 20 + 5
await new Promise(resolve => setTimeout(resolve, delay))
if (buffer.length >= 3 || i === chars.length - 1) {
yield buffer
buffer = ''
}
}
}
// 模拟 AI 响应接口
export interface StreamCallbacks {
onStart?: () => void
onToken?: (token: string, fullText: string) => void
onComplete?: (fullText: string) => void
onError?: (error: Error) => void
}
export async function streamAIResponse(
userMessage: string,
callbacks: StreamCallbacks,
signal?: AbortSignal
): Promise<void> {
try {
callbacks.onStart?.()
await new Promise(resolve => setTimeout(resolve, 500))
if (signal?.aborted) {
return
}
const response = matchResponse(userMessage)
let fullText = ''
for await (const token of streamText(response, signal)) {
if (signal?.aborted) {
break
}
fullText += token
callbacks.onToken?.(token, fullText)
}
if (!signal?.aborted) {
callbacks.onComplete?.(fullText)
}
} catch (error) {
if (error instanceof Error && error.name !== 'AbortError') {
callbacks.onError?.(error)
}
}
}
// 生成推荐选项
export function generateSuggestions(): { id: string; text: string }[] {
const suggestions = [
'继续深入讲解',
'给我一个实际例子',
'有什么最佳实践吗?',
'可以用中文解释吗?',
]
return suggestions.map(text => ({
id: generateId(),
text,
}))
}

View File

@ -1,285 +1,285 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import type {
Conversation,
Message,
MessageContent,
ConversationSettings,
} from "@/types/chat";
import { MessageRole } from "@/types/chat";
import { generateId, extractTitleFromMessage } from "@/utils/helpers";
export const useChatStore = defineStore("chat", () => {
// 状态
const conversations = ref<Conversation[]>([]);
const currentConversationId = ref<string | null>(null);
const isStreaming = ref(false);
const streamController = ref<AbortController | null>(null);
// 计算属性
const currentConversation = computed(() => {
return (
conversations.value.find((c) => c.id === currentConversationId.value) ||
null
);
});
const sortedConversations = computed(() => {
return [...conversations.value].sort((a, b) => {
if (a.pinned && !b.pinned) return -1;
if (!a.pinned && b.pinned) return 1;
return b.updatedAt - a.updatedAt;
});
});
const pinnedConversations = computed(() => {
return sortedConversations.value.filter((c) => c.pinned && !c.archived);
});
const recentConversations = computed(() => {
return sortedConversations.value.filter((c) => !c.pinned && !c.archived);
});
// 方法
function createConversation(): string {
const newConversation: Conversation = {
id: generateId(),
title: "新对话",
messages: [],
createdAt: Date.now(),
updatedAt: Date.now(),
pinned: false,
archived: false,
settings: undefined,
};
conversations.value.unshift(newConversation);
currentConversationId.value = newConversation.id;
saveToStorage();
return newConversation.id;
}
function deleteConversation(id: string) {
const index = conversations.value.findIndex((c) => c.id === id);
if (index !== -1) {
conversations.value.splice(index, 1);
if (currentConversationId.value === id) {
currentConversationId.value = conversations.value[0]?.id || null;
}
saveToStorage();
}
}
function selectConversation(id: string) {
currentConversationId.value = id;
}
function togglePinConversation(id: string) {
const conversation = conversations.value.find((c) => c.id === id);
if (conversation) {
conversation.pinned = !conversation.pinned;
saveToStorage();
}
}
function renameConversation(id: string, newTitle: string) {
const conversation = conversations.value.find((c) => c.id === id);
if (conversation) {
conversation.title = newTitle;
conversation.updatedAt = Date.now();
saveToStorage();
}
}
function updateConversationSettings(
id: string,
convSettings: ConversationSettings,
) {
const conversation = conversations.value.find((c) => c.id === id);
if (conversation) {
conversation.settings = { ...conversation.settings, ...convSettings };
conversation.updatedAt = Date.now();
saveToStorage();
}
}
function addMessage(
role: MessageRole,
content: MessageContent,
conversationId?: string,
): Message {
const targetId = conversationId || currentConversationId.value;
if (!targetId) {
createConversation();
}
const conversation = conversations.value.find(
(c) => c.id === (targetId || currentConversationId.value),
);
if (!conversation) {
throw new Error("Conversation not found");
}
const message: any = {
id: generateId(),
role,
content,
timestamp: Date.now(),
isStreaming: false,
};
conversation.messages.push(message);
conversation.updatedAt = Date.now();
if (
role === MessageRole.USER &&
conversation.messages.length === 1 &&
content.text
) {
conversation.title = extractTitleFromMessage(content.text);
}
saveToStorage();
return message;
}
function updateMessage(messageId: string, updates: Partial<Message>) {
const conversation = currentConversation.value;
if (!conversation) return;
const message = conversation.messages.find((m) => m.id === messageId);
if (message) {
Object.assign(message, updates);
saveToStorage();
}
}
function updateMessageContent(messageId: string, text: string) {
const conversation = currentConversation.value;
if (!conversation) return;
const message = conversation.messages.find((m) => m.id === messageId);
if (message) {
message.content.text = text;
}
}
function setMessageFeedback(
messageId: string,
feedback: "like" | "dislike" | null,
) {
const conversation = currentConversation.value;
if (!conversation) return;
const message = conversation.messages.find((m) => m.id === messageId);
if (message) {
message.feedback = {
liked: feedback === "like",
disliked: feedback === "dislike",
copied: message.feedback?.copied,
};
saveToStorage();
}
}
function setMessageCopied(messageId: string) {
const conversation = currentConversation.value;
if (!conversation) return;
const message = conversation.messages.find((m) => m.id === messageId);
if (message) {
message.feedback = {
...message.feedback,
copied: true,
};
}
}
function startStreaming() {
isStreaming.value = true;
streamController.value = new AbortController();
}
function stopStreaming() {
isStreaming.value = false;
if (streamController.value) {
streamController.value.abort();
streamController.value = null;
}
}
function clearConversation(id: string) {
const conversation = conversations.value.find((c) => c.id === id);
if (conversation) {
conversation.messages = [];
conversation.updatedAt = Date.now();
saveToStorage();
}
}
function saveToStorage() {
try {
localStorage.setItem(
"chat-conversations",
JSON.stringify(conversations.value),
);
localStorage.setItem(
"chat-current-id",
currentConversationId.value || "",
);
} catch (e) {
console.error("Failed to save to storage:", e);
}
}
function loadFromStorage() {
try {
const stored = localStorage.getItem("chat-conversations");
if (stored) {
conversations.value = JSON.parse(stored);
}
const storedId = localStorage.getItem("chat-current-id");
if (storedId && conversations.value.find((c) => c.id === storedId)) {
currentConversationId.value = storedId;
} else if (conversations.value.length > 0) {
currentConversationId.value = conversations.value[0].id;
}
} catch (e) {
console.error("Failed to load from storage:", e);
}
}
loadFromStorage();
return {
conversations,
currentConversationId,
isStreaming,
streamController,
currentConversation,
sortedConversations,
pinnedConversations,
recentConversations,
createConversation,
deleteConversation,
selectConversation,
togglePinConversation,
renameConversation,
updateConversationSettings,
addMessage,
updateMessage,
updateMessageContent,
setMessageFeedback,
setMessageCopied,
startStreaming,
stopStreaming,
clearConversation,
loadFromStorage,
};
});
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import type {
Conversation,
Message,
MessageContent,
ConversationSettings,
} from "@/types/chat";
import { MessageRole } from "@/types/chat";
import { generateId, extractTitleFromMessage } from "@/utils/helpers";
export const useChatStore = defineStore("chat", () => {
// 状态
const conversations = ref<Conversation[]>([]);
const currentConversationId = ref<string | null>(null);
const isStreaming = ref(false);
const streamController = ref<AbortController | null>(null);
// 计算属性
const currentConversation = computed(() => {
return (
conversations.value.find((c) => c.id === currentConversationId.value) ||
null
);
});
const sortedConversations = computed(() => {
return [...conversations.value].sort((a, b) => {
if (a.pinned && !b.pinned) return -1;
if (!a.pinned && b.pinned) return 1;
return b.updatedAt - a.updatedAt;
});
});
const pinnedConversations = computed(() => {
return sortedConversations.value.filter((c) => c.pinned && !c.archived);
});
const recentConversations = computed(() => {
return sortedConversations.value.filter((c) => !c.pinned && !c.archived);
});
// 方法
function createConversation(): string {
const newConversation: Conversation = {
id: generateId(),
title: "新对话",
messages: [],
createdAt: Date.now(),
updatedAt: Date.now(),
pinned: false,
archived: false,
settings: undefined,
};
conversations.value.unshift(newConversation);
currentConversationId.value = newConversation.id;
saveToStorage();
return newConversation.id;
}
function deleteConversation(id: string) {
const index = conversations.value.findIndex((c) => c.id === id);
if (index !== -1) {
conversations.value.splice(index, 1);
if (currentConversationId.value === id) {
currentConversationId.value = conversations.value[0]?.id || null;
}
saveToStorage();
}
}
function selectConversation(id: string) {
currentConversationId.value = id;
}
function togglePinConversation(id: string) {
const conversation = conversations.value.find((c) => c.id === id);
if (conversation) {
conversation.pinned = !conversation.pinned;
saveToStorage();
}
}
function renameConversation(id: string, newTitle: string) {
const conversation = conversations.value.find((c) => c.id === id);
if (conversation) {
conversation.title = newTitle;
conversation.updatedAt = Date.now();
saveToStorage();
}
}
function updateConversationSettings(
id: string,
convSettings: ConversationSettings,
) {
const conversation = conversations.value.find((c) => c.id === id);
if (conversation) {
conversation.settings = { ...conversation.settings, ...convSettings };
conversation.updatedAt = Date.now();
saveToStorage();
}
}
function addMessage(
role: MessageRole,
content: MessageContent,
conversationId?: string,
): Message {
const targetId = conversationId || currentConversationId.value;
if (!targetId) {
createConversation();
}
const conversation = conversations.value.find(
(c) => c.id === (targetId || currentConversationId.value),
);
if (!conversation) {
throw new Error("Conversation not found");
}
const message: any = {
id: generateId(),
role,
content,
timestamp: Date.now(),
isStreaming: false,
};
conversation.messages.push(message);
conversation.updatedAt = Date.now();
if (
role === MessageRole.USER &&
conversation.messages.length === 1 &&
content.text
) {
conversation.title = extractTitleFromMessage(content.text);
}
saveToStorage();
return message;
}
function updateMessage(messageId: string, updates: Partial<Message>) {
const conversation = currentConversation.value;
if (!conversation) return;
const message = conversation.messages.find((m) => m.id === messageId);
if (message) {
Object.assign(message, updates);
saveToStorage();
}
}
function updateMessageContent(messageId: string, text: string) {
const conversation = currentConversation.value;
if (!conversation) return;
const message = conversation.messages.find((m) => m.id === messageId);
if (message) {
message.content.text = text;
}
}
function setMessageFeedback(
messageId: string,
feedback: "like" | "dislike" | null,
) {
const conversation = currentConversation.value;
if (!conversation) return;
const message = conversation.messages.find((m) => m.id === messageId);
if (message) {
message.feedback = {
liked: feedback === "like",
disliked: feedback === "dislike",
copied: message.feedback?.copied,
};
saveToStorage();
}
}
function setMessageCopied(messageId: string) {
const conversation = currentConversation.value;
if (!conversation) return;
const message = conversation.messages.find((m) => m.id === messageId);
if (message) {
message.feedback = {
...message.feedback,
copied: true,
};
}
}
function startStreaming() {
isStreaming.value = true;
streamController.value = new AbortController();
}
function stopStreaming() {
isStreaming.value = false;
if (streamController.value) {
streamController.value.abort();
streamController.value = null;
}
}
function clearConversation(id: string) {
const conversation = conversations.value.find((c) => c.id === id);
if (conversation) {
conversation.messages = [];
conversation.updatedAt = Date.now();
saveToStorage();
}
}
function saveToStorage() {
try {
localStorage.setItem(
"chat-conversations",
JSON.stringify(conversations.value),
);
localStorage.setItem(
"chat-current-id",
currentConversationId.value || "",
);
} catch (e) {
console.error("Failed to save to storage:", e);
}
}
function loadFromStorage() {
try {
const stored = localStorage.getItem("chat-conversations");
if (stored) {
conversations.value = JSON.parse(stored);
}
const storedId = localStorage.getItem("chat-current-id");
if (storedId && conversations.value.find((c) => c.id === storedId)) {
currentConversationId.value = storedId;
} else if (conversations.value.length > 0) {
currentConversationId.value = conversations.value[0].id;
}
} catch (e) {
console.error("Failed to load from storage:", e);
}
}
loadFromStorage();
return {
conversations,
currentConversationId,
isStreaming,
streamController,
currentConversation,
sortedConversations,
pinnedConversations,
recentConversations,
createConversation,
deleteConversation,
selectConversation,
togglePinConversation,
renameConversation,
updateConversationSettings,
addMessage,
updateMessage,
updateMessageContent,
setMessageFeedback,
setMessageCopied,
startStreaming,
stopStreaming,
clearConversation,
loadFromStorage,
};
});

View File

@ -1,291 +1,284 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { AppSettings, AIModel } from '@/types/chat'
export const useSettingsStore = defineStore('settings', () => {
// 默认设置
const defaultSettings: AppSettings = {
// 外观设置
theme: 'system',
language: 'zh-CN',
fontSize: 'medium',
// 对话设置
sendOnEnter: false,
showTimestamp: true,
compactMode: false,
// AI 默认设置
defaultModel: 'gpt-4',
defaultTemperature: 0.7,
defaultMaxTokens: 4096,
defaultSystemPrompt: '你是一个有帮助的 AI 助手。',
// 功能设置
enableSound: true,
enableNotification: true,
autoSaveInterval: 30,
// 隐私设置
saveHistory: true,
shareAnalytics: false,
}
// 可用的 AI 模型
const availableModels: AIModel[] = [
{
id: 'gpt-4',
name: 'GPT-4',
description: '最强大的模型,适合复杂任务',
maxTokens: 8192,
provider: 'OpenAI',
},
{
id: 'gpt-4-turbo',
name: 'GPT-4 Turbo',
description: '更快的响应速度128K 上下文',
maxTokens: 128000,
provider: 'OpenAI',
},
{
id: 'gpt-3.5-turbo',
name: 'GPT-3.5 Turbo',
description: '快速高效,适合日常对话',
maxTokens: 16384,
provider: 'OpenAI',
},
{
id: 'claude-3-opus',
name: 'Claude 3 Opus',
description: '优秀的长文本处理能力',
maxTokens: 200000,
provider: 'Anthropic',
},
{
id: 'claude-3-sonnet',
name: 'Claude 3 Sonnet',
description: '平衡性能与成本',
maxTokens: 200000,
provider: 'Anthropic',
},
]
// 状态
const settings = ref<AppSettings>({ ...defaultSettings })
const sidebarCollapsed = ref(false)
const sidebarWidth = ref(280)
const showShortcutsModal = ref(false)
const showSearchModal = ref(false)
const showSettingsModal = ref(false)
const showConversationSettingsModal = ref(false)
// 主题相关
function applyTheme(theme: AppSettings['theme']) {
const root = document.documentElement
if (theme === 'system') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
root.classList.toggle('dark', prefersDark)
} else {
root.classList.toggle('dark', theme === 'dark')
}
}
function toggleTheme() {
const themes: AppSettings['theme'][] = ['light', 'dark', 'system']
const currentIndex = themes.indexOf(settings.value.theme)
settings.value.theme = themes[(currentIndex + 1) % themes.length]
applyTheme(settings.value.theme)
saveToStorage()
}
function setTheme(theme: AppSettings['theme']) {
settings.value.theme = theme
applyTheme(theme)
saveToStorage()
}
// 字体大小
function applyFontSize(size: AppSettings['fontSize']) {
const root = document.documentElement
const sizeMap = {
small: '14px',
medium: '16px',
large: '18px',
}
root.style.setProperty('--base-font-size', sizeMap[size])
}
function setFontSize(size: AppSettings['fontSize']) {
settings.value.fontSize = size
applyFontSize(size)
saveToStorage()
}
// 侧边栏
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value
saveToStorage()
}
function setSidebarWidth(width: number) {
sidebarWidth.value = Math.max(200, Math.min(400, width))
saveToStorage()
}
// 模态框
function openShortcutsModal() {
showShortcutsModal.value = true
}
function closeShortcutsModal() {
showShortcutsModal.value = false
}
function openSearchModal() {
showSearchModal.value = true
}
function closeSearchModal() {
showSearchModal.value = false
}
function openSettingsModal() {
showSettingsModal.value = true
}
function closeSettingsModal() {
showSettingsModal.value = false
}
function openConversationSettingsModal() {
showConversationSettingsModal.value = true
}
function closeConversationSettingsModal() {
showConversationSettingsModal.value = false
}
// 更新设置
function updateSettings(updates: Partial<AppSettings>) {
Object.assign(settings.value, updates)
if (updates.theme) {
applyTheme(updates.theme)
}
if (updates.fontSize) {
applyFontSize(updates.fontSize)
}
saveToStorage()
}
// 重置设置
function resetSettings() {
settings.value = { ...defaultSettings }
applyTheme(settings.value.theme)
applyFontSize(settings.value.fontSize)
saveToStorage()
}
// 导出设置
function exportSettings(): string {
return JSON.stringify(settings.value, null, 2)
}
// 导入设置
function importSettings(json: string): boolean {
try {
const imported = JSON.parse(json)
settings.value = { ...defaultSettings, ...imported }
applyTheme(settings.value.theme)
applyFontSize(settings.value.fontSize)
saveToStorage()
return true
} catch {
return false
}
}
// 存储
function saveToStorage() {
try {
localStorage.setItem('chat-settings', JSON.stringify(settings.value))
localStorage.setItem('chat-sidebar-collapsed', JSON.stringify(sidebarCollapsed.value))
localStorage.setItem('chat-sidebar-width', JSON.stringify(sidebarWidth.value))
} catch (e) {
console.error('Failed to save settings:', e)
}
}
function loadFromStorage() {
try {
const stored = localStorage.getItem('chat-settings')
if (stored) {
settings.value = { ...defaultSettings, ...JSON.parse(stored) }
}
const collapsedStored = localStorage.getItem('chat-sidebar-collapsed')
if (collapsedStored) {
sidebarCollapsed.value = JSON.parse(collapsedStored)
}
const widthStored = localStorage.getItem('chat-sidebar-width')
if (widthStored) {
sidebarWidth.value = JSON.parse(widthStored)
}
// 应用主题和字体
applyTheme(settings.value.theme)
applyFontSize(settings.value.fontSize)
} catch (e) {
console.error('Failed to load settings:', e)
}
}
// 监听系统主题变化
if (typeof window !== 'undefined') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQuery.addEventListener('change', () => {
if (settings.value.theme === 'system') {
applyTheme('system')
}
})
}
// 初始化
loadFromStorage()
return {
// 状态
settings,
sidebarCollapsed,
sidebarWidth,
showShortcutsModal,
showSearchModal,
showSettingsModal,
showConversationSettingsModal,
availableModels,
// 方法
toggleTheme,
setTheme,
setFontSize,
toggleSidebar,
setSidebarWidth,
openShortcutsModal,
closeShortcutsModal,
openSearchModal,
closeSearchModal,
openSettingsModal,
closeSettingsModal,
openConversationSettingsModal,
closeConversationSettingsModal,
updateSettings,
resetSettings,
exportSettings,
importSettings,
loadFromStorage,
}
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { AppSettings, AIModel } from '@/types/chat'
export const useSettingsStore = defineStore('settings', () => {
// 默认设置
const defaultSettings: AppSettings = {
// 外观设置
theme: 'system',
language: 'zh-CN',
fontSize: 'medium',
// 对话设置
sendOnEnter: false,
showTimestamp: true,
compactMode: false,
// AI 默认设置
defaultModel: 'qwen-plus',
defaultTemperature: 0.7,
defaultMaxTokens: 4096,
defaultSystemPrompt: '你是一个有帮助的 AI 助手。',
// 功能设置
enableSound: true,
enableNotification: true,
autoSaveInterval: 30,
// 隐私设置
saveHistory: true,
shareAnalytics: false,
}
// 可用的 AI 模型
const availableModels: AIModel[] = [
{
id: 'qwen-max',
name: '通义千问 Max',
description: '最强大的模型,适合复杂任务',
maxTokens: 8192,
provider: 'Aliyun',
},
{
id: 'qwen-plus',
name: '通义千问 Plus',
description: '能力均衡,更快的响应速度',
maxTokens: 8192,
provider: 'Aliyun',
},
{
id: 'qwen-turbo',
name: '通义千问 Turbo',
description: '快速高效,适合日常对话',
maxTokens: 8192,
provider: 'Aliyun',
},
{
id: 'qwen-vl-max',
name: '通义千问 VL-Max',
description: '强大的视觉理解模型',
maxTokens: 8192,
provider: 'Aliyun',
},
]
// 状态
const settings = ref<AppSettings>({ ...defaultSettings })
const sidebarCollapsed = ref(false)
const sidebarWidth = ref(280)
const showShortcutsModal = ref(false)
const showSearchModal = ref(false)
const showSettingsModal = ref(false)
const showConversationSettingsModal = ref(false)
// 主题相关
function applyTheme(theme: AppSettings['theme']) {
const root = document.documentElement
if (theme === 'system') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
root.classList.toggle('dark', prefersDark)
} else {
root.classList.toggle('dark', theme === 'dark')
}
}
function toggleTheme() {
const themes: AppSettings['theme'][] = ['light', 'dark', 'system']
const currentIndex = themes.indexOf(settings.value.theme)
settings.value.theme = themes[(currentIndex + 1) % themes.length]
applyTheme(settings.value.theme)
saveToStorage()
}
function setTheme(theme: AppSettings['theme']) {
settings.value.theme = theme
applyTheme(theme)
saveToStorage()
}
// 字体大小
function applyFontSize(size: AppSettings['fontSize']) {
const root = document.documentElement
const sizeMap = {
small: '14px',
medium: '16px',
large: '18px',
}
root.style.setProperty('--base-font-size', sizeMap[size])
}
function setFontSize(size: AppSettings['fontSize']) {
settings.value.fontSize = size
applyFontSize(size)
saveToStorage()
}
// 侧边栏
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value
saveToStorage()
}
function setSidebarWidth(width: number) {
sidebarWidth.value = Math.max(200, Math.min(400, width))
saveToStorage()
}
// 模态框
function openShortcutsModal() {
showShortcutsModal.value = true
}
function closeShortcutsModal() {
showShortcutsModal.value = false
}
function openSearchModal() {
showSearchModal.value = true
}
function closeSearchModal() {
showSearchModal.value = false
}
function openSettingsModal() {
showSettingsModal.value = true
}
function closeSettingsModal() {
showSettingsModal.value = false
}
function openConversationSettingsModal() {
showConversationSettingsModal.value = true
}
function closeConversationSettingsModal() {
showConversationSettingsModal.value = false
}
// 更新设置
function updateSettings(updates: Partial<AppSettings>) {
Object.assign(settings.value, updates)
if (updates.theme) {
applyTheme(updates.theme)
}
if (updates.fontSize) {
applyFontSize(updates.fontSize)
}
saveToStorage()
}
// 重置设置
function resetSettings() {
settings.value = { ...defaultSettings }
applyTheme(settings.value.theme)
applyFontSize(settings.value.fontSize)
saveToStorage()
}
// 导出设置
function exportSettings(): string {
return JSON.stringify(settings.value, null, 2)
}
// 导入设置
function importSettings(json: string): boolean {
try {
const imported = JSON.parse(json)
settings.value = { ...defaultSettings, ...imported }
applyTheme(settings.value.theme)
applyFontSize(settings.value.fontSize)
saveToStorage()
return true
} catch {
return false
}
}
// 存储
function saveToStorage() {
try {
localStorage.setItem('chat-settings', JSON.stringify(settings.value))
localStorage.setItem('chat-sidebar-collapsed', JSON.stringify(sidebarCollapsed.value))
localStorage.setItem('chat-sidebar-width', JSON.stringify(sidebarWidth.value))
} catch (e) {
console.error('Failed to save settings:', e)
}
}
function loadFromStorage() {
try {
const stored = localStorage.getItem('chat-settings')
if (stored) {
settings.value = { ...defaultSettings, ...JSON.parse(stored) }
}
const collapsedStored = localStorage.getItem('chat-sidebar-collapsed')
if (collapsedStored) {
sidebarCollapsed.value = JSON.parse(collapsedStored)
}
const widthStored = localStorage.getItem('chat-sidebar-width')
if (widthStored) {
sidebarWidth.value = JSON.parse(widthStored)
}
// 应用主题和字体
applyTheme(settings.value.theme)
applyFontSize(settings.value.fontSize)
} catch (e) {
console.error('Failed to load settings:', e)
}
}
// 监听系统主题变化
if (typeof window !== 'undefined') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQuery.addEventListener('change', () => {
if (settings.value.theme === 'system') {
applyTheme('system')
}
})
}
// 初始化
loadFromStorage()
return {
// 状态
settings,
sidebarCollapsed,
sidebarWidth,
showShortcutsModal,
showSearchModal,
showSettingsModal,
showConversationSettingsModal,
availableModels,
// 方法
toggleTheme,
setTheme,
setFontSize,
toggleSidebar,
setSidebarWidth,
openShortcutsModal,
closeShortcutsModal,
openSearchModal,
closeSearchModal,
openSettingsModal,
closeSettingsModal,
openConversationSettingsModal,
closeConversationSettingsModal,
updateSettings,
resetSettings,
exportSettings,
importSettings,
loadFromStorage,
}
})

View File

@ -1,79 +1,79 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

@ -1,78 +1,78 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
// 自定义滚动条
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(155, 155, 155, 0.5);
border-radius: 3px;
&:hover {
background: rgba(155, 155, 155, 0.7);
}
}
// 暗色模式滚动条
.dark {
::-webkit-scrollbar-thumb {
background: rgba(100, 100, 100, 0.5);
&:hover {
background: rgba(100, 100, 100, 0.7);
}
}
}
// 全局变量
:root {
--chat-sidebar-width: 280px;
--chat-input-height: 140px;
--header-height: 60px;
}
// 基础样式重置
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
// 过渡动画
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-enter-active,
.slide-leave-active {
transition: all 0.3s ease;
}
.slide-enter-from {
opacity: 0;
transform: translateX(-20px);
}
.slide-leave-to {
opacity: 0;
transform: translateX(-20px);
@tailwind base;
@tailwind components;
@tailwind utilities;
// 自定义滚动条
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(155, 155, 155, 0.5);
border-radius: 3px;
&:hover {
background: rgba(155, 155, 155, 0.7);
}
}
// 暗色模式滚动条
.dark {
::-webkit-scrollbar-thumb {
background: rgba(100, 100, 100, 0.5);
&:hover {
background: rgba(100, 100, 100, 0.7);
}
}
}
// 全局变量
:root {
--chat-sidebar-width: 280px;
--chat-input-height: 140px;
--header-height: 60px;
}
// 基础样式重置
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
// 过渡动画
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-enter-active,
.slide-leave-active {
transition: all 0.3s ease;
}
.slide-enter-from {
opacity: 0;
transform: translateX(-20px);
}
.slide-leave-to {
opacity: 0;
transform: translateX(-20px);
}

View File

@ -1,146 +1,146 @@
// 消息类型枚举
export enum MessageType {
TEXT = "text",
IMAGE = "image",
VIDEO = "video",
MULTI_VIDEO = "multi_video",
FILE = "file",
CODE = "code",
SUGGESTION = "suggestion",
THINKING = "thinking",
}
// 消息角色
export enum MessageRole {
USER = "user",
ASSISTANT = "assistant",
SYSTEM = "system",
}
// 附件类型
export interface Attachment {
id: string;
name: string;
type: "image" | "file" | "video";
url: string;
size?: number;
mimeType?: string;
thumbnail?: string;
}
// 推荐选项
export interface Suggestion {
id: string;
text: string;
icon?: string;
}
// 视频信息
export interface VideoInfo {
id: string;
url: string;
poster?: string;
title?: string;
duration?: number;
}
// 消息内容
export interface MessageContent {
type: MessageType;
text?: string;
images?: Attachment[];
videos?: VideoInfo[];
files?: Attachment[];
suggestions?: Suggestion[];
codeLanguage?: string;
}
// 消息反馈
export interface MessageFeedback {
liked?: boolean;
disliked?: boolean;
copied?: boolean;
}
// 单条消息
export interface Message {
id: string;
role: MessageRole;
content: MessageContent;
timestamp: number;
feedback?: MessageFeedback;
isStreaming?: boolean;
isError?: boolean;
isEnd?: boolean;
isBreak?: boolean;
errorMessage?: string;
messageId: string;
}
// 对话设置
export interface ConversationSettings {
model: string;
temperature: number;
maxTokens: number;
systemPrompt: string;
enableMemory: boolean;
memoryLength: number;
}
// 对话
export interface Conversation {
id: string;
title: string;
messages: Message[];
createdAt: number;
updatedAt: number;
pinned?: boolean;
archived?: boolean;
settings?: ConversationSettings;
}
// 输入框状态
export interface InputState {
text: string;
attachments: Attachment[];
isDeepSearch: boolean;
isWebSearch: boolean;
}
// 应用设置
export interface AppSettings {
// 外观设置
theme: "light" | "dark" | "system";
language: string;
fontSize: "small" | "medium" | "large";
// 对话设置
sendOnEnter: boolean;
showTimestamp: boolean;
compactMode: boolean;
// AI 默认设置
defaultModel: string;
defaultTemperature: number;
defaultMaxTokens: number;
defaultSystemPrompt: string;
// 功能设置
enableSound: boolean;
enableNotification: boolean;
autoSaveInterval: number;
// 隐私设置
saveHistory: boolean;
shareAnalytics: boolean;
}
// AI 模型配置
export interface AIModel {
id: string;
name: string;
description: string;
maxTokens: number;
provider: string;
icon?: string;
}
// 消息类型枚举
export enum MessageType {
TEXT = "text",
IMAGE = "image",
VIDEO = "video",
MULTI_VIDEO = "multi_video",
FILE = "file",
CODE = "code",
SUGGESTION = "suggestion",
THINKING = "thinking",
}
// 消息角色
export enum MessageRole {
USER = "user",
ASSISTANT = "assistant",
SYSTEM = "system",
}
// 附件类型
export interface Attachment {
id: string;
name: string;
type: "image" | "file" | "video";
url: string;
size?: number;
mimeType?: string;
thumbnail?: string;
}
// 推荐选项
export interface Suggestion {
id: string;
text: string;
icon?: string;
}
// 视频信息
export interface VideoInfo {
id: string;
url: string;
poster?: string;
title?: string;
duration?: number;
}
// 消息内容
export interface MessageContent {
type: MessageType;
text?: string;
images?: Attachment[];
videos?: VideoInfo[];
files?: Attachment[];
suggestions?: Suggestion[];
codeLanguage?: string;
}
// 消息反馈
export interface MessageFeedback {
liked?: boolean;
disliked?: boolean;
copied?: boolean;
}
// 单条消息
export interface Message {
id: string;
role: MessageRole;
content: MessageContent;
timestamp: number;
feedback?: MessageFeedback;
isStreaming?: boolean;
isError?: boolean;
isEnd?: boolean;
isBreak?: boolean;
errorMessage?: string;
messageId: string;
}
// 对话设置
export interface ConversationSettings {
model: string;
temperature: number;
maxTokens: number;
systemPrompt: string;
enableMemory: boolean;
memoryLength: number;
}
// 对话
export interface Conversation {
id: string;
title: string;
messages: Message[];
createdAt: number;
updatedAt: number;
pinned?: boolean;
archived?: boolean;
settings?: ConversationSettings;
}
// 输入框状态
export interface InputState {
text: string;
attachments: Attachment[];
isDeepSearch: boolean;
isWebSearch: boolean;
}
// 应用设置
export interface AppSettings {
// 外观设置
theme: "light" | "dark" | "system";
language: string;
fontSize: "small" | "medium" | "large";
// 对话设置
sendOnEnter: boolean;
showTimestamp: boolean;
compactMode: boolean;
// AI 默认设置
defaultModel: string;
defaultTemperature: number;
defaultMaxTokens: number;
defaultSystemPrompt: string;
// 功能设置
enableSound: boolean;
enableNotification: boolean;
autoSaveInterval: number;
// 隐私设置
saveHistory: boolean;
shareAnalytics: boolean;
}
// AI 模型配置
export interface AIModel {
id: string;
name: string;
description: string;
maxTokens: number;
provider: string;
icon?: string;
}

View File

@ -1,148 +1,148 @@
// 删除未使用的 nanoid 导入,使用自定义实现
// 生成唯一ID
export function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
// 格式化时间戳
export function formatTimestamp(timestamp: number): string {
const date = new Date(timestamp);
const now = new Date();
const diff = now.getTime() - date.getTime();
if (diff < 60 * 1000) {
return "刚刚";
}
if (diff < 60 * 60 * 1000) {
const minutes = Math.floor(diff / (60 * 1000));
return `${minutes}分钟前`;
}
if (diff < 24 * 60 * 60 * 1000) {
const hours = Math.floor(diff / (60 * 60 * 1000));
return `${hours}小时前`;
}
if (date.getFullYear() === now.getFullYear()) {
return `${date.getMonth() + 1}${date.getDate()}${padZero(date.getHours())}:${padZero(date.getMinutes())}`;
}
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}`;
}
function padZero(num: number): string {
return num < 10 ? `0${num}` : `${num}`;
}
export function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
export function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength) + "...";
}
export async function copyToClipboard(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand("copy");
return true;
} catch {
return false;
} finally {
document.body.removeChild(textarea);
}
}
}
export function extractTitleFromMessage(message: string): string {
const firstLine = message.split("\n")[0].trim();
return truncateText(firstLine, 30) || "新对话";
}
export function debounce<T extends (...args: any[]) => any>(
fn: T,
delay: number,
): (...args: Parameters<T>) => ReturnType<T> | undefined {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return function (this: any, ...args: Parameters<T>): any {
const context = this;
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
return fn.apply(context, args);
}, delay);
};
}
export function throttle<T extends (...args: unknown[]) => unknown>(
fn: T,
limit: number,
): (...args: Parameters<T>) => void {
let inThrottle = false;
return (...args: Parameters<T>) => {
if (!inThrottle) {
fn(...args);
inThrottle = true;
setTimeout(() => {
inThrottle = false;
}, limit);
}
};
}
export function getFileIcon(mimeType: string): string {
if (mimeType.startsWith("image/")) return "🖼️";
if (mimeType.startsWith("video/")) return "🎬";
if (mimeType.startsWith("audio/")) return "🎵";
if (mimeType.includes("pdf")) return "📄";
if (mimeType.includes("word") || mimeType.includes("document")) return "📝";
if (mimeType.includes("excel") || mimeType.includes("spreadsheet"))
return "📊";
if (mimeType.includes("powerpoint") || mimeType.includes("presentation"))
return "📽️";
if (
mimeType.includes("zip") ||
mimeType.includes("rar") ||
mimeType.includes("7z")
)
return "📦";
return "📎";
}
export function detectCodeLanguage(code: string): string {
if (code.includes("import React") || code.includes("jsx")) return "jsx";
if (code.includes("<template>") || code.includes("defineComponent"))
return "vue";
if (code.includes("func ") && code.includes("package ")) return "go";
if (code.includes("def ") && code.includes("import ")) return "python";
if (code.includes("public class") || code.includes("private void"))
return "java";
if (code.includes("fn ") && code.includes("let mut")) return "rust";
if (code.includes("interface ") || code.includes(": string"))
return "typescript";
return "javascript";
}
// 删除未使用的 nanoid 导入,使用自定义实现
// 生成唯一ID
export function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
// 格式化时间戳
export function formatTimestamp(timestamp: number): string {
const date = new Date(timestamp);
const now = new Date();
const diff = now.getTime() - date.getTime();
if (diff < 60 * 1000) {
return "刚刚";
}
if (diff < 60 * 60 * 1000) {
const minutes = Math.floor(diff / (60 * 1000));
return `${minutes}分钟前`;
}
if (diff < 24 * 60 * 60 * 1000) {
const hours = Math.floor(diff / (60 * 60 * 1000));
return `${hours}小时前`;
}
if (date.getFullYear() === now.getFullYear()) {
return `${date.getMonth() + 1}${date.getDate()}${padZero(date.getHours())}:${padZero(date.getMinutes())}`;
}
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}`;
}
function padZero(num: number): string {
return num < 10 ? `0${num}` : `${num}`;
}
export function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
export function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength) + "...";
}
export async function copyToClipboard(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand("copy");
return true;
} catch {
return false;
} finally {
document.body.removeChild(textarea);
}
}
}
export function extractTitleFromMessage(message: string): string {
const firstLine = message.split("\n")[0].trim();
return truncateText(firstLine, 30) || "新对话";
}
export function debounce<T extends (...args: any[]) => any>(
fn: T,
delay: number,
): (...args: Parameters<T>) => ReturnType<T> | undefined {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return function (this: any, ...args: Parameters<T>): any {
const context = this;
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
return fn.apply(context, args);
}, delay);
};
}
export function throttle<T extends (...args: unknown[]) => unknown>(
fn: T,
limit: number,
): (...args: Parameters<T>) => void {
let inThrottle = false;
return (...args: Parameters<T>) => {
if (!inThrottle) {
fn(...args);
inThrottle = true;
setTimeout(() => {
inThrottle = false;
}, limit);
}
};
}
export function getFileIcon(mimeType: string): string {
if (mimeType.startsWith("image/")) return "🖼️";
if (mimeType.startsWith("video/")) return "🎬";
if (mimeType.startsWith("audio/")) return "🎵";
if (mimeType.includes("pdf")) return "📄";
if (mimeType.includes("word") || mimeType.includes("document")) return "📝";
if (mimeType.includes("excel") || mimeType.includes("spreadsheet"))
return "📊";
if (mimeType.includes("powerpoint") || mimeType.includes("presentation"))
return "📽️";
if (
mimeType.includes("zip") ||
mimeType.includes("rar") ||
mimeType.includes("7z")
)
return "📦";
return "📎";
}
export function detectCodeLanguage(code: string): string {
if (code.includes("import React") || code.includes("jsx")) return "jsx";
if (code.includes("<template>") || code.includes("defineComponent"))
return "vue";
if (code.includes("func ") && code.includes("package ")) return "go";
if (code.includes("def ") && code.includes("import ")) return "python";
if (code.includes("public class") || code.includes("private void"))
return "java";
if (code.includes("fn ") && code.includes("let mut")) return "rust";
if (code.includes("interface ") || code.includes(": string"))
return "typescript";
return "javascript";
}

View File

@ -1,52 +1,52 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
dark: {
100: '#1e1e2e',
200: '#181825',
300: '#11111b',
400: '#0a0a0f',
}
},
animation: {
'fade-in': 'fadeIn 0.3s ease-out',
'slide-up': 'slideUp 0.3s ease-out',
'pulse-dot': 'pulseDot 1.5s infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { opacity: '0', transform: 'translateY(10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
pulseDot: {
'0%, 100%': { opacity: '0.4' },
'50%': { opacity: '1' },
}
}
},
},
plugins: [],
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
dark: {
100: '#1e1e2e',
200: '#181825',
300: '#11111b',
400: '#0a0a0f',
}
},
animation: {
'fade-in': 'fadeIn 0.3s ease-out',
'slide-up': 'slideUp 0.3s ease-out',
'pulse-dot': 'pulseDot 1.5s infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { opacity: '0', transform: 'translateY(10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
pulseDot: {
'0%, 100%': { opacity: '0.4' },
'50%': { opacity: '1' },
}
}
},
},
plugins: [],
}

View File

@ -1,32 +1,32 @@
{
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Paths */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
{
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Paths */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

View File

@ -1,31 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Paths */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"exclude": ["node_modules", "dist"]
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Paths */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"exclude": ["node_modules", "dist"]
}

View File

@ -1,12 +1,12 @@
{
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
{
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View File

@ -1 +1 @@
{"root":["./src/main.ts","./src/components/icons/index.ts","./src/composables/usekeyboard.ts","./src/services/api.ts","./src/services/mockai.ts","./src/stores/chat.ts","./src/stores/settings.ts","./src/types/chat.ts","./src/utils/helpers.ts","./src/app.vue","./src/components/chat/chatheader.vue","./src/components/chat/chatmain.vue","./src/components/chat/messagelist.vue","./src/components/chat/welcomescreen.vue","./src/components/input/attachmentpreview.vue","./src/components/input/chatinput.vue","./src/components/message/codeblock.vue","./src/components/message/messageactions.vue","./src/components/message/messagebubble.vue","./src/components/message/components/echartscontainernode.vue","./src/components/message/components/loading.vue","./src/components/message/components/thinkingnode.vue","./src/components/modals/conversationsettingsmodal.vue","./src/components/modals/searchmodal.vue","./src/components/modals/settingsmodal.vue","./src/components/modals/shortcutsmodal.vue","./src/components/sidebar/chatsidebar.vue","./src/components/sidebar/conversationitem.vue","./src/components/ui/formselect.vue","./src/components/ui/formslider.vue","./src/components/ui/formswitch.vue"],"version":"5.9.3"}
{"root":["./src/main.ts","./src/components/icons/index.ts","./src/composables/useKeyboard.ts","./src/services/api.ts","./src/stores/chat.ts","./src/stores/settings.ts","./src/types/chat.ts","./src/utils/helpers.ts","./src/App.vue","./src/components/chat/ChatHeader.vue","./src/components/chat/ChatMain.vue","./src/components/chat/MessageList.vue","./src/components/chat/WelcomeScreen.vue","./src/components/input/AttachmentPreview.vue","./src/components/input/ChatInput.vue","./src/components/message/CodeBlock.vue","./src/components/message/MessageActions.vue","./src/components/message/MessageBubble.vue","./src/components/message/components/EChartsContainerNode.vue","./src/components/message/components/Loading.vue","./src/components/message/components/ThinkingNode.vue","./src/components/modals/ConversationSettingsModal.vue","./src/components/modals/SearchModal.vue","./src/components/modals/SettingsModal.vue","./src/components/modals/ShortcutsModal.vue","./src/components/sidebar/ChatSidebar.vue","./src/components/sidebar/ConversationItem.vue","./src/components/ui/FormSelect.vue","./src/components/ui/FormSlider.vue","./src/components/ui/FormSwitch.vue"],"version":"5.9.3"}

View File

@ -1,28 +1,28 @@
import {
defineConfig,
presetAttributify,
transformerDirectives,
transformerVariantGroup,
presetUno
} from "unocss";
export default defineConfig({
content: {
pipeline: {
include: [
/\.(vue|svelte|[jt]sx|mdx?|astro|elm|php|phtml|html)($|\?)/, // the default
"**/src/**/*.{js,ts}" // include js/ts files
]
}
},
presets: [presetAttributify(), presetUno()],
transformers: [transformerDirectives(), transformerVariantGroup()],
shortcuts: [
["flex-center", "flex justify-center items-center"],
["full", "w-full h-full"],
[/^(.*)-i$/, ([, prefix]) => `${prefix}!`], // w-full-i -> { width: 100% !important }
[/^(.*)-(\d+)p$/, ([, prefix, d]) => `${prefix}-${d}%`], // w-50p -> { width: 50% }
[/^(.*)-var-(.*)$/, ([, prefix, v]) => `${prefix}-$${v}`] // bg-var-el-color-primary -> { background-color: var(--el-color-primary) }
],
rules: [[/^(.*)-setvar-(.*)$/, ([, prefix, v]) => ({ [`--${prefix}`]: v })]]
});
import {
defineConfig,
presetAttributify,
transformerDirectives,
transformerVariantGroup,
presetUno
} from "unocss";
export default defineConfig({
content: {
pipeline: {
include: [
/\.(vue|svelte|[jt]sx|mdx?|astro|elm|php|phtml|html)($|\?)/, // the default
"**/src/**/*.{js,ts}" // include js/ts files
]
}
},
presets: [presetAttributify(), presetUno()],
transformers: [transformerDirectives(), transformerVariantGroup()],
shortcuts: [
["flex-center", "flex justify-center items-center"],
["full", "w-full h-full"],
[/^(.*)-i$/, ([, prefix]) => `${prefix}!`], // w-full-i -> { width: 100% !important }
[/^(.*)-(\d+)p$/, ([, prefix, d]) => `${prefix}-${d}%`], // w-50p -> { width: 50% }
[/^(.*)-var-(.*)$/, ([, prefix, v]) => `${prefix}-$${v}`] // bg-var-el-color-primary -> { background-color: var(--el-color-primary) }
],
rules: [[/^(.*)-setvar-(.*)$/, ([, prefix, v]) => ({ [`--${prefix}`]: v })]]
});

View File

@ -1,54 +1,60 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { resolve } from "path";
import UnoCSS from "unocss/vite";
export default defineConfig({
plugins: [vue(), UnoCSS()],
// 基础路径
base: "/chat-ui/",
resolve: {
alias: {
"@": resolve(__dirname, "src"),
},
},
server: {
host: "0.0.0.0",
},
build: {
// 输出目录
outDir: "dist",
// 静态资源目录
assetsDir: "assets",
// 生成 sourcemap生产环境可关闭
sourcemap: false,
// 使用 esbuild 压缩(默认,更快)
minify: "esbuild",
// 分包策略
rollupOptions: {
output: {
manualChunks: {
"vue-vendor": ["vue", "pinia"],
"ui-vendor": ["lucide-vue-next"],
},
chunkFileNames: "assets/js/[name]-[hash].js",
entryFileNames: "assets/js/[name]-[hash].js",
assetFileNames: "assets/[ext]/[name]-[hash].[ext]",
},
},
},
css: {
preprocessorOptions: {
scss: {
additionalData: ``,
},
},
},
});
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { resolve } from "path";
import UnoCSS from "unocss/vite";
export default defineConfig({
plugins: [vue(), UnoCSS()],
// 基础路径
base: "/chat-ui/",
resolve: {
alias: {
"@": resolve(__dirname, "src"),
},
},
server: {
host: "0.0.0.0",
proxy: {
"/api/chat-ui": {
target: "http://localhost:3000",
changeOrigin: true,
},
},
},
build: {
// 输出目录
outDir: "dist",
// 静态资源目录
assetsDir: "assets",
// 生成 sourcemap生产环境可关闭
sourcemap: false,
// 使用 esbuild 压缩(默认,更快)
minify: "esbuild",
// 分包策略
rollupOptions: {
output: {
manualChunks: {
"vue-vendor": ["vue", "pinia"],
"ui-vendor": ["lucide-vue-next"],
},
chunkFileNames: "assets/js/[name]-[hash].js",
entryFileNames: "assets/js/[name]-[hash].js",
assetFileNames: "assets/[ext]/[name]-[hash].[ext]",
},
},
},
css: {
preprocessorOptions: {
scss: {
additionalData: ``,
},
},
},
});