| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391 |
- <script setup lang="ts">
- import { NTag, NSpin, NButton, NCard } from 'naive-ui'
- import { computed, ref } from 'vue'
- import { marked, Renderer } from 'marked'
- import DOMPurify from 'dompurify'
- import { useClipboard } from '@vueuse/core'
- import { downloadAiFile } from '@/service/aiChat'
- import type { ChatMessage, ToolCallInfo } from './interface'
- const props = defineProps<{
- message: ChatMessage
- }>()
- const emit = defineEmits<{
- resolvePermission: [optionId: string]
- }>()
- const isUser = computed(() => props.message.role === 'user')
- // 是否使用有序 segments 渲染(新消息带 segments;旧消息回退到旧逻辑)
- const useSegments = computed(
- () => !isUser.value && Array.isArray(props.message.segments) && props.message.segments.length > 0,
- )
- // 自定义 marked renderer,为代码块添加复制按钮
- const renderer = new Renderer()
- renderer.code = ({ text, lang }: { text: string; lang?: string }) => {
- const escaped = text
- .replace(/&/g, '&')
- .replace(/</g, '<')
- .replace(/>/g, '>')
- .replace(/"/g, '"')
- const langLabel = lang ? `<span class="code-lang-label">${lang}</span>` : ''
- return `<div class="code-block-wrapper">${langLabel}<button class="copy-btn" data-code="${escaped}">复制</button><pre><code>${text}</code></pre></div>`
- }
- // 将一段 AI 文本渲染为安全 HTML
- function renderMarkdown(text: string): string {
- const raw = marked.parse(text, { async: false, renderer }) as string
- return DOMPurify.sanitize(raw, { ADD_ATTR: ['data-code'] })
- }
- const formattedContent = computed(() => {
- if (isUser.value) {
- // 用户消息保持纯文本显示
- return props.message.content.replace(/\n/g, '<br/>')
- }
- // AI 回复使用 Markdown 渲染
- return renderMarkdown(props.message.content)
- })
- // 工具标签展示映射:区分执行中 / 已完成 / 失败
- function toolTagType(status: ToolCallInfo['status']) {
- if (status === 'running') return 'warning'
- if (status === 'completed') return 'success'
- return 'error'
- }
- function toolStatusLabel(status: ToolCallInfo['status']) {
- if (status === 'running') return '执行中'
- if (status === 'completed') return '已完成'
- return '失败'
- }
- // 复制到剪贴板
- const { copy } = useClipboard()
- const copiedIndex = ref<string | null>(null)
- function handleContentClick(e: MouseEvent) {
- const target = e.target as HTMLElement
- if (target.classList.contains('copy-btn')) {
- const code = target.getAttribute('data-code')
- ?.replace(/&/g, '&')
- .replace(/</g, '<')
- .replace(/>/g, '>')
- .replace(/"/g, '"')
- if (code) {
- copy(code)
- // 显示已复制反馈
- const id = Math.random().toString(36).slice(2)
- copiedIndex.value = id
- target.textContent = '已复制 ✓'
- target.classList.add('copied')
- setTimeout(() => {
- if (copiedIndex.value === id) {
- target.textContent = '复制'
- target.classList.remove('copied')
- copiedIndex.value = null
- }
- }, 2000)
- }
- }
- }
- function downloadFile(key: string, fileName: string) {
- downloadAiFile(key, fileName)
- }
- function formatSize(bytes: number): string {
- if (bytes < 1024) return `${bytes}B`
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
- return `${(bytes / 1024 / 1024).toFixed(1)}MB`
- }
- </script>
- <template>
- <div class="flex" :class="isUser ? 'justify-end' : 'justify-start'">
- <div
- class="max-w-[80%] rounded-lg px-3 py-2 text-sm"
- :class="isUser
- ? 'bg-blue-500 text-white'
- : 'bg-neutral-100 text-neutral-800 dark:bg-neutral-700 dark:text-neutral-200'"
- >
- <!-- 工具调用标签(旧消息回退:无 segments 时集中展示在顶部) -->
- <div v-if="!useSegments && message.toolCalls?.length" class="mb-2 flex flex-wrap gap-1">
- <NTag
- v-for="tc in message.toolCalls"
- :key="tc.id"
- size="small"
- :title="tc.title"
- :type="toolTagType(tc.status)"
- >
- <template #icon>
- <NSpin v-if="tc.status === 'running'" :size="10" />
- </template>
- {{ tc.description }}
- <span class="ml-1 opacity-70">· {{ toolStatusLabel(tc.status) }}</span>
- </NTag>
- </div>
- <!-- 权限请求 UI -->
- <div v-if="message.permission && !message.permission.resolved" class="my-2">
- <NCard size="small" :bordered="true" class="!bg-yellow-50 dark:!bg-yellow-900/20">
- <div class="mb-2 text-xs font-medium text-yellow-700 dark:text-yellow-300">
- 🔒 写操作授权请求
- </div>
- <div class="mb-2 text-xs text-neutral-600 dark:text-neutral-300">
- {{ message.permission.title }}
- </div>
- <div class="flex flex-wrap gap-2">
- <NButton
- v-for="opt in message.permission.options"
- :key="opt.optionId"
- size="tiny"
- :type="opt.kind === 'deny' ? 'error' : opt.kind === 'allow_always' ? 'warning' : 'success'"
- @click="emit('resolvePermission', opt.optionId)"
- >
- {{ opt.name }}
- </NButton>
- </div>
- </NCard>
- </div>
- <!-- 权限已授权提示 -->
- <div v-if="message.permission?.resolved" class="my-1">
- <NTag size="small" type="success" :bordered="false">
- ✅ 已授权
- </NTag>
- </div>
- <!-- 文件下载按钮(旧消息回退:无 segments 时集中展示在顶部;有 segments 时按标记位置内联渲染) -->
- <div v-if="!useSegments && message.files?.length" class="my-2 space-y-1">
- <div v-for="file in message.files" :key="file.key" class="flex items-center gap-2">
- <NButton
- size="tiny"
- type="primary"
- @click="downloadFile(file.key, file.fileName)"
- >
- <template #icon>
- <span class="i-ph:download-simple" />
- </template>
- {{ file.fileName }}
- <span class="ml-1 text-xs opacity-70">({{ formatSize(file.size) }})</span>
- </NButton>
- </div>
- </div>
- <!-- 消息内容 -->
- <!-- 有序 segments:文本与工具调用按到达顺序交错渲染 -->
- <div v-if="useSegments" class="segments" @click="handleContentClick">
- <template v-for="(seg, idx) in message.segments" :key="idx">
- <div
- v-if="seg.kind === 'text' && seg.content.trim()"
- class="markdown-body"
- v-html="renderMarkdown(seg.content)"
- />
- <div v-else-if="seg.kind === 'tool'" class="my-1 flex flex-wrap gap-1">
- <NTag
- size="small"
- :title="seg.tool.title"
- :type="toolTagType(seg.tool.status)"
- :bordered="false"
- >
- <template #icon>
- <NSpin v-if="seg.tool.status === 'running'" :size="10" />
- </template>
- {{ seg.tool.description }}
- <span class="ml-1 opacity-70">· {{ toolStatusLabel(seg.tool.status) }}</span>
- </NTag>
- </div>
- <div v-else-if="seg.kind === 'file'" class="my-2">
- <NButton
- size="tiny"
- type="primary"
- @click="downloadFile(seg.file.key, seg.file.fileName)"
- >
- <template #icon>
- <span class="i-ph:download-simple" />
- </template>
- {{ seg.file.fileName }}
- <span class="ml-1 text-xs opacity-70">({{ formatSize(seg.file.size) }})</span>
- </NButton>
- </div>
- </template>
- </div>
- <!-- 旧消息回退:整体文本渲染 -->
- <div
- v-else-if="message.content"
- :class="{ 'markdown-body': !isUser }"
- v-html="formattedContent"
- @click="handleContentClick"
- />
- </div>
- </div>
- </template>
- <style scoped lang="scss">
- .markdown-body {
- line-height: 1.6;
- :deep(h1),
- :deep(h2),
- :deep(h3),
- :deep(h4),
- :deep(h5),
- :deep(h6) {
- margin-top: 0.5em;
- margin-bottom: 0.25em;
- font-weight: 600;
- line-height: 1.4;
- }
- :deep(h1) { font-size: 1.25em; }
- :deep(h2) { font-size: 1.15em; }
- :deep(h3) { font-size: 1.05em; }
- :deep(p) {
- margin: 0.4em 0;
- }
- :deep(ul),
- :deep(ol) {
- padding-left: 1.5em;
- margin: 0.4em 0;
- }
- :deep(li) {
- margin: 0.2em 0;
- }
- :deep(code) {
- background: rgba(127, 127, 127, 0.15);
- border-radius: 3px;
- padding: 0.15em 0.4em;
- font-size: 0.9em;
- font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
- }
- :deep(pre) {
- background: rgba(0, 0, 0, 0.06);
- border-radius: 6px;
- padding: 0.75em 1em;
- margin: 0.5em 0;
- overflow-x: auto;
- code {
- background: none;
- padding: 0;
- border-radius: 0;
- font-size: 0.85em;
- }
- }
- :deep(.code-block-wrapper) {
- position: relative;
- margin: 0.5em 0;
- pre {
- margin: 0;
- padding-top: 2em;
- }
- .code-lang-label {
- position: absolute;
- top: 6px;
- left: 12px;
- font-size: 0.7em;
- color: rgba(127, 127, 127, 0.7);
- user-select: none;
- pointer-events: none;
- }
- .copy-btn {
- position: absolute;
- top: 6px;
- right: 8px;
- padding: 2px 8px;
- font-size: 0.7em;
- color: rgba(127, 127, 127, 0.8);
- background: rgba(127, 127, 127, 0.1);
- border: 1px solid rgba(127, 127, 127, 0.2);
- border-radius: 4px;
- cursor: pointer;
- transition: all 0.2s;
- user-select: none;
- z-index: 1;
- &:hover {
- color: rgba(127, 127, 127, 1);
- background: rgba(127, 127, 127, 0.2);
- }
- &.copied {
- color: #10b981;
- border-color: #10b981;
- background: rgba(16, 185, 129, 0.1);
- }
- }
- }
- :deep(blockquote) {
- border-left: 3px solid rgba(127, 127, 127, 0.4);
- padding-left: 0.75em;
- margin: 0.5em 0;
- color: rgba(127, 127, 127, 0.8);
- }
- :deep(table) {
- border-collapse: collapse;
- margin: 0.5em 0;
- width: 100%;
- font-size: 0.9em;
- }
- :deep(th),
- :deep(td) {
- border: 1px solid rgba(127, 127, 127, 0.3);
- padding: 0.3em 0.6em;
- text-align: left;
- }
- :deep(th) {
- background: rgba(127, 127, 127, 0.1);
- font-weight: 600;
- }
- :deep(hr) {
- border: none;
- border-top: 1px solid rgba(127, 127, 127, 0.3);
- margin: 0.75em 0;
- }
- :deep(a) {
- color: #3b82f6;
- text-decoration: underline;
- }
- :deep(img) {
- max-width: 100%;
- border-radius: 4px;
- }
- :deep(> :first-child) {
- margin-top: 0;
- }
- :deep(> :last-child) {
- margin-bottom: 0;
- }
- }
- :global(.dark) .markdown-body {
- :deep(pre) {
- background: rgba(255, 255, 255, 0.06);
- }
- :deep(a) {
- color: #60a5fa;
- }
- }
- </style>
|