release: 0.1.10 — balance widget actually shows + avatar ring

Balance pill was rendering nothing for admin/root accounts because
mcp-server's §4 returns HEICODE_USER_NOT_FOUND for users that never
came through from-agnet onboarding. Fall back to Heicode NewAPI's
own /api/user/self when that happens; reshape into the same envelope
so the UI is path-agnostic.

New balanceStore (zustand) — single polling loop, BalanceBar +
avatar ring share it. AppShell starts it once auth bootstraps.
BalanceBar now shows a "loading…" placeholder on first fetch so
the widget is visible from frame one.

SidebarUserCard avatar wears an SVG ring whose arc length tracks
remaining/(remaining+used) and color hits the same green→amber→red
thresholds as the bar.

Manager: /desktop-client drops the manifest-notes wall of text, the
old HEICODE_DESKTOP_FILE_* subtitle goes away, and a Mac fallback
entry is always spliced in when the live manifest is Win-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 16:44:13 +08:00
co-authored by Claude Opus 4.7
parent 47a74eaf03
commit ac1aa87312
12 changed files with 260 additions and 54 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "heicode-desktop",
"private": true,
"version": "0.1.9",
"version": "0.1.10",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "heicode-desktop"
version = "0.1.9"
version = "0.1.10"
edition = "2021"
[lib]
+1 -1
View File
@@ -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",
@@ -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<HeicodeBalance | null>(null)
const balance = useBalanceStore((s) => s.balance)
const initializing = useBalanceStore((s) => s.initializing)
const t = useTranslation()
useEffect(() => {
let cancelled = false
let timer: ReturnType<typeof setInterval> | null = null
const tick = async () => {
const b = await heicodeAuthApi.balance()
if (!cancelled) setBalance(b)
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 (
<div className="mx-auto mb-2 mt-1 w-fit rounded-[14px] border border-[var(--color-border)]/30 bg-[var(--color-surface-container-low)]/40 px-3 py-1.5 text-[11px] text-[var(--color-text-tertiary)]">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[14px]">account_balance_wallet</span>
<span>{t('balance.loading')}</span>
</div>
</div>
)
}
void tick()
timer = setInterval(tick, POLL_MS)
return () => {
cancelled = true
if (timer) clearInterval(timer)
}
}, [])
if (!balance) return null
const total = balance.quota + balance.usedQuota
const remainingFraction = total > 0 ? balance.quota / total : 1
@@ -88,9 +75,6 @@ export function BalanceBar() {
)}
</span>
</div>
{/* 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. */}
<div
className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full"
style={{ backgroundColor: health.bg }}
@@ -8,6 +8,7 @@ import { HeicodeLoginPage } from '../login/HeicodeLoginPage'
import { useSettingsStore } from '../../stores/settingsStore'
import { useUIStore, type SettingsTab } from '../../stores/uiStore'
import { useHeicodeAuthStore } from '../../stores/heicodeAuthStore'
import { useBalanceStore } from '../../stores/balanceStore'
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts'
import { initializeDesktopServerUrl } from '../../lib/desktopRuntime'
import { TabBar } from './TabBar'
@@ -62,6 +63,17 @@ export function AppShell() {
}
}, [fetchSettings, fetchAuth])
// Start the shared balance poller once the auth bootstrap is done so
// BalanceBar + the avatar ring read from the same in-memory source.
// Idempotent on the store side; stops when AppShell unmounts (rare).
useEffect(() => {
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
@@ -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 (
<svg
className="pointer-events-none absolute inset-0"
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
aria-hidden
>
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke={color}
strokeOpacity={0.18}
strokeWidth={stroke}
/>
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke={color}
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={`${circumference * clamped} ${circumference}`}
transform={`rotate(-90 ${size / 2} ${size / 2})`}
/>
</svg>
)
}
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 (
<button
type="button"
onClick={openManagerProfile}
className="mb-2 flex h-9 w-9 cursor-pointer items-center justify-center rounded-full text-[12px] font-bold text-white transition-opacity hover:opacity-85"
className="relative mb-2 flex h-9 w-9 cursor-pointer items-center justify-center rounded-full text-[12px] font-bold text-white transition-opacity hover:opacity-85"
style={{ backgroundImage: 'var(--gradient-brand-wordmark)' }}
title={`${profileTitle}\n${user.email || name}`}
>
{initial}
<BalanceRing size={36} remainingFraction={balanceRemainingFraction} />
</button>
)
}
@@ -664,10 +716,11 @@ function SidebarUserCard({
}}
>
<span
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
className="relative flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
style={{ backgroundImage: 'var(--gradient-brand-wordmark)' }}
>
{initial}
<BalanceRing size={32} remainingFraction={balanceRemainingFraction} />
</span>
<div className="min-w-0 flex-1 leading-tight">
<div className="truncate text-[12px] font-semibold text-[var(--color-text-primary)]">
+1
View File
@@ -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',
+1
View File
@@ -44,6 +44,7 @@ export const zh: Record<TranslationKey, string> = {
'balance.usedPrefix': '已用 ',
'balance.requestsSuffix': '次',
'balance.tooltip': '余额来自 Heicode 平台 · 进度条为剩余 / 总额',
'balance.loading': '余额加载中…',
'sidebar.collapse': '折叠侧边栏',
'sidebar.expand': '展开侧边栏',
'sidebar.logout': '退出登录',
@@ -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<void>
}
let timer: ReturnType<typeof setInterval> | null = null
export const useBalanceStore = create<BalanceState>((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))
}
+64 -6
View File
@@ -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,
+29 -2
View File
@@ -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,
},
})
@@ -41,7 +41,7 @@ export function DesktopClientDownloadPage() {
</SectionPageLayout.Title>
<SectionPageLayout.Description>
{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.',
)}
</SectionPageLayout.Description>
<SectionPageLayout.Content>
@@ -58,11 +58,6 @@ export function DesktopClientDownloadPage() {
<p className='text-muted-foreground text-sm'>
{t('Version: {{version}}', { version: payload.version })}
</p>
{payload.notes ? (
<p className='text-muted-foreground whitespace-pre-wrap text-sm'>
{payload.notes}
</p>
) : null}
<ul className='flex flex-col gap-3'>
{payload.items.map((item) => (
<li