输入框样式调整

This commit is contained in:
zll-g 2026-02-11 13:26:50 +08:00
parent 099f34f985
commit 83fbfc2c37
56 changed files with 19414 additions and 1 deletions

24
.gitignore vendored Normal file
View File

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

View File

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 zll-g
Copyright (c) 2026 zll-it
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

104
README.md Normal file
View File

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

16
index.html Normal file
View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vue.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI-CHAT-UI - 企业级智能对话</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

5384
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
package.json Normal file
View File

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

3671
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

1
public/vue.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

BIN
screenshots/img.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
screenshots/img_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
screenshots/img_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

BIN
screenshots/img_3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

BIN
screenshots/img_4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

BIN
screenshots/img_5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

BIN
screenshots/img_6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

225
src/App.vue Normal file
View File

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

1
src/assets/vue.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@ -0,0 +1,529 @@
<template>
<header class="chat-header">
<!-- 左侧侧边栏切换和标题 -->
<div class="header-left">
<button
v-if="showSidebarToggle"
class="toggle-sidebar-btn"
title="切换侧边栏 (Ctrl+B)"
@click="$emit('toggle-sidebar')"
>
<Menu :size="20" />
</button>
<div class="conversation-info">
<h1 class="title">{{ title }}</h1>
<span v-if="messageCount > 0" class="message-count">
{{ messageCount }} 条消息
</span>
</div>
</div>
<!-- 中间模型选择可选 -->
<div class="header-center">
<button class="model-selector" @click="showModelMenu = !showModelMenu">
<Sparkles :size="16" />
<span>{{ currentModel }}</span>
<ChevronDown :size="14" />
</button>
<Transition name="dropdown">
<div v-if="showModelMenu" class="model-menu">
<button
v-for="model in models"
:key="model.id"
class="model-option"
:class="{ active: model.id === currentModelId }"
@click="selectModel(model.id, model.name)"
>
<div class="model-info">
<span class="model-name">{{ model.name }}</span>
<span class="model-desc">{{ model.description }}</span>
</div>
<Check
v-if="model.id === currentModelId"
:size="16"
class="check-icon"
/>
</button>
</div>
</Transition>
</div>
<!-- 右侧操作按钮 -->
<div class="header-right">
<!-- 对话大小切换 -->
<button
class="header-btn"
:title="isWideMode ? '标准视图' : '宽屏视图'"
@click="$emit('toggle-wide-mode')"
>
<Maximize2 v-if="!isWideMode" :size="18" />
<Minimize2 v-else :size="18" />
</button>
<!-- 清空对话 -->
<button
class="header-btn"
title="清空对话"
:disabled="messageCount === 0"
@click="handleClear"
>
<Trash2 :size="18" />
</button>
<!-- 导出对话 -->
<button
class="header-btn"
title="导出对话"
:disabled="messageCount === 0"
@click="$emit('export')"
>
<Download :size="18" />
</button>
<!-- 更多操作 -->
<button
class="header-btn"
title="更多选项"
@click="showMoreMenu = !showMoreMenu"
>
<MoreHorizontal :size="18" />
</button>
<Transition name="dropdown">
<div v-if="showMoreMenu" class="more-menu">
<button class="menu-item" @click="handleShare">
<ExternalLink :size="16" />
<span>分享对话</span>
</button>
<button class="menu-item" @click="handlePin">
<Pin :size="16" />
<span>{{ isPinned ? "取消置顶" : "置顶对话" }}</span>
</button>
<button class="menu-item" @click="handleArchive">
<Archive :size="16" />
<span>归档对话</span>
</button>
<div class="menu-divider"></div>
<button class="menu-item" @click="handleSettings">
<Settings :size="16" />
<span>对话设置</span>
</button>
</div>
</Transition>
</div>
</header>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import {
Menu,
Sparkles,
ChevronDown,
Check,
Maximize2,
Minimize2,
Trash2,
Download,
MoreHorizontal,
ExternalLink,
Pin,
Archive,
Settings,
} from "@/components/icons";
import { useSettingsStore } from "@/stores/settings.ts";
import { chatApi } from "@/services/api.ts";
const props = withDefaults(
defineProps<{
title?: string;
messageCount?: number;
showSidebarToggle?: boolean;
isWideMode?: boolean;
isPinned?: boolean;
currentModelId?: string;
}>(),
{
title: "新对话",
messageCount: 0,
showSidebarToggle: true,
isWideMode: true,
isPinned: false,
currentModelId: "gpt-4",
},
);
const emit = defineEmits<{
"toggle-sidebar": [];
"toggle-wide-mode": [];
clear: [];
export: [];
share: [];
pin: [];
archive: [];
settings: [];
"select-model": [modelId: string];
"conversation-settings": [];
}>();
const showModelMenu = ref(false);
const showMoreMenu = ref(false);
const settingsStore = useSettingsStore();
const currentModel = ref(localStorage.getItem("modelSelect") || "");
const models: any = ref([]);
onMounted(() => {
chatApi.getModels().then((res: any) => {
models.value = res;
if (!localStorage.getItem("modelSelect")) {
currentModel.value = models.value[0]["name"] || "";
localStorage.setItem("modelSelect", currentModel.value);
}
});
});
function selectModel(modelId: string, modelName: string) {
localStorage.setItem("modelSelect", modelName);
const model = models.value?.find((m: any) => m.id === modelId);
if (model) {
currentModel.value = model.name;
emit("select-model", modelId);
}
showModelMenu.value = false;
}
function handleClear() {
if (confirm("确定要清空当前对话吗?此操作不可恢复。")) {
emit("clear");
}
}
function handleShare() {
showMoreMenu.value = false;
emit("share");
}
function handlePin() {
showMoreMenu.value = false;
emit("pin");
}
function handleArchive() {
showMoreMenu.value = false;
emit("archive");
}
//
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest(".model-selector") && !target.closest(".model-menu")) {
showModelMenu.value = false;
}
if (!target.closest(".header-btn") && !target.closest(".more-menu")) {
showMoreMenu.value = false;
}
}
function handleSettings() {
showMoreMenu.value = false;
settingsStore.openConversationSettingsModal();
}
if (typeof window !== "undefined") {
document.addEventListener("click", handleClickOutside);
}
</script>
<style lang="scss" scoped>
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 64px;
padding: 0 20px;
background: white;
border-bottom: 1px solid #e2e8f0;
flex-shrink: 0;
.dark & {
background: #1e1e2e;
border-bottom-color: #2d2d3d;
}
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.toggle-sidebar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: none;
border-radius: 10px;
background: transparent;
color: #6b7280;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #f3f4f6;
color: #374151;
.dark & {
background: #2d2d3d;
color: #e5e7eb;
}
}
}
.conversation-info {
display: flex;
flex-direction: column;
}
.title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #1f2937;
.dark & {
color: #f3f4f6;
}
}
.message-count {
font-size: 12px;
color: #9ca3af;
}
.header-center {
position: relative;
}
.model-selector {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: white;
color: #374151;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
.dark & {
background: #2d2d3d;
border-color: #374151;
color: #e5e7eb;
}
&:hover {
border-color: #3b82f6;
}
svg:first-child {
color: #8b5cf6;
}
}
.model-menu {
position: absolute;
top: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
min-width: 280px;
padding: 8px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 14px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
z-index: 100;
.dark & {
background: #1e1e2e;
border-color: #2d2d3d;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
}
}
.model-option {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 12px 14px;
border: none;
border-radius: 10px;
background: transparent;
text-align: left;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: #f3f4f6;
.dark & {
background: #2d2d3d;
}
}
&.active {
background: rgba(59, 130, 246, 0.1);
.model-name {
color: #3b82f6;
}
}
}
.model-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.model-name {
font-size: 14px;
font-weight: 500;
color: #1f2937;
.dark & {
color: #f3f4f6;
}
}
.model-desc {
font-size: 12px;
color: #9ca3af;
}
.check-icon {
color: #3b82f6;
}
.header-right {
display: flex;
align-items: center;
gap: 6px;
position: relative;
}
.header-btn {
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border: none;
border-radius: 10px;
background: transparent;
color: #6b7280;
cursor: pointer;
transition: all 0.2s ease;
&:hover:not(:disabled) {
background: #f3f4f6;
color: #374151;
.dark & {
background: #2d2d3d;
color: #e5e7eb;
}
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.more-menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
min-width: 180px;
padding: 8px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 14px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
z-index: 100;
.dark & {
background: #1e1e2e;
border-color: #2d2d3d;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
}
}
.menu-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 12px;
border: none;
border-radius: 8px;
background: transparent;
color: #374151;
font-size: 14px;
text-align: left;
cursor: pointer;
transition: all 0.15s ease;
.dark & {
color: #e5e7eb;
}
&:hover {
background: #f3f4f6;
.dark & {
background: #2d2d3d;
}
}
svg {
color: #6b7280;
}
}
.menu-divider {
height: 1px;
margin: 6px 0;
background: #e2e8f0;
.dark & {
background: #374151;
}
}
//
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.2s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-8px);
}
.header-center .dropdown-enter-from,
.header-center .dropdown-leave-to {
transform: translateX(-50%) translateY(-8px);
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,646 @@
<template>
<div
class="chat-input-container"
:class="{ 'is-focused': isFocused, 'is-expanded': isExpanded }"
>
<!-- 附件预览区 -->
<AttachmentPreview
v-if="attachments.length > 0"
:attachments="attachments"
@remove="removeAttachment"
/>
<!-- 输入区域 -->
<div class="input-area">
<!-- 左侧功能按钮 -->
<div class="input-actions left">
<!-- 附件按钮 -->
<button class="action-btn" title="添加附件" @click="triggerFileInput">
<Paperclip :size="20" />
</button>
<!-- 图片按钮 -->
<button class="action-btn" title="添加图片" @click="triggerImageInput">
<Image :size="20" />
</button>
<!-- 隐藏的文件输入框 -->
<input
ref="fileInputRef"
type="file"
multiple
hidden
@change="handleFileSelect"
/>
<input
ref="imageInputRef"
type="file"
accept="image/*"
multiple
hidden
@change="handleImageSelect"
/>
</div>
<!-- 文本输入框 -->
<div class="textarea-wrapper">
<textarea
ref="textareaRef"
v-model="inputText"
:placeholder="placeholder"
:rows="1"
@input="autoResize"
@focus="isFocused = true"
@blur="isFocused = false"
@keydown="handleKeydown"
@paste="handlePaste"
/>
</div>
<!-- 右侧功能按钮 -->
<div class="input-actions right">
<!-- 发送/停止按钮 -->
<button
v-if="isStreaming"
class="action-btn stop"
title="停止生成"
@click="$emit('stop')"
>
<StopCircle :size="20" />
</button>
<button
v-else
class="action-btn send"
:class="{ active: canSend }"
:disabled="!canSend"
title="发送消息 (Ctrl+Enter)"
@click="handleSend"
>
<Send :size="20" />
</button>
</div>
</div>
<!-- 底部工具栏 -->
<div class="input-toolbar">
<div class="toolbar-left">
<!-- 深度思考开关 -->
<button
class="toolbar-btn"
:class="{ active: isDeepThinking }"
title="深度思考"
@click="toggleDeepThink"
>
<Brain :size="16" />
<span>深度思考</span>
</button>
<!-- 深度搜索开关 -->
<button
class="toolbar-btn"
:class="{ active: isDeepSearch }"
title="深度搜索"
@click="toggleDeepSearch"
>
<Sparkles :size="16" />
<span>深度搜索</span>
</button>
<!-- 联网搜索开关 -->
<button
class="toolbar-btn"
:class="{ active: isWebSearch }"
title="联网搜索"
@click="toggleWebSearch"
>
<Globe :size="16" />
<span>联网搜索</span>
</button>
<!-- 展开/收起 -->
<button class="toolbar-btn" title="展开输入框" @click="toggleExpand">
<Maximize2 v-if="!isExpanded" :size="16" />
<Minimize2 v-else :size="16" />
</button>
</div>
<div class="toolbar-right">
<span
class="char-count"
:class="{ warning: charCount > maxChars * 0.9 }"
>
{{ charCount }} / {{ maxChars }}
</span>
<span class="send-hint">
{{ sendOnEnter ? "Enter 发送, Shift+Enter 换行" : "Ctrl+Enter 发送" }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted } from "vue";
import {
Paperclip,
Image,
Send,
StopCircle,
Sparkles,
Globe,
Maximize2,
Minimize2,
Brain,
} from "@/components/icons";
import AttachmentPreview from "./AttachmentPreview.vue";
import { generateId } from "@/utils/helpers";
import type { Attachment } from "@/types/chat";
interface AttachmentWithProgress extends Attachment {
uploading?: boolean;
progress?: number;
}
const props = withDefaults(
defineProps<{
placeholder?: string;
isStreaming?: boolean;
sendOnEnter?: boolean;
maxChars?: number;
disabled?: boolean;
}>(),
{
placeholder: "输入你的问题...",
isStreaming: false,
sendOnEnter: false,
maxChars: 4000,
disabled: false,
},
);
const emit = defineEmits<{
send: [
text: string,
attachments: Attachment[],
options: { deepSearch: boolean; webSearch: boolean; deepThinking: boolean },
];
stop: [];
}>();
//
const inputText = ref("");
const attachments = ref<AttachmentWithProgress[]>([]);
const isFocused = ref(false);
const isExpanded = ref(false);
const isDeepSearch = ref(
JSON.parse(localStorage.getItem("isDeepSearch") || "false"),
);
const isDeepThinking = ref(
JSON.parse(localStorage.getItem("isDeepThinking") || "false"),
);
const isWebSearch = ref(
JSON.parse(localStorage.getItem("isWebSearch") || "false"),
);
// DOM
const textareaRef = ref<HTMLTextAreaElement | null>(null);
const fileInputRef = ref<HTMLInputElement | null>(null);
const imageInputRef = ref<HTMLInputElement | null>(null);
//
const charCount = computed(() => inputText.value.length);
const canSend = computed(() => {
return (
(inputText.value.trim().length > 0 || attachments.value.length > 0) &&
!props.disabled &&
charCount.value <= props.maxChars
);
});
//
function autoResize() {
const textarea = textareaRef.value;
if (!textarea) return;
textarea.style.height = "auto";
const maxHeight = isExpanded.value ? 400 : 160;
textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`;
}
//
function handleKeydown(event: KeyboardEvent) {
// Ctrl+Enter Cmd+Enter
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
event.preventDefault();
handleSend();
return;
}
// Enter Shift
if (props.sendOnEnter && event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleSend();
return;
}
}
//
function handleSend() {
if (!canSend.value) return;
emit("send", inputText.value.trim(), [...attachments.value], {
deepSearch: isDeepSearch.value,
webSearch: isWebSearch.value,
deepThinking: isDeepThinking.value,
});
//
inputText.value = "";
attachments.value = [];
// isDeepSearch.value = false;
// isWebSearch.value = false;
// isDeepThinking.value = false;
nextTick(() => {
autoResize();
});
}
//
async function handlePaste(event: ClipboardEvent) {
const items = event.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (item.type.startsWith("image/")) {
event.preventDefault();
const file = item.getAsFile();
if (file) {
await addFileAsAttachment(file, "image");
}
}
}
}
//
function triggerFileInput() {
fileInputRef.value?.click();
}
function triggerImageInput() {
imageInputRef.value?.click();
}
//
async function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
const files = input.files;
if (!files) return;
for (const file of files) {
await addFileAsAttachment(file, "file");
}
input.value = "";
}
async function handleImageSelect(event: Event) {
const input = event.target as HTMLInputElement;
const files = input.files;
if (!files) return;
for (const file of files) {
await addFileAsAttachment(file, "image");
}
input.value = "";
}
//
async function addFileAsAttachment(
file: File,
type: "image" | "file" | "video",
) {
const id = generateId();
// URL
const url = URL.createObjectURL(file);
const attachment: AttachmentWithProgress = {
id,
name: file.name,
type,
url,
size: file.size,
mimeType: file.type,
uploading: true,
progress: 0,
};
attachments.value.push(attachment);
//
simulateUpload(id);
}
function simulateUpload(id: string) {
let progress = 0;
const interval = setInterval(() => {
progress += Math.random() * 30;
if (progress >= 100) {
progress = 100;
clearInterval(interval);
const attachment = attachments.value.find((a) => a.id === id);
if (attachment) {
attachment.uploading = false;
attachment.progress = 100;
}
} else {
const attachment = attachments.value.find((a) => a.id === id);
if (attachment) {
attachment.progress = progress;
}
}
}, 200);
}
//
function removeAttachment(id: string) {
const index = attachments.value.findIndex((a) => a.id === id);
if (index !== -1) {
// blob URL
URL.revokeObjectURL(attachments.value[index].url);
attachments.value.splice(index, 1);
}
}
//
function toggleDeepSearch() {
isDeepSearch.value = !isDeepSearch.value;
localStorage.setItem("isDeepSearch", String(isDeepSearch.value));
}
function toggleDeepThink() {
isDeepThinking.value = !isDeepThinking.value;
localStorage.setItem("isDeepThinking", String(isDeepThinking.value));
}
function toggleWebSearch() {
isWebSearch.value = !isWebSearch.value;
localStorage.setItem("isWebSearch", String(isWebSearch.value));
}
function toggleExpand() {
isExpanded.value = !isExpanded.value;
nextTick(() => {
autoResize();
});
}
//
function focus() {
textareaRef.value?.focus();
}
function clear() {
inputText.value = "";
attachments.value = [];
nextTick(() => {
autoResize();
});
}
defineExpose({
focus,
clear,
});
//
watch(inputText, () => {
nextTick(() => {
autoResize();
});
});
onMounted(() => {
autoResize();
});
</script>
<style lang="scss" scoped>
.chat-input-container {
background: white;
border: 2px solid #e2e8f0;
border-radius: 20px;
overflow: hidden;
transition: all 0.2s ease;
.dark & {
background: #1e1e2e;
border-color: #374151;
}
&.is-focused {
border-color: #3b82f6;
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
}
&.is-expanded {
.textarea-wrapper textarea {
min-height: 200px;
}
}
}
.input-area {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 12px 16px;
}
.input-actions {
display: flex;
align-items: center;
gap: 4px;
padding-bottom: 4px;
&.left {
flex-shrink: 0;
}
&.right {
flex-shrink: 0;
}
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border: none;
border-radius: 12px;
background: transparent;
color: #6b7280;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #f3f4f6;
color: #374151;
.dark & {
background: #374151;
color: #e5e7eb;
}
}
&.send {
background: #e5e7eb;
color: #9ca3af;
.dark & {
background: #374151;
}
&.active {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
&:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
&.stop {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
color: white;
animation: pulse 2s infinite;
&:hover {
transform: scale(1.05);
}
}
}
.textarea-wrapper {
flex: 1;
min-width: 0;
textarea {
width: 100%;
min-height: 24px;
max-height: 160px;
padding: 8px 0;
border: none;
outline: none;
background: transparent;
font-family: inherit;
font-size: 15px;
line-height: 1.5;
color: #1f2937;
resize: none;
.dark & {
color: #f3f4f6;
}
&::placeholder {
color: #9ca3af;
}
}
}
.input-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
border-top: 1px solid #f3f4f6;
background: #fafbfc;
.dark & {
border-top-color: #2d2d3d;
background: #181825;
}
}
.toolbar-left {
display: flex;
align-items: center;
gap: 6px;
}
.toolbar-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: #6b7280;
font-size: 13px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #f3f4f6;
color: #374151;
.dark & {
background: #2d2d3d;
color: #e5e7eb;
}
}
&.active {
background: rgba(59, 130, 246, 0.1);
border-color: rgba(59, 130, 246, 0.3);
color: #3b82f6;
svg {
color: #3b82f6;
}
}
}
.toolbar-right {
display: flex;
align-items: center;
gap: 16px;
}
.char-count {
font-size: 12px;
color: #9ca3af;
&.warning {
color: #f59e0b;
}
}
.send-hint {
font-size: 12px;
color: #9ca3af;
}
@keyframes pulse {
0%,
100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
}
50% {
box-shadow: 0 0 0 8px rgba(239, 68, 68, 0);
}
}
</style>

View File

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

View File

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

View File

@ -0,0 +1,838 @@
<template>
<div
class="message-bubble"
:class="[
`role-${message.role}`,
{
'is-streaming': message.isStreaming,
'is-end': !message.isEnd && message.role !== 'user',
'is-error': message.isError,
compact: compact,
},
]"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
>
<!-- 头像 -->
<div class="avatar">
<div class="avatar-inner" :class="message.role">
<Bot v-if="message.role === 'assistant'" :size="20" />
<User v-else :size="20" />
</div>
</div>
<!-- 消息内容区域 -->
<div class="message-content-wrapper">
<!-- 角色名称 -->
<div class="message-header">
<span class="role-name">
{{ message.role === "assistant" ? "AI 助手" : "你" }}
</span>
<span v-if="showTimestamp" class="timestamp">
{{ formattedTime }}
</span>
</div>
<!-- 消息主体 -->
<div class="message-body">
<!-- 错误状态 -->
<div v-if="message.isError" class="error-content">
<AlertCircle :size="18" />
<span>{{ message.errorMessage || "消息发送失败" }}</span>
<button class="retry-btn" @click="$emit('retry')">
<RefreshCw :size="14" />
重试
</button>
</div>
<!-- 正常内容 -->
<template v-else>
<!-- 文本内容 - 使用 markstream-vue -->
<div v-if="message.content.text" class="text-content markstream-vue">
<MarkdownRender
v-if="message.role !== 'user'"
:content="message.content.text"
:custom-html-tags="['think']"
custom-id="playground-demo"
:escape-html-tags="['question', 'answer']"
@copy="textCopy"
/>
<div v-else style="white-space: pre-wrap">
{{ message.content.text }}
</div>
</div>
<!-- 推荐选项 -->
<div
v-if="message.content.suggestions?.length && isNew"
class="suggestions"
>
<button
v-for="suggestion in message.content.suggestions"
:key="suggestion.id"
class="suggestion-btn"
@click="$emit('select-suggestion', suggestion)"
>
<Zap :size="14" />
{{ suggestion.text }}
</button>
</div>
<!-- 图片展示 -->
<div v-if="message.content.images?.length" class="images-grid">
<div
v-for="(image, index) in message.content.images"
:key="image.id"
class="image-item"
@click="$emit('preview-image', image, index)"
>
<img :src="image.url" :alt="image.name" loading="lazy" />
<div class="image-overlay">
<Maximize2 :size="18" />
</div>
</div>
</div>
<!-- 单个视频 -->
<div v-if="message.content.videos?.length === 1" class="single-video">
<video
:src="message.content.videos[0].url"
:poster="message.content.videos[0].poster"
controls
preload="metadata"
/>
</div>
<!-- 多个视频 -->
<div
v-if="message.content.videos && message.content.videos.length > 1"
class="videos-grid"
>
<div
v-for="video in message.content.videos"
:key="video.id"
class="video-item"
@click="$emit('play-video', video)"
>
<img :src="video.poster" :alt="video.title" />
<div class="video-overlay">
<Play :size="32" />
</div>
<span v-if="video.duration" class="video-duration">
{{ formatDuration(video.duration) }}
</span>
</div>
</div>
<!-- 附件列表 -->
<div v-if="message.content.files?.length" class="files-list">
<div
v-for="file in message.content.files"
:key="file.id"
class="file-item"
>
<div class="file-icon">
{{ getFileEmoji(file.mimeType) }}
</div>
<div class="file-info">
<span class="file-name">{{ file.name }}</span>
<span class="file-size">{{ formatSize(file.size) }}</span>
</div>
<button
class="download-btn"
@click="$emit('download-file', file)"
>
<Download :size="16" />
</button>
</div>
</div>
</template>
<!-- 加载动画 -->
<div
v-if="message.isStreaming && !message.content.text"
class="loading-dots"
>
<span></span>
<span></span>
<span></span>
</div>
</div>
<!-- 操作栏 -->
<MessageActions
v-if="
message.role === 'assistant' &&
!message.isStreaming &&
!message.isError
"
:content="message.content.text || ''"
:feedback="message.feedback"
:show-regenerate="true"
:is-hovered="isHovered"
:is-new="isNew"
:is-break="message.isBreak"
@copy="handleCopy"
@like="handleLike"
@dislike="handleDislike"
@regenerate="$emit('regenerate')"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useClipboard } from "@vueuse/core";
import { ref, computed } from "vue";
// markstream-vue
import MarkdownRender from "markstream-vue";
import { setCustomComponents } from "markstream-vue";
import {
Bot,
User,
AlertCircle,
RefreshCw,
Zap,
Maximize2,
Play,
Download,
} from "@/components/icons";
import MessageActions from "./MessageActions.vue";
import { formatTimestamp, formatFileSize, getFileIcon } from "@/utils/helpers";
import type { Message, Suggestion, Attachment, VideoInfo } from "@/types/chat";
import ThinkingNode from "./components/ThinkingNode.vue";
import EChartsContainerNode from "./components/EChartsContainerNode.vue";
const props = withDefaults(
defineProps<{
message: Message;
showTimestamp?: boolean;
compact?: boolean;
isNew?: boolean;
}>(),
{
showTimestamp: true,
compact: false,
},
);
const { copy } = useClipboard({ legacy: true });
const emit = defineEmits<{
retry: [];
regenerate: [];
copy: [];
like: [];
dislike: [];
"select-suggestion": [suggestion: Suggestion];
"preview-image": [image: Attachment, index: number];
"play-video": [video: VideoInfo];
"download-file": [file: Attachment];
}>();
const isHovered = ref(false);
const formattedTime = computed(() => {
return formatTimestamp(props.message.timestamp);
});
function getFileEmoji(mimeType?: string) {
return getFileIcon(mimeType || "");
}
function formatSize(size?: number) {
return size ? formatFileSize(size) : "";
}
function formatDuration(seconds: number) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
function textCopy(data: any) {
if (typeof data === "string") {
copy(data);
}
}
function handleCopy() {
emit("copy");
}
function handleLike() {
emit("like");
}
function handleDislike() {
emit("dislike");
}
setCustomComponents("playground-demo", {
think: ThinkingNode,
vmr_container: EChartsContainerNode,
});
</script>
<style lang="scss" scoped>
.message-bubble {
display: flex;
gap: 16px;
padding: 20px 24px;
animation: fadeIn 0.3s ease;
&.role-user {
flex-direction: row-reverse;
.message-content-wrapper {
align-items: flex-end;
}
.message-header {
flex-direction: row-reverse;
}
.message-body {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
border-radius: 20px 20px 4px 20px;
}
.text-content {
:deep(a) {
color: #bfdbfe;
}
:deep(code) {
background: rgba(255, 255, 255, 0.15);
color: white;
}
}
}
&.role-assistant {
.message-body {
background: #f8fafc;
border-radius: 20px 20px 20px 4px;
.dark & {
background: #2d2d3d;
}
}
}
&.compact {
padding: 12px 20px;
.avatar {
width: 32px;
height: 32px;
svg {
width: 16px;
height: 16px;
}
}
}
// &.is-streaming {
// .message-body {
// position: relative;
// &::after {
// content: "";
// position: absolute;
// bottom: 12px;
// right: 12px;
// width: 8px;
// height: 8px;
// background: #3b82f6;
// border-radius: 50%;
// animation: pulse 1.5s infinite;
// }
// }
// }
&.is-end {
.message-body {
position: relative;
&::after {
content: "";
position: absolute;
bottom: 12px;
right: 12px;
width: 8px;
height: 8px;
background: #3b82f6;
border-radius: 50%;
animation: pulse 1.5s infinite;
}
}
}
}
.avatar {
flex-shrink: 0;
}
.avatar-inner {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
&.assistant {
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
color: white;
}
&.user {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
}
}
.message-content-wrapper {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
max-width: 75%;
min-width: 0;
}
.message-header {
display: flex;
align-items: center;
gap: 10px;
}
.role-name {
font-size: 14px;
font-weight: 600;
color: #1f2937;
.dark & {
color: #f3f4f6;
}
}
.timestamp {
font-size: 12px;
color: #9ca3af;
}
.message-body {
padding: 16px 20px;
color: #1f2937;
line-height: 1.7;
.dark & {
color: #e5e7eb;
}
}
// markstream-vue
.text-content {
:deep(p) {
margin: 0 0 12px;
&:last-child {
margin-bottom: 0;
}
}
:deep(ul),
:deep(ol) {
margin: 12px 0;
padding-left: 24px;
}
:deep(code:not(pre code)) {
padding: 2px 6px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.06);
font-family: "JetBrains Mono", monospace;
font-size: 0.9em;
.dark & {
background: rgba(255, 255, 255, 0.1);
}
}
:deep(pre) {
margin: 16px 0;
border-radius: 12px;
overflow: hidden;
}
:deep(a) {
color: #3b82f6;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
:deep(table) {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
th,
td {
border: 1px solid #e2e8f0;
padding: 8px 12px;
text-align: left;
}
th {
background: #f8fafc;
font-weight: 600;
}
.dark & {
th,
td {
border-color: #374151;
}
th {
background: #1e1e2e;
}
}
}
:deep(blockquote) {
margin: 16px 0;
padding: 12px 20px;
border-left: 4px solid #3b82f6;
background: rgba(59, 130, 246, 0.05);
border-radius: 0 8px 8px 0;
p {
margin: 0;
}
}
:deep(h1),
:deep(h2),
:deep(h3),
:deep(h4) {
margin: 20px 0 12px;
font-weight: 600;
line-height: 1.4;
&:first-child {
margin-top: 0;
}
}
:deep(h1) {
font-size: 1.5em;
}
:deep(h2) {
font-size: 1.3em;
}
:deep(h3) {
font-size: 1.15em;
}
:deep(h4) {
font-size: 1em;
}
}
.error-content {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: rgba(239, 68, 68, 0.1);
border-radius: 10px;
color: #ef4444;
font-size: 14px;
.retry-btn {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
padding: 6px 12px;
border: none;
border-radius: 6px;
background: #ef4444;
color: white;
font-size: 13px;
cursor: pointer;
transition: background 0.2s ease;
&:hover {
background: #dc2626;
}
}
}
.suggestions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 16px;
}
.suggestion-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border: 1px solid #e2e8f0;
border-radius: 20px;
background: white;
color: #374151;
font-size: 13px;
cursor: pointer;
transition: all 0.2s ease;
.dark & {
background: #1e1e2e;
border-color: #374151;
color: #e5e7eb;
}
&:hover {
border-color: #3b82f6;
color: #3b82f6;
background: rgba(59, 130, 246, 0.05);
}
svg {
color: #f59e0b;
}
}
.images-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 8px;
margin-top: 12px;
}
.image-item {
position: relative;
aspect-ratio: 1;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.image-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.4);
color: white;
opacity: 0;
transition: opacity 0.2s ease;
}
&:hover {
img {
transform: scale(1.05);
}
.image-overlay {
opacity: 1;
}
}
}
.single-video {
margin-top: 12px;
video {
width: 100%;
max-width: 480px;
border-radius: 12px;
}
}
.videos-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
margin-top: 12px;
}
.video-item {
position: relative;
aspect-ratio: 16/9;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.3);
color: white;
transition: background 0.2s ease;
}
.video-duration {
position: absolute;
bottom: 8px;
right: 8px;
padding: 2px 8px;
background: rgba(0, 0, 0, 0.7);
border-radius: 4px;
color: white;
font-size: 12px;
}
&:hover .video-overlay {
background: rgba(0, 0, 0, 0.5);
}
}
.files-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
}
.file-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: rgba(0, 0, 0, 0.03);
border-radius: 10px;
.dark & {
background: rgba(255, 255, 255, 0.05);
}
}
.file-icon {
font-size: 24px;
}
.file-info {
flex: 1;
min-width: 0;
}
.file-name {
display: block;
font-size: 14px;
font-weight: 500;
color: #1f2937;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.dark & {
color: #f3f4f6;
}
}
.file-size {
font-size: 12px;
color: #9ca3af;
}
.download-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
border-radius: 8px;
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #3b82f6;
color: white;
}
}
.loading-dots {
display: flex;
gap: 6px;
padding: 8px 0;
span {
width: 8px;
height: 8px;
background: #9ca3af;
border-radius: 50%;
animation: pulseDot 1.4s infinite ease-in-out both;
&:nth-child(1) {
animation-delay: -0.32s;
}
&:nth-child(2) {
animation-delay: -0.16s;
}
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(0.8);
}
}
@keyframes pulseDot {
0%,
80%,
100% {
transform: scale(0.6);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
</style>

View File

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

View File

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

View File

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

View File

@ -0,0 +1,749 @@
<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="visible" class="modal-overlay" @click.self="close">
<div class="conversation-settings-modal">
<!-- 头部 -->
<div class="modal-header">
<div class="header-title">
<MessageSquare :size="22" />
<h3>对话设置</h3>
</div>
<button class="close-btn" @click="close">
<X :size="20" />
</button>
</div>
<!-- 内容 -->
<div class="modal-content">
<!-- 对话信息 -->
<div class="section">
<div class="section-title">对话信息</div>
<!-- 标题 -->
<div class="setting-item vertical">
<label class="setting-label">对话标题</label>
<input
v-model="localSettings.title"
type="text"
class="text-input"
placeholder="输入对话标题"
/>
</div>
<!-- 创建时间 -->
<div class="setting-item">
<span class="setting-label">创建时间</span>
<span class="setting-value">{{
formatDate(conversation?.createdAt)
}}</span>
</div>
<!-- 消息数量 -->
<div class="setting-item">
<span class="setting-label">消息数量</span>
<span class="setting-value"
>{{ conversation?.messages.length || 0 }} </span
>
</div>
</div>
<!-- AI 设置 -->
<div class="section">
<div class="section-title">AI 设置</div>
<!-- 模型选择 -->
<div class="setting-item vertical">
<label class="setting-label">AI 模型</label>
<FormSelect
v-model="modelSelect"
:options="modelOptions"
valueProp="label"
@update:model-value="updateSelect"
/>
</div>
<!-- 温度 -->
<div class="setting-item vertical">
<div class="setting-header">
<label class="setting-label">温度</label>
<span class="setting-value">{{
localSettings.temperature.toFixed(1)
}}</span>
</div>
<FormSlider
v-model="localSettings.temperature"
:min="0"
:max="1"
:step="0.1"
/>
<p class="setting-hint">
较低的温度使回复更加集中和确定较高的温度使回复更加多样化
</p>
</div>
<!-- 最大 Token -->
<div class="setting-item vertical">
<div class="setting-header">
<label class="setting-label">最大回复长度</label>
<span class="setting-value">{{
localSettings.maxTokens
}}</span>
</div>
<FormSlider
v-model="localSettings.maxTokens"
:min="256"
:max="8192"
:step="256"
/>
</div>
</div>
<!-- 系统提示词 -->
<div class="section">
<div class="section-title">系统提示词</div>
<div class="setting-item vertical">
<textarea
v-model="localSettings.systemPrompt"
class="prompt-textarea"
rows="5"
placeholder="输入系统提示词,定义 AI 的角色和行为..."
/>
<p class="setting-hint">
系统提示词会影响 AI 在整个对话中的行为方式
</p>
</div>
<!-- 预设提示词 -->
<div class="preset-prompts">
<span class="preset-label">快速选择</span>
<button
v-for="preset in presetPrompts"
:key="preset.name"
class="preset-btn"
@click="localSettings.systemPrompt = preset.prompt"
>
{{ preset.name }}
</button>
</div>
</div>
<!-- 记忆设置 -->
<div class="section">
<div class="section-title">记忆设置</div>
<div class="setting-item">
<div class="setting-info">
<span class="setting-label">启用对话记忆</span>
<span class="setting-desc">AI 会记住之前的对话内容</span>
</div>
<FormSwitch v-model="localSettings.enableMemory" />
</div>
<div
v-if="localSettings.enableMemory"
class="setting-item vertical"
>
<div class="setting-header">
<label class="setting-label">记忆长度</label>
<span class="setting-value"
>{{ localSettings.memoryLength }} 条消息</span
>
</div>
<FormSlider
v-model="localSettings.memoryLength"
:min="2"
:max="50"
:step="2"
/>
</div>
</div>
<!-- 危险操作 -->
<div class="section danger-section">
<div class="section-title">危险操作</div>
<div class="danger-actions">
<button class="danger-btn" @click="handleClearMessages">
<Trash2 :size="18" />
<span>清空消息</span>
</button>
<button class="danger-btn" @click="handleDeleteConversation">
<Trash2 :size="18" />
<span>删除对话</span>
</button>
</div>
</div>
</div>
<!-- 底部 -->
<div class="modal-footer">
<button class="cancel-btn" @click="close">取消</button>
<button class="save-btn" @click="handleSave">
<Check :size="18" />
<span>保存设置</span>
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from "vue";
import { storeToRefs } from "pinia";
import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings";
import FormSwitch from "@/components/ui/FormSwitch.vue";
import FormSlider from "@/components/ui/FormSlider.vue";
import FormSelect from "@/components/ui/FormSelect.vue";
import { MessageSquare, X, Check, Trash2 } from "@/components/icons";
import { chatApi } from "@/services/api.ts";
import type { ConversationSettings } from "@/types/chat";
const chatStore = useChatStore();
const settingsStore = useSettingsStore();
// storeToRefs store
const { currentConversation: conversation } = storeToRefs(chatStore);
const { showConversationSettingsModal: visible, settings } =
storeToRefs(settingsStore);
const availableModels: any = ref([]);
const modelSelect = ref(localStorage.getItem("modelSelect") || "");
onMounted(() => {
chatApi.getModels().then((res: any) => {
availableModels.value = res;
if (!localStorage.getItem("modelSelect")) {
modelSelect.value = availableModels.value[0]["name"] || "";
localStorage.setItem("modelSelect", modelSelect.value);
}
});
});
//
const localSettings = ref({
title: "",
model: "gpt-4",
temperature: 0.7,
maxTokens: 4096,
systemPrompt: "",
enableMemory: true,
memoryLength: 20,
});
// -
const modelOptions = computed(() => {
if (!availableModels.value || !Array.isArray(availableModels.value)) {
return [{ value: "gpt-4", label: "GPT-4", description: "默认模型" }];
}
return availableModels.value?.map((model: any) => ({
value: model.name,
label: model.name,
description: model.description,
}));
});
//
const presetPrompts = [
{
name: "默认助手",
prompt: "你是一个有帮助的 AI 助手,能够回答各种问题并提供帮助。",
},
{
name: "代码专家",
prompt:
"你是一个专业的编程助手,擅长代码编写、调试和解释。请提供简洁、高质量的代码示例。",
},
{
name: "写作助手",
prompt:
"你是一个专业的写作助手,擅长文章撰写、润色和编辑。请使用优美的语言和清晰的结构。",
},
{
name: "翻译专家",
prompt: "你是一个专业的翻译助手,能够在多种语言之间进行准确、自然的翻译。",
},
{
name: "学习导师",
prompt:
"你是一个耐心的学习导师,擅长用简单易懂的方式解释复杂的概念,并提供练习建议。",
},
];
function updateSelect(data: any) {
localStorage.setItem("modelSelect", data);
}
//
function formatDate(timestamp?: number): string {
if (!timestamp) return "-";
const date = new Date(timestamp);
return date.toLocaleString("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
//
watch(
[visible, conversation],
([isVisible, conv]) => {
if (isVisible && conv) {
localSettings.value = {
title: conv.title || "",
model: conv.settings?.model || settings.value.defaultModel || "gpt-4",
temperature:
conv.settings?.temperature ??
settings.value.defaultTemperature ??
0.7,
maxTokens:
conv.settings?.maxTokens || settings.value.defaultMaxTokens || 4096,
systemPrompt:
conv.settings?.systemPrompt ||
settings.value.defaultSystemPrompt ||
"",
enableMemory: conv.settings?.enableMemory ?? true,
memoryLength: conv.settings?.memoryLength || 20,
};
}
},
{ immediate: true },
);
function close() {
settingsStore.closeConversationSettingsModal();
}
function handleSave() {
if (!conversation.value) return;
//
if (localSettings.value.title !== conversation.value.title) {
chatStore.renameConversation(
conversation.value.id,
localSettings.value.title,
);
}
// -
const convSettings: ConversationSettings = {
model: localSettings.value.model,
temperature: localSettings.value.temperature,
maxTokens: localSettings.value.maxTokens,
systemPrompt: localSettings.value.systemPrompt,
enableMemory: localSettings.value.enableMemory,
memoryLength: localSettings.value.memoryLength,
};
//
chatStore.updateConversationSettings(conversation.value.id, convSettings);
close();
//
if (window.$toast) {
window.$toast("设置已保存", "success");
}
}
function handleClearMessages() {
if (!conversation.value) return;
if (confirm("确定要清空所有消息吗?此操作不可恢复。")) {
chatStore.clearConversation(conversation.value.id);
close();
}
}
function handleDeleteConversation() {
if (!conversation.value) return;
if (confirm("确定要删除这个对话吗?此操作不可恢复。")) {
chatStore.deleteConversation(conversation.value.id);
close();
}
}
</script>
<style lang="scss" scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.conversation-settings-modal {
width: 520px;
max-width: 90vw;
max-height: 85vh;
background: white;
border-radius: 20px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
display: flex;
flex-direction: column;
overflow: hidden;
.dark & {
background: #1e1e2e;
}
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid #e2e8f0;
.dark & {
border-bottom-color: #2d2d3d;
}
}
.header-title {
display: flex;
align-items: center;
gap: 12px;
svg {
color: #3b82f6;
}
h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #1f2937;
.dark & {
color: #f3f4f6;
}
}
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
border-radius: 10px;
background: transparent;
color: #6b7280;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #f3f4f6;
color: #1f2937;
.dark & {
background: #374151;
color: #f3f4f6;
}
}
}
.modal-content {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
}
.section {
margin-bottom: 28px;
&:last-child {
margin-bottom: 0;
}
}
.section-title {
font-size: 12px;
font-weight: 600;
color: #9ca3af;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 16px;
}
.setting-item {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
&.vertical {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
}
.setting-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.setting-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.setting-label {
font-size: 14px;
font-weight: 500;
color: #1f2937;
.dark & {
color: #f3f4f6;
}
}
.setting-desc {
font-size: 12px;
color: #9ca3af;
}
.setting-value {
font-size: 14px;
color: #6b7280;
.setting-header & {
font-weight: 500;
color: #3b82f6;
}
}
.setting-hint {
margin: 0;
font-size: 12px;
color: #9ca3af;
line-height: 1.5;
}
.text-input {
width: 100%;
padding: 10px 14px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: white;
font-size: 14px;
color: #1f2937;
.dark & {
background: #2d2d3d;
border-color: #374151;
color: #f3f4f6;
}
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
&::placeholder {
color: #9ca3af;
}
}
.prompt-textarea {
width: 100%;
padding: 12px 14px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: white;
font-family: inherit;
font-size: 14px;
color: #1f2937;
resize: vertical;
min-height: 120px;
.dark & {
background: #2d2d3d;
border-color: #374151;
color: #f3f4f6;
}
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
&::placeholder {
color: #9ca3af;
}
}
.preset-prompts {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 12px;
}
.preset-label {
font-size: 12px;
color: #9ca3af;
}
.preset-btn {
padding: 6px 12px;
border: 1px solid #e2e8f0;
border-radius: 16px;
background: white;
color: #6b7280;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
.dark & {
background: #2d2d3d;
border-color: #374151;
color: #9ca3af;
}
&:hover {
border-color: #3b82f6;
color: #3b82f6;
}
}
.danger-section {
padding: 16px;
background: rgba(239, 68, 68, 0.05);
border-radius: 12px;
border: 1px solid rgba(239, 68, 68, 0.1);
}
.danger-actions {
display: flex;
gap: 12px;
}
.danger-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border: 1px solid #fecaca;
border-radius: 10px;
background: white;
color: #ef4444;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
.dark & {
background: transparent;
border-color: rgba(239, 68, 68, 0.3);
}
&:hover {
background: #ef4444;
border-color: #ef4444;
color: white;
}
}
.modal-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid #e2e8f0;
.dark & {
border-top-color: #2d2d3d;
}
}
.cancel-btn {
padding: 10px 20px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: transparent;
color: #6b7280;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
.dark & {
border-color: #374151;
color: #9ca3af;
}
&:hover {
background: #f3f4f6;
color: #374151;
.dark & {
background: #2d2d3d;
color: #e5e7eb;
}
}
}
.save-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border: none;
border-radius: 10px;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
}
//
.modal-enter-active,
.modal-leave-active {
transition: all 0.25s ease;
.conversation-settings-modal {
transition: all 0.25s ease;
}
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
.conversation-settings-modal {
transform: scale(0.9);
opacity: 0;
}
}
</style>

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

26
src/main.ts Normal file
View File

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

209
src/services/api.ts Normal file
View File

@ -0,0 +1,209 @@
/**
* Chat UI API
*
*/
// API 端点定义(固定)
const API_ENDPOINTS = {
// 发送消息(流式)
CHAT_STREAM: "/api/chat-ui/chat",
// 发送消息(非流式)
CHAT: "/api/chat-ui/chat",
// 获取对话历史
CONVERSATIONS: "/api/chat-ui/conversations",
// 获取单个对话
CONVERSATION: "/api/chat-ui/conversations/:id",
// 删除对话
DELETE_CONVERSATION: "/api/chat-ui/conversations/:id",
// 上传文件
UPLOAD: "/api/chat-ui/upload",
// 获取模型列表
MODELS: "/api/chat-ui/models",
// 停止生成
STOP: "/api/chat-ui/stop",
};
// 请求类型定义
export interface ChatMessage {
role: "user" | "assistant" | "system";
content: string;
images?: string[];
files?: string[];
}
export interface ChatRequest {
conversationId?: string;
message: string;
images?: string[];
files?: string[];
model?: string;
temperature?: number;
maxTokens?: number;
systemPrompt?: string;
stream?: boolean;
// 扩展选项
deepSearch?: boolean;
webSearch?: boolean;
deepThinking?: boolean;
}
export interface ChatResponse {
id: string;
conversationId: string;
content: string;
model: string;
createdAt: number;
usage?: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
};
}
export interface ModelInfo {
id: string;
name: string;
description: string;
maxTokens: number;
provider: string;
}
export interface UploadResult {
url: string;
name: string;
size?: number;
mimeType?: string;
}
// API 调用类
class ChatApi {
private baseUrl: string;
constructor(baseUrl = "") {
this.baseUrl = baseUrl;
}
/**
*
*/
async *streamChat(
request: ChatRequest,
signal?: AbortSignal,
): AsyncGenerator<string> {
const response = await fetch(
`${this.baseUrl}${API_ENDPOINTS.CHAT_STREAM}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify(request),
signal,
},
);
if (!response.ok) {
const error = await response.text();
throw new Error(error || `HTTP ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error("Response body is not readable");
}
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
const match = text.match(/data:\s*(\{.*\})/);
if (match) {
yield JSON.parse(match[1])["message"];
}
}
}
/**
*
*/
async chat(request: ChatRequest): Promise<ChatResponse> {
const response = await fetch(`${this.baseUrl}${API_ENDPOINTS.CHAT}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(request),
});
if (!response.ok) {
const error = await response.text();
throw new Error(error || `HTTP ${response.status}`);
}
return response.json();
}
/**
*
*/
async stopChat(messageId?: string) {
await fetch(`${this.baseUrl}${API_ENDPOINTS.STOP}/${messageId}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
}
/**
*
*/
async getModels(): Promise<ModelInfo[]> {
return [
{
id: "gpt-4",
name: "GPT-4",
description: "最强大的模型",
maxTokens: 8192,
provider: "OpenAI",
},
{
id: "gpt-3.5-turbo",
name: "GPT-3.5 Turbo",
description: "快速高效",
maxTokens: 16384,
provider: "OpenAI",
},
];
}
/**
*
*/
async uploadFile(file: File): Promise<UploadResult> {
const formData = new FormData();
formData.append("file", file);
const response = await fetch(`${this.baseUrl}${API_ENDPOINTS.UPLOAD}`, {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error(`上传失败: HTTP ${response.status}`);
}
return response.json();
}
}
// 导出单例
export const chatApi = new ChatApi();
// 导出类用于自定义配置
export { ChatApi, API_ENDPOINTS };
// 导出端点常量(供调试使用)
// export {API_ENDPOINTS}

270
src/services/mockAI.ts Normal file
View File

@ -0,0 +1,270 @@
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,
}))
}

285
src/stores/chat.ts Normal file
View File

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

291
src/stores/settings.ts Normal file
View File

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

79
src/style.css Normal file
View File

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

78
src/styles/main.scss Normal file
View File

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

146
src/types/chat.ts Normal file
View File

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

148
src/utils/helpers.ts Normal file
View File

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

52
tailwind.config.js Normal file
View File

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

32
tsconfig.app.json Normal file
View File

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

31
tsconfig.json Normal file
View File

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

12
tsconfig.node.json Normal file
View File

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

1
tsconfig.tsbuildinfo Normal file
View File

@ -0,0 +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"}

28
uno.config.ts Normal file
View File

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

54
vite.config.ts Normal file
View File

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