Files
AstrBot/dashboard/src/components/chat/ChatMessageList.vue
2026-04-23 11:45:57 +08:00

1595 lines
43 KiB
Vue

<template>
<div
ref="listRoot"
class="chat-message-list"
:class="[
`variant-${variant}`,
{ 'is-dark': isDark, 'is-group-chat': useDefaultBotAvatar },
]"
>
<div class="messages-list">
<div
v-for="(msg, msgIndex) in messages"
:key="msg.id || `${msgIndex}-${msg.created_at || ''}`"
v-show="shouldRenderMessage(msg)"
class="message-row"
:class="[
isUserMessage(msg) ? 'from-user' : 'from-bot',
{ 'is-continuation': isContinuationMessage(msg, msgIndex) },
]"
>
<v-avatar
v-if="!isUserMessage(msg) && !isContinuationMessage(msg, msgIndex)"
class="bot-avatar"
:size="avatarSize"
>
<v-progress-circular
v-if="!useDefaultBotAvatar && isMessageStreaming(msg, msgIndex)"
class="bot-streaming-spinner"
indeterminate
size="22"
width="2"
/>
<img v-else-if="botAvatar(msg)" :src="botAvatar(msg)" alt="" />
<img v-else-if="useDefaultBotAvatar" :src="defaultBotAvatar" alt="" />
<span v-else class="bot-avatar-symbol" aria-hidden="true"></span>
</v-avatar>
<div
v-else-if="!isUserMessage(msg)"
class="bot-avatar-spacer"
:style="{ width: `${avatarSize}px` }"
></div>
<div class="message-stack">
<div
v-if="showSenderName(msg) && !isContinuationMessage(msg, msgIndex)"
class="message-sender-name"
>
<span>{{ msg.sender_name || msg.sender_id }}</span>
<span v-if="isGroupChat && msg.created_at" class="group-message-time">
{{ formatTime(msg.created_at) }}
</span>
</div>
<div
v-if="isContinuationMessage(msg, msgIndex) && msg.created_at"
class="group-continuation-time"
>
{{ formatTime(msg.created_at) }}
</div>
<div
v-if="isUserMessage(msg) && userAttachmentParts(msg).length"
class="sent-attachments"
:class="{ 'images-only': hasImageOnlyAttachments(msg) }"
>
<template
v-for="(part, attachmentIndex) in userAttachmentParts(msg)"
:key="`${msgIndex}-attachment-${attachmentIndex}-${part.type}`"
>
<button
v-if="part.type === 'image'"
class="sent-attachment-card sent-image-card"
type="button"
@click="openImage(partUrl(part))"
>
<img :src="partUrl(part)" :alt="part.filename || 'image'" />
</button>
<div v-else class="sent-attachment-card sent-file-card">
<div
class="sent-attachment-icon"
:style="{ color: attachmentPresentation(part).color }"
>
<v-icon :icon="attachmentPresentation(part).icon" size="24" />
<span class="sent-attachment-ext">
{{ attachmentPresentation(part).label }}
</span>
</div>
<span class="sent-attachment-name">
{{ attachmentName(part) }}
</span>
<v-btn
v-if="part.type === 'file'"
icon="mdi-download"
size="x-small"
variant="text"
:loading="
downloadingFiles.has(
part.attachment_id || part.filename || '',
)
"
@click="downloadPart(part)"
/>
</div>
</template>
</div>
<div
v-if="shouldShowMessageBubble(msg)"
class="message-bubble"
:class="{ user: isUserMessage(msg), bot: !isUserMessage(msg) }"
@mouseup="handleMouseUp($event, msg)"
>
<div v-if="messageContent(msg).isLoading" class="loading-message">
<span>{{ tm("message.loading") }}</span>
</div>
<template v-else-if="isEditingMessage(msg)">
<div class="inline-message-editor">
<textarea
:value="editDraft"
class="inline-message-editor-input"
rows="2"
autofocus
@input="
emit(
'update:editDraft',
($event.target as HTMLTextAreaElement).value,
)
"
@keydown.esc="emit('cancelEdit')"
></textarea>
<div class="inline-message-editor-actions">
<v-btn
class="inline-message-editor-action"
size="small"
variant="text"
@click="emit('cancelEdit')"
>
{{ t("core.common.cancel") }}
</v-btn>
<v-btn
class="inline-message-editor-action"
size="small"
color="primary"
variant="tonal"
:loading="savingEdit"
@click="emit('saveEdit')"
>
{{ t("core.common.save") }}
</v-btn>
</div>
</div>
</template>
<template v-else>
<ReasoningBlock
v-if="messageContent(msg).reasoning"
:reasoning="messageContent(msg).reasoning || ''"
:is-dark="isDark"
:initial-expanded="false"
:is-streaming="isMessageStreaming(msg, msgIndex)"
:has-non-reasoning-content="hasNonReasoningContent(msg)"
/>
<template
v-for="(part, partIndex) in bubbleParts(msg)"
:key="`${msgIndex}-${partIndex}-${part.type}`"
>
<button
v-if="part.type === 'reply'"
class="reply-quote"
type="button"
@click="scrollToMessage(part.message_id)"
>
<v-icon size="15">mdi-reply</v-icon>
<span>{{ replyPreview(part.message_id, part.selected_text) }}</span>
</button>
<div
v-else-if="part.type === 'plain' && isUserMessage(msg)"
class="plain-content"
>
{{ part.text || "" }}
</div>
<span v-else-if="part.type === 'at'" class="mention-part">
@{{ mentionName(part) }}
</span>
<div
v-else-if="part.type === 'plain' && messageThreads(msg).length"
class="threaded-message-content"
>
<ThreadedMarkdownMessagePart
:text="part.text || ''"
:threads="messageThreads(msg)"
:refs="resolvedMessageRefs(msg)"
:is-dark="isDark"
:custom-html-tags="customMarkdownTags"
@open-thread="emit('openThread', $event)"
/>
</div>
<MarkdownMessagePart
v-else-if="part.type === 'plain'"
:content="part.text || ''"
:refs="resolvedMessageRefs(msg)"
:is-dark="isDark"
:custom-html-tags="customMarkdownTags"
/>
<button
v-else-if="part.type === 'image'"
class="image-part"
type="button"
@click="openImage(partUrl(part))"
>
<img :src="partUrl(part)" :alt="part.filename || 'image'" />
</button>
<audio
v-else-if="part.type === 'record'"
class="audio-part"
controls
:src="partUrl(part)"
/>
<video
v-else-if="part.type === 'video'"
class="video-part"
controls
:src="partUrl(part)"
/>
<div v-else-if="part.type === 'file'" class="file-part">
<v-icon size="20">mdi-file-document-outline</v-icon>
<span>{{ part.filename || "file" }}</span>
<v-btn
icon="mdi-download"
size="x-small"
variant="text"
:loading="
downloadingFiles.has(
part.attachment_id || part.filename || '',
)
"
@click="downloadPart(part)"
/>
</div>
<div v-else-if="part.type === 'tool_call'" class="tool-call-block">
<template v-for="tool in part.tool_calls || []" :key="tool.id || tool.name">
<ToolCallItem v-if="isIPythonToolCall(tool)" :is-dark="isDark">
<template #label>
<v-icon size="16">mdi-code-json</v-icon>
<span>{{ tool.name || "python" }}</span>
<span class="tool-call-inline-status">
{{ toolCallStatusText(tool) }}
</span>
</template>
<template #details>
<IPythonToolBlock
:tool-call="normalizeToolCall(tool)"
:is-dark="isDark"
:show-header="false"
:force-expanded="true"
/>
</template>
</ToolCallItem>
<ToolCallCard
v-else
:tool-call="normalizeToolCall(tool)"
:is-dark="isDark"
/>
</template>
</div>
<div v-else class="unknown-part">
{{ formatJson(part) }}
</div>
</template>
</template>
</div>
<div v-if="showMessageMeta(msg, msgIndex)" class="message-meta">
<span v-if="msg.created_at">{{ formatTime(msg.created_at) }}</span>
<v-btn
v-if="canEditMessage(msg, msgIndex)"
icon="mdi-pencil-outline"
size="x-small"
variant="text"
@click="emit('openEdit', msg)"
/>
<RegenerateMenu
v-if="canRegenerateMessage(msg, msgIndex)"
@retry="emit('regenerate', msg)"
@retry-with-model="emit('regenerateWithModel', msg, $event)"
/>
<v-btn
v-if="enableCopy && !isUserMessage(msg)"
icon="mdi-content-copy"
size="x-small"
variant="text"
@click="copyMessage(msg)"
/>
<v-menu
v-if="messageContent(msg).agentStats"
location="bottom"
transition="none"
>
<template #activator="{ props: statsProps }">
<v-btn
v-bind="statsProps"
icon="mdi-information-outline"
size="x-small"
variant="text"
/>
</template>
<v-card class="stats-card" elevation="4">
<div class="stats-row">
<span>{{ tm("stats.inputTokens") }}</span>
<strong>{{ inputTokens(messageContent(msg).agentStats) }}</strong>
</div>
<div class="stats-row">
<span>{{ tm("stats.outputTokens") }}</span>
<strong>{{ outputTokens(messageContent(msg).agentStats) }}</strong>
</div>
<div v-if="agentTtft(messageContent(msg).agentStats)" class="stats-row">
<span>{{ tm("stats.ttft") }}</span>
<strong>{{ agentTtft(messageContent(msg).agentStats) }}</strong>
</div>
<div class="stats-row">
<span>{{ tm("stats.duration") }}</span>
<strong>{{ agentDuration(messageContent(msg).agentStats) }}</strong>
</div>
</v-card>
</v-menu>
<StyledMenu
v-if="messageThreads(msg).length"
location="bottom"
transition="none"
no-border
>
<template #activator="{ props: threadMenuProps }">
<button
v-bind="threadMenuProps"
class="message-thread-meta"
type="button"
>
<v-icon size="14">mdi-source-branch</v-icon>
<span>{{ threadCountLabel(messageThreads(msg).length) }}</span>
</button>
</template>
<v-list-item
v-for="thread in messageThreads(msg)"
:key="thread.thread_id"
class="styled-menu-item thread-menu-item"
rounded="md"
@click="emit('openThread', thread)"
>
<template #prepend>
<v-icon size="16">mdi-source-branch</v-icon>
</template>
<v-list-item-title class="thread-menu-title">
{{ threadPreview(thread) }}
</v-list-item-title>
</v-list-item>
</StyledMenu>
<div v-if="messageRefs(msg).length" class="message-meta-refs">
<ActionRef
:refs="resolvedMessageRefs(msg)"
@open-refs="handleOpenRefs"
/>
</div>
</div>
</div>
</div>
</div>
<RefsSidebar
v-if="manageRefsSidebar"
v-model="refsSidebarOpen"
:refs="selectedRefs"
/>
<v-overlay
v-model="imagePreview.visible"
class="image-preview-overlay"
scrim="rgba(0, 0, 0, 0.86)"
@click="closeImage"
>
<img
:src="imagePreview.url"
class="preview-image"
alt="preview"
@click.stop
/>
</v-overlay>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, reactive, ref } from "vue";
import axios from "axios";
import { setCustomComponents } from "markstream-vue";
import "markstream-vue/index.css";
import RegenerateMenu, {
type RegenerateModelSelection,
} from "@/components/chat/RegenerateMenu.vue";
import ThreadedMarkdownMessagePart from "@/components/chat/ThreadedMarkdownMessagePart.vue";
import ReasoningBlock from "@/components/chat/message_list_comps/ReasoningBlock.vue";
import ToolCallCard from "@/components/chat/message_list_comps/ToolCallCard.vue";
import ToolCallItem from "@/components/chat/message_list_comps/ToolCallItem.vue";
import IPythonToolBlock from "@/components/chat/message_list_comps/IPythonToolBlock.vue";
import RefsSidebar from "@/components/chat/message_list_comps/RefsSidebar.vue";
import RefNode from "@/components/chat/message_list_comps/RefNode.vue";
import ThreadNode from "@/components/chat/message_list_comps/ThreadNode.vue";
import ActionRef from "@/components/chat/message_list_comps/ActionRef.vue";
import MarkdownMessagePart from "@/components/chat/message_list_comps/MarkdownMessagePart.vue";
import ThemeAwareMarkdownCodeBlock from "@/components/shared/ThemeAwareMarkdownCodeBlock.vue";
import StyledMenu from "@/components/shared/StyledMenu.vue";
import defaultBotAvatar from "@/assets/images/chatui-group-default-avatar.png";
import type {
ChatContent,
ChatRecord,
ChatThread,
MessagePart,
} from "@/composables/useMessages";
import { useI18n, useModuleI18n } from "@/i18n/composables";
const props = withDefaults(
defineProps<{
messages: ChatRecord[];
isDark?: boolean;
isStreaming?: boolean;
variant?: "main" | "thread";
enableEdit?: boolean;
enableRegenerate?: boolean;
enableThreadSelection?: boolean;
enableCopy?: boolean;
manageRefsSidebar?: boolean;
editingMessageId?: string | number | null;
editDraft?: string;
savingEdit?: boolean;
showSenderNames?: boolean;
useDefaultBotAvatar?: boolean;
botAvatars?: Record<string, string>;
hideLoadingBotMessage?: boolean;
}>(),
{
isDark: false,
isStreaming: false,
variant: "main",
enableEdit: false,
enableRegenerate: false,
enableThreadSelection: false,
enableCopy: true,
manageRefsSidebar: true,
editingMessageId: null,
editDraft: "",
savingEdit: false,
showSenderNames: false,
useDefaultBotAvatar: false,
botAvatars: () => ({}),
hideLoadingBotMessage: false,
},
);
const emit = defineEmits<{
"update:editDraft": [value: string];
openEdit: [message: ChatRecord];
cancelEdit: [];
saveEdit: [];
regenerate: [message: ChatRecord];
regenerateWithModel: [
message: ChatRecord,
selection: RegenerateModelSelection,
];
selectBotText: [event: MouseEvent, message: ChatRecord];
openThread: [thread: ChatThread];
openRefs: [refs: unknown];
}>();
setCustomComponents("chat-message", {
ref: RefNode,
thread: ThreadNode,
code_block: ThemeAwareMarkdownCodeBlock,
});
const { t } = useI18n();
const { tm } = useModuleI18n("features/chat");
const customMarkdownTags = ["ref"];
const downloadingFiles = ref(new Set<string>());
const imagePreview = reactive({ visible: false, url: "" });
const refsSidebarOpen = ref(false);
const selectedRefs = ref<Record<string, unknown> | null>(null);
const listRoot = ref<HTMLElement | null>(null);
const isGroupChat = computed(() => props.useDefaultBotAvatar);
const avatarSize = computed(() => {
if (props.variant === "thread") return 36;
return props.useDefaultBotAvatar ? 40 : 56;
});
function isUserMessage(message: ChatRecord) {
return messageContent(message).type === "user";
}
function shouldRenderMessage(message: ChatRecord) {
return !(
props.hideLoadingBotMessage &&
!isUserMessage(message) &&
messageContent(message).isLoading &&
!hasNonReasoningContent(message)
);
}
function isContinuationMessage(message: ChatRecord, index: number) {
if (!isGroupChat.value) return false;
if (isUserMessage(message)) return false;
const sender = senderKey(message);
if (!sender) return false;
const previous = previousVisibleBotMessage(index);
if (!previous || senderKey(previous.message) !== sender) return false;
const groupStart = groupStartMessage(previous.index);
return isWithinGroupWindow(groupStart, message);
}
function senderKey(message: ChatRecord) {
return message.sender_id || message.sender_name || "";
}
function previousVisibleBotMessage(index: number) {
for (let prevIndex = index - 1; prevIndex >= 0; prevIndex -= 1) {
const previous = props.messages[prevIndex];
if (!previous || !shouldRenderMessage(previous)) continue;
if (isUserMessage(previous)) return null;
return { message: previous, index: prevIndex };
}
return null;
}
function groupStartMessage(index: number): ChatRecord {
const message = props.messages[index];
const previous = previousVisibleBotMessage(index);
if (
previous &&
senderKey(previous.message) === senderKey(message) &&
isContinuationMessage(message, index)
) {
return groupStartMessage(previous.index);
}
return message;
}
function isWithinGroupWindow(first: ChatRecord, message: ChatRecord) {
if (!first.created_at || !message.created_at) return true;
const firstTime = new Date(first.created_at).getTime();
const messageTime = new Date(message.created_at).getTime();
if (Number.isNaN(firstTime) || Number.isNaN(messageTime)) return true;
return messageTime - firstTime <= 5 * 60 * 1000;
}
function showSenderName(message: ChatRecord) {
if (!props.showSenderNames) return false;
if (isUserMessage(message)) return false;
const sender = message.sender_name || message.sender_id;
if (!sender) return false;
if (sender === "bot") return false;
return true;
}
function botAvatar(message: ChatRecord) {
const senderId = message.sender_id || "";
return senderId ? props.botAvatars[senderId] || "" : "";
}
function messageContent(message: ChatRecord): ChatContent {
return message.content || { type: "bot", message: [] };
}
function messageParts(message: ChatRecord): MessagePart[] {
const parts = messageContent(message).message;
if (Array.isArray(parts)) return parts;
if (typeof parts === "string") return [{ type: "plain", text: parts }];
return [];
}
function isAttachmentPart(part: MessagePart) {
return ["image", "record", "video", "file"].includes(part.type);
}
function userAttachmentParts(message: ChatRecord) {
if (!isUserMessage(message)) return [];
return messageParts(message).filter(isAttachmentPart);
}
function hasImageOnlyAttachments(message: ChatRecord) {
const attachments = userAttachmentParts(message);
return (
attachments.length > 0 &&
attachments.every((part) => part.type === "image")
);
}
function mentionName(part: MessagePart) {
return part.name || part.target || part.bot_id || "";
}
function bubbleParts(message: ChatRecord) {
if (!isUserMessage(message)) return messageParts(message);
return messageParts(message).filter((part) => !isAttachmentPart(part));
}
function shouldShowMessageBubble(message: ChatRecord) {
return (
!isUserMessage(message) ||
isEditingMessage(message) ||
messageContent(message).isLoading ||
bubbleParts(message).length > 0
);
}
function isMessageStreaming(message: ChatRecord, messageIndex: number) {
return (
props.isStreaming &&
!isUserMessage(message) &&
messageIndex === props.messages.length - 1
);
}
function isEditingMessage(message: ChatRecord) {
return (
props.editingMessageId != null &&
message.id != null &&
String(props.editingMessageId) === String(message.id)
);
}
function canEditMessage(message: ChatRecord, messageIndex: number) {
return (
props.enableEdit &&
isUserMessage(message) &&
messageIndex === latestEditableUserIndex() &&
message.id != null &&
!String(message.id).startsWith("local-")
);
}
function latestEditableUserIndex() {
for (let index = props.messages.length - 1; index >= 0; index -= 1) {
const message = props.messages[index];
if (
isUserMessage(message) &&
message.id != null &&
!String(message.id).startsWith("local-")
) {
return index;
}
}
return -1;
}
function canRegenerateMessage(message: ChatRecord, messageIndex: number) {
return (
props.enableRegenerate &&
!isUserMessage(message) &&
messageIndex === props.messages.length - 1 &&
!isMessageStreaming(message, messageIndex) &&
Boolean(message.llm_checkpoint_id)
);
}
function showMessageMeta(message: ChatRecord, messageIndex: number) {
if (isGroupChat.value) return false;
return (
!messageContent(message).isLoading &&
!isMessageStreaming(message, messageIndex)
);
}
function hasNonReasoningContent(message: ChatRecord) {
return messageParts(message).some((part) => {
if (part.type === "reply") return false;
if (part.type === "plain") return Boolean(String(part.text || "").trim());
return true;
});
}
const attachmentTypeStyles: Record<
string,
{ color: string; icon: string; label: string }
> = {
pdf: { color: "#d32f2f", icon: "mdi-file-pdf-box", label: "PDF" },
txt: { color: "#1976d2", icon: "mdi-file-document-outline", label: "TXT" },
md: { color: "#1976d2", icon: "mdi-language-markdown-outline", label: "MD" },
markdown: {
color: "#1976d2",
icon: "mdi-language-markdown-outline",
label: "MD",
},
doc: { color: "#2b579a", icon: "mdi-file-word-box", label: "DOC" },
docx: { color: "#2b579a", icon: "mdi-file-word-box", label: "DOCX" },
xls: { color: "#217346", icon: "mdi-file-excel-box", label: "XLS" },
xlsx: { color: "#217346", icon: "mdi-file-excel-box", label: "XLSX" },
csv: { color: "#217346", icon: "mdi-file-delimited-outline", label: "CSV" },
ppt: { color: "#d24726", icon: "mdi-file-powerpoint-box", label: "PPT" },
pptx: { color: "#d24726", icon: "mdi-file-powerpoint-box", label: "PPTX" },
zip: { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "ZIP" },
rar: { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "RAR" },
"7z": { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "7Z" },
tar: { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "TAR" },
gz: { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "GZ" },
json: { color: "#6a1b9a", icon: "mdi-code-json", label: "JSON" },
yaml: { color: "#6a1b9a", icon: "mdi-code-braces", label: "YAML" },
yml: { color: "#6a1b9a", icon: "mdi-code-braces", label: "YML" },
js: { color: "#b8860b", icon: "mdi-language-javascript", label: "JS" },
ts: { color: "#3178c6", icon: "mdi-language-typescript", label: "TS" },
html: { color: "#e34c26", icon: "mdi-language-html5", label: "HTML" },
css: { color: "#264de4", icon: "mdi-language-css3", label: "CSS" },
py: { color: "#3776ab", icon: "mdi-language-python", label: "PY" },
java: { color: "#b07219", icon: "mdi-language-java", label: "JAVA" },
mp3: { color: "#00897b", icon: "mdi-file-music-outline", label: "MP3" },
wav: { color: "#00897b", icon: "mdi-file-music-outline", label: "WAV" },
flac: { color: "#00897b", icon: "mdi-file-music-outline", label: "FLAC" },
mp4: { color: "#5e35b1", icon: "mdi-file-video-outline", label: "MP4" },
mov: { color: "#5e35b1", icon: "mdi-file-video-outline", label: "MOV" },
webm: { color: "#5e35b1", icon: "mdi-file-video-outline", label: "WEBM" },
};
function attachmentName(part: MessagePart) {
return part.embedded_file?.filename || part.filename || part.type || "file";
}
function attachmentExtension(part: MessagePart) {
const name = attachmentName(part);
const extension = name.split(".").pop()?.toLowerCase() || "";
return extension === name.toLowerCase() ? "" : extension;
}
function attachmentPresentation(part: MessagePart) {
if (part.type === "record") {
return { color: "#00897b", icon: "mdi-microphone", label: "AUDIO" };
}
if (part.type === "video") {
return { color: "#5e35b1", icon: "mdi-file-video-outline", label: "VIDEO" };
}
const extension = attachmentExtension(part);
return (
attachmentTypeStyles[extension] || {
color: "#607d8b",
icon: "mdi-file-document-outline",
label: extension ? extension.slice(0, 4).toUpperCase() : "FILE",
}
);
}
function handleMouseUp(event: MouseEvent, message: ChatRecord) {
if (props.enableThreadSelection && !isUserMessage(message)) {
emit("selectBotText", event, message);
}
}
function messageThreads(message: ChatRecord) {
return message.threads || [];
}
function threadCountLabel(count: number) {
return tm("thread.count", { count });
}
function threadPreview(thread: ChatThread) {
return truncate(thread.selected_text || tm("thread.title"), 48);
}
function partUrl(part: MessagePart) {
if (part.embedded_url) return part.embedded_url;
if (part.embedded_file?.url) return part.embedded_file.url;
if (part.attachment_id) {
return `/api/chat/get_attachment?attachment_id=${encodeURIComponent(
part.attachment_id,
)}`;
}
if (part.filename) {
return `/api/chat/get_file?filename=${encodeURIComponent(part.filename)}`;
}
return "";
}
function plainTextFromMessage(message: ChatRecord) {
return messageParts(message)
.filter((part) => part.type === "plain" && part.text)
.map((part) => part.text)
.join("\n");
}
function replyPreview(messageId?: string | number, fallback?: string) {
if (fallback) return truncate(fallback, 80);
const found = props.messages.find(
(message) => String(message.id) === String(messageId),
);
const text = found ? plainTextFromMessage(found) : "";
return text ? truncate(text, 80) : tm("reply.replyTo");
}
function truncate(value: string, max: number) {
return value.length > max ? `${value.slice(0, max)}...` : value;
}
function scrollToMessage(messageId?: string | number) {
if (!messageId) return;
const index = props.messages.findIndex(
(message) => String(message.id) === String(messageId),
);
if (index < 0) return;
nextTick(() => {
listRoot.value
?.querySelectorAll(".message-row")
[index]?.scrollIntoView({ behavior: "smooth", block: "center" });
});
}
function formatJson(value: unknown) {
if (typeof value === "string") {
const parsed = parseJsonSafe(value);
if (parsed !== value) return JSON.stringify(parsed, null, 2);
return value;
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value ?? "");
}
}
function parseJsonSafe(value: unknown) {
if (typeof value !== "string") return value;
try {
return JSON.parse(value);
} catch {
return value;
}
}
function messageRefs(message: ChatRecord) {
return resolvedMessageRefs(message).used;
}
function resolvedMessageRefs(message: ChatRecord) {
return normalizeRefs(messageContent(message).refs);
}
function normalizeRefs(refs: unknown) {
if (!refs) return { used: [] as Array<Record<string, unknown>> };
const used = Array.isArray((refs as any)?.used)
? (refs as any).used
: Array.isArray(refs)
? refs
: [];
return { used: normalizeRefItems(used) };
}
function normalizeRefItems(items: unknown[]) {
return items
.map((item: any) => ({
index: item?.index,
title: item?.title || item?.url || tm("refs.title"),
url: item?.url,
snippet: item?.snippet,
favicon: item?.favicon,
}))
.filter((item) => item.url);
}
function handleOpenRefs(refs: unknown) {
if (!props.manageRefsSidebar) {
emit("openRefs", refs);
return;
}
selectedRefs.value =
refs && typeof refs === "object" ? (refs as Record<string, unknown>) : null;
refsSidebarOpen.value = true;
}
function normalizeToolCall(tool: Record<string, unknown>) {
const normalized = { ...tool };
normalized.args = normalized.args ?? normalized.arguments ?? {};
normalized.ts = normalized.ts ?? Date.now() / 1000;
if (normalized.result && typeof normalized.result === "object") {
normalized.result = JSON.stringify(normalized.result, null, 2);
}
return normalized;
}
function isIPythonToolCall(tool: Record<string, unknown>) {
const name = String(tool.name || "").toLowerCase();
return name.includes("python") || name.includes("ipython");
}
function toolCallStatusText(tool: Record<string, unknown>) {
if (tool.finished_ts) return tm("toolStatus.done");
return tm("toolStatus.running");
}
async function copyMessage(message: ChatRecord) {
const text = plainTextFromMessage(message);
if (!text) return;
await navigator.clipboard?.writeText(text);
}
async function downloadPart(part: MessagePart) {
const key = part.attachment_id || part.filename || "";
if (!key) return;
downloadingFiles.value = new Set(downloadingFiles.value).add(key);
try {
const response = await axios.get(partUrl(part), { responseType: "blob" });
const url = URL.createObjectURL(response.data);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = part.filename || "file";
anchor.click();
URL.revokeObjectURL(url);
} finally {
const next = new Set(downloadingFiles.value);
next.delete(key);
downloadingFiles.value = next;
}
}
function openImage(url: string) {
imagePreview.url = url;
imagePreview.visible = true;
}
function closeImage() {
imagePreview.visible = false;
imagePreview.url = "";
}
function formatTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function inputTokens(stats: any) {
const usage = stats?.token_usage || {};
return (usage.input_other || 0) + (usage.input_cached || 0);
}
function outputTokens(stats: any) {
return stats?.token_usage?.output || 0;
}
function agentDuration(stats: any) {
const directDuration = readPositiveNumber(stats, [
"duration",
"total_duration",
]);
if (directDuration !== null) return formatDuration(directDuration);
const startTime = readPositiveNumber(stats, ["start_time"]);
const endTime = readPositiveNumber(stats, ["end_time"]);
if (startTime === null || endTime === null || endTime < startTime) return "-";
return formatDuration(endTime - startTime);
}
function agentTtft(stats: any) {
const ttft = readPositiveNumber(stats, [
"time_to_first_token",
"ttft",
"first_token_latency",
]);
if (ttft === null) return "";
return formatDuration(ttft);
}
function readPositiveNumber(source: any, keys: string[]) {
for (const key of keys) {
const value = Number(source?.[key]);
if (Number.isFinite(value) && value > 0) return value;
}
return null;
}
function formatDuration(seconds: number) {
if (seconds < 1) return `${Math.round(seconds * 1000)}ms`;
if (seconds < 60) return `${seconds.toFixed(1)}s`;
const minutes = Math.floor(seconds / 60);
const restSeconds = Math.round(seconds % 60);
return `${minutes}m ${restSeconds}s`;
}
</script>
<style scoped>
.chat-message-list {
--chat-border: rgba(var(--v-border-color), 0.16);
--chat-muted: rgba(var(--v-theme-on-surface), 0.62);
width: 100%;
color: rgb(var(--v-theme-on-surface));
}
.chat-message-list.is-dark {
--chat-border: rgba(255, 255, 255, 0.1);
}
.messages-list {
display: flex;
flex-direction: column;
gap: 22px;
}
.message-row {
display: flex;
gap: 10px;
max-width: 100%;
}
.is-group-chat .message-row {
margin-right: -8px;
margin-left: -8px;
padding: 2px 8px;
}
.is-group-chat .message-row.is-continuation {
margin-top: -14px;
}
.message-row.from-user {
justify-content: flex-end;
}
.message-stack {
position: relative;
display: flex;
flex-direction: column;
max-width: min(760px, 82%);
}
.from-user .message-stack {
align-items: flex-end;
max-width: 60%;
}
.message-sender-name {
max-width: 100%;
color: rgba(var(--v-theme-on-surface), 0.5);
font-size: 12px;
font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.is-group-chat .message-sender-name {
display: flex;
align-items: baseline;
gap: 8px;
}
.group-message-time,
.group-continuation-time {
color: rgba(var(--v-theme-on-surface), 0.38);
font-size: 11px;
font-weight: 400;
line-height: 16px;
}
.group-continuation-time {
position: absolute;
left: -52px;
top: 8px;
width: 40px;
text-align: right;
opacity: 0;
pointer-events: none;
}
.message-row.is-continuation:hover .group-continuation-time {
opacity: 1;
}
.sent-attachments {
display: flex;
max-width: 100%;
gap: 10px;
margin-bottom: 8px;
padding: 2px 2px 4px;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
}
.sent-attachment-card {
position: relative;
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: flex-start;
gap: 8px;
height: 64px;
overflow: hidden;
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: 12px;
background: rgba(var(--v-theme-on-surface), 0.04);
color: rgb(var(--v-theme-on-surface));
}
.sent-image-card {
width: 64px;
padding: 0;
border: 0;
cursor: zoom-in;
}
.sent-image-card img {
width: 100%;
height: 100%;
border-radius: 11px;
object-fit: cover;
}
.sent-attachments.images-only {
max-width: min(420px, 100%);
}
.sent-attachments.images-only .sent-image-card {
width: 180px;
height: 180px;
}
.sent-attachments.images-only .sent-image-card img {
object-fit: cover;
background: rgba(var(--v-theme-on-surface), 0.04);
}
.sent-file-card {
width: 220px;
padding: 8px 10px;
}
.sent-attachment-icon {
display: inline-flex;
flex-shrink: 0;
min-width: 34px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1px;
}
.sent-attachment-ext {
max-width: 58px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 10px;
font-weight: 700;
line-height: 12px;
}
.sent-attachment-name {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
line-height: 18px;
}
.bot-avatar {
margin-top: 2px;
color: rgb(var(--v-theme-primary));
user-select: none;
}
.bot-avatar-spacer {
flex: 0 0 auto;
}
.bot-avatar img {
width: 100%;
height: 100%;
object-fit: contain;
}
.bot-streaming-spinner {
margin-top: -4px;
}
.bot-avatar-symbol {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 26px;
margin-top: -2px;
line-height: 0;
pointer-events: none;
user-select: none;
}
.message-bubble {
border-radius: 8px;
padding: 10px 14px;
line-height: 1.65;
overflow-wrap: anywhere;
}
.message-bubble.user {
color: var(--v-theme-primaryText);
padding: 12px 18px;
font-size: 15px;
max-width: 100%;
border-radius: 1.5rem;
background: rgba(var(--v-theme-primary), 0.12);
}
.message-bubble.bot {
background: transparent;
padding-left: 0;
}
.is-group-chat .message-bubble.bot {
padding-top: 2px;
padding-bottom: 2px;
}
.plain-content {
display: inline;
white-space: pre-wrap;
}
.mention-part {
display: inline;
width: auto;
max-width: 100%;
margin-right: 4px;
color: rgb(var(--v-theme-primary));
font-weight: 500;
overflow-wrap: anywhere;
}
.inline-message-editor {
display: flex;
flex-direction: column;
gap: 6px;
min-width: min(420px, 72vw);
}
.inline-message-editor-input {
width: 100%;
min-height: 0;
max-height: 220px;
padding: 0;
border: 0;
outline: 0;
resize: vertical;
background: transparent;
color: inherit;
font: inherit;
line-height: 1.65;
white-space: pre-wrap;
}
.inline-message-editor-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
margin-top: 2px;
}
.inline-message-editor-action {
min-height: 34px;
padding: 0 14px;
border-radius: 14px;
}
.loading-message {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
color: var(--chat-muted);
}
.chat-message-list :deep(.markdown-content p) {
margin: 0.25rem 0;
}
.chat-message-list :deep(.hr-node) {
margin: 24px 0;
}
.unknown-part {
max-width: 100%;
overflow-x: auto;
border-radius: 8px;
padding: 10px;
background: rgba(var(--v-theme-on-surface), 0.06);
font-size: 13px;
line-height: 1.5;
}
.reply-quote {
width: 100%;
display: flex;
align-items: center;
gap: 6px;
border: 0;
border-left: 3px solid rgb(var(--v-theme-primary));
border-radius: 6px;
padding: 7px 9px;
margin-bottom: 8px;
background: rgba(var(--v-theme-primary), 0.08);
color: inherit;
cursor: pointer;
text-align: left;
}
.image-part {
display: block;
border: 0;
padding: 0;
margin-top: 8px;
background: transparent;
cursor: zoom-in;
}
.image-part img {
max-width: min(420px, 100%);
max-height: 360px;
border-radius: 8px;
object-fit: contain;
}
.audio-part,
.video-part {
display: block;
max-width: 100%;
margin-top: 8px;
}
.video-part {
max-height: 360px;
border-radius: 8px;
}
.file-part {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
padding: 8px 10px;
border: 1px solid var(--chat-border);
border-radius: 8px;
}
.file-part span {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tool-call-block {
margin: 8px 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.message-bubble.bot
> .tool-call-block:first-child
:deep(.tool-call-card:first-child) {
margin-top: 0;
}
.tool-call-inline-status {
color: var(--chat-muted);
font-size: 12px;
}
.message-meta {
display: flex;
align-items: center;
gap: 2px;
min-height: 24px;
color: var(--chat-muted);
font-size: 12px;
}
.message-meta-refs {
display: flex;
align-items: center;
}
.message-thread-meta {
min-height: 24px;
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 8px;
padding: 0 6px;
background: transparent;
color: inherit;
font: inherit;
font-size: 12px;
line-height: 24px;
cursor: pointer;
}
.message-thread-meta:hover {
background: rgba(var(--v-theme-on-surface), 0.06);
}
.thread-menu-item {
max-width: min(320px, 72vw);
}
.thread-menu-title {
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
.from-user .message-meta {
justify-content: flex-end;
}
.stats-card {
min-width: 150px;
padding: 8px 10px;
}
.stats-row {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
padding: 2px 0;
font-size: 13px;
line-height: 1.35;
}
.stats-row span {
color: var(--chat-muted);
}
.stats-row strong {
font-size: 12px;
font-weight: 600;
}
.threaded-message-content {
color: inherit;
}
.image-preview-overlay {
display: flex;
align-items: center;
justify-content: center;
}
.preview-image {
max-width: min(92vw, 1200px);
max-height: 90vh;
object-fit: contain;
cursor: zoom-out;
}
.variant-thread .messages-list {
gap: 14px;
}
.variant-thread .message-stack {
max-width: min(300px, 86%);
}
.variant-thread .from-user .message-stack {
max-width: 78%;
}
.variant-thread .bot-avatar-symbol {
font-size: 22px;
}
.variant-thread .from-bot .bot-avatar {
display: none;
}
.variant-thread .from-bot .message-stack {
max-width: 100%;
}
.variant-thread .message-bubble {
padding: 9px 12px;
border-radius: 18px;
font-size: 14px;
}
.variant-thread .message-bubble.user {
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
background: rgb(var(--v-theme-on-surface));
color: rgb(var(--v-theme-surface));
}
.variant-thread .message-bubble.bot {
border: 0;
background: transparent;
padding-left: 12px;
}
@media (max-width: 760px) {
.messages-list {
gap: 18px;
}
.message-row.from-bot {
flex-direction: column;
gap: 2px;
}
.message-row.from-bot .bot-avatar {
display: none;
}
.message-row.from-bot .message-stack {
max-width: 100%;
}
.message-stack {
max-width: 88%;
}
.from-user .message-stack {
max-width: 82%;
}
.sent-file-card {
width: min(220px, calc(100vw - 28px));
height: 58px;
}
.sent-image-card {
width: 58px;
height: 58px;
}
.sent-attachments.images-only .sent-image-card {
width: min(180px, calc(100vw - 52px));
height: min(180px, calc(100vw - 52px));
}
.message-bubble {
padding: 9px 12px;
}
.message-bubble.user {
padding: 10px 14px;
}
.inline-message-editor {
min-width: min(100%, calc(100vw - 36px));
}
.image-part img {
max-width: min(100%, calc(100vw - 36px));
max-height: 300px;
}
.video-part {
max-height: 300px;
}
.variant-thread .messages-list {
gap: 12px;
}
.variant-thread .message-stack,
.variant-thread .from-bot .message-stack {
max-width: 100%;
}
.variant-thread .from-user .message-stack {
max-width: 82%;
}
.variant-thread .message-bubble.bot {
padding-left: 0;
}
}
</style>