diff --git a/cc-haha/desktop/package.json b/cc-haha/desktop/package.json index 46d0178..4453629 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.5", + "version": "0.1.6", "type": "module", "scripts": { "dev": "vite", diff --git a/cc-haha/desktop/src-tauri/Cargo.toml b/cc-haha/desktop/src-tauri/Cargo.toml index a680d0d..44b702a 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.5" +version = "0.1.6" edition = "2021" [lib] diff --git a/cc-haha/desktop/src-tauri/tauri.conf.json b/cc-haha/desktop/src-tauri/tauri.conf.json index c66a30a..709e0c6 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.5", + "version": "0.1.6", "identifier": "com.heicode.desktop", "build": { "frontendDist": "../dist", diff --git a/cc-haha/desktop/src/api/heicodeAuth.ts b/cc-haha/desktop/src/api/heicodeAuth.ts index 5c2d22c..8ebfe01 100644 --- a/cc-haha/desktop/src/api/heicodeAuth.ts +++ b/cc-haha/desktop/src/api/heicodeAuth.ts @@ -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 { + return api.get('/api/heicode-auth/balance') + .then((env) => env?.data ?? null) + .catch(() => null) + }, } diff --git a/cc-haha/desktop/src/components/chat/BalanceBar.tsx b/cc-haha/desktop/src/components/chat/BalanceBar.tsx new file mode 100644 index 0000000..96d943e --- /dev/null +++ b/cc-haha/desktop/src/components/chat/BalanceBar.tsx @@ -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(null) + 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 + + return ( +
+ + account_balance_wallet + + + {formatUsd(balance.quota)} + + {t('balance.remaining')} + + + {t('balance.usedPrefix')}{formatUsd(balance.usedQuota)} + + {typeof balance.requestCount === 'number' && balance.requestCount > 0 && ( + <> + + + {balance.requestCount} {t('balance.requestsSuffix')} + + + )} +
+ ) +} diff --git a/cc-haha/desktop/src/i18n/locales/en.ts b/cc-haha/desktop/src/i18n/locales/en.ts index 0b4f006..c0d19f4 100644 --- a/cc-haha/desktop/src/i18n/locales/en.ts +++ b/cc-haha/desktop/src/i18n/locales/en.ts @@ -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', diff --git a/cc-haha/desktop/src/i18n/locales/zh.ts b/cc-haha/desktop/src/i18n/locales/zh.ts index 4e4b671..1c9b274 100644 --- a/cc-haha/desktop/src/i18n/locales/zh.ts +++ b/cc-haha/desktop/src/i18n/locales/zh.ts @@ -40,6 +40,9 @@ export const zh: Record = { 'sidebar.timeFilter.title': '按时间筛选', 'sidebar.timeFilter.all': '全部', 'sidebar.userCard.openProfile': '在 Heicode Manager 查看账号', + 'balance.remaining': '剩余', + 'balance.usedPrefix': '已用 ', + 'balance.requestsSuffix': '次', 'sidebar.collapse': '折叠侧边栏', 'sidebar.expand': '展开侧边栏', 'sidebar.logout': '退出登录', diff --git a/cc-haha/desktop/src/pages/ActiveSession.tsx b/cc-haha/desktop/src/pages/ActiveSession.tsx index 921f3aa..6c0952c 100644 --- a/cc-haha/desktop/src/pages/ActiveSession.tsx +++ b/cc-haha/desktop/src/pages/ActiveSession.tsx @@ -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() { + {!isMemberSession && } {!isMemberSession && activeTabId ? ( 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') { diff --git a/heicode/controller/desktop_download.go b/heicode/controller/desktop_download.go index 97bb254..0e4279e 100644 --- a/heicode/controller/desktop_download.go +++ b/heicode/controller/desktop_download.go @@ -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/` — +// 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) }