Files
ChatLab/src/components/AIChat/chat/ChatMessage.vue
T

671 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 -->