| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221 |
- <script setup lang="ts">
- import { NInput, NButton, NEmpty, NScrollbar, NPopconfirm, NSpin } from 'naive-ui'
- import { ref, nextTick, watch, onMounted } from 'vue'
- import { useAiChat } from './useAiChat'
- import MessageItem from './MessageItem.vue'
- const {
- messages,
- isStreaming,
- welcomeMessage,
- sessionList,
- currentSessionId,
- isLoadingSession,
- sendMessage,
- cancelChat,
- clearMessages,
- resolvePermission,
- fetchSession,
- createNewSession,
- loadSession,
- deleteSession,
- } = useAiChat()
- const inputText = ref('')
- const scrollRef = ref<InstanceType<typeof NScrollbar>>()
- const showSidebar = ref(false)
- onMounted(() => {
- fetchSession()
- })
- // 自动滚动到底部
- watch(
- () => messages.value[messages.value.length - 1]?.content,
- () => {
- nextTick(() => {
- scrollRef.value?.scrollTo({ top: 99999, behavior: 'smooth' })
- })
- },
- )
- function handleSend() {
- if (!inputText.value.trim() || isStreaming.value) return
- const msg = inputText.value
- inputText.value = ''
- sendMessage(msg)
- }
- function handleKeydown(e: KeyboardEvent) {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault()
- handleSend()
- }
- }
- function handleNewSession() {
- createNewSession()
- showSidebar.value = false
- }
- function handleLoadSession(sessionId: string) {
- loadSession(sessionId)
- showSidebar.value = false
- }
- function formatTime(ts: number): string {
- if (!ts) return ''
- const d = new Date(ts)
- const now = new Date()
- const diff = now.getTime() - d.getTime()
- if (diff < 60000) return '刚刚'
- if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`
- if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`
- if (diff < 604800000) return `${Math.floor(diff / 86400000)} 天前`
- return d.toLocaleDateString()
- }
- </script>
- <template>
- <div class="relative flex h-full flex-col">
- <!-- 顶部操作栏 -->
- <div class="flex items-center justify-between border-b px-3 py-2 dark:border-neutral-700">
- <div class="flex items-center gap-2">
- <NButton text size="small" @click="showSidebar = !showSidebar">
- <span class="i-ph:list text-sm" />
- </NButton>
- <span class="text-xs text-neutral-400">AI 助手</span>
- </div>
- <div class="flex items-center gap-1">
- <NButton text size="small" title="新建对话" @click="handleNewSession">
- <span class="i-ph:plus text-sm" />
- </NButton>
- <NButton text size="small" title="清空当前消息" @click="clearMessages">
- <span class="i-ph:trash text-sm" />
- </NButton>
- </div>
- </div>
- <!-- 会话列表侧边栏 -->
- <Transition name="slide">
- <div
- v-if="showSidebar"
- class="absolute inset-y-0 left-0 z-10 w-56 border-r bg-white flex flex-col dark:border-neutral-700 dark:bg-neutral-800"
- >
- <div class="flex items-center justify-between border-b px-3 py-2 dark:border-neutral-700">
- <span class="text-xs font-medium text-neutral-500">对话历史</span>
- <NButton text size="tiny" @click="showSidebar = false">
- <span class="i-ph:x text-xs" />
- </NButton>
- </div>
- <NScrollbar class="flex-1">
- <div class="p-2 space-y-1">
- <div
- v-for="sess in sessionList"
- :key="sess.sessionId"
- class="group cursor-pointer rounded-md px-2 py-1.5 text-xs transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700"
- :class="{ 'bg-blue-50 dark:bg-blue-900/20': sess.isActive }"
- @click="handleLoadSession(sess.sessionId)"
- >
- <div class="flex items-center justify-between">
- <span class="flex-1 truncate text-neutral-700 dark:text-neutral-300">
- {{ sess.title || '新对话' }}
- </span>
- <NPopconfirm @positive-click.stop="deleteSession(sess.sessionId)">
- <template #trigger>
- <NButton text size="tiny" class="hidden! group-hover:block!" @click.stop>
- <span class="i-ph:trash text-xs text-neutral-400" />
- </NButton>
- </template>
- 删除此对话?
- </NPopconfirm>
- </div>
- <div class="mt-0.5 text-neutral-400 text-[10px]">
- {{ formatTime(sess.lastActive) }}
- </div>
- </div>
- <NEmpty v-if="sessionList.length === 0" description="暂无历史对话" size="small" />
- </div>
- </NScrollbar>
- </div>
- </Transition>
- <!-- 加载中遮罩 -->
- <div
- v-if="isLoadingSession"
- class="absolute inset-0 z-20 flex items-center justify-center bg-white/60 dark:bg-neutral-900/60"
- >
- <NSpin size="medium" />
- </div>
- <!-- 消息列表 -->
- <NScrollbar ref="scrollRef" class="flex-1">
- <div class="space-y-3 p-4">
- <NEmpty v-if="messages.length === 0 && !welcomeMessage" description="输入消息开始对话" />
- <div
- v-else-if="messages.length === 0 && welcomeMessage"
- class="text-sm text-neutral-500 whitespace-pre-wrap"
- >
- {{ welcomeMessage }}
- </div>
- <MessageItem
- v-for="msg in messages"
- :key="msg.id"
- :message="msg"
- @resolve-permission="resolvePermission"
- />
- <!-- 流式输出中的提示 -->
- <div
- v-if="isStreaming && messages[messages.length - 1]?.content === ''"
- class="flex justify-start"
- >
- <div
- class="rounded-lg bg-neutral-100 px-3 py-2 text-sm text-neutral-500 dark:bg-neutral-700"
- >
- <span class="animate-pulse">思考中...</span>
- </div>
- </div>
- </div>
- </NScrollbar>
- <!-- 输入区域 -->
- <div class="border-t p-3 dark:border-neutral-700">
- <div class="flex gap-2">
- <NInput
- v-model:value="inputText"
- type="textarea"
- :autosize="{ minRows: 1, maxRows: 4 }"
- placeholder="输入消息...(Shift+Enter 换行)"
- :disabled="isStreaming"
- @keydown="handleKeydown"
- />
- <div class="flex flex-col justify-end">
- <NButton
- v-if="!isStreaming"
- type="primary"
- size="small"
- :disabled="!inputText.trim()"
- @click="handleSend"
- >
- <span class="i-ph:paper-plane-tilt" />
- </NButton>
- <NButton v-else type="error" size="small" @click="cancelChat">
- <span class="i-ph:stop-circle" />
- </NButton>
- </div>
- </div>
- </div>
- </div>
- </template>
- <style scoped>
- .slide-enter-active,
- .slide-leave-active {
- transition: transform 0.2s ease;
- }
- .slide-enter-from,
- .slide-leave-to {
- transform: translateX(-100%);
- }
- </style>
|