mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
671 lines
25 KiB
Vue
671 lines
25 KiB
Vue
<script setup lang="ts">
|
||
import { computed, ref, watch, nextTick } from 'vue'
|
||
import { useI18n } from 'vue-i18n'
|
||
import dayjs from 'dayjs'
|
||
import MarkdownIt from 'markdown-it'
|
||
import type { ContentBlock, ToolBlockContent } from '@/composables/useAIChat'
|
||
import CaptureButton from '@/components/common/CaptureButton.vue'
|
||
import ErrorBlock from './ErrorBlock.vue'
|
||
import ChartBlockRenderer from './ChartBlockRenderer.vue'
|
||
import { useToast } from '@/composables/useToast'
|
||
|
||
const { t, te, locale } = useI18n()
|
||
const toast = useToast()
|
||
|
||
// Props
|
||
const props = defineProps<{
|
||
messageId?: string
|
||
role: 'user' | 'assistant' | 'summary'
|
||
content: string
|
||
timestamp: number
|
||
isStreaming?: boolean
|
||
/** AI 消息的混合内容块(按时序排列的文本和工具调用) */
|
||
contentBlocks?: ContentBlock[]
|
||
/** 是否显示截屏按钮(仅 AI 回复) */
|
||
showCaptureButton?: boolean
|
||
editable?: boolean
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
edit: [payload: { messageId: string; content: string; overwriteSubsequent?: boolean }]
|
||
fork: [messageId: string]
|
||
}>()
|
||
|
||
// 格式化时间
|
||
const formattedTime = computed(() => {
|
||
return dayjs(props.timestamp).format('HH:mm')
|
||
})
|
||
|
||
// 是否是用户消息
|
||
const isUser = computed(() => props.role === 'user')
|
||
const isSummary = computed(() => props.role === 'summary')
|
||
const isEditing = ref(false)
|
||
const editContent = ref(props.content)
|
||
const editTextareaRef = ref<HTMLTextAreaElement | null>(null)
|
||
const canEdit = computed(() => isUser.value && props.editable && !props.isStreaming && !!props.messageId)
|
||
const canFork = computed(() => !isUser.value && !isSummary.value && !props.isStreaming && !!props.messageId)
|
||
const overwriteSubsequent = ref(false)
|
||
|
||
// 创建 markdown-it 实例
|
||
const md = new MarkdownIt({
|
||
html: false, // 禁用 HTML 标签
|
||
breaks: true, // 将换行转为 <br>
|
||
linkify: true, // 自动将 URL 转为链接
|
||
typographer: true, // 启用排版优化
|
||
})
|
||
|
||
md.renderer.rules.link_open = (tokens, idx, options, _env, self) => {
|
||
tokens[idx].attrSet('target', '_blank')
|
||
tokens[idx].attrSet('rel', 'noopener noreferrer')
|
||
return self.renderToken(tokens, idx, options)
|
||
}
|
||
|
||
// 渲染 Markdown 文本
|
||
function renderMarkdown(text: string): string {
|
||
if (!text) return ''
|
||
return md.render(text)
|
||
}
|
||
|
||
// 思考标签名称映射
|
||
function getThinkLabel(tag: string): string {
|
||
const normalized = tag?.toLowerCase() || 'think'
|
||
if (normalized === 'analysis') return t('ai.chat.message.think.labels.analysis')
|
||
if (normalized === 'reasoning') return t('ai.chat.message.think.labels.reasoning')
|
||
if (normalized === 'reflection') return t('ai.chat.message.think.labels.reflection')
|
||
if (normalized === 'think' || normalized === 'thought' || normalized === 'thinking') {
|
||
return t('ai.chat.message.think.labels.think')
|
||
}
|
||
return t('ai.chat.message.think.labels.other', { tag })
|
||
}
|
||
|
||
// 格式化思考耗时(毫秒 -> 秒)
|
||
function formatThinkDuration(durationMs?: number): string {
|
||
if (!durationMs) return ''
|
||
const seconds = (durationMs / 1000).toFixed(1)
|
||
return t('ai.chat.message.think.duration', { seconds })
|
||
}
|
||
|
||
// 渲染后的 HTML(用于用户消息或纯文本 AI 消息)
|
||
const renderedContent = computed(() => {
|
||
if (!props.content) return ''
|
||
return md.render(props.content)
|
||
})
|
||
|
||
watch(
|
||
() => props.content,
|
||
(content) => {
|
||
if (!isEditing.value) editContent.value = content
|
||
}
|
||
)
|
||
|
||
function syncEditTextareaHeight() {
|
||
const el = editTextareaRef.value
|
||
if (!el) return
|
||
el.style.height = 'auto'
|
||
const maxHeight = 384
|
||
const nextHeight = Math.min(el.scrollHeight, maxHeight)
|
||
el.style.height = `${nextHeight}px`
|
||
el.style.overflowY = el.scrollHeight > maxHeight ? 'auto' : 'hidden'
|
||
}
|
||
|
||
async function startEditing() {
|
||
if (!canEdit.value) return
|
||
editContent.value = props.content
|
||
isEditing.value = true
|
||
await nextTick()
|
||
syncEditTextareaHeight()
|
||
editTextareaRef.value?.focus()
|
||
}
|
||
|
||
function cancelEditing() {
|
||
isEditing.value = false
|
||
editContent.value = props.content
|
||
overwriteSubsequent.value = false
|
||
}
|
||
|
||
function submitEditing() {
|
||
if (!props.messageId) return
|
||
const content = editContent.value.trim()
|
||
if (!content || content === props.content.trim()) {
|
||
cancelEditing()
|
||
return
|
||
}
|
||
isEditing.value = false
|
||
emit('edit', { messageId: props.messageId, content, overwriteSubsequent: overwriteSubsequent.value })
|
||
overwriteSubsequent.value = false
|
||
}
|
||
|
||
// 过滤无内容的文本/思考块,避免显示空气泡
|
||
const visibleBlocks = computed(() => {
|
||
const blocks = props.contentBlocks || []
|
||
return blocks.filter((block) => {
|
||
if (block.type === 'text' || block.type === 'think') {
|
||
return block.text.trim().length > 0
|
||
}
|
||
return true
|
||
})
|
||
})
|
||
|
||
// 是否使用 contentBlocks 渲染(AI 消息且有内容块)
|
||
const useBlocksRendering = computed(() => {
|
||
return props.role === 'assistant' && visibleBlocks.value.length > 0
|
||
})
|
||
|
||
function getToolDisplayName(tool: ToolBlockContent): string {
|
||
return te(`ai.chat.message.tools.${tool.name}`) ? t(`ai.chat.message.tools.${tool.name}`) : tool.displayName
|
||
}
|
||
|
||
function formatToolStatusForCopy(status: ToolBlockContent['status']): string {
|
||
if (status === 'running') return 'running'
|
||
if (status === 'done') return 'done'
|
||
return 'error'
|
||
}
|
||
|
||
function getPlanStatusLabel(status: 'created' | 'executing' | 'done' | 'skipped'): string {
|
||
return t(`ai.chat.message.plan.status.${status}`)
|
||
}
|
||
|
||
function formatPlanTools(tools: string[]): string {
|
||
if (tools.length === 0) return t('ai.chat.message.plan.noTools')
|
||
return tools.join(', ')
|
||
}
|
||
|
||
// 格式化时间参数显示
|
||
function formatTimeParams(params: Record<string, unknown>): string {
|
||
// 优先使用 start_time/end_time
|
||
if (params.start_time || params.end_time) {
|
||
const start = params.start_time ? String(params.start_time) : ''
|
||
const end = params.end_time ? String(params.end_time) : ''
|
||
if (start && end) {
|
||
return `${start} ~ ${end}`
|
||
}
|
||
return start || end
|
||
}
|
||
|
||
// 使用 year/month/day/hour 组合
|
||
if (params.year) {
|
||
if (locale.value.startsWith('zh')) {
|
||
let result = `${params.year}年`
|
||
if (params.month) {
|
||
result += `${params.month}月`
|
||
if (params.day) {
|
||
result += `${params.day}日`
|
||
if (params.hour !== undefined) {
|
||
result += ` ${params.hour}点`
|
||
}
|
||
}
|
||
}
|
||
return result
|
||
} else {
|
||
// English format
|
||
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
||
let result = ''
|
||
if (params.month) {
|
||
result = monthNames[(params.month as number) - 1] || String(params.month)
|
||
if (params.day) {
|
||
result += ` ${params.day}`
|
||
if (params.hour !== undefined) {
|
||
const hour = params.hour as number
|
||
const suffix = hour >= 12 ? 'pm' : 'am'
|
||
const hour12 = hour % 12 || 12
|
||
result += `, ${hour12}${suffix}`
|
||
}
|
||
}
|
||
result += `, ${params.year}`
|
||
} else {
|
||
result = String(params.year)
|
||
}
|
||
return result
|
||
}
|
||
}
|
||
|
||
return ''
|
||
}
|
||
|
||
// 格式化工具参数显示
|
||
function formatToolParams(tool: ToolBlockContent): string {
|
||
if (!tool.params) return ''
|
||
|
||
const name = tool.name
|
||
const params = tool.params
|
||
|
||
if (name === 'search_messages') {
|
||
const keywords = params.keywords as string[] | undefined
|
||
const parts: string[] = []
|
||
|
||
if (keywords && keywords.length > 0) {
|
||
parts.push(`${t('ai.chat.message.toolParams.keywords')}: ${keywords.join(', ')}`)
|
||
}
|
||
|
||
const timeStr = formatTimeParams(params)
|
||
if (timeStr) {
|
||
parts.push(`${t('ai.chat.message.toolParams.time')}: ${timeStr}`)
|
||
}
|
||
|
||
return parts.join(' | ')
|
||
}
|
||
|
||
if (name === 'get_recent_messages') {
|
||
const parts: string[] = []
|
||
parts.push(t('ai.chat.message.toolParams.getMessages', { count: params.limit || 100 }))
|
||
|
||
const timeStr = formatTimeParams(params)
|
||
if (timeStr) {
|
||
parts.push(timeStr)
|
||
}
|
||
|
||
return parts.join(' | ')
|
||
}
|
||
|
||
if (name === 'get_conversation_between') {
|
||
const parts: string[] = []
|
||
|
||
const timeStr = formatTimeParams(params)
|
||
if (timeStr) {
|
||
parts.push(`${t('ai.chat.message.toolParams.time')}: ${timeStr}`)
|
||
}
|
||
|
||
if (params.limit) {
|
||
parts.push(t('ai.chat.message.toolParams.limit', { count: params.limit }))
|
||
}
|
||
|
||
return parts.join(' | ')
|
||
}
|
||
|
||
if (name === 'get_message_context') {
|
||
const ids = params.message_ids as number[] | undefined
|
||
const size = params.context_size || 20
|
||
if (ids && ids.length > 0) {
|
||
return t('ai.chat.message.toolParams.contextWithMessages', { msgCount: ids.length, contextSize: size })
|
||
}
|
||
return t('ai.chat.message.toolParams.context', { size })
|
||
}
|
||
|
||
if (name === 'get_member_stats') {
|
||
return t('ai.chat.message.toolParams.topMembers', { count: params.top_n || 10 })
|
||
}
|
||
|
||
if (name === 'get_time_stats') {
|
||
const typeKey = params.type as string
|
||
return t(`ai.chat.message.toolParams.timeStats.${typeKey}`) || String(params.type)
|
||
}
|
||
|
||
if (name === 'get_members') {
|
||
if (params.search) {
|
||
return `${t('ai.chat.message.toolParams.search')}: ${params.search}`
|
||
}
|
||
return t('ai.chat.message.toolParams.getMemberList')
|
||
}
|
||
|
||
if (name === 'get_member_name_history') {
|
||
return `${t('ai.chat.message.toolParams.memberId')}: ${params.member_id}`
|
||
}
|
||
|
||
// 通用兜底方案:展示最多3个非空参数
|
||
const genericParts: string[] = []
|
||
for (const [key, value] of Object.entries(params)) {
|
||
if (value === undefined || value === null || value === '') continue
|
||
const strVal = typeof value === 'object' ? JSON.stringify(value) : String(value)
|
||
const displayVal = strVal.length > 30 ? strVal.substring(0, 30) + '...' : strVal
|
||
genericParts.push(`${key}: ${displayVal}`)
|
||
if (genericParts.length >= 3) {
|
||
genericParts.push('...')
|
||
break
|
||
}
|
||
}
|
||
|
||
return genericParts.join(' | ')
|
||
}
|
||
|
||
const copyMarkdownText = computed(() => {
|
||
if (props.content.trim()) return props.content
|
||
if (!useBlocksRendering.value) return ''
|
||
|
||
const lines = visibleBlocks.value
|
||
.map((block) => {
|
||
if (block.type === 'text') {
|
||
return block.text
|
||
}
|
||
|
||
if (block.type === 'think') {
|
||
const thinkTitle = getThinkLabel(block.tag)
|
||
const thinkBody = block.text
|
||
.split('\n')
|
||
.map((line) => `> ${line}`)
|
||
.join('\n')
|
||
return `> ${thinkTitle}\n>\n${thinkBody}`
|
||
}
|
||
|
||
if (block.type === 'skill') {
|
||
return `> ${t('ai.skill.active.label', { name: block.skillName })}`
|
||
}
|
||
|
||
if (block.type === 'chart') {
|
||
return `> Chart: ${block.chart.spec.title}`
|
||
}
|
||
|
||
if (block.type === 'plan') {
|
||
const steps = block.plan.steps
|
||
.map(
|
||
(step, index) =>
|
||
`${index + 1}. ${step.goal}\n - ${t('ai.chat.message.plan.evidenceNeeded')}: ${step.evidenceNeeded}\n - ${t('ai.chat.message.plan.suggestedTools')}: ${formatPlanTools(step.suggestedTools)}`
|
||
)
|
||
.join('\n')
|
||
const criteria = block.plan.successCriteria.map((item) => `- ${item}`).join('\n')
|
||
return `> ${t('ai.chat.message.plan.label')}: ${block.plan.title} (${getPlanStatusLabel(block.status)})\n\n${steps}\n\n${t('ai.chat.message.plan.successCriteria')}:\n${criteria}`
|
||
}
|
||
|
||
if (block.type === 'tool') {
|
||
const toolName = getToolDisplayName(block.tool)
|
||
const toolParams = formatToolParams(block.tool)
|
||
const paramsSuffix = toolParams ? ` (${toolParams})` : ''
|
||
return `- [${formatToolStatusForCopy(block.tool.status)}] ${toolName}${paramsSuffix}`
|
||
}
|
||
|
||
return ''
|
||
})
|
||
.filter((line) => line.trim().length > 0)
|
||
|
||
return lines.join('\n\n')
|
||
})
|
||
|
||
const canCopyMarkdown = computed(() => !props.isStreaming && copyMarkdownText.value.trim().length > 0)
|
||
|
||
async function handleCopyMarkdown() {
|
||
if (!canCopyMarkdown.value) return
|
||
|
||
try {
|
||
await navigator.clipboard.writeText(copyMarkdownText.value)
|
||
toast.success(t('ai.chat.message.copy.success'))
|
||
} catch (error) {
|
||
toast.fail(t('ai.chat.message.copy.failed'), { description: String(error) })
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div
|
||
class="flex items-start gap-3"
|
||
:class="[isUser && !isEditing ? 'flex-row-reverse' : '', isSummary ? 'justify-center' : '']"
|
||
>
|
||
<!-- 消息内容 -->
|
||
<div :class="[isSummary || isEditing ? 'w-full min-w-0' : 'max-w-[85%] min-w-0']">
|
||
<!-- System 消息:可折叠的上下文总结 -->
|
||
<template v-if="isSummary">
|
||
<details
|
||
class="w-full rounded-lg border border-gray-200 bg-gray-50/80 dark:border-gray-700/50 dark:bg-gray-800/40"
|
||
>
|
||
<summary
|
||
class="flex cursor-pointer select-none items-center gap-2 px-3 py-2 text-xs font-medium text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||
>
|
||
<UIcon name="i-heroicons-arrow-path" class="h-3.5 w-3.5 shrink-0" />
|
||
<span>{{ t('ai.chat.message.summary.label') }}</span>
|
||
<UIcon name="i-heroicons-chevron-right" class="ml-auto h-3 w-3 transition-transform [[open]>&]:rotate-90" />
|
||
</summary>
|
||
<div class="border-t border-gray-200/60 px-3 py-2.5 dark:border-gray-700/40">
|
||
<div
|
||
class="prose prose-sm dark:prose-invert max-w-none text-xs leading-relaxed text-gray-600 dark:text-gray-300"
|
||
v-html="renderedContent"
|
||
/>
|
||
<p class="mt-2 text-[11px] text-gray-400 dark:text-gray-500">
|
||
{{ t('ai.chat.message.summary.info') }}
|
||
</p>
|
||
</div>
|
||
</details>
|
||
</template>
|
||
|
||
<!-- 用户消息:简单气泡 -->
|
||
<template v-else-if="isUser">
|
||
<div
|
||
v-if="isEditing"
|
||
class="rounded-2xl bg-primary-50 p-4 text-gray-900 dark:bg-primary-500/50 dark:text-gray-100"
|
||
>
|
||
<textarea
|
||
ref="editTextareaRef"
|
||
v-model="editContent"
|
||
rows="2"
|
||
class="w-full resize-none rounded-xl border border-primary-200 bg-white/90 px-4 py-3 text-sm leading-relaxed outline-none focus:border-primary-400 dark:border-primary-400/40 dark:bg-gray-900/70"
|
||
@input="syncEditTextareaHeight"
|
||
@keydown.esc.prevent="cancelEditing"
|
||
@keydown.ctrl.enter.prevent="submitEditing"
|
||
@keydown.meta.enter.prevent="submitEditing"
|
||
/>
|
||
<div class="mt-2 flex items-center justify-between">
|
||
<label class="flex cursor-pointer items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||
<input
|
||
v-model="overwriteSubsequent"
|
||
type="checkbox"
|
||
class="h-3.5 w-3.5 rounded border-gray-300 text-primary-500 focus:ring-primary-500 dark:border-gray-600"
|
||
/>
|
||
{{ t('ai.chat.message.edit.overwriteSubsequent') }}
|
||
</label>
|
||
<div class="flex gap-2">
|
||
<UButton size="xs" variant="ghost" color="gray" @click="cancelEditing">
|
||
{{ t('common.cancel') }}
|
||
</UButton>
|
||
<UButton size="xs" color="primary" @click="submitEditing">
|
||
{{ t('ai.chat.message.edit.submit') }}
|
||
</UButton>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else class="rounded-3xl bg-primary-50 px-5 py-3 text-gray-900 dark:bg-primary-500/50 dark:text-gray-100">
|
||
<div class="prose prose-sm dark:prose-invert max-w-none leading-relaxed" v-html="renderedContent" />
|
||
</div>
|
||
</template>
|
||
|
||
<!-- AI 消息:混合内容块布局 -->
|
||
<template v-else-if="useBlocksRendering">
|
||
<div class="space-y-2">
|
||
<template v-for="(block, idx) in visibleBlocks" :key="idx">
|
||
<!-- 文本块 -->
|
||
<div v-if="block.type === 'text'" class="py-1 text-gray-900 dark:text-gray-100">
|
||
<div
|
||
class="prose prose-sm dark:prose-invert max-w-none leading-relaxed"
|
||
v-html="renderMarkdown(block.text)"
|
||
/>
|
||
<!-- 流式输出光标(只在最后一个文本块显示) -->
|
||
<span
|
||
v-if="isStreaming && idx === visibleBlocks.length - 1"
|
||
class="ml-1 inline-block h-4 w-1.5 animate-pulse rounded-sm bg-gray-800 dark:bg-gray-200"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 思考块(默认折叠) -->
|
||
<details
|
||
v-else-if="block.type === 'think'"
|
||
class="mb-2 border-l-2 border-gray-200 pl-4 py-1 text-sm text-gray-500 dark:border-gray-700 dark:text-gray-400"
|
||
>
|
||
<summary
|
||
class="cursor-pointer select-none text-xs font-medium transition-colors hover:text-gray-700 dark:hover:text-gray-300"
|
||
>
|
||
{{ getThinkLabel(block.tag) }}
|
||
<span v-if="block.durationMs" class="ml-2 text-xs text-gray-400 dark:text-gray-500">
|
||
{{ formatThinkDuration(block.durationMs) }}
|
||
</span>
|
||
<span
|
||
v-if="isStreaming && idx === visibleBlocks.length - 1"
|
||
class="ml-2 inline-flex items-center gap-1 text-[11px] text-gray-400 dark:text-gray-500"
|
||
>
|
||
<span>{{ t('ai.chat.message.think.loading') }}</span>
|
||
<span class="flex gap-0.5">
|
||
<span class="h-1 w-1 animate-bounce rounded-full bg-gray-400 [animation-delay:0ms]" />
|
||
<span class="h-1 w-1 animate-bounce rounded-full bg-gray-400 [animation-delay:150ms]" />
|
||
<span class="h-1 w-1 animate-bounce rounded-full bg-gray-400 [animation-delay:300ms]" />
|
||
</span>
|
||
</span>
|
||
</summary>
|
||
<div class="mt-2 prose prose-sm dark:prose-invert max-w-none leading-relaxed text-xs">
|
||
<div v-html="renderMarkdown(block.text)" />
|
||
</div>
|
||
</details>
|
||
|
||
<!-- 技能块 -->
|
||
<div
|
||
v-else-if="block.type === 'skill'"
|
||
class="inline-flex items-center gap-1.5 rounded-lg bg-gray-50 px-3 py-1.5 text-xs font-medium text-gray-600 dark:bg-gray-800/50 dark:text-gray-400"
|
||
>
|
||
<UIcon name="i-heroicons-bolt" class="h-3.5 w-3.5" />
|
||
<span>{{ t('ai.skill.active.label', { name: block.skillName }) }}</span>
|
||
</div>
|
||
|
||
<!-- 计划块 -->
|
||
<details
|
||
v-else-if="block.type === 'plan'"
|
||
class="mb-2 rounded-lg border border-blue-100 bg-blue-50/60 text-sm text-gray-700 dark:border-blue-900/40 dark:bg-blue-950/20 dark:text-gray-300"
|
||
>
|
||
<summary
|
||
class="flex cursor-pointer select-none items-center gap-2 px-3 py-2 text-xs font-medium text-blue-700 transition-colors hover:text-blue-800 dark:text-blue-300 dark:hover:text-blue-200"
|
||
>
|
||
<UIcon name="i-heroicons-clipboard-document-list" class="h-3.5 w-3.5 shrink-0" />
|
||
<span class="min-w-0 truncate">{{ t('ai.chat.message.plan.label') }} · {{ block.plan.title }}</span>
|
||
<span
|
||
class="ml-auto shrink-0 rounded-full bg-white/70 px-2 py-0.5 text-[11px] text-blue-600 dark:bg-blue-950/60 dark:text-blue-300"
|
||
>
|
||
{{ getPlanStatusLabel(block.status) }}
|
||
</span>
|
||
</summary>
|
||
<div class="border-t border-blue-100/70 px-3 py-2.5 dark:border-blue-900/40">
|
||
<ol class="space-y-2">
|
||
<li v-for="(step, stepIndex) in block.plan.steps" :key="stepIndex" class="text-xs leading-relaxed">
|
||
<div class="font-medium text-gray-800 dark:text-gray-200">{{ stepIndex + 1 }}. {{ step.goal }}</div>
|
||
<div class="mt-1 text-gray-500 dark:text-gray-400">
|
||
{{ t('ai.chat.message.plan.evidenceNeeded') }}: {{ step.evidenceNeeded }}
|
||
</div>
|
||
<div class="mt-0.5 text-gray-500 dark:text-gray-400">
|
||
{{ t('ai.chat.message.plan.suggestedTools') }}: {{ formatPlanTools(step.suggestedTools) }}
|
||
</div>
|
||
</li>
|
||
</ol>
|
||
<div class="mt-2 border-t border-blue-100/70 pt-2 dark:border-blue-900/40">
|
||
<div class="text-[11px] font-medium text-gray-500 dark:text-gray-400">
|
||
{{ t('ai.chat.message.plan.successCriteria') }}
|
||
</div>
|
||
<ul class="mt-1 list-disc space-y-0.5 pl-4 text-xs leading-relaxed text-gray-600 dark:text-gray-300">
|
||
<li v-for="(criterion, criterionIndex) in block.plan.successCriteria" :key="criterionIndex">
|
||
{{ criterion }}
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
|
||
<!-- 图表块 -->
|
||
<ChartBlockRenderer v-else-if="block.type === 'chart'" :chart="block.chart" />
|
||
|
||
<!-- 工具块 -->
|
||
<div
|
||
v-else-if="block.type === 'tool'"
|
||
class="flex w-fit items-center gap-1.5 rounded-lg px-2.5 py-1 text-xs transition-colors"
|
||
:class="[
|
||
block.tool.status === 'running'
|
||
? 'bg-gray-100 text-gray-600 dark:bg-gray-800/50 dark:text-gray-400'
|
||
: block.tool.status === 'done'
|
||
? 'bg-gray-50 text-gray-500 dark:bg-gray-800/30 dark:text-gray-400/80'
|
||
: 'bg-red-50 text-red-600 dark:bg-red-900/20 dark:text-red-400',
|
||
]"
|
||
>
|
||
<!-- 状态图标 -->
|
||
<UIcon
|
||
:name="
|
||
block.tool.status === 'running'
|
||
? 'i-heroicons-arrow-path'
|
||
: block.tool.status === 'done'
|
||
? 'i-heroicons-wrench-screwdriver'
|
||
: 'i-heroicons-exclamation-circle'
|
||
"
|
||
class="h-3.5 w-3.5 shrink-0"
|
||
:class="[block.tool.status === 'running' ? 'animate-spin' : '']"
|
||
/>
|
||
<!-- 工具信息 -->
|
||
<div class="flex min-w-0 items-baseline gap-1.5 font-medium">
|
||
<span>{{ getToolDisplayName(block.tool) }}</span>
|
||
<span
|
||
v-if="formatToolParams(block.tool)"
|
||
class="truncate font-normal text-[11px] opacity-75 max-w-[200px] sm:max-w-[300px]"
|
||
>
|
||
{{ formatToolParams(block.tool) }}
|
||
</span>
|
||
<span
|
||
v-if="block.tool.status === 'done' && block.tool.durationMs"
|
||
class="shrink-0 font-normal text-[11px] opacity-75"
|
||
>
|
||
· {{ formatThinkDuration(block.tool.durationMs) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 错误块 -->
|
||
<ErrorBlock v-else-if="block.type === 'error'" :error="block.error" />
|
||
</template>
|
||
|
||
<!-- 流式处理中指示器(当最后一个块是已完成的工具块时显示) -->
|
||
<div
|
||
v-if="isStreaming && visibleBlocks.length > 0 && visibleBlocks[visibleBlocks.length - 1].type === 'tool'"
|
||
class="flex items-center gap-2 px-1 py-2 text-sm text-gray-500 dark:text-gray-400"
|
||
>
|
||
<span class="flex gap-1">
|
||
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-gray-400 [animation-delay:0ms]" />
|
||
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-gray-400 [animation-delay:150ms]" />
|
||
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-gray-400 [animation-delay:300ms]" />
|
||
</span>
|
||
<span>{{ t('ai.chat.message.generating') }}</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- AI 消息:传统纯文本渲染(向后兼容) -->
|
||
<template v-else>
|
||
<div class="py-1 text-gray-900 dark:text-gray-100">
|
||
<div class="prose prose-sm dark:prose-invert max-w-none leading-relaxed" v-html="renderedContent" />
|
||
<span
|
||
v-if="isStreaming"
|
||
class="ml-1 inline-block h-4 w-1.5 animate-pulse rounded-sm bg-gray-800 dark:bg-gray-200"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 时间戳 + 操作按钮(summary 消息和流式输出中不显示) -->
|
||
<div
|
||
v-if="!isSummary && !isStreaming"
|
||
class="mt-1 flex items-center gap-2 px-1"
|
||
:class="[isUser ? 'flex-row-reverse' : '']"
|
||
>
|
||
<span class="text-xs text-gray-400">{{ formattedTime }}</span>
|
||
<UTooltip :text="t('ai.chat.message.copy.tooltip')" class="no-capture">
|
||
<UButton
|
||
icon="i-heroicons-document-duplicate"
|
||
variant="ghost"
|
||
color="primary"
|
||
size="xs"
|
||
:disabled="!canCopyMarkdown"
|
||
@click="handleCopyMarkdown"
|
||
/>
|
||
</UTooltip>
|
||
<UTooltip v-if="canEdit" :text="t('ai.chat.message.edit.tooltip')" class="no-capture">
|
||
<UButton icon="i-heroicons-pencil-square" variant="ghost" color="primary" size="xs" @click="startEditing" />
|
||
</UTooltip>
|
||
<UTooltip v-if="canFork" :text="t('ai.chat.message.fork.tooltip')" class="no-capture">
|
||
<UButton
|
||
icon="i-heroicons-arrow-top-right-on-square"
|
||
variant="ghost"
|
||
color="primary"
|
||
size="xs"
|
||
@click="emit('fork', messageId!)"
|
||
/>
|
||
</UTooltip>
|
||
<!-- 截屏按钮(仅 AI 回复显示) -->
|
||
<CaptureButton
|
||
v-if="showCaptureButton && !isUser && !isStreaming"
|
||
size="xs"
|
||
type="element"
|
||
target-selector=".qa-pair"
|
||
markdown-fix
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Markdown 样式已提取到全局 src/assets/styles/markdown.css -->
|