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:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "heicode-desktop",
|
||||
"private": true,
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "heicode-desktop"
|
||||
version = "0.1.5"
|
||||
version = "0.1.6"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
// GetDesktopDownloads returns metadata for authenticated users (login required via UserAuth).
|
||||
func GetDesktopDownloads(c *gin.Context) {
|
||||
version := common.GetEnvOrDefaultString(envDesktopVersion, "")
|
||||
if version == "" {
|
||||
version = "0.0.0"
|
||||
// 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
|
||||
}
|
||||
notes := strings.TrimSpace(os.Getenv(envNotes))
|
||||
|
||||
items := make([]desktopDownloadItem, 0, 3)
|
||||
add := func(id, label, envKey string) {
|
||||
p := resolveDesktopPath(envKey)
|
||||
if p == "" {
|
||||
return
|
||||
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
|
||||
}
|
||||
if st, err := os.Stat(p); err != nil || st.IsDir() {
|
||||
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) {
|
||||
manifest, err := fetchManifest(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{
|
||||
"success": false,
|
||||
"message": common.TranslateMessage(c, i18n.MsgRetryLater),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user