Files
AstrBot/dashboard/src/components/chat/MessageList.vue
Soulter 81309bc908 perf: enhance reply functionality to support selected text quoting (#4387)
* feat(chat): enhance reply functionality to support selected text quoting

* perf: improve ui

* feat(chat): add label for tools used in tool calls and update translations

* feat(chat): simplify reply handling by removing text truncation logic
2026-01-09 18:04:43 +08:00

1639 lines
56 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.
<template>
<div class="messages-container" ref="messageContainer" :class="{ 'is-dark': isDark }">
<!-- 加载指示器 -->
<div v-if="isLoadingMessages" class="loading-overlay" :class="{ 'is-dark': isDark }">
<v-progress-circular indeterminate size="48" width="4" color="primary"></v-progress-circular>
</div>
<!-- 聊天消息列表 -->
<div class="message-list" :class="{ 'loading-blur': isLoadingMessages }" @mouseup="handleTextSelection">
<div class="message-item fade-in" v-for="(msg, index) in messages" :key="index">
<!-- 用户消息 -->
<div v-if="msg.content.type == 'user'" class="user-message">
<div class="message-bubble user-bubble" :class="{ 'has-audio': hasAudio(msg.content.message) }"
:style="{ backgroundColor: isDark ? '#2d2e30' : '#e7ebf4' }">
<!-- 遍历 message parts -->
<template v-for="(part, partIndex) in msg.content.message" :key="partIndex">
<!-- 引用消息 -->
<div v-if="part.type === 'reply'" class="reply-quote"
@click="scrollToMessage(part.message_id)">
<v-icon size="small" class="reply-quote-icon">mdi-reply</v-icon>
<span class="reply-quote-text">{{ getReplyContent(part.message_id) }}</span>
</div>
<!-- 纯文本 -->
<pre v-else-if="part.type === 'plain' && part.text"
style="font-family: inherit; white-space: pre-wrap; word-wrap: break-word;">{{ part.text }}</pre>
<!-- 图片附件 -->
<div v-else-if="part.type === 'image' && part.embedded_url" class="image-attachments">
<div class="image-attachment">
<img :src="part.embedded_url" class="attached-image"
@click="$emit('openImagePreview', part.embedded_url)" />
</div>
</div>
<!-- 音频附件 -->
<div v-else-if="part.type === 'record' && part.embedded_url" class="audio-attachment">
<audio controls class="audio-player">
<source :src="part.embedded_url" type="audio/wav">
{{ t('messages.errors.browser.audioNotSupported') }}
</audio>
</div>
<!-- 文件附件 -->
<div v-else-if="part.type === 'file' && part.embedded_file" class="file-attachments">
<div class="file-attachment">
<a v-if="part.embedded_file.url" :href="part.embedded_file.url"
:download="part.embedded_file.filename" class="file-link"
:class="{ 'is-dark': isDark }" :style="isDark ? {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderColor: 'rgba(255, 255, 255, 0.1)',
color: 'var(--v-theme-secondary)'
} : {}">
<v-icon size="small" class="file-icon"
:style="isDark ? { color: 'var(--v-theme-secondary)' } : {}">mdi-file-document-outline</v-icon>
<span class="file-name">{{ part.embedded_file.filename }}</span>
</a>
<a v-else @click="downloadFile(part.embedded_file)"
class="file-link file-link-download" :class="{ 'is-dark': isDark }" :style="isDark ? {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderColor: 'rgba(255, 255, 255, 0.1)',
color: 'var(--v-theme-secondary)'
} : {}">
<v-icon size="small" class="file-icon"
:style="isDark ? { color: 'var(--v-theme-secondary)' } : {}">mdi-file-document-outline</v-icon>
<span class="file-name">{{ part.embedded_file.filename }}</span>
<v-icon v-if="downloadingFiles.has(part.embedded_file.attachment_id)"
size="small" class="download-icon">mdi-loading mdi-spin</v-icon>
<v-icon v-else size="small" class="download-icon">mdi-download</v-icon>
</a>
</div>
</div>
</template>
</div>
</div>
<!-- Bot Messages -->
<div v-else class="bot-message">
<v-avatar class="bot-avatar" size="36">
<v-progress-circular :index="index" v-if="isStreaming && index === messages.length - 1"
indeterminate size="28" width="2"></v-progress-circular>
<v-icon v-else-if="messages[index - 1]?.content.type !== 'bot'" size="64"
color="#8fb6d2">mdi-star-four-points-small</v-icon>
</v-avatar>
<div class="bot-message-content">
<div class="message-bubble bot-bubble">
<!-- Loading state -->
<div v-if="msg.content.isLoading" class="loading-container">
<span class="loading-text">{{ tm('message.loading') }}</span>
</div>
<template v-else>
<!-- Reasoning Block (Collapsible) - 放在最前面 -->
<div v-if="msg.content.reasoning && msg.content.reasoning.trim()"
class="reasoning-container" :class="{ 'is-dark': isDark }"
:style="isDark ? { backgroundColor: 'rgba(103, 58, 183, 0.08)' } : {}">
<div class="reasoning-header" :class="{ 'is-dark': isDark }"
@click="toggleReasoning(index)">
<v-icon size="small" class="reasoning-icon">
{{ isReasoningExpanded(index) ? 'mdi-chevron-down' : 'mdi-chevron-right' }}
</v-icon>
<span class="reasoning-label">{{ tm('reasoning.thinking') }}</span>
</div>
<div v-if="isReasoningExpanded(index)" class="reasoning-content">
<MarkdownRender :content="msg.content.reasoning"
class="reasoning-text markdown-content" :typewriter="false"
:style="isDark ? { opacity: '0.85' } : {}" :is-dark="isDark" />
</div>
</div>
<!-- 遍历 message parts (保持顺序) -->
<template v-for="(part, partIndex) in msg.content.message" :key="partIndex">
<!-- Tool Calls Block -->
<div v-if="part.type === 'tool_call' && part.tool_calls && part.tool_calls.length > 0"
class="tool-calls-container">
<div class="tool-calls-label">{{ tm('actions.toolsUsed') }}</div>
<div v-for="(toolCall, tcIndex) in part.tool_calls" :key="toolCall.id"
class="tool-call-card" :class="{ 'is-dark': isDark, 'expanded': isToolCallExpanded(index, partIndex, tcIndex) }" :style="isDark ? {
backgroundColor: 'rgba(40, 60, 100, 0.4)',
borderColor: 'rgba(100, 140, 200, 0.4)'
} : {}">
<div class="tool-call-header" :class="{ 'is-dark': isDark }"
@click="toggleToolCall(index, partIndex, tcIndex)">
<v-icon size="small" class="tool-call-expand-icon">
{{ isToolCallExpanded(index, partIndex, tcIndex) ?
'mdi-chevron-down' : 'mdi-chevron-right' }}
</v-icon>
<v-icon size="small" class="tool-call-icon">mdi-wrench-outline</v-icon>
<div class="tool-call-info">
<span class="tool-call-name">{{ toolCall.name }}</span>
</div>
<span class="tool-call-status"
:class="{ 'status-running': !toolCall.finished_ts, 'status-finished': toolCall.finished_ts }">
<template v-if="toolCall.finished_ts">
<v-icon size="x-small"
class="status-icon">mdi-check-circle</v-icon>
{{ formatDuration(toolCall.finished_ts - toolCall.ts) }}
</template>
<template v-else>
<v-icon size="x-small"
class="status-icon spinning">mdi-loading</v-icon>
{{ getElapsedTime(toolCall.ts) }}
</template>
</span>
</div>
<div v-if="isToolCallExpanded(index, partIndex, tcIndex)"
class="tool-call-details" :style="isDark ? {
borderTopColor: 'rgba(100, 140, 200, 0.3)',
backgroundColor: 'rgba(30, 45, 70, 0.5)'
} : {}">
<div class="tool-call-detail-row">
<span class="detail-label">ID:</span>
<code class="detail-value"
:style="isDark ? { backgroundColor: 'transparent' } : {}">{{ toolCall.id
}}</code>
</div>
<div class="tool-call-detail-row">
<span class="detail-label">Args:</span>
<pre class="detail-value detail-json"
:style="isDark ? { backgroundColor: 'transparent' } : {}">{{
JSON.stringify(toolCall.args, null, 2) }}</pre>
</div>
<div v-if="toolCall.result" class="tool-call-detail-row">
<span class="detail-label">Result:</span>
<pre class="detail-value detail-json detail-result"
:style="isDark ? { backgroundColor: 'transparent' } : {}">{{ formatToolResult(toolCall.result) }}
</pre>
</div>
</div>
</div>
</div>
<!-- Text (Markdown) -->
<MarkdownRender v-else-if="part.type === 'plain' && part.text && part.text.trim()"
:content="part.text" :typewriter="false" class="markdown-content"
:is-dark="isDark" :monacoOptions="{ theme: isDark ? 'vs-dark' : 'vs-light' }" />
<!-- Image -->
<div v-else-if="part.type === 'image' && part.embedded_url" class="embedded-images">
<div class="embedded-image">
<img :src="part.embedded_url" class="bot-embedded-image"
@click="$emit('openImagePreview', part.embedded_url)" />
</div>
</div>
<!-- Audio -->
<div v-else-if="part.type === 'record' && part.embedded_url" class="embedded-audio">
<audio controls class="audio-player">
<source :src="part.embedded_url" type="audio/wav">
{{ t('messages.errors.browser.audioNotSupported') }}
</audio>
</div>
<!-- Files -->
<div v-else-if="part.type === 'file' && part.embedded_file" class="embedded-files">
<div class="embedded-file">
<a v-if="part.embedded_file.url" :href="part.embedded_file.url"
:download="part.embedded_file.filename" class="file-link"
:class="{ 'is-dark': isDark }" :style="isDark ? {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderColor: 'rgba(255, 255, 255, 0.1)',
color: 'var(--v-theme-secondary)'
} : {}">
<v-icon size="small" class="file-icon"
:style="isDark ? { color: 'var(--v-theme-secondary)' } : {}">mdi-file-document-outline</v-icon>
<span class="file-name">{{ part.embedded_file.filename }}</span>
</a>
<a v-else @click="downloadFile(part.embedded_file)"
class="file-link file-link-download" :class="{ 'is-dark': isDark }"
:style="isDark ? {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderColor: 'rgba(255, 255, 255, 0.1)',
color: 'var(--v-theme-secondary)'
} : {}">
<v-icon size="small" class="file-icon"
:style="isDark ? { color: 'var(--v-theme-secondary)' } : {}">mdi-file-document-outline</v-icon>
<span class="file-name">{{ part.embedded_file.filename }}</span>
<v-icon v-if="downloadingFiles.has(part.embedded_file.attachment_id)"
size="small" class="download-icon">mdi-loading mdi-spin</v-icon>
<v-icon v-else size="small" class="download-icon">mdi-download</v-icon>
</a>
</div>
</div>
</template>
</template>
</div>
<div class="message-actions" v-if="!msg.content.isLoading || index === messages.length - 1">
<span class="message-time" v-if="msg.created_at">{{ formatMessageTime(msg.created_at)
}}</span>
<!-- Agent Stats Menu -->
<v-menu v-if="msg.content.agentStats" location="bottom" open-on-hover
:close-on-content-click="false">
<template v-slot:activator="{ props }">
<v-icon v-bind="props" size="x-small"
class="stats-info-icon">mdi-information-outline</v-icon>
</template>
<v-card class="stats-menu-card" variant="elevated" elevation="3">
<v-card-text class="stats-menu-content">
<div class="stats-menu-row">
<span class="stats-menu-label">{{ tm('stats.inputTokens') }}</span>
<span class="stats-menu-value">{{
getInputTokens(msg.content.agentStats.token_usage) }}</span>
</div>
<div class="stats-menu-row">
<span class="stats-menu-label">{{ tm('stats.outputTokens') }}</span>
<span class="stats-menu-value">{{ msg.content.agentStats.token_usage.output
|| 0 }}</span>
</div>
<div class="stats-menu-row"
v-if="msg.content.agentStats.token_usage.input_cached > 0">
<span class="stats-menu-label">{{ tm('stats.cachedTokens') }}</span>
<span class="stats-menu-value">{{
msg.content.agentStats.token_usage.input_cached }}</span>
</div>
<div class="stats-menu-row"
v-if="msg.content.agentStats.time_to_first_token > 0">
<span class="stats-menu-label">{{ tm('stats.ttft') }}</span>
<span class="stats-menu-value">{{
formatTTFT(msg.content.agentStats.time_to_first_token) }}</span>
</div>
<div class="stats-menu-row">
<span class="stats-menu-label">{{ tm('stats.duration') }}</span>
<span class="stats-menu-value">{{
formatAgentDuration(msg.content.agentStats) }}</span>
</div>
</v-card-text>
</v-card>
</v-menu>
<v-btn :icon="getCopyIcon(index)" size="x-small" variant="text" class="copy-message-btn"
:class="{ 'copy-success': isCopySuccess(index) }"
@click="copyBotMessage(msg.content.message, index)" :title="t('core.common.copy')" />
<v-btn icon="mdi-reply-outline" size="x-small" variant="text" class="reply-message-btn"
@click="$emit('replyMessage', msg, index)" :title="tm('actions.reply')" />
</div>
</div>
</div>
</div>
</div>
<!-- 浮动引用按钮 -->
<div v-if="selectedText.content && selectedText.messageIndex !== null" class="selection-quote-button" :style="{
top: selectedText.position.top + 'px',
left: selectedText.position.left + 'px',
position: 'fixed'
}">
<v-btn size="large" rounded="xl" @click="handleQuoteSelected" class="quote-btn"
:class="{ 'dark-mode': isDark }">
<v-icon left small>mdi-reply</v-icon>
引用
</v-btn>
</div>
</div>
</template>
<script>
import { useI18n, useModuleI18n } from '@/i18n/composables';
import { MarkdownRender, enableKatex, enableMermaid } from 'markstream-vue'
import 'markstream-vue/index.css'
import 'katex/dist/katex.min.css'
import 'highlight.js/styles/github.css';
import axios from 'axios';
enableKatex();
enableMermaid();
export default {
name: 'MessageList',
components: {
MarkdownRender
},
props: {
messages: {
type: Array,
required: true
},
isDark: {
type: Boolean,
default: false
},
isStreaming: {
type: Boolean,
default: false
},
isLoadingMessages: {
type: Boolean,
default: false
}
},
emits: ['openImagePreview', 'replyMessage', 'replyWithText'],
setup() {
const { t } = useI18n();
const { tm } = useModuleI18n('features/chat');
return {
t,
tm
};
},
data() {
return {
copiedMessages: new Set(),
isUserNearBottom: true,
scrollThreshold: 1,
scrollTimer: null,
expandedReasoning: new Set(), // Track which reasoning blocks are expanded
downloadingFiles: new Set(), // Track which files are being downloaded
expandedToolCalls: new Set(), // Track which tool call cards are expanded
elapsedTimeTimer: null, // Timer for updating elapsed time
currentTime: Date.now() / 1000, // Current time for elapsed time calculation
// 选中文本相关状态
selectedText: {
content: '',
messageIndex: null,
position: { top: 0, left: 0 }
}
};
},
mounted() {
this.initCodeCopyButtons();
this.initImageClickEvents();
this.addScrollListener();
this.scrollToBottom();
this.startElapsedTimeTimer();
},
updated() {
this.initCodeCopyButtons();
this.initImageClickEvents();
if (this.isUserNearBottom) {
this.scrollToBottom();
}
},
methods: {
// 处理文本选择
handleTextSelection() {
const selection = window.getSelection();
const selectedText = selection.toString();
if (!selectedText.trim()) {
// 清除选中状态
this.selectedText.content = '';
this.selectedText.messageIndex = null;
return;
}
// 获取被选中的元素找到对应的message-item
const range = selection.getRangeAt(0);
const startContainer = range.startContainer;
let messageItem = null;
let node = startContainer.parentElement;
// 遍历DOM树向上查找message-item
while (node && !node.classList.contains('message-item')) {
node = node.parentElement;
}
messageItem = node;
if (!messageItem) {
this.selectedText.content = '';
this.selectedText.messageIndex = null;
return;
}
// 获取message-item在messages数组中的索引
const messageItems = this.$refs.messageContainer?.querySelectorAll('.message-item');
let messageIndex = -1;
if (messageItems) {
for (let i = 0; i < messageItems.length; i++) {
if (messageItems[i] === messageItem) {
messageIndex = i;
break;
}
}
}
if (messageIndex === -1) {
this.selectedText.content = '';
this.selectedText.messageIndex = null;
return;
}
// 获取选中文本的位置相对于viewport
const rect = selection.getRangeAt(0).getBoundingClientRect();
this.selectedText.content = selectedText;
this.selectedText.messageIndex = messageIndex;
this.selectedText.position = {
top: Math.max(0, rect.bottom + 5),
left: Math.max(0, (rect.left + rect.right) / 2)
};
},
// 处理引用选中的文本
handleQuoteSelected() {
if (this.selectedText.messageIndex === null) return;
const msg = this.messages[this.selectedText.messageIndex];
if (!msg || !msg.id) return;
// 触发replyWithText事件传递选中的文本内容
this.$emit('replyWithText', {
messageId: msg.id,
selectedText: this.selectedText.content,
messageIndex: this.selectedText.messageIndex
});
// 清除选中状态
this.selectedText.content = '';
this.selectedText.messageIndex = null;
window.getSelection().removeAllRanges();
},
// 检查 message 中是否有音频
hasAudio(messageParts) {
if (!Array.isArray(messageParts)) return false;
return messageParts.some(part => part.type === 'record' && part.embedded_url);
},
// 获取被引用消息的内容
getReplyContent(messageId) {
const replyMsg = this.messages.find(m => m.id === messageId);
if (!replyMsg) {
return this.tm('reply.notFound');
}
let content = '';
if (Array.isArray(replyMsg.content.message)) {
const textParts = replyMsg.content.message
.filter(part => part.type === 'plain' && part.text)
.map(part => part.text);
content = textParts.join('');
}
// 截断过长内容
if (content.length > 50) {
content = content.substring(0, 50) + '...';
}
return content || '[媒体内容]';
},
// 滚动到指定消息
scrollToMessage(messageId) {
const msgIndex = this.messages.findIndex(m => m.id === messageId);
if (msgIndex === -1) return;
const container = this.$refs.messageContainer;
const messageItems = container?.querySelectorAll('.message-item');
if (messageItems && messageItems[msgIndex]) {
messageItems[msgIndex].scrollIntoView({ behavior: 'smooth', block: 'center' });
// 高亮一下
messageItems[msgIndex].classList.add('highlight-message');
setTimeout(() => {
messageItems[msgIndex].classList.remove('highlight-message');
}, 2000);
}
},
// Toggle reasoning expansion state
toggleReasoning(messageIndex) {
if (this.expandedReasoning.has(messageIndex)) {
this.expandedReasoning.delete(messageIndex);
} else {
this.expandedReasoning.add(messageIndex);
}
// Force reactivity
this.expandedReasoning = new Set(this.expandedReasoning);
},
// Check if reasoning is expanded
isReasoningExpanded(messageIndex) {
return this.expandedReasoning.has(messageIndex);
},
// 下载文件
async downloadFile(file) {
if (!file.attachment_id) return;
// 标记为下载中
this.downloadingFiles.add(file.attachment_id);
this.downloadingFiles = new Set(this.downloadingFiles);
try {
const response = await axios.get(`/api/chat/get_attachment?attachment_id=${file.attachment_id}`, {
responseType: 'blob'
});
const url = URL.createObjectURL(response.data);
const a = document.createElement('a');
a.href = url;
a.download = file.filename || 'file';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 100);
} catch (err) {
console.error('Download file failed:', err);
} finally {
this.downloadingFiles.delete(file.attachment_id);
this.downloadingFiles = new Set(this.downloadingFiles);
}
},
// 复制代码到剪贴板
copyCodeToClipboard(code) {
navigator.clipboard.writeText(code).then(() => {
console.log('代码已复制到剪贴板');
}).catch(err => {
console.error('复制失败:', err);
// 如果现代API失败使用传统方法
const textArea = document.createElement('textarea');
textArea.value = code;
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
console.log('代码已复制到剪贴板 (fallback)');
} catch (fallbackErr) {
console.error('复制失败 (fallback):', fallbackErr);
}
document.body.removeChild(textArea);
});
},
// 复制bot消息到剪贴板
copyBotMessage(messageParts, messageIndex) {
let textToCopy = '';
if (Array.isArray(messageParts)) {
// 提取所有文本内容
const textContents = messageParts
.filter(part => part.type === 'plain' && part.text)
.map(part => part.text);
textToCopy = textContents.join('\n');
// 检查是否有图片
const imageCount = messageParts.filter(part => part.type === 'image' && part.embedded_url).length;
if (imageCount > 0) {
if (textToCopy) textToCopy += '\n\n';
textToCopy += `[包含 ${imageCount} 张图片]`;
}
// 检查是否有音频
const hasAudio = messageParts.some(part => part.type === 'record' && part.embedded_url);
if (hasAudio) {
if (textToCopy) textToCopy += '\n\n';
textToCopy += '[包含音频内容]';
}
}
// 如果没有任何内容,使用默认文本
if (!textToCopy.trim()) {
textToCopy = '[媒体内容]';
}
navigator.clipboard.writeText(textToCopy).then(() => {
console.log('消息已复制到剪贴板');
this.showCopySuccess(messageIndex);
}).catch(err => {
console.error('复制失败:', err);
// 如果现代API失败使用传统方法
const textArea = document.createElement('textarea');
textArea.value = textToCopy;
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
console.log('消息已复制到剪贴板 (fallback)');
this.showCopySuccess(messageIndex);
} catch (fallbackErr) {
console.error('复制失败 (fallback):', fallbackErr);
}
document.body.removeChild(textArea);
});
},
// 显示复制成功提示
showCopySuccess(messageIndex) {
this.copiedMessages.add(messageIndex);
// 2秒后移除成功状态
setTimeout(() => {
this.copiedMessages.delete(messageIndex);
}, 2000);
},
// 获取复制按钮图标
getCopyIcon(messageIndex) {
return this.copiedMessages.has(messageIndex) ? 'mdi-check' : 'mdi-content-copy';
},
// 检查是否为复制成功状态
isCopySuccess(messageIndex) {
return this.copiedMessages.has(messageIndex);
},
// 获取复制图标SVG
getCopyIconSvg() {
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>';
},
// 获取成功图标SVG
getSuccessIconSvg() {
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20,6 9,17 4,12"></polyline></svg>';
},
// 初始化代码块复制按钮
initCodeCopyButtons() {
this.$nextTick(() => {
const codeBlocks = this.$refs.messageContainer?.querySelectorAll('pre code') || [];
codeBlocks.forEach((codeBlock, index) => {
const pre = codeBlock.parentElement;
if (pre && !pre.querySelector('.copy-code-btn')) {
const button = document.createElement('button');
button.className = 'copy-code-btn';
button.innerHTML = this.getCopyIconSvg();
button.title = '复制代码';
button.addEventListener('click', () => {
this.copyCodeToClipboard(codeBlock.textContent);
// 显示复制成功提示
button.innerHTML = this.getSuccessIconSvg();
button.style.color = '#4caf50';
setTimeout(() => {
button.innerHTML = this.getCopyIconSvg();
button.style.color = '';
}, 2000);
});
pre.style.position = 'relative';
pre.appendChild(button);
}
});
});
},
initImageClickEvents() {
this.$nextTick(() => {
// 查找所有动态生成的图片在markdown-content中
const images = document.querySelectorAll('.markdown-content img');
images.forEach((img) => {
if (!img.hasAttribute('data-click-enabled')) {
img.style.cursor = 'pointer';
img.setAttribute('data-click-enabled', 'true');
img.onclick = () => this.$emit('openImagePreview', img.src);
}
});
});
},
scrollToBottom() {
this.$nextTick(() => {
const container = this.$refs.messageContainer;
if (container) {
container.scrollTop = container.scrollHeight;
this.isUserNearBottom = true; // 程序滚动到底部后标记用户在底部
}
});
},
// 添加滚动事件监听器
addScrollListener() {
const container = this.$refs.messageContainer;
if (container) {
container.addEventListener('scroll', this.throttledHandleScroll);
}
},
// 节流处理滚动事件
throttledHandleScroll() {
if (this.scrollTimer) return;
this.scrollTimer = setTimeout(() => {
this.handleScroll();
this.scrollTimer = null;
}, 50); // 50ms 节流
},
// 处理滚动事件
handleScroll() {
const container = this.$refs.messageContainer;
if (container) {
const { scrollTop, scrollHeight, clientHeight } = container;
const distanceFromBottom = scrollHeight - (scrollTop + clientHeight);
// 判断用户是否在底部附近
this.isUserNearBottom = distanceFromBottom <= this.scrollThreshold;
}
},
// 组件销毁时移除监听器
beforeUnmount() {
const container = this.$refs.messageContainer;
if (container) {
container.removeEventListener('scroll', this.throttledHandleScroll);
}
// 清理定时器
if (this.scrollTimer) {
clearTimeout(this.scrollTimer);
this.scrollTimer = null;
}
// 清理 elapsed time 计时器
if (this.elapsedTimeTimer) {
clearInterval(this.elapsedTimeTimer);
this.elapsedTimeTimer = null;
}
},
// 格式化消息时间,支持别名显示
formatMessageTime(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
const now = new Date();
// 获取本地时间的日期部分
const dateDay = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const todayDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterdayDay = new Date(todayDay);
yesterdayDay.setDate(yesterdayDay.getDate() - 1);
// 格式化时间 HH:MM
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
const timeStr = `${hours}:${minutes}`;
// 判断是今天、昨天还是更早
if (dateDay.getTime() === todayDay.getTime()) {
return `${this.tm('time.today')} ${timeStr}`;
} else if (dateDay.getTime() === yesterdayDay.getTime()) {
return `${this.tm('time.yesterday')} ${timeStr}`;
} else {
// 更早的日期显示完整格式
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
return `${month}-${day} ${timeStr}`;
}
},
// Tool call related methods
toggleToolCall(messageIndex, partIndex, toolCallIndex) {
const key = `${messageIndex}-${partIndex}-${toolCallIndex}`;
if (this.expandedToolCalls.has(key)) {
this.expandedToolCalls.delete(key);
} else {
this.expandedToolCalls.add(key);
}
// Force reactivity
this.expandedToolCalls = new Set(this.expandedToolCalls);
},
isToolCallExpanded(messageIndex, partIndex, toolCallIndex) {
return this.expandedToolCalls.has(`${messageIndex}-${partIndex}-${toolCallIndex}`);
},
// Start timer for updating elapsed time
startElapsedTimeTimer() {
// Update every 12ms for sub-second precision, then every second after 1s
let fastUpdateCount = 0;
const fastUpdateInterval = 12;
const slowUpdateInterval = 1000;
const updateTime = () => {
this.currentTime = Date.now() / 1000;
// Check if there are any running tool calls
const hasRunningToolCalls = this.messages.some(msg =>
Array.isArray(msg.content.message) && msg.content.message.some(part =>
part.type === 'tool_call' && part.tool_calls?.some(tc => !tc.finished_ts)
)
);
if (hasRunningToolCalls) {
// Check if any running tool call is under 1 second
const hasSubSecondToolCall = this.messages.some(msg =>
Array.isArray(msg.content.message) && msg.content.message.some(part =>
part.type === 'tool_call' && part.tool_calls?.some(tc =>
!tc.finished_ts && (this.currentTime - tc.ts) < 1
)
)
);
if (hasSubSecondToolCall) {
fastUpdateCount++;
this.elapsedTimeTimer = setTimeout(updateTime, fastUpdateInterval);
} else {
this.elapsedTimeTimer = setTimeout(updateTime, slowUpdateInterval);
}
} else {
// No running tool calls, check again after 1 second
this.elapsedTimeTimer = setTimeout(updateTime, slowUpdateInterval);
}
};
updateTime();
},
// Get elapsed time string for a tool call
getElapsedTime(startTs) {
const elapsed = this.currentTime - startTs;
return this.formatDuration(elapsed);
},
// Format duration in seconds to human readable string
formatDuration(seconds) {
if (seconds < 1) {
return `${Math.round(seconds * 1000)}ms`;
} else if (seconds < 60) {
return `${seconds.toFixed(1)}s`;
} else {
const minutes = Math.floor(seconds / 60);
const secs = Math.round(seconds % 60);
return `${minutes}m ${secs}s`;
}
},
// Format tool result for display
formatToolResult(result) {
if (!result) return '';
// Try to parse as JSON for pretty formatting
try {
const parsed = JSON.parse(result);
return JSON.stringify(parsed, null, 2);
} catch {
return result;
}
},
// Get input tokens (input_other + input_cached)
getInputTokens(tokenUsage) {
if (!tokenUsage) return 0;
return (tokenUsage.input_other || 0) + (tokenUsage.input_cached || 0);
},
// Format agent duration
formatAgentDuration(agentStats) {
if (!agentStats) return '';
const duration = agentStats.end_time - agentStats.start_time;
return this.formatDuration(duration);
},
// Format time to first token
formatTTFT(ttft) {
if (!ttft || ttft <= 0) return '';
return this.formatDuration(ttft);
}
}
}
</script>
<style scoped>
:deep(.hr-node) {
margin-top: 1.25rem;
margin-bottom: 1.25rem;
opacity: 0.5;
border-top-width: .3px;
}
:deep(.paragraph-node) {
margin: .5rem 0;
line-height: 1.7;
margin-block: 1rem;
}
:deep(.list-node) {
margin-top: .5rem;
margin-bottom: .5rem;
}
:deep(.mermaid-block-header) {
gap: 8px;
}
:deep(code.bg-secondary) {
background-color: #ececec !important;
color: #0d0d0d !important;
}
:deep(code.rounded) {
border-radius: 6px !important;
}
.messages-container.is-dark :deep(code.bg-secondary) {
background-color: #424242 !important;
color: #ffffff !important;
}
.messages-container.is-dark :deep(.code-block-container) {
background-color: #1f1f1f !important;
}
/* 基础动画 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(0);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.messages-container {
height: 100%;
max-height: 100%;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
position: relative;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
z-index: 10;
background-color: rgba(255, 255, 255, 0.7);
transition: opacity 0.3s ease;
}
.loading-overlay.is-dark {
background-color: rgba(30, 30, 30, 0.7);
}
.message-list.loading-blur {
opacity: 0.5;
transition: opacity 0.3s ease;
pointer-events: none;
}
.message-bubble {
padding: 2px 16px;
border-radius: 12px;
}
.loading-container {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
margin-top: 8px;
}
.loading-text {
font-size: 14px;
color: var(--v-theme-secondaryText);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 0.6;
}
50% {
opacity: 1;
}
}
@media (max-width: 768px) {
.messages-container {
padding: 8px;
}
.message-list {
max-width: 100%;
}
.message-item {
padding: 0;
}
.message-bubble {
padding: 2px 12px;
}
.bot-message {
flex-direction: column;
align-items: flex-start;
gap: 8px;
width: 100%;
}
.bot-message-content {
max-width: 100% !important;
width: 100% !important;
}
.bot-bubble {
width: 100% !important;
max-width: 100% !important;
}
.bot-avatar {
margin-left: 4px;
}
}
/* 消息列表样式 */
.message-list {
max-width: 900px;
margin: 0 auto;
width: 100%;
}
.message-item {
margin-bottom: 12px;
animation: fadeIn 0.3s ease-out;
}
.user-message {
display: flex;
justify-content: flex-end;
align-items: flex-start;
gap: 12px;
}
.bot-message {
display: flex;
justify-content: flex-start;
align-items: flex-start;
gap: 12px;
}
.bot-message-content {
display: flex;
flex-direction: column;
align-items: flex-start;
max-width: 80%;
position: relative;
}
.message-actions {
display: flex;
align-items: center;
gap: 8px;
opacity: 0;
transition: opacity 0.2s ease;
margin-left: 16px;
}
/* 最后一条消息始终显示操作按钮 */
.message-item:last-child .message-actions {
opacity: 1;
}
.message-time {
font-size: 12px;
color: var(--v-theme-secondaryText);
opacity: 0.7;
white-space: nowrap;
}
/* Agent Stats Info Icon */
.stats-info-icon {
margin-left: 6px;
color: var(--v-theme-secondaryText);
opacity: 0.6;
cursor: pointer;
transition: opacity 0.2s ease;
}
.stats-info-icon:hover {
opacity: 1;
}
.bot-message:hover .message-actions {
opacity: 1;
}
.copy-message-btn {
opacity: 0.6;
transition: all 0.2s ease;
color: var(--v-theme-secondary);
}
.copy-message-btn:hover {
opacity: 1;
background-color: rgba(103, 58, 183, 0.1);
}
.copy-message-btn.copy-success {
color: #4caf50;
opacity: 1;
}
.copy-message-btn.copy-success:hover {
color: #4caf50;
background-color: rgba(76, 175, 80, 0.1);
}
.reply-message-btn {
opacity: 0.6;
transition: all 0.2s ease;
color: var(--v-theme-secondary);
}
.reply-message-btn:hover {
opacity: 1;
background-color: rgba(103, 58, 183, 0.1);
}
/* 引用消息显示样式 */
.reply-quote {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
margin-bottom: 8px;
background-color: rgba(103, 58, 183, 0.08);
border-left: 3px solid var(--v-theme-secondary);
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s ease;
}
.reply-quote:hover {
background-color: rgba(103, 58, 183, 0.15);
}
.reply-quote-icon {
color: var(--v-theme-secondary);
flex-shrink: 0;
}
.reply-quote-text {
font-size: 13px;
color: var(--v-theme-secondaryText);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 消息高亮动画 */
.highlight-message {
animation: highlightPulse 2s ease-out;
}
@keyframes highlightPulse {
0% {
background-color: rgba(103, 58, 183, 0.3);
}
100% {
background-color: transparent;
}
}
.user-bubble {
color: var(--v-theme-primaryText);
padding: 12px 18px;
font-size: 15px;
max-width: 60%;
border-radius: 1.5rem;
}
.bot-bubble {
border: 1px solid var(--v-theme-border);
color: var(--v-theme-primaryText);
font-size: 16px;
max-width: 100%;
padding-left: 12px;
}
.user-avatar,
.bot-avatar {
align-self: flex-start;
margin-top: 12px;
}
/* 附件样式 */
.image-attachments {
display: flex;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
}
.image-attachment {
position: relative;
display: inline-block;
}
.attached-image {
width: 120px;
height: 120px;
object-fit: cover;
border-radius: 12px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease;
}
.audio-attachment {
margin-top: 8px;
min-width: 250px;
}
/* 包含音频的消息气泡最小宽度 */
.message-bubble.has-audio {
min-width: 280px;
}
.audio-player {
width: 100%;
height: 36px;
border-radius: 18px;
}
.embedded-images {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.embedded-image {
display: flex;
justify-content: flex-start;
}
.bot-embedded-image {
max-width: 40%;
width: auto;
height: auto;
border-radius: 8px;
cursor: pointer;
transition: transform 0.2s ease;
}
.embedded-audio {
width: 300px;
margin-top: 8px;
}
.embedded-audio .audio-player {
width: 100%;
max-width: 300px;
}
/* 文件附件样式 */
.file-attachments,
.embedded-files {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.file-attachment,
.embedded-file {
display: flex;
align-items: center;
}
.file-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background-color: rgba(var(--v-theme-primary), 0.08);
border: 1px solid rgba(var(--v-theme-primary), 0.2);
border-radius: 8px;
color: rgb(var(--v-theme-primary));
text-decoration: none;
font-size: 14px;
transition: all 0.2s ease;
max-width: 300px;
}
.file-link-download {
cursor: pointer;
}
.download-icon {
margin-left: 4px;
opacity: 0.7;
}
.file-icon {
flex-shrink: 0;
color: rgb(var(--v-theme-primary));
}
.file-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-link.is-dark:hover {
background-color: rgba(255, 255, 255, 0.1) !important;
border-color: rgba(255, 255, 255, 0.2) !important;
}
/* 动画类 */
.fade-in {
animation: fadeIn 0.3s ease-in-out;
}
/* Reasoning 区块样式 */
.reasoning-container {
margin-bottom: 12px;
margin-top: 6px;
border: 1px solid var(--v-theme-border);
border-radius: 20px;
overflow: hidden;
width: fit-content;
}
.reasoning-header {
display: inline-flex;
align-items: center;
padding: 8px 8px;
cursor: pointer;
user-select: none;
transition: background-color 0.2s ease;
border-radius: 20px;
}
.reasoning-header:hover {
background-color: rgba(103, 58, 183, 0.08);
}
.reasoning-header.is-dark:hover {
background-color: rgba(103, 58, 183, 0.15);
}
.reasoning-icon {
margin-right: 6px;
color: var(--v-theme-secondary);
transition: transform 0.2s ease;
}
.reasoning-label {
font-size: 13px;
font-weight: 500;
color: var(--v-theme-secondary);
letter-spacing: 0.3px;
}
.reasoning-content {
padding: 0px 12px;
border-top: 1px solid var(--v-theme-border);
color: gray;
animation: fadeIn 0.2s ease-in-out;
font-style: italic;
}
.reasoning-text {
font-size: 14px;
line-height: 1.6;
color: var(--v-theme-secondaryText);
}
/* Tool Call Card Styles */
.tool-calls-container {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
margin-top: 6px;
}
.tool-calls-label {
font-size: 13px;
font-weight: 500;
color: var(--v-theme-secondaryText);
opacity: 0.7;
margin-bottom: 4px;
}
.tool-call-card {
border-radius: 8px;
overflow: hidden;
background-color: #eff3f6;
margin: 8px 0px;
max-width: 300px;
transition: max-width 0.1s ease;
}
.tool-call-card.expanded {
max-width: 100%;
}
.tool-call-header {
display: flex;
align-items: center;
padding: 10px 12px;
cursor: pointer;
user-select: none;
transition: background-color 0.2s ease;
gap: 8px;
}
.tool-call-header:hover {
background-color: rgba(169, 194, 219, 0.15);
}
.tool-call-header.is-dark:hover {
background-color: rgba(100, 150, 200, 0.2);
}
.tool-call-expand-icon {
color: var(--v-theme-secondary);
transition: transform 0.2s ease;
flex-shrink: 0;
}
.tool-call-icon {
color: var(--v-theme-secondary);
flex-shrink: 0;
}
.tool-call-info {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.tool-call-name {
font-size: 13px;
font-weight: 600;
color: var(--v-theme-secondary);
}
.tool-call-id {
font-size: 11px;
color: var(--v-theme-secondaryText);
opacity: 0.7;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tool-call-status {
margin-left: 8px;
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 500;
flex-shrink: 0;
}
.tool-call-status.status-running {
color: #ff9800;
}
.tool-call-status.status-finished {
color: #4caf50;
}
.tool-call-status .status-icon {
font-size: 14px;
}
/* 浮动引用按钮样式 */
.selection-quote-button {
position: fixed;
z-index: 1000;
display: flex;
align-items: center;
gap: 8px;
pointer-events: all;
}
.quote-btn {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
font-size: 14px;
padding: 4px 24px;
background-color: #f6f4fa !important;
color: #333333 !important;
}
.quote-btn:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
background-color: #f6f4fa !important;
}
/* 深色主题 */
.quote-btn.dark-mode {
background-color: #2d2d2d !important;
color: #ffffff !important;
}
.tool-call-status .status-icon.spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.tool-call-details {
padding: 12px;
background-color: rgba(255, 255, 255, 0.5);
animation: fadeIn 0.2s ease-in-out;
}
.tool-call-detail-row {
display: flex;
flex-direction: column;
margin-bottom: 8px;
}
.tool-call-detail-row:last-child {
margin-bottom: 0;
}
.detail-label {
font-size: 11px;
font-weight: 600;
color: var(--v-theme-secondaryText);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.detail-value {
font-size: 12px;
color: var(--v-theme-primaryText);
background-color: transparent;
padding: 4px 8px;
border-radius: 4px;
word-break: break-all;
}
.detail-json {
font-family: 'Fira Code', 'Consolas', monospace;
white-space: pre-wrap;
max-height: 200px;
overflow-y: auto;
margin: 0;
}
.detail-result {
max-height: 300px;
background-color: transparent;
}
</style>
<style>
.markdown-content {
max-width: 100%;
line-height: 1.6;
}
/* Stats Menu 样式 */
.stats-menu-card {
border-radius: 8px !important;
min-width: 160px;
}
.stats-menu-content {
padding: 12px 16px !important;
display: flex;
flex-direction: column;
gap: 8px;
}
.stats-menu-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 20px;
}
.stats-menu-label {
font-size: 13px;
color: var(--v-theme-secondaryText);
}
.stats-menu-value {
font-size: 13px;
font-weight: 600;
font-family: 'Fira Code', 'Consolas', monospace;
color: var(--v-theme-primaryText);
}
</style>