2026-04-11 15:59:14 +08:00
|
|
|
<script setup lang="ts">
|
2026-05-02 15:39:01 +08:00
|
|
|
import type { Message, ContentBlock } from "@/stores/hermes/chat";
|
2026-05-02 13:26:57 +08:00
|
|
|
import { computed, onBeforeUnmount, onMounted, ref, watchEffect } from "vue";
|
2026-04-14 14:47:18 +08:00
|
|
|
import { useI18n } from "vue-i18n";
|
2026-04-23 12:09:39 +08:00
|
|
|
import { useMessage } from "naive-ui";
|
|
|
|
|
import { downloadFile } from "@/api/hermes/download";
|
2026-05-02 15:39:01 +08:00
|
|
|
import { getApiKey } from "@/api/client";
|
2026-04-30 18:36:00 +08:00
|
|
|
import { copyToClipboard } from "@/utils/clipboard";
|
2026-04-14 14:47:18 +08:00
|
|
|
import MarkdownRenderer from "./MarkdownRenderer.vue";
|
2026-04-25 08:46:50 +08:00
|
|
|
import { parseThinking, countThinkingChars } from "@/utils/thinking-parser";
|
|
|
|
|
import { useChatStore } from "@/stores/hermes/chat";
|
|
|
|
|
import { useSettingsStore } from "@/stores/hermes/settings";
|
2026-04-21 12:35:48 +08:00
|
|
|
import {
|
|
|
|
|
copyTextToClipboard,
|
|
|
|
|
handleCodeBlockCopyClick,
|
|
|
|
|
renderHighlightedCodeBlock,
|
|
|
|
|
} from "./highlight";
|
2026-05-02 13:26:57 +08:00
|
|
|
import { useGlobalSpeech } from "@/composables/useSpeech";
|
2026-04-21 12:35:48 +08:00
|
|
|
|
|
|
|
|
const TOOL_PAYLOAD_DISPLAY_LIMIT = 2000;
|
2026-04-11 15:59:14 +08:00
|
|
|
|
2026-04-22 14:00:34 +08:00
|
|
|
const props = defineProps<{ message: Message; highlight?: boolean }>();
|
2026-04-14 14:47:18 +08:00
|
|
|
const { t } = useI18n();
|
2026-04-23 12:09:39 +08:00
|
|
|
const toast = useMessage();
|
2026-04-11 15:59:14 +08:00
|
|
|
|
2026-04-14 14:47:18 +08:00
|
|
|
const isSystem = computed(() => props.message.role === "system");
|
2026-05-02 15:39:01 +08:00
|
|
|
|
|
|
|
|
// Parse ContentBlock[] from JSON string
|
|
|
|
|
const contentBlocks = computed(() => {
|
|
|
|
|
const content = props.message.content || '';
|
|
|
|
|
if (!content.trim()) return null;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Try to parse as ContentBlock[] array
|
|
|
|
|
const parsed = JSON.parse(content);
|
|
|
|
|
if (Array.isArray(parsed) && parsed.length > 0 && 'type' in parsed[0]) {
|
|
|
|
|
return parsed as ContentBlock[];
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Not valid JSON, treat as plain text
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Check if content is in ContentBlock[] format
|
|
|
|
|
const isContentBlockArray = computed(() => contentBlocks.value !== null);
|
|
|
|
|
|
|
|
|
|
// Extract text content from ContentBlock[] for display
|
|
|
|
|
const displayText = computed(() => {
|
|
|
|
|
if (!isContentBlockArray.value) {
|
|
|
|
|
return props.message.content || '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Extract text from blocks
|
|
|
|
|
return contentBlocks.value!
|
|
|
|
|
.filter(block => block.type === 'text')
|
|
|
|
|
.map(block => block.text)
|
|
|
|
|
.join('\n');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Extract files from ContentBlock[]
|
|
|
|
|
const contentFiles = computed(() => {
|
|
|
|
|
if (!isContentBlockArray.value) return null;
|
|
|
|
|
|
|
|
|
|
return contentBlocks.value!.filter(block => block.type === 'image' || block.type === 'file');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Generate download URL with auth token
|
|
|
|
|
function getDownloadUrl(path: string, name: string): string {
|
|
|
|
|
const token = getApiKey();
|
|
|
|
|
const base = `/api/hermes/download?path=${encodeURIComponent(path)}&name=${encodeURIComponent(name)}`;
|
|
|
|
|
return token ? `${base}&token=${encodeURIComponent(token)}` : base;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 14:47:18 +08:00
|
|
|
const toolExpanded = ref(false);
|
2026-04-26 13:28:08 +08:00
|
|
|
const previewUrl = ref<string | null>(null);
|
2026-04-11 15:59:14 +08:00
|
|
|
|
2026-04-25 08:46:50 +08:00
|
|
|
const chatStore = useChatStore();
|
|
|
|
|
const settingsStore = useSettingsStore();
|
2026-05-02 13:26:57 +08:00
|
|
|
const speech = useGlobalSpeech();
|
2026-04-25 08:46:50 +08:00
|
|
|
|
2026-04-26 22:59:43 +08:00
|
|
|
// Copy entire bubble content
|
|
|
|
|
const copyableContent = computed(() => {
|
|
|
|
|
if (props.message.role === 'tool') return null
|
|
|
|
|
const content = props.message.content || ''
|
|
|
|
|
if (!content.trim()) return null
|
|
|
|
|
return content
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
async function copyBubbleContent() {
|
|
|
|
|
const text = copyableContent.value
|
|
|
|
|
if (!text) return
|
2026-04-30 18:36:00 +08:00
|
|
|
const ok = await copyToClipboard(text)
|
|
|
|
|
if (ok) {
|
2026-04-26 22:59:43 +08:00
|
|
|
toast.success(t('chat.copiedBubble'))
|
2026-04-30 18:36:00 +08:00
|
|
|
return
|
2026-04-26 22:59:43 +08:00
|
|
|
}
|
2026-04-30 18:36:00 +08:00
|
|
|
toast.error(t('chat.copyFailed'))
|
2026-04-26 22:59:43 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-25 08:46:50 +08:00
|
|
|
const parsedThinking = computed(() =>
|
|
|
|
|
parseThinking(props.message.content || "", { streaming: !!props.message.isStreaming }),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 优先使用来自 reasoning 字段/事件的思考文本;否则回退到从 content 解析的 <think> 标签。
|
|
|
|
|
// 若两者共存,则拼接展示(罕见,但保持信息不丢)。
|
|
|
|
|
const hasReasoningField = computed(() => !!(props.message.reasoning && props.message.reasoning.length > 0));
|
|
|
|
|
|
|
|
|
|
const hasThinking = computed(() => hasReasoningField.value || parsedThinking.value.hasThinking);
|
|
|
|
|
|
|
|
|
|
const thinkingFullText = computed(() => {
|
|
|
|
|
const parts: string[] = [];
|
|
|
|
|
if (props.message.reasoning) parts.push(props.message.reasoning);
|
|
|
|
|
parts.push(...parsedThinking.value.segments);
|
|
|
|
|
if (parsedThinking.value.pending) parts.push(parsedThinking.value.pending);
|
|
|
|
|
return parts.join("\n\n");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const thinkingCharCount = computed(() => {
|
|
|
|
|
let count = countThinkingChars(parsedThinking.value);
|
|
|
|
|
if (props.message.reasoning) count += props.message.reasoning.length;
|
|
|
|
|
return count;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 流式思考态:仍有未闭合 <think> 标签,或 reasoning 有内容但正文尚未开始。
|
|
|
|
|
const thinkingStreamingNow = computed(() => {
|
|
|
|
|
if (!props.message.isStreaming) return false;
|
|
|
|
|
if (parsedThinking.value.pending !== null) return true;
|
|
|
|
|
if (hasReasoningField.value && !props.message.content) return true;
|
|
|
|
|
return false;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const thinkingOverride = ref<boolean | null>(null);
|
|
|
|
|
|
|
|
|
|
const thinkingExpanded = computed(() => {
|
|
|
|
|
if (thinkingStreamingNow.value) return true;
|
|
|
|
|
if (thinkingOverride.value !== null) return thinkingOverride.value;
|
|
|
|
|
return !!settingsStore.display.show_reasoning;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function toggleThinking() {
|
|
|
|
|
thinkingOverride.value = !thinkingExpanded.value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nowTick = ref(Date.now());
|
|
|
|
|
let tickTimer: number | null = null;
|
|
|
|
|
|
|
|
|
|
function ensureTick() {
|
|
|
|
|
const ob = chatStore.getThinkingObservation(props.message.id);
|
|
|
|
|
const shouldTick = !!(
|
|
|
|
|
props.message.isStreaming &&
|
|
|
|
|
ob?.startedAt !== undefined &&
|
|
|
|
|
ob.endedAt === undefined
|
|
|
|
|
);
|
|
|
|
|
if (shouldTick && tickTimer === null) {
|
|
|
|
|
tickTimer = window.setInterval(() => {
|
|
|
|
|
nowTick.value = Date.now();
|
|
|
|
|
}, 1000);
|
|
|
|
|
} else if (!shouldTick && tickTimer !== null) {
|
|
|
|
|
window.clearInterval(tickTimer);
|
|
|
|
|
tickTimer = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
watchEffect(ensureTick);
|
|
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
if (tickTimer !== null) window.clearInterval(tickTimer);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const thinkingDurationMs = computed<number | null>(() => {
|
|
|
|
|
const ob = chatStore.getThinkingObservation(props.message.id);
|
|
|
|
|
if (!ob?.startedAt) return null;
|
2026-04-30 16:40:37 +08:00
|
|
|
const startedAt = ob.startedAt!; // Non-null assertion after check
|
|
|
|
|
const end = ob?.endedAt ?? (props.message.isStreaming ? nowTick.value : startedAt);
|
|
|
|
|
return Math.max(0, end - startedAt);
|
2026-04-25 08:46:50 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function formatDuration(ms: number): string {
|
|
|
|
|
const s = Math.floor(ms / 1000);
|
|
|
|
|
if (s < 60) return `${s}s`;
|
|
|
|
|
const m = Math.floor(s / 60);
|
|
|
|
|
const r = s % 60;
|
|
|
|
|
return r === 0 ? `${m}m` : `${m}m ${r}s`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 15:59:14 +08:00
|
|
|
const timeStr = computed(() => {
|
2026-04-14 14:47:18 +08:00
|
|
|
const d = new Date(props.message.timestamp);
|
|
|
|
|
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
|
|
|
});
|
2026-04-11 18:54:46 +08:00
|
|
|
|
|
|
|
|
function isImage(type: string): boolean {
|
2026-04-14 14:47:18 +08:00
|
|
|
return type.startsWith("image/");
|
2026-04-11 18:54:46 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatSize(bytes: number): string {
|
2026-04-14 14:47:18 +08:00
|
|
|
if (bytes < 1024) return bytes + " B";
|
|
|
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
|
|
|
|
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
2026-04-11 18:54:46 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-23 12:09:39 +08:00
|
|
|
/**
|
|
|
|
|
* Extract the upload file path from message content for a given attachment.
|
|
|
|
|
* Upload format in content: [File: name.txt](/tmp/hermes-uploads/abc123.txt)
|
|
|
|
|
*/
|
|
|
|
|
function getFilePathFromContent(attName: string): string | null {
|
|
|
|
|
const content = props.message.content || "";
|
2026-05-02 15:39:01 +08:00
|
|
|
|
|
|
|
|
// Try ContentBlock[] format first
|
|
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(content);
|
|
|
|
|
if (Array.isArray(parsed) && parsed.length > 0 && 'type' in parsed[0]) {
|
|
|
|
|
const fileBlock = parsed.find((block: any) =>
|
|
|
|
|
block.type === 'file' && block.name === attName
|
|
|
|
|
);
|
|
|
|
|
if (fileBlock && (fileBlock as any).path) {
|
|
|
|
|
return (fileBlock as any).path;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Not valid JSON, continue to regex matching
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fallback to markdown format: [File: name](path)
|
2026-04-23 12:09:39 +08:00
|
|
|
const regex = /\[File:\s*([^\]]+)\]\(([^)]+)\)/g;
|
|
|
|
|
let match: RegExpExecArray | null;
|
|
|
|
|
while ((match = regex.exec(content)) !== null) {
|
|
|
|
|
if (match[1].trim() === attName.trim()) return match[2];
|
|
|
|
|
}
|
2026-05-02 15:39:01 +08:00
|
|
|
|
2026-04-23 12:09:39 +08:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleAttachmentDownload(att: { name: string; url: string; type: string }) {
|
|
|
|
|
const filePath = getFilePathFromContent(att.name);
|
|
|
|
|
if (filePath) {
|
|
|
|
|
toast.info(t("download.downloading"));
|
|
|
|
|
downloadFile(filePath, att.name).catch((err: Error) => {
|
|
|
|
|
toast.error(err.message || t("download.downloadFailed"));
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (att.url && att.url.startsWith("blob:")) {
|
|
|
|
|
const a = document.createElement("a");
|
|
|
|
|
a.href = att.url;
|
|
|
|
|
a.download = att.name;
|
|
|
|
|
document.body.appendChild(a);
|
|
|
|
|
a.click();
|
|
|
|
|
document.body.removeChild(a);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 12:35:48 +08:00
|
|
|
type ToolPayload = {
|
|
|
|
|
full: string;
|
|
|
|
|
display: string;
|
|
|
|
|
language?: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function formatToolPayload(raw?: string): ToolPayload {
|
|
|
|
|
if (!raw) {
|
|
|
|
|
return { full: "", display: "" };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const full = JSON.stringify(JSON.parse(raw), null, 2);
|
|
|
|
|
return {
|
|
|
|
|
full,
|
|
|
|
|
display:
|
|
|
|
|
full.length > TOOL_PAYLOAD_DISPLAY_LIMIT
|
|
|
|
|
? full.slice(0, TOOL_PAYLOAD_DISPLAY_LIMIT) + "\n" + t("chat.truncated")
|
|
|
|
|
: full,
|
|
|
|
|
language: "json",
|
|
|
|
|
};
|
|
|
|
|
} catch {
|
|
|
|
|
return {
|
|
|
|
|
full: raw,
|
|
|
|
|
display:
|
|
|
|
|
raw.length > TOOL_PAYLOAD_DISPLAY_LIMIT
|
|
|
|
|
? raw.slice(0, TOOL_PAYLOAD_DISPLAY_LIMIT) + "\n" + t("chat.truncated")
|
|
|
|
|
: raw,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderToolPayload(content: string, language?: string): string {
|
|
|
|
|
return renderHighlightedCodeBlock(content, language, t("common.copy"), {
|
|
|
|
|
maxHighlightLength: TOOL_PAYLOAD_DISPLAY_LIMIT,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleToolDetailClick(event: MouseEvent): Promise<void> {
|
|
|
|
|
const target = event.target;
|
|
|
|
|
if (!(target instanceof HTMLElement)) return;
|
|
|
|
|
|
|
|
|
|
const button = target.closest<HTMLElement>("[data-copy-code=\"true\"]");
|
|
|
|
|
if (!button) return;
|
|
|
|
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
|
|
|
|
const source = button.closest<HTMLElement>("[data-copy-source]")?.dataset.copySource;
|
|
|
|
|
if (source === "tool-args" && fullToolArgs.value) {
|
2026-04-30 18:36:00 +08:00
|
|
|
const ok = await copyTextToClipboard(fullToolArgs.value);
|
|
|
|
|
if (ok) toast.success(t("common.copied"));
|
|
|
|
|
else toast.error(t("chat.copyFailed"));
|
2026-04-21 12:35:48 +08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (source === "tool-result" && fullToolResult.value) {
|
2026-04-30 18:36:00 +08:00
|
|
|
const ok = await copyTextToClipboard(fullToolResult.value);
|
|
|
|
|
if (ok) toast.success(t("common.copied"));
|
|
|
|
|
else toast.error(t("chat.copyFailed"));
|
2026-04-21 12:35:48 +08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-30 18:36:00 +08:00
|
|
|
const copyResult = await handleCodeBlockCopyClick(event);
|
|
|
|
|
if (copyResult) toast.success(t("common.copied"));
|
|
|
|
|
else if (copyResult === false) toast.error(t("chat.copyFailed"));
|
2026-04-21 12:35:48 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-14 14:47:18 +08:00
|
|
|
const hasAttachments = computed(
|
|
|
|
|
() => (props.message.attachments?.length ?? 0) > 0,
|
|
|
|
|
);
|
2026-04-12 23:59:18 +08:00
|
|
|
|
2026-04-14 14:47:18 +08:00
|
|
|
const hasToolDetails = computed(
|
|
|
|
|
() => !!(props.message.toolArgs || props.message.toolResult),
|
|
|
|
|
);
|
2026-04-12 23:59:18 +08:00
|
|
|
|
2026-04-21 12:35:48 +08:00
|
|
|
const toolArgsPayload = computed(() => formatToolPayload(props.message.toolArgs));
|
|
|
|
|
const toolResultPayload = computed(() => formatToolPayload(props.message.toolResult));
|
|
|
|
|
|
|
|
|
|
const fullToolArgs = computed(() => toolArgsPayload.value.full);
|
|
|
|
|
const formattedToolArgs = computed(() => toolArgsPayload.value.display);
|
|
|
|
|
const fullToolResult = computed(() => toolResultPayload.value.full);
|
|
|
|
|
const formattedToolResult = computed(() => toolResultPayload.value.display);
|
|
|
|
|
|
|
|
|
|
const renderedToolArgs = computed(() => {
|
|
|
|
|
if (!formattedToolArgs.value) return "";
|
|
|
|
|
return renderToolPayload(
|
|
|
|
|
formattedToolArgs.value,
|
|
|
|
|
toolArgsPayload.value.language,
|
|
|
|
|
);
|
2026-04-14 14:47:18 +08:00
|
|
|
});
|
2026-04-12 23:59:18 +08:00
|
|
|
|
2026-04-21 12:35:48 +08:00
|
|
|
const renderedToolResult = computed(() => {
|
|
|
|
|
if (!formattedToolResult.value) return "";
|
|
|
|
|
return renderToolPayload(
|
|
|
|
|
formattedToolResult.value,
|
|
|
|
|
toolResultPayload.value.language,
|
|
|
|
|
);
|
2026-04-14 14:47:18 +08:00
|
|
|
});
|
2026-05-02 13:26:57 +08:00
|
|
|
|
|
|
|
|
// 语音播放相关
|
|
|
|
|
const canPlaySpeech = computed(() => {
|
|
|
|
|
// 只有 assistant 消息可以播放,且浏览器支持 Web Speech API
|
|
|
|
|
return props.message.role === 'assistant' &&
|
|
|
|
|
speech.isSupported &&
|
|
|
|
|
copyableContent.value;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const isPlayingThisMessage = computed(() => {
|
|
|
|
|
return speech.currentMessageId.value === props.message.id && speech.isPlaying.value;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const isPausedThisMessage = computed(() => {
|
|
|
|
|
return speech.currentMessageId.value === props.message.id && speech.isPaused.value;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function handleSpeechToggle() {
|
|
|
|
|
if (!canPlaySpeech.value) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
const content = props.message.content || ''
|
2026-05-07 10:34:58 +08:00
|
|
|
speech.toggle(props.message.id, content, getSpeechOptions())
|
|
|
|
|
}
|
2026-05-02 13:26:57 +08:00
|
|
|
|
2026-05-07 10:34:58 +08:00
|
|
|
function getSpeechOptions() {
|
2026-05-02 13:26:57 +08:00
|
|
|
// 尝试获取男声语音包
|
|
|
|
|
const allVoices = speech.getAllVoices()
|
2026-05-07 10:34:58 +08:00
|
|
|
let maleVoice: SpeechSynthesisVoice | null = null
|
2026-05-02 13:26:57 +08:00
|
|
|
|
|
|
|
|
// 查找可能的男声语音包
|
|
|
|
|
for (const voice of allVoices) {
|
|
|
|
|
const name = voice.name.toLowerCase()
|
|
|
|
|
// 常见男声关键词
|
|
|
|
|
if (name.includes('male') || name.includes('david') || name.includes('daniel') ||
|
|
|
|
|
name.includes('mark') || name.includes('yaoyao') || name.includes('google')) {
|
|
|
|
|
// 优先选择中文男声
|
|
|
|
|
if (voice.lang.startsWith('zh')) {
|
|
|
|
|
maleVoice = voice
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
// 如果没有找到中文男声,记住第一个男声
|
|
|
|
|
if (!maleVoice) {
|
|
|
|
|
maleVoice = voice
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 快速男声:语速快、音调低
|
2026-05-07 10:34:58 +08:00
|
|
|
return {
|
2026-05-02 13:26:57 +08:00
|
|
|
pitch: 0.5, // 低沉
|
|
|
|
|
rate: 1.2, // 快速
|
|
|
|
|
voice: maleVoice || undefined, // 使用男声,如果没有就用默认
|
2026-05-07 10:34:58 +08:00
|
|
|
}
|
2026-05-02 13:26:57 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 监听自动播放事件
|
|
|
|
|
let autoPlayHandler: ((e: Event) => void) | null = null
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
autoPlayHandler = (e: Event) => {
|
|
|
|
|
const customEvent = e as CustomEvent<{ messageId: string; content: string }>
|
|
|
|
|
if (customEvent.detail.messageId === props.message.id && canPlaySpeech.value) {
|
2026-05-07 10:34:58 +08:00
|
|
|
speech.enqueue(props.message.id, customEvent.detail.content || props.message.content || '', getSpeechOptions())
|
2026-05-02 13:26:57 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
window.addEventListener('auto-play-speech', autoPlayHandler)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 组件卸载时停止播放并清理事件监听
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
if (autoPlayHandler) {
|
|
|
|
|
window.removeEventListener('auto-play-speech', autoPlayHandler)
|
|
|
|
|
}
|
|
|
|
|
if (speech.currentMessageId.value === props.message.id) {
|
|
|
|
|
speech.stop();
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-04-11 15:59:14 +08:00
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<template>
|
2026-04-22 14:00:34 +08:00
|
|
|
<div
|
|
|
|
|
class="message"
|
|
|
|
|
:class="[message.role, { highlight }]"
|
|
|
|
|
:id="`message-${message.id}`"
|
|
|
|
|
>
|
2026-04-11 15:59:14 +08:00
|
|
|
<template v-if="message.role === 'tool'">
|
2026-04-14 14:47:18 +08:00
|
|
|
<div
|
|
|
|
|
class="tool-line"
|
|
|
|
|
:class="{ expandable: hasToolDetails }"
|
|
|
|
|
@click="hasToolDetails && (toolExpanded = !toolExpanded)"
|
|
|
|
|
>
|
|
|
|
|
<svg
|
|
|
|
|
v-if="hasToolDetails"
|
|
|
|
|
width="10"
|
|
|
|
|
height="10"
|
|
|
|
|
viewBox="0 0 24 24"
|
|
|
|
|
fill="none"
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
stroke-width="2"
|
|
|
|
|
class="tool-chevron"
|
|
|
|
|
:class="{ rotated: toolExpanded }"
|
|
|
|
|
>
|
|
|
|
|
<polyline points="9 18 15 12 9 6" />
|
|
|
|
|
</svg>
|
|
|
|
|
<svg
|
|
|
|
|
v-else
|
|
|
|
|
width="12"
|
|
|
|
|
height="12"
|
|
|
|
|
viewBox="0 0 24 24"
|
|
|
|
|
fill="none"
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
stroke-width="1.5"
|
|
|
|
|
class="tool-icon"
|
|
|
|
|
>
|
|
|
|
|
<path
|
|
|
|
|
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
|
|
|
|
|
/>
|
|
|
|
|
</svg>
|
2026-04-11 15:59:14 +08:00
|
|
|
<span class="tool-name">{{ message.toolName }}</span>
|
2026-04-14 14:47:18 +08:00
|
|
|
<span
|
|
|
|
|
v-if="message.toolPreview && !toolExpanded"
|
|
|
|
|
class="tool-preview"
|
|
|
|
|
>{{ message.toolPreview }}</span
|
|
|
|
|
>
|
|
|
|
|
<span
|
|
|
|
|
v-if="message.toolStatus === 'running'"
|
|
|
|
|
class="tool-spinner"
|
|
|
|
|
></span>
|
|
|
|
|
<span v-if="message.toolStatus === 'error'" class="tool-error-badge">{{
|
|
|
|
|
t("chat.error")
|
|
|
|
|
}}</span>
|
2026-04-12 23:59:18 +08:00
|
|
|
</div>
|
2026-04-21 12:35:48 +08:00
|
|
|
<div v-if="toolExpanded && hasToolDetails" class="tool-details" @click="handleToolDetailClick">
|
|
|
|
|
<div v-if="formattedToolArgs" class="tool-detail-section" data-copy-source="tool-args">
|
2026-04-14 14:47:18 +08:00
|
|
|
<div class="tool-detail-label">{{ t("chat.arguments") }}</div>
|
2026-04-21 12:35:48 +08:00
|
|
|
<div class="tool-detail-code-block" v-html="renderedToolArgs"></div>
|
2026-04-12 23:59:18 +08:00
|
|
|
</div>
|
2026-04-21 12:35:48 +08:00
|
|
|
<div v-if="formattedToolResult" class="tool-detail-section" data-copy-source="tool-result">
|
2026-04-14 14:47:18 +08:00
|
|
|
<div class="tool-detail-label">{{ t("chat.result") }}</div>
|
2026-04-21 12:35:48 +08:00
|
|
|
<div class="tool-detail-code-block" v-html="renderedToolResult"></div>
|
2026-04-12 23:59:18 +08:00
|
|
|
</div>
|
2026-04-11 15:59:14 +08:00
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
<template v-else>
|
|
|
|
|
<div class="msg-body">
|
2026-04-14 14:47:18 +08:00
|
|
|
<img
|
|
|
|
|
v-if="message.role === 'assistant'"
|
2026-04-14 21:48:53 +08:00
|
|
|
src="/logo.png"
|
2026-04-14 14:47:18 +08:00
|
|
|
alt="Hermes"
|
|
|
|
|
class="msg-avatar"
|
|
|
|
|
/>
|
2026-04-11 15:59:14 +08:00
|
|
|
<div class="msg-content" :class="message.role">
|
2026-05-02 13:26:57 +08:00
|
|
|
<div class="message-bubble" :class="{ system: isSystem, 'speech-playing': isPlayingThisMessage && !isPausedThisMessage }">
|
2026-04-11 18:54:46 +08:00
|
|
|
<div v-if="hasAttachments" class="msg-attachments">
|
|
|
|
|
<div
|
|
|
|
|
v-for="att in message.attachments"
|
|
|
|
|
:key="att.id"
|
|
|
|
|
class="msg-attachment"
|
|
|
|
|
:class="{ image: isImage(att.type) }"
|
|
|
|
|
>
|
|
|
|
|
<template v-if="isImage(att.type) && att.url">
|
2026-04-14 14:47:18 +08:00
|
|
|
<img
|
|
|
|
|
:src="att.url"
|
|
|
|
|
:alt="att.name"
|
|
|
|
|
class="msg-attachment-thumb"
|
2026-04-26 13:28:08 +08:00
|
|
|
@click="previewUrl = att.url"
|
2026-04-14 14:47:18 +08:00
|
|
|
/>
|
2026-04-11 18:54:46 +08:00
|
|
|
</template>
|
|
|
|
|
<template v-else>
|
2026-04-23 12:09:39 +08:00
|
|
|
<div class="msg-attachment-file" @click="handleAttachmentDownload(att)" style="cursor: pointer;" :title="t('download.downloadFile')">
|
2026-04-14 14:47:18 +08:00
|
|
|
<svg
|
|
|
|
|
width="16"
|
|
|
|
|
height="16"
|
|
|
|
|
viewBox="0 0 24 24"
|
|
|
|
|
fill="none"
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
stroke-width="1.5"
|
|
|
|
|
>
|
|
|
|
|
<path
|
|
|
|
|
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
|
|
|
|
/>
|
|
|
|
|
<polyline points="14 2 14 8 20 8" />
|
|
|
|
|
</svg>
|
2026-04-11 18:54:46 +08:00
|
|
|
<span class="att-name">{{ att.name }}</span>
|
|
|
|
|
<span class="att-size">{{ formatSize(att.size) }}</span>
|
2026-04-23 12:09:39 +08:00
|
|
|
<svg class="att-download-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
|
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
|
|
|
<polyline points="7 10 12 15 17 10" />
|
|
|
|
|
<line x1="12" y1="15" x2="12" y2="3" />
|
|
|
|
|
</svg>
|
2026-04-11 18:54:46 +08:00
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-04-25 08:46:50 +08:00
|
|
|
<div
|
|
|
|
|
v-if="hasThinking"
|
|
|
|
|
class="thinking-block"
|
|
|
|
|
:class="{ expanded: thinkingExpanded }"
|
|
|
|
|
>
|
|
|
|
|
<div class="thinking-header" @click="toggleThinking">
|
|
|
|
|
<svg
|
|
|
|
|
width="10"
|
|
|
|
|
height="10"
|
|
|
|
|
viewBox="0 0 24 24"
|
|
|
|
|
fill="none"
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
stroke-width="2"
|
|
|
|
|
class="thinking-chevron"
|
|
|
|
|
:class="{ rotated: thinkingExpanded }"
|
|
|
|
|
>
|
|
|
|
|
<polyline points="9 18 15 12 9 6" />
|
|
|
|
|
</svg>
|
|
|
|
|
<span class="thinking-icon">💭</span>
|
|
|
|
|
<span class="thinking-label">
|
|
|
|
|
{{
|
|
|
|
|
thinkingStreamingNow
|
|
|
|
|
? t('chat.thinkingInProgress')
|
|
|
|
|
: t('chat.thinkingLabel')
|
|
|
|
|
}}
|
|
|
|
|
</span>
|
|
|
|
|
<span v-if="thinkingDurationMs !== null && thinkingDurationMs > 0" class="thinking-meta">
|
|
|
|
|
· {{ t('chat.thinkingDuration', { duration: formatDuration(thinkingDurationMs) }) }}
|
|
|
|
|
</span>
|
|
|
|
|
<span class="thinking-meta">
|
|
|
|
|
· {{ t('chat.thinkingChars', { count: thinkingCharCount }) }}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div v-if="thinkingExpanded" class="thinking-body">
|
|
|
|
|
<MarkdownRenderer :content="thinkingFullText" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-04-14 14:47:18 +08:00
|
|
|
<MarkdownRenderer
|
2026-05-02 15:39:01 +08:00
|
|
|
v-if="parsedThinking.body && message.role === 'assistant'"
|
2026-04-25 08:46:50 +08:00
|
|
|
:content="parsedThinking.body"
|
2026-04-14 14:47:18 +08:00
|
|
|
/>
|
|
|
|
|
|
2026-05-02 15:39:01 +08:00
|
|
|
<!-- Render user message content -->
|
|
|
|
|
<template v-if="message.role === 'user'">
|
|
|
|
|
<!-- ContentBlock[] format -->
|
|
|
|
|
<template v-if="isContentBlockArray">
|
|
|
|
|
<div v-if="contentFiles && contentFiles.length > 0" class="msg-attachments">
|
|
|
|
|
<div
|
|
|
|
|
v-for="(file, idx) in contentFiles"
|
|
|
|
|
:key="idx"
|
|
|
|
|
class="msg-attachment"
|
|
|
|
|
:class="{ image: file.type === 'image' }"
|
|
|
|
|
>
|
|
|
|
|
<template v-if="file.type === 'image'">
|
|
|
|
|
<img
|
|
|
|
|
:src="getDownloadUrl(file.path, file.name)"
|
|
|
|
|
:alt="file.name"
|
|
|
|
|
class="msg-attachment-thumb"
|
|
|
|
|
@click="previewUrl = getDownloadUrl(file.path, file.name)"
|
|
|
|
|
/>
|
|
|
|
|
</template>
|
|
|
|
|
<template v-else>
|
|
|
|
|
<div
|
|
|
|
|
class="msg-attachment-file"
|
|
|
|
|
@click="downloadFile(file.path, file.name).catch(err => toast.error(err.message || t('download.downloadFailed')))"
|
|
|
|
|
style="cursor: pointer;"
|
|
|
|
|
:title="t('download.downloadFile')"
|
|
|
|
|
>
|
|
|
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
|
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
|
|
|
<polyline points="14 2 14 8 20 8" />
|
|
|
|
|
</svg>
|
|
|
|
|
<span class="att-name">{{ file.name }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<MarkdownRenderer v-if="displayText" :content="displayText" />
|
|
|
|
|
</template>
|
|
|
|
|
<!-- Plain text format -->
|
|
|
|
|
<MarkdownRenderer v-else-if="message.content" :content="message.content" />
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<!-- Render assistant message content -->
|
|
|
|
|
<MarkdownRenderer
|
|
|
|
|
v-if="message.role === 'assistant' && message.content && !parsedThinking.body"
|
|
|
|
|
:content="message.content"
|
|
|
|
|
/>
|
|
|
|
|
|
2026-04-14 14:47:18 +08:00
|
|
|
<span v-if="message.isStreaming && !message.content" class="streaming-dots">
|
2026-04-11 15:59:14 +08:00
|
|
|
<span></span><span></span><span></span>
|
2026-04-14 14:47:18 +08:00
|
|
|
</span>
|
2026-04-11 15:59:14 +08:00
|
|
|
</div>
|
2026-04-26 22:59:43 +08:00
|
|
|
<div class="message-meta">
|
2026-05-02 13:26:57 +08:00
|
|
|
<button
|
|
|
|
|
v-if="canPlaySpeech"
|
|
|
|
|
class="speech-bubble-btn"
|
|
|
|
|
:class="{ playing: isPlayingThisMessage, paused: isPausedThisMessage }"
|
|
|
|
|
@click="handleSpeechToggle"
|
|
|
|
|
:title="isPlayingThisMessage ? (isPausedThisMessage ? t('chat.resumeSpeech') : t('chat.pauseSpeech')) : t('chat.playSpeech')"
|
|
|
|
|
>
|
|
|
|
|
<svg v-if="!isPlayingThisMessage || isPausedThisMessage" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
|
|
|
<polygon points="5 3 19 12 5 21 5 3"/>
|
|
|
|
|
</svg>
|
|
|
|
|
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
|
|
|
<rect x="6" y="4" width="4" height="16"/>
|
|
|
|
|
<rect x="14" y="4" width="4" height="16"/>
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
2026-04-26 22:59:43 +08:00
|
|
|
<button
|
|
|
|
|
v-if="copyableContent"
|
|
|
|
|
class="copy-bubble-btn"
|
|
|
|
|
@click="copyBubbleContent"
|
|
|
|
|
:title="t('chat.copyBubble')"
|
|
|
|
|
>
|
|
|
|
|
<svg width="14" height="14" 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"/>
|
|
|
|
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
<span class="message-time">{{ timeStr }}</span>
|
|
|
|
|
</div>
|
2026-04-11 15:59:14 +08:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
</div>
|
2026-04-26 13:28:08 +08:00
|
|
|
<Teleport to="body">
|
|
|
|
|
<div v-if="previewUrl" class="image-preview-overlay" @click.self="previewUrl = null">
|
|
|
|
|
<img :src="previewUrl" class="image-preview-img" @click="previewUrl = null" />
|
|
|
|
|
</div>
|
|
|
|
|
</Teleport>
|
2026-04-11 15:59:14 +08:00
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<style scoped lang="scss">
|
2026-04-14 14:47:18 +08:00
|
|
|
@use "@/styles/variables" as *;
|
2026-04-11 15:59:14 +08:00
|
|
|
|
|
|
|
|
.message {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
2026-05-02 13:26:57 +08:00
|
|
|
position: relative;
|
2026-04-11 15:59:14 +08:00
|
|
|
|
|
|
|
|
&.user {
|
|
|
|
|
align-items: flex-end;
|
|
|
|
|
|
|
|
|
|
.msg-body {
|
|
|
|
|
max-width: 75%;
|
2026-05-02 13:26:57 +08:00
|
|
|
position: relative;
|
|
|
|
|
z-index: 1;
|
2026-04-11 15:59:14 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.msg-content.user {
|
|
|
|
|
align-items: flex-end;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.message-bubble {
|
|
|
|
|
background-color: $msg-user-bg;
|
2026-04-16 23:13:04 +08:00
|
|
|
border-radius: 10px;
|
2026-04-11 15:59:14 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
&.assistant {
|
|
|
|
|
flex-direction: row;
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
|
|
|
|
.msg-body {
|
|
|
|
|
max-width: 80%;
|
2026-05-02 13:26:57 +08:00
|
|
|
position: relative;
|
|
|
|
|
z-index: 1;
|
2026-04-11 15:59:14 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.msg-avatar {
|
2026-04-14 14:47:18 +08:00
|
|
|
width: 40px;
|
|
|
|
|
height: 40px;
|
2026-04-11 15:59:14 +08:00
|
|
|
flex-shrink: 0;
|
|
|
|
|
margin-top: 2px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.message-bubble {
|
|
|
|
|
background-color: $msg-assistant-bg;
|
2026-04-16 23:13:04 +08:00
|
|
|
border-radius: 10px;
|
2026-04-11 15:59:14 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
&.tool {
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
&.system {
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
}
|
2026-04-22 14:00:34 +08:00
|
|
|
|
|
|
|
|
&.highlight {
|
|
|
|
|
.message-bubble {
|
|
|
|
|
box-shadow: 0 0 0 1px rgba(var(--accent-primary-rgb), 0.45);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-11 15:59:14 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-02 13:26:57 +08:00
|
|
|
@keyframes gradient-flow {
|
|
|
|
|
0% {
|
|
|
|
|
background-position: 0% 50%;
|
|
|
|
|
}
|
|
|
|
|
50% {
|
|
|
|
|
background-position: 100% 50%;
|
|
|
|
|
}
|
|
|
|
|
100% {
|
|
|
|
|
background-position: 0% 50%;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 15:59:14 +08:00
|
|
|
.msg-body {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
max-width: 85%;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.msg-content {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
min-width: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.message-bubble {
|
|
|
|
|
padding: 10px 14px;
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
line-height: 1.65;
|
|
|
|
|
word-break: break-word;
|
2026-04-16 23:13:04 +08:00
|
|
|
border-radius: 10px;
|
2026-04-29 19:56:41 +08:00
|
|
|
max-width: 100%;
|
2026-05-02 13:26:57 +08:00
|
|
|
position: relative;
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
|
|
|
|
|
&.system {
|
|
|
|
|
border-left: 3px solid $warning;
|
|
|
|
|
border-radius: $radius-sm;
|
|
|
|
|
max-width: 80%;
|
|
|
|
|
background-color: rgba(var(--warning-rgb), 0.06);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
&.speech-playing {
|
|
|
|
|
box-shadow:
|
|
|
|
|
0 0 0 2px #ff6b6b,
|
|
|
|
|
0 0 10px rgba(255, 107, 107, 0.4),
|
|
|
|
|
0 0 20px rgba(255, 107, 107, 0.2);
|
|
|
|
|
animation: rainbow-glow 4s linear infinite;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@keyframes rainbow-glow {
|
|
|
|
|
0% {
|
|
|
|
|
box-shadow:
|
|
|
|
|
0 0 0 2px #ff6b6b,
|
|
|
|
|
0 0 10px rgba(255, 107, 107, 0.4),
|
|
|
|
|
0 0 20px rgba(255, 107, 107, 0.2);
|
|
|
|
|
}
|
|
|
|
|
16.66% {
|
|
|
|
|
box-shadow:
|
|
|
|
|
0 0 0 2px #feca57,
|
|
|
|
|
0 0 10px rgba(254, 202, 87, 0.4),
|
|
|
|
|
0 0 20px rgba(254, 202, 87, 0.2);
|
|
|
|
|
}
|
|
|
|
|
33.33% {
|
|
|
|
|
box-shadow:
|
|
|
|
|
0 0 0 2px #48dbfb,
|
|
|
|
|
0 0 10px rgba(72, 219, 251, 0.4),
|
|
|
|
|
0 0 20px rgba(72, 219, 251, 0.2);
|
|
|
|
|
}
|
|
|
|
|
50% {
|
|
|
|
|
box-shadow:
|
|
|
|
|
0 0 0 2px #ff9ff3,
|
|
|
|
|
0 0 10px rgba(255, 159, 243, 0.4),
|
|
|
|
|
0 0 20px rgba(255, 159, 243, 0.2);
|
|
|
|
|
}
|
|
|
|
|
66.66% {
|
|
|
|
|
box-shadow:
|
|
|
|
|
0 0 0 2px #54a0ff,
|
|
|
|
|
0 0 10px rgba(84, 160, 255, 0.4),
|
|
|
|
|
0 0 20px rgba(84, 160, 255, 0.2);
|
|
|
|
|
}
|
|
|
|
|
83.33% {
|
|
|
|
|
box-shadow:
|
|
|
|
|
0 0 0 2px #5f27cd,
|
|
|
|
|
0 0 10px rgba(95, 39, 205, 0.4),
|
|
|
|
|
0 0 20px rgba(95, 39, 205, 0.2);
|
|
|
|
|
}
|
|
|
|
|
100% {
|
|
|
|
|
box-shadow:
|
|
|
|
|
0 0 0 2px #ff6b6b,
|
|
|
|
|
0 0 10px rgba(255, 107, 107, 0.4),
|
|
|
|
|
0 0 20px rgba(255, 107, 107, 0.2);
|
|
|
|
|
}
|
2026-04-11 15:59:14 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-11 18:54:46 +08:00
|
|
|
.msg-attachments {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
margin-bottom: 8px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.msg-attachment {
|
|
|
|
|
border-radius: $radius-sm;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
background-color: rgba(0, 0, 0, 0.04);
|
|
|
|
|
border: 1px solid $border-light;
|
|
|
|
|
|
|
|
|
|
&.image {
|
|
|
|
|
max-width: 200px;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.msg-attachment-thumb {
|
|
|
|
|
display: block;
|
|
|
|
|
max-width: 200px;
|
|
|
|
|
max-height: 160px;
|
|
|
|
|
object-fit: contain;
|
2026-04-26 13:28:08 +08:00
|
|
|
cursor: pointer;
|
2026-04-11 18:54:46 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.msg-attachment-file {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 6px;
|
|
|
|
|
padding: 6px 10px;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: $text-secondary;
|
|
|
|
|
|
|
|
|
|
.att-name {
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
max-width: 160px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.att-size {
|
|
|
|
|
color: $text-muted;
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 08:46:50 +08:00
|
|
|
.thinking-block {
|
|
|
|
|
margin-bottom: 8px;
|
|
|
|
|
padding: 4px 0;
|
|
|
|
|
border-bottom: 1px dashed $border-light;
|
|
|
|
|
|
|
|
|
|
.thinking-header {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 6px;
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
color: $text-muted;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
padding: 2px 4px;
|
|
|
|
|
border-radius: $radius-sm;
|
|
|
|
|
user-select: none;
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
background: rgba(0, 0, 0, 0.03);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.thinking-chevron {
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
transition: transform 0.15s ease;
|
|
|
|
|
|
|
|
|
|
&.rotated {
|
|
|
|
|
transform: rotate(90deg);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.thinking-icon {
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.thinking-label {
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.thinking-meta {
|
|
|
|
|
color: $text-muted;
|
|
|
|
|
font-variant-numeric: tabular-nums;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.thinking-body {
|
|
|
|
|
margin-top: 6px;
|
|
|
|
|
padding: 6px 10px;
|
|
|
|
|
border-left: 2px solid $border-light;
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
opacity: 0.85;
|
|
|
|
|
font-style: italic;
|
|
|
|
|
|
|
|
|
|
:deep(p) { margin: 0.3em 0; }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 22:59:43 +08:00
|
|
|
.message-meta {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 6px;
|
|
|
|
|
margin-top: 4px;
|
|
|
|
|
padding: 0 4px;
|
|
|
|
|
opacity: 0;
|
|
|
|
|
transition: opacity 0.15s ease;
|
|
|
|
|
|
|
|
|
|
.message:hover & {
|
|
|
|
|
opacity: 1;
|
|
|
|
|
}
|
2026-05-02 13:26:57 +08:00
|
|
|
|
|
|
|
|
// 移动端一直显示按钮
|
|
|
|
|
@media (max-width: 768px) {
|
|
|
|
|
opacity: 1;
|
|
|
|
|
}
|
2026-04-26 22:59:43 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-02 13:26:57 +08:00
|
|
|
.copy-bubble-btn,
|
|
|
|
|
.speech-bubble-btn {
|
2026-04-26 22:59:43 +08:00
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
width: 24px;
|
|
|
|
|
height: 24px;
|
|
|
|
|
border: none;
|
|
|
|
|
background: transparent;
|
|
|
|
|
color: $text-muted;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
border-radius: $radius-sm;
|
|
|
|
|
padding: 0;
|
|
|
|
|
transition: color 0.15s ease, background 0.15s ease;
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
color: $text-secondary;
|
|
|
|
|
background: rgba(0, 0, 0, 0.06);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.dark & {
|
|
|
|
|
color: #999999;
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
color: #cccccc;
|
2026-05-02 13:26:57 +08:00
|
|
|
background: rgba(255, 255, 255, 0.1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.speech-bubble-btn {
|
|
|
|
|
&.playing {
|
|
|
|
|
color: var(--accent-primary);
|
|
|
|
|
animation: pulse 1.5s ease-in-out infinite;
|
|
|
|
|
|
|
|
|
|
&.paused {
|
|
|
|
|
animation: none;
|
|
|
|
|
opacity: 0.6;
|
2026-04-26 22:59:43 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 13:26:57 +08:00
|
|
|
@keyframes pulse {
|
|
|
|
|
0%, 100% {
|
|
|
|
|
opacity: 1;
|
|
|
|
|
}
|
|
|
|
|
50% {
|
|
|
|
|
opacity: 0.5;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 15:59:14 +08:00
|
|
|
.message-time {
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
color: $text-muted;
|
2026-04-26 22:59:43 +08:00
|
|
|
user-select: none;
|
2026-04-16 23:13:04 +08:00
|
|
|
|
|
|
|
|
.dark & {
|
|
|
|
|
color: #999999;
|
|
|
|
|
}
|
2026-04-11 15:59:14 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.tool-line {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 6px;
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
color: $text-muted;
|
2026-04-12 23:59:18 +08:00
|
|
|
padding: 2px 4px;
|
|
|
|
|
border-radius: $radius-sm;
|
|
|
|
|
|
|
|
|
|
&.expandable {
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
background: rgba(0, 0, 0, 0.03);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-11 15:59:14 +08:00
|
|
|
|
|
|
|
|
.tool-name {
|
|
|
|
|
font-family: $font-code;
|
2026-04-12 23:59:18 +08:00
|
|
|
flex-shrink: 0;
|
2026-04-11 15:59:14 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.tool-preview {
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
max-width: 400px;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 23:59:18 +08:00
|
|
|
.tool-chevron {
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
transition: transform 0.15s ease;
|
|
|
|
|
|
|
|
|
|
&.rotated {
|
|
|
|
|
transform: rotate(90deg);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.tool-spinner {
|
|
|
|
|
width: 10px;
|
|
|
|
|
height: 10px;
|
|
|
|
|
border: 1.5px solid $text-muted;
|
|
|
|
|
border-top-color: transparent;
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
animation: spin 0.6s linear infinite;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.tool-error-badge {
|
|
|
|
|
font-size: 9px;
|
|
|
|
|
color: $error;
|
2026-04-16 23:13:04 +08:00
|
|
|
background: rgba(var(--error-rgb), 0.08);
|
2026-04-12 23:59:18 +08:00
|
|
|
padding: 0 4px;
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
line-height: 14px;
|
2026-04-30 16:40:37 +08:00
|
|
|
margin-left: 4px;
|
2026-04-12 23:59:18 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.tool-details {
|
|
|
|
|
margin-left: 16px;
|
|
|
|
|
margin-top: 2px;
|
|
|
|
|
border-left: 2px solid $border-light;
|
|
|
|
|
padding-left: 10px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.tool-detail-section {
|
|
|
|
|
margin-bottom: 6px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.tool-detail-label {
|
|
|
|
|
font-size: 10px;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
color: $text-muted;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
letter-spacing: 0.3px;
|
|
|
|
|
margin-bottom: 2px;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 12:35:48 +08:00
|
|
|
.tool-detail-code-block {
|
|
|
|
|
:deep(.hljs-code-block) {
|
|
|
|
|
margin: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
:deep(.code-header) {
|
|
|
|
|
background: rgba(0, 0, 0, 0.02);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
:deep(code.hljs) {
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
max-height: 300px;
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
white-space: pre-wrap;
|
|
|
|
|
word-break: break-word;
|
|
|
|
|
}
|
2026-04-12 23:59:18 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@keyframes spin {
|
2026-04-14 14:47:18 +08:00
|
|
|
to {
|
|
|
|
|
transform: rotate(360deg);
|
|
|
|
|
}
|
2026-04-12 23:59:18 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-11 15:59:14 +08:00
|
|
|
.streaming-cursor {
|
|
|
|
|
display: inline-block;
|
|
|
|
|
width: 2px;
|
|
|
|
|
height: 1em;
|
|
|
|
|
background-color: $text-muted;
|
|
|
|
|
margin-left: 2px;
|
|
|
|
|
vertical-align: text-bottom;
|
|
|
|
|
animation: blink 0.8s infinite;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.streaming-dots {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 4px;
|
|
|
|
|
padding: 4px 0;
|
|
|
|
|
|
|
|
|
|
span {
|
|
|
|
|
width: 6px;
|
|
|
|
|
height: 6px;
|
|
|
|
|
background-color: $text-muted;
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
animation: pulse 1.4s infinite ease-in-out;
|
|
|
|
|
|
|
|
|
|
&:nth-child(2) { animation-delay: 0.2s; }
|
|
|
|
|
&:nth-child(3) { animation-delay: 0.4s; }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@keyframes blink {
|
2026-04-14 14:47:18 +08:00
|
|
|
0%,
|
|
|
|
|
50% {
|
|
|
|
|
opacity: 1;
|
|
|
|
|
}
|
|
|
|
|
51%,
|
|
|
|
|
100% {
|
|
|
|
|
opacity: 0;
|
|
|
|
|
}
|
2026-04-11 15:59:14 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@keyframes pulse {
|
2026-04-14 14:47:18 +08:00
|
|
|
0%,
|
|
|
|
|
80%,
|
|
|
|
|
100% {
|
|
|
|
|
opacity: 0.3;
|
|
|
|
|
transform: scale(0.8);
|
|
|
|
|
}
|
|
|
|
|
40% {
|
|
|
|
|
opacity: 1;
|
|
|
|
|
transform: scale(1);
|
|
|
|
|
}
|
2026-04-11 15:59:14 +08:00
|
|
|
}
|
2026-04-15 09:12:54 +08:00
|
|
|
|
2026-04-26 13:28:08 +08:00
|
|
|
.image-preview-overlay {
|
|
|
|
|
position: fixed;
|
|
|
|
|
inset: 0;
|
|
|
|
|
z-index: 9999;
|
|
|
|
|
background: rgba(0, 0, 0, 0.85);
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.image-preview-img {
|
|
|
|
|
max-width: 90vw;
|
|
|
|
|
max-height: 90vh;
|
|
|
|
|
object-fit: contain;
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 09:12:54 +08:00
|
|
|
@media (max-width: $breakpoint-mobile) {
|
2026-04-15 10:28:53 +08:00
|
|
|
.message.user .msg-body {
|
|
|
|
|
max-width: 100%;
|
2026-04-15 09:12:54 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-15 10:28:53 +08:00
|
|
|
.message.assistant .msg-body {
|
|
|
|
|
max-width: 100%;
|
2026-04-15 09:12:54 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-15 10:28:53 +08:00
|
|
|
.message.system .msg-body {
|
|
|
|
|
max-width: 100%;
|
2026-04-15 09:12:54 +08:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-11 15:59:14 +08:00
|
|
|
</style>
|