diff --git a/heicode/.env.example b/heicode/.env.example index 440c6ef..b91d220 100644 --- a/heicode/.env.example +++ b/heicode/.env.example @@ -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=可选说明,展示在下载页 diff --git a/heicode/controller/desktop_download.go b/heicode/controller/desktop_download.go new file mode 100644 index 0000000..97bb254 --- /dev/null +++ b/heicode/controller/desktop_download.go @@ -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) +} diff --git a/heicode/controller/heicode_agnet_session.go b/heicode/controller/heicode_agnet_session.go index 7f29ab6..534cd37 100644 --- a/heicode/controller/heicode_agnet_session.go +++ b/heicode/controller/heicode_agnet_session.go @@ -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, diff --git a/heicode/controller/token.go b/heicode/controller/token.go index fa0a6e6..91c3bb6 100644 --- a/heicode/controller/token.go +++ b/heicode/controller/token.go @@ -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}) diff --git a/heicode/controller/token_test.go b/heicode/controller/token_test.go index 9e1a980..6dc36bf 100644 --- a/heicode/controller/token_test.go +++ b/heicode/controller/token_test.go @@ -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 } diff --git a/heicode/controller/user.go b/heicode/controller/user.go index d55c270..e00232d 100644 --- a/heicode/controller/user.go +++ b/heicode/controller/user.go @@ -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" diff --git a/heicode/model/token.go b/heicode/model/token.go index a436012..a2c3758 100644 --- a/heicode/model/token.go +++ b/heicode/model/token.go @@ -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 != "" { diff --git a/heicode/router/api-router.go b/heicode/router/api-router.go index 4baf2b0..84e5ee0 100644 --- a/heicode/router/api-router.go +++ b/heicode/router/api-router.go @@ -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) diff --git a/heicode/web/default/src/features/desktop-client-download/desktop-client-download-page.tsx b/heicode/web/default/src/features/desktop-client-download/desktop-client-download-page.tsx new file mode 100644 index 0000000..a747251 --- /dev/null +++ b/heicode/web/default/src/features/desktop-client-download/desktop-client-download-page.tsx @@ -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 ( + + + {t('Heicode desktop client')} + + + {t( + 'Installers are served only after you sign in. Place files on the server and set HEICODE_DESKTOP_FILE_* environment variables.', + )} + + + {q.isLoading ? ( +

{t('Loading...')}

+ ) : q.isError ? ( +

{t('Request failed')}

+ ) : !payload?.items?.length ? ( +

+ {t('No desktop installers are available yet.')} +

+ ) : ( +
+

+ {t('Version: {{version}}', { version: payload.version })} +

+ {payload.notes ? ( +

+ {payload.notes} +

+ ) : null} +
    + {payload.items.map((item) => ( +
  • +
    +
    {item.label}
    +
    + {item.filename} +
    +
    + +
  • + ))} +
+
+ )} +
+
+ ) +} diff --git a/heicode/web/default/src/hooks/use-sidebar-data.ts b/heicode/web/default/src/hooks/use-sidebar-data.ts index 9eccc53..0c3b6d5 100644 --- a/heicode/web/default/src/hooks/use-sidebar-data.ts +++ b/heicode/web/default/src/hooks/use-sidebar-data.ts @@ -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', diff --git a/heicode/web/default/src/i18n/locales/en.json b/heicode/web/default/src/i18n/locales/en.json index c637194..1835de5 100644 --- a/heicode/web/default/src/i18n/locales/en.json +++ b/heicode/web/default/src/i18n/locales/en.json @@ -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", diff --git a/heicode/web/default/src/i18n/locales/fr.json b/heicode/web/default/src/i18n/locales/fr.json index 0e7f0e4..001631c 100644 --- a/heicode/web/default/src/i18n/locales/fr.json +++ b/heicode/web/default/src/i18n/locales/fr.json @@ -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", diff --git a/heicode/web/default/src/i18n/locales/ja.json b/heicode/web/default/src/i18n/locales/ja.json index d93d020..4bb5f86 100644 --- a/heicode/web/default/src/i18n/locales/ja.json +++ b/heicode/web/default/src/i18n/locales/ja.json @@ -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": "利用可能な報酬", diff --git a/heicode/web/default/src/i18n/locales/ru.json b/heicode/web/default/src/i18n/locales/ru.json index 1bc28ff..e7ad823 100644 --- a/heicode/web/default/src/i18n/locales/ru.json +++ b/heicode/web/default/src/i18n/locales/ru.json @@ -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": "Доступные награды", diff --git a/heicode/web/default/src/i18n/locales/vi.json b/heicode/web/default/src/i18n/locales/vi.json index a1c0b5f..dc1f2c8 100644 --- a/heicode/web/default/src/i18n/locales/vi.json +++ b/heicode/web/default/src/i18n/locales/vi.json @@ -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ó", diff --git a/heicode/web/default/src/i18n/locales/zh.json b/heicode/web/default/src/i18n/locales/zh.json index 1bffb57..6157800 100644 --- a/heicode/web/default/src/i18n/locales/zh.json +++ b/heicode/web/default/src/i18n/locales/zh.json @@ -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": "钱包管理", diff --git a/heicode/web/default/src/routeTree.gen.ts b/heicode/web/default/src/routeTree.gen.ts index cf68fcc..4de22a0 100644 --- a/heicode/web/default/src/routeTree.gen.ts +++ b/heicode/web/default/src/routeTree.gen.ts @@ -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, diff --git a/heicode/web/default/src/routes/_authenticated/available-models/index.tsx b/heicode/web/default/src/routes/_authenticated/available-models/index.tsx new file mode 100644 index 0000000..54407d0 --- /dev/null +++ b/heicode/web/default/src/routes/_authenticated/available-models/index.tsx @@ -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 ( +
+
+
+ +

+ {t('Available models')} +

+
+

+ {t('Available models description')} +

+
+ + {isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ) : isError ? ( +

+ {(error as Error)?.message || t('Request failed')} +

+ ) : !models?.length ? ( +

+ {t('No models available for your account')} +

+ ) : ( + + )} +
+ ) +} diff --git a/heicode/web/default/src/routes/_authenticated/desktop-client/index.tsx b/heicode/web/default/src/routes/_authenticated/desktop-client/index.tsx new file mode 100644 index 0000000..6517dde --- /dev/null +++ b/heicode/web/default/src/routes/_authenticated/desktop-client/index.tsx @@ -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, +})