diff --git a/desktop/renderer/src/App.vue b/desktop/renderer/src/App.vue index bc7f431..b1af839 100644 --- a/desktop/renderer/src/App.vue +++ b/desktop/renderer/src/App.vue @@ -82,7 +82,6 @@ - @@ -151,7 +150,6 @@ import { ElMessage } from "element-plus"; import SidePanel from "@/components/SidePanel.vue"; import GatewayLoading from "@/components/GatewayLoading.vue"; import PermissionDialog from "@/components/PermissionDialog.vue"; -import UsagePanel from "@/components/UsagePanel.vue"; import { useAgentStore } from "@/stores/agents"; import { useGatewayStore } from "@/stores/gateway"; import { useChatStore } from "@/stores/chat"; diff --git a/desktop/renderer/src/components/SidePanel.vue b/desktop/renderer/src/components/SidePanel.vue index 3caeaa7..84b749c 100644 --- a/desktop/renderer/src/components/SidePanel.vue +++ b/desktop/renderer/src/components/SidePanel.vue @@ -134,25 +134,6 @@ - - - - - - - - {{ t("sidebar.usage") }} - - {{ t("sidebar.settings") }} + + + + {{ t("sidebar.usageSnapshotTitle") }} + + + + + + + + + {{ t("settings.usageLoading") }} + + + {{ t("sidebar.usageSnapshotSpend") }} + {{ formatCny(usageSnapshot.totalSpend) }} + + + {{ t("sidebar.usageSnapshotTokens") }} + {{ formatCompact(usageSnapshot.totalTokens) }} + + + {{ t("sidebar.usageSnapshotOffline") }} + {{ t("sidebar.usageSnapshotNoData") }} + + + {{ t("sidebar.usageSnapshotPeriod") }} + {{ t("sidebar.usageSnapshotUpdated", { time: lastUpdatedLabel }) }} + + + + @@ -189,7 +222,6 @@ import { useChatStore } from "@/stores/chat"; import { useSessionStore } from "@/stores/sessions"; import { useAgentStore } from "@/stores/agents"; import { t } from "@/i18n"; -import { useUsagePanel } from "@/composables/useUsagePanel"; import IconPlus from "@/components/icons/IconPlus.vue"; import IconChevronDown from "@/components/icons/IconChevronDown.vue"; import IconClose from "@/components/icons/IconClose.vue"; @@ -201,7 +233,20 @@ const chatStore = useChatStore(); const sessionStore = useSessionStore(); const agentStore = useAgentStore(); -const { open: openUsage } = useUsagePanel(); +interface UsageSnapshot { + totalSpend: number; + totalTokens: number; + exchangeRate: number | null; +} + +// Fallback mirrors USD_TO_CNY_FALLBACK_RATE in SettingsView.vue / the main process. +const USD_TO_CNY_FALLBACK_RATE = 7.2; + +const usageSnapshot = ref(null); +const usageLoading = ref(false); +const usageLastUpdated = ref(null); +const usageCollapsed = ref(false); +let usageRefreshTimer: ReturnType | null = null; const chatExpanded = ref(true); const isAgentFlyoutOpen = ref(false); @@ -217,6 +262,12 @@ const flyoutStyle = computed(() => ({ left: `${flyoutLeft.value}px`, })); +const gatewayOnline = computed(() => gateway.ready && gateway.status === "running"); +const lastUpdatedLabel = computed(() => { + if (!usageLastUpdated.value) return ""; + return usageLastUpdated.value.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +}); + const filteredAgents = computed(() => { const q = agentQuery.value.trim().toLowerCase(); if (!q) return agentStore.agents; @@ -235,10 +286,17 @@ const currentAgent = computed(() => { onMounted(() => { gateway.refreshWeixinStatus(); + try { + usageCollapsed.value = localStorage.getItem("microclaw:usageSnapshotCollapsed") === "1"; + } catch {} document.addEventListener("mousedown", handleDocumentPointerDown); document.addEventListener("keydown", handleDocumentKeyDown); window.addEventListener("resize", handleWindowResize); window.addEventListener("scroll", handleWindowScroll, true); + void loadUsageSnapshot(); + usageRefreshTimer = setInterval(() => { + void loadUsageSnapshot(); + }, 60_000); }); onUnmounted(() => { @@ -246,8 +304,72 @@ onUnmounted(() => { document.removeEventListener("keydown", handleDocumentKeyDown); window.removeEventListener("resize", handleWindowResize); window.removeEventListener("scroll", handleWindowScroll, true); + if (usageRefreshTimer) { + clearInterval(usageRefreshTimer); + usageRefreshTimer = null; + } }); +async function loadUsageSnapshot() { + if (usageLoading.value) return; + usageLoading.value = true; + try { + const usageApi = window.openclaw?.usage?.getStats; + if (!gatewayOnline.value || typeof usageApi !== "function") { + if (import.meta.env.DEV) { + applyMockSnapshot(); + } else { + usageSnapshot.value = null; + } + return; + } + + const stats = await usageApi(); + usageSnapshot.value = { + totalSpend: Number(stats?.totalSpend || 0), + totalTokens: Number(stats?.totalTokens || 0), + exchangeRate: Number(stats?.exchangeRate) || null, + }; + usageLastUpdated.value = new Date(); + } catch { + if (import.meta.env.DEV) { + applyMockSnapshot(); + } else { + usageSnapshot.value = null; + } + } finally { + usageLoading.value = false; + } +} + +function applyMockSnapshot() { + usageSnapshot.value = { + totalSpend: 42.35, + totalTokens: 128400, + exchangeRate: null, + }; + usageLastUpdated.value = new Date(); +} + +function toggleUsageCollapsed() { + usageCollapsed.value = !usageCollapsed.value; + try { + localStorage.setItem("microclaw:usageSnapshotCollapsed", usageCollapsed.value ? "1" : "0"); + } catch {} +} + +function formatCompact(value: number) { + return new Intl.NumberFormat(undefined, { + notation: "compact", + maximumFractionDigits: 1, + }).format(value || 0); +} + +function formatCny(value: number) { + const rate = usageSnapshot.value?.exchangeRate ?? USD_TO_CNY_FALLBACK_RATE; + return `${t("settings.currencySymbol")}${((value || 0) * rate).toFixed(2)}`; +} + /** * Ensure the active chat is a fresh session bound to the currently-selected agent. * Starts a new session when the current chat already has messages, OR when it @@ -688,6 +810,109 @@ html.dark .sp-create-btn:hover { min-height: 0; } +.sp-usage-snapshot { + padding: 10px clamp(8px, 1.5vw, 16px) 12px; + background: #fbfbf9; +} + +html.dark .sp-usage-snapshot { + background: var(--bg-secondary); +} + +.sp-usage-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} + +.sp-usage-title { + font-size: 12px; + color: var(--text-secondary); + font-weight: 600; +} + +.sp-usage-toggle { + border: none; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + width: 18px; + height: 18px; + padding: 0; + line-height: 1; + display: grid; + place-items: center; + border-radius: 4px; + font-size: 11px; +} + +.sp-usage-toggle-icon { + transition: transform 0.2s ease; +} + +.sp-usage-toggle-icon.collapsed { + transform: rotate(-90deg); +} + +.sp-usage-toggle:hover { + background: #f0ece7; + color: var(--text-primary); +} + +html.dark .sp-usage-toggle:hover { + background: var(--bg-tertiary); +} + +.sp-usage-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; +} + +.sp-usage-card { + border: 1px solid #ece7e2; + border-radius: 8px; + padding: 6px; + background: #fff; + display: flex; + flex-direction: column; + gap: 4px; +} + +html.dark .sp-usage-card { + border-color: var(--border); + background: var(--bg-primary); +} + +.sp-usage-label { + font-size: 10px; + color: var(--text-muted); +} + +.sp-usage-value { + font-size: 12px; + color: var(--text-primary); + font-weight: 600; + line-height: 1.2; +} + +.sp-usage-state { + font-size: 12px; + color: var(--text-muted); + padding: 4px 0; +} + +.sp-usage-foot { + margin-top: 8px; + display: flex; + justify-content: space-between; + gap: 8px; + font-size: 10px; + color: var(--text-muted); +} + /* ── Chat section ── */ .sp-section { margin-bottom: 8px; diff --git a/desktop/renderer/src/i18n/en-US.ts b/desktop/renderer/src/i18n/en-US.ts index a0197bc..446bb5b 100644 --- a/desktop/renderer/src/i18n/en-US.ts +++ b/desktop/renderer/src/i18n/en-US.ts @@ -48,6 +48,20 @@ export default { "sidebar.chatDeleteFailed": "Failed to delete chat: {error}", "sidebar.phone": "Phone", "sidebar.tagline": "Weave the future through conversation", + "sidebar.usageSnapshotTitle": "Usage", + "sidebar.usageSnapshotViewDetails": "Details", + "sidebar.usageSnapshotSpend": "Spend", + "sidebar.usageSnapshotTokens": "Tokens", + "sidebar.usageSnapshotRequests": "Requests", + "sidebar.usageSnapshotHealth": "Health", + "sidebar.usageSnapshotOffline": "Gateway offline", + "sidebar.usageSnapshotNoData": "No usage data yet", + "sidebar.usageSnapshotPeriod": "Last 30 days", + "sidebar.usageSnapshotUpdated": "Updated {time}", + "sidebar.usageSnapshotExpand": "Expand usage snapshot", + "sidebar.usageSnapshotCollapse": "Collapse usage snapshot", + "sidebar.usageSnapshotMock": "Mock data", + "sidebar.usageSnapshotSimulated": "Simulated", // ── HomeView ── "home.heading": "Ready? Pick a task below to start exploring!", diff --git a/desktop/renderer/src/i18n/zh-CN.ts b/desktop/renderer/src/i18n/zh-CN.ts index 9fafcc3..d27aedd 100644 --- a/desktop/renderer/src/i18n/zh-CN.ts +++ b/desktop/renderer/src/i18n/zh-CN.ts @@ -46,6 +46,20 @@ export default { "sidebar.chatDeleteFailed": "删除对话失败:{error}", "sidebar.phone": "手机", "sidebar.tagline": "透过对话,编织万物未来", + "sidebar.usageSnapshotTitle": "使用情况", + "sidebar.usageSnapshotViewDetails": "查看详情", + "sidebar.usageSnapshotSpend": "花费", + "sidebar.usageSnapshotTokens": "Tokens", + "sidebar.usageSnapshotRequests": "请求", + "sidebar.usageSnapshotHealth": "状态", + "sidebar.usageSnapshotOffline": "网关未连接", + "sidebar.usageSnapshotNoData": "暂无用量数据", + "sidebar.usageSnapshotPeriod": "最近 30 天", + "sidebar.usageSnapshotUpdated": "更新于 {time}", + "sidebar.usageSnapshotExpand": "展开使用概览", + "sidebar.usageSnapshotCollapse": "收起使用概览", + "sidebar.usageSnapshotMock": "模拟数据", + "sidebar.usageSnapshotSimulated": "模拟", // ── HomeView ── "home.heading": "准备好了?从下面挑一个任务开始探索吧!", diff --git a/desktop/renderer/src/views/SettingsView.vue b/desktop/renderer/src/views/SettingsView.vue index 7fc1159..42c5acc 100644 --- a/desktop/renderer/src/views/SettingsView.vue +++ b/desktop/renderer/src/views/SettingsView.vue @@ -86,7 +86,7 @@ {{ t("settings.totalSpend") }} {{ t("settings.currencySymbol") }}{{ toCny(usageData.totalSpend).toFixed(4) }}{{ t("settings.currencySymbol") }}{{ toCny(usageData.totalSpend).toFixed(2) }} @@ -165,7 +165,7 @@ > {{ t("settings.currencySymbol") }}{{ toCny(m.spend).toFixed(4) }}{{ t("settings.currencySymbol") }}{{ toCny(m.spend).toFixed(2) }}