index.vue 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965
  1. <template>
  2. <a-drawer
  3. :visible="visible"
  4. :width="drawerWidth"
  5. :placement="isMobile ? 'bottom' : 'right'"
  6. :height="isMobile ? '100vh' : undefined"
  7. :footer="false"
  8. class="ai-chat-drawer"
  9. unmount-on-close
  10. @cancel="close"
  11. >
  12. <template #title>
  13. <div class="ai-chat-title">
  14. <a-avatar :size="36" class="ai-chat-title__avatar">
  15. <img src="@/assets/img/ai.png" alt="小妍助理" />
  16. </a-avatar>
  17. <div class="ai-chat-title__copy">
  18. <div class="ai-chat-title__name">小妍助理</div>
  19. <div class="ai-chat-title__sub">
  20. <span class="status-dot"></span>
  21. 在线
  22. </div>
  23. </div>
  24. </div>
  25. </template>
  26. <div class="ai-chat-shell" @paste="handlePaste">
  27. <aside class="conversation-panel" :class="{ 'conversation-panel--open': showConversationPanel }">
  28. <div class="conversation-panel__head">
  29. <div>
  30. <div class="conversation-panel__label">会话</div>
  31. <div class="conversation-panel__count">{{ conversations.length }} 条</div>
  32. </div>
  33. <a-tooltip content="新建对话">
  34. <a-button type="primary" shape="circle" @click="createConversationAndOpen">
  35. <template #icon><icon-plus /></template>
  36. </a-button>
  37. </a-tooltip>
  38. </div>
  39. <div class="conversation-list">
  40. <button
  41. v-for="item in conversations"
  42. :key="item.id"
  43. class="conversation-item"
  44. :class="{ 'conversation-item--active': item.id === currentConversationId }"
  45. @click="openConversation(item.id)"
  46. >
  47. <span class="conversation-item__top">
  48. <span class="conversation-item__title">{{ item.title || '小妍助理对话' }}</span>
  49. <span v-if="unreadMap[item.id]" class="conversation-item__dot"></span>
  50. </span>
  51. <span class="conversation-item__preview">{{ item.last_message_preview || '尚未开始' }}</span>
  52. <span class="conversation-item__foot">
  53. <span>{{ formatShortTime(item.update_time) || '新对话' }}</span>
  54. <span v-if="item.conversation_no">NO.{{ item.conversation_no }}</span>
  55. </span>
  56. </button>
  57. <div v-if="!conversations.length" class="conversation-empty">
  58. <a-avatar :size="42" class="conversation-empty__avatar">
  59. <img src="@/assets/img/ai.png" alt="小妍助理" />
  60. </a-avatar>
  61. <div>暂无会话</div>
  62. </div>
  63. </div>
  64. </aside>
  65. <main
  66. class="chat-main"
  67. :class="{ 'chat-main--dragging': dragging }"
  68. @dragenter.prevent="dragging = true"
  69. @dragover.prevent="dragging = true"
  70. @dragleave.prevent="dragging = false"
  71. @drop.prevent="handleDrop"
  72. >
  73. <div class="chat-toolbar">
  74. <a-button v-if="isMobile" type="text" shape="circle" @click="showConversationPanel = !showConversationPanel">
  75. <template #icon><icon-menu /></template>
  76. </a-button>
  77. <div class="chat-toolbar__identity">
  78. <a-avatar :size="34" class="chat-toolbar__avatar">
  79. <img src="@/assets/img/ai.png" alt="小妍助理" />
  80. </a-avatar>
  81. <div class="chat-toolbar__text">
  82. <strong>{{ currentConversation?.title || '新对话' }}</strong>
  83. <span>{{ currentConversation?.conversation_no ? `NO.${currentConversation.conversation_no}` : '准备开始' }}</span>
  84. </div>
  85. </div>
  86. <a-popconfirm content="是否删除该对话?" @ok="deleteCurrentConversation">
  87. <a-button :disabled="!currentConversationId" type="text" shape="circle" status="danger">
  88. <template #icon><icon-delete /></template>
  89. </a-button>
  90. </a-popconfirm>
  91. </div>
  92. <div ref="messageContainer" class="message-list">
  93. <div v-if="!currentConversationId" class="empty-state">
  94. <a-avatar :size="64" class="empty-state__avatar">
  95. <img src="@/assets/img/ai.png" alt="小妍助理" />
  96. </a-avatar>
  97. <div class="empty-state__title">开启一段新对话</div>
  98. </div>
  99. <template v-else>
  100. <div v-if="messagesLoading" class="message-loading">
  101. <a-spin />
  102. </div>
  103. <div v-if="!messagesLoading && !messages.length" class="welcome-card">
  104. <a-avatar :size="52" class="welcome-card__avatar">
  105. <img src="@/assets/img/ai.png" alt="小妍助理" />
  106. </a-avatar>
  107. <div>
  108. <div class="welcome-card__title">我是小妍助理</div>
  109. <div class="welcome-card__text">可以开始输入问题。</div>
  110. </div>
  111. </div>
  112. <div
  113. v-for="message in messages"
  114. :key="message.id"
  115. class="message-row"
  116. :class="`message-row--${message.role}`"
  117. >
  118. <a-avatar :size="36" class="message-avatar">
  119. <img v-if="message.role === 'assistant'" src="@/assets/img/ai.png" alt="小妍助理" />
  120. <icon-user v-else-if="message.role === 'user'" />
  121. <icon-info-circle v-else />
  122. </a-avatar>
  123. <div class="message-stack">
  124. <div class="message-meta">
  125. <span>{{ roleLabel(message.role) }}</span>
  126. <span>{{ formatFullTime(message.create_time) }}</span>
  127. <a-tag v-if="message.status === 'error'" size="small" color="red">异常</a-tag>
  128. </div>
  129. <div class="message-bubble">
  130. <div v-if="message.content" class="message-content" v-html="renderMarkdown(message.content)"></div>
  131. <div v-if="message.images?.length" class="message-images">
  132. <a-image
  133. v-for="url in message.images"
  134. :key="url"
  135. :src="url"
  136. width="118"
  137. height="118"
  138. fit="cover"
  139. />
  140. </div>
  141. <div v-if="message.error_msg" class="message-error">{{ message.error_msg }}</div>
  142. </div>
  143. </div>
  144. </div>
  145. </template>
  146. </div>
  147. <div v-if="uploadingImages.length" class="image-preview-bar">
  148. <div v-for="item in uploadingImages" :key="item.id" class="image-preview">
  149. <a-image :src="item.url" width="62" height="62" fit="cover" />
  150. <a-button size="mini" shape="circle" @click="removeImage(item.id)">
  151. <template #icon><icon-close /></template>
  152. </a-button>
  153. </div>
  154. </div>
  155. <div class="composer-wrap">
  156. <div class="composer">
  157. <input ref="fileInput" type="file" accept="image/png,image/jpeg,image/jpg,image/gif" multiple hidden @change="handleFileInput" />
  158. <a-tooltip content="添加图片">
  159. <a-button :loading="uploading" shape="circle" class="composer__tool" @click="fileInput?.click()">
  160. <template #icon><icon-image /></template>
  161. </a-button>
  162. </a-tooltip>
  163. <a-textarea
  164. v-model="input"
  165. placeholder="输入消息"
  166. :max-length="1000"
  167. :auto-size="{ minRows: 1, maxRows: 5 }"
  168. allow-clear
  169. @keydown.enter.exact.prevent="sendMessage"
  170. />
  171. <a-button type="primary" shape="circle" :loading="sending" :disabled="!canSend" class="composer__send" @click="sendMessage">
  172. <template #icon><icon-send /></template>
  173. </a-button>
  174. </div>
  175. </div>
  176. </main>
  177. </div>
  178. </a-drawer>
  179. </template>
  180. <script setup>
  181. import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
  182. import { Message, Notification } from '@arco-design/web-vue'
  183. import MarkdownIt from 'markdown-it'
  184. import storage from 'store'
  185. import { useUserStore } from '@/store/modules/user'
  186. import { uploadImg } from '@/api/upload'
  187. import {
  188. createAIChatConversation,
  189. deleteAIChatConversation,
  190. listAIChatConversations,
  191. listAIChatMessages,
  192. sendAIChatMessage
  193. } from '@/api/aiChat'
  194. const props = defineProps({
  195. visible: {
  196. type: Boolean,
  197. default: false
  198. }
  199. })
  200. const emit = defineEmits(['close', 'unread-change'])
  201. const md = new MarkdownIt({ html: false, linkify: true, breaks: true })
  202. const userStore = useUserStore()
  203. const isMobile = ref(window.innerWidth <= 768)
  204. const showConversationPanel = ref(false)
  205. const conversations = ref([])
  206. const messages = ref([])
  207. const currentConversationId = ref(null)
  208. const input = ref('')
  209. const uploadingImages = ref([])
  210. const messagesLoading = ref(false)
  211. const sending = ref(false)
  212. const uploading = ref(false)
  213. const dragging = ref(false)
  214. const messageContainer = ref(null)
  215. const fileInput = ref(null)
  216. const unreadMap = ref({})
  217. let conversationTimer = null
  218. let messageTimer = null
  219. const visible = computed(() => props.visible)
  220. const drawerWidth = computed(() => (isMobile.value ? '100%' : '940px'))
  221. const currentConversation = computed(() => conversations.value.find(item => item.id === currentConversationId.value))
  222. const canSend = computed(() => currentConversationId.value && (input.value.trim() || uploadingImages.value.length > 0))
  223. const unreadTotal = computed(() => Object.values(unreadMap.value).reduce((sum, value) => sum + Number(value || 0), 0))
  224. const seenKey = computed(() => `aiChatSeen:${userStore.uuid || 'guest'}`)
  225. const roleLabel = (role) => ({ user: '我', assistant: '小妍助理', system: '系统' }[role] || role)
  226. const renderMarkdown = (text) => md.render(text || '')
  227. const formatFullTime = (time) => time ? new Date(Number(time)).toLocaleString('zh-CN', { hour12: false }) : ''
  228. const formatShortTime = (time) => {
  229. if (!time) return ''
  230. return new Date(Number(time)).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })
  231. }
  232. const loadSeen = () => storage.get(seenKey.value) || {}
  233. const saveSeen = (seen) => storage.set(seenKey.value, seen)
  234. const updateUnreadByConversations = (list) => {
  235. const oldTotal = unreadTotal.value
  236. const seen = loadSeen()
  237. const next = {}
  238. list.forEach((item) => {
  239. if (item.id === currentConversationId.value && visible.value) return
  240. if (Number(item.last_message_time || 0) > Number(seen[item.id] || 0)) next[item.id] = 1
  241. })
  242. unreadMap.value = next
  243. emit('unread-change', unreadTotal.value)
  244. if (!visible.value && unreadTotal.value > oldTotal) {
  245. Notification.info({ title: '小妍助理有新消息', content: '点击右下角助手查看回复' })
  246. }
  247. }
  248. const markCurrentSeen = () => {
  249. if (!currentConversationId.value) return
  250. const seen = loadSeen()
  251. const last = messages.value[messages.value.length - 1]
  252. const conv = currentConversation.value
  253. seen[currentConversationId.value] = Math.max(Number(last?.create_time || 0), Number(conv?.last_message_time || 0), Date.now())
  254. saveSeen(seen)
  255. delete unreadMap.value[currentConversationId.value]
  256. unreadMap.value = { ...unreadMap.value }
  257. emit('unread-change', unreadTotal.value)
  258. }
  259. const scrollToBottom = () => {
  260. nextTick(() => {
  261. if (messageContainer.value) messageContainer.value.scrollTop = messageContainer.value.scrollHeight
  262. })
  263. }
  264. const loadConversations = async () => {
  265. const res = await listAIChatConversations({ current: 1, pagesize: 30 })
  266. if (!res || res.code !== 0) return
  267. conversations.value = res.data || []
  268. updateUnreadByConversations(conversations.value)
  269. if (visible.value && !currentConversationId.value && conversations.value.length) {
  270. await openConversation(conversations.value[0].id)
  271. }
  272. }
  273. const loadMessages = async () => {
  274. if (!currentConversationId.value) return
  275. messagesLoading.value = true
  276. try {
  277. const res = await listAIChatMessages({ conversation_id: currentConversationId.value, current: 1, pagesize: 80 })
  278. if (!res || res.code !== 0) return Message.error(res?.msg || '获取消息失败')
  279. messages.value = res.data || []
  280. markCurrentSeen()
  281. scrollToBottom()
  282. } finally {
  283. messagesLoading.value = false
  284. }
  285. }
  286. const pollNewMessages = async () => {
  287. if (!currentConversationId.value || !visible.value) return
  288. const lastId = messages.value[messages.value.length - 1]?.id || 0
  289. const res = await listAIChatMessages({ conversation_id: currentConversationId.value, after_id: lastId })
  290. if (!res || res.code !== 0) return
  291. const incoming = res.data || []
  292. if (!incoming.length) return
  293. messages.value.push(...incoming)
  294. const hasAssistant = incoming.some(item => item.role === 'assistant' || item.role === 'system')
  295. if (hasAssistant) Notification.info({ title: '小妍助理有新回复', content: incoming[incoming.length - 1]?.content || '收到新消息' })
  296. markCurrentSeen()
  297. scrollToBottom()
  298. await loadConversations()
  299. }
  300. const openConversation = async (id) => {
  301. currentConversationId.value = id
  302. showConversationPanel.value = false
  303. await loadMessages()
  304. }
  305. const createConversationAndOpen = async () => {
  306. const blank = conversations.value.find(item => Number(item.last_message_time || 0) === 0)
  307. if (blank) {
  308. await openConversation(blank.id)
  309. return
  310. }
  311. const res = await createAIChatConversation({ title: '小妍助理对话' })
  312. if (!res || res.code !== 0) return Message.error(res?.msg || '创建会话失败')
  313. currentConversationId.value = res.data.id
  314. messages.value = []
  315. await loadConversations()
  316. scrollToBottom()
  317. }
  318. const deleteCurrentConversation = async () => {
  319. if (!currentConversationId.value) return
  320. const res = await deleteAIChatConversation({ id: currentConversationId.value })
  321. if (!res || res.code !== 0) return Message.error(res?.msg || '删除失败')
  322. currentConversationId.value = null
  323. messages.value = []
  324. await loadConversations()
  325. }
  326. const uploadFiles = async (files) => {
  327. const imageFiles = Array.from(files || []).filter(file => file.type?.startsWith('image/')).slice(0, 6 - uploadingImages.value.length)
  328. if (!imageFiles.length) return
  329. uploading.value = true
  330. try {
  331. for (const file of imageFiles) {
  332. const formData = new FormData()
  333. formData.append('uuid', userStore.uuid)
  334. formData.append('session', userStore.session)
  335. formData.append('upload', file)
  336. const res = await uploadImg(formData)
  337. if (!res || res.code !== 0) {
  338. Message.error(res?.msg || '图片上传失败')
  339. continue
  340. }
  341. const url = res.data?.picturePath
  342. if (url) uploadingImages.value.push({ id: `${Date.now()}-${Math.random()}`, url })
  343. }
  344. } finally {
  345. uploading.value = false
  346. if (fileInput.value) fileInput.value.value = ''
  347. }
  348. }
  349. const handleFileInput = (event) => uploadFiles(event.target.files)
  350. const handlePaste = (event) => {
  351. const files = Array.from(event.clipboardData?.files || [])
  352. if (files.length) uploadFiles(files)
  353. }
  354. const handleDrop = (event) => {
  355. dragging.value = false
  356. uploadFiles(event.dataTransfer?.files)
  357. }
  358. const removeImage = (id) => {
  359. uploadingImages.value = uploadingImages.value.filter(item => item.id !== id)
  360. }
  361. const sendMessage = async () => {
  362. if (!canSend.value || sending.value) return
  363. const content = input.value.trim()
  364. const images = uploadingImages.value.map(item => item.url)
  365. input.value = ''
  366. uploadingImages.value = []
  367. sending.value = true
  368. try {
  369. const res = await sendAIChatMessage({ conversation_id: currentConversationId.value, content, images })
  370. if (!res || res.code !== 0) {
  371. Message.error(res?.msg || '发送失败')
  372. input.value = content
  373. uploadingImages.value = images.map(url => ({ id: `${Date.now()}-${Math.random()}`, url }))
  374. return
  375. }
  376. await loadMessages()
  377. await loadConversations()
  378. } finally {
  379. sending.value = false
  380. }
  381. }
  382. const close = () => emit('close')
  383. const syncMobile = () => { isMobile.value = window.innerWidth <= 768 }
  384. const startPolling = () => {
  385. if (!conversationTimer) conversationTimer = setInterval(loadConversations, 8000)
  386. if (!messageTimer) messageTimer = setInterval(pollNewMessages, 3000)
  387. }
  388. const stopPolling = () => {
  389. clearInterval(conversationTimer)
  390. clearInterval(messageTimer)
  391. conversationTimer = null
  392. messageTimer = null
  393. }
  394. watch(() => props.visible, async (value) => {
  395. if (value) {
  396. await loadConversations()
  397. if (!currentConversationId.value) await createConversationAndOpen()
  398. markCurrentSeen()
  399. scrollToBottom()
  400. }
  401. })
  402. watch(unreadTotal, value => emit('unread-change', value))
  403. onMounted(async () => {
  404. await userStore.getInfo()
  405. window.addEventListener('resize', syncMobile)
  406. await loadConversations()
  407. startPolling()
  408. })
  409. onUnmounted(() => {
  410. window.removeEventListener('resize', syncMobile)
  411. stopPolling()
  412. })
  413. </script>
  414. <style lang="less" scoped>
  415. .ai-chat-title {
  416. display: flex;
  417. align-items: center;
  418. gap: 12px;
  419. &__avatar {
  420. background: linear-gradient(135deg, #eaf2ff, #f7ecff);
  421. box-shadow: 0 8px 20px rgba(48, 91, 189, 0.16);
  422. }
  423. &__copy {
  424. display: grid;
  425. gap: 3px;
  426. }
  427. &__name {
  428. color: #1d2129;
  429. font-size: 16px;
  430. font-weight: 700;
  431. line-height: 1.2;
  432. }
  433. &__sub {
  434. display: inline-flex;
  435. align-items: center;
  436. gap: 6px;
  437. color: #4e5969;
  438. font-size: 12px;
  439. }
  440. }
  441. .status-dot {
  442. width: 7px;
  443. height: 7px;
  444. border-radius: 50%;
  445. background: #16b364;
  446. box-shadow: 0 0 0 4px rgba(22, 179, 100, 0.12);
  447. }
  448. .ai-chat-shell {
  449. display: grid;
  450. grid-template-columns: 270px minmax(0, 1fr);
  451. height: calc(100vh - 92px);
  452. min-height: 0;
  453. margin: -12px -8px -16px;
  454. background:
  455. radial-gradient(circle at 18% 6%, rgba(36, 99, 235, 0.08), transparent 30%),
  456. linear-gradient(135deg, #f7faff 0%, #ffffff 46%, #f8fbf8 100%);
  457. border-top: 1px solid rgba(29, 33, 41, 0.08);
  458. }
  459. .conversation-panel {
  460. min-width: 0;
  461. border-right: 1px solid rgba(29, 33, 41, 0.08);
  462. background: rgba(255, 255, 255, 0.72);
  463. backdrop-filter: blur(18px);
  464. }
  465. .conversation-panel__head {
  466. display: flex;
  467. align-items: center;
  468. justify-content: space-between;
  469. padding: 18px 16px 12px;
  470. }
  471. .conversation-panel__label {
  472. color: #1d2129;
  473. font-size: 16px;
  474. font-weight: 720;
  475. }
  476. .conversation-panel__count {
  477. margin-top: 2px;
  478. color: #86909c;
  479. font-size: 12px;
  480. }
  481. .conversation-list {
  482. height: calc(100% - 76px);
  483. overflow-y: auto;
  484. padding: 0 12px 18px;
  485. }
  486. .conversation-item {
  487. position: relative;
  488. display: grid;
  489. width: 100%;
  490. gap: 7px;
  491. margin-bottom: 10px;
  492. padding: 13px 14px;
  493. border: 1px solid rgba(29, 33, 41, 0.07);
  494. border-radius: 8px;
  495. background: rgba(255, 255, 255, 0.58);
  496. box-shadow: 0 10px 24px rgba(15, 23, 42, 0.04);
  497. text-align: left;
  498. cursor: pointer;
  499. transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease, background 0.18s ease;
  500. &:hover,
  501. &--active {
  502. border-color: rgba(37, 99, 235, 0.36);
  503. background: #ffffff;
  504. box-shadow: 0 12px 28px rgba(37, 99, 235, 0.12);
  505. transform: translateY(-1px);
  506. }
  507. &__top,
  508. &__foot {
  509. display: flex;
  510. align-items: center;
  511. justify-content: space-between;
  512. gap: 10px;
  513. min-width: 0;
  514. }
  515. &__title,
  516. &__preview {
  517. overflow: hidden;
  518. text-overflow: ellipsis;
  519. white-space: nowrap;
  520. }
  521. &__title {
  522. min-width: 0;
  523. color: #1d2129;
  524. font-weight: 650;
  525. }
  526. &__preview {
  527. color: #4e5969;
  528. font-size: 13px;
  529. }
  530. &__foot {
  531. color: #86909c;
  532. font-size: 11px;
  533. }
  534. &__dot {
  535. flex: 0 0 auto;
  536. width: 8px;
  537. height: 8px;
  538. border-radius: 50%;
  539. background: #f53f3f;
  540. box-shadow: 0 0 0 4px rgba(245, 63, 63, 0.12);
  541. }
  542. }
  543. .conversation-empty {
  544. display: grid;
  545. place-items: center;
  546. gap: 10px;
  547. padding: 40px 12px;
  548. color: #86909c;
  549. font-size: 13px;
  550. &__avatar {
  551. background: #eef4ff;
  552. }
  553. }
  554. .chat-main {
  555. position: relative;
  556. display: grid;
  557. grid-template-rows: auto minmax(0, 1fr) auto auto;
  558. min-width: 0;
  559. &--dragging::after {
  560. content: '释放上传图片';
  561. position: absolute;
  562. inset: 16px;
  563. z-index: 5;
  564. display: grid;
  565. place-items: center;
  566. border: 2px dashed #2563eb;
  567. border-radius: 8px;
  568. background: rgba(255, 255, 255, 0.88);
  569. color: #1d4ed8;
  570. font-size: 15px;
  571. font-weight: 700;
  572. backdrop-filter: blur(6px);
  573. }
  574. }
  575. .chat-toolbar {
  576. display: flex;
  577. align-items: center;
  578. gap: 12px;
  579. min-height: 68px;
  580. padding: 14px 18px;
  581. border-bottom: 1px solid rgba(29, 33, 41, 0.08);
  582. background: rgba(255, 255, 255, 0.62);
  583. backdrop-filter: blur(16px);
  584. &__identity {
  585. display: flex;
  586. align-items: center;
  587. gap: 11px;
  588. flex: 1;
  589. min-width: 0;
  590. }
  591. &__avatar {
  592. flex: 0 0 auto;
  593. background: #eef4ff;
  594. }
  595. &__text {
  596. display: grid;
  597. flex: 1;
  598. min-width: 0;
  599. gap: 3px;
  600. strong,
  601. span {
  602. overflow: hidden;
  603. text-overflow: ellipsis;
  604. white-space: nowrap;
  605. }
  606. strong {
  607. color: #1d2129;
  608. font-size: 15px;
  609. font-weight: 700;
  610. }
  611. span {
  612. color: #86909c;
  613. font-size: 12px;
  614. }
  615. }
  616. }
  617. .message-list {
  618. overflow-y: auto;
  619. padding: 22px 22px 18px;
  620. }
  621. .message-loading {
  622. display: flex;
  623. justify-content: center;
  624. padding: 18px;
  625. }
  626. .empty-state,
  627. .welcome-card {
  628. display: grid;
  629. place-items: center;
  630. gap: 10px;
  631. height: 100%;
  632. color: #86909c;
  633. }
  634. .empty-state__avatar,
  635. .welcome-card__avatar {
  636. background: linear-gradient(135deg, #eaf2ff, #f7ecff);
  637. }
  638. .empty-state__title,
  639. .welcome-card__title {
  640. color: #1d2129;
  641. font-size: 16px;
  642. font-weight: 700;
  643. }
  644. .welcome-card {
  645. justify-content: center;
  646. align-content: center;
  647. height: auto;
  648. min-height: 220px;
  649. text-align: center;
  650. }
  651. .welcome-card__text {
  652. margin-top: 4px;
  653. color: #86909c;
  654. font-size: 13px;
  655. }
  656. .message-row {
  657. display: flex;
  658. align-items: flex-start;
  659. gap: 11px;
  660. margin-bottom: 18px;
  661. &--user {
  662. flex-direction: row-reverse;
  663. .message-stack {
  664. align-items: flex-end;
  665. }
  666. .message-meta {
  667. flex-direction: row-reverse;
  668. }
  669. .message-bubble {
  670. color: #ffffff;
  671. background: linear-gradient(135deg, #2563eb, #0f766e);
  672. box-shadow: 0 12px 28px rgba(37, 99, 235, 0.2);
  673. }
  674. .message-content :deep(a) {
  675. color: #ffffff;
  676. }
  677. }
  678. &--system {
  679. .message-avatar {
  680. background: #fff7e8;
  681. color: #d46b08;
  682. }
  683. .message-bubble {
  684. background: #fffaf0;
  685. border-color: rgba(245, 158, 11, 0.22);
  686. }
  687. }
  688. }
  689. .message-avatar {
  690. flex: 0 0 36px;
  691. background: #eef4ff;
  692. box-shadow: 0 8px 20px rgba(15, 23, 42, 0.08);
  693. }
  694. .message-stack {
  695. display: flex;
  696. flex-direction: column;
  697. align-items: flex-start;
  698. max-width: min(78%, 640px);
  699. min-width: 0;
  700. }
  701. .message-meta {
  702. display: flex;
  703. flex-wrap: wrap;
  704. align-items: center;
  705. gap: 8px;
  706. margin-bottom: 6px;
  707. color: #86909c;
  708. font-size: 12px;
  709. }
  710. .message-bubble {
  711. max-width: 100%;
  712. padding: 12px 14px;
  713. border: 1px solid rgba(29, 33, 41, 0.07);
  714. border-radius: 8px;
  715. background: rgba(255, 255, 255, 0.86);
  716. box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06);
  717. line-height: 1.72;
  718. word-break: break-word;
  719. }
  720. .message-content {
  721. :deep(p) {
  722. margin: 0 0 7px;
  723. }
  724. :deep(p:last-child) {
  725. margin-bottom: 0;
  726. }
  727. :deep(ul),
  728. :deep(ol) {
  729. margin: 6px 0;
  730. padding-left: 20px;
  731. }
  732. :deep(code) {
  733. border-radius: 4px;
  734. padding: 2px 5px;
  735. background: rgba(15, 23, 42, 0.08);
  736. font-size: 12px;
  737. }
  738. }
  739. .message-images {
  740. display: flex;
  741. flex-wrap: wrap;
  742. gap: 8px;
  743. margin-top: 9px;
  744. :deep(.arco-image-img) {
  745. border-radius: 8px;
  746. }
  747. }
  748. .message-error {
  749. margin-top: 7px;
  750. color: #f53f3f;
  751. font-size: 12px;
  752. }
  753. .image-preview-bar {
  754. display: flex;
  755. gap: 10px;
  756. overflow-x: auto;
  757. padding: 10px 18px 0;
  758. }
  759. .image-preview {
  760. position: relative;
  761. flex: 0 0 auto;
  762. border-radius: 8px;
  763. box-shadow: 0 8px 20px rgba(15, 23, 42, 0.1);
  764. :deep(.arco-image-img) {
  765. border-radius: 8px;
  766. }
  767. :deep(.arco-btn) {
  768. position: absolute;
  769. top: -8px;
  770. right: -8px;
  771. background: #ffffff;
  772. }
  773. }
  774. .composer-wrap {
  775. padding: 12px 18px 18px;
  776. background: linear-gradient(180deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.9) 32%);
  777. }
  778. .composer {
  779. display: grid;
  780. grid-template-columns: auto minmax(0, 1fr) auto;
  781. align-items: end;
  782. gap: 10px;
  783. padding: 10px;
  784. border: 1px solid rgba(29, 33, 41, 0.08);
  785. border-radius: 8px;
  786. background: rgba(255, 255, 255, 0.92);
  787. box-shadow: 0 16px 36px rgba(15, 23, 42, 0.1);
  788. :deep(.arco-textarea-wrapper) {
  789. border: 0;
  790. background: transparent;
  791. box-shadow: none;
  792. }
  793. :deep(textarea) {
  794. font-size: 14px;
  795. line-height: 1.65;
  796. }
  797. &__tool {
  798. background: #f2f3f5;
  799. }
  800. &__send {
  801. background: linear-gradient(135deg, #2563eb, #0f766e);
  802. border: 0;
  803. }
  804. }
  805. @media (max-width: 768px) {
  806. .ai-chat-shell {
  807. grid-template-columns: 1fr;
  808. height: calc(100vh - 64px);
  809. margin: -12px -8px -16px;
  810. }
  811. .conversation-panel {
  812. position: absolute;
  813. top: 0;
  814. bottom: 0;
  815. left: 0;
  816. z-index: 10;
  817. width: min(86vw, 310px);
  818. transform: translateX(-100%);
  819. transition: transform 0.22s ease;
  820. box-shadow: 12px 0 32px rgba(15, 23, 42, 0.16);
  821. &--open {
  822. transform: translateX(0);
  823. }
  824. }
  825. .chat-toolbar {
  826. min-height: 62px;
  827. padding: 12px;
  828. }
  829. .message-list {
  830. padding: 16px 12px 12px;
  831. }
  832. .message-stack {
  833. max-width: calc(100% - 52px);
  834. }
  835. .message-bubble {
  836. padding: 11px 12px;
  837. }
  838. .image-preview-bar {
  839. padding: 8px 12px 0;
  840. }
  841. .composer-wrap {
  842. padding: 10px 12px max(12px, env(safe-area-inset-bottom));
  843. }
  844. .composer {
  845. gap: 8px;
  846. padding: 8px;
  847. }
  848. }
  849. </style>