feat: AI对话支持复制

This commit is contained in:
digua
2026-04-06 21:38:20 +08:00
committed by digua
parent 72c0d40f4d
commit e74af1a8b6
6 changed files with 105 additions and 7 deletions
+84 -7
View File
@@ -1,12 +1,14 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useToast } from '@nuxt/ui/runtime/composables/useToast.js'
import dayjs from 'dayjs'
import MarkdownIt from 'markdown-it'
import type { ContentBlock, ToolBlockContent } from '@/composables/useAIChat'
import CaptureButton from '@/components/common/CaptureButton.vue'
const { t, te, locale } = useI18n()
const toast = useToast()
// Props
const props = defineProps<{
@@ -83,6 +85,16 @@ 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 formatTimeParams(params: Record<string, unknown>): string {
// 优先使用 start_time/end_time
@@ -229,6 +241,67 @@ function formatToolParams(tool: ToolBlockContent): string {
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 === '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.add({
title: t('ai.chat.message.copy.success'),
color: 'primary',
icon: 'i-heroicons-clipboard-document-check',
duration: 2000,
})
} catch (error) {
toast.add({
title: t('ai.chat.message.copy.failed'),
description: String(error),
color: 'error',
icon: 'i-heroicons-x-circle',
duration: 3000,
})
}
}
</script>
<template>
@@ -323,13 +396,7 @@ function formatToolParams(tool: ToolBlockContent): string {
/>
<!-- 工具信息 -->
<div class="flex min-w-0 items-baseline gap-1.5 font-medium">
<span>
{{
te(`ai.chat.message.tools.${block.tool.name}`)
? t(`ai.chat.message.tools.${block.tool.name}`)
: block.tool.displayName
}}
</span>
<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]"
@@ -375,6 +442,16 @@ function formatToolParams(tool: ToolBlockContent): string {
<!-- 时间戳 + 操作按钮 -->
<div 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>
<!-- 截屏按钮 AI 回复显示 -->
<CaptureButton
v-if="showCaptureButton && !isUser && !isStreaming"
+5
View File
@@ -46,6 +46,11 @@
"message": {
"userAvatar": "User Avatar",
"calling": "Calling",
"copy": {
"tooltip": "Copy Markdown",
"success": "Markdown copied to clipboard",
"failed": "Failed to copy Markdown"
},
"tools": {
"get_chat_overview": "Get Chat Overview",
"search_messages": "Search Messages",
+5
View File
@@ -46,6 +46,11 @@
"message": {
"userAvatar": "ユーザーアバター",
"calling": "実行中",
"copy": {
"tooltip": "Markdown をコピー",
"success": "Markdown をクリップボードにコピーしました",
"failed": "Markdown のコピーに失敗しました"
},
"tools": {
"get_chat_overview": "チャット概要を取得",
"search_messages": "チャット履歴を検索",
+5
View File
@@ -46,6 +46,11 @@
"message": {
"userAvatar": "用户头像",
"calling": "调用",
"copy": {
"tooltip": "复制 Markdown",
"success": "Markdown 已复制到剪贴板",
"failed": "复制 Markdown 失败"
},
"tools": {
"get_chat_overview": "获取聊天概览",
"search_messages": "搜索聊天记录",
+5
View File
@@ -46,6 +46,11 @@
"message": {
"userAvatar": "使用者頭像",
"calling": "執行",
"copy": {
"tooltip": "複製 Markdown",
"success": "Markdown 已複製到剪貼簿",
"failed": "複製 Markdown 失敗"
},
"tools": {
"get_chat_overview": "取得聊天概覽",
"search_messages": "搜尋聊天紀錄",
+1
View File
@@ -29,6 +29,7 @@ export interface ToolBlockContent {
displayName: string
status: 'running' | 'done' | 'error'
params?: Record<string, unknown>
durationMs?: number
}
export interface MentionedMemberContext {