feat: add usage statistics page, CLI improvements, and UI enhancements
- Add Usage Stats page with token breakdown, model distribution, and 30-day trend - Pass through cache/cost token fields in BFF (cache_read/write_tokens, reasoning_tokens, actual_cost_usd) - Add CLI commands: -v/--version, -h/--help, update/upgrade with auto-restart - Auto-open browser on startup, auto-kill port conflicts (cross-platform) - Validate all api_server config fields on startup (enabled, host, port, key, cors_origins) - Add streaming thinking video animation with tool calls panel - Add context token usage display (used / total) in chat header - Sidebar: white logo area with shadow, dance video beside logo (canvas seamless loop) - Fix sidebar nav scroll (app-main overflow-y: auto) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -109,6 +109,57 @@ const activeSessionTitle = computed(() =>
|
||||
chatStore.activeSession?.title || t('chat.newChat'),
|
||||
)
|
||||
|
||||
const totalTokens = computed(() => {
|
||||
const input = chatStore.activeSession?.inputTokens ?? 0
|
||||
const output = chatStore.activeSession?.outputTokens ?? 0
|
||||
return input + output
|
||||
})
|
||||
|
||||
const MODEL_CONTEXT: Record<string, number> = {
|
||||
'claude-opus-4': 200000,
|
||||
'claude-sonnet-4': 200000,
|
||||
'claude-haiku-4': 200000,
|
||||
'claude-3.5-sonnet': 200000,
|
||||
'claude-3.5-haiku': 200000,
|
||||
'claude-3-opus': 200000,
|
||||
'claude-3-sonnet': 200000,
|
||||
'claude-3-haiku': 200000,
|
||||
'gpt-4o': 128000,
|
||||
'gpt-4o-mini': 128000,
|
||||
'gpt-4-turbo': 128000,
|
||||
'gpt-4': 8192,
|
||||
'gpt-3.5-turbo': 16385,
|
||||
'o1': 200000,
|
||||
'o1-mini': 128000,
|
||||
'o3': 200000,
|
||||
'o3-mini': 200000,
|
||||
'o4-mini': 200000,
|
||||
'deepseek-chat': 65536,
|
||||
'deepseek-reasoner': 65536,
|
||||
'gemini-2.5-pro': 1000000,
|
||||
'gemini-2.5-flash': 1000000,
|
||||
'gemini-2.0-flash': 1000000,
|
||||
'glm-4-plus': 128000,
|
||||
'glm-4': 128000,
|
||||
'qwen-max': 128000,
|
||||
'qwen-plus': 128000,
|
||||
'qwen-turbo': 128000,
|
||||
}
|
||||
|
||||
const contextWindow = computed(() => {
|
||||
const model = chatStore.activeSession?.model || ''
|
||||
for (const [key, val] of Object.entries(MODEL_CONTEXT)) {
|
||||
if (model.includes(key)) return val
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + 'k'
|
||||
return String(n)
|
||||
}
|
||||
|
||||
const activeSessionSource = computed(() =>
|
||||
chatStore.activeSession?.source || '',
|
||||
)
|
||||
@@ -310,6 +361,9 @@ async function handleRenameConfirm() {
|
||||
</header>
|
||||
|
||||
<MessageList />
|
||||
<div v-if="contextWindow !== null" class="context-info">
|
||||
<span>{{ formatTokens(totalTokens) }} / {{ formatTokens(contextWindow) }}</span>
|
||||
</div>
|
||||
<ChatInput />
|
||||
</div>
|
||||
</div>
|
||||
@@ -542,4 +596,11 @@ async function handleRenameConfirm() {
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.context-info {
|
||||
padding: 0 20px 4px;
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,84 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
import type { Message } from '@/stores/chat';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import MarkdownRenderer from './MarkdownRenderer.vue';
|
||||
import type { Message } from "@/stores/chat";
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import MarkdownRenderer from "./MarkdownRenderer.vue";
|
||||
|
||||
const props = defineProps<{ message: Message }>()
|
||||
const { t } = useI18n()
|
||||
const props = defineProps<{ message: Message }>();
|
||||
const { t } = useI18n();
|
||||
|
||||
const isSystem = computed(() => props.message.role === 'system')
|
||||
const toolExpanded = ref(false)
|
||||
const isSystem = computed(() => props.message.role === "system");
|
||||
const toolExpanded = ref(false);
|
||||
|
||||
const timeStr = computed(() => {
|
||||
const d = new Date(props.message.timestamp)
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
})
|
||||
const d = new Date(props.message.timestamp);
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
});
|
||||
|
||||
function isImage(type: string): boolean {
|
||||
return type.startsWith('image/')
|
||||
return type.startsWith("image/");
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
||||
}
|
||||
|
||||
const hasAttachments = computed(() => (props.message.attachments?.length ?? 0) > 0)
|
||||
const hasAttachments = computed(
|
||||
() => (props.message.attachments?.length ?? 0) > 0,
|
||||
);
|
||||
|
||||
const hasToolDetails = computed(() => !!(props.message.toolArgs || props.message.toolResult))
|
||||
const hasToolDetails = computed(
|
||||
() => !!(props.message.toolArgs || props.message.toolResult),
|
||||
);
|
||||
|
||||
const formattedToolArgs = computed(() => {
|
||||
if (!props.message.toolArgs) return ''
|
||||
if (!props.message.toolArgs) return "";
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(props.message.toolArgs), null, 2)
|
||||
return JSON.stringify(JSON.parse(props.message.toolArgs), null, 2);
|
||||
} catch {
|
||||
return props.message.toolArgs
|
||||
return props.message.toolArgs;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const formattedToolResult = computed(() => {
|
||||
if (!props.message.toolResult) return ''
|
||||
if (!props.message.toolResult) return "";
|
||||
try {
|
||||
const parsed = JSON.parse(props.message.toolResult)
|
||||
const str = JSON.stringify(parsed, null, 2)
|
||||
const parsed = JSON.parse(props.message.toolResult);
|
||||
const str = JSON.stringify(parsed, null, 2);
|
||||
// Truncate very long output
|
||||
if (str.length > 2000) return str.slice(0, 2000) + '\n' + t('chat.truncated')
|
||||
return str
|
||||
if (str.length > 2000)
|
||||
return str.slice(0, 2000) + "\n" + t("chat.truncated");
|
||||
return str;
|
||||
} catch {
|
||||
const raw = props.message.toolResult
|
||||
if (raw.length > 2000) return raw.slice(0, 2000) + '\n' + t('chat.truncated')
|
||||
return raw
|
||||
const raw = props.message.toolResult;
|
||||
if (raw.length > 2000)
|
||||
return raw.slice(0, 2000) + "\n" + t("chat.truncated");
|
||||
return raw;
|
||||
}
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="message" :class="[message.role]">
|
||||
<template v-if="message.role === 'tool'">
|
||||
<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>
|
||||
<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>
|
||||
<span class="tool-name">{{ message.toolName }}</span>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<div v-if="toolExpanded && hasToolDetails" class="tool-details">
|
||||
<div v-if="formattedToolArgs" class="tool-detail-section">
|
||||
<div class="tool-detail-label">{{ t('chat.arguments') }}</div>
|
||||
<div class="tool-detail-label">{{ t("chat.arguments") }}</div>
|
||||
<pre class="tool-detail-code">{{ formattedToolArgs }}</pre>
|
||||
</div>
|
||||
<div v-if="formattedToolResult" class="tool-detail-section">
|
||||
<div class="tool-detail-label">{{ t('chat.result') }}</div>
|
||||
<div class="tool-detail-label">{{ t("chat.result") }}</div>
|
||||
<pre class="tool-detail-code">{{ formattedToolResult }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="msg-body">
|
||||
<img v-if="message.role === 'assistant'" src="/assets/logo.png" alt="Hermes" class="msg-avatar" />
|
||||
<img
|
||||
v-if="message.role === 'assistant'"
|
||||
src="/assets/logo.png"
|
||||
alt="Hermes"
|
||||
class="msg-avatar"
|
||||
/>
|
||||
<div class="msg-content" :class="message.role">
|
||||
<div class="message-bubble" :class="{ system: isSystem }">
|
||||
<div v-if="hasAttachments" class="msg-attachments">
|
||||
@@ -89,22 +138,41 @@ const formattedToolResult = computed(() => {
|
||||
:class="{ image: isImage(att.type) }"
|
||||
>
|
||||
<template v-if="isImage(att.type) && att.url">
|
||||
<img :src="att.url" :alt="att.name" class="msg-attachment-thumb" />
|
||||
<img
|
||||
:src="att.url"
|
||||
:alt="att.name"
|
||||
class="msg-attachment-thumb"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="msg-attachment-file">
|
||||
<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>
|
||||
<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">{{ att.name }}</span>
|
||||
<span class="att-size">{{ formatSize(att.size) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<MarkdownRenderer v-if="message.content" :content="message.content" />
|
||||
<span v-if="message.isStreaming" class="streaming-cursor"></span>
|
||||
<div v-if="message.isStreaming && !message.content" class="streaming-dots">
|
||||
<MarkdownRenderer
|
||||
v-if="message.content"
|
||||
:content="message.content"
|
||||
/>
|
||||
|
||||
<span v-if="message.isStreaming && !message.content" class="streaming-dots">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<div class="message-time">{{ timeStr }}</div>
|
||||
</div>
|
||||
@@ -114,7 +182,7 @@ const formattedToolResult = computed(() => {
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
@@ -147,9 +215,8 @@ const formattedToolResult = computed(() => {
|
||||
}
|
||||
|
||||
.msg-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
@@ -345,7 +412,9 @@ const formattedToolResult = computed(() => {
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.streaming-cursor {
|
||||
@@ -376,12 +445,26 @@ const formattedToolResult = computed(() => {
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
0%,
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
51%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
40% { opacity: 1; transform: scale(1); }
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
40% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import MessageItem from './MessageItem.vue'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import thinkingVideo from '@/assets/thinking.mp4'
|
||||
|
||||
const chatStore = useChatStore()
|
||||
const { t } = useI18n()
|
||||
const listRef = ref<HTMLElement>()
|
||||
|
||||
const displayMessages = computed(() =>
|
||||
chatStore.messages.filter(m => m.role !== 'tool'),
|
||||
)
|
||||
|
||||
const currentToolCalls = computed(() => {
|
||||
const msgs = chatStore.messages
|
||||
// Find the last user message index
|
||||
let lastUserIdx = -1
|
||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||
if (msgs[i].role === 'user') { lastUserIdx = i; break }
|
||||
}
|
||||
// Only tool calls after the last user message, newest on top
|
||||
const tools = msgs.filter((m, i) => m.role === 'tool' && i > lastUserIdx)
|
||||
return [...tools].reverse()
|
||||
})
|
||||
|
||||
function scrollToBottom() {
|
||||
nextTick(() => {
|
||||
if (listRef.value) {
|
||||
@@ -19,6 +36,7 @@ function scrollToBottom() {
|
||||
watch(() => chatStore.messages.length, scrollToBottom)
|
||||
watch(() => chatStore.messages[chatStore.messages.length - 1]?.content, scrollToBottom)
|
||||
watch(() => chatStore.isStreaming, (v) => { if (v) scrollToBottom() })
|
||||
watch(currentToolCalls, scrollToBottom)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -28,13 +46,38 @@ watch(() => chatStore.isStreaming, (v) => { if (v) scrollToBottom() })
|
||||
<p>{{ t('chat.emptyState') }}</p>
|
||||
</div>
|
||||
<MessageItem
|
||||
v-for="msg in chatStore.messages"
|
||||
v-for="msg in displayMessages"
|
||||
:key="msg.id"
|
||||
:message="msg"
|
||||
/>
|
||||
<div v-if="chatStore.isStreaming" class="streaming-indicator">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
<Transition name="fade">
|
||||
<div v-if="chatStore.isStreaming" class="streaming-indicator">
|
||||
<video :src="thinkingVideo" autoplay loop muted playsinline class="thinking-video" />
|
||||
<div v-if="currentToolCalls.length > 0" class="tool-calls-panel">
|
||||
<div
|
||||
v-for="tc in currentToolCalls"
|
||||
:key="tc.id"
|
||||
class="tool-call-item"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="tool-call-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>
|
||||
<span class="tool-call-name">{{ tc.toolName }}</span>
|
||||
<span v-if="tc.toolPreview" class="tool-call-preview">{{ tc.toolPreview }}</span>
|
||||
<span v-if="tc.toolStatus === 'running'" class="tool-call-spinner"></span>
|
||||
<span v-if="tc.toolStatus === 'error'" class="tool-call-error">{{ t('chat.error') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -48,6 +91,7 @@ watch(() => chatStore.isStreaming, (v) => { if (v) scrollToBottom() })
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
@@ -70,27 +114,91 @@ watch(() => chatStore.isStreaming, (v) => { if (v) scrollToBottom() })
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.streaming-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 4px;
|
||||
color: $text-muted;
|
||||
|
||||
span {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
background-color: $text-muted;
|
||||
border-radius: 50%;
|
||||
animation: stream-pulse 1.4s infinite ease-in-out;
|
||||
|
||||
&:nth-child(2) { animation-delay: 0.2s; }
|
||||
&:nth-child(3) { animation-delay: 0.4s; }
|
||||
.thinking-video {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: $radius-md;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes stream-pulse {
|
||||
0%, 80%, 100% { opacity: 0.2; transform: scale(0.8); }
|
||||
40% { opacity: 1; transform: scale(1); }
|
||||
.tool-calls-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
padding-top: 4px;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
}
|
||||
|
||||
.tool-call-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: $text-secondary;
|
||||
padding: 3px 8px;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border-radius: $radius-sm;
|
||||
|
||||
.tool-call-icon {
|
||||
flex-shrink: 0;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.tool-call-name {
|
||||
font-family: $font-code;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tool-call-preview {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 300px;
|
||||
color: $text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-call-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-call-error {
|
||||
font-size: 9px;
|
||||
color: $error;
|
||||
background: rgba($error, 0.08);
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user