perf(code): 格式化代码以提升可读性与一致性,统一代码风格 [优化:使用 Prettier 进行格式化]

This commit is contained in:
SuperManTouX 2026-03-06 09:23:39 +08:00
parent 972d92ba1a
commit 6984a09737
34 changed files with 10043 additions and 9964 deletions

17
package-lock.json generated
View File

@ -32,6 +32,7 @@
"@vue/tsconfig": "^0.8.1", "@vue/tsconfig": "^0.8.1",
"autoprefixer": "^10.4.24", "autoprefixer": "^10.4.24",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "^3.8.1",
"sass": "^1.97.3", "sass": "^1.97.3",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"typescript": "~5.9.3", "typescript": "~5.9.3",
@ -4363,6 +4364,22 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmmirror.com/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/property-information": { "node_modules/property-information": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmmirror.com/property-information/-/property-information-7.1.0.tgz", "resolved": "https://registry.npmmirror.com/property-information/-/property-information-7.1.0.tgz",

View File

@ -33,6 +33,7 @@
"@vue/tsconfig": "^0.8.1", "@vue/tsconfig": "^0.8.1",
"autoprefixer": "^10.4.24", "autoprefixer": "^10.4.24",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "^3.8.1",
"sass": "^1.97.3", "sass": "^1.97.3",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"typescript": "~5.9.3", "typescript": "~5.9.3",

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,418 +1,418 @@
<template> <template>
<main class="chat-main" :class="{ 'wide-mode': isWideMode }"> <main class="chat-main" :class="{ 'wide-mode': isWideMode }">
<!-- 头部 --> <!-- 头部 -->
<ChatHeader <ChatHeader
:title="currentConversation?.title || '新对话'" :title="currentConversation?.title || '新对话'"
:message-count="messages.length" :message-count="messages.length"
:show-sidebar-toggle="sidebarCollapsed" :show-sidebar-toggle="sidebarCollapsed"
:is-wide-mode="isWideMode" :is-wide-mode="isWideMode"
:is-pinned="currentConversation?.pinned" :is-pinned="currentConversation?.pinned"
@toggle-sidebar="$emit('toggle-sidebar')" @toggle-sidebar="$emit('toggle-sidebar')"
@toggle-wide-mode="toggleWideMode" @toggle-wide-mode="toggleWideMode"
@clear="handleClear" @clear="handleClear"
@export="handleExport" @export="handleExport"
@pin="handlePin" @pin="handlePin"
/> />
<!-- 消息列表 --> <!-- 消息列表 -->
<MessageList <MessageList
ref="messageListRef" ref="messageListRef"
:messages="messages" :messages="messages"
:show-timestamp="settings.showTimestamp" :show-timestamp="settings.showTimestamp"
:compact="settings.compactMode" :compact="settings.compactMode"
:is-typing="isTyping" :is-typing="isTyping"
@retry="handleRetry" @retry="handleRetry"
@regenerate="handleRegenerate" @regenerate="handleRegenerate"
@select-suggestion="handleSuggestion" @select-suggestion="handleSuggestion"
/> />
<!-- 输入区域 --> <!-- 输入区域 -->
<div class="input-wrapper"> <div class="input-wrapper">
<div class="input-container" :class="{ wide: isWideMode }"> <div class="input-container" :class="{ wide: isWideMode }">
<ChatInput <ChatInput
ref="chatInputRef" ref="chatInputRef"
:placeholder="inputPlaceholder" :placeholder="inputPlaceholder"
:is-streaming="isStreaming" :is-streaming="isStreaming"
:send-on-enter="settings.sendOnEnter" :send-on-enter="settings.sendOnEnter"
:disabled="false" :disabled="false"
@send="handleSend" @send="handleSend"
@stop="handleStop" @stop="handleStop"
/> />
</div> </div>
</div> </div>
</main> </main>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, nextTick } from "vue"; import { ref, computed, watch, nextTick } from "vue";
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
import { useChatStore } from "@/stores/chat"; import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings"; import { useSettingsStore } from "@/stores/settings";
import ChatHeader from "./ChatHeader.vue"; import ChatHeader from "./ChatHeader.vue";
import MessageList from "./MessageList.vue"; import MessageList from "./MessageList.vue";
import ChatInput from "@/components/input/ChatInput.vue"; import ChatInput from "@/components/input/ChatInput.vue";
import { MessageType, MessageRole } from "@/types/chat"; import { MessageType, MessageRole } from "@/types/chat";
import type { Attachment } from "@/types/chat"; import type { Attachment } from "@/types/chat";
import { chatApi } from "@/services/api"; import { chatApi } from "@/services/api";
defineEmits<{ defineEmits<{
"toggle-sidebar": []; "toggle-sidebar": [];
}>(); }>();
const chatStore = useChatStore(); const chatStore = useChatStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const { currentConversation, isStreaming } = storeToRefs(chatStore); const { currentConversation, isStreaming } = storeToRefs(chatStore);
const { settings, sidebarCollapsed } = storeToRefs(settingsStore); const { settings, sidebarCollapsed } = storeToRefs(settingsStore);
const messageListRef = ref<InstanceType<typeof MessageList> | null>(null); const messageListRef = ref<InstanceType<typeof MessageList> | null>(null);
const chatInputRef = ref<InstanceType<typeof ChatInput> | null>(null); const chatInputRef = ref<InstanceType<typeof ChatInput> | null>(null);
const isWideMode = ref(true); const isWideMode = ref(true);
const isTyping = ref(false); const isTyping = ref(false);
const currentStreamingMessageId = ref<string | null>(null); const currentStreamingMessageId = ref<string | null>(null);
const abortController: any = ref<AbortController | null>(null); const abortController: any = ref<AbortController | null>(null);
const messages: any = computed(() => currentConversation.value?.messages || []); const messages: any = computed(() => currentConversation.value?.messages || []);
const inputPlaceholder = computed(() => { const inputPlaceholder = computed(() => {
if (isStreaming.value) return "正在生成回复..."; if (isStreaming.value) return "正在生成回复...";
return "输入你的问题,按 Ctrl+Enter 发送"; return "输入你的问题,按 Ctrl+Enter 发送";
}); });
function toggleWideMode() { function toggleWideMode() {
isWideMode.value = !isWideMode.value; isWideMode.value = !isWideMode.value;
} }
function handleClear() { function handleClear() {
if (currentConversation.value) { if (currentConversation.value) {
chatStore.clearConversation(currentConversation.value.id); chatStore.clearConversation(currentConversation.value.id);
} }
} }
function handleExport() { function handleExport() {
if (!currentConversation.value) return; if (!currentConversation.value) return;
const data = { const data = {
title: currentConversation.value.title, title: currentConversation.value.title,
messages: currentConversation.value.messages, messages: currentConversation.value.messages,
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
}; };
const blob = new Blob([JSON.stringify(data, null, 2)], { const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json", type: "application/json",
}); });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = `${currentConversation.value.title}.json`; a.download = `${currentConversation.value.title}.json`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
function handlePin() { function handlePin() {
if (currentConversation.value) { if (currentConversation.value) {
chatStore.togglePinConversation(currentConversation.value.id); chatStore.togglePinConversation(currentConversation.value.id);
} }
} }
// - 使 API // - 使 API
async function handleSend( async function handleSend(
text: string, text: string,
attachments: Attachment[], attachments: Attachment[],
options?: { options?: {
deepSearch?: boolean; deepSearch?: boolean;
webSearch?: boolean; webSearch?: boolean;
deepThinking?: boolean; deepThinking?: boolean;
}, },
) { ) {
console.log("handleSend", text, attachments, options); console.log("handleSend", text, attachments, options);
// //
const uploadingAttachments = attachments.filter((a) => a.uploading); const uploadingAttachments = attachments.filter((a) => a.uploading);
if (uploadingAttachments.length > 0) { if (uploadingAttachments.length > 0) {
// //
const uploads = uploadingAttachments.map(async (attachment) => { const uploads = uploadingAttachments.map(async (attachment) => {
// //
// //
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
const checkUpload = () => { const checkUpload = () => {
const stillUploading = attachments.some( const stillUploading = attachments.some(
(a) => a.id === attachment.id && a.uploading, (a) => a.id === attachment.id && a.uploading,
); );
if (!stillUploading) { if (!stillUploading) {
resolve(); resolve();
} else { } else {
setTimeout(checkUpload, 100); // 100ms setTimeout(checkUpload, 100); // 100ms
} }
}; };
checkUpload(); checkUpload();
}); });
}); });
try { try {
await Promise.all(uploads); await Promise.all(uploads);
} catch (error) { } catch (error) {
console.error("等待上传完成时发生错误:", error); console.error("等待上传完成时发生错误:", error);
} }
} }
// //
if (!currentConversation.value) { if (!currentConversation.value) {
chatStore.createConversation(); chatStore.createConversation();
} }
// //
const existingMessages = currentConversation.value?.messages || []; const existingMessages = currentConversation.value?.messages || [];
const MAX_HISTORY_ROUNDS = 20; // 20 40 const MAX_HISTORY_ROUNDS = 20; // 20 40
const historyMessages = existingMessages const historyMessages = existingMessages
.filter( .filter(
(m: any) => (m: any) =>
m.role === MessageRole.USER || m.role === MessageRole.ASSISTANT, m.role === MessageRole.USER || m.role === MessageRole.ASSISTANT,
) )
.filter((m: any) => m.content?.text) // .filter((m: any) => m.content?.text) //
.slice(-(MAX_HISTORY_ROUNDS * 2)) .slice(-(MAX_HISTORY_ROUNDS * 2))
.map((m: any) => ({ role: m.role, content: m.content.text })); .map((m: any) => ({ role: m.role, content: m.content.text }));
// //
chatStore.addMessage(MessageRole.USER, { chatStore.addMessage(MessageRole.USER, {
type: MessageType.TEXT, type: MessageType.TEXT,
text, text,
images: attachments.filter((a) => a.type === "image"), images: attachments.filter((a) => a.type === "image"),
files: attachments.filter((a) => a.type === "file"), files: attachments.filter((a) => a.type === "file"),
}); });
// AI // AI
const aiMessage = chatStore.addMessage(MessageRole.ASSISTANT, { const aiMessage = chatStore.addMessage(MessageRole.ASSISTANT, {
type: MessageType.TEXT, type: MessageType.TEXT,
text: "", text: "",
}); });
currentStreamingMessageId.value = aiMessage.id; currentStreamingMessageId.value = aiMessage.id;
chatStore.updateMessage(aiMessage.id, { isStreaming: true }); chatStore.updateMessage(aiMessage.id, { isStreaming: true });
chatStore.startStreaming(); chatStore.startStreaming();
isTyping.value = true; isTyping.value = true;
// AbortController // AbortController
abortController.value = new AbortController(); abortController.value = new AbortController();
try { try {
// URLAPI // URLAPI
const imageUrls = attachments const imageUrls = attachments
.filter((a) => a.type === "image") .filter((a) => a.type === "image")
.map((a) => a.url); .map((a) => a.url);
// URLtxt, pdf, docx // URLtxt, pdf, docx
const fileUrls = attachments const fileUrls = attachments
.filter((a) => a.type === "file") .filter((a) => a.type === "file")
.map((a) => a.url); .map((a) => a.url);
const stream = chatApi.streamChat( const stream = chatApi.streamChat(
{ {
message: text, message: text,
conversationId: currentConversation.value?.id || "", conversationId: currentConversation.value?.id || "",
images: imageUrls, images: imageUrls,
files: fileUrls, files: fileUrls,
model: settings.value.defaultModel, model: settings.value.defaultModel,
stream: true, stream: true,
history: historyMessages, history: historyMessages,
deepSearch: options?.deepSearch, deepSearch: options?.deepSearch,
webSearch: options?.webSearch, webSearch: options?.webSearch,
deepThinking: options?.deepThinking, deepThinking: options?.deepThinking,
}, },
abortController.value.signal, abortController.value.signal,
); );
let fullText = ""; let fullText = "";
isTyping.value = false; isTyping.value = false;
for await (const chunk of stream) { for await (const chunk of stream) {
if (abortController.value?.signal.aborted) break; if (abortController.value?.signal.aborted) break;
fullText += chunk; fullText += chunk;
chatStore.updateMessageContent(aiMessage.id, fullText); chatStore.updateMessageContent(aiMessage.id, fullText);
} }
if (!abortController.value?.signal.aborted) { if (!abortController.value?.signal.aborted) {
chatStore.updateMessage(aiMessage.id, { chatStore.updateMessage(aiMessage.id, {
isStreaming: false, isStreaming: false,
content: { content: {
type: MessageType.TEXT, type: MessageType.TEXT,
text: fullText, text: fullText,
}, },
}); });
} }
} catch (error: any) { } catch (error: any) {
if (error.name !== "AbortError") { if (error.name !== "AbortError") {
chatStore.updateMessage(aiMessage.id, { chatStore.updateMessage(aiMessage.id, {
isStreaming: false, isStreaming: false,
isError: true, isError: true,
errorMessage: error.message || "请求失败", errorMessage: error.message || "请求失败",
}); });
} }
} finally { } finally {
chatStore.stopStreaming(); chatStore.stopStreaming();
currentStreamingMessageId.value = null; currentStreamingMessageId.value = null;
} }
} }
// //
function handleStop() { function handleStop() {
if (abortController.value) { if (abortController.value) {
abortController.value.abort(); abortController.value.abort();
abortController.value = null; abortController.value = null;
} }
chatStore.stopStreaming(); chatStore.stopStreaming();
chatApi.stopChat(messages.value.at(-1)["messageId"]); chatApi.stopChat(messages.value.at(-1)["messageId"]);
if (currentStreamingMessageId.value) { if (currentStreamingMessageId.value) {
chatStore.updateMessage(currentStreamingMessageId.value, { chatStore.updateMessage(currentStreamingMessageId.value, {
isStreaming: false, isStreaming: false,
}); });
currentStreamingMessageId.value = null; currentStreamingMessageId.value = null;
} }
} }
// //
async function handleRetry(messageId: string) { async function handleRetry(messageId: string) {
const message = messages.value.find((m: any) => m.id === messageId); const message = messages.value.find((m: any) => m.id === messageId);
if (!message || message.role !== MessageRole.ASSISTANT) return; if (!message || message.role !== MessageRole.ASSISTANT) return;
const messageIndex = messages.value.findIndex((m: any) => m.id === messageId); const messageIndex = messages.value.findIndex((m: any) => m.id === messageId);
if (messageIndex <= 0) return; if (messageIndex <= 0) return;
const userMessage = messages.value[messageIndex - 1]; const userMessage = messages.value[messageIndex - 1];
if (userMessage.role !== MessageRole.USER) return; if (userMessage.role !== MessageRole.USER) return;
// //
const MAX_HISTORY_ROUNDS = 20; const MAX_HISTORY_ROUNDS = 20;
const priorMessages = messages.value const priorMessages = messages.value
.slice(0, messageIndex - 1) // user assistant .slice(0, messageIndex - 1) // user assistant
.filter( .filter(
(m: any) => (m: any) =>
m.role === MessageRole.USER || m.role === MessageRole.ASSISTANT, m.role === MessageRole.USER || m.role === MessageRole.ASSISTANT,
) )
.filter((m: any) => m.content?.text) .filter((m: any) => m.content?.text)
.slice(-(MAX_HISTORY_ROUNDS * 2)) .slice(-(MAX_HISTORY_ROUNDS * 2))
.map((m: any) => ({ role: m.role, content: m.content.text })); .map((m: any) => ({ role: m.role, content: m.content.text }));
// //
chatStore.updateMessage(messageId, { chatStore.updateMessage(messageId, {
isError: false, isError: false,
errorMessage: undefined, errorMessage: undefined,
isStreaming: true, isStreaming: true,
isEnd: true, isEnd: true,
content: { type: MessageType.TEXT, text: "" }, content: { type: MessageType.TEXT, text: "" },
}); });
currentStreamingMessageId.value = messageId; currentStreamingMessageId.value = messageId;
chatStore.startStreaming(); chatStore.startStreaming();
abortController.value = new AbortController(); abortController.value = new AbortController();
try { try {
const stream = chatApi.streamChat( const stream = chatApi.streamChat(
{ {
message: userMessage.content.text || "", message: userMessage.content.text || "",
conversationId: currentConversation.value?.id, conversationId: currentConversation.value?.id,
model: settings.value.defaultModel, model: settings.value.defaultModel,
stream: true, stream: true,
history: priorMessages, history: priorMessages,
}, },
abortController.value.signal, abortController.value.signal,
); );
let fullText = ""; let fullText = "";
for await (const chunk of stream) { for await (const chunk of stream) {
if (abortController.value?.signal.aborted) break; if (abortController.value?.signal.aborted) break;
fullText += chunk; fullText += chunk;
chatStore.updateMessageContent(messageId, fullText); chatStore.updateMessageContent(messageId, fullText);
} }
if (!abortController.value?.signal.aborted) { if (!abortController.value?.signal.aborted) {
chatStore.updateMessage(messageId, { chatStore.updateMessage(messageId, {
isStreaming: false, isStreaming: false,
content: { content: {
type: MessageType.TEXT, type: MessageType.TEXT,
text: fullText, text: fullText,
}, },
}); });
} }
} catch (error: any) { } catch (error: any) {
if (error.name !== "AbortError") { if (error.name !== "AbortError") {
chatStore.updateMessage(messageId, { chatStore.updateMessage(messageId, {
isStreaming: false, isStreaming: false,
isError: true, isError: true,
errorMessage: error.message || "请求失败", errorMessage: error.message || "请求失败",
}); });
} }
} finally { } finally {
chatStore.stopStreaming(); chatStore.stopStreaming();
currentStreamingMessageId.value = null; currentStreamingMessageId.value = null;
} }
} }
function handleRegenerate(messageId: string) { function handleRegenerate(messageId: string) {
handleRetry(messageId); handleRetry(messageId);
} }
function handleSuggestion(text: string) { function handleSuggestion(text: string) {
handleSend(text, []); handleSend(text, []);
} }
function focusInput() { function focusInput() {
chatInputRef.value?.focus(); chatInputRef.value?.focus();
} }
defineExpose({ defineExpose({
focusInput, focusInput,
messageListRef, messageListRef,
}); });
watch( watch(
() => currentConversation.value?.id, () => currentConversation.value?.id,
() => { () => {
nextTick(() => { nextTick(() => {
focusInput(); focusInput();
}); });
}, },
); );
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.chat-main { .chat-main {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1; flex: 1;
height: 100vh; height: 100vh;
background: #ffffff; background: #ffffff;
overflow: hidden; overflow: hidden;
border-radius: 15px; border-radius: 15px;
.dark & { .dark & {
background: #11111b; background: #11111b;
} }
&.wide-mode { &.wide-mode {
.input-container { .input-container {
max-width: 1000px; max-width: 1000px;
} }
} }
} }
.input-wrapper { .input-wrapper {
flex-shrink: 0; flex-shrink: 0;
padding: 16px 24px 24px; padding: 16px 24px 24px;
background: linear-gradient(to top, white 80%, transparent); background: linear-gradient(to top, white 80%, transparent);
.dark & { .dark & {
background: linear-gradient(to top, #11111b 80%, transparent); background: linear-gradient(to top, #11111b 80%, transparent);
} }
} }
.input-container { .input-container {
max-width: 800px; max-width: 800px;
margin: 0 auto; margin: 0 auto;
transition: max-width 0.3s ease; transition: max-width 0.3s ease;
&.wide { &.wide {
max-width: 1000px; max-width: 1000px;
} }
} }
</style> </style>

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -1,211 +1,220 @@
<script setup lang="ts"> <script setup lang="ts">
import { MarkdownRender } from "markstream-vue"; import { MarkdownRender } from "markstream-vue";
import { useClipboard } from "@vueuse/core"; import { useClipboard } from "@vueuse/core";
import { ref, watch } from "vue"; import { ref, watch } from "vue";
const props = defineProps<{ const props = defineProps<{
node: { node: {
type: "think"; type: "think";
content: string; content: string;
children: any[]; children: any[];
loading?: boolean; loading?: boolean;
}; };
}>(); }>();
const { copy } = useClipboard({ legacy: true }); const { copy } = useClipboard({ legacy: true });
const collapsed = ref(false); const collapsed = ref(false);
// loading true false // loading true false
watch( watch(
() => props.node.loading, () => props.node.loading,
(newVal, oldVal) => { (newVal, oldVal) => {
if (oldVal === true && newVal === false) { if (oldVal === true && newVal === false) {
collapsed.value = true; collapsed.value = true;
} }
}, },
); );
function toggleCollapse() { function toggleCollapse() {
collapsed.value = !collapsed.value; collapsed.value = !collapsed.value;
} }
async function textCopy(data: any) { async function textCopy(data: any) {
if (typeof data === "string") { if (typeof data === "string") {
copy(data); copy(data);
} }
} }
</script> </script>
<template> <template>
<div <div
class="thinking-node p-4 my-4 bg-blue-50 dark:bg-blue-900/40 rounded-md border-l-4 border-blue-400" class="thinking-node p-4 my-4 bg-blue-50 dark:bg-blue-900/40 rounded-md border-l-4 border-blue-400"
> >
<!-- 可点击的标题栏 --> <!-- 可点击的标题栏 -->
<div class="thinking-header" @click="toggleCollapse"> <div class="thinking-header" @click="toggleCollapse">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<!-- 思考图标 --> <!-- 思考图标 -->
<div <div
class="w-8 h-8 rounded-full bg-blue-200 dark:bg-blue-700 flex items-center justify-center text-blue-700 dark:text-blue-100" class="w-8 h-8 rounded-full bg-blue-200 dark:bg-blue-700 flex items-center justify-center text-blue-700 dark:text-blue-100"
> >
<svg <svg
class="w-4 h-4" class="w-4 h-4"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
aria-hidden="true" aria-hidden="true"
> >
<path <path
d="M12 3C7.03 3 3 6.58 3 11c0 1.86.66 3.57 1.77 4.98L4 21l5.2-1.9C10.06 19.35 11 19.5 12 19.5c4.97 0 9-3.58 9-8.5S16.97 3 12 3z" d="M12 3C7.03 3 3 6.58 3 11c0 1.86.66 3.57 1.77 4.98L4 21l5.2-1.9C10.06 19.35 11 19.5 12 19.5c4.97 0 9-3.58 9-8.5S16.97 3 12 3z"
stroke="currentColor" stroke="currentColor"
stroke-width="0.8" stroke-width="0.8"
fill="currentColor" fill="currentColor"
opacity="0.9" opacity="0.9"
/> />
</svg> </svg>
</div> </div>
</div> </div>
<div class="thinking-title"> <div class="thinking-title">
<strong class="text-sm">💭 深度思考</strong> <strong class="text-sm">💭 深度思考</strong>
<!-- 加载动画 --> <!-- 加载动画 -->
<span v-if="node.loading" class="thinking-dots visible" aria-hidden="true"> <span
<span class="dot dot-1" /> v-if="node.loading"
<span class="dot dot-2" /> class="thinking-dots visible"
<span class="dot dot-3" /> aria-hidden="true"
</span> >
<span v-else class="thinking-status text-xs text-slate-500 dark:text-slate-300"> <span class="dot dot-1" />
已完成 <span class="dot dot-2" />
</span> <span class="dot dot-3" />
</div> </span>
<!-- 折叠/展开箭头 --> <span
<div class="collapse-arrow" :class="{ collapsed }"> v-else
<svg class="thinking-status text-xs text-slate-500 dark:text-slate-300"
width="16" >
height="16" 已完成
viewBox="0 0 24 24" </span>
fill="none" </div>
stroke="currentColor" <!-- 折叠/展开箭头 -->
stroke-width="2" <div class="collapse-arrow" :class="{ collapsed }">
stroke-linecap="round" <svg
stroke-linejoin="round" width="16"
> height="16"
<polyline points="6 9 12 15 18 9" /> viewBox="0 0 24 24"
</svg> fill="none"
</div> stroke="currentColor"
</div> stroke-width="2"
stroke-linecap="round"
<!-- 可折叠的内容区域 --> stroke-linejoin="round"
<div class="thinking-content" :class="{ collapsed }"> >
<div <polyline points="6 9 12 15 18 9" />
class="mt-3 text-sm leading-relaxed text-slate-800 dark:text-slate-100" </svg>
> </div>
<MarkdownRender :content="node.content" @copy="textCopy" /> </div>
</div>
</div> <!-- 可折叠的内容区域 -->
</div> <div class="thinking-content" :class="{ collapsed }">
</template> <div
class="mt-3 text-sm leading-relaxed text-slate-800 dark:text-slate-100"
<style scoped> >
.thinking-node { <MarkdownRender :content="node.content" @copy="textCopy" />
color: #0f172a; </div>
} </div>
.dark .thinking-node { </div>
color: #e6f0ff; </template>
}
<style scoped>
/* 标题栏 */ .thinking-node {
.thinking-header { color: #0f172a;
display: flex; }
align-items: center; .dark .thinking-node {
gap: 12px; color: #e6f0ff;
cursor: pointer; }
user-select: none;
} /* 标题栏 */
.thinking-header {
.thinking-title { display: flex;
flex: 1; align-items: center;
display: flex; gap: 12px;
align-items: center; cursor: pointer;
gap: 8px; user-select: none;
} }
.thinking-status { .thinking-title {
font-style: italic; flex: 1;
} display: flex;
align-items: center;
/* 折叠箭头 */ gap: 8px;
.collapse-arrow { }
display: flex;
align-items: center; .thinking-status {
justify-content: center; font-style: italic;
width: 24px; }
height: 24px;
color: #64748b; /* 折叠箭头 */
transition: transform 0.25s ease; .collapse-arrow {
border-radius: 4px; display: flex;
} align-items: center;
.collapse-arrow:hover { justify-content: center;
background: rgba(0, 0, 0, 0.06); width: 24px;
} height: 24px;
.dark .collapse-arrow:hover { color: #64748b;
background: rgba(255, 255, 255, 0.08); transition: transform 0.25s ease;
} border-radius: 4px;
.collapse-arrow.collapsed { }
transform: rotate(-90deg); .collapse-arrow:hover {
} background: rgba(0, 0, 0, 0.06);
}
/* 可折叠内容 */ .dark .collapse-arrow:hover {
.thinking-content { background: rgba(255, 255, 255, 0.08);
max-height: 2000px; }
overflow: hidden; .collapse-arrow.collapsed {
transition: max-height 0.35s ease, opacity 0.25s ease; transform: rotate(-90deg);
opacity: 1; }
}
.thinking-content.collapsed { /* 可折叠内容 */
max-height: 0; .thinking-content {
opacity: 0; max-height: 2000px;
} overflow: hidden;
transition:
/* 加载动画 */ max-height 0.35s ease,
.thinking-dots { opacity 0.25s ease;
display: inline-flex; opacity: 1;
align-items: center; }
gap: 6px; .thinking-content.collapsed {
height: 12px; max-height: 0;
} opacity: 0;
.thinking-dots .dot { }
width: 6px;
height: 6px; /* 加载动画 */
border-radius: 9999px; .thinking-dots {
background: #1e3a8a; display: inline-flex;
opacity: 0.25; align-items: center;
} gap: 6px;
.thinking-dots.visible .dot-1 { height: 12px;
animation: think-bounce 1s infinite ease-in-out; }
animation-delay: 0s; .thinking-dots .dot {
} width: 6px;
.thinking-dots.visible .dot-2 { height: 6px;
animation: think-bounce 1s infinite ease-in-out; border-radius: 9999px;
animation-delay: 0.12s; background: #1e3a8a;
} opacity: 0.25;
.thinking-dots.visible .dot-3 { }
animation: think-bounce 1s infinite ease-in-out; .thinking-dots.visible .dot-1 {
animation-delay: 0.24s; animation: think-bounce 1s infinite ease-in-out;
} animation-delay: 0s;
.dark .thinking-dots .dot { }
background: #bfdbfe; .thinking-dots.visible .dot-2 {
opacity: 0.28; animation: think-bounce 1s infinite ease-in-out;
} animation-delay: 0.12s;
}
@keyframes think-bounce { .thinking-dots.visible .dot-3 {
0%, animation: think-bounce 1s infinite ease-in-out;
80%, animation-delay: 0.24s;
100% { }
transform: translateY(0); .dark .thinking-dots .dot {
opacity: 0.25; background: #bfdbfe;
} opacity: 0.28;
40% { }
transform: translateY(-6px);
opacity: 1; @keyframes think-bounce {
} 0%,
} 80%,
</style> 100% {
transform: translateY(0);
opacity: 0.25;
}
40% {
transform: translateY(-6px);
opacity: 1;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,301 +1,304 @@
/** /**
* Chat UI API * Chat UI API
* *
*/ */
// API 端点定义(固定) // API 端点定义(固定)
const API_ENDPOINTS = { const API_ENDPOINTS = {
// 发送消息(流式) // 发送消息(流式)
CHAT_STREAM: "/api/chat-ui/chat", CHAT_STREAM: "/api/chat-ui/chat",
// 发送消息(非流式) // 发送消息(非流式)
CHAT: "/api/chat-ui/chat", CHAT: "/api/chat-ui/chat",
// 获取对话历史 // 获取对话历史
CONVERSATIONS: "/api/chat-ui/conversations", CONVERSATIONS: "/api/chat-ui/conversations",
// 获取单个对话 // 获取单个对话
CONVERSATION: "/api/chat-ui/conversations/:id", CONVERSATION: "/api/chat-ui/conversations/:id",
// 删除对话 // 删除对话
DELETE_CONVERSATION: "/api/chat-ui/conversations/:id", DELETE_CONVERSATION: "/api/chat-ui/conversations/:id",
// 上传文件 // 上传文件
UPLOAD: "/api/chat-ui/upload", UPLOAD: "/api/chat-ui/upload",
// 获取模型列表 // 获取模型列表
MODELS: "/api/chat-ui/models", MODELS: "/api/chat-ui/models",
// 停止生成 // 停止生成
STOP: "/api/chat-ui/stop", STOP: "/api/chat-ui/stop",
}; };
// 请求类型定义 // 请求类型定义
export interface ChatMessage { export interface ChatMessage {
role: "user" | "assistant" | "system"; role: "user" | "assistant" | "system";
content: string; content: string;
images?: string[]; images?: string[];
files?: string[]; files?: string[];
} }
export interface ChatRequest { export interface ChatRequest {
conversationId?: string; conversationId?: string;
message: string; message: string;
images?: string[]; images?: string[];
files?: string[]; // 非图片附件 URL 列表 files?: string[]; // 非图片附件 URL 列表
model?: string; model?: string;
temperature?: number; temperature?: number;
maxTokens?: number; maxTokens?: number;
systemPrompt?: string; systemPrompt?: string;
stream?: boolean; stream?: boolean;
// 历史对话消息(用于上下文记忆) // 历史对话消息(用于上下文记忆)
history?: { role: string; content: string }[]; history?: { role: string; content: string }[];
// 扩展选项 // 扩展选项
deepSearch?: boolean; deepSearch?: boolean;
webSearch?: boolean; webSearch?: boolean;
deepThinking?: boolean; deepThinking?: boolean;
} }
export interface ChatResponse { export interface ChatResponse {
id: string; id: string;
conversationId: string; conversationId: string;
content: string; content: string;
model: string; model: string;
createdAt: number; createdAt: number;
usage?: { usage?: {
promptTokens: number; promptTokens: number;
completionTokens: number; completionTokens: number;
totalTokens: number; totalTokens: number;
}; };
} }
export interface ModelInfo { export interface ModelInfo {
id: string; id: string;
name: string; name: string;
description: string; description: string;
maxTokens: number; maxTokens: number;
provider: string; provider: string;
} }
export interface UploadResult { export interface UploadResult {
url: string; url: string;
name: string; name: string;
size?: number; size?: number;
mimeType?: string; mimeType?: string;
} }
// API 调用类 // API 调用类
class ChatApi { class ChatApi {
private baseUrl: string; private baseUrl: string;
constructor(baseUrl = "") { constructor(baseUrl = "") {
this.baseUrl = baseUrl; this.baseUrl = baseUrl;
} }
/** /**
* *
*/ */
async *streamChat( async *streamChat(
request: ChatRequest, request: ChatRequest,
signal?: AbortSignal, signal?: AbortSignal,
): AsyncGenerator<string> { ): AsyncGenerator<string> {
// 构建消息数组,考虑是否包含图片 // 构建消息数组,考虑是否包含图片
let userContent; let userContent;
if (request.images && request.images.length > 0) { if (request.images && request.images.length > 0) {
// 如果有图片则构建内容数组针对阿里云DashScope API的格式 // 如果有图片则构建内容数组针对阿里云DashScope API的格式
userContent = [{ type: "text", text: request.message }]; userContent = [{ type: "text", text: request.message }];
// 添加图片URL到内容中阿里云格式 // 添加图片URL到内容中阿里云格式
request.images.forEach((imageUrl) => { request.images.forEach((imageUrl) => {
userContent.push({ userContent.push({
type: "image_url", type: "image_url",
image_url: imageUrl, // 注意:阿里云格式不需要嵌套对象 image_url: imageUrl, // 注意:阿里云格式不需要嵌套对象
}); });
}); });
} else { } else {
// 没有图片时,使用简单的文本 // 没有图片时,使用简单的文本
userContent = request.message; userContent = request.message;
} }
// 将前端简化的请求翻译为 OpenAI 兼容的规范请求体 // 将前端简化的请求翻译为 OpenAI 兼容的规范请求体
// 构建 messages 数组system + 历史消息 + 当前用户消息 // 构建 messages 数组system + 历史消息 + 当前用户消息
const systemMessage = { const systemMessage = {
role: "system", role: "system",
content: request.systemPrompt || "你是一个智能助手,可以分析用户发送的文字,文件或图片内容,并进行回答。", content:
}; request.systemPrompt ||
const currentUserMessage = { "你是一个智能助手,可以分析用户发送的文字,文件或图片内容,并进行回答。",
role: "user", };
content: userContent, const currentUserMessage = {
}; role: "user",
const allMessages = request.history && request.history.length > 0 content: userContent,
? [systemMessage, ...request.history, currentUserMessage] };
: [systemMessage, currentUserMessage]; const allMessages =
request.history && request.history.length > 0
const openAiRequest = { ? [systemMessage, ...request.history, currentUserMessage]
model: request.model || "glm-4-flash", : [systemMessage, currentUserMessage];
messages: allMessages,
stream: true, const openAiRequest = {
temperature: request.temperature, model: request.model || "glm-4-flash",
max_tokens: request.maxTokens, messages: allMessages,
files: request.files || [], stream: true,
// 扩展参数传递给我们的 Python 后端进行特殊处理 temperature: request.temperature,
deepSearch: request.deepSearch, max_tokens: request.maxTokens,
webSearch: request.webSearch, files: request.files || [],
deepThinking: request.deepThinking, // 扩展参数传递给我们的 Python 后端进行特殊处理
}; deepSearch: request.deepSearch,
webSearch: request.webSearch,
const response = await fetch( deepThinking: request.deepThinking,
`${this.baseUrl}${API_ENDPOINTS.CHAT_STREAM}`, };
{
method: "POST", const response = await fetch(
headers: { `${this.baseUrl}${API_ENDPOINTS.CHAT_STREAM}`,
"Content-Type": "application/json", {
Accept: "text/event-stream", method: "POST",
}, headers: {
body: JSON.stringify(openAiRequest), "Content-Type": "application/json",
signal, Accept: "text/event-stream",
}, },
); body: JSON.stringify(openAiRequest),
signal,
if (!response.ok) { },
const error = await response.text(); );
throw new Error(error || `HTTP ${response.status}`);
} if (!response.ok) {
const error = await response.text();
const reader = response.body?.getReader(); throw new Error(error || `HTTP ${response.status}`);
if (!reader) { }
throw new Error("Response body is not readable");
} const reader = response.body?.getReader();
if (!reader) {
const decoder = new TextDecoder("utf-8"); throw new Error("Response body is not readable");
let buffer = ""; }
while (true) { const decoder = new TextDecoder("utf-8");
const { done, value } = await reader.read(); let buffer = "";
if (done) break;
while (true) {
buffer += decoder.decode(value, { stream: true }); const { done, value } = await reader.read();
const lines = buffer.split("\n"); if (done) break;
// 保留最后一行未完整的 JSON
buffer = lines.pop() || ""; buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
for (const line of lines) { // 保留最后一行未完整的 JSON
if (line.trim() === "" || line.includes("[DONE]")) continue; buffer = lines.pop() || "";
const match = line.match(/^data:\s*(.+)$/);
if (match) { for (const line of lines) {
try { if (line.trim() === "" || line.includes("[DONE]")) continue;
const data = JSON.parse(match[1]); const match = line.match(/^data:\s*(.+)$/);
// 检查是否有完成原因,如果是完成则跳出 if (match) {
const finishReason = data.choices?.[0]?.finish_reason; try {
if (finishReason && finishReason !== "null") { const data = JSON.parse(match[1]);
break; // 检查是否有完成原因,如果是完成则跳出
} const finishReason = data.choices?.[0]?.finish_reason;
if (finishReason && finishReason !== "null") {
const content = data.choices?.[0]?.delta?.content; break;
if (content) { }
yield content;
} const content = data.choices?.[0]?.delta?.content;
} catch (e) { if (content) {
console.warn("JSON解析错误", e, line); yield content;
} }
} } catch (e) {
} console.warn("JSON解析错误", e, line);
} }
} }
}
/** }
* }
*/
async chat(request: ChatRequest): Promise<ChatResponse> { /**
// 构建消息数组,考虑是否包含图片 *
let userContent; */
if (request.images && request.images.length > 0) { async chat(request: ChatRequest): Promise<ChatResponse> {
// 如果有图片,则构建内容数组 // 构建消息数组,考虑是否包含图片
userContent = [{ type: "text", text: request.message }]; let userContent;
// 添加图片URL到内容中 if (request.images && request.images.length > 0) {
request.images.forEach((imageUrl) => { // 如果有图片,则构建内容数组
userContent.push({ userContent = [{ type: "text", text: request.message }];
type: "image_url", // 添加图片URL到内容中
image_url: { url: imageUrl }, request.images.forEach((imageUrl) => {
}); userContent.push({
}); type: "image_url",
} else { image_url: { url: imageUrl },
// 没有图片时,使用简单的文本 });
userContent = request.message; });
} } else {
// 没有图片时,使用简单的文本
const requestBody = { userContent = request.message;
...request, }
message: userContent,
}; const requestBody = {
...request,
const response = await fetch(`${this.baseUrl}${API_ENDPOINTS.CHAT}`, { message: userContent,
method: "POST", };
headers: {
"Content-Type": "application/json", const response = await fetch(`${this.baseUrl}${API_ENDPOINTS.CHAT}`, {
}, method: "POST",
body: JSON.stringify(requestBody), headers: {
}); "Content-Type": "application/json",
},
if (!response.ok) { body: JSON.stringify(requestBody),
const error = await response.text(); });
throw new Error(error || `HTTP ${response.status}`);
} if (!response.ok) {
const error = await response.text();
return response.json(); 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: { async stopChat(messageId?: string) {
"Content-Type": "application/json", await fetch(`${this.baseUrl}${API_ENDPOINTS.STOP}/${messageId}`, {
}, method: "POST",
}); headers: {
} "Content-Type": "application/json",
},
/** });
* }
*/
async getModels(): Promise<ModelInfo[]> { /**
return [ *
{ */
id: "glm-4.6", async getModels(): Promise<ModelInfo[]> {
name: "智普 GLM-4.6", return [
description: "最强大的模型", {
maxTokens: 8192, id: "glm-4.6",
provider: "Zhipu", name: "智普 GLM-4.6",
}, description: "最强大的模型",
// GLM-4.5,联网搜索功能有问题 maxTokens: 8192,
// { provider: "Zhipu",
// id: "glm-4.5", },
// name: "智普 GLM-4.5", // GLM-4.5,联网搜索功能有问题
// description: "能力均衡", // {
// maxTokens: 8192, // id: "glm-4.5",
// provider: "Zhipu", // name: "智普 GLM-4.5",
// }, // description: "能力均衡",
]; // maxTokens: 8192,
} // provider: "Zhipu",
// },
/** ];
* }
*/
async uploadFile(file: File): Promise<UploadResult> { /**
const formData = new FormData(); *
formData.append("file", file); */
async uploadFile(file: File): Promise<UploadResult> {
const response = await fetch(`${this.baseUrl}${API_ENDPOINTS.UPLOAD}`, { const formData = new FormData();
method: "POST", formData.append("file", file);
body: formData,
}); const response = await fetch(`${this.baseUrl}${API_ENDPOINTS.UPLOAD}`, {
method: "POST",
if (!response.ok) { body: formData,
throw new Error(`上传失败: HTTP ${response.status}`); });
}
if (!response.ok) {
return response.json(); throw new Error(`上传失败: HTTP ${response.status}`);
} }
}
return response.json();
// 导出单例 }
export const chatApi = new ChatApi(); }
// 导出类用于自定义配置 // 导出单例
export { ChatApi, API_ENDPOINTS }; export const chatApi = new ChatApi();
// 导出端点常量(供调试使用) // 导出类用于自定义配置
// export {API_ENDPOINTS} export { ChatApi, API_ENDPOINTS };
// 导出端点常量(供调试使用)
// export {API_ENDPOINTS}

View File

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

View File

@ -1,304 +1,310 @@
import { defineStore } from 'pinia' import { defineStore } from "pinia";
import { ref } from 'vue' import { ref } from "vue";
import type { AppSettings, AIModel } from '@/types/chat' import type { AppSettings, AIModel } from "@/types/chat";
export const useSettingsStore = defineStore('settings', () => { export const useSettingsStore = defineStore("settings", () => {
// 默认设置 // 默认设置
const defaultSettings: AppSettings = { const defaultSettings: AppSettings = {
// 外观设置 // 外观设置
theme: 'system', theme: "system",
language: 'zh-CN', language: "zh-CN",
fontSize: 'medium', fontSize: "medium",
// 对话设置 // 对话设置
sendOnEnter: false, sendOnEnter: false,
showTimestamp: true, showTimestamp: true,
compactMode: false, compactMode: false,
// AI 默认设置 // AI 默认设置
defaultModel: 'glm-4.6', defaultModel: "glm-4.6",
defaultTemperature: 0.7, defaultTemperature: 0.7,
defaultMaxTokens: 4096, defaultMaxTokens: 4096,
defaultSystemPrompt: '你是一个有帮助的 AI 助手。', defaultSystemPrompt: "你是一个有帮助的 AI 助手。",
// 功能设置 // 功能设置
enableSound: true, enableSound: true,
enableNotification: true, enableNotification: true,
autoSaveInterval: 30, autoSaveInterval: 30,
// 隐私设置 // 隐私设置
saveHistory: true, saveHistory: true,
shareAnalytics: false, shareAnalytics: false,
} };
// 可用的 AI 模型 // 可用的 AI 模型
const availableModels: AIModel[] = [ const availableModels: AIModel[] = [
{ {
id: "glm-4.6", id: "glm-4.6",
name: "智普 GLM-4.6", name: "智普 GLM-4.6",
description: "最强大的模型", description: "最强大的模型",
maxTokens: 8192, maxTokens: 8192,
provider: "Zhipu", provider: "Zhipu",
}, },
{ {
id: "glm-4.5", id: "glm-4.5",
name: "智普 GLM-4.5", name: "智普 GLM-4.5",
description: "能力均衡", description: "能力均衡",
maxTokens: 8192, maxTokens: 8192,
provider: "Zhipu", provider: "Zhipu",
}, },
{ {
id: 'glm-4-flash', id: "glm-4-flash",
name: '智普 GLM-4-Flash', name: "智普 GLM-4-Flash",
description: '快速高效,适合日常对话', description: "快速高效,适合日常对话",
maxTokens: 8192, maxTokens: 8192,
provider: 'Zhipu', provider: "Zhipu",
}, },
{ {
id: 'glm-4v-plus', id: "glm-4v-plus",
name: '智普 GLM-4V-Plus', name: "智普 GLM-4V-Plus",
description: '强大的视觉理解模型', description: "强大的视觉理解模型",
maxTokens: 8192, maxTokens: 8192,
provider: 'Zhipu', provider: "Zhipu",
}, },
] ];
// 状态 // 状态
const settings = ref<AppSettings>({ ...defaultSettings }) const settings = ref<AppSettings>({ ...defaultSettings });
const sidebarCollapsed = ref(false) const sidebarCollapsed = ref(false);
const sidebarWidth = ref(280) const sidebarWidth = ref(280);
const showShortcutsModal = ref(false) const showShortcutsModal = ref(false);
const showSearchModal = ref(false) const showSearchModal = ref(false);
const showSettingsModal = ref(false) const showSettingsModal = ref(false);
const showConversationSettingsModal = ref(false) const showConversationSettingsModal = ref(false);
// 主题相关 // 主题相关
function applyTheme(theme: AppSettings['theme']) { function applyTheme(theme: AppSettings["theme"]) {
const root = document.documentElement const root = document.documentElement;
if (theme === 'system') { if (theme === "system") {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches const prefersDark = window.matchMedia(
root.classList.toggle('dark', prefersDark) "(prefers-color-scheme: dark)",
} else { ).matches;
root.classList.toggle('dark', theme === 'dark') 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) function toggleTheme() {
settings.value.theme = themes[(currentIndex + 1) % themes.length] const themes: AppSettings["theme"][] = ["light", "dark", "system"];
applyTheme(settings.value.theme) const currentIndex = themes.indexOf(settings.value.theme);
saveToStorage() settings.value.theme = themes[(currentIndex + 1) % themes.length];
} applyTheme(settings.value.theme);
saveToStorage();
function setTheme(theme: AppSettings['theme']) { }
settings.value.theme = theme
applyTheme(theme) function setTheme(theme: AppSettings["theme"]) {
saveToStorage() settings.value.theme = theme;
} applyTheme(theme);
saveToStorage();
// 字体大小 }
function applyFontSize(size: AppSettings['fontSize']) {
const root = document.documentElement // 字体大小
const sizeMap = { function applyFontSize(size: AppSettings["fontSize"]) {
small: '14px', const root = document.documentElement;
medium: '16px', const sizeMap = {
large: '18px', small: "14px",
} medium: "16px",
root.style.setProperty('--base-font-size', sizeMap[size]) large: "18px",
} };
root.style.setProperty("--base-font-size", sizeMap[size]);
function setFontSize(size: AppSettings['fontSize']) { }
settings.value.fontSize = size
applyFontSize(size) function setFontSize(size: AppSettings["fontSize"]) {
saveToStorage() settings.value.fontSize = size;
} applyFontSize(size);
saveToStorage();
// 侧边栏 }
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value // 侧边栏
saveToStorage() function toggleSidebar() {
} sidebarCollapsed.value = !sidebarCollapsed.value;
saveToStorage();
function setSidebarWidth(width: number) { }
sidebarWidth.value = Math.max(200, Math.min(400, width))
saveToStorage() function setSidebarWidth(width: number) {
} sidebarWidth.value = Math.max(200, Math.min(400, width));
saveToStorage();
// 模态框 }
function openShortcutsModal() {
showShortcutsModal.value = true // 模态框
} function openShortcutsModal() {
showShortcutsModal.value = true;
function closeShortcutsModal() { }
showShortcutsModal.value = false
} function closeShortcutsModal() {
showShortcutsModal.value = false;
function openSearchModal() { }
showSearchModal.value = true
} function openSearchModal() {
showSearchModal.value = true;
function closeSearchModal() { }
showSearchModal.value = false
} function closeSearchModal() {
showSearchModal.value = false;
function openSettingsModal() { }
showSettingsModal.value = true
} function openSettingsModal() {
showSettingsModal.value = true;
function closeSettingsModal() { }
showSettingsModal.value = false
} function closeSettingsModal() {
showSettingsModal.value = false;
function openConversationSettingsModal() { }
showConversationSettingsModal.value = true
} function openConversationSettingsModal() {
showConversationSettingsModal.value = true;
function closeConversationSettingsModal() { }
showConversationSettingsModal.value = false
} function closeConversationSettingsModal() {
showConversationSettingsModal.value = false;
// 更新设置 }
function updateSettings(updates: Partial<AppSettings>) {
Object.assign(settings.value, updates) // 更新设置
function updateSettings(updates: Partial<AppSettings>) {
if (updates.theme) { Object.assign(settings.value, updates);
applyTheme(updates.theme)
} if (updates.theme) {
applyTheme(updates.theme);
if (updates.fontSize) { }
applyFontSize(updates.fontSize)
} if (updates.fontSize) {
applyFontSize(updates.fontSize);
saveToStorage() }
}
saveToStorage();
// 重置设置 }
function resetSettings() {
settings.value = { ...defaultSettings } // 重置设置
applyTheme(settings.value.theme) function resetSettings() {
applyFontSize(settings.value.fontSize) settings.value = { ...defaultSettings };
saveToStorage() applyTheme(settings.value.theme);
} applyFontSize(settings.value.fontSize);
saveToStorage();
// 导出设置 }
function exportSettings(): string {
return JSON.stringify(settings.value, null, 2) // 导出设置
} function exportSettings(): string {
return JSON.stringify(settings.value, null, 2);
// 导入设置 }
function importSettings(json: string): boolean {
try { // 导入设置
const imported = JSON.parse(json) function importSettings(json: string): boolean {
settings.value = { ...defaultSettings, ...imported } try {
applyTheme(settings.value.theme) const imported = JSON.parse(json);
applyFontSize(settings.value.fontSize) settings.value = { ...defaultSettings, ...imported };
saveToStorage() applyTheme(settings.value.theme);
return true applyFontSize(settings.value.fontSize);
} catch { saveToStorage();
return false return true;
} } catch {
} return false;
}
// 存储 }
function saveToStorage() {
try { // 存储
localStorage.setItem('chat-settings', JSON.stringify(settings.value)) function saveToStorage() {
localStorage.setItem('chat-sidebar-collapsed', JSON.stringify(sidebarCollapsed.value)) try {
localStorage.setItem('chat-sidebar-width', JSON.stringify(sidebarWidth.value)) localStorage.setItem("chat-settings", JSON.stringify(settings.value));
} catch (e) { localStorage.setItem(
console.error('Failed to save settings:', e) "chat-sidebar-collapsed",
} JSON.stringify(sidebarCollapsed.value),
} );
localStorage.setItem(
"chat-sidebar-width",
// 存储选中模型 ID 的 localStorage key JSON.stringify(sidebarWidth.value),
const MODEL_ID_KEY = 'modelSelectId' );
} catch (e) {
// 获取当前选择的模型 ID console.error("Failed to save settings:", e);
function getSelectedModelId(): string { }
}
return defaultSettings.defaultModel
} // 存储选中模型 ID 的 localStorage key
const MODEL_ID_KEY = "modelSelectId";
// 设置当前选择的模型 ID
function setSelectedModelId(modelId: string) { // 获取当前选择的模型 ID
localStorage.setItem(MODEL_ID_KEY, modelId) function getSelectedModelId(): string {
// 同时更新 settings 中的 defaultModel return defaultSettings.defaultModel;
settings.value.defaultModel = modelId }
saveToStorage()
} // 设置当前选择的模型 ID
function setSelectedModelId(modelId: string) {
function loadFromStorage() { localStorage.setItem(MODEL_ID_KEY, modelId);
try { // 同时更新 settings 中的 defaultModel
const stored = localStorage.getItem('chat-settings') settings.value.defaultModel = modelId;
if (stored) { saveToStorage();
settings.value = { ...defaultSettings, ...JSON.parse(stored) } }
}
function loadFromStorage() {
const collapsedStored = localStorage.getItem('chat-sidebar-collapsed') try {
if (collapsedStored) { const stored = localStorage.getItem("chat-settings");
sidebarCollapsed.value = JSON.parse(collapsedStored) if (stored) {
} settings.value = { ...defaultSettings, ...JSON.parse(stored) };
}
const widthStored = localStorage.getItem('chat-sidebar-width')
if (widthStored) { const collapsedStored = localStorage.getItem("chat-sidebar-collapsed");
sidebarWidth.value = JSON.parse(widthStored) if (collapsedStored) {
} sidebarCollapsed.value = JSON.parse(collapsedStored);
}
// 应用主题和字体
applyTheme(settings.value.theme) const widthStored = localStorage.getItem("chat-sidebar-width");
applyFontSize(settings.value.fontSize) if (widthStored) {
} catch (e) { sidebarWidth.value = JSON.parse(widthStored);
console.error('Failed to load settings:', e) }
}
} // 应用主题和字体
applyTheme(settings.value.theme);
// 监听系统主题变化 applyFontSize(settings.value.fontSize);
if (typeof window !== 'undefined') { } catch (e) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') console.error("Failed to load settings:", e);
mediaQuery.addEventListener('change', () => { }
if (settings.value.theme === 'system') { }
applyTheme('system')
} // 监听系统主题变化
}) if (typeof window !== "undefined") {
} const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaQuery.addEventListener("change", () => {
// 初始化 if (settings.value.theme === "system") {
loadFromStorage() applyTheme("system");
}
return { });
// 状态 }
settings,
sidebarCollapsed, // 初始化
sidebarWidth, loadFromStorage();
showShortcutsModal,
showSearchModal, return {
showSettingsModal, // 状态
showConversationSettingsModal, settings,
availableModels, sidebarCollapsed,
sidebarWidth,
// 方法 showShortcutsModal,
toggleTheme, showSearchModal,
setTheme, showSettingsModal,
setFontSize, showConversationSettingsModal,
toggleSidebar, availableModels,
setSidebarWidth,
openShortcutsModal, // 方法
closeShortcutsModal, toggleTheme,
openSearchModal, setTheme,
closeSearchModal, setFontSize,
openSettingsModal, toggleSidebar,
closeSettingsModal, setSidebarWidth,
openConversationSettingsModal, openShortcutsModal,
closeConversationSettingsModal, closeShortcutsModal,
updateSettings, openSearchModal,
resetSettings, closeSearchModal,
exportSettings, openSettingsModal,
importSettings, closeSettingsModal,
loadFromStorage, openConversationSettingsModal,
getSelectedModelId, closeConversationSettingsModal,
setSelectedModelId, updateSettings,
} resetSettings,
}) exportSettings,
importSettings,
loadFromStorage,
getSelectedModelId,
setSelectedModelId,
};
});

View File

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

View File

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

View File

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

View File

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