feat(manager): login auto relay token hidden from UI; available models page

- Add hide_from_user_ui on tokens; EnsureUserRelayToken on login and Agnet session

- List/search tokens: end-users see only visible keys; admins see all

- Add /available-models and sidebar entry; i18n en/zh + locales

- desktop download / router hooks if present under heicode/
This commit is contained in:
Ubuntu
2026-05-01 10:00:11 +00:00
parent b0acfd44c1
commit 2f0bf2563e
19 changed files with 524 additions and 7 deletions
+7
View File
@@ -97,3 +97,10 @@ LINUX_DO_USER_ENDPOINT=https://connect.linux.do/api/user
# 用于验证支付成功/取消回调URL的域名安全性
# 示例: example.com,myapp.io 将允许 example.com, sub.example.com, myapp.io 等
# TRUSTED_REDIRECT_DOMAINS=example.com,myapp.io
# Heicode 桌面安装包(登录用户可从控制台「Heicode 桌面客户端」下载;路径为宿主机绝对路径)
# HEICODE_DESKTOP_CLIENT_VERSION=0.1.0
# HEICODE_DESKTOP_FILE_WINDOWS=/data/desktop-artifacts/Heicode_0.1.0_windows_x64-setup.exe
# HEICODE_DESKTOP_FILE_MACOS_ARM64=/data/desktop-artifacts/Heicode_0.1.0_macos_arm64.dmg
# HEICODE_DESKTOP_FILE_MACOS_X64=/data/desktop-artifacts/Heicode_0.1.0_macos_x64.dmg
# HEICODE_DESKTOP_DOWNLOAD_NOTES=可选说明,展示在下载页
+125
View File
@@ -0,0 +1,125 @@
package controller
import (
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"github.com/heicode/manager/common"
"github.com/heicode/manager/i18n"
)
const envDesktopVersion = "HEICODE_DESKTOP_CLIENT_VERSION"
const (
envWindows = "HEICODE_DESKTOP_FILE_WINDOWS"
envMacArm = "HEICODE_DESKTOP_FILE_MACOS_ARM64"
envMacIntel = "HEICODE_DESKTOP_FILE_MACOS_X64"
envNotes = "HEICODE_DESKTOP_DOWNLOAD_NOTES"
)
type desktopDownloadItem struct {
ID string `json:"id"`
Label string `json:"label"`
Filename string `json:"filename"`
DownloadURL string `json:"downloadUrl"`
}
type desktopDownloadsPayload struct {
Version string `json:"version"`
Notes string `json:"notes,omitempty"`
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)
}
func safeFileBase(path string) string {
return filepath.Base(path)
}
// 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"
}
notes := strings.TrimSpace(os.Getenv(envNotes))
items := make([]desktopDownloadItem, 0, 3)
add := func(id, label, envKey string) {
p := resolveDesktopPath(envKey)
if p == "" {
return
}
if st, err := os.Stat(p); err != nil || st.IsDir() {
return
}
items = append(items, desktopDownloadItem{
ID: id,
Label: label,
Filename: safeFileBase(p),
DownloadURL: "/api/user/desktop-downloads/file/" + id,
})
}
add("windows", "Windows (x64)", envWindows)
add("macos_arm64", "macOS (Apple silicon)", envMacArm)
add("macos_x64", "macOS (Intel)", envMacIntel)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": desktopDownloadsPayload{
Version: version,
Notes: 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.
func DownloadDesktopFile(c *gin.Context) {
platform := strings.TrimSpace(c.Param("platform"))
envKey, ok := desktopPlatformEnv[platform]
if !ok {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgInvalidParams),
})
return
}
path := resolveDesktopPath(envKey)
if path == "" {
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)
}
@@ -327,6 +327,8 @@ func HeicodeAgnetSessionLogin(c *gin.Context) {
return
}
model.EnsureUserRelayToken(user.Id, user.Username)
data := gin.H{
"id": user.Id,
"username": user.Username,
+61 -5
View File
@@ -31,15 +31,31 @@ func buildMaskedTokenResponses(tokens []*model.Token) []*model.Token {
return maskedTokens
}
// denyHiddenRelayTokenForEndUser 系统托管且对用户隐藏的令牌仅管理员可在控制台查看/操作。
func denyHiddenRelayTokenForEndUser(c *gin.Context, tok *model.Token) bool {
if tok == nil || !tok.HideFromUserUI {
return false
}
return c.GetInt("role") < common.RoleAdminUser
}
func GetAllTokens(c *gin.Context) {
userId := c.GetInt("id")
pageInfo := common.GetPageQuery(c)
tokens, err := model.GetAllUserTokens(userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
var tokens []*model.Token
var total int64
var err error
if c.GetInt("role") >= common.RoleAdminUser {
tokens, err = model.GetAllUserTokens(userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
total, _ = model.CountUserTokens(userId)
} else {
tokens, err = model.GetAllUserTokensVisibleInUI(userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
total, _ = model.CountUserTokensVisibleInUI(userId)
}
if err != nil {
common.ApiError(c, err)
return
}
total, _ := model.CountUserTokens(userId)
pageInfo.SetTotal(int(total))
pageInfo.SetItems(buildMaskedTokenResponses(tokens))
common.ApiSuccess(c, pageInfo)
@@ -52,7 +68,8 @@ func SearchTokens(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
tokens, total, err := model.SearchUserTokens(userId, keyword, token, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
restrictVisible := c.GetInt("role") < common.RoleAdminUser
tokens, total, err := model.SearchUserTokens(userId, keyword, token, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), restrictVisible)
if err != nil {
common.ApiError(c, err)
return
@@ -74,6 +91,10 @@ func GetToken(c *gin.Context) {
common.ApiError(c, err)
return
}
if denyHiddenRelayTokenForEndUser(c, token) {
common.ApiErrorI18n(c, i18n.MsgTokenGetInfoFailed)
return
}
common.ApiSuccess(c, buildMaskedTokenResponse(token))
}
@@ -89,6 +110,10 @@ func GetTokenKey(c *gin.Context) {
common.ApiError(c, err)
return
}
if denyHiddenRelayTokenForEndUser(c, token) {
common.ApiErrorI18n(c, i18n.MsgTokenGetInfoFailed)
return
}
common.ApiSuccess(c, gin.H{
"key": token.GetFullKey(),
})
@@ -102,6 +127,10 @@ func GetTokenStatus(c *gin.Context) {
common.ApiError(c, err)
return
}
if denyHiddenRelayTokenForEndUser(c, token) {
common.ApiErrorI18n(c, i18n.MsgTokenGetInfoFailed)
return
}
expiredAt := token.ExpiredTime
if expiredAt == -1 {
expiredAt = 0
@@ -236,7 +265,16 @@ func AddToken(c *gin.Context) {
func DeleteToken(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
userId := c.GetInt("id")
err := model.DeleteTokenById(id, userId)
tok, err := model.GetTokenByIds(id, userId)
if err != nil {
common.ApiError(c, err)
return
}
if denyHiddenRelayTokenForEndUser(c, tok) {
common.ApiErrorI18n(c, i18n.MsgTokenGetInfoFailed)
return
}
err = model.DeleteTokenById(id, userId)
if err != nil {
common.ApiError(c, err)
return
@@ -276,6 +314,10 @@ func UpdateToken(c *gin.Context) {
common.ApiError(c, err)
return
}
if denyHiddenRelayTokenForEndUser(c, cleanToken) {
common.ApiErrorI18n(c, i18n.MsgTokenGetInfoFailed)
return
}
if token.Status == common.TokenStatusEnabled {
if cleanToken.Status == common.TokenStatusExpired && cleanToken.ExpiredTime <= common.GetTimestamp() && cleanToken.ExpiredTime != -1 {
common.ApiErrorI18n(c, i18n.MsgTokenExpiredCannotEnable)
@@ -323,6 +365,16 @@ func DeleteTokenBatch(c *gin.Context) {
return
}
userId := c.GetInt("id")
for _, id := range tokenBatch.Ids {
tok, err := model.GetTokenByIds(id, userId)
if err != nil {
continue
}
if denyHiddenRelayTokenForEndUser(c, tok) {
common.ApiErrorI18n(c, i18n.MsgTokenGetInfoFailed)
return
}
}
count, err := model.BatchDeleteTokens(tokenBatch.Ids, userId)
if err != nil {
common.ApiError(c, err)
@@ -352,7 +404,11 @@ func GetTokenKeysBatch(c *gin.Context) {
return
}
keysMap := make(map[int]string)
for _, t := range tokens {
for i := range tokens {
t := &tokens[i]
if denyHiddenRelayTokenForEndUser(c, t) {
continue
}
keysMap[t.Id] = t.GetFullKey()
}
common.ApiSuccess(c, gin.H{"keys": keysMap})
+1
View File
@@ -203,6 +203,7 @@ func newAuthenticatedContext(t *testing.T, method string, target string, body an
ctx.Request.Header.Set("Content-Type", "application/json")
}
ctx.Set("id", userID)
ctx.Set("role", common.RoleCommonUser)
return ctx, recorder
}
+2
View File
@@ -103,6 +103,7 @@ func setupLogin(user *model.User, c *gin.Context) {
common.ApiErrorI18n(c, i18n.MsgUserSessionSaveFailed)
return
}
model.EnsureUserRelayToken(user.Id, user.Username)
c.JSON(http.StatusOK, gin.H{
"message": "",
"success": true,
@@ -215,6 +216,7 @@ func Register(c *gin.Context) {
RemainQuota: 500000, // 示例额度
UnlimitedQuota: true,
ModelLimitsEnabled: false,
HideFromUserUI: true,
}
if setting.DefaultUseAutoGroup {
token.Group = "auto"
+69 -2
View File
@@ -6,6 +6,7 @@ import (
"strings"
"github.com/heicode/manager/common"
"github.com/heicode/manager/setting"
"github.com/heicode/manager/setting/operation_setting"
"github.com/bytedance/gopkg/util/gopool"
"gorm.io/gorm"
@@ -28,6 +29,7 @@ type Token struct {
UsedQuota int `json:"used_quota" gorm:"default:0"` // used quota
Group string `json:"group" gorm:"default:''"`
CrossGroupRetry bool `json:"cross_group_retry"` // 跨分组重试,仅auto分组有效
HideFromUserUI bool `json:"hide_from_user_ui" gorm:"default:false"` // 系统托管令牌:列表/API key 详情对用户隐藏(中继仍可用)
DeletedAt gorm.DeletedAt `gorm:"index"`
}
@@ -85,6 +87,61 @@ func GetAllUserTokens(userId int, startIdx int, num int) ([]*Token, error) {
return tokens, err
}
// GetAllUserTokensVisibleInUI 返回控制台「令牌」列表中应对用户展示的条目(排除系统托管、对用户隐藏的 key)。
func GetAllUserTokensVisibleInUI(userId int, startIdx int, num int) ([]*Token, error) {
var tokens []*Token
err := DB.Where("user_id = ? AND hide_from_user_ui = ?", userId, false).
Order("id desc").Limit(num).Offset(startIdx).Find(&tokens).Error
return tokens, err
}
// CountUserTokensVisibleInUI 与列表分页一致,仅统计对用户可见的令牌。
func CountUserTokensVisibleInUI(userId int) (int64, error) {
var total int64
err := DB.Model(&Token{}).
Where("user_id = ? AND hide_from_user_ui = ?", userId, false).
Count(&total).Error
return total, err
}
// EnsureUserRelayToken 在用户没有任何 relay 令牌时创建一枚系统托管令牌(对用户隐藏),用于登录后即可走网关。
func EnsureUserRelayToken(userId int, username string) {
if userId <= 0 {
return
}
count, err := CountUserTokens(userId)
if err != nil {
common.SysLog("EnsureUserRelayToken count: " + err.Error())
return
}
if count > 0 {
return
}
key, err := common.GenerateKey()
if err != nil {
common.SysLog("EnsureUserRelayToken generate key: " + err.Error())
return
}
token := Token{
UserId: userId,
Name: username + "的初始令牌",
Key: key,
CreatedTime: common.GetTimestamp(),
AccessedTime: common.GetTimestamp(),
ExpiredTime: -1,
RemainQuota: 500000,
UnlimitedQuota: true,
ModelLimitsEnabled: false,
HideFromUserUI: true,
}
if setting.DefaultUseAutoGroup {
token.Group = "auto"
}
if err := token.Insert(); err != nil {
common.SysLog("EnsureUserRelayToken insert: " + err.Error())
}
}
// sanitizeLikePattern 校验并清洗用户输入的 LIKE 搜索模式。
// 规则:
// 1. 转义 ! 和 _(使用 ! 作为 ESCAPE 字符,兼容 MySQL/PostgreSQL/SQLite)
@@ -124,7 +181,8 @@ func sanitizeLikePattern(input string) (string, error) {
const searchHardLimit = 100
func SearchUserTokens(userId int, keyword string, token string, offset int, limit int) (tokens []*Token, total int64, err error) {
// SearchUserTokens 搜索用户令牌。restrictVisibleInUI 为 true 时排除系统托管、对用户隐藏的令牌(与普通用户控制台列表一致)。
func SearchUserTokens(userId int, keyword string, token string, offset int, limit int, restrictVisibleInUI bool) (tokens []*Token, total int64, err error) {
// model 层强制截断
if limit <= 0 || limit > searchHardLimit {
limit = searchHardLimit
@@ -141,7 +199,13 @@ func SearchUserTokens(userId int, keyword string, token string, offset int, limi
maxTokens := operation_setting.GetMaxUserTokens()
hasFuzzy := strings.Contains(keyword, "%") || strings.Contains(token, "%")
if hasFuzzy {
count, err := CountUserTokens(userId)
var count int64
var err error
if restrictVisibleInUI {
count, err = CountUserTokensVisibleInUI(userId)
} else {
count, err = CountUserTokens(userId)
}
if err != nil {
common.SysLog("failed to count user tokens: " + err.Error())
return nil, 0, errors.New("获取令牌数量失败")
@@ -152,6 +216,9 @@ func SearchUserTokens(userId int, keyword string, token string, offset int, limi
}
baseQuery := DB.Model(&Token{}).Where("user_id = ?", userId)
if restrictVisibleInUI {
baseQuery = baseQuery.Where("hide_from_user_ui = ?", false)
}
// 非空才加 LIKE 条件,空则跳过(不过滤该字段)
if keyword != "" {
+4
View File
@@ -77,6 +77,10 @@ func SetApiRouter(router *gin.Engine) {
selfRoute.PUT("/self", controller.UpdateSelf)
selfRoute.DELETE("/self", controller.DeleteSelf)
selfRoute.GET("/token", controller.GenerateAccessToken)
// Heicode desktop client installers (paths from env, login required)
selfRoute.GET("/desktop-downloads", controller.GetDesktopDownloads)
selfRoute.GET("/desktop-downloads/file/:platform", controller.DownloadDesktopFile)
selfRoute.GET("/passkey", controller.PasskeyStatus)
selfRoute.POST("/passkey/register/begin", controller.PasskeyRegisterBegin)
selfRoute.POST("/passkey/register/finish", controller.PasskeyRegisterFinish)
@@ -0,0 +1,92 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Download } from 'lucide-react'
import { api } from '@/lib/api'
import { SectionPageLayout } from '@/components/layout'
import { Button } from '@/components/ui/button'
type DesktopDownloadItem = {
id: string
label: string
filename: string
downloadUrl: string
}
type DesktopDownloadsPayload = {
version: string
notes?: string
items: DesktopDownloadItem[]
}
export function DesktopClientDownloadPage() {
const { t } = useTranslation()
const q = useQuery({
queryKey: ['desktop-downloads'],
queryFn: async () => {
const res = await api.get<{
success: boolean
data?: DesktopDownloadsPayload
}>('/api/user/desktop-downloads')
return res.data
},
})
const payload = q.data?.success ? q.data.data : undefined
return (
<SectionPageLayout>
<SectionPageLayout.Title>
{t('Heicode desktop client')}
</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.',
)}
</SectionPageLayout.Description>
<SectionPageLayout.Content>
{q.isLoading ? (
<p className='text-muted-foreground text-sm'>{t('Loading...')}</p>
) : q.isError ? (
<p className='text-destructive text-sm'>{t('Request failed')}</p>
) : !payload?.items?.length ? (
<p className='text-muted-foreground text-sm'>
{t('No desktop installers are available yet.')}
</p>
) : (
<div className='flex flex-col gap-6'>
<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
key={item.id}
className='flex flex-wrap items-center justify-between gap-3 rounded-lg border p-4'
>
<div>
<div className='font-medium'>{item.label}</div>
<div className='text-muted-foreground text-sm'>
{item.filename}
</div>
</div>
<Button asChild variant='default'>
<a href={item.downloadUrl} rel='noreferrer'>
<Download className='mr-2 h-4 w-4' aria-hidden />
{t('Download')}
</a>
</Button>
</li>
))}
</ul>
</div>
)}
</SectionPageLayout.Content>
</SectionPageLayout>
)
}
+12
View File
@@ -1,6 +1,8 @@
import {
Activity,
Command,
Cpu,
Download,
FileBarChart,
GitBranch,
LayoutDashboard,
@@ -94,6 +96,16 @@ export function useSidebarData(): SidebarData {
url: '/wallet',
icon: Wallet,
},
{
title: t('Available models'),
url: '/available-models',
icon: Cpu,
},
{
title: t('Heicode desktop client'),
url: '/desktop-client',
icon: Download,
},
{
title: t('Profile'),
url: '/profile',
+7
View File
@@ -430,6 +430,9 @@
"Automatically sync model list when upstream changes are detected": "Automatically sync model list when upstream changes are detected",
"Automatically test channels and notify users when limits are hit": "Automatically test channels and notify users when limits are hit",
"Available": "Available",
"Available models": "Available models",
"Available models description": "Models your account can use through the gateway, based on your group and channel configuration. API keys are managed for you; you do not need to create a key to see this list.",
"No models available for your account": "No models are available for your account yet.",
"Available disk space": "Available disk space",
"Available Models": "Available Models",
"Available Rewards": "Available Rewards",
@@ -3746,6 +3749,10 @@
"Waffo Public Key (Sandbox)": "Waffo Public Key (Sandbox)",
"Waiting": "Waiting",
"Waiting for email...": "Waiting for email...",
"Version: {{version}}": "Version: {{version}}",
"Heicode desktop client": "Heicode desktop client",
"Installers are served only after you sign in. Place files on the server and set HEICODE_DESKTOP_FILE_* environment variables.": "Installers are served only after you sign in. Place files on the server and set HEICODE_DESKTOP_FILE_* environment variables.",
"No desktop installers are available yet.": "No desktop installers are available yet.",
"Wallet": "Wallet",
"Wallet First": "Wallet First",
"Wallet Management": "Wallet Management",
+3
View File
@@ -430,6 +430,9 @@
"Automatically sync model list when upstream changes are detected": "Synchroniser automatiquement la liste des modèles lorsque des changements en amont sont détectés",
"Automatically test channels and notify users when limits are hit": "Tester automatiquement les canaux et notifier les utilisateurs lorsque les limites sont atteintes",
"Available": "Disponible",
"Available models": "Available models",
"Available models description": "Models your account can use through the gateway, based on your group and channel configuration. API keys are managed for you; you do not need to create a key to see this list.",
"No models available for your account": "No models are available for your account yet.",
"Available disk space": "Espace disque disponible",
"Available Models": "Modèles disponibles",
"Available Rewards": "Récompenses disponibles",
+3
View File
@@ -430,6 +430,9 @@
"Automatically sync model list when upstream changes are detected": "アップストリームの変更が検出されたときにモデルリストを自動的に同期",
"Automatically test channels and notify users when limits are hit": "チャネルを自動的にテストし、制限に達したときにユーザーに通知する",
"Available": "空き",
"Available models": "Available models",
"Available models description": "Models your account can use through the gateway, based on your group and channel configuration. API keys are managed for you; you do not need to create a key to see this list.",
"No models available for your account": "No models are available for your account yet.",
"Available disk space": "利用可能なディスク容量",
"Available Models": "利用可能なモデル",
"Available Rewards": "利用可能な報酬",
+3
View File
@@ -430,6 +430,9 @@
"Automatically sync model list when upstream changes are detected": "Автоматически синхронизировать список моделей при обнаружении изменений у провайдера",
"Automatically test channels and notify users when limits are hit": "Автоматически тестировать каналы и уведомлять пользователей при достижении лимитов",
"Available": "Доступно",
"Available models": "Available models",
"Available models description": "Models your account can use through the gateway, based on your group and channel configuration. API keys are managed for you; you do not need to create a key to see this list.",
"No models available for your account": "No models are available for your account yet.",
"Available disk space": "Доступное дисковое пространство",
"Available Models": "Доступные модели",
"Available Rewards": "Доступные награды",
+3
View File
@@ -430,6 +430,9 @@
"Automatically sync model list when upstream changes are detected": "Tự động đồng bộ danh sách mô hình khi phát hiện thay đổi từ nguồn",
"Automatically test channels and notify users when limits are hit": "Tự động kiểm tra các kênh và thông báo cho người dùng khi đạt đến giới hạn",
"Available": "Khả dụng",
"Available models": "Available models",
"Available models description": "Models your account can use through the gateway, based on your group and channel configuration. API keys are managed for you; you do not need to create a key to see this list.",
"No models available for your account": "No models are available for your account yet.",
"Available disk space": "Dung lượng đĩa khả dụng",
"Available Models": "Mô hình khả dụng",
"Available Rewards": "Phần thưởng hiện có",
+7
View File
@@ -430,6 +430,9 @@
"Automatically sync model list when upstream changes are detected": "检测到上游模型变更时自动同步模型列表",
"Automatically test channels and notify users when limits are hit": "自动测试渠道并在达到限制时通知用户",
"Available": "可用",
"Available models": "可用模型",
"Available models description": "根据你的分组与渠道配置,当前账号可通过网关调用的模型列表。系统已托管 API 密钥,无需自行创建密钥即可查看此列表。",
"No models available for your account": "当前账号暂无可用的模型。",
"Available disk space": "可用磁盘空间",
"Available Models": "可用模型",
"Available Rewards": "可用奖励",
@@ -3746,6 +3749,10 @@
"Waffo Public Key (Sandbox)": "Waffo 公钥(沙盒)",
"Waiting": "等待中",
"Waiting for email...": "等待电子邮件...",
"Version: {{version}}": "版本:{{version}}",
"Heicode desktop client": "Heicode 桌面客户端",
"Installers are served only after you sign in. Place files on the server and set HEICODE_DESKTOP_FILE_* environment variables.": "安装包仅在登录后提供。请管理员将安装文件放在服务器上并配置 HEICODE_DESKTOP_FILE_* 环境变量。",
"No desktop installers are available yet.": "暂无可下载的桌面安装包。",
"Wallet": "钱包",
"Wallet First": "优先钱包",
"Wallet Management": "钱包管理",
+45
View File
@@ -45,9 +45,11 @@ import { Route as AuthenticatedPlaygroundIndexRouteImport } from './routes/_auth
import { Route as AuthenticatedModelsIndexRouteImport } from './routes/_authenticated/models/index'
import { Route as AuthenticatedKeysIndexRouteImport } from './routes/_authenticated/keys/index'
import { Route as AuthenticatedEventsIndexRouteImport } from './routes/_authenticated/events/index'
import { Route as AuthenticatedDesktopClientIndexRouteImport } from './routes/_authenticated/desktop-client/index'
import { Route as AuthenticatedDeploymentsIndexRouteImport } from './routes/_authenticated/deployments/index'
import { Route as AuthenticatedDashboardIndexRouteImport } from './routes/_authenticated/dashboard/index'
import { Route as AuthenticatedChannelsIndexRouteImport } from './routes/_authenticated/channels/index'
import { Route as AuthenticatedAvailableModelsIndexRouteImport } from './routes/_authenticated/available-models/index'
import { Route as AuthenticatedAuditIndexRouteImport } from './routes/_authenticated/audit/index'
import { Route as AuthenticatedAgentsIndexRouteImport } from './routes/_authenticated/agents/index'
import { Route as AuthenticatedUsageLogsSectionRouteImport } from './routes/_authenticated/usage-logs/$section'
@@ -261,6 +263,12 @@ const AuthenticatedEventsIndexRoute =
path: '/events/',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedDesktopClientIndexRoute =
AuthenticatedDesktopClientIndexRouteImport.update({
id: '/desktop-client/',
path: '/desktop-client/',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedDeploymentsIndexRoute =
AuthenticatedDeploymentsIndexRouteImport.update({
id: '/deployments/',
@@ -279,6 +287,12 @@ const AuthenticatedChannelsIndexRoute =
path: '/channels/',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedAvailableModelsIndexRoute =
AuthenticatedAvailableModelsIndexRouteImport.update({
id: '/available-models/',
path: '/available-models/',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedAuditIndexRoute = AuthenticatedAuditIndexRouteImport.update({
id: '/audit/',
path: '/audit/',
@@ -438,9 +452,11 @@ export interface FileRoutesByFullPath {
'/usage-logs/$section': typeof AuthenticatedUsageLogsSectionRoute
'/agents/': typeof AuthenticatedAgentsIndexRoute
'/audit/': typeof AuthenticatedAuditIndexRoute
'/available-models/': typeof AuthenticatedAvailableModelsIndexRoute
'/channels/': typeof AuthenticatedChannelsIndexRoute
'/dashboard/': typeof AuthenticatedDashboardIndexRoute
'/deployments/': typeof AuthenticatedDeploymentsIndexRoute
'/desktop-client/': typeof AuthenticatedDesktopClientIndexRoute
'/events/': typeof AuthenticatedEventsIndexRoute
'/keys/': typeof AuthenticatedKeysIndexRoute
'/models/': typeof AuthenticatedModelsIndexRoute
@@ -498,9 +514,11 @@ export interface FileRoutesByTo {
'/usage-logs/$section': typeof AuthenticatedUsageLogsSectionRoute
'/agents': typeof AuthenticatedAgentsIndexRoute
'/audit': typeof AuthenticatedAuditIndexRoute
'/available-models': typeof AuthenticatedAvailableModelsIndexRoute
'/channels': typeof AuthenticatedChannelsIndexRoute
'/dashboard': typeof AuthenticatedDashboardIndexRoute
'/deployments': typeof AuthenticatedDeploymentsIndexRoute
'/desktop-client': typeof AuthenticatedDesktopClientIndexRoute
'/events': typeof AuthenticatedEventsIndexRoute
'/keys': typeof AuthenticatedKeysIndexRoute
'/models': typeof AuthenticatedModelsIndexRoute
@@ -562,9 +580,11 @@ export interface FileRoutesById {
'/_authenticated/usage-logs/$section': typeof AuthenticatedUsageLogsSectionRoute
'/_authenticated/agents/': typeof AuthenticatedAgentsIndexRoute
'/_authenticated/audit/': typeof AuthenticatedAuditIndexRoute
'/_authenticated/available-models/': typeof AuthenticatedAvailableModelsIndexRoute
'/_authenticated/channels/': typeof AuthenticatedChannelsIndexRoute
'/_authenticated/dashboard/': typeof AuthenticatedDashboardIndexRoute
'/_authenticated/deployments/': typeof AuthenticatedDeploymentsIndexRoute
'/_authenticated/desktop-client/': typeof AuthenticatedDesktopClientIndexRoute
'/_authenticated/events/': typeof AuthenticatedEventsIndexRoute
'/_authenticated/keys/': typeof AuthenticatedKeysIndexRoute
'/_authenticated/models/': typeof AuthenticatedModelsIndexRoute
@@ -625,9 +645,11 @@ export interface FileRouteTypes {
| '/usage-logs/$section'
| '/agents/'
| '/audit/'
| '/available-models/'
| '/channels/'
| '/dashboard/'
| '/deployments/'
| '/desktop-client/'
| '/events/'
| '/keys/'
| '/models/'
@@ -685,9 +707,11 @@ export interface FileRouteTypes {
| '/usage-logs/$section'
| '/agents'
| '/audit'
| '/available-models'
| '/channels'
| '/dashboard'
| '/deployments'
| '/desktop-client'
| '/events'
| '/keys'
| '/models'
@@ -748,9 +772,11 @@ export interface FileRouteTypes {
| '/_authenticated/usage-logs/$section'
| '/_authenticated/agents/'
| '/_authenticated/audit/'
| '/_authenticated/available-models/'
| '/_authenticated/channels/'
| '/_authenticated/dashboard/'
| '/_authenticated/deployments/'
| '/_authenticated/desktop-client/'
| '/_authenticated/events/'
| '/_authenticated/keys/'
| '/_authenticated/models/'
@@ -1053,6 +1079,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedEventsIndexRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/desktop-client/': {
id: '/_authenticated/desktop-client/'
path: '/desktop-client'
fullPath: '/desktop-client/'
preLoaderRoute: typeof AuthenticatedDesktopClientIndexRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/deployments/': {
id: '/_authenticated/deployments/'
path: '/deployments'
@@ -1074,6 +1107,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedChannelsIndexRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/available-models/': {
id: '/_authenticated/available-models/'
path: '/available-models'
fullPath: '/available-models/'
preLoaderRoute: typeof AuthenticatedAvailableModelsIndexRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/audit/': {
id: '/_authenticated/audit/'
path: '/audit'
@@ -1322,9 +1362,11 @@ interface AuthenticatedRouteRouteChildren {
AuthenticatedUsageLogsSectionRoute: typeof AuthenticatedUsageLogsSectionRoute
AuthenticatedAgentsIndexRoute: typeof AuthenticatedAgentsIndexRoute
AuthenticatedAuditIndexRoute: typeof AuthenticatedAuditIndexRoute
AuthenticatedAvailableModelsIndexRoute: typeof AuthenticatedAvailableModelsIndexRoute
AuthenticatedChannelsIndexRoute: typeof AuthenticatedChannelsIndexRoute
AuthenticatedDashboardIndexRoute: typeof AuthenticatedDashboardIndexRoute
AuthenticatedDeploymentsIndexRoute: typeof AuthenticatedDeploymentsIndexRoute
AuthenticatedDesktopClientIndexRoute: typeof AuthenticatedDesktopClientIndexRoute
AuthenticatedEventsIndexRoute: typeof AuthenticatedEventsIndexRoute
AuthenticatedKeysIndexRoute: typeof AuthenticatedKeysIndexRoute
AuthenticatedModelsIndexRoute: typeof AuthenticatedModelsIndexRoute
@@ -1350,9 +1392,12 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
AuthenticatedUsageLogsSectionRoute: AuthenticatedUsageLogsSectionRoute,
AuthenticatedAgentsIndexRoute: AuthenticatedAgentsIndexRoute,
AuthenticatedAuditIndexRoute: AuthenticatedAuditIndexRoute,
AuthenticatedAvailableModelsIndexRoute:
AuthenticatedAvailableModelsIndexRoute,
AuthenticatedChannelsIndexRoute: AuthenticatedChannelsIndexRoute,
AuthenticatedDashboardIndexRoute: AuthenticatedDashboardIndexRoute,
AuthenticatedDeploymentsIndexRoute: AuthenticatedDeploymentsIndexRoute,
AuthenticatedDesktopClientIndexRoute: AuthenticatedDesktopClientIndexRoute,
AuthenticatedEventsIndexRoute: AuthenticatedEventsIndexRoute,
AuthenticatedKeysIndexRoute: AuthenticatedKeysIndexRoute,
AuthenticatedModelsIndexRoute: AuthenticatedModelsIndexRoute,
@@ -0,0 +1,72 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Cpu } from 'lucide-react'
import { getUserModels } from '@/lib/api'
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
export const Route = createFileRoute('/_authenticated/available-models/')({
component: AvailableModelsPage,
})
function AvailableModelsPage() {
const { t } = useTranslation()
const { data: models, isLoading, isError, error } = useQuery({
queryKey: ['user', 'models'],
queryFn: async () => {
const res = await getUserModels()
if (!res.success) {
throw new Error(res.message || t('Request failed'))
}
return res.data ?? []
},
})
return (
<section className='space-y-5'>
<header className='flex flex-col gap-1 border-b border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] pb-4'>
<div className='flex items-center gap-2'>
<Cpu className='h-5 w-5 text-primary' />
<h1 className='text-xl font-semibold tracking-tight'>
{t('Available models')}
</h1>
</div>
<p className='text-sm text-muted-foreground'>
{t('Available models description')}
</p>
</header>
{isLoading ? (
<div className='grid gap-2 sm:grid-cols-2'>
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className='h-12 rounded-xl' />
))}
</div>
) : isError ? (
<p className='text-sm text-destructive'>
{(error as Error)?.message || t('Request failed')}
</p>
) : !models?.length ? (
<p className='text-sm text-muted-foreground'>
{t('No models available for your account')}
</p>
) : (
<ul className='grid gap-2 sm:grid-cols-2 lg:grid-cols-3'>
{models.map((id) => (
<li
key={id}
className={cn(
'rounded-xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))]',
'bg-[color-mix(in_oklch,var(--card)_55%,transparent)] px-3 py-2.5',
'font-mono text-xs'
)}
>
{id}
</li>
))}
</ul>
)}
</section>
)
}
@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { DesktopClientDownloadPage } from '@/features/desktop-client-download/desktop-client-download-page'
export const Route = createFileRoute('/_authenticated/desktop-client/')({
component: DesktopClientDownloadPage,
})