|
@@ -5,7 +5,7 @@ import { marked, Renderer } from 'marked'
|
|
|
import DOMPurify from 'dompurify'
|
|
import DOMPurify from 'dompurify'
|
|
|
import { useClipboard } from '@vueuse/core'
|
|
import { useClipboard } from '@vueuse/core'
|
|
|
import { downloadAiFile } from '@/service/aiChat'
|
|
import { downloadAiFile } from '@/service/aiChat'
|
|
|
-import type { ChatMessage } from './interface'
|
|
|
|
|
|
|
+import type { ChatMessage, ToolCallInfo } from './interface'
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
const props = defineProps<{
|
|
|
message: ChatMessage
|
|
message: ChatMessage
|
|
@@ -17,6 +17,11 @@ const emit = defineEmits<{
|
|
|
|
|
|
|
|
const isUser = computed(() => props.message.role === 'user')
|
|
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,为代码块添加复制按钮
|
|
// 自定义 marked renderer,为代码块添加复制按钮
|
|
|
const renderer = new Renderer()
|
|
const renderer = new Renderer()
|
|
|
renderer.code = ({ text, lang }: { text: string; lang?: string }) => {
|
|
renderer.code = ({ text, lang }: { text: string; lang?: string }) => {
|
|
@@ -29,16 +34,34 @@ renderer.code = ({ text, lang }: { text: string; lang?: string }) => {
|
|
|
return `<div class="code-block-wrapper">${langLabel}<button class="copy-btn" data-code="${escaped}">复制</button><pre><code>${text}</code></pre></div>`
|
|
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(() => {
|
|
const formattedContent = computed(() => {
|
|
|
if (isUser.value) {
|
|
if (isUser.value) {
|
|
|
// 用户消息保持纯文本显示
|
|
// 用户消息保持纯文本显示
|
|
|
return props.message.content.replace(/\n/g, '<br/>')
|
|
return props.message.content.replace(/\n/g, '<br/>')
|
|
|
}
|
|
}
|
|
|
// AI 回复使用 Markdown 渲染
|
|
// AI 回复使用 Markdown 渲染
|
|
|
- const raw = marked.parse(props.message.content, { async: false, renderer }) as string
|
|
|
|
|
- return DOMPurify.sanitize(raw, { ADD_ATTR: ['data-code'] })
|
|
|
|
|
|
|
+ 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 { copy } = useClipboard()
|
|
|
const copiedIndex = ref<string | null>(null)
|
|
const copiedIndex = ref<string | null>(null)
|
|
@@ -88,19 +111,20 @@ function formatSize(bytes: number): string {
|
|
|
? 'bg-blue-500 text-white'
|
|
? 'bg-blue-500 text-white'
|
|
|
: 'bg-neutral-100 text-neutral-800 dark:bg-neutral-700 dark:text-neutral-200'"
|
|
: 'bg-neutral-100 text-neutral-800 dark:bg-neutral-700 dark:text-neutral-200'"
|
|
|
>
|
|
>
|
|
|
- <!-- 工具调用标签 -->
|
|
|
|
|
- <div v-if="message.toolCalls?.length" class="mb-2 flex flex-wrap gap-1">
|
|
|
|
|
|
|
+ <!-- 工具调用标签(旧消息回退:无 segments 时集中展示在顶部) -->
|
|
|
|
|
+ <div v-if="!useSegments && message.toolCalls?.length" class="mb-2 flex flex-wrap gap-1">
|
|
|
<NTag
|
|
<NTag
|
|
|
v-for="tc in message.toolCalls"
|
|
v-for="tc in message.toolCalls"
|
|
|
:key="tc.id"
|
|
:key="tc.id"
|
|
|
size="small"
|
|
size="small"
|
|
|
:title="tc.title"
|
|
:title="tc.title"
|
|
|
- :type="tc.status === 'running' ? 'warning' : tc.status === 'completed' ? 'success' : 'error'"
|
|
|
|
|
|
|
+ :type="toolTagType(tc.status)"
|
|
|
>
|
|
>
|
|
|
<template #icon>
|
|
<template #icon>
|
|
|
<NSpin v-if="tc.status === 'running'" :size="10" />
|
|
<NSpin v-if="tc.status === 'running'" :size="10" />
|
|
|
</template>
|
|
</template>
|
|
|
{{ tc.description }}
|
|
{{ tc.description }}
|
|
|
|
|
+ <span class="ml-1 opacity-70">· {{ toolStatusLabel(tc.status) }}</span>
|
|
|
</NTag>
|
|
</NTag>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
@@ -152,8 +176,33 @@ function formatSize(bytes: number): string {
|
|
|
</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>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <!-- 旧消息回退:整体文本渲染 -->
|
|
|
<div
|
|
<div
|
|
|
- v-if="message.content"
|
|
|
|
|
|
|
+ v-else-if="message.content"
|
|
|
:class="{ 'markdown-body': !isUser }"
|
|
:class="{ 'markdown-body': !isUser }"
|
|
|
v-html="formattedContent"
|
|
v-html="formattedContent"
|
|
|
@click="handleContentClick"
|
|
@click="handleContentClick"
|