diff --git a/cc-haha/desktop/package.json b/cc-haha/desktop/package.json index c8b8368..7051ce5 100644 --- a/cc-haha/desktop/package.json +++ b/cc-haha/desktop/package.json @@ -1,7 +1,7 @@ { "name": "heicode-desktop", "private": true, - "version": "0.1.9", + "version": "0.1.10", "type": "module", "scripts": { "dev": "vite", diff --git a/cc-haha/desktop/src-tauri/Cargo.toml b/cc-haha/desktop/src-tauri/Cargo.toml index a871118..a8154c7 100644 --- a/cc-haha/desktop/src-tauri/Cargo.toml +++ b/cc-haha/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "heicode-desktop" -version = "0.1.9" +version = "0.1.10" edition = "2021" [lib] diff --git a/cc-haha/desktop/src-tauri/tauri.conf.json b/cc-haha/desktop/src-tauri/tauri.conf.json index d147aa1..294a96a 100644 --- a/cc-haha/desktop/src-tauri/tauri.conf.json +++ b/cc-haha/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicegui/nicegui/main/nicegui/static/tauri-schema-v2.json", "productName": "HeiCode", - "version": "0.1.9", + "version": "0.1.10", "identifier": "com.heicode.desktop", "build": { "frontendDist": "../dist", diff --git a/cc-haha/desktop/src/components/chat/BalanceBar.tsx b/cc-haha/desktop/src/components/chat/BalanceBar.tsx index 7d5ff51..edf3371 100644 --- a/cc-haha/desktop/src/components/chat/BalanceBar.tsx +++ b/cc-haha/desktop/src/components/chat/BalanceBar.tsx @@ -1,5 +1,4 @@ -import { useEffect, useState } from 'react' -import { heicodeAuthApi, type HeicodeBalance } from '../../api/heicodeAuth' +import { useBalanceStore } from '../../stores/balanceStore' import { useTranslation } from '../../i18n' // Quota unit convention (NewAPI / Heicode): 500,000 units = USD $1. The @@ -7,23 +6,12 @@ import { useTranslation } from '../../i18n' // just hard-code the constant — display is informational, not used for // charging math. const QUOTA_PER_USD = 500_000 -const POLL_MS = 60_000 function formatUsd(quota: number): string { const usd = quota / QUOTA_PER_USD return `$${usd >= 100 ? usd.toFixed(2) : usd >= 1 ? usd.toFixed(2) : usd.toFixed(4)}` } -/** - * Bar fill semantics: `remaining / (remaining + used)` — how much of the - * lifetime-funded total is still in the wallet. 100% = brand-new account - * that hasn't spent anything; 0% = quota fully consumed. - * - * Color thresholds: - * ≥ 30% → green (healthy) - * ≥ 10% → amber (getting low) - * < 10% → red (top up soon) - */ function healthOf(remainingFraction: number): { color: string; bg: string } { if (remainingFraction >= 0.3) { return { color: 'var(--color-success)', bg: 'rgba(34,197,94,0.16)' } @@ -36,29 +24,28 @@ function healthOf(remainingFraction: number): { color: string; bg: string } { /** * Compact balance pill with progress bar that lives right under the chat - * composer. Renders nothing until we have a positive answer — silent on - * logged-out / failed states so the layout doesn't flash an error strip. + * composer. Reads from the shared balanceStore so the user-card avatar + * ring stays in sync without a second fetch. */ export function BalanceBar() { - const [balance, setBalance] = useState(null) + const balance = useBalanceStore((s) => s.balance) + const initializing = useBalanceStore((s) => s.initializing) const t = useTranslation() - useEffect(() => { - let cancelled = false - let timer: ReturnType | null = null - const tick = async () => { - const b = await heicodeAuthApi.balance() - if (!cancelled) setBalance(b) - } - void tick() - timer = setInterval(tick, POLL_MS) - return () => { - cancelled = true - if (timer) clearInterval(timer) - } - }, []) - - if (!balance) return null + if (!balance) { + // Hide entirely after initial load fails so we don't squat layout + // for a permanently-broken endpoint. Show a muted placeholder only + // during the first fetch. + if (!initializing) return null + return ( +
+
+ account_balance_wallet + {t('balance.loading')} +
+
+ ) + } const total = balance.quota + balance.usedQuota const remainingFraction = total > 0 ? balance.quota / total : 1 @@ -88,9 +75,6 @@ export function BalanceBar() { )} - {/* Progress bar — fills with remaining / total. The full track is the - lifetime-funded amount; the filled portion is what's still in the - wallet. Color tracks the same green/amber/red health bands. */}
{ + if (!authHasFetched) return + if (!authStatus?.loggedIn) return + const store = useBalanceStore.getState() + store.start() + return () => store.stop() + }, [authHasFetched, authStatus?.loggedIn]) + // Listen for macOS native menu navigation events (About / Settings) useEffect(() => { let unlisten: (() => void) | undefined diff --git a/cc-haha/desktop/src/components/layout/Sidebar.tsx b/cc-haha/desktop/src/components/layout/Sidebar.tsx index 19c8cb7..a999aa9 100644 --- a/cc-haha/desktop/src/components/layout/Sidebar.tsx +++ b/cc-haha/desktop/src/components/layout/Sidebar.tsx @@ -13,6 +13,7 @@ import { } from '../../stores/tabStore' import { useChatStore } from '../../stores/chatStore' import { useHeicodeAuthStore } from '../../stores/heicodeAuthStore' +import { useBalanceRemainingFraction } from '../../stores/balanceStore' const isTauri = typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window) const isWindows = typeof navigator !== 'undefined' && /Win/.test(navigator.platform) @@ -619,6 +620,55 @@ function openManagerProfile() { .catch(() => window.open(HEICODE_MANAGER_URL, '_blank')) } +/** + * Tiny SVG ring around the avatar showing balance health. The arc length + * equals `remainingFraction`; color mirrors BalanceBar's traffic-light + * thresholds (green ≥30%, amber ≥10%, red <10%). When balance is null + * we skip the ring entirely so a logged-out / pre-fetch state doesn't + * paint a stale color. + */ +function BalanceRing({ size, remainingFraction }: { size: number; remainingFraction: number | null }) { + if (remainingFraction === null) return null + const stroke = 2 + const r = (size - stroke) / 2 + const circumference = 2 * Math.PI * r + const clamped = Math.max(0, Math.min(1, remainingFraction)) + const color = + clamped >= 0.3 ? 'var(--color-success)' : + clamped >= 0.1 ? 'var(--color-warning)' : + 'var(--color-error)' + return ( + + + + + ) +} + function SidebarUserCard({ user, collapsed, @@ -637,17 +687,19 @@ function SidebarUserCard({ const roleBadge = role && role !== 'user' && role !== '1' ? role : null const t = useTranslation() const profileTitle = t('sidebar.userCard.openProfile') + const balanceRemainingFraction = useBalanceRemainingFraction() if (collapsed) { return ( ) } @@ -664,10 +716,11 @@ function SidebarUserCard({ }} > {initial} +
diff --git a/cc-haha/desktop/src/i18n/locales/en.ts b/cc-haha/desktop/src/i18n/locales/en.ts index 694802f..0278898 100644 --- a/cc-haha/desktop/src/i18n/locales/en.ts +++ b/cc-haha/desktop/src/i18n/locales/en.ts @@ -42,6 +42,7 @@ export const en = { 'balance.usedPrefix': 'used ', 'balance.requestsSuffix': 'reqs', 'balance.tooltip': 'Balance from Heicode platform · bar shows remaining / total', + 'balance.loading': 'Loading balance…', 'sidebar.collapse': 'Collapse sidebar', 'sidebar.expand': 'Expand sidebar', 'sidebar.logout': 'Log out', diff --git a/cc-haha/desktop/src/i18n/locales/zh.ts b/cc-haha/desktop/src/i18n/locales/zh.ts index 07bc7d2..ea8dc10 100644 --- a/cc-haha/desktop/src/i18n/locales/zh.ts +++ b/cc-haha/desktop/src/i18n/locales/zh.ts @@ -44,6 +44,7 @@ export const zh: Record = { 'balance.usedPrefix': '已用 ', 'balance.requestsSuffix': '次', 'balance.tooltip': '余额来自 Heicode 平台 · 进度条为剩余 / 总额', + 'balance.loading': '余额加载中…', 'sidebar.collapse': '折叠侧边栏', 'sidebar.expand': '展开侧边栏', 'sidebar.logout': '退出登录', diff --git a/cc-haha/desktop/src/stores/balanceStore.ts b/cc-haha/desktop/src/stores/balanceStore.ts new file mode 100644 index 0000000..b6459ff --- /dev/null +++ b/cc-haha/desktop/src/stores/balanceStore.ts @@ -0,0 +1,75 @@ +// desktop/src/stores/balanceStore.ts +// +// Single source of truth for the Heicode balance pill + user-card ring. +// One polling loop, multiple subscribers — avoids stampeding the +// `/api/heicode-auth/balance` endpoint when both surfaces are visible. + +import { create } from 'zustand' +import { heicodeAuthApi, type HeicodeBalance } from '../api/heicodeAuth' +import { isTauriRuntime } from '../lib/desktopRuntime' + +const POLL_MS = 60_000 + +type BalanceState = { + balance: HeicodeBalance | null + /** True while the very first fetch is still in flight; UI uses this + * to show a "loading" placeholder instead of an absent widget. */ + initializing: boolean + + /** Start the polling loop. Idempotent — subsequent calls no-op. + * Mount this once from AppShell. */ + start: () => void + /** Tear-down for tests / logout flows. */ + stop: () => void + /** Manual refresh without waiting for the next tick. */ + refresh: () => Promise +} + +let timer: ReturnType | null = null + +export const useBalanceStore = create((set) => ({ + balance: null, + initializing: true, + + start: () => { + if (timer) return + const tick = async () => { + const b = await heicodeAuthApi.balance() + set({ balance: b, initializing: false }) + } + // Skip the network call entirely when we're in the dev / browser + // preview without the tauri host. Most callers won't be logged in + // there anyway, and the fetch surfaces are noisy when it 401s. + if (!isTauriRuntime()) { + set({ initializing: false }) + return + } + void tick() + timer = setInterval(tick, POLL_MS) + }, + + stop: () => { + if (timer) { + clearInterval(timer) + timer = null + } + set({ balance: null, initializing: true }) + }, + + refresh: async () => { + if (!isTauriRuntime()) return + const b = await heicodeAuthApi.balance() + set({ balance: b, initializing: false }) + }, +})) + +/** Convenience selector — `null` while loading or when balance is + * genuinely unavailable; the BalanceRing component treats both as + * "don't draw an arc". */ +export function useBalanceRemainingFraction(): number | null { + const balance = useBalanceStore((s) => s.balance) + if (!balance) return null + const total = balance.quota + balance.usedQuota + if (total <= 0) return 1 + return Math.max(0, Math.min(1, balance.quota / total)) +} diff --git a/cc-haha/src/server/api/heicode-auth.ts b/cc-haha/src/server/api/heicode-auth.ts index 1a5f7e9..de5604d 100644 --- a/cc-haha/src/server/api/heicode-auth.ts +++ b/cc-haha/src/server/api/heicode-auth.ts @@ -142,10 +142,13 @@ export async function handleHeicodeAuthApi( return Response.json({ ok: true }) } - // GET /api/heicode-auth/balance — proxies mcp-server §4 to surface - // the active user's remaining quota / usage in the desktop UI. Reads - // the access token from the active provider's mcpAuth record (same - // source the in-app TitleBar uses for the user pill). + // GET /api/heicode-auth/balance — try mcp-server §4 first, fall + // back to hitting the Heicode NewAPI's own /api/user/self with the + // provider's API key when mcp-server reports the user as unmirrored + // (HEICODE_USER_NOT_FOUND — happens for admin/root accounts that + // never came through the from-agnet onboarding flow). The fallback + // shape is rewritten to the same HeicodeBalance envelope so the + // UI doesn't care which path served it. if (action === 'balance' && req.method === 'GET') { const { providers, activeId } = await providerService.listProviders() const active = activeId ? providers.find((p) => p.id === activeId) : null @@ -155,11 +158,66 @@ export async function handleHeicodeAuthApi( { status: 401, headers: { 'Content-Type': 'application/json' } }, ) } - const base = active.mcpAuth.managerLoginUrl.replace(/\/+$/, '') - const upstream = await fetch(`${base}/api/user/heicode/balance`, { + const mcpBase = active.mcpAuth.managerLoginUrl.replace(/\/+$/, '') + const upstream = await fetch(`${mcpBase}/api/user/heicode/balance`, { method: 'GET', headers: { 'Authorization': `Bearer ${active.mcpAuth.accessToken}` }, }) + // Happy path — mcp-server knows the user. + if (upstream.ok) { + const body = await upstream.text() + return new Response(body, { + status: 200, + headers: { 'Content-Type': upstream.headers.get('Content-Type') ?? 'application/json' }, + }) + } + // Fallback: hit Heicode NewAPI directly. Only applies if the + // provider stores an API key (Heicode preset has needsApiKey=true). + const heicodeBase = active.baseUrl?.replace(/\/+$/, '') ?? '' + if (active.apiKey && heicodeBase) { + try { + const self = await fetch(`${heicodeBase}/api/user/self`, { + method: 'GET', + headers: { 'Authorization': `Bearer ${active.apiKey}` }, + }) + if (self.ok) { + const json = await self.json() as { + data?: { + id?: number + username?: string + email?: string + display_name?: string + group?: string + status?: number + quota?: number + used_quota?: number + request_count?: number + } + } + const u = json.data ?? {} + return new Response( + JSON.stringify({ + success: true, + data: { + heicodeUserId: u.id ?? 0, + username: u.username, + email: u.email, + displayName: u.display_name, + group: u.group, + status: u.status, + quota: u.quota ?? 0, + usedQuota: u.used_quota ?? 0, + requestCount: u.request_count, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ) + } + } catch { + // fall through to forward mcp-server's original error + } + } + // Neither path worked — return whatever mcp-server said. const body = await upstream.text() return new Response(body, { status: upstream.status, diff --git a/heicode/controller/desktop_download.go b/heicode/controller/desktop_download.go index 0e4279e..991fc4b 100644 --- a/heicode/controller/desktop_download.go +++ b/heicode/controller/desktop_download.go @@ -124,6 +124,19 @@ func filenameFromURL(rawURL string) string { return rawURL } +// Fallback URLs for platforms the live manifest doesn't currently carry. +// We ship Win and Mac on different cadences (Mac builds depend on physical +// hardware access). When a release goes out Win-only, the manifest only +// names windows-x86_64 — but the Manager download page should still show +// the latest Mac binary that ever shipped to blob, so Mac users aren't +// stranded. +// +// Update these constants whenever a NEW Mac binary is uploaded to blob. +const ( + fallbackMacArmVersion = "0.1.5" + fallbackMacArmURL = "https://heicodeblob.blob.core.windows.net/msi/desktop/0.1.5/HeiCode.app.tar.gz" +) + // GetDesktopDownloads returns the latest released desktop client metadata // (per platform) for authenticated users. func GetDesktopDownloads(c *gin.Context) { @@ -136,7 +149,7 @@ func GetDesktopDownloads(c *gin.Context) { return } - items := make([]desktopDownloadItem, 0, len(manifest.Platforms)) + items := make([]desktopDownloadItem, 0, 3) add := func(id, label, key string) { p, ok := manifest.Platforms[key] if !ok || strings.TrimSpace(p.URL) == "" { @@ -153,12 +166,26 @@ func GetDesktopDownloads(c *gin.Context) { add("macos_arm64", "macOS (Apple silicon)", "darwin-aarch64") add("macos_x64", "macOS (Intel)", "darwin-x86_64") + // If the live manifest doesn't carry a Mac arm64 entry (Win-only + // release), splice in the last known Mac binary so the download + // page never goes blank on Mac users. + if _, hasMac := manifest.Platforms["darwin-aarch64"]; !hasMac { + items = append(items, desktopDownloadItem{ + ID: "macos_arm64", + Label: "macOS (Apple silicon, " + fallbackMacArmVersion + ")", + Filename: filenameFromURL(fallbackMacArmURL), + DownloadURL: fallbackMacArmURL, + }) + } + c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", + // `Notes` deliberately dropped here — the download page is for the + // download links + version number only; users don't want a wall of + // release-note text below it. "data": desktopDownloadsPayload{ Version: manifest.Version, - Notes: manifest.Notes, Items: items, }, }) diff --git a/heicode/web/default/src/features/desktop-client-download/desktop-client-download-page.tsx b/heicode/web/default/src/features/desktop-client-download/desktop-client-download-page.tsx index a747251..4153c88 100644 --- a/heicode/web/default/src/features/desktop-client-download/desktop-client-download-page.tsx +++ b/heicode/web/default/src/features/desktop-client-download/desktop-client-download-page.tsx @@ -41,7 +41,7 @@ export function DesktopClientDownloadPage() { {t( - 'Installers are served only after you sign in. Place files on the server and set HEICODE_DESKTOP_FILE_* environment variables.', + 'Sign in required to access the installers.', )} @@ -58,11 +58,6 @@ export function DesktopClientDownloadPage() {

{t('Version: {{version}}', { version: payload.version })}

- {payload.notes ? ( -

- {payload.notes} -

- ) : null}
    {payload.items.map((item) => (