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
logs logs
*.log *.log
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
node_modules node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
# Editor directories and files .env
.vscode/*
!.vscode/extensions.json # Editor directories and files
.idea .vscode/*
.DS_Store !.vscode/extensions.json
*.suo .idea
*.ntvs* .DS_Store
*.njsproj *.suo
*.sln *.ntvs*
*.sw? *.njsproj
*.sln
*.sw?

42
LICENSE
View File

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

208
README.md
View File

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

View File

@ -1,16 +1,16 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vue.svg" /> <link rel="icon" type="image/svg+xml" href="/vue.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI-CHAT-UI - 企业级智能对话</title> <title>AI-CHAT-UI - 企业级智能对话</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </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", "name": "ai-chat-ui",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc -b && vite build", "build": "vue-tsc -b && vite build",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@microsoft/fetch-event-source": "^2.0.1", "@microsoft/fetch-event-source": "^2.0.1",
"@terrastruct/d2": "^0.1.33", "@terrastruct/d2": "^0.1.33",
"@unocss/reset": "^66.6.0", "@unocss/reset": "^66.6.0",
"@vueuse/core": "^14.2.0", "@vueuse/core": "^14.2.0",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"katex": "^0.16.28", "katex": "^0.16.28",
"lodash": "^4.17.23", "lodash": "^4.17.23",
"lucide-vue-next": "^0.563.0", "lucide-vue-next": "^0.563.0",
"markstream-vue": "^0.0.7-beta.4", "markstream-vue": "^0.0.7-beta.4",
"mermaid": "^11.12.2", "mermaid": "^11.12.2",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"shiki": "^3.22.0", "shiki": "^3.22.0",
"stream-markdown": "^0.0.14", "stream-markdown": "^0.0.14",
"stream-monaco": "^0.0.18", "stream-monaco": "^0.0.18",
"unocss": "^66.6.0", "unocss": "^66.6.0",
"vue": "^3.5.24", "vue": "^3.5.24",
"vue-router": "^5.0.2" "vue-router": "^5.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1", "@vue/tsconfig": "^0.8.1",
"autoprefixer": "^10.4.24", "autoprefixer": "^10.4.24",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"sass": "^1.97.3", "sass": "^1.97.3",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^7.2.4", "vite": "^7.2.4",
"vue-tsc": "^3.1.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> <template>
<div class="app" :class="{ 'dark': isDark }"> <div class="app" :class="{ 'dark': isDark }">
<!-- 侧边栏 --> <!-- 侧边栏 -->
<ChatSidebar /> <ChatSidebar />
<!-- 主内容区 --> <!-- 主内容区 -->
<ChatMain <ChatMain
ref="chatMainRef" ref="chatMainRef"
@toggle-sidebar="toggleSidebar" @toggle-sidebar="toggleSidebar"
/> />
<!-- 模态框 --> <!-- 模态框 -->
<SearchModal /> <SearchModal />
<ShortcutsModal /> <ShortcutsModal />
<SettingsModal /> <SettingsModal />
<ConversationSettingsModal /> <ConversationSettingsModal />
<!-- Toast 通知 --> <!-- Toast 通知 -->
<Teleport to="body"> <Teleport to="body">
<TransitionGroup name="toast" tag="div" class="toast-container"> <TransitionGroup name="toast" tag="div" class="toast-container">
<div <div
v-for="toast in toasts" v-for="toast in toasts"
:key="toast.id" :key="toast.id"
class="toast" class="toast"
:class="toast.type" :class="toast.type"
> >
<Check v-if="toast.type === 'success'" :size="18" /> <Check v-if="toast.type === 'success'" :size="18" />
<AlertCircle v-else-if="toast.type === 'error'" :size="18" /> <AlertCircle v-else-if="toast.type === 'error'" :size="18" />
<Info v-else :size="18" /> <Info v-else :size="18" />
<span>{{ toast.message }}</span> <span>{{ toast.message }}</span>
</div> </div>
</TransitionGroup> </TransitionGroup>
</Teleport> </Teleport>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useChatStore } from '@/stores/chat' import { useChatStore } from '@/stores/chat'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
import { useKeyboard, getDefaultShortcuts } from '@/composables/useKeyboard' import { useKeyboard, getDefaultShortcuts } from '@/composables/useKeyboard'
import ChatSidebar from '@/components/sidebar/ChatSidebar.vue' import ChatSidebar from '@/components/sidebar/ChatSidebar.vue'
import ChatMain from '@/components/chat/ChatMain.vue' import ChatMain from '@/components/chat/ChatMain.vue'
import SearchModal from '@/components/modals/SearchModal.vue' import SearchModal from '@/components/modals/SearchModal.vue'
import ShortcutsModal from '@/components/modals/ShortcutsModal.vue' import ShortcutsModal from '@/components/modals/ShortcutsModal.vue'
import SettingsModal from '@/components/modals/SettingsModal.vue' import SettingsModal from '@/components/modals/SettingsModal.vue'
import ConversationSettingsModal from '@/components/modals/ConversationSettingsModal.vue' import ConversationSettingsModal from '@/components/modals/ConversationSettingsModal.vue'
import { Check, AlertCircle, Info } from '@/components/icons' import { Check, AlertCircle, Info } from '@/components/icons'
// Stores // Stores
const chatStore = useChatStore() const chatStore = useChatStore()
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
const { settings } = storeToRefs(settingsStore) const { settings } = storeToRefs(settingsStore)
// Refs // Refs
const chatMainRef = ref<InstanceType<typeof ChatMain> | null>(null) const chatMainRef = ref<InstanceType<typeof ChatMain> | null>(null)
// //
const isDark = computed(() => { const isDark = computed(() => {
if (settings.value.theme === 'system') { if (settings.value.theme === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches return window.matchMedia('(prefers-color-scheme: dark)').matches
} }
return settings.value.theme === 'dark' return settings.value.theme === 'dark'
}) })
// Toast // Toast
interface Toast { interface Toast {
id: number id: number
message: string message: string
type: 'success' | 'error' | 'info' type: 'success' | 'error' | 'info'
} }
const toasts = ref<Toast[]>([]) const toasts = ref<Toast[]>([])
let toastId = 0 let toastId = 0
function showToast(message: string, type: Toast['type'] = 'info') { function showToast(message: string, type: Toast['type'] = 'info') {
const id = ++toastId const id = ++toastId
toasts.value.push({ id, message, type }) toasts.value.push({ id, message, type })
setTimeout(() => { setTimeout(() => {
const index = toasts.value.findIndex(t => t.id === id) const index = toasts.value.findIndex(t => t.id === id)
if (index !== -1) { if (index !== -1) {
toasts.value.splice(index, 1) toasts.value.splice(index, 1)
} }
}, 3000) }, 3000)
} }
// //
function toggleSidebar() { function toggleSidebar() {
settingsStore.toggleSidebar() settingsStore.toggleSidebar()
} }
function newChat() { function newChat() {
chatStore.createConversation() chatStore.createConversation()
showToast('已创建新对话', 'success') showToast('已创建新对话', 'success')
} }
function focusInput() { function focusInput() {
chatMainRef.value?.focusInput() chatMainRef.value?.focusInput()
} }
// //
useKeyboard( useKeyboard(
getDefaultShortcuts({ getDefaultShortcuts({
newChat, newChat,
toggleSidebar, toggleSidebar,
focusInput, focusInput,
sendMessage: () => {}, // ChatInput sendMessage: () => {}, // ChatInput
cancelStream: () => { cancelStream: () => {
if (chatStore.isStreaming) { if (chatStore.isStreaming) {
chatStore.stopStreaming() chatStore.stopStreaming()
showToast('已停止生成', 'info') showToast('已停止生成', 'info')
} }
}, },
toggleTheme: () => { toggleTheme: () => {
settingsStore.toggleTheme() settingsStore.toggleTheme()
showToast(`主题已切换为 ${settings.value.theme}`, 'success') showToast(`主题已切换为 ${settings.value.theme}`, 'success')
}, },
showShortcuts: () => { showShortcuts: () => {
settingsStore.openShortcutsModal() settingsStore.openShortcutsModal()
}, },
searchConversations: () => { searchConversations: () => {
settingsStore.openSearchModal() settingsStore.openSearchModal()
}, },
}) })
) )
// //
onMounted(() => { onMounted(() => {
// //
if (chatStore.conversations.length === 0) { if (chatStore.conversations.length === 0) {
chatStore.createConversation() chatStore.createConversation()
} }
}) })
// 使 // 使
window.$toast = showToast window.$toast = showToast
</script> </script>
<style lang="scss"> <style lang="scss">
.app { .app {
display: flex; display: flex;
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
overflow: hidden; overflow: hidden;
background: #ffffff; background: #ffffff;
&.dark { &.dark {
background: #11111b; background: #11111b;
color: #e5e7eb; color: #e5e7eb;
} }
} }
// Toast // Toast
.toast-container { .toast-container {
position: fixed; position: fixed;
top: 20px; top: 20px;
right: 20px; right: 20px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
z-index: 9999; z-index: 9999;
pointer-events: none; pointer-events: none;
} }
.toast { .toast {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 14px 20px; padding: 14px 20px;
background: white; background: white;
border-radius: 12px; border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15); box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
color: #374151; color: #374151;
pointer-events: auto; pointer-events: auto;
.dark & { .dark & {
background: #2d2d3d; background: #2d2d3d;
color: #e5e7eb; color: #e5e7eb;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4); box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
} }
&.success { &.success {
svg { svg {
color: #10b981; color: #10b981;
} }
} }
&.error { &.error {
svg { svg {
color: #ef4444; color: #ef4444;
} }
} }
&.info { &.info {
svg { svg {
color: #3b82f6; color: #3b82f6;
} }
} }
} }
// Toast // Toast
.toast-enter-active, .toast-enter-active,
.toast-leave-active { .toast-leave-active {
transition: all 0.3s ease; transition: all 0.3s ease;
} }
.toast-enter-from { .toast-enter-from {
opacity: 0; opacity: 0;
transform: translateX(100px); transform: translateX(100px);
} }
.toast-leave-to { .toast-leave-to {
opacity: 0; opacity: 0;
transform: translateX(100px); transform: translateX(100px);
} }
.toast-move { .toast-move {
transition: transform 0.3s ease; transition: transform 0.3s ease;
} }
</style> </style>

File diff suppressed because it is too large Load Diff

View File

@ -1,320 +1,338 @@
<template> <template>
<main class="chat-main" :class="{ 'wide-mode': isWideMode }"> <main class="chat-main" :class="{ 'wide-mode': isWideMode }">
<!-- 头部 --> <!-- 头部 -->
<ChatHeader <ChatHeader
:title="currentConversation?.title || '新对话'" :title="currentConversation?.title || '新对话'"
:message-count="messages.length" :message-count="messages.length"
:show-sidebar-toggle="sidebarCollapsed" :show-sidebar-toggle="sidebarCollapsed"
:is-wide-mode="isWideMode" :is-wide-mode="isWideMode"
:is-pinned="currentConversation?.pinned" :is-pinned="currentConversation?.pinned"
@toggle-sidebar="$emit('toggle-sidebar')" @toggle-sidebar="$emit('toggle-sidebar')"
@toggle-wide-mode="toggleWideMode" @toggle-wide-mode="toggleWideMode"
@clear="handleClear" @clear="handleClear"
@export="handleExport" @export="handleExport"
@pin="handlePin" @pin="handlePin"
/> />
<!-- 消息列表 --> <!-- 消息列表 -->
<MessageList <MessageList
ref="messageListRef" ref="messageListRef"
:messages="messages" :messages="messages"
:show-timestamp="settings.showTimestamp" :show-timestamp="settings.showTimestamp"
:compact="settings.compactMode" :compact="settings.compactMode"
:is-typing="isTyping" :is-typing="isTyping"
@retry="handleRetry" @retry="handleRetry"
@regenerate="handleRegenerate" @regenerate="handleRegenerate"
@select-suggestion="handleSuggestion" @select-suggestion="handleSuggestion"
/> />
<!-- 输入区域 --> <!-- 输入区域 -->
<div class="input-wrapper"> <div class="input-wrapper">
<div class="input-container" :class="{ wide: isWideMode }"> <div class="input-container" :class="{ wide: isWideMode }">
<ChatInput <ChatInput
ref="chatInputRef" ref="chatInputRef"
:placeholder="inputPlaceholder" :placeholder="inputPlaceholder"
:is-streaming="isStreaming" :is-streaming="isStreaming"
:send-on-enter="settings.sendOnEnter" :send-on-enter="settings.sendOnEnter"
:disabled="false" :disabled="false"
@send="handleSend" @send="handleSend"
@stop="handleStop" @stop="handleStop"
/> />
</div> </div>
</div> </div>
</main> </main>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, nextTick } from "vue"; import { ref, computed, watch, nextTick } from "vue";
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
import { useChatStore } from "@/stores/chat"; import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings"; import { useSettingsStore } from "@/stores/settings";
import ChatHeader from "./ChatHeader.vue"; import ChatHeader from "./ChatHeader.vue";
import MessageList from "./MessageList.vue"; import MessageList from "./MessageList.vue";
import ChatInput from "@/components/input/ChatInput.vue"; import ChatInput from "@/components/input/ChatInput.vue";
import { MessageType, MessageRole } from "@/types/chat"; import { MessageType, MessageRole } from "@/types/chat";
import type { Attachment } from "@/types/chat"; import type { Attachment } from "@/types/chat";
import { chatApi } from "@/services/api.ts"; import { chatApi } from "@/services/api";
import { streamAIResponse, generateSuggestions } from "@/services/mockAI";
defineEmits<{
defineEmits<{ "toggle-sidebar": [];
"toggle-sidebar": []; }>();
}>();
const chatStore = useChatStore();
const chatStore = useChatStore(); const settingsStore = useSettingsStore();
const settingsStore = useSettingsStore();
const { currentConversation, isStreaming } = storeToRefs(chatStore);
const { currentConversation, isStreaming } = storeToRefs(chatStore); const { settings, sidebarCollapsed } = storeToRefs(settingsStore);
const { settings, sidebarCollapsed } = storeToRefs(settingsStore);
const messageListRef = ref<InstanceType<typeof MessageList> | null>(null);
const messageListRef = ref<InstanceType<typeof MessageList> | null>(null); const chatInputRef = ref<InstanceType<typeof ChatInput> | null>(null);
const chatInputRef = ref<InstanceType<typeof ChatInput> | null>(null); const isWideMode = ref(true);
const isWideMode = ref(true); const isTyping = ref(false);
const isTyping = ref(false); const currentStreamingMessageId = ref<string | null>(null);
const currentStreamingMessageId = ref<string | null>(null); const abortController: any = ref<AbortController | null>(null);
const abortController: any = ref<AbortController | null>(null);
const messages: any = computed(() => currentConversation.value?.messages || []);
const messages: any = computed(() => currentConversation.value?.messages || []);
const inputPlaceholder = computed(() => {
const inputPlaceholder = computed(() => { if (isStreaming.value) return "正在生成回复...";
if (isStreaming.value) return "正在生成回复..."; return "输入你的问题,按 Ctrl+Enter 发送";
return "输入你的问题,按 Ctrl+Enter 发送"; });
});
function toggleWideMode() {
function toggleWideMode() { isWideMode.value = !isWideMode.value;
isWideMode.value = !isWideMode.value; }
}
function handleClear() {
function handleClear() { if (currentConversation.value) {
if (currentConversation.value) { chatStore.clearConversation(currentConversation.value.id);
chatStore.clearConversation(currentConversation.value.id); }
} }
}
function handleExport() {
function handleExport() { if (!currentConversation.value) return;
if (!currentConversation.value) return;
const data = {
const data = { title: currentConversation.value.title,
title: currentConversation.value.title, messages: currentConversation.value.messages,
messages: currentConversation.value.messages, exportedAt: new Date().toISOString(),
exportedAt: new Date().toISOString(), };
};
const blob = new Blob([JSON.stringify(data, null, 2)], {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json",
type: "application/json", });
}); const url = URL.createObjectURL(blob);
const url = URL.createObjectURL(blob); const a = document.createElement("a");
const a = document.createElement("a"); a.href = url;
a.href = url; a.download = `${currentConversation.value.title}.json`;
a.download = `${currentConversation.value.title}.json`; a.click();
a.click(); URL.revokeObjectURL(url);
URL.revokeObjectURL(url); }
}
function handlePin() {
function handlePin() { if (currentConversation.value) {
if (currentConversation.value) { chatStore.togglePinConversation(currentConversation.value.id);
chatStore.togglePinConversation(currentConversation.value.id); }
} }
}
// - 使 API
// - 使 API async function handleSend(text: string, attachments: Attachment[]) {
async function handleSend(text: string, attachments: Attachment[]) { //
// if (!currentConversation.value) {
if (!currentConversation.value) { chatStore.createConversation();
chatStore.createConversation(); }
}
//
// chatStore.addMessage(MessageRole.USER, {
chatStore.addMessage(MessageRole.USER, { type: MessageType.TEXT,
type: MessageType.TEXT, text,
text, images: attachments.filter((a) => a.type === "image"),
images: attachments.filter((a) => a.type === "image"), files: attachments.filter((a) => a.type === "file"),
files: attachments.filter((a) => a.type === "file"), });
});
// AI
// AI const aiMessage = chatStore.addMessage(MessageRole.ASSISTANT, {
const aiMessage = chatStore.addMessage(MessageRole.ASSISTANT, { type: MessageType.TEXT,
type: MessageType.TEXT, text: "",
text: "", });
});
currentStreamingMessageId.value = aiMessage.id;
currentStreamingMessageId.value = aiMessage.id; chatStore.updateMessage(aiMessage.id, { isStreaming: true });
chatStore.updateMessage(aiMessage.id, { isStreaming: true }); chatStore.startStreaming();
chatStore.startStreaming(); isTyping.value = true;
isTyping.value = true;
// AbortController
// AbortController abortController.value = new AbortController();
abortController.value = new AbortController(); try {
await streamAIResponse( const stream = chatApi.streamChat(
text, {
{ message: text,
onStart: () => { conversationId: currentConversation.value?.id || "",
isTyping.value = false; model: settings.value.defaultModel,
}, stream: true,
onToken: (_token, fullText) => { },
chatStore.updateMessageContent(aiMessage.id, fullText); abortController.value.signal,
}, );
onComplete: (fullText) => {
chatStore.updateMessage(aiMessage.id, { let fullText = "";
isStreaming: false, isTyping.value = false;
content: {
type: MessageType.TEXT, for await (const chunk of stream) {
text: fullText, if (abortController.value?.signal.aborted) break;
suggestions: generateSuggestions(), fullText += chunk;
}, chatStore.updateMessageContent(aiMessage.id, fullText);
}); }
chatStore.stopStreaming();
currentStreamingMessageId.value = null; if (!abortController.value?.signal.aborted) {
}, chatStore.updateMessage(aiMessage.id, {
onError: (error) => { isStreaming: false,
chatStore.updateMessage(aiMessage.id, { content: {
isStreaming: false, type: MessageType.TEXT,
isError: true, text: fullText,
errorMessage: error.message, },
}); });
chatStore.stopStreaming(); }
currentStreamingMessageId.value = null; } catch (error: any) {
}, if (error.name !== "AbortError") {
}, chatStore.updateMessage(aiMessage.id, {
chatStore.streamController?.signal, isStreaming: false,
); isError: true,
} errorMessage: error.message || "请求失败",
});
// }
function handleStop() { } finally {
if (abortController.value) { chatStore.stopStreaming();
abortController.value.abort(); currentStreamingMessageId.value = null;
abortController.value = null; }
} }
chatStore.stopStreaming(); //
chatApi.stopChat(messages.value.at(-1)["messageId"]); function handleStop() {
if (currentStreamingMessageId.value) { if (abortController.value) {
chatStore.updateMessage(currentStreamingMessageId.value, { abortController.value.abort();
isStreaming: false, abortController.value = null;
}); }
currentStreamingMessageId.value = null;
} chatStore.stopStreaming();
} chatApi.stopChat(messages.value.at(-1)["messageId"]);
if (currentStreamingMessageId.value) {
// chatStore.updateMessage(currentStreamingMessageId.value, {
async function handleRetry(messageId: string) { isStreaming: false,
const message = messages.value.find((m: any) => m.id === messageId); });
if (!message || message.role !== MessageRole.ASSISTANT) return; currentStreamingMessageId.value = null;
}
const messageIndex = messages.value.findIndex((m: any) => m.id === messageId); }
if (messageIndex <= 0) return;
//
const userMessage = messages.value[messageIndex - 1]; async function handleRetry(messageId: string) {
if (userMessage.role !== MessageRole.USER) return; const message = messages.value.find((m: any) => m.id === messageId);
if (!message || message.role !== MessageRole.ASSISTANT) return;
//
chatStore.updateMessage(messageId, { const messageIndex = messages.value.findIndex((m: any) => m.id === messageId);
isError: false, if (messageIndex <= 0) return;
errorMessage: undefined,
isStreaming: true, const userMessage = messages.value[messageIndex - 1];
isEnd: true, if (userMessage.role !== MessageRole.USER) return;
content: { type: MessageType.TEXT, text: "" },
}); //
chatStore.updateMessage(messageId, {
currentStreamingMessageId.value = messageId; isError: false,
chatStore.startStreaming(); errorMessage: undefined,
abortController.value = new AbortController(); isStreaming: true,
await streamAIResponse( isEnd: true,
userMessage.content.text || "", content: { type: MessageType.TEXT, text: "" },
{ });
onToken: (_token, fullText) => {
chatStore.updateMessageContent(messageId, fullText); currentStreamingMessageId.value = messageId;
}, chatStore.startStreaming();
onComplete: (fullText) => { abortController.value = new AbortController();
chatStore.updateMessage(messageId, {
isStreaming: false, try {
content: { const stream = chatApi.streamChat(
type: MessageType.TEXT, {
text: fullText, message: userMessage.content.text || "",
suggestions: generateSuggestions(), conversationId: currentConversation.value?.id,
}, model: settings.value.defaultModel,
}); stream: true,
chatStore.stopStreaming(); },
currentStreamingMessageId.value = null; abortController.value.signal,
}, );
onError: (error) => {
chatStore.updateMessage(messageId, { let fullText = "";
isStreaming: false,
isError: true, for await (const chunk of stream) {
errorMessage: error.message, if (abortController.value?.signal.aborted) break;
}); fullText += chunk;
chatStore.stopStreaming(); chatStore.updateMessageContent(messageId, fullText);
currentStreamingMessageId.value = null; }
},
}, if (!abortController.value?.signal.aborted) {
chatStore.streamController?.signal, chatStore.updateMessage(messageId, {
); isStreaming: false,
} content: {
type: MessageType.TEXT,
function handleRegenerate(messageId: string) { text: fullText,
handleRetry(messageId); },
} });
}
function handleSuggestion(text: string) { } catch (error: any) {
handleSend(text, []); if (error.name !== "AbortError") {
} chatStore.updateMessage(messageId, {
isStreaming: false,
function focusInput() { isError: true,
chatInputRef.value?.focus(); errorMessage: error.message || "请求失败",
} });
}
defineExpose({ } finally {
focusInput, chatStore.stopStreaming();
messageListRef, currentStreamingMessageId.value = null;
}); }
}
watch(
() => currentConversation.value?.id, function handleRegenerate(messageId: string) {
() => { handleRetry(messageId);
nextTick(() => { }
focusInput();
}); function handleSuggestion(text: string) {
}, handleSend(text, []);
); }
</script>
function focusInput() {
<style lang="scss" scoped> chatInputRef.value?.focus();
.chat-main { }
display: flex;
flex-direction: column; defineExpose({
flex: 1; focusInput,
height: 100vh; messageListRef,
background: #ffffff; });
overflow: hidden;
watch(
.dark & { () => currentConversation.value?.id,
background: #11111b; () => {
} nextTick(() => {
focusInput();
&.wide-mode { });
.input-container { },
max-width: 1000px; );
} </script>
}
} <style lang="scss" scoped>
.chat-main {
.input-wrapper { display: flex;
flex-shrink: 0; flex-direction: column;
padding: 16px 24px 24px; flex: 1;
background: linear-gradient(to top, white 80%, transparent); height: 100vh;
background: #ffffff;
.dark & { overflow: hidden;
background: linear-gradient(to top, #11111b 80%, transparent);
} .dark & {
} background: #11111b;
}
.input-container {
max-width: 800px; &.wide-mode {
margin: 0 auto; .input-container {
transition: max-width 0.3s ease; max-width: 1000px;
}
&.wide { }
max-width: 1000px; }
}
} .input-wrapper {
</style> 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> <template>
<div ref="boxRef" style="flex: 1; position: relative"> <div ref="boxRef" style="flex: 1; position: relative">
<div ref="containerRef" class="message-list" @scroll="handleScroll"> <div ref="containerRef" class="message-list" @scroll="handleScroll">
<!-- 欢迎界面 --> <!-- 欢迎界面 -->
<WelcomeScreen <WelcomeScreen
v-if="messages.length === 0" v-if="messages.length === 0"
@select="$emit('select-suggestion', $event)" @select="$emit('select-suggestion', $event)"
/> />
<!-- 消息列表 --> <!-- 消息列表 -->
<template v-else> <template v-else>
<div class="messages-wrapper"> <div class="messages-wrapper">
<TransitionGroup name="message"> <TransitionGroup name="message">
<MessageBubble <MessageBubble
v-for="(message, index) in messages" v-for="(message, index) in messages"
:key="message.id" :key="message.id"
:message="message" :message="message"
:show-timestamp="showTimestamp" :show-timestamp="showTimestamp"
:compact="compact" :compact="compact"
:is-New="index === messages.length - 1" :is-New="index === messages.length - 1"
@retry="$emit('retry', message.id)" @retry="$emit('retry', message.id)"
@regenerate="$emit('regenerate', message.id)" @regenerate="$emit('regenerate', message.id)"
@copy="handleCopy(message)" @copy="handleCopy(message)"
@like="handleLike(message)" @like="handleLike(message)"
@dislike="handleDislike(message)" @dislike="handleDislike(message)"
@select-suggestion="$emit('select-suggestion', $event.text)" @select-suggestion="$emit('select-suggestion', $event.text)"
@preview-image="handlePreviewImage" @preview-image="handlePreviewImage"
@play-video="handlePlayVideo" @play-video="handlePlayVideo"
@download-file="handleDownloadFile" @download-file="handleDownloadFile"
/> />
</TransitionGroup> </TransitionGroup>
<!-- 正在输入指示器 --> <!-- 正在输入指示器 -->
<div v-if="isTyping" class="typing-indicator"> <div v-if="isTyping" class="typing-indicator">
<div class="typing-avatar"> <div class="typing-avatar">
<Bot :size="20" /> <Bot :size="20" />
</div> </div>
<div class="typing-dots"> <div class="typing-dots">
<span></span> <span></span>
<span></span> <span></span>
<span></span> <span></span>
</div> </div>
<span class="typing-text">AI 正在思考...</span> <span class="typing-text">AI 正在思考...</span>
</div> </div>
</div> </div>
</template> </template>
</div> </div>
<!-- 回到底部按钮 --> <!-- 回到底部按钮 -->
<Transition name="fade"> <Transition name="fade">
<button <button
v-if="showScrollButton" v-if="showScrollButton"
class="scroll-bottom-btn" class="scroll-bottom-btn"
@click="handleScrollToBottom" @click="handleScrollToBottom"
> >
<ChevronDown :size="20" /> <ChevronDown :size="20" />
<span v-if="newMessageCount > 0" class="new-count"> <span v-if="newMessageCount > 0" class="new-count">
{{ newMessageCount }} {{ newMessageCount }}
</span> </span>
</button> </button>
</Transition> </Transition>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, nextTick, onMounted } from "vue"; import { ref, watch, nextTick, onMounted } from "vue";
import { useChatStore } from "@/stores/chat"; import { useChatStore } from "@/stores/chat";
import MessageBubble from "@/components/message/MessageBubble.vue"; import MessageBubble from "@/components/message/MessageBubble.vue";
import WelcomeScreen from "./WelcomeScreen.vue"; import WelcomeScreen from "./WelcomeScreen.vue";
import { Bot, ChevronDown } from "@/components/icons"; import { Bot, ChevronDown } from "@/components/icons";
import type { Message, Attachment, VideoInfo } from "@/types/chat"; import type { Message, Attachment, VideoInfo } from "@/types/chat";
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
messages: Message[]; messages: Message[];
showTimestamp?: boolean; showTimestamp?: boolean;
compact?: boolean; compact?: boolean;
isTyping?: boolean; isTyping?: boolean;
}>(), }>(),
{ {
showTimestamp: true, showTimestamp: true,
compact: false, compact: false,
isTyping: false, isTyping: false,
}, },
); );
const emit = defineEmits<{ const emit = defineEmits<{
retry: [messageId: string]; retry: [messageId: string];
regenerate: [messageId: string]; regenerate: [messageId: string];
"select-suggestion": [text: string]; "select-suggestion": [text: string];
"preview-image": [image: Attachment, index: number]; "preview-image": [image: Attachment, index: number];
"play-video": [video: VideoInfo]; "play-video": [video: VideoInfo];
"download-file": [file: Attachment]; "download-file": [file: Attachment];
}>(); }>();
const chatStore = useChatStore(); const chatStore = useChatStore();
// //
const boxRef: any = ref<HTMLElement | null>(null); const boxRef: any = ref<HTMLElement | null>(null);
const containerRef: any = ref<HTMLElement | null>(null); const containerRef: any = ref<HTMLElement | null>(null);
const showScrollButton = ref(false); const showScrollButton = ref(false);
const newMessageCount = ref(0); const newMessageCount = ref(0);
const isAutoScrolling = ref(true); const isAutoScrolling = ref(true);
const lastScrollTop = ref(0); const lastScrollTop = ref(0);
onMounted(() => { onMounted(() => {
containerRef.value.style.height = boxRef.value?.clientHeight + "px"; containerRef.value.style.height = boxRef.value?.clientHeight + "px";
}); });
// //
function handleScroll() { function handleScroll() {
const container = containerRef.value; const container = containerRef.value;
if (!container) return; if (!container) return;
const { scrollTop, scrollHeight, clientHeight } = container; const { scrollTop, scrollHeight, clientHeight } = container;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 100; const isAtBottom = scrollHeight - scrollTop - clientHeight < 100;
if (scrollTop < lastScrollTop.value && !isAtBottom) { if (scrollTop < lastScrollTop.value && !isAtBottom) {
isAutoScrolling.value = false; isAutoScrolling.value = false;
showScrollButton.value = true; showScrollButton.value = true;
} }
if (isAtBottom) { if (isAtBottom) {
isAutoScrolling.value = true; isAutoScrolling.value = true;
showScrollButton.value = false; showScrollButton.value = false;
newMessageCount.value = 0; newMessageCount.value = 0;
} }
lastScrollTop.value = scrollTop; lastScrollTop.value = scrollTop;
} }
// //
function scrollToBottom(smooth = true) { function scrollToBottom(smooth = true) {
const container = containerRef.value; const container = containerRef.value;
if (!container) return; if (!container) return;
container.scrollTo({ container.scrollTo({
top: container.scrollHeight, top: container.scrollHeight,
behavior: smooth ? "smooth" : "auto", behavior: smooth ? "smooth" : "auto",
}); });
isAutoScrolling.value = true; isAutoScrolling.value = true;
showScrollButton.value = false; showScrollButton.value = false;
newMessageCount.value = 0; newMessageCount.value = 0;
} }
// //
function handleScrollToBottom() { function handleScrollToBottom() {
scrollToBottom(true); scrollToBottom(true);
} }
// //
function handleCopy(message: Message) { function handleCopy(message: Message) {
chatStore.setMessageCopied(message.id); chatStore.setMessageCopied(message.id);
} }
function handleLike(message: Message) { function handleLike(message: Message) {
const currentLiked = message.feedback?.liked; const currentLiked = message.feedback?.liked;
chatStore.setMessageFeedback(message.id, currentLiked ? null : "like"); chatStore.setMessageFeedback(message.id, currentLiked ? null : "like");
} }
function handleDislike(message: Message) { function handleDislike(message: Message) {
const currentDisliked = message.feedback?.disliked; const currentDisliked = message.feedback?.disliked;
chatStore.setMessageFeedback(message.id, currentDisliked ? null : "dislike"); chatStore.setMessageFeedback(message.id, currentDisliked ? null : "dislike");
} }
function handlePreviewImage(image: Attachment, index: number) { function handlePreviewImage(image: Attachment, index: number) {
emit("preview-image", image, index); emit("preview-image", image, index);
} }
function handlePlayVideo(video: VideoInfo) { function handlePlayVideo(video: VideoInfo) {
emit("play-video", video); emit("play-video", video);
} }
function handleDownloadFile(file: Attachment) { function handleDownloadFile(file: Attachment) {
emit("download-file", file); emit("download-file", file);
} }
// //
watch( watch(
() => props.messages.length, () => props.messages.length,
(newLen, oldLen) => { (newLen, oldLen) => {
if (newLen > oldLen) { if (newLen > oldLen) {
if (isAutoScrolling.value) { if (isAutoScrolling.value) {
nextTick(() => { nextTick(() => {
scrollToBottom(false); scrollToBottom(false);
}); });
} else { } else {
newMessageCount.value++; newMessageCount.value++;
} }
} }
}, },
); );
// //
watch( watch(
() => props.messages[props.messages.length - 1]?.content.text, () => props.messages[props.messages.length - 1]?.content.text,
() => { () => {
if (isAutoScrolling.value) { if (isAutoScrolling.value) {
nextTick(() => { nextTick(() => {
const container = containerRef.value; const container = containerRef.value;
if (container) { if (container) {
container.scrollTop = container.scrollHeight; container.scrollTop = container.scrollHeight;
} }
}); });
} }
}, },
); );
// isTyping // isTyping
watch( watch(
() => props.isTyping, () => props.isTyping,
(typing) => { (typing) => {
if (typing && isAutoScrolling.value) { if (typing && isAutoScrolling.value) {
nextTick(() => { nextTick(() => {
scrollToBottom(true); scrollToBottom(true);
}); });
} }
}, },
); );
// //
defineExpose({ defineExpose({
scrollToBottom, scrollToBottom,
}); });
onMounted(() => { onMounted(() => {
scrollToBottom(false); scrollToBottom(false);
}); });
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.message-list { .message-list {
height: 500px; height: 500px;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
position: relative; position: relative;
} }
.messages-wrapper { .messages-wrapper {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 20px 0; padding: 20px 0;
min-height: 100%; min-height: 100%;
} }
.typing-indicator { .typing-indicator {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 16px 24px; padding: 16px 24px;
animation: fadeIn 0.3s ease; animation: fadeIn 0.3s ease;
} }
.typing-avatar { .typing-avatar {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 40px; width: 40px;
height: 40px; height: 40px;
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%); background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
border-radius: 12px; border-radius: 12px;
color: white; color: white;
} }
.typing-dots { .typing-dots {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
padding: 12px 16px; padding: 12px 16px;
background: #f3f4f6; background: #f3f4f6;
border-radius: 16px; border-radius: 16px;
.dark & { .dark & {
background: #2d2d3d; background: #2d2d3d;
} }
span { span {
width: 8px; width: 8px;
height: 8px; height: 8px;
background: #9ca3af; background: #9ca3af;
border-radius: 50%; border-radius: 50%;
animation: typingBounce 1.4s infinite ease-in-out both; animation: typingBounce 1.4s infinite ease-in-out both;
&:nth-child(1) { &:nth-child(1) {
animation-delay: -0.32s; animation-delay: -0.32s;
} }
&:nth-child(2) { &:nth-child(2) {
animation-delay: -0.16s; animation-delay: -0.16s;
} }
} }
} }
.typing-text { .typing-text {
font-size: 13px; font-size: 13px;
color: #9ca3af; color: #9ca3af;
} }
.scroll-bottom-btn { .scroll-bottom-btn {
position: absolute; position: absolute;
bottom: 20px; bottom: 20px;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 44px; width: 44px;
height: 44px; height: 44px;
border: none; border: none;
border-radius: 50%; border-radius: 50%;
background: white; background: white;
color: #374151; color: #374151;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
z-index: 10; z-index: 10;
.dark & { .dark & {
background: #2d2d3d; background: #2d2d3d;
color: #e5e7eb; color: #e5e7eb;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
} }
&:hover { &:hover {
transform: translateX(-50%) scale(1.1); transform: translateX(-50%) scale(1.1);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2); box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
} }
.new-count { .new-count {
position: absolute; position: absolute;
top: -4px; top: -4px;
right: -4px; right: -4px;
min-width: 20px; min-width: 20px;
height: 20px; height: 20px;
padding: 0 6px; padding: 0 6px;
background: #ef4444; background: #ef4444;
border-radius: 10px; border-radius: 10px;
color: white; color: white;
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
} }
// //
.message-enter-active, .message-enter-active,
.message-leave-active { .message-leave-active {
transition: all 0.3s ease; transition: all 0.3s ease;
} }
.message-enter-from { .message-enter-from {
opacity: 0; opacity: 0;
transform: translateY(20px); transform: translateY(20px);
} }
.message-leave-to { .message-leave-to {
opacity: 0; opacity: 0;
transform: translateX(-20px); transform: translateX(-20px);
} }
// //
.fade-enter-active, .fade-enter-active,
.fade-leave-active { .fade-leave-active {
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.fade-enter-from, .fade-enter-from,
.fade-leave-to { .fade-leave-to {
opacity: 0; opacity: 0;
transform: translateX(-50%) translateY(10px); transform: translateX(-50%) translateY(10px);
} }
@keyframes fadeIn { @keyframes fadeIn {
from { from {
opacity: 0; opacity: 0;
transform: translateY(10px); transform: translateY(10px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
} }
} }
@keyframes typingBounce { @keyframes typingBounce {
0%, 0%,
80%, 80%,
100% { 100% {
transform: scale(0.7); transform: scale(0.7);
opacity: 0.5; opacity: 0.5;
} }
40% { 40% {
transform: scale(1); transform: scale(1);
opacity: 1; opacity: 1;
} }
} }
</style> </style>

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,209 +1,238 @@
/** /**
* Chat UI API * Chat UI API
* *
*/ */
// API 端点定义(固定) // API 端点定义(固定)
const API_ENDPOINTS = { const API_ENDPOINTS = {
// 发送消息(流式) // 发送消息(流式)
CHAT_STREAM: "/api/chat-ui/chat", CHAT_STREAM: "/api/chat-ui/chat",
// 发送消息(非流式) // 发送消息(非流式)
CHAT: "/api/chat-ui/chat", CHAT: "/api/chat-ui/chat",
// 获取对话历史 // 获取对话历史
CONVERSATIONS: "/api/chat-ui/conversations", CONVERSATIONS: "/api/chat-ui/conversations",
// 获取单个对话 // 获取单个对话
CONVERSATION: "/api/chat-ui/conversations/:id", CONVERSATION: "/api/chat-ui/conversations/:id",
// 删除对话 // 删除对话
DELETE_CONVERSATION: "/api/chat-ui/conversations/:id", DELETE_CONVERSATION: "/api/chat-ui/conversations/:id",
// 上传文件 // 上传文件
UPLOAD: "/api/chat-ui/upload", UPLOAD: "/api/chat-ui/upload",
// 获取模型列表 // 获取模型列表
MODELS: "/api/chat-ui/models", MODELS: "/api/chat-ui/models",
// 停止生成 // 停止生成
STOP: "/api/chat-ui/stop", STOP: "/api/chat-ui/stop",
}; };
// 请求类型定义 // 请求类型定义
export interface ChatMessage { export interface ChatMessage {
role: "user" | "assistant" | "system"; role: "user" | "assistant" | "system";
content: string; content: string;
images?: string[]; images?: string[];
files?: string[]; files?: string[];
} }
export interface ChatRequest { export interface ChatRequest {
conversationId?: string; conversationId?: string;
message: string; message: string;
images?: string[]; images?: string[];
files?: string[]; files?: string[];
model?: string; model?: string;
temperature?: number; temperature?: number;
maxTokens?: number; maxTokens?: number;
systemPrompt?: string; systemPrompt?: string;
stream?: boolean; stream?: boolean;
// 扩展选项 // 扩展选项
deepSearch?: boolean; deepSearch?: boolean;
webSearch?: boolean; webSearch?: boolean;
deepThinking?: boolean; deepThinking?: boolean;
} }
export interface ChatResponse { export interface ChatResponse {
id: string; id: string;
conversationId: string; conversationId: string;
content: string; content: string;
model: string; model: string;
createdAt: number; createdAt: number;
usage?: { usage?: {
promptTokens: number; promptTokens: number;
completionTokens: number; completionTokens: number;
totalTokens: number; totalTokens: number;
}; };
} }
export interface ModelInfo { export interface ModelInfo {
id: string; id: string;
name: string; name: string;
description: string; description: string;
maxTokens: number; maxTokens: number;
provider: string; provider: string;
} }
export interface UploadResult { export interface UploadResult {
url: string; url: string;
name: string; name: string;
size?: number; size?: number;
mimeType?: string; mimeType?: string;
} }
// API 调用类 // API 调用类
class ChatApi { class ChatApi {
private baseUrl: string; private baseUrl: string;
constructor(baseUrl = "") { constructor(baseUrl = "") {
this.baseUrl = baseUrl; this.baseUrl = baseUrl;
} }
/** /**
* *
*/ */
async *streamChat( async *streamChat(
request: ChatRequest, request: ChatRequest,
signal?: AbortSignal, signal?: AbortSignal,
): AsyncGenerator<string> { ): AsyncGenerator<string> {
const response = await fetch( // 将前端简化的请求翻译为 OpenAI 兼容的规范请求体
`${this.baseUrl}${API_ENDPOINTS.CHAT_STREAM}`, const openAiRequest = {
{ model: request.model || "qwen-plus",
method: "POST", messages: [
headers: { { role: "system", content: request.systemPrompt || "你是一个有用的助手。" },
"Content-Type": "application/json", { role: "user", content: request.message }
Accept: "text/event-stream", ],
}, stream: true,
body: JSON.stringify(request), temperature: request.temperature,
signal, max_tokens: request.maxTokens
}, };
);
const response = await fetch(
if (!response.ok) { `${this.baseUrl}${API_ENDPOINTS.CHAT_STREAM}`,
const error = await response.text(); {
throw new Error(error || `HTTP ${response.status}`); method: "POST",
} headers: {
"Content-Type": "application/json",
const reader = response.body?.getReader(); Accept: "text/event-stream",
if (!reader) { },
throw new Error("Response body is not readable"); body: JSON.stringify(openAiRequest),
} signal,
},
const decoder = new TextDecoder(); );
while (true) {
const { done, value } = await reader.read(); if (!response.ok) {
if (done) break; const error = await response.text();
throw new Error(error || `HTTP ${response.status}`);
const text = decoder.decode(value, { stream: true }); }
const match = text.match(/data:\s*(\{.*\})/);
if (match) { const reader = response.body?.getReader();
yield JSON.parse(match[1])["message"]; if (!reader) {
} throw new Error("Response body is not readable");
} }
}
const decoder = new TextDecoder("utf-8");
/** let buffer = "";
*
*/ while (true) {
async chat(request: ChatRequest): Promise<ChatResponse> { const { done, value } = await reader.read();
const response = await fetch(`${this.baseUrl}${API_ENDPOINTS.CHAT}`, { if (done) break;
method: "POST",
headers: { buffer += decoder.decode(value, { stream: true });
"Content-Type": "application/json", const lines = buffer.split("\n");
}, // 保留最后一行未完整的 JSON
body: JSON.stringify(request), buffer = lines.pop() || "";
});
for (const line of lines) {
if (!response.ok) { if (line.trim() === "" || line.includes("[DONE]")) continue;
const error = await response.text(); const match = line.match(/^data:\s*(.+)$/);
throw new Error(error || `HTTP ${response.status}`); if (match) {
} try {
const data = JSON.parse(match[1]);
return response.json(); const content = data.choices?.[0]?.delta?.content;
} if (content) {
yield content;
/** }
* } catch (e) {
*/ console.warn("JSON解析错误", e, line);
async stopChat(messageId?: string) { }
await fetch(`${this.baseUrl}${API_ENDPOINTS.STOP}/${messageId}`, { }
method: "POST", }
headers: { }
"Content-Type": "application/json", }
},
}); /**
} *
*/
/** async chat(request: ChatRequest): Promise<ChatResponse> {
* const response = await fetch(`${this.baseUrl}${API_ENDPOINTS.CHAT}`, {
*/ method: "POST",
async getModels(): Promise<ModelInfo[]> { headers: {
return [ "Content-Type": "application/json",
{ },
id: "gpt-4", body: JSON.stringify(request),
name: "GPT-4", });
description: "最强大的模型",
maxTokens: 8192, if (!response.ok) {
provider: "OpenAI", const error = await response.text();
}, throw new Error(error || `HTTP ${response.status}`);
{ }
id: "gpt-3.5-turbo",
name: "GPT-3.5 Turbo", return response.json();
description: "快速高效", }
maxTokens: 16384,
provider: "OpenAI", /**
}, *
]; */
} async stopChat(messageId?: string) {
await fetch(`${this.baseUrl}${API_ENDPOINTS.STOP}/${messageId}`, {
/** method: "POST",
* headers: {
*/ "Content-Type": "application/json",
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, */
}); async getModels(): Promise<ModelInfo[]> {
return [
if (!response.ok) { {
throw new Error(`上传失败: HTTP ${response.status}`); id: "qwen-max",
} name: "通义千问 Max",
description: "最强大的模型",
return response.json(); maxTokens: 8192,
} provider: "Aliyun",
} },
{
// 导出单例 id: "qwen-plus",
export const chatApi = new ChatApi(); name: "通义千问 Plus",
description: "能力均衡",
// 导出类用于自定义配置 maxTokens: 8192,
export { ChatApi, API_ENDPOINTS }; provider: "Aliyun",
},
// 导出端点常量(供调试使用) ];
// export {API_ENDPOINTS} }
/**
*
*/
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 { defineStore } from "pinia";
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import type { import type {
Conversation, Conversation,
Message, Message,
MessageContent, MessageContent,
ConversationSettings, ConversationSettings,
} from "@/types/chat"; } from "@/types/chat";
import { MessageRole } from "@/types/chat"; import { MessageRole } from "@/types/chat";
import { generateId, extractTitleFromMessage } from "@/utils/helpers"; import { generateId, extractTitleFromMessage } from "@/utils/helpers";
export const useChatStore = defineStore("chat", () => { export const useChatStore = defineStore("chat", () => {
// 状态 // 状态
const conversations = ref<Conversation[]>([]); const conversations = ref<Conversation[]>([]);
const currentConversationId = ref<string | null>(null); const currentConversationId = ref<string | null>(null);
const isStreaming = ref(false); const isStreaming = ref(false);
const streamController = ref<AbortController | null>(null); const streamController = ref<AbortController | null>(null);
// 计算属性 // 计算属性
const currentConversation = computed(() => { const currentConversation = computed(() => {
return ( return (
conversations.value.find((c) => c.id === currentConversationId.value) || conversations.value.find((c) => c.id === currentConversationId.value) ||
null null
); );
}); });
const sortedConversations = computed(() => { const sortedConversations = computed(() => {
return [...conversations.value].sort((a, b) => { return [...conversations.value].sort((a, b) => {
if (a.pinned && !b.pinned) return -1; if (a.pinned && !b.pinned) return -1;
if (!a.pinned && b.pinned) return 1; if (!a.pinned && b.pinned) return 1;
return b.updatedAt - a.updatedAt; return b.updatedAt - a.updatedAt;
}); });
}); });
const pinnedConversations = computed(() => { const pinnedConversations = computed(() => {
return sortedConversations.value.filter((c) => c.pinned && !c.archived); return sortedConversations.value.filter((c) => c.pinned && !c.archived);
}); });
const recentConversations = computed(() => { const recentConversations = computed(() => {
return sortedConversations.value.filter((c) => !c.pinned && !c.archived); return sortedConversations.value.filter((c) => !c.pinned && !c.archived);
}); });
// 方法 // 方法
function createConversation(): string { function createConversation(): string {
const newConversation: Conversation = { const newConversation: Conversation = {
id: generateId(), id: generateId(),
title: "新对话", title: "新对话",
messages: [], messages: [],
createdAt: Date.now(), createdAt: Date.now(),
updatedAt: Date.now(), updatedAt: Date.now(),
pinned: false, pinned: false,
archived: false, archived: false,
settings: undefined, settings: undefined,
}; };
conversations.value.unshift(newConversation); conversations.value.unshift(newConversation);
currentConversationId.value = newConversation.id; currentConversationId.value = newConversation.id;
saveToStorage(); saveToStorage();
return newConversation.id; return newConversation.id;
} }
function deleteConversation(id: string) { function deleteConversation(id: string) {
const index = conversations.value.findIndex((c) => c.id === id); const index = conversations.value.findIndex((c) => c.id === id);
if (index !== -1) { if (index !== -1) {
conversations.value.splice(index, 1); conversations.value.splice(index, 1);
if (currentConversationId.value === id) { if (currentConversationId.value === id) {
currentConversationId.value = conversations.value[0]?.id || null; currentConversationId.value = conversations.value[0]?.id || null;
} }
saveToStorage(); saveToStorage();
} }
} }
function selectConversation(id: string) { function selectConversation(id: string) {
currentConversationId.value = id; currentConversationId.value = id;
} }
function togglePinConversation(id: string) { function togglePinConversation(id: string) {
const conversation = conversations.value.find((c) => c.id === id); const conversation = conversations.value.find((c) => c.id === id);
if (conversation) { if (conversation) {
conversation.pinned = !conversation.pinned; conversation.pinned = !conversation.pinned;
saveToStorage(); saveToStorage();
} }
} }
function renameConversation(id: string, newTitle: string) { function renameConversation(id: string, newTitle: string) {
const conversation = conversations.value.find((c) => c.id === id); const conversation = conversations.value.find((c) => c.id === id);
if (conversation) { if (conversation) {
conversation.title = newTitle; conversation.title = newTitle;
conversation.updatedAt = Date.now(); conversation.updatedAt = Date.now();
saveToStorage(); saveToStorage();
} }
} }
function updateConversationSettings( function updateConversationSettings(
id: string, id: string,
convSettings: ConversationSettings, convSettings: ConversationSettings,
) { ) {
const conversation = conversations.value.find((c) => c.id === id); const conversation = conversations.value.find((c) => c.id === id);
if (conversation) { if (conversation) {
conversation.settings = { ...conversation.settings, ...convSettings }; conversation.settings = { ...conversation.settings, ...convSettings };
conversation.updatedAt = Date.now(); conversation.updatedAt = Date.now();
saveToStorage(); saveToStorage();
} }
} }
function addMessage( function addMessage(
role: MessageRole, role: MessageRole,
content: MessageContent, content: MessageContent,
conversationId?: string, conversationId?: string,
): Message { ): Message {
const targetId = conversationId || currentConversationId.value; const targetId = conversationId || currentConversationId.value;
if (!targetId) { if (!targetId) {
createConversation(); createConversation();
} }
const conversation = conversations.value.find( const conversation = conversations.value.find(
(c) => c.id === (targetId || currentConversationId.value), (c) => c.id === (targetId || currentConversationId.value),
); );
if (!conversation) { if (!conversation) {
throw new Error("Conversation not found"); throw new Error("Conversation not found");
} }
const message: any = { const message: any = {
id: generateId(), id: generateId(),
role, role,
content, content,
timestamp: Date.now(), timestamp: Date.now(),
isStreaming: false, isStreaming: false,
}; };
conversation.messages.push(message); conversation.messages.push(message);
conversation.updatedAt = Date.now(); conversation.updatedAt = Date.now();
if ( if (
role === MessageRole.USER && role === MessageRole.USER &&
conversation.messages.length === 1 && conversation.messages.length === 1 &&
content.text content.text
) { ) {
conversation.title = extractTitleFromMessage(content.text); conversation.title = extractTitleFromMessage(content.text);
} }
saveToStorage(); saveToStorage();
return message; return message;
} }
function updateMessage(messageId: string, updates: Partial<Message>) { function updateMessage(messageId: string, updates: Partial<Message>) {
const conversation = currentConversation.value; const conversation = currentConversation.value;
if (!conversation) return; if (!conversation) return;
const message = conversation.messages.find((m) => m.id === messageId); const message = conversation.messages.find((m) => m.id === messageId);
if (message) { if (message) {
Object.assign(message, updates); Object.assign(message, updates);
saveToStorage(); saveToStorage();
} }
} }
function updateMessageContent(messageId: string, text: string) { function updateMessageContent(messageId: string, text: string) {
const conversation = currentConversation.value; const conversation = currentConversation.value;
if (!conversation) return; if (!conversation) return;
const message = conversation.messages.find((m) => m.id === messageId); const message = conversation.messages.find((m) => m.id === messageId);
if (message) { if (message) {
message.content.text = text; message.content.text = text;
} }
} }
function setMessageFeedback( function setMessageFeedback(
messageId: string, messageId: string,
feedback: "like" | "dislike" | null, feedback: "like" | "dislike" | null,
) { ) {
const conversation = currentConversation.value; const conversation = currentConversation.value;
if (!conversation) return; if (!conversation) return;
const message = conversation.messages.find((m) => m.id === messageId); const message = conversation.messages.find((m) => m.id === messageId);
if (message) { if (message) {
message.feedback = { message.feedback = {
liked: feedback === "like", liked: feedback === "like",
disliked: feedback === "dislike", disliked: feedback === "dislike",
copied: message.feedback?.copied, copied: message.feedback?.copied,
}; };
saveToStorage(); saveToStorage();
} }
} }
function setMessageCopied(messageId: string) { function setMessageCopied(messageId: string) {
const conversation = currentConversation.value; const conversation = currentConversation.value;
if (!conversation) return; if (!conversation) return;
const message = conversation.messages.find((m) => m.id === messageId); const message = conversation.messages.find((m) => m.id === messageId);
if (message) { if (message) {
message.feedback = { message.feedback = {
...message.feedback, ...message.feedback,
copied: true, copied: true,
}; };
} }
} }
function startStreaming() { function startStreaming() {
isStreaming.value = true; isStreaming.value = true;
streamController.value = new AbortController(); streamController.value = new AbortController();
} }
function stopStreaming() { function stopStreaming() {
isStreaming.value = false; isStreaming.value = false;
if (streamController.value) { if (streamController.value) {
streamController.value.abort(); streamController.value.abort();
streamController.value = null; streamController.value = null;
} }
} }
function clearConversation(id: string) { function clearConversation(id: string) {
const conversation = conversations.value.find((c) => c.id === id); const conversation = conversations.value.find((c) => c.id === id);
if (conversation) { if (conversation) {
conversation.messages = []; conversation.messages = [];
conversation.updatedAt = Date.now(); conversation.updatedAt = Date.now();
saveToStorage(); saveToStorage();
} }
} }
function saveToStorage() { function saveToStorage() {
try { try {
localStorage.setItem( localStorage.setItem(
"chat-conversations", "chat-conversations",
JSON.stringify(conversations.value), JSON.stringify(conversations.value),
); );
localStorage.setItem( localStorage.setItem(
"chat-current-id", "chat-current-id",
currentConversationId.value || "", currentConversationId.value || "",
); );
} catch (e) { } catch (e) {
console.error("Failed to save to storage:", e); console.error("Failed to save to storage:", e);
} }
} }
function loadFromStorage() { function loadFromStorage() {
try { try {
const stored = localStorage.getItem("chat-conversations"); const stored = localStorage.getItem("chat-conversations");
if (stored) { if (stored) {
conversations.value = JSON.parse(stored); conversations.value = JSON.parse(stored);
} }
const storedId = localStorage.getItem("chat-current-id"); const storedId = localStorage.getItem("chat-current-id");
if (storedId && conversations.value.find((c) => c.id === storedId)) { if (storedId && conversations.value.find((c) => c.id === storedId)) {
currentConversationId.value = storedId; currentConversationId.value = storedId;
} else if (conversations.value.length > 0) { } else if (conversations.value.length > 0) {
currentConversationId.value = conversations.value[0].id; currentConversationId.value = conversations.value[0].id;
} }
} catch (e) { } catch (e) {
console.error("Failed to load from storage:", e); console.error("Failed to load from storage:", e);
} }
} }
loadFromStorage(); loadFromStorage();
return { return {
conversations, conversations,
currentConversationId, currentConversationId,
isStreaming, isStreaming,
streamController, streamController,
currentConversation, currentConversation,
sortedConversations, sortedConversations,
pinnedConversations, pinnedConversations,
recentConversations, recentConversations,
createConversation, createConversation,
deleteConversation, deleteConversation,
selectConversation, selectConversation,
togglePinConversation, togglePinConversation,
renameConversation, renameConversation,
updateConversationSettings, updateConversationSettings,
addMessage, addMessage,
updateMessage, updateMessage,
updateMessageContent, updateMessageContent,
setMessageFeedback, setMessageFeedback,
setMessageCopied, setMessageCopied,
startStreaming, startStreaming,
stopStreaming, stopStreaming,
clearConversation, clearConversation,
loadFromStorage, loadFromStorage,
}; };
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,12 +1,12 @@
{ {
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"skipLibCheck": true, "skipLibCheck": true,
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"strict": true "strict": true
}, },
"include": ["vite.config.ts"] "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 { import {
defineConfig, defineConfig,
presetAttributify, presetAttributify,
transformerDirectives, transformerDirectives,
transformerVariantGroup, transformerVariantGroup,
presetUno presetUno
} from "unocss"; } from "unocss";
export default defineConfig({ export default defineConfig({
content: { content: {
pipeline: { pipeline: {
include: [ include: [
/\.(vue|svelte|[jt]sx|mdx?|astro|elm|php|phtml|html)($|\?)/, // the default /\.(vue|svelte|[jt]sx|mdx?|astro|elm|php|phtml|html)($|\?)/, // the default
"**/src/**/*.{js,ts}" // include js/ts files "**/src/**/*.{js,ts}" // include js/ts files
] ]
} }
}, },
presets: [presetAttributify(), presetUno()], presets: [presetAttributify(), presetUno()],
transformers: [transformerDirectives(), transformerVariantGroup()], transformers: [transformerDirectives(), transformerVariantGroup()],
shortcuts: [ shortcuts: [
["flex-center", "flex justify-center items-center"], ["flex-center", "flex justify-center items-center"],
["full", "w-full h-full"], ["full", "w-full h-full"],
[/^(.*)-i$/, ([, prefix]) => `${prefix}!`], // w-full-i -> { width: 100% !important } [/^(.*)-i$/, ([, prefix]) => `${prefix}!`], // w-full-i -> { width: 100% !important }
[/^(.*)-(\d+)p$/, ([, prefix, d]) => `${prefix}-${d}%`], // w-50p -> { width: 50% } [/^(.*)-(\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) } [/^(.*)-var-(.*)$/, ([, prefix, v]) => `${prefix}-$${v}`] // bg-var-el-color-primary -> { background-color: var(--el-color-primary) }
], ],
rules: [[/^(.*)-setvar-(.*)$/, ([, prefix, v]) => ({ [`--${prefix}`]: v })]] rules: [[/^(.*)-setvar-(.*)$/, ([, prefix, v]) => ({ [`--${prefix}`]: v })]]
}); });

View File

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