astrbbbb / dashboard /src /components /chat /MessageList.vue
qa1145's picture
Upload 1245 files
8ede856 verified
<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="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) - 放在最前面 -->
<ReasoningBlock v-if="msg.content.reasoning && msg.content.reasoning.trim()"
:reasoning="msg.content.reasoning" :is-dark="isDark"
class="mt-2"
:initial-expanded="isReasoningExpanded(index)" />
<MessagePartsRenderer :parts="msg.content.message" :is-dark="isDark"
:current-time="currentTime" :downloading-files="downloadingFiles"
@open-image-preview="openImagePreview" @download-file="downloadFile" />
</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), 'copy-failed': isCopyFailure(index) }"
@click="copyBotMessage(msg.content.message, index)" :title="getCopyTitle(index)" />
<v-btn icon="mdi-reply-outline" size="x-small" variant="text" class="reply-message-btn"
@click="$emit('replyMessage', msg, index)" :title="tm('actions.reply')" />
<!-- Refs Visualization -->
<ActionRef :refs="msg.content.refs" @open-refs="openRefsSidebar" />
</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>
<!-- 图片预览 Overlay -->
<v-overlay v-model="imagePreview.show" class="image-preview-overlay" @click="closeImagePreview">
<div class="image-preview-container" @click.stop>
<img :src="imagePreview.url" class="preview-image" @click="closeImagePreview" />
</div>
</v-overlay>
</template>
<script>
import { useI18n, useModuleI18n } from '@/i18n/composables';
import { enableKatex, enableMermaid, setCustomComponents } from 'markstream-vue'
import 'markstream-vue/index.css'
import 'katex/dist/katex.min.css'
import 'highlight.js/styles/github.css';
import axios from 'axios';
import { useToast } from '@/utils/toast'
import ReasoningBlock from './message_list_comps/ReasoningBlock.vue';
import MessagePartsRenderer from './message_list_comps/MessagePartsRenderer.vue';
import RefNode from './message_list_comps/RefNode.vue';
import ActionRef from './message_list_comps/ActionRef.vue';
enableKatex();
enableMermaid();
// 注册自定义 ref 组件
setCustomComponents('message-list', { ref: RefNode });
export default {
name: 'MessageList',
components: {
ReasoningBlock,
MessagePartsRenderer,
RefNode,
ActionRef
},
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', 'openRefs'],
setup() {
const { t } = useI18n();
const { tm } = useModuleI18n('features/chat');
const toast = useToast()
return {
t,
tm,
toast
};
},
provide() {
return {
isDark: this.isDark,
webSearchResults: () => this.webSearchResults
};
},
data() {
return {
copiedMessages: new Set(),
copyFailedMessages: 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
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 }
},
// 图片预览
imagePreview: {
show: false,
url: ''
},
// Web search results mapping: { 'uuid.idx': { url, title, snippet } }
webSearchResults: {}
};
},
async mounted() {
this.initCodeCopyButtons();
this.initImageClickEvents();
this.addScrollListener();
this.scrollToBottom();
this.startElapsedTimeTimer();
this.extractWebSearchResults();
},
updated() {
this.initCodeCopyButtons();
this.initImageClickEvents();
if (this.isUserNearBottom) {
this.scrollToBottom();
}
this.extractWebSearchResults();
},
methods: {
// 从消息中提取 web_search_tavily 的搜索结果
extractWebSearchResults() {
const results = {};
this.messages.forEach(msg => {
if (msg.content.type !== 'bot' || !Array.isArray(msg.content.message)) {
return;
}
msg.content.message.forEach(part => {
if (part.type !== 'tool_call' || !Array.isArray(part.tool_calls)) {
return;
}
part.tool_calls.forEach(toolCall => {
// 检查是否是 web_search_tavily 工具调用
if (toolCall.name !== 'web_search_tavily' || !toolCall.result) {
return;
}
try {
// 解析工具调用结果
const resultData = typeof toolCall.result === 'string'
? JSON.parse(toolCall.result)
: toolCall.result;
if (resultData.results && Array.isArray(resultData.results)) {
resultData.results.forEach(item => {
if (item.index) {
results[item.index] = {
url: item.url,
title: item.title,
snippet: item.snippet
};
}
});
}
} catch (e) {
console.error('Failed to parse web search result:', e);
}
});
});
});
this.webSearchResults = results;
},
// 处理文本选择
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);
}
},
// 复制代码到剪贴板
tryExecCommandCopy(text) {
let textArea = null;
try {
textArea = document.createElement('textarea');
textArea.value = text;
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const ok = document.execCommand('copy');
return ok;
} catch (_) {
return false;
} finally {
try {
textArea?.remove?.();
} catch (_) {
// ignore cleanup errors
}
}
},
async copyTextToClipboard(text) {
// 优先使用同步复制,尽量保留用户手势上下文;
// 在非安全来源(例如通过局域网 IP + vite --host)时成功率更高。
if (this.tryExecCommandCopy(text)) {
return { ok: true, method: 'execCommand' };
}
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return { ok: true, method: 'clipboard' };
} catch (error) {
return { ok: false, method: 'clipboard', error };
}
}
return { ok: false, method: 'unavailable' };
},
async copyWithFeedback(text, messageIndex = null) {
const result = await this.copyTextToClipboard(text);
const ok = !!result?.ok;
if (messageIndex !== null && messageIndex !== undefined) {
if (ok) this.showCopySuccess(messageIndex);
else this.showCopyFailure(messageIndex);
}
if (ok) {
this.toast?.success?.(this.t('core.common.copied'));
} else {
this.toast?.error?.(this.t('core.common.copyFailed'));
}
return result;
},
buildCopyTextFromParts(messageParts) {
if (typeof messageParts === 'string') {
return messageParts.trim();
}
if (!Array.isArray(messageParts)) {
return '';
}
const textContents = messageParts
.filter(part => part && typeof part === 'object' && part.type === 'plain' && part.text)
.map(part => part.text);
let 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 += '[包含音频内容]';
}
return String(textToCopy || '').trim();
},
async copyCodeToClipboard(code) {
const text = String(code ?? '');
if (!text) return { ok: false, method: 'empty' };
return await this.copyWithFeedback(text, null);
},
// 复制bot消息到剪贴板
async copyBotMessage(messageParts, messageIndex) {
let textToCopy = this.buildCopyTextFromParts(messageParts);
if (!textToCopy) textToCopy = '[媒体内容]';
await this.copyWithFeedback(textToCopy, messageIndex);
},
// 显示复制成功提示
showCopySuccess(messageIndex) {
if (this.copyFailedMessages.has(messageIndex)) {
this.copyFailedMessages.delete(messageIndex);
this.copyFailedMessages = new Set(this.copyFailedMessages);
}
this.copiedMessages.add(messageIndex);
this.copiedMessages = new Set(this.copiedMessages);
// 2秒后移除成功状态
setTimeout(() => {
this.copiedMessages.delete(messageIndex);
this.copiedMessages = new Set(this.copiedMessages);
}, 2000);
},
// 显示复制失败提示
showCopyFailure(messageIndex) {
if (this.copiedMessages.has(messageIndex)) {
this.copiedMessages.delete(messageIndex);
this.copiedMessages = new Set(this.copiedMessages);
}
this.copyFailedMessages.add(messageIndex);
this.copyFailedMessages = new Set(this.copyFailedMessages);
setTimeout(() => {
this.copyFailedMessages.delete(messageIndex);
this.copyFailedMessages = new Set(this.copyFailedMessages);
}, 2000);
},
// 获取复制按钮图标
getCopyIcon(messageIndex) {
if (this.copiedMessages.has(messageIndex)) return 'mdi-check';
if (this.copyFailedMessages.has(messageIndex)) return 'mdi-alert-circle-outline';
return 'mdi-content-copy';
},
// 检查是否为复制成功状态
isCopySuccess(messageIndex) {
return this.copiedMessages.has(messageIndex);
},
// 检查是否为复制失败状态
isCopyFailure(messageIndex) {
return this.copyFailedMessages.has(messageIndex);
},
// 获取复制按钮提示文本
getCopyTitle(messageIndex) {
if (this.isCopySuccess(messageIndex)) return this.t('core.common.copied');
if (this.isCopyFailure(messageIndex)) return this.t('core.common.copyFailed');
return this.t('core.common.copy');
},
// 获取复制图标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>';
},
// 获取失败图标SVG
getErrorIconSvg() {
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="13"></line><circle cx="12" cy="16.5" r="1"></circle></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 = this.t('core.common.copy');
button.addEventListener('click', async () => {
const res = await this.copyCodeToClipboard(codeBlock.textContent || '');
const ok = !!res?.ok;
button.innerHTML = ok ? this.getSuccessIconSvg() : this.getErrorIconSvg();
button.style.color = ok
? 'rgb(var(--v-theme-success))'
: 'rgb(var(--v-theme-error))';
button.setAttribute("title", this.t(`core.common.${ok ? "copied" : "copyFailed"}`));
setTimeout(() => {
button.innerHTML = this.getCopyIconSvg();
button.style.color = '';
button.setAttribute("title", this.t('core.common.copy'));
}, 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.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}`;
}
},
// 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`;
}
},
// 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);
},
// 打开图片预览
openImagePreview(url) {
this.imagePreview.url = url;
this.imagePreview.show = true;
},
// 关闭图片预览
closeImagePreview() {
this.imagePreview.show = false;
setTimeout(() => {
this.imagePreview.url = '';
}, 300);
},
// Open refs sidebar
openRefsSidebar(refs) {
this.$emit('openRefs', refs);
}
}
}
</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;
overscroll-behavior-y: contain;
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: rgb(var(--v-theme-success));
opacity: 1;
}
.copy-message-btn.copy-success:hover {
color: rgb(var(--v-theme-success));
background-color: rgba(var(--v-theme-success), 0.1);
}
.copy-message-btn.copy-failed {
color: rgb(var(--v-theme-error));
opacity: 1;
}
.copy-message-btn.copy-failed:hover {
color: rgb(var(--v-theme-error));
background-color: rgba(var(--v-theme-error), 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;
}
/* 文件附件样式 */
.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;
}
/* 浮动引用按钮样式 */
.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;
}
</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);
}
/* 图片预览样式 */
.image-preview-overlay {
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.image-preview-container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.preview-image {
max-width: 90vw;
max-height: 90vh;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
cursor: pointer;
}
.close-preview-btn {
position: fixed;
top: 20px;
right: 20px;
}
</style>