ChatPanel.vue 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. <script setup lang="ts">
  2. import { NInput, NButton, NEmpty, NScrollbar, NPopconfirm, NSpin } from 'naive-ui'
  3. import { ref, nextTick, watch, onMounted } from 'vue'
  4. import { useAiChat } from './useAiChat'
  5. import MessageItem from './MessageItem.vue'
  6. const {
  7. messages,
  8. isStreaming,
  9. welcomeMessage,
  10. sessionList,
  11. currentSessionId,
  12. isLoadingSession,
  13. sendMessage,
  14. cancelChat,
  15. clearMessages,
  16. resolvePermission,
  17. fetchSession,
  18. createNewSession,
  19. loadSession,
  20. deleteSession,
  21. } = useAiChat()
  22. const inputText = ref('')
  23. const scrollRef = ref<InstanceType<typeof NScrollbar>>()
  24. const showSidebar = ref(false)
  25. onMounted(() => {
  26. fetchSession()
  27. })
  28. // 自动滚动到底部
  29. watch(
  30. () => messages.value[messages.value.length - 1]?.content,
  31. () => {
  32. nextTick(() => {
  33. scrollRef.value?.scrollTo({ top: 99999, behavior: 'smooth' })
  34. })
  35. },
  36. )
  37. function handleSend() {
  38. if (!inputText.value.trim() || isStreaming.value) return
  39. const msg = inputText.value
  40. inputText.value = ''
  41. sendMessage(msg)
  42. }
  43. function handleKeydown(e: KeyboardEvent) {
  44. if (e.key === 'Enter' && !e.shiftKey) {
  45. e.preventDefault()
  46. handleSend()
  47. }
  48. }
  49. function handleNewSession() {
  50. createNewSession()
  51. showSidebar.value = false
  52. }
  53. function handleLoadSession(sessionId: string) {
  54. loadSession(sessionId)
  55. showSidebar.value = false
  56. }
  57. function formatTime(ts: number): string {
  58. if (!ts) return ''
  59. const d = new Date(ts)
  60. const now = new Date()
  61. const diff = now.getTime() - d.getTime()
  62. if (diff < 60000) return '刚刚'
  63. if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`
  64. if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`
  65. if (diff < 604800000) return `${Math.floor(diff / 86400000)} 天前`
  66. return d.toLocaleDateString()
  67. }
  68. </script>
  69. <template>
  70. <div class="relative flex h-full flex-col">
  71. <!-- 顶部操作栏 -->
  72. <div class="flex items-center justify-between border-b px-3 py-2 dark:border-neutral-700">
  73. <div class="flex items-center gap-2">
  74. <NButton text size="small" @click="showSidebar = !showSidebar">
  75. <span class="i-ph:list text-sm" />
  76. </NButton>
  77. <span class="text-xs text-neutral-400">AI 助手</span>
  78. </div>
  79. <div class="flex items-center gap-1">
  80. <NButton text size="small" title="新建对话" @click="handleNewSession">
  81. <span class="i-ph:plus text-sm" />
  82. </NButton>
  83. <NButton text size="small" title="清空当前消息" @click="clearMessages">
  84. <span class="i-ph:trash text-sm" />
  85. </NButton>
  86. </div>
  87. </div>
  88. <!-- 会话列表侧边栏 -->
  89. <Transition name="slide">
  90. <div
  91. v-if="showSidebar"
  92. 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"
  93. >
  94. <div class="flex items-center justify-between border-b px-3 py-2 dark:border-neutral-700">
  95. <span class="text-xs font-medium text-neutral-500">对话历史</span>
  96. <NButton text size="tiny" @click="showSidebar = false">
  97. <span class="i-ph:x text-xs" />
  98. </NButton>
  99. </div>
  100. <NScrollbar class="flex-1">
  101. <div class="p-2 space-y-1">
  102. <div
  103. v-for="sess in sessionList"
  104. :key="sess.sessionId"
  105. class="group cursor-pointer rounded-md px-2 py-1.5 text-xs transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700"
  106. :class="{ 'bg-blue-50 dark:bg-blue-900/20': sess.isActive }"
  107. @click="handleLoadSession(sess.sessionId)"
  108. >
  109. <div class="flex items-center justify-between">
  110. <span class="flex-1 truncate text-neutral-700 dark:text-neutral-300">
  111. {{ sess.title || '新对话' }}
  112. </span>
  113. <NPopconfirm @positive-click.stop="deleteSession(sess.sessionId)">
  114. <template #trigger>
  115. <NButton text size="tiny" class="hidden! group-hover:block!" @click.stop>
  116. <span class="i-ph:trash text-xs text-neutral-400" />
  117. </NButton>
  118. </template>
  119. 删除此对话?
  120. </NPopconfirm>
  121. </div>
  122. <div class="mt-0.5 text-neutral-400 text-[10px]">
  123. {{ formatTime(sess.lastActive) }}
  124. </div>
  125. </div>
  126. <NEmpty v-if="sessionList.length === 0" description="暂无历史对话" size="small" />
  127. </div>
  128. </NScrollbar>
  129. </div>
  130. </Transition>
  131. <!-- 加载中遮罩 -->
  132. <div
  133. v-if="isLoadingSession"
  134. class="absolute inset-0 z-20 flex items-center justify-center bg-white/60 dark:bg-neutral-900/60"
  135. >
  136. <NSpin size="medium" />
  137. </div>
  138. <!-- 消息列表 -->
  139. <NScrollbar ref="scrollRef" class="flex-1">
  140. <div class="space-y-3 p-4">
  141. <NEmpty v-if="messages.length === 0 && !welcomeMessage" description="输入消息开始对话" />
  142. <div
  143. v-else-if="messages.length === 0 && welcomeMessage"
  144. class="text-sm text-neutral-500 whitespace-pre-wrap"
  145. >
  146. {{ welcomeMessage }}
  147. </div>
  148. <MessageItem
  149. v-for="msg in messages"
  150. :key="msg.id"
  151. :message="msg"
  152. @resolve-permission="resolvePermission"
  153. />
  154. <!-- 流式输出中的提示 -->
  155. <div
  156. v-if="isStreaming && messages[messages.length - 1]?.content === ''"
  157. class="flex justify-start"
  158. >
  159. <div
  160. class="rounded-lg bg-neutral-100 px-3 py-2 text-sm text-neutral-500 dark:bg-neutral-700"
  161. >
  162. <span class="animate-pulse">思考中...</span>
  163. </div>
  164. </div>
  165. </div>
  166. </NScrollbar>
  167. <!-- 输入区域 -->
  168. <div class="border-t p-3 dark:border-neutral-700">
  169. <div class="flex gap-2">
  170. <NInput
  171. v-model:value="inputText"
  172. type="textarea"
  173. :autosize="{ minRows: 1, maxRows: 4 }"
  174. placeholder="输入消息...(Shift+Enter 换行)"
  175. :disabled="isStreaming"
  176. @keydown="handleKeydown"
  177. />
  178. <div class="flex flex-col justify-end">
  179. <NButton
  180. v-if="!isStreaming"
  181. type="primary"
  182. size="small"
  183. :disabled="!inputText.trim()"
  184. @click="handleSend"
  185. >
  186. <span class="i-ph:paper-plane-tilt" />
  187. </NButton>
  188. <NButton v-else type="error" size="small" @click="cancelChat">
  189. <span class="i-ph:stop-circle" />
  190. </NButton>
  191. </div>
  192. </div>
  193. </div>
  194. </div>
  195. </template>
  196. <style scoped>
  197. .slide-enter-active,
  198. .slide-leave-active {
  199. transition: transform 0.2s ease;
  200. }
  201. .slide-enter-from,
  202. .slide-leave-to {
  203. transform: translateX(-100%);
  204. }
  205. </style>