MessageItem.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  1. <script setup lang="ts">
  2. import { NTag, NSpin, NButton, NCard } from 'naive-ui'
  3. import { computed, ref } from 'vue'
  4. import { marked, Renderer } from 'marked'
  5. import DOMPurify from 'dompurify'
  6. import { useClipboard } from '@vueuse/core'
  7. import { downloadAiFile } from '@/service/aiChat'
  8. import type { ChatMessage, ToolCallInfo } from './interface'
  9. const props = defineProps<{
  10. message: ChatMessage
  11. }>()
  12. const emit = defineEmits<{
  13. resolvePermission: [optionId: string]
  14. }>()
  15. const isUser = computed(() => props.message.role === 'user')
  16. // 是否使用有序 segments 渲染(新消息带 segments;旧消息回退到旧逻辑)
  17. const useSegments = computed(
  18. () => !isUser.value && Array.isArray(props.message.segments) && props.message.segments.length > 0,
  19. )
  20. // 自定义 marked renderer,为代码块添加复制按钮
  21. const renderer = new Renderer()
  22. renderer.code = ({ text, lang }: { text: string; lang?: string }) => {
  23. const escaped = text
  24. .replace(/&/g, '&amp;')
  25. .replace(/</g, '&lt;')
  26. .replace(/>/g, '&gt;')
  27. .replace(/"/g, '&quot;')
  28. const langLabel = lang ? `<span class="code-lang-label">${lang}</span>` : ''
  29. return `<div class="code-block-wrapper">${langLabel}<button class="copy-btn" data-code="${escaped}">复制</button><pre><code>${text}</code></pre></div>`
  30. }
  31. // 将一段 AI 文本渲染为安全 HTML
  32. function renderMarkdown(text: string): string {
  33. const raw = marked.parse(text, { async: false, renderer }) as string
  34. return DOMPurify.sanitize(raw, { ADD_ATTR: ['data-code'] })
  35. }
  36. const formattedContent = computed(() => {
  37. if (isUser.value) {
  38. // 用户消息保持纯文本显示
  39. return props.message.content.replace(/\n/g, '<br/>')
  40. }
  41. // AI 回复使用 Markdown 渲染
  42. return renderMarkdown(props.message.content)
  43. })
  44. // 工具标签展示映射:区分执行中 / 已完成 / 失败
  45. function toolTagType(status: ToolCallInfo['status']) {
  46. if (status === 'running') return 'warning'
  47. if (status === 'completed') return 'success'
  48. return 'error'
  49. }
  50. function toolStatusLabel(status: ToolCallInfo['status']) {
  51. if (status === 'running') return '执行中'
  52. if (status === 'completed') return '已完成'
  53. return '失败'
  54. }
  55. // 复制到剪贴板
  56. const { copy } = useClipboard()
  57. const copiedIndex = ref<string | null>(null)
  58. function handleContentClick(e: MouseEvent) {
  59. const target = e.target as HTMLElement
  60. if (target.classList.contains('copy-btn')) {
  61. const code = target.getAttribute('data-code')
  62. ?.replace(/&amp;/g, '&')
  63. .replace(/&lt;/g, '<')
  64. .replace(/&gt;/g, '>')
  65. .replace(/&quot;/g, '"')
  66. if (code) {
  67. copy(code)
  68. // 显示已复制反馈
  69. const id = Math.random().toString(36).slice(2)
  70. copiedIndex.value = id
  71. target.textContent = '已复制 ✓'
  72. target.classList.add('copied')
  73. setTimeout(() => {
  74. if (copiedIndex.value === id) {
  75. target.textContent = '复制'
  76. target.classList.remove('copied')
  77. copiedIndex.value = null
  78. }
  79. }, 2000)
  80. }
  81. }
  82. }
  83. function downloadFile(key: string, fileName: string) {
  84. downloadAiFile(key, fileName)
  85. }
  86. function formatSize(bytes: number): string {
  87. if (bytes < 1024) return `${bytes}B`
  88. if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
  89. return `${(bytes / 1024 / 1024).toFixed(1)}MB`
  90. }
  91. </script>
  92. <template>
  93. <div class="flex" :class="isUser ? 'justify-end' : 'justify-start'">
  94. <div
  95. class="max-w-[80%] rounded-lg px-3 py-2 text-sm"
  96. :class="isUser
  97. ? 'bg-blue-500 text-white'
  98. : 'bg-neutral-100 text-neutral-800 dark:bg-neutral-700 dark:text-neutral-200'"
  99. >
  100. <!-- 工具调用标签(旧消息回退:无 segments 时集中展示在顶部) -->
  101. <div v-if="!useSegments && message.toolCalls?.length" class="mb-2 flex flex-wrap gap-1">
  102. <NTag
  103. v-for="tc in message.toolCalls"
  104. :key="tc.id"
  105. size="small"
  106. :title="tc.title"
  107. :type="toolTagType(tc.status)"
  108. >
  109. <template #icon>
  110. <NSpin v-if="tc.status === 'running'" :size="10" />
  111. </template>
  112. {{ tc.description }}
  113. <span class="ml-1 opacity-70">· {{ toolStatusLabel(tc.status) }}</span>
  114. </NTag>
  115. </div>
  116. <!-- 权限请求 UI -->
  117. <div v-if="message.permission && !message.permission.resolved" class="my-2">
  118. <NCard size="small" :bordered="true" class="!bg-yellow-50 dark:!bg-yellow-900/20">
  119. <div class="mb-2 text-xs font-medium text-yellow-700 dark:text-yellow-300">
  120. 🔒 写操作授权请求
  121. </div>
  122. <div class="mb-2 text-xs text-neutral-600 dark:text-neutral-300">
  123. {{ message.permission.title }}
  124. </div>
  125. <div class="flex flex-wrap gap-2">
  126. <NButton
  127. v-for="opt in message.permission.options"
  128. :key="opt.optionId"
  129. size="tiny"
  130. :type="opt.kind === 'deny' ? 'error' : opt.kind === 'allow_always' ? 'warning' : 'success'"
  131. @click="emit('resolvePermission', opt.optionId)"
  132. >
  133. {{ opt.name }}
  134. </NButton>
  135. </div>
  136. </NCard>
  137. </div>
  138. <!-- 权限已授权提示 -->
  139. <div v-if="message.permission?.resolved" class="my-1">
  140. <NTag size="small" type="success" :bordered="false">
  141. ✅ 已授权
  142. </NTag>
  143. </div>
  144. <!-- 文件下载按钮(旧消息回退:无 segments 时集中展示在顶部;有 segments 时按标记位置内联渲染) -->
  145. <div v-if="!useSegments && message.files?.length" class="my-2 space-y-1">
  146. <div v-for="file in message.files" :key="file.key" class="flex items-center gap-2">
  147. <NButton
  148. size="tiny"
  149. type="primary"
  150. @click="downloadFile(file.key, file.fileName)"
  151. >
  152. <template #icon>
  153. <span class="i-ph:download-simple" />
  154. </template>
  155. {{ file.fileName }}
  156. <span class="ml-1 text-xs opacity-70">({{ formatSize(file.size) }})</span>
  157. </NButton>
  158. </div>
  159. </div>
  160. <!-- 消息内容 -->
  161. <!-- 有序 segments:文本与工具调用按到达顺序交错渲染 -->
  162. <div v-if="useSegments" class="segments" @click="handleContentClick">
  163. <template v-for="(seg, idx) in message.segments" :key="idx">
  164. <div
  165. v-if="seg.kind === 'text' && seg.content.trim()"
  166. class="markdown-body"
  167. v-html="renderMarkdown(seg.content)"
  168. />
  169. <div v-else-if="seg.kind === 'tool'" class="my-1 flex flex-wrap gap-1">
  170. <NTag
  171. size="small"
  172. :title="seg.tool.title"
  173. :type="toolTagType(seg.tool.status)"
  174. :bordered="false"
  175. >
  176. <template #icon>
  177. <NSpin v-if="seg.tool.status === 'running'" :size="10" />
  178. </template>
  179. {{ seg.tool.description }}
  180. <span class="ml-1 opacity-70">· {{ toolStatusLabel(seg.tool.status) }}</span>
  181. </NTag>
  182. </div>
  183. <div v-else-if="seg.kind === 'file'" class="my-2">
  184. <NButton
  185. size="tiny"
  186. type="primary"
  187. @click="downloadFile(seg.file.key, seg.file.fileName)"
  188. >
  189. <template #icon>
  190. <span class="i-ph:download-simple" />
  191. </template>
  192. {{ seg.file.fileName }}
  193. <span class="ml-1 text-xs opacity-70">({{ formatSize(seg.file.size) }})</span>
  194. </NButton>
  195. </div>
  196. </template>
  197. </div>
  198. <!-- 旧消息回退:整体文本渲染 -->
  199. <div
  200. v-else-if="message.content"
  201. :class="{ 'markdown-body': !isUser }"
  202. v-html="formattedContent"
  203. @click="handleContentClick"
  204. />
  205. </div>
  206. </div>
  207. </template>
  208. <style scoped lang="scss">
  209. .markdown-body {
  210. line-height: 1.6;
  211. :deep(h1),
  212. :deep(h2),
  213. :deep(h3),
  214. :deep(h4),
  215. :deep(h5),
  216. :deep(h6) {
  217. margin-top: 0.5em;
  218. margin-bottom: 0.25em;
  219. font-weight: 600;
  220. line-height: 1.4;
  221. }
  222. :deep(h1) { font-size: 1.25em; }
  223. :deep(h2) { font-size: 1.15em; }
  224. :deep(h3) { font-size: 1.05em; }
  225. :deep(p) {
  226. margin: 0.4em 0;
  227. }
  228. :deep(ul),
  229. :deep(ol) {
  230. padding-left: 1.5em;
  231. margin: 0.4em 0;
  232. }
  233. :deep(li) {
  234. margin: 0.2em 0;
  235. }
  236. :deep(code) {
  237. background: rgba(127, 127, 127, 0.15);
  238. border-radius: 3px;
  239. padding: 0.15em 0.4em;
  240. font-size: 0.9em;
  241. font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
  242. }
  243. :deep(pre) {
  244. background: rgba(0, 0, 0, 0.06);
  245. border-radius: 6px;
  246. padding: 0.75em 1em;
  247. margin: 0.5em 0;
  248. overflow-x: auto;
  249. code {
  250. background: none;
  251. padding: 0;
  252. border-radius: 0;
  253. font-size: 0.85em;
  254. }
  255. }
  256. :deep(.code-block-wrapper) {
  257. position: relative;
  258. margin: 0.5em 0;
  259. pre {
  260. margin: 0;
  261. padding-top: 2em;
  262. }
  263. .code-lang-label {
  264. position: absolute;
  265. top: 6px;
  266. left: 12px;
  267. font-size: 0.7em;
  268. color: rgba(127, 127, 127, 0.7);
  269. user-select: none;
  270. pointer-events: none;
  271. }
  272. .copy-btn {
  273. position: absolute;
  274. top: 6px;
  275. right: 8px;
  276. padding: 2px 8px;
  277. font-size: 0.7em;
  278. color: rgba(127, 127, 127, 0.8);
  279. background: rgba(127, 127, 127, 0.1);
  280. border: 1px solid rgba(127, 127, 127, 0.2);
  281. border-radius: 4px;
  282. cursor: pointer;
  283. transition: all 0.2s;
  284. user-select: none;
  285. z-index: 1;
  286. &:hover {
  287. color: rgba(127, 127, 127, 1);
  288. background: rgba(127, 127, 127, 0.2);
  289. }
  290. &.copied {
  291. color: #10b981;
  292. border-color: #10b981;
  293. background: rgba(16, 185, 129, 0.1);
  294. }
  295. }
  296. }
  297. :deep(blockquote) {
  298. border-left: 3px solid rgba(127, 127, 127, 0.4);
  299. padding-left: 0.75em;
  300. margin: 0.5em 0;
  301. color: rgba(127, 127, 127, 0.8);
  302. }
  303. :deep(table) {
  304. border-collapse: collapse;
  305. margin: 0.5em 0;
  306. width: 100%;
  307. font-size: 0.9em;
  308. }
  309. :deep(th),
  310. :deep(td) {
  311. border: 1px solid rgba(127, 127, 127, 0.3);
  312. padding: 0.3em 0.6em;
  313. text-align: left;
  314. }
  315. :deep(th) {
  316. background: rgba(127, 127, 127, 0.1);
  317. font-weight: 600;
  318. }
  319. :deep(hr) {
  320. border: none;
  321. border-top: 1px solid rgba(127, 127, 127, 0.3);
  322. margin: 0.75em 0;
  323. }
  324. :deep(a) {
  325. color: #3b82f6;
  326. text-decoration: underline;
  327. }
  328. :deep(img) {
  329. max-width: 100%;
  330. border-radius: 4px;
  331. }
  332. :deep(> :first-child) {
  333. margin-top: 0;
  334. }
  335. :deep(> :last-child) {
  336. margin-bottom: 0;
  337. }
  338. }
  339. :global(.dark) .markdown-body {
  340. :deep(pre) {
  341. background: rgba(255, 255, 255, 0.06);
  342. }
  343. :deep(a) {
  344. color: #60a5fa;
  345. }
  346. }
  347. </style>