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:
@@ -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=可选说明,展示在下载页
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 != "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
+92
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "利用可能な報酬",
|
||||
|
||||
@@ -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": "Доступные награды",
|
||||
|
||||
@@ -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ó",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
Reference in New Issue
Block a user