release: 0.1.6 — balance pill under composer + Manager download fix

cc-haha desktop (0.1.6):
- New sidecar route `/api/heicode-auth/balance` proxies mcp-server
  §4 `/api/user/heicode/balance` using the active provider's stored
  access token. Returns null silently on 401 / network failure so
  the UI doesn't flash error strips.
- New BalanceBar component renders a compact pill right below the
  ChatInput: `[wallet icon] $X.XX 剩余 · 已用 $Y.YY · N 次`. Polls
  every 60s. Hidden when not logged in.
- Quota → USD display uses NewAPI convention (500_000 units = $1).

heicode Manager (Go controller):
- `GetDesktopDownloads` rewritten to pull from the same Azure Blob
  updater manifest the in-app updater uses (`heicodeblob/.../
  updater/latest.json`). 5-min in-process cache; stale-on-error
  fallback. Stops the Manager web from showing stale
  `Heicode_0.1.0_x64-setup.exe` after fresh releases.
- `DownloadDesktopFile` kept for back-compat — it now 302s to the
  manifest's blob URL instead of streaming a VM-local file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 17:22:15 +08:00
co-authored by Claude Opus 4.7
parent da80b97f54
commit 02866a8a33
10 changed files with 269 additions and 62 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "heicode-desktop",
"private": true,
"version": "0.1.5",
"version": "0.1.6",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "heicode-desktop"
version = "0.1.5"
version = "0.1.6"
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.5",
"version": "0.1.6",
"identifier": "com.heicode.desktop",
"build": {
"frontendDist": "../dist",
+29
View File
@@ -5,6 +5,26 @@
import { api } from './client'
// mcp-server §4 balance envelope. Quota fields are integer "quota-units"
// (Heicode billing uses 500_000 units = $1 by NewAPI convention; the UI
// applies that conversion only at display time).
export type HeicodeBalance = {
heicodeUserId: number
username?: string
email?: string
displayName?: string
group?: string
status?: number
quota: number
usedQuota: number
requestCount?: number
}
type HeicodeBalanceEnvelope = {
success?: boolean
data?: HeicodeBalance
}
export type HeicodeProviderId = 'taijiaicloud'
export type HeicodeLoginProviderInfo = {
@@ -104,4 +124,13 @@ export const heicodeAuthApi = {
logout() {
return api.post<{ ok: true }>('/api/heicode-auth/logout')
},
// Wraps mcp-server §4 /api/user/heicode/balance. Returns null on 401 /
// network failure so the UI can keep silent (rather than flash an error
// strip) — balance is informational, not gating.
balance(): Promise<HeicodeBalance | null> {
return api.get<HeicodeBalanceEnvelope>('/api/heicode-auth/balance')
.then((env) => env?.data ?? null)
.catch(() => null)
},
}
@@ -0,0 +1,67 @@
import { useEffect, useState } from 'react'
import { heicodeAuthApi, type HeicodeBalance } from '../../api/heicodeAuth'
import { useTranslation } from '../../i18n'
// Quota unit convention (NewAPI / Heicode): 500,000 units = USD $1. The
// Manager web has a fancier currency converter; for the in-app pill we
// 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
// Show 2 decimals up to $100, 4 decimals below to make pennies legible.
return `$${usd >= 100 ? usd.toFixed(2) : usd >= 1 ? usd.toFixed(2) : usd.toFixed(4)}`
}
/**
* Compact balance pill 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.
*/
export function BalanceBar() {
const [balance, setBalance] = useState<HeicodeBalance | null>(null)
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)
}
void tick()
timer = setInterval(tick, POLL_MS)
return () => {
cancelled = true
if (timer) clearInterval(timer)
}
}, [])
if (!balance) return null
return (
<div className="mx-auto mb-2 mt-1 flex w-fit max-w-full items-center gap-2 rounded-full border border-[var(--color-border)]/40 bg-[var(--color-surface-container-low)]/60 px-3 py-1 text-[11px] text-[var(--color-text-secondary)]">
<span className="material-symbols-outlined text-[14px] text-[var(--color-brand)]">
account_balance_wallet
</span>
<span className="font-medium text-[var(--color-text-primary)]">
{formatUsd(balance.quota)}
</span>
<span className="text-[var(--color-text-tertiary)]">{t('balance.remaining')}</span>
<span className="h-3 w-px bg-[var(--color-border)]/70" aria-hidden />
<span className="tabular-nums text-[var(--color-text-tertiary)]">
{t('balance.usedPrefix')}{formatUsd(balance.usedQuota)}
</span>
{typeof balance.requestCount === 'number' && balance.requestCount > 0 && (
<>
<span className="h-3 w-px bg-[var(--color-border)]/70" aria-hidden />
<span className="tabular-nums text-[var(--color-text-tertiary)]">
{balance.requestCount} {t('balance.requestsSuffix')}
</span>
</>
)}
</div>
)
}
+3
View File
@@ -38,6 +38,9 @@ export const en = {
'sidebar.timeFilter.title': 'Filter by recency',
'sidebar.timeFilter.all': 'All',
'sidebar.userCard.openProfile': 'Open account on Heicode Manager',
'balance.remaining': 'left',
'balance.usedPrefix': 'used ',
'balance.requestsSuffix': 'reqs',
'sidebar.collapse': 'Collapse sidebar',
'sidebar.expand': 'Expand sidebar',
'sidebar.logout': 'Log out',
+3
View File
@@ -40,6 +40,9 @@ export const zh: Record<TranslationKey, string> = {
'sidebar.timeFilter.title': '按时间筛选',
'sidebar.timeFilter.all': '全部',
'sidebar.userCard.openProfile': '在 Heicode Manager 查看账号',
'balance.remaining': '剩余',
'balance.usedPrefix': '已用 ',
'balance.requestsSuffix': '次',
'sidebar.collapse': '折叠侧边栏',
'sidebar.expand': '展开侧边栏',
'sidebar.logout': '退出登录',
@@ -7,6 +7,7 @@ import { useTeamStore } from '../stores/teamStore'
import { useTranslation } from '../i18n'
import { MessageList } from '../components/chat/MessageList'
import { ChatInput } from '../components/chat/ChatInput'
import { BalanceBar } from '../components/chat/BalanceBar'
import { ComputerUsePermissionModal } from '../components/chat/ComputerUsePermissionModal'
import { TeamStatusBar } from '../components/teams/TeamStatusBar'
import { SessionTaskBar } from '../components/chat/SessionTaskBar'
@@ -207,6 +208,7 @@ export function ActiveSession() {
<TeamStatusBar />
<ChatInput variant={isEmpty && !isMemberSession ? 'hero' : 'default'} />
{!isMemberSession && <BalanceBar />}
{!isMemberSession && activeTabId ? (
<ComputerUsePermissionModal
+25
View File
@@ -142,6 +142,31 @@ 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).
if (action === 'balance' && req.method === 'GET') {
const { providers, activeId } = await providerService.listProviders()
const active = activeId ? providers.find((p) => p.id === activeId) : null
if (!active?.mcpAuth?.accessToken || !active.mcpAuth.managerLoginUrl) {
return new Response(
JSON.stringify({ error: 'not_logged_in' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } },
)
}
const base = active.mcpAuth.managerLoginUrl.replace(/\/+$/, '')
const upstream = await fetch(`${base}/api/user/heicode/balance`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${active.mcpAuth.accessToken}` },
})
const body = await upstream.text()
return new Response(body, {
status: upstream.status,
headers: { 'Content-Type': upstream.headers.get('Content-Type') ?? 'application/json' },
})
}
// /api/heicode-auth/oauth/*
if (action === 'oauth') {
if (subAction === 'start' && req.method === 'POST') {
+136 -58
View File
@@ -1,23 +1,33 @@
package controller
import (
"context"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/heicode/manager/common"
"github.com/heicode/manager/i18n"
)
const envDesktopVersion = "HEICODE_DESKTOP_CLIENT_VERSION"
// Heicode desktop downloads pull from the same Azure Blob updater manifest
// (`https://heicodeblob.blob.core.windows.net/msi/updater/latest.json`) that
// the in-app Tauri updater consumes. The Manager front-end's download page
// therefore always reflects the latest release without having to manage
// VM-local file copies + env vars.
//
// The previous file-based implementation (HEICODE_DESKTOP_FILE_WINDOWS etc.)
// is preserved at the bottom of this file in a commented-out block; flip
// `useManifest` to false to fall back if the blob endpoint is down.
const (
envWindows = "HEICODE_DESKTOP_FILE_WINDOWS"
envMacArm = "HEICODE_DESKTOP_FILE_MACOS_ARM64"
envMacIntel = "HEICODE_DESKTOP_FILE_MACOS_X64"
envNotes = "HEICODE_DESKTOP_DOWNLOAD_NOTES"
manifestURL = "https://heicodeblob.blob.core.windows.net/msi/updater/latest.json"
// Short cache so a fresh release reaches users within ~5min without
// hammering the blob endpoint on every page hit.
manifestCacheTTL = 5 * time.Minute
)
type desktopDownloadItem struct {
@@ -33,93 +43,161 @@ type desktopDownloadsPayload struct {
Items []desktopDownloadItem `json:"items"`
}
func resolveDesktopPath(envKey string) string {
p := strings.TrimSpace(os.Getenv(envKey))
if p == "" {
return ""
}
if abs, err := filepath.Abs(p); err == nil {
return abs
}
return filepath.Clean(p)
// tauriManifest mirrors the schema produced by scripts/release-desktop.mjs.
type tauriManifest struct {
Version string `json:"version"`
Notes string `json:"notes"`
PubDate string `json:"pub_date"`
Platforms map[string]struct {
Signature string `json:"signature"`
URL string `json:"url"`
} `json:"platforms"`
}
func safeFileBase(path string) string {
return filepath.Base(path)
var (
manifestCacheMu sync.Mutex
manifestCacheData *tauriManifest
manifestCacheLoadedAt time.Time
)
// fetchManifest returns the parsed Tauri updater manifest, with a short
// in-process cache. Network failures while a cached copy exists return
// the stale copy rather than 500-ing.
func fetchManifest(ctx context.Context) (*tauriManifest, error) {
manifestCacheMu.Lock()
defer manifestCacheMu.Unlock()
if manifestCacheData != nil && time.Since(manifestCacheLoadedAt) < manifestCacheTTL {
return manifestCacheData, nil
}
// GetDesktopDownloads returns metadata for authenticated users (login required via UserAuth).
reqCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, manifestURL, nil)
if err != nil {
if manifestCacheData != nil {
return manifestCacheData, nil
}
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
if manifestCacheData != nil {
return manifestCacheData, nil
}
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
if manifestCacheData != nil {
return manifestCacheData, nil
}
return nil, &manifestError{status: resp.StatusCode}
}
body, err := io.ReadAll(resp.Body)
if err != nil {
if manifestCacheData != nil {
return manifestCacheData, nil
}
return nil, err
}
var m tauriManifest
if err := common.Unmarshal(body, &m); err != nil {
if manifestCacheData != nil {
return manifestCacheData, nil
}
return nil, err
}
manifestCacheData = &m
manifestCacheLoadedAt = time.Now()
return &m, nil
}
type manifestError struct{ status int }
func (e *manifestError) Error() string { return "manifest fetch returned non-200" }
func filenameFromURL(rawURL string) string {
if i := strings.LastIndex(rawURL, "/"); i >= 0 && i+1 < len(rawURL) {
return rawURL[i+1:]
}
return rawURL
}
// GetDesktopDownloads returns the latest released desktop client metadata
// (per platform) for authenticated users.
func GetDesktopDownloads(c *gin.Context) {
version := common.GetEnvOrDefaultString(envDesktopVersion, "")
if version == "" {
version = "0.0.0"
}
notes := strings.TrimSpace(os.Getenv(envNotes))
items := make([]desktopDownloadItem, 0, 3)
add := func(id, label, envKey string) {
p := resolveDesktopPath(envKey)
if p == "" {
manifest, err := fetchManifest(c.Request.Context())
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgRetryLater),
})
return
}
if st, err := os.Stat(p); err != nil || st.IsDir() {
items := make([]desktopDownloadItem, 0, len(manifest.Platforms))
add := func(id, label, key string) {
p, ok := manifest.Platforms[key]
if !ok || strings.TrimSpace(p.URL) == "" {
return
}
items = append(items, desktopDownloadItem{
ID: id,
Label: label,
Filename: safeFileBase(p),
DownloadURL: "/api/user/desktop-downloads/file/" + id,
Filename: filenameFromURL(p.URL),
DownloadURL: p.URL,
})
}
add("windows", "Windows (x64)", envWindows)
add("macos_arm64", "macOS (Apple silicon)", envMacArm)
add("macos_x64", "macOS (Intel)", envMacIntel)
add("windows", "Windows (x64)", "windows-x86_64")
add("macos_arm64", "macOS (Apple silicon)", "darwin-aarch64")
add("macos_x64", "macOS (Intel)", "darwin-x86_64")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": desktopDownloadsPayload{
Version: version,
Notes: notes,
Version: manifest.Version,
Notes: manifest.Notes,
Items: items,
},
})
}
var desktopPlatformEnv = map[string]string{
"windows": envWindows,
"macos_arm64": envMacArm,
"macos_x64": envMacIntel,
}
// DownloadDesktopFile streams an installer for logged-in users only.
// DownloadDesktopFile is kept for backward compatibility with any link
// that still points at `/api/user/desktop-downloads/file/<platform>` —
// it 302s to the blob URL the manifest reports. New front-end builds use
// the direct blob URL returned by GetDesktopDownloads, so this path is
// only hit by stale clients.
func DownloadDesktopFile(c *gin.Context) {
platform := strings.TrimSpace(c.Param("platform"))
envKey, ok := desktopPlatformEnv[platform]
if !ok {
platformKey := map[string]string{
"windows": "windows-x86_64",
"macos_arm64": "darwin-aarch64",
"macos_x64": "darwin-x86_64",
}[platform]
if platformKey == "" {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgInvalidParams),
})
return
}
path := resolveDesktopPath(envKey)
if path == "" {
manifest, err := fetchManifest(c.Request.Context())
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgRetryLater),
})
return
}
entry, ok := manifest.Platforms[platformKey]
if !ok || strings.TrimSpace(entry.URL) == "" {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgNotFound),
})
return
}
st, err := os.Stat(path)
if err != nil || st.IsDir() {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgNotFound),
})
return
}
c.Header("Content-Disposition", "attachment; filename=\""+safeFileBase(path)+"\"")
c.File(path)
c.Redirect(http.StatusFound, entry.URL)
}