Compare commits

...

29 Commits

Author SHA1 Message Date
Sakurasan
4a68ff6162 add claude proxy 2025-07-30 11:18:47 +08:00
Sakurasan
6b2d78fe56 fix:maxTokens 2025-05-04 02:52:04 +08:00
Sakurasan
9c604460b1 fix empty models 2025-04-22 02:52:56 +08:00
Sakurasan
8d34f8d6fe up 2025-04-22 02:16:28 +08:00
Sakurasan
6d1d0f3b6b up 2025-04-22 02:08:32 +08:00
Sakurasan
24529189d9 fix daily usage 2025-04-22 01:50:50 +08:00
Sakurasan
000162b1b1 fix usage 2025-04-22 01:06:03 +08:00
Sakurasan
6662ea5e04 fix record usage 2025-04-22 00:48:24 +08:00
Sakurasan
5789d50e9e update record usage 2025-04-21 23:59:30 +08:00
Sakurasan
ca3d89751d fix stream usage 2025-04-21 22:48:28 +08:00
Sakurasan
2bc857cf88 add log 2025-04-21 21:59:13 +08:00
Sakurasan
a9ff7e1c94 add log 2025-04-21 21:50:29 +08:00
Sakurasan
51d4651c6c up 2025-04-21 20:19:48 +08:00
Sakurasan
e112f3af12 collect usage 2025-04-21 19:10:27 +08:00
Sakurasan
73e53c2333 add models task 2025-04-21 01:40:06 +08:00
Sakurasan
470e49b850 support fetch models 2025-04-21 01:30:17 +08:00
Sakurasan
d426781e47 UP 2025-04-20 19:21:51 +08:00
Sakurasan
b80f0759a5 fix select key 2025-04-20 18:33:59 +08:00
Sakurasan
b83c6d9786 fix active key,suffix 2025-04-20 16:22:23 +08:00
Sakurasan
fe0f2a7e88 key icon 2025-04-20 02:59:08 +08:00
Sakurasan
ed42f3ded7 fix UI & token copy 2025-04-20 02:03:13 +08:00
Sakurasan
54246c542a up 2025-04-19 01:35:18 +08:00
Sakurasan
ca305f4199 fetch model & add apitype 2025-04-19 01:21:28 +08:00
Sakurasan
f8e539c9b4 add cli load 2025-04-18 19:05:23 +08:00
Sakurasan
e0b531c578 fix passkey 2025-04-18 02:47:10 +08:00
Sakurasan
afb51b0a94 remove 2025-04-17 23:56:28 +08:00
Sakurasan
3c4619b98c fix UI 2025-04-17 23:50:02 +08:00
Sakurasan
efd38034ac push home 2025-04-17 23:35:03 +08:00
Sakurasan
d4bf227cd8 fix UI 2025-04-17 23:15:43 +08:00
62 changed files with 1317 additions and 973 deletions

View File

@@ -1,32 +1,23 @@
package main
import (
"context"
"embed"
"fmt"
"io/fs"
"log"
"net/http"
"opencatd-open/middleware"
"opencatd-open/internal/cli"
"opencatd-open/internal/consts"
"opencatd-open/pkg/config"
"opencatd-open/pkg/store"
"opencatd-open/wire"
"os"
"os/signal"
"sync"
"syscall"
"time"
"opencatd-open/router"
"github.com/gin-gonic/gin"
"github.com/spf13/cobra"
)
//go:embed dist/*
var web embed.FS
func main() {
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
cfg, err := config.LoadConfig()
if err != nil {
panic(err)
@@ -36,169 +27,20 @@ func main() {
if err != nil {
panic(err)
}
sqlDB, err := db.DB()
if err != nil {
log.Fatalf("Failed to get underlying *sql.DB: %v", err)
rootCmd := &cobra.Command{
Use: "openteam",
Short: "openteam cli",
Long: consts.Logo,
Run: func(cmd *cobra.Command, args []string) {
router.SetRouter(cfg, db, &web)
},
}
team, err := wire.InitTeamHandler(ctx, cfg, db)
if err != nil {
panic(err)
rootCmd.AddCommand(cli.LoadCmd)
if err := rootCmd.Execute(); err != nil {
log.Fatal(err)
}
api, err := wire.InitAPIHandler(ctx, cfg, db)
if err != nil {
panic(err)
}
proxy, err := wire.InitProxyHandler(ctx, cfg, db, &wg)
if err != nil {
panic(err)
}
r := gin.Default()
r.Use(middleware.CORS())
teamGroup := r.Group("/1")
teamGroup.Use(team.AuthMiddleware())
{
teamGroup.POST("/users/init", team.InitAdmin)
// 获取当前用户信息
teamGroup.GET("/me", team.Me)
// team.GET("/me/usages", team.HandleMeUsage)
teamGroup.POST("/keys", team.CreateKey)
teamGroup.GET("/keys", team.ListKeys)
teamGroup.POST("/keys/:id", team.UpdateKey)
teamGroup.DELETE("/keys/:id", team.DeleteKey)
teamGroup.POST("/users", team.CreateUser)
teamGroup.GET("/users", team.ListUsers)
teamGroup.POST("/users/:id/reset", team.ResetUserToken)
teamGroup.DELETE("/users/:id", team.DeleteUser)
teamGroup.GET("/1/usages", team.ListUsages)
}
public := r.Group("/api/auth")
{
public.GET("/passkey/begin", api.PasskeyAuthBegin)
public.POST("/passkey/finish", api.PasskeyAuthFinish)
public.POST("/register", api.Register)
public.POST("/login", api.Login)
}
apiGroup := r.Group("/api", middleware.Auth)
{
apiGroup.GET("/profile", api.Profile)
apiGroup.POST("/profile/update", api.UpdateProfile)
apiGroup.POST("/profile/update/password", api.UpdatePassword)
// 绑定PassKey
apiGroup.GET("/profile/passkey", api.PasskeyCreateBegin)
apiGroup.POST("/profile/passkey", api.PasskeyCreateFinish)
apiGroup.GET("/profile/passkeys", api.ListPasskey)
apiGroup.DELETE("/profile/passkeys/:id", api.DeletePasskey)
userGroup := apiGroup.Group("/users")
{
userGroup.POST("", api.CreateUser)
userGroup.GET("", api.ListUser)
userGroup.GET("/:id", api.GetUser)
userGroup.PUT("/:id", api.EditUser)
userGroup.DELETE("/:id", api.DeleteUser)
userGroup.POST("/batch/:option", api.UserOption)
}
tokenGroup := apiGroup.Group("/tokens")
tokenGroup.POST("", api.CreateToken)
tokenGroup.GET("", api.ListToken)
// tokenGroup.GET("/:id", api.GetToken)
tokenGroup.POST("/reset/:id", api.ResetToken)
tokenGroup.PUT("/:id", api.UpdateToken)
tokenGroup.DELETE("/:id", api.DeleteToken)
// tokenGroup.POST("/batch/:option", api.TokenOption)
apiGroup.POST("keys", api.CreateApiKey)
apiGroup.GET("keys", api.ListApiKey)
apiGroup.GET("keys/:id", api.GetApiKey)
apiGroup.PUT("keys/:id", api.UpdateApiKey)
apiGroup.DELETE("keys/:id", api.DeleteApiKey)
apiGroup.POST("keys/batch/:option", api.ApiKeyOption)
}
v1 := r.Group("/v1")
v1.Use(middleware.AuthLLM(db))
{
// v1.POST("/v2/*proxypath", router.HandleProxy)
v1.POST("/*proxypath", proxy.HandleProxy)
// v1.GET("/models", dashboard.HandleModels)
}
idxFS, err := fs.Sub(web, "dist")
if err != nil {
panic(err)
}
assetsFS, err := fs.Sub(web, "dist/assets")
if err != nil {
panic(err)
}
r.StaticFS("/assets", http.FS(assetsFS))
r.NoRoute(func(c *gin.Context) {
if c.Writer.Status() == http.StatusNotFound {
c.FileFromFS("/", http.FS(idxFS))
}
})
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Port),
Handler: r,
}
go func() {
fmt.Println("Starting server at port:", cfg.Port)
// 服务启动
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %s\n", err)
}
}()
// 等待中断信号来优雅地关闭服务器
quit := make(chan os.Signal, 1)
// kill (no param) default send syscall.SIGTERM
// kill -2 is syscall.SIGINT
// kill -9 is syscall.SIGKILL but can't be catch
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
fmt.Println("\nShutdown Server ...")
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Fatalln("Server Shutdown:", err)
}
cancel()
sqlDB.Close()
waitChan := make(chan struct{})
go func() {
wg.Wait()
close(waitChan)
}()
select {
case <-waitChan:
fmt.Println("All goroutines have finished")
case <-shutdownCtx.Done():
fmt.Println("⚠️ Shutdown timeout")
}
fmt.Println("Server exited")
}
func printFilesAndDirs(fsys fs.FS, prefix string) error {

View File

@@ -0,0 +1,8 @@
version: '3.9'
services:
adminer:
image: adminer
restart: always
ports:
- 8080:8080

View File

@@ -4,6 +4,7 @@ services:
mariadb:
image: mariadb
container_name: mysql
restart: unless-stopped
ports:
- "3306:3306"
volumes:
@@ -17,9 +18,4 @@ services:
MYSQL_DATABASE: openteam
MYSQL_USER: openteam
MYSQL_PASSWORD: openteam
# adminer:
# image: adminer
# restart: always
# ports:
# - 8080:8080

View File

@@ -20,8 +20,3 @@ services:
volumes:
- $PWD/pgdata:/var/lib/postgresql/data
# adminer:
# image: adminer
# restart: always
# ports:
# - 8080:8080

View File

@@ -0,0 +1,10 @@
version: '3.7'
services:
sqlite-web:
image: vaalacat/sqlite-web
ports:
- 8800:8080
volumes:
- $PWD/db:/data
environment:
- SQLITE_DATABASE=openteam.db

View File

@@ -14,6 +14,7 @@
"axios": "^1.8.4",
"element-plus": "^2.9.7",
"lucide-vue-next": "^0.479.0",
"qrcode.vue": "^3.6.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},

169
frontend/pnpm-lock.yaml generated
View File

@@ -23,6 +23,9 @@ importers:
lucide-vue-next:
specifier: ^0.479.0
version: 0.479.0(vue@3.5.13)
qrcode.vue:
specifier: ^3.6.0
version: 3.6.0(vue@3.5.13)
vue:
specifier: ^3.5.13
version: 3.5.13
@@ -556,6 +559,10 @@ packages:
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
engines: {node: '>= 6'}
camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
caniuse-lite@1.0.30001714:
resolution: {integrity: sha512-mtgapdwDLSSBnCI3JokHM7oEQBLxiJKVRtg10AxM1AyeiKcM96f0Mkbqeq+1AbiCtvMcHRulAAEMu693JrSWqg==}
@@ -563,6 +570,9 @@ packages:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -604,6 +614,10 @@ packages:
dayjs@1.11.13:
resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==}
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
@@ -611,6 +625,9 @@ packages:
didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
dlv@1.1.3:
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
@@ -692,6 +709,10 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
follow-redirects@1.15.9:
resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==}
engines: {node: '>=4.0'}
@@ -720,6 +741,10 @@ packages:
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
@@ -797,6 +822,10 @@ packages:
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
lodash-es@4.17.21:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
@@ -882,9 +911,25 @@ packages:
resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
engines: {node: '>= 6'}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
@@ -924,6 +969,10 @@ packages:
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
engines: {node: '>= 6'}
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
postcss-import@15.1.0:
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
engines: {node: '>=14.0.0'}
@@ -968,6 +1017,16 @@ packages:
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
qrcode.vue@3.6.0:
resolution: {integrity: sha512-vQcl2fyHYHMjDO1GguCldJxepq2izQjBkDEEu9NENgfVKP6mv/e2SU62WbqYHGwTgWXLhxZ1NCD1dAZKHQq1fg==}
peerDependencies:
vue: ^3.0.0
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -978,6 +1037,13 @@ packages:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
resolve@1.22.10:
resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==}
engines: {node: '>= 0.4'}
@@ -995,6 +1061,9 @@ packages:
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -1132,11 +1201,18 @@ packages:
typescript:
optional: true
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
hasBin: true
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -1145,11 +1221,22 @@ packages:
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
engines: {node: '>=12'}
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
yaml@2.7.1:
resolution: {integrity: sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==}
engines: {node: '>= 14'}
hasBin: true
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
snapshots:
'@alloc/quick-lru@5.2.0': {}
@@ -1540,6 +1627,8 @@ snapshots:
camelcase-css@2.0.1: {}
camelcase@5.3.1: {}
caniuse-lite@1.0.30001714: {}
chokidar@3.6.0:
@@ -1554,6 +1643,12 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
cliui@6.0.0:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -1594,10 +1689,14 @@ snapshots:
dayjs@1.11.13: {}
decamelize@1.2.0: {}
delayed-stream@1.0.0: {}
didyoumean@1.2.2: {}
dijkstrajs@1.0.3: {}
dlv@1.1.3: {}
dunder-proto@1.0.1:
@@ -1708,6 +1807,11 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
find-up@4.1.0:
dependencies:
locate-path: 5.0.0
path-exists: 4.0.0
follow-redirects@1.15.9: {}
foreground-child@3.3.1:
@@ -1729,6 +1833,8 @@ snapshots:
function-bind@1.1.2: {}
get-caller-file@2.0.5: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -1808,6 +1914,10 @@ snapshots:
lines-and-columns@1.2.4: {}
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
lodash-es@4.17.21: {}
lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.17.21)(lodash@4.17.21):
@@ -1871,8 +1981,20 @@ snapshots:
object-hash@3.0.0: {}
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
p-locate@4.1.0:
dependencies:
p-limit: 2.3.0
p-try@2.2.0: {}
package-json-from-dist@1.0.1: {}
path-exists@4.0.0: {}
path-key@3.1.1: {}
path-parse@1.0.7: {}
@@ -1900,6 +2022,8 @@ snapshots:
pirates@4.0.7: {}
pngjs@5.0.0: {}
postcss-import@15.1.0(postcss@8.5.3):
dependencies:
postcss: 8.5.3
@@ -1939,6 +2063,16 @@ snapshots:
proxy-from-env@1.1.0: {}
qrcode.vue@3.6.0(vue@3.5.13):
dependencies:
vue: 3.5.13
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
queue-microtask@1.2.3: {}
read-cache@1.0.0:
@@ -1949,6 +2083,10 @@ snapshots:
dependencies:
picomatch: 2.3.1
require-directory@2.1.1: {}
require-main-filename@2.0.0: {}
resolve@1.22.10:
dependencies:
is-core-module: 2.16.1
@@ -1987,6 +2125,8 @@ snapshots:
dependencies:
queue-microtask: 1.2.3
set-blocking@2.0.0: {}
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
@@ -2113,10 +2253,18 @@ snapshots:
'@vue/server-renderer': 3.5.13(vue@3.5.13)
'@vue/shared': 3.5.13
which-module@2.0.1: {}
which@2.0.2:
dependencies:
isexe: 2.0.0
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
@@ -2129,4 +2277,25 @@ snapshots:
string-width: 5.1.2
strip-ansi: 7.1.0
y18n@4.0.3: {}
yaml@2.7.1: {}
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
yargs@15.4.1:
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3

View File

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"></path></svg>

After

Width:  |  Height:  |  Size: 368 B

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Azure</title><path d="M7.242 1.613A1.11 1.11 0 018.295.857h6.977L8.03 22.316a1.11 1.11 0 01-1.052.755h-5.43a1.11 1.11 0 01-1.053-1.466L7.242 1.613z" fill="url(#lobe-icons-azure-fill-0)"></path><path d="M18.397 15.296H7.4a.51.51 0 00-.347.882l7.066 6.595c.206.192.477.298.758.298h6.226l-2.706-7.775z" fill="#0078D4"></path><path d="M15.272.857H7.497L0 23.071h7.775l1.596-4.73 5.068 4.73h6.665l-2.707-7.775h-7.998L15.272.857z" fill="url(#lobe-icons-azure-fill-1)"></path><path d="M17.193 1.613a1.11 1.11 0 00-1.052-.756h-7.81.035c.477 0 .9.304 1.052.756l6.748 19.992a1.11 1.11 0 01-1.052 1.466h-.12 7.895a1.11 1.11 0 001.052-1.466L17.193 1.613z" fill="url(#lobe-icons-azure-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-0" x1="8.247" x2="1.002" y1="1.626" y2="23.03"><stop stop-color="#114A8B"></stop><stop offset="1" stop-color="#0669BC"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-1" x1="14.042" x2="12.324" y1="15.302" y2="15.888"><stop stop-opacity=".3"></stop><stop offset=".071" stop-opacity=".2"></stop><stop offset=".321" stop-opacity=".1"></stop><stop offset=".623" stop-opacity=".05"></stop><stop offset="1" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-2" x1="12.841" x2="20.793" y1="1.626" y2="22.814"><stop stop-color="#3CCBF4"></stop><stop offset="1" stop-color="#2892DF"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Bedrock</title><defs><linearGradient id="lobe-icons-bedrock-fill" x1="80%" x2="20%" y1="20%" y2="80%"><stop offset="0%" stop-color="#6350FB"></stop><stop offset="50%" stop-color="#3D8FFF"></stop><stop offset="100%" stop-color="#9AD8F8"></stop></linearGradient></defs><path d="M13.05 15.513h3.08c.214 0 .389.177.389.394v1.82a1.704 1.704 0 011.296 1.661c0 .943-.755 1.708-1.685 1.708-.931 0-1.686-.765-1.686-1.708 0-.807.554-1.484 1.297-1.662v-1.425h-2.69v4.663a.395.395 0 01-.188.338l-2.69 1.641a.385.385 0 01-.405-.002l-4.926-3.086a.395.395 0 01-.185-.336V16.3L2.196 14.87A.395.395 0 012 14.555L2 14.528V9.406c0-.14.073-.27.192-.34l2.465-1.462V4.448c0-.129.062-.249.165-.322l.021-.014L9.77 1.058a.385.385 0 01.407 0l2.69 1.675a.395.395 0 01.185.336V7.6h3.856V5.683a1.704 1.704 0 01-1.296-1.662c0-.943.755-1.708 1.685-1.708.931 0 1.685.765 1.685 1.708 0 .807-.553 1.484-1.296 1.662v2.311a.391.391 0 01-.389.394h-4.245v1.806h6.624a1.69 1.69 0 011.64-1.313c.93 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708a1.69 1.69 0 01-1.64-1.314H13.05v1.937h4.953l.915 1.18a1.66 1.66 0 01.84-.227c.931 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708-.93 0-1.685-.765-1.685-1.708 0-.346.102-.668.276-.937l-.724-.935H13.05v1.806zM9.973 1.856L7.93 3.122V6.09h-.778V3.604L5.435 4.669v2.945l2.11 1.36L9.712 7.61V5.334h.778V7.83c0 .136-.07.263-.184.335L7.963 9.638v2.081l1.422 1.009-.446.646-1.406-.998-1.53 1.005-.423-.66 1.605-1.055v-1.99L5.038 8.29l-2.26 1.34v1.676l1.972-1.189.398.677-2.37 1.429V14.3l2.166 1.258 2.27-1.368.397.677-2.176 1.311V19.3l1.876 1.175 2.365-1.426.398.678-2.017 1.216 1.918 1.201 2.298-1.403v-5.78l-4.758 2.893-.4-.675 5.158-3.136V3.289L9.972 1.856zM16.13 18.47a.913.913 0 00-.908.92c0 .507.406.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zm3.63-3.81a.913.913 0 00-.908.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92zm1.555-4.99a.913.913 0 00-.908.92c0 .507.407.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zM17.296 3.1a.913.913 0 00-.907.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92z" fill="url(#lobe-icons-bedrock-fill)" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><defs><linearGradient id="lobe-icons-gemini-fill" x1="0%" x2="68.73%" y1="100%" y2="30.395%"><stop offset="0%" stop-color="#1C7DFF"></stop><stop offset="52.021%" stop-color="#1C69FF"></stop><stop offset="100%" stop-color="#F0DCD6"></stop></linearGradient></defs><path d="M12 24A14.304 14.304 0 000 12 14.304 14.304 0 0012 0a14.305 14.305 0 0012 12 14.305 14.305 0 00-12 12" fill="url(#lobe-icons-gemini-fill)" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 581 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="56" viewBox="0 0 24 24" width="56" xmlns="http://www.w3.org/2000/svg" style="flex: 0 0 auto; line-height: 1;"><title>Github</title><path d="M12 0c6.63 0 12 5.276 12 11.79-.001 5.067-3.29 9.567-8.175 11.187-.6.118-.825-.25-.825-.56 0-.398.015-1.665.015-3.242 0-1.105-.375-1.813-.81-2.181 2.67-.295 5.475-1.297 5.475-5.822 0-1.297-.465-2.344-1.23-3.169.12-.295.54-1.503-.12-3.125 0 0-1.005-.324-3.3 1.209a11.32 11.32 0 00-3-.398c-1.02 0-2.04.133-3 .398-2.295-1.518-3.3-1.209-3.3-1.209-.66 1.622-.24 2.83-.12 3.125-.765.825-1.23 1.887-1.23 3.169 0 4.51 2.79 5.527 5.46 5.822-.345.294-.66.81-.765 1.577-.69.31-2.415.81-3.495-.973-.225-.354-.9-1.223-1.845-1.209-1.005.015-.405.56.015.781.51.28 1.095 1.327 1.23 1.666.24.663 1.02 1.93 4.035 1.385 0 .988.015 1.916.015 2.196 0 .31-.225.664-.825.56C3.303 21.374-.003 16.867 0 11.791 0 5.276 5.37 0 12 0z"></path></svg>

After

Width:  |  Height:  |  Size: 913 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="56" viewBox="0 0 24 24" width="56" xmlns="http://www.w3.org/2000/svg" style="flex: 0 0 auto; line-height: 1;"><title>Github</title><path d="M12 0c6.63 0 12 5.276 12 11.79-.001 5.067-3.29 9.567-8.175 11.187-.6.118-.825-.25-.825-.56 0-.398.015-1.665.015-3.242 0-1.105-.375-1.813-.81-2.181 2.67-.295 5.475-1.297 5.475-5.822 0-1.297-.465-2.344-1.23-3.169.12-.295.54-1.503-.12-3.125 0 0-1.005-.324-3.3 1.209a11.32 11.32 0 00-3-.398c-1.02 0-2.04.133-3 .398-2.295-1.518-3.3-1.209-3.3-1.209-.66 1.622-.24 2.83-.12 3.125-.765.825-1.23 1.887-1.23 3.169 0 4.51 2.79 5.527 5.46 5.822-.345.294-.66.81-.765 1.577-.69.31-2.415.81-3.495-.973-.225-.354-.9-1.223-1.845-1.209-1.005.015-.405.56.015.781.51.28 1.095 1.327 1.23 1.666.24.663 1.02 1.93 4.035 1.385 0 .988.015 1.916.015 2.196 0 .31-.225.664-.825.56C3.303 21.374-.003 16.867 0 11.791 0 5.276 5.37 0 12 0z"></path></svg>

After

Width:  |  Height:  |  Size: 913 B

View File

@@ -1,243 +0,0 @@
<template>
<!-- 组件根元素相对定位设置最大宽度外边距宽高比背景渐变内边距圆角阴影和溢出隐藏 -->
<div
class="relative w-full max-w-4xl mx-auto my-10 aspect-[4/3] bg-gradient-to-br from-slate-50 to-orange-50 p-4 rounded-lg shadow-md overflow-hidden">
<!-- 中心图标容器 -->
<div ref="centerElement" class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-20">
<!-- 中心图标本身设置宽高圆角Flex 布局居中背景模糊效果 -->
<div class="w-10 h-10 md:w-12 md:h-12 rounded-full flex items-center justify-center backdrop-blur-md">
<!-- 中心图标图片 -->
<img src="../assets/logo.svg" alt="Center Logo" class="w-full h-full object-contain">
</div>
</div>
<!-- 左侧图标列 -->
<div
class="absolute top-0 left-0 h-full flex flex-col justify-around items-center py-4 md:py-8 px-2 md:px-4 z-10">
<!-- 遍历左侧图标数据 -->
<div v-for="icon in leftIcons" :key="icon.id" :ref="el => { if (el) iconRefs[icon.id] = el }"
class="w-8 h-8 md:w-10 md:h-10 flex items-center justify-center">
<!-- 图标图片或占位符 -->
<img v-if="icon.img" :src="icon.img" :alt="icon.name" class="w-full h-full object-contain">
<div v-else
class="w-full h-full rounded bg-gray-300 flex items-center justify-center text-xs text-gray-600">?
</div>
</div>
</div>
<!-- 右侧图标列 -->
<div
class="absolute top-0 right-0 h-full flex flex-col justify-around items-center py-4 md:py-8 px-2 md:px-4 z-10">
<!-- 遍历右侧图标数据 -->
<div v-for="icon in rightIcons" :key="icon.id" :ref="el => { if (el) iconRefs[icon.id] = el }"
class="w-8 h-8 md:w-10 md:h-10 flex items-center justify-center">
<!-- 图标图片或占位符 -->
<img v-if="icon.img" :src="icon.img" :alt="icon.name" class="w-full h-full object-contain">
<div v-else
class="w-full h-full rounded bg-gray-300 flex items-center justify-center text-xs text-gray-600">?
</div>
</div>
</div>
<!-- SVG 画布用于绘制线条和动画 -->
<svg class="absolute inset-0 w-full h-full z-0" ref="svgCanvas">
<defs>
<!-- 这里可以定义 SVG 渐变或标记 (marker) -->
</defs>
<!-- 只有当中心点和图标坐标都计算好后才开始绘制 -->
<g v-if="centerCoords && Object.keys(iconCoords).length >= (leftIcons.length + rightIcons.length)">
<!-- 绘制左侧图标的线条和动画 -->
<template v-for="icon in leftIcons" :key="'line-' + icon.id">
<!-- 绘制静态连接线 (图标到中心) -->
<path :id="'path-visual-' + icon.id" :d="calculatePathForVisual(iconCoords[icon.id], centerCoords, 'left')"
stroke="#E5E7EB" stroke-width="1" fill="none" />
<!-- 创建一个用于动画的小圆点 (从图标到中心) -->
<circle cx="0" cy="0" r="2.5" :fill="icon.color || '#DB2777'">
<!-- 定义动画让圆点沿着指定路径移动 (图标到中心) -->
<animateMotion :dur="`${4 + Math.random() * 4}s`" :begin="`${Math.random() * -5}s`"
repeatCount="indefinite" fill="freeze"
:path="calculatePathForAnimation(iconCoords[icon.id], centerCoords, 'left', 'toCenter')"
rotate="auto" />
</circle>
</template>
<!-- 绘制右侧图标的线条和动画 -->
<template v-for="icon in rightIcons" :key="'line-' + icon.id">
<!-- 绘制静态连接线 (图标到中心) -->
<path :id="'path-visual-' + icon.id" :d="calculatePathForVisual(iconCoords[icon.id], centerCoords, 'right')"
stroke="#E5E7EB" stroke-width="1" fill="none" />
<!-- 创建一个用于动画的小圆点 (从中心到图标) -->
<circle cx="0" cy="0" r="2.5" :fill="icon.color || '#1D4ED8'">
<!-- 定义动画让圆点沿着指定路径移动 (中心到图标) -->
<animateMotion :dur="`${4 + Math.random() * 4}s`" :begin="`${Math.random() * -5}s`"
repeatCount="indefinite" fill="freeze"
:path="calculatePathForAnimation(iconCoords[icon.id], centerCoords, 'right', 'fromCenter')"
rotate="auto" />
</circle>
</template>
</g>
</svg>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick, reactive } from 'vue';
// --- 图标数据 ---
// (保持你的图标数据不变)
const leftIcons = ref([
{ id: 'web', name: 'Web', img: 'https://img.icons8.com/?size=100&id=38536&format=png&color=000000', color: '#DB4437' },
{ id: 'iphone', name: 'iPhone', img: 'https://img.icons8.com/?size=100&id=ZwGNoFXGbt9n&format=png&color=000000', color: '#00AB6C' },
{ id: 'mac', name: 'Mac', img: 'https://img.icons8.com/?size=100&id=RHxDgbKmJhUD&format=png&color=000000', color: '#1DB954' },
]);
const rightIcons = ref([
{ id: 'openai', name: 'OpenAI', img: 'https://img.icons8.com/?size=100&id=FBO05Dys9QCg&format=png&color=000000', color: '#E4405F' },
{ id: 'claude', name: 'Claude', img: 'https://img.icons8.com/?size=100&id=H5H0mqCCr5AV&format=png&color=000000', color: '#229ED9' },
{ id: 'gemini', name: 'Gemini', img: 'https://img.icons8.com/?size=100&id=eoxMN35Z6JKg&format=png&color=000000', color: '#FF6600' },
{ id: 'azure', name: 'Azure', img: 'https://img.icons8.com/?size=100&id=VLKafOkk3sBX&format=png&color=000000', color: '#007FFF' }, // Changed color slightly
{ id: 'bedrock', name: 'BedRock', img: 'https://img.icons8.com/?size=100&id=saSupsgVcmJe&format=png&color=000000', color: '#FF9900' }, // Changed color slightly
{ id: 'google', name: 'Google', img: 'https://img.icons8.com/color/48/google-logo.png', color: '#DB4437' },
{ id: 'deepseek', name: 'DeepSeek', img: 'https://img.icons8.com/?size=100&id=YWOidjGxCpFW&format=png&color=000000', color: '#3a7dd5' }, // Changed color slightly
{ id: 'github', name: 'GitHub', img: 'https://img.icons8.com/ios-filled/50/000000/github.png', color: '#111111' },
]);
// --- 结束图标数据 ---
const svgCanvas = ref(null);
const centerElement = ref(null);
const iconRefs = reactive({});
const centerCoords = ref(null);
const iconCoords = reactive({});
const getElementCenterCoords = (element) => {
if (!element || !svgCanvas.value) return null;
const svgRect = svgCanvas.value.getBoundingClientRect();
const elemRect = element.getBoundingClientRect();
return {
x: elemRect.left + elemRect.width / 2 - svgRect.left,
y: elemRect.top + elemRect.height / 2 - svgRect.top,
};
};
const updateCoordinates = () => {
if (!centerElement.value || !svgCanvas.value) return;
centerCoords.value = getElementCenterCoords(centerElement.value);
const allIcons = [...leftIcons.value, ...rightIcons.value];
let coordsFound = 0;
allIcons.forEach(icon => {
const element = iconRefs[icon.id];
if (element) {
iconCoords[icon.id] = getElementCenterCoords(element);
if (iconCoords[icon.id]) {
coordsFound++;
}
} else {
console.warn(`找不到图标 ${icon.id} 的 DOM 元素引用。`);
}
});
if (coordsFound < allIcons.length) {
// console.warn("部分图标坐标未能成功计算。"); // 可以取消注释以进行调试
}
};
/**
* 计算静态视觉连接线的 SVG 路径 (总是从图标到中心)
* @param {object} iconCoord 图标坐标 {x, y}
* @param {object} centerCoord 中心坐标 {x, y}
* @param {'left' | 'right'} side 图标在哪一侧
* @returns {string} SVG path 'd' 属性字符串
*/
const calculatePathForVisual = (iconCoord, centerCoord, side) => {
if (!iconCoord || !centerCoord) return '';
const { x: startX, y: startY } = iconCoord; // 起点是图标
const { x: endX, y: endY } = centerCoord; // 终点是中心
// 控制点计算逻辑 (与原版一致,确保曲线形状不变)
const controlX = (side === 'left')
? startX + (endX - startX) * 0.6
: startX - (startX - endX) * 0.6;
const controlY = startY; // 控制点 Y 与起点(图标)对齐
return `M ${startX},${startY} Q ${controlX},${controlY} ${endX},${endY}`;
};
/**
* 计算动画运动的 SVG 路径
* @param {object} iconCoord 图标坐标 {x, y}
* @param {object} centerCoord 中心坐标 {x, y}
* @param {'left' | 'right'} side 图标在哪一侧
* @param {'toCenter' | 'fromCenter'} direction 动画方向
* @returns {string} SVG path 'd' 属性字符串
*/
const calculatePathForAnimation = (iconCoord, centerCoord, side, direction) => {
if (!iconCoord || !centerCoord) return '';
let startX, startY, endX, endY;
let controlX, controlY;
if (direction === 'fromCenter') {
// --- 动画从中心开始 ---
startX = centerCoord.x;
startY = centerCoord.y;
endX = iconCoord.x;
endY = iconCoord.y;
// 控制点计算:
// 为了使曲线形状看起来与 'toCenter' 类似但方向相反
// 我们将控制点放在靠近中心(起点)的位置,并使其 Y 坐标与终点(图标)对齐
controlX = startX + (endX - startX) * 0.4; // X 轴方向上,控制点靠近起点 (中心)
controlY = endY; // Y 轴方向上,与终点 (图标) 对齐
} else { // direction === 'toCenter' (默认)
// --- 动画从图标开始 ---
startX = iconCoord.x;
startY = iconCoord.y;
endX = centerCoord.x;
endY = centerCoord.y;
// 控制点计算 (与视觉线一致)
controlX = (side === 'left')
? startX + (endX - startX) * 0.6
: startX - (startX - endX) * 0.6;
controlY = startY; // Y 轴方向上,与起点 (图标) 对齐
}
return `M ${startX},${startY} Q ${controlX},${controlY} ${endX},${endY}`;
};
// --- 生命周期钩子 ---
let resizeObserver;
onMounted(async () => {
await nextTick();
updateCoordinates();
resizeObserver = new ResizeObserver(updateCoordinates); // 简化:直接传递函数
if (svgCanvas.value?.parentElement) {
resizeObserver.observe(svgCanvas.value.parentElement);
} else {
console.warn("无法找到用于 ResizeObserver 的父元素。");
}
});
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect();
}
});
</script>
<style scoped>
img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
background: #3a7dd5;
}
.flex-col.justify-around {
justify-content: space-around;
}
</style>

View File

@@ -1,251 +0,0 @@
<template>
<div class="relative w-full h-[500px] flex items-center justify-center backdrop-blur-0">
<svg class="absolute w-full h-full" viewBox="0 0 500 500" xmlns="http://www.w3.org/2000/svg">
<g>
<path :d="'M80,' + getLeftIconY(0) + ' C150,' + (getLeftIconY(0) + centerOffsetY) / 2 + ' 200,' + (getLeftIconY(0) + centerOffsetY) / 2 + ' 250,250'" fill="none" stroke="#f0f0f0" stroke-width="1.5" />
<g class="flow-segment left-flow">
<line x1="-8" y1="0" x2="8" y2="0" stroke="#3b82f6" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="4s" repeatCount="indefinite"
:path="'M80,' + getLeftIconY(0) + ' C150,' + (getLeftIconY(0) + centerOffsetY) / 2 + ' 200,' + (getLeftIconY(0) + centerOffsetY) / 2 + ' 250,250'"
rotate="auto" />
</line>
<line x1="-5" y1="0" x2="5" y2="0" stroke="#3b82f6" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="4s" begin="1s" repeatCount="indefinite"
:path="'M80,' + getLeftIconY(0) + ' C150,' + (getLeftIconY(0) + centerOffsetY) / 2 + ' 200,' + (getLeftIconY(0) + centerOffsetY) / 2 + ' 250,250'"
rotate="auto" />
</line>
<line x1="-6" y1="0" x2="6" y2="0" stroke="#3b82f6" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="4s" begin="2s" repeatCount="indefinite"
:path="'M80,' + getLeftIconY(0) + ' C150,' + (getLeftIconY(0) + centerOffsetY) / 2 + ' 200,' + (getLeftIconY(0) + centerOffsetY) / 2 + ' 250,250'"
rotate="auto" />
</line>
</g>
<path :d="'M80,' + getLeftIconY(1) + ' C150,' + (getLeftIconY(1) + centerOffsetY) / 2 + ' 200,' + (getLeftIconY(1) + centerOffsetY) / 2 + ' 250,250'" fill="none" stroke="#f0f0f0" stroke-width="1.5" />
<g class="flow-segment left-flow">
<line x1="-8" y1="0" x2="8" y2="0" stroke="#3b82f6" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="3.7s" repeatCount="indefinite"
:path="'M80,' + getLeftIconY(1) + ' C150,' + (getLeftIconY(1) + centerOffsetY) / 2 + ' 200,' + (getLeftIconY(1) + centerOffsetY) / 2 + ' 250,250'"
rotate="auto" />
</line>
<line x1="-5" y1="0" x2="5" y2="0" stroke="#3b82f6" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="3.7s" begin="1.2s" repeatCount="indefinite"
:path="'M80,' + getLeftIconY(1) + ' C150,' + (getLeftIconY(1) + centerOffsetY) / 2 + ' 200,' + (getLeftIconY(1) + centerOffsetY) / 2 + ' 250,250'"
rotate="auto" />
</line>
<line x1="-6" y1="0" x2="6" y2="0" stroke="#3b82f6" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="3.7s" begin="2.4s" repeatCount="indefinite"
:path="'M80,' + getLeftIconY(1) + ' C150,' + (getLeftIconY(1) + centerOffsetY) / 2 + ' 200,' + (getLeftIconY(1) + centerOffsetY) / 2 + ' 250,250'"
rotate="auto" />
</line>
</g>
<path :d="'M250,250 C300,' + (250 + getRightIconY(0)) / 2 + ' 350,' + (250 + getRightIconY(0)) / 2 + ' 420,' + getRightIconY(0)" fill="none" stroke="#f0f0f0" stroke-width="1.5" />
<g class="flow-segment right-flow">
<line x1="-8" y1="0" x2="8" y2="0" stroke="#f97316" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="3s" repeatCount="indefinite"
:path="'M250,250 C300,' + (250 + getRightIconY(0)) / 2 + ' 350,' + (250 + getRightIconY(0)) / 2 + ' 420,' + getRightIconY(0)"
rotate="auto" />
</line>
<line x1="-5" y1="0" x2="5" y2="0" stroke="#f97316" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="3s" begin="1s" repeatCount="indefinite"
:path="'M250,250 C300,' + (250 + getRightIconY(0)) / 2 + ' 350,' + (250 + getRightIconY(0)) / 2 + ' 420,' + getRightIconY(0)"
rotate="auto" />
</line>
<line x1="-6" y1="0" x2="6" y2="0" stroke="#f97316" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="3s" begin="2s" repeatCount="indefinite"
:path="'M250,250 C300,' + (250 + getRightIconY(0)) / 2 + ' 350,' + (250 + getRightIconY(0)) / 2 + ' 420,' + getRightIconY(0)"
rotate="auto" />
</line>
</g>
<path :d="'M250,250 C300,' + (250 + getRightIconY(1)) / 2 + ' 350,' + (250 + getRightIconY(1)) / 2 + ' 420,' + getRightIconY(1)" fill="none" stroke="#f0f0f0" stroke-width="1.5" />
<g class="flow-segment right-flow">
<line x1="-8" y1="0" x2="8" y2="0" stroke="#f97316" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="2.5s" repeatCount="indefinite"
:path="'M250,250 C300,' + (250 + getRightIconY(1)) / 2 + ' 350,' + (250 + getRightIconY(1)) / 2 + ' 420,' + getRightIconY(1)"
rotate="auto" />
</line>
<line x1="-5" y1="0" x2="5" y2="0" stroke="#f97316" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="2.5s" begin="0.8s" repeatCount="indefinite"
:path="'M250,250 C300,' + (250 + getRightIconY(1)) / 2 + ' 350,' + (250 + getRightIconY(1)) / 2 + ' 420,' + getRightIconY(1)"
rotate="auto" />
</line>
<line x1="-6" y1="0" x2="6" y2="0" stroke="#f97316" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="2.5s" begin="1.6s" repeatCount="indefinite"
:path="'M250,250 C300,' + (250 + getRightIconY(1)) / 2 + ' 350,' + (250 + getRightIconY(1)) / 2 + ' 420,' + getRightIconY(1)"
rotate="auto" />
</line>
</g>
<path :d="'M250,250 C300,' + (250 + getRightIconY(2)) / 2 + ' 350,' + (250 + getRightIconY(2)) / 2 + ' 420,' + getRightIconY(2)" fill="none" stroke="#f0f0f0" stroke-width="1.5" />
<g class="flow-segment right-flow">
<line x1="-8" y1="0" x2="8" y2="0" stroke="#f97316" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="3.2s" repeatCount="indefinite"
:path="'M250,250 C300,' + (250 + getRightIconY(2)) / 2 + ' 350,' + (250 + getRightIconY(2)) / 2 + ' 420,' + getRightIconY(2)"
rotate="auto" />
</line>
<line x1="-5" y1="0" x2="5" y2="0" stroke="#f97316" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="3.2s" begin="1.1s" repeatCount="indefinite"
:path="'M250,250 C300,' + (250 + getRightIconY(2)) / 2 + ' 350,' + (250 + getRightIconY(2)) / 2 + ' 420,' + getRightIconY(2)"
rotate="auto" />
</line>
<line x1="-6" y1="0" x2="6" y2="0" stroke="#f97316" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="3.2s" begin="2.2s" repeatCount="indefinite"
:path="'M250,250 C300,' + (250 + getRightIconY(2)) / 2 + ' 350,' + (250 + getRightIconY(2)) / 2 + ' 420,' + getRightIconY(2)"
rotate="auto" />
</line>
</g>
<path :d="'M250,250 C300,' + (250 + getRightIconY(3)) / 2 + ' 350,' + (250 + getRightIconY(3)) / 2 + ' 420,' + getRightIconY(3)" fill="none" stroke="#f0f0f0" stroke-width="1.5" />
<g class="flow-segment right-flow">
<line x1="-8" y1="0" x2="8" y2="0" stroke="#f97316" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="3.7s" repeatCount="indefinite"
:path="'M250,250 C300,' + (250 + getRightIconY(3)) / 2 + ' 350,' + (250 + getRightIconY(3)) / 2 + ' 420,' + getRightIconY(3)"
rotate="auto" />
</line>
<line x1="-5" y1="0" x2="5" y2="0" stroke="#f97316" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="3.7s" begin="1.2s" repeatCount="indefinite"
:path="'M250,250 C300,' + (250 + getRightIconY(3)) / 2 + ' 350,' + (250 + getRightIconY(3)) / 2 + ' 420,' + getRightIconY(3)"
rotate="auto" />
</line>
<line x1="-6" y1="0" x2="6" y2="0" stroke="#f97316" stroke-width="2.5" stroke-linecap="round">
<animateMotion dur="3.7s" begin="2.4s" repeatCount="indefinite"
:path="'M250,250 C300,' + (250 + getRightIconY(3)) / 2 + ' 350,' + (250 + getRightIconY(3)) / 2 + ' 420,' + getRightIconY(3)"
rotate="auto" />
</line>
</g>
</g>
<g transform="translate(250, 250)">
<rect x="-18" y="-18" width="36" height="36" rx="6" fill="#f97316" />
<path d="M-9,-5 A9,3 0 0,1 9,-5 M-9,0 A9,3 0 0,1 9,0 M-9,5 A9,3 0 0,1 9,5 M-9,-5 L-9,5 M9,-5 L9,5"
stroke="white" stroke-width="1.5" fill="none" />
</g>
<g :transform="'translate(80, ' + getLeftIconY(0) + ')'">
<circle cx="0" cy="0" r="18" fill="white" stroke="#e2e8f0" stroke-width="1" />
<svg x="-12" y="-12" width="24" height="24" viewBox="0 0 24 24">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4" />
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853" />
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05" />
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335" />
</svg>
</g>
<g :transform="'translate(80, ' + getLeftIconY(1) + ')'">
<circle cx="0" cy="0" r="18" fill="white" stroke="#e2e8f0" stroke-width="1" />
<svg x="-12" y="-12" width="24" height="24" viewBox="0 0 24 24">
<path
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
fill="#000" />
</svg>
</g>
<g :transform="'translate(420, ' + getRightIconY(0) + ')'">
<circle cx="0" cy="0" r="18" fill="white" stroke="#e2e8f0" stroke-width="1" />
<svg x="-12" y="-12" width="24" height="24" viewBox="0 0 24 24">
<path
d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.166 6.839 9.489.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.603-3.369-1.342-3.369-1.342-.454-1.155-1.11-1.462-1.11-1.462-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0 1 12 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.161 22 16.416 22 12c0-5.523-4.477-10-10-10z"
fill="#000" />
</svg>
</g>
<g :transform="'translate(420, ' + getRightIconY(1) + ')'">
<circle cx="0" cy="0" r="18" fill="white" stroke="#e2e8f0" stroke-width="1" />
<svg x="-12" y="-12" width="24" height="24" viewBox="0 0 24 24">
<path
d="M12 2c2.717 0 3.056.01 4.122.06 1.065.05 1.79.217 2.428.465.66.254 1.216.598 1.772 1.153.509.5.902 1.105 1.153 1.772.247.637.415 1.363.465 2.428.047 1.066.06 1.405.06 4.122 0 2.717-.01 3.056-.06 4.122-.05 1.065-.218 1.79-.465 2.428a4.883 4.883 0 0 1-1.153 1.772c-.5.508-1.105.902-1.772 1.153-.637.247-1.363.415-2.428.465-1.066.047-1.405.06-4.122.06-2.717 0-3.056-.01-4.122-.06-1.065-.05-1.79-.218-2.428-.465a4.89 4.89 0 0 1-1.772-1.153 4.904 4.904 0 0 1-1.153-1.772c-.247-.637-.415-1.363-.465-2.428C2.013 15.056 2 14.717 2 12c0-2.717.01-3.056.06-4.122.05-1.066.217-1.79.465-2.428a4.88 4.88 0 0 1 1.153-1.772A4.897 4.897 0 0 1 5.45 2.525c.638-.248 1.362-.415 2.428-.465C8.944 2.013 9.283 2 12 2zm0 1.802c-2.67 0-2.987.01-4.04.059-.976.045-1.505.207-1.858.344-.466.182-.8.398-1.15.748-.35.35-.566.683-.748 1.15-.137.353-.3.882-.344 1.857-.048 1.053-.059 1.37-.059 4.04 0 2.67.01 2.987.059 4.04.045.975.207 1.504.344 1.857.182.466.399.8.748 1.15.35.35.684.566 1.15.748.353.137.882.3 1.857.344 1.054.048 1.37.059 4.04.059 2.67 0 2.987-.01 4.04-.059.975-.045 1.504-.207 1.857-.344.466-.182.8-.398 1.15-.748.35-.35.566-.683.748-1.15.137-.353.3-.882.344-1.857.048-1.054.059-1.37.059-4.04 0-2.67-.01-2.987-.059-4.04-.045-.976-.207-1.505-.344-1.858a3.097 3.097 0 0 0-.748-1.15 3.098 3.098 0 0 0-1.15-.748c-.353-.137-.882-.3-1.857-.344-1.054-.048-1.37-.059-4.04-.059zm0 3.063a5.135 5.135 0 1 1 0 10.27 5.135 5.135 0 0 1 0-10.27zm0 8.468a3.333 3.333 0 1 0 0-6.666 3.333 3.333 0 0 0 0 6.666zm6.538-8.469a1.2 1.2 0 1 1-2.4 0 1.2 1.2 0 0 1 2.4 0z"
fill="#E1306C" />
</svg>
</g>
<g :transform="'translate(420, ' + getRightIconY(2) + ')'">
<circle cx="0" cy="0" r="18" fill="white" stroke="#e2e8f0" stroke-width="1" />
<svg x="-12" y="-12" width="24" height="24" viewBox="0 0 24 24">
<path
d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.96 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"
fill="#0088cc" />
</svg>
</g>
<g :transform="'translate(420, ' + getRightIconY(3) + ')'">
<circle cx="0" cy="0" r="18" fill="white" stroke="#e2e8f0" stroke-width="1" />
<svg x="-12" y="-12" width="24" height="24" viewBox="0 0 24 24">
<path
d="M10 15l5.19-3L10 9v6m11.56-7.83c.13.47.22 1.1.28 1.9.07.8.1 1.49.1 2.09L22 12c0 2.19-.16 3.8-.44 4.83-.25.9-.83 1.48-1.73 1.73-.47.13-1.33.22-2.65.28-1.3.07-2.49.1-3.59.1L12 19c-4.19 0-6.8-.16-7.83-.44-.9-.25-1.48-.83-1.73-1.73-.13-.47-.22-1.1-.28-1.9-.07-.8-.1-1.49-.1-2.09L2 12c0-2.19.16-3.8.44-4.83.25-.9.83-1.48 1.73-1.73.47-.13 1.33-.22 2.65-.28 1.3-.07 2.49-.1 3.59-.1L12 5c4.19 0 6.8.16 7.83.44.9.25 1.48.83 1.73 1.73z"
fill="#FF0000" />
</svg>
</g>
</svg>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
const containerHeight = 500;
const centerOffsetY = containerHeight / 2;
const iconRadius = 18;
const spacing = 70; // Adjust as needed
const leftIconsCount = 2;
const rightIconsCount = 4;
const getLeftIconY = (index) => {
if (leftIconsCount % 2 === 1) {
const middleIndex = Math.floor(leftIconsCount / 2);
if (index === middleIndex) {
return centerOffsetY;
} else {
const offset = Math.abs(index - middleIndex) * spacing;
return index < middleIndex ? centerOffsetY - offset : centerOffsetY + offset;
}
} else {
const halfCount = leftIconsCount / 2;
const offsetBase = centerOffsetY - (spacing / 2) - ((halfCount - 1) * spacing);
return offsetBase + index * spacing;
}
};
const getRightIconY = (index) => {
if (rightIconsCount % 2 === 1) {
const middleIndex = Math.floor(rightIconsCount / 2);
if (index === middleIndex) {
return centerOffsetY;
} else {
const offset = Math.abs(index - middleIndex) * spacing;
return index < middleIndex ? centerOffsetY - offset : centerOffsetY + offset;
}
} else {
const halfCount = rightIconsCount / 2;
const offsetBase = centerOffsetY - (spacing / 2) - ((halfCount - 1) * spacing);
return offsetBase + index * spacing;
}
};
</script>
<style>
/* Animation styles */
.flow-segment line {
filter: drop-shadow(0 0 3px currentColor);
}
.left-flow line {
stroke: #6ca0f3;
/* Blue color for left to center flow */
}
.right-flow line {
stroke: #f09859;
/* Orange color for center to right flow */
}
</style>

View File

@@ -0,0 +1,119 @@
<template>
<div class="flex flex-col items-center space-y-4 p-6 bg-base-100 rounded-xl shadow-lg mt-2 max-w-sm mx-auto backdrop-blur-xl glass">
<div class="p-3 bg-white rounded-lg shadow-inner cursor-pointer" @click="toggleQRCode">
<qrcode-vue :value="currentValue" :size="size" level="H" />
</div>
<div class="relative w-full p-4 ">
<p
class="text-sm break-all whitespace-pre-wrap m-1 p-1 pr-5 text-base-content border border-dashed border-base-content rounded-lg">
{{ currentValue }}
</p>
<button @click="copyValue" class="absolute top-2 right-2 btn btn-xs btn-ghost bg-gray-100">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2M16 8h2a2 2 0 012 2v8a2 2 0 01-2-2h-8a2 2 0 01-2-2v-2" />
</svg>
</button>
<div v-if="showCopied"
class="absolute -top-5 -right-2 bg-neutral text-neutral-content px-2 py-1 rounded text-xs opacity-100 transition-opacity duration-300">
Copied
</div>
</div>
<div class="grid grid-cols-2 gap-2 w-full">
<button v-for="app in applist" :key="app.name"
class="btn btn-sm btn-outline btn-ghost flex items-center justify-center"
@click="applyPrefix(app.name)">
<img :src="app.url" alt="" class="w-5 h-5 mr-1">
<span>{{ app.name }}</span>
</button>
</div>
<div class="w-full">
<button class="btn btn-outline btn-sm w-full" @click="resetValue">
Reset
</button>
</div>
</div>
</template>
<script setup>
import { ref, reactive, watch } from 'vue';
import QrcodeVue from 'qrcode.vue';
// 定义组件接收的 props
const props = defineProps({
value: { // 二维码的原始值
type: String,
required: true
},
size: { // 二维码的尺寸 (像素)
type: Number,
default: 160 // 默认大小
}
});
// 使用 ref 创建一个响应式变量,用于存储当前显示的二维码值
// 初始值是来自 props 的 value
const currentValue = ref(props.value);
// 监听 props.value 的变化,如果外部传入的 value 改变,更新 currentValue
watch(() => props.value, (newValue) => {
currentValue.value = newValue;
});
const showCopied = ref(false);
const copyValue = async () => {
try {
await navigator.clipboard.writeText(currentValue.value);
showCopied.value = true;
setTimeout(() => {
showCopied.value = false;
}, 1500); // 1.5 秒后恢复
} catch (err) {
console.error('Failed to copy: ', err);
// 可以在这里添加错误提示
}
};
const applist = reactive([
// { name: 'openteam', url: '/assets/logo.svg' },
{ name: 'botgem', url: 'https://botgem.com/favicon.ico' },
{ name: 'opencat', url: 'https://opencat.app/favicon.ico' },
])
const applyPrefix = (name) => {
let origin = window.location.origin;
switch (name) {
case 'botgem':
currentValue.value = `ama://set-api-key?server=${origin}&key=${props.value}`;
break;
case 'opencat':
currentValue.value = `opencat://team/join?domain=${origin}&token=${props.value}`;
break;
default:
currentValue.value = name + props.value;
break;
}
};
const toggleQRCode = () => {
if (currentValue.value.startsWith('sk-')) {
return
} else {
window.open(currentValue.value, '_blank')
}
}
// 清除前缀,恢复到原始值
const resetValue = () => {
currentValue.value = props.value;
};
</script>

View File

@@ -1,8 +1,8 @@
<template>
<aside class="w-60 bg-base-100 border-r border-base-200">
<div class="flex items-center p-4 outline-none select-none">
<aside class="w-60 bg-base-100 border-r border-base-200 glass">
<div class="flex items-center p-4 outline-none select-none backdrop-blur-lg">
<div class="w-12 h-12 rounded-full">
<img src='@/assets/logo.svg' class="">
<img src='@/assets/logo.svg' class="" @click="$router.push('/')" >
</div>
<div class="p-0 text-2xl font-bold text-center">OpenTeam</div>
</div>

View File

@@ -11,7 +11,7 @@ if (import.meta.env.DEV) { // Vite 的方式判断开发环境
const service = axios.create({
baseURL: baseURL,
timeout: 5000,
timeout: 6000,
headers: {
'Content-Type': 'application/json',
},

View File

@@ -73,7 +73,7 @@
</header>
<!-- Main Content Area -->
<main class="flex-1 overflow-y-auto p-6 bg-base-200 mx-0 px-0">
<main class="flex-1 overflow-y-auto p-6 bg-base-200 px-1 sm:px-6">
<router-view></router-view>
</main>
</div>

View File

@@ -115,7 +115,7 @@ const copyUrl = async () => {
const star = ref(0);
const getGithubStars = async () => {
const res = await fetch('https://ungh.cc/repos/mirrors2/opencatd-open', { next: { revalidate: 3600 } });
const res = await fetch('https://ungh.cc/repos/mirrors2/openteam', { next: { revalidate: 3600 } });
const data = await res.json();
return data.repo.stars;
};

View File

@@ -12,17 +12,6 @@
</p>
</div>
<div v-if="error" role="alert" class="alert shadow-lg bg-rose-100">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<span>Error! {{ error }}</span>
</div>
<button class="btn btn-sm" @click="error = null">X</button>
</div>
<div class="card border border-base-300/40 shadow-sm">
<form @submit.prevent="createApiKey" class="card-body space-y-5 p-3 sm:p-8">
<div class="space-y-4">
@@ -53,23 +42,14 @@
<option value="openai">OpenAI</option>
<option value="claude">Claude</option>
<option value="gemini">Gemini</option>
<option value="azure">Azure</option>
<option value="github">Github</option>
<option value="openai-compatible">OpenAI Compatible</option>
</select>
<button type="button" @click="togglePasswordVisibility" tabindex="-1"
class="absolute inset-y-0 left-0 px-3 flex items-center text-base-content/60 hover:text-base-content/80 focus:outline-none focus:ring-0 rounded-r-md"
id="password-visibility-toggle">
<template v-if="newApiKey.type === 'openai'">
<img src="../../assets/openai.svg" class="w-5 h-5" alt="">
</template>
<template v-else-if="newApiKey.type === 'claude'">
<img src="../../assets/claude.svg" class="w-5 h-5" alt="">
</template>
<template v-else-if="newApiKey.type === 'gemini'">
<img src="../../assets/gemini.svg" class="w-5 h-5" alt="">
</template>
<template v-else="newApiKey.type">
<img src="../../assets/logo.svg" class="w-5 h-5" alt="">
</template>
<img :src="apiKeyImageUrl(newApiKey.type)" class="w-5 h-5" alt="">
</button>
</div>
</div>
@@ -109,13 +89,13 @@
class="input input-sm input-bordered w-full" />
</div>
<div class="form-control">
<!-- <div class="form-control">
<label for="deployment_name" class="label">
<span class="label-text">Deployment Name</span>
</label>
<input id="deployment_name" type="text" v-model="newApiKey.deployment_name"
placeholder="Deployment Name" class="input input-sm input-bordered w-full" />
</div>
</div> -->
<div class="form-control">
<label for="api_secret" class="label">
@@ -156,7 +136,7 @@
<!-- <textarea id="support_models" v-model="newApiKey.support_models_text"
placeholder='["model1", "model2"]' class="textarea textarea-sm textarea-bordered w-full"></textarea> -->
<el-input-tag v-model="newApiKey.support_models_array" :trigger="'Enter'" clearable
placeholder="Please input" @change="onchange_supportmodel"/>
placeholder="Please input" @change="onchange_supportmodel" />
</div>
<div class="form-control">
@@ -174,10 +154,23 @@
</div>
</div>
</div>
<div v-if="error" role="alert" class="alert shadow-lg bg-rose-100">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<span>{{ error }}</span>
</div>
<button class="btn btn-sm" @click="error = null">X</button>
</div>
<div class="flex justify-end pt-4 items-center gap-2">
<button @click="cancel" class="btn btn-sm btn-outline">Cancel</button>
<button type="submit" class="btn btn-outline btn-sm px-4 text-sm font-medium btn-success" :disabled="!isFormValid">
<button type="submit" class="btn btn-outline btn-sm px-4 text-sm font-medium btn-success"
:disabled="!isFormValid">
Create
</button>
</div>
@@ -209,12 +202,12 @@ const newApiKey = ref({
active: true,
endpoint: '',
resource_name: '',
deployment_name: '',
// deployment_name: '',
api_secret: '',
model_prefix: '',
model_alias: '',
parameters: '{}',
support_models: '',
support_models: '[]',
support_models_array: [],
})
@@ -226,12 +219,12 @@ const resetNewApiKey = () => {
active: true,
endpoint: '',
resource_name: '',
deployment_name: '',
// deployment_name: '',
api_secret: '',
model_prefix: '',
model_alias: '',
parameters: '{}',
support_models: '',
support_models: '[]',
support_models_array: [],
}
}
@@ -252,12 +245,25 @@ const cancel = () => {
emit('closeModal', true)
}
const apiKeyImageMap = {
'openai': '/assets/openai.svg',
'claude': '/assets/claude.svg',
'gemini': '/assets/gemini.svg',
'azure': '/assets/azure.svg',
'github': '/assets/github.svg'
};
const apiKeyImageUrl = (keytype) => {
return apiKeyImageMap[keytype] || '/assets/logo.svg';
};
const createApiKey = async () => {
if (!isFormValid.value) {
setToast('Please fill in all required fields (Name, Type, API Key).', 'error')
return
}
try {
try {
if (!Array.isArray(newApiKey.value.support_models_array)) {

View File

@@ -33,22 +33,13 @@
<option value="openai">OpenAI</option>
<option value="claude">Claude</option>
<option value="gemini">Gemini</option>
<option value="azure">Azure</option>
<option value="github">Github</option>
<option value="openai-compatible">OpenAI Compatible</option>
</select>
<button type="button" class="absolute inset-y-0 left-0 px-3 flex items-center text-base-content/60 hover:text-base-content/80 focus:outline-none focus:ring-0 rounded-r-md"
id="password-visibility-toggle">
<template v-if="key.type === 'openai'">
<img src="../../assets/openai.svg" class="w-5 h-5" alt="">
</template>
<template v-else-if="key.type === 'claude'">
<img src="../../assets/claude.svg" class="w-5 h-5" alt="">
</template>
<template v-else-if="key.type === 'gemini'">
<img src="../../assets/gemini.svg" class="w-5 h-5" alt="">
</template>
<template v-else="key.type">
<img src="../../assets/logo.svg" class="w-5 h-5" alt="">
</template>
<img :src="apiKeyImageUrl(key.type)" class="w-5 h-5" alt="">
</button>
</div>
</div>
@@ -88,13 +79,13 @@
class="input input-sm input-bordered w-full" />
</div>
<div class="form-control">
<!-- <div class="form-control">
<label for="deployment_name" class="label">
<span class="label-text">Deployment Name</span>
</label>
<input id="deployment_name" type="text" v-model="key.deployment_name"
placeholder="Deployment Name" class="input input-sm input-bordered w-full" />
</div>
</div> -->
<div class="form-control">
<label for="api_secret" class="label">
@@ -167,7 +158,7 @@
</template>
<script setup>
import { ref, computed, onMounted, inject } from 'vue';
import { ref, computed, onMounted, inject, reactive } from 'vue';
import { useRoute,useRouter } from 'vue-router';
import { Eye, EyeOff, BadgeCheck, Send, CircleX, CircleCheckBig, TrashIcon, Infinity } from 'lucide-vue-next';
import { useKeyStore } from '../../stores/key';
@@ -194,6 +185,27 @@ onMounted(async () => {
}
});
const keyOption = reactive([
{name: 'openai', label: 'OpenAI'},
{name: 'claude', label: 'Claude'},
{name: 'gemini', label: 'Gemini'},
{name: 'azure', label: 'Azure'},
{name: 'github', label: 'Github'},
{name: 'openai-compatible', label: 'OpenAI Compatible'}
])
const apiKeyImageMap = {
'openai': '/assets/openai.svg',
'claude': '/assets/claude.svg',
'gemini': '/assets/gemini.svg',
'azure': '/assets/azure.svg',
'github': '/assets/github.svg'
};
const apiKeyImageUrl = (keytype) => {
return apiKeyImageMap[keytype] || '/assets/logo.svg';
};
const onchange_supportmodel = () => {
key.value.support_models = JSON.stringify(key.value.support_models_array)
}

View File

@@ -2,12 +2,12 @@
<div class="min-h-screen bg-base-100 p-4">
<!-- Breadcrumb and Title -->
<BreadcrumbHeader />
<!-- <div v-if="keyStore.loading" class="loading loading-spinner loading-lg"></div> -->
<div class="flex flex-wrap gap-2 mb-4" v-if="keys">
<div class="flex flex-1 items-center space-x-2">
<input
class="flex rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 h-8 w-[150px] lg:w-[250px]"
class="flex rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 h-8 w-[120px] lg:w-[250px]"
placeholder="Filter" value="">
<div class="dropdown">
@@ -33,7 +33,8 @@
</div>
<button class="btn btn-outline btn-success btn-sm gap-1" onclick="myModal.showModal()">
<div class="flex flex-1 items-center justify-end space-x-2">
<button class="btn btn-outline btn-success btn-sm gap-1" onclick="myModal.showModal()">
<PlusIcon class="w-3.5 h-3.5" />New
</button>
<dialog id="myModal" class="modal" ref="modalRef">
@@ -48,6 +49,7 @@
<button>关闭</button>
</form>
</dialog>
</div>
<div class="dropdown dropdown-end dropdown-hover">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm p-1 h-8 w-8">
@@ -85,13 +87,15 @@
<thead>
<tr>
<th>
<input type="checkbox" class="checkbox checkbox-xs" v-model="selectAll" @change="toggleSelectAll" />
<div class="flex gap-1 items-center">
<input type="checkbox" class="checkbox checkbox-xs" v-model="selectAll" @change="toggleSelectAll" />
</div>
</th>
<th>Type</th>
<th>Name</th>
<th>Active</th>
<th>Key</th>
<th>Endpoint</th>
<!-- <th>Key</th> -->
<!-- <th>Endpoint</th> -->
<th></th>
</tr>
</thead>
@@ -100,27 +104,38 @@
<tr v-for="key in keys" :key="key.id"
class="hover:bg-gray-500/50 dark:hover:bg-neutral-600 transition-colors">
<td>
<input type="checkbox" class="checkbox checkbox-xs" v-model="key.selected"
@change="toggleUserSelection(key)" />
<div class="flex gap-1 items-center">
<input type="checkbox" class="checkbox checkbox-xs" v-model="key.selected"
@change="toggleUserSelection(key)" />
</div>
</td>
<td class="text-xs dark:text-white">
<div class="flex gap-1 items-center">
<span class="backdrop-blur-lg glass rounded-full border-none"> <img :src="displayIcon(key.type)"
class="w-5 h-5" alt=""></span>
{{ key.type }}
</div>
</td>
<td class="text-xs dark:text-white">{{ key.type }}</td>
<td class="text-xs dark:text-white">{{ key.name }}</td>
<td>
<input type="checkbox" class="toggle toggle-xs" :class="key.active ? 'toggle-success' : 'toggle-error'"
v-model="key.active" @change="updateStatus(key)" />
</td>
<td class="text-xs dark:text-white">{{ key.apikey }}</td>
<td class="text-xs dark:text-white">{{ key.endpoint }}</td>
<td>
<div class="flex gap-1">
<input type="checkbox" class="toggle toggle-xs" :class="key.active ? 'toggle-success' : 'toggle-error'"
v-model="key.active" @change="updateStatus(key)" />
</div>
</td>
<!-- <td class="text-xs dark:text-white">{{ key.apikey }}</td> -->
<!-- <td class="text-xs dark:text-white">{{ key.endpoint }}</td> -->
<td>
<div class="flex gap-2">
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open" data-tip="预览">
<button class="btn btn-ghost btn-xs btn-square " @click="viewKey(key)">
<EyeIcon class="w-3.5 h-3.5 dark:text-white" />
<EyeIcon class="w-4 h-4 dark:text-white" />
</button>
</div>
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open" data-tip="删除">
<button class="btn btn-ghost btn-xs btn-square text-error hover:bg-error/30" @click="confirmDeleteKey(key)">
<TrashIcon class="w-3.5 h-3.5 dark:text-white" />
<button class="btn btn-ghost btn-xs btn-square text-error hover:bg-error/30"
@click="confirmDeleteKey(key)">
<TrashIcon class="w-4 h-4 dark:text-white" />
</button>
</div>
</div>
@@ -133,7 +148,7 @@
<!-- Pagination -->
<Pagination :currentPage="currentPage" :totalItems="totalItems" :pageSize="pageSize"
:pageSizeOptions="[ 10, 20, 50, 100]" @changePage="changePage" @changePageSize="changePageSize" />
:pageSizeOptions="[10, 20, 50, 100]" @changePage="changePage" @changePageSize="changePageSize" />
</div>
</template>
@@ -305,6 +320,24 @@ const deleteKey = async (key) => {
}
};
const displayIcon = (apitype) => {
switch (apitype) {
case 'openai':
return '/assets/openai.svg';
case 'claude':
return '/assets/claude.svg';
case 'gemini':
return '/assets/gemini.svg'
case 'azure':
return '/assets/azure.svg';
case 'github':
return '/assets/github.svg';
default:
return '/assets/logo.svg';
}
}
// 关闭模态框
const modalRef = ref(null);
const closeModal = async () => {

View File

@@ -65,7 +65,7 @@
class="absolute inset-y-0 right-0 px-3 flex items-center text-base-content/60 hover:text-base-content/80 focus:outline-none focus:ring-0 rounded-r-md"
id="token-visibility-toggle">
<template v-if="!isPasswordVisible">
<template v-if="!isTokenVisible">
<EyeOff class="w-5 h-5" />
</template>
<template v-else>
@@ -93,7 +93,7 @@
</label>
<div class="flex items-center space-x-3">
<input id="quota" type="number" v-model="newToken.quota" placeholder="Enter quota amount"
class="input input-sm input-bordered flex-grow" :disabled="newToken.unlimited_quota" />
class="input input-sm input-bordered w-1/2 flex-grow" :disabled="newToken.unlimited_quota" />
<label class="flex items-center space-x-2 cursor-pointer whitespace-nowrap">
<input type="checkbox" v-model="newToken.unlimited_quota" class="checkbox checkbox-sm" />
<span class="text-sm text-base-content/90">Unlimited</span>

View File

@@ -24,69 +24,90 @@
</div>
<!-- Table -->
<div class="card card-bordered bg-base-100 shadow-sm mt-6" v-if="user">
<div class="card card-bordered bg-base-100 shadow-sm mt-6">
<div class="card-body">
<h3 class="card-title text-lg">Tokens</h3>
<div v-if="user.tokens && user.tokens.length" class="overflow-x-auto -mx-6">
<table class="table table-sm w-full">
<thead>
<tr class="text-xs text-base-content/70 uppercase bg-base-200">
<th class="px-2 py-3">Token Name</th>
<th class="px-2 py-3">Status</th>
<th class="px-2 py-3">Key</th>
<th class="px-2 py-3">Expired At</th>
<th class="px-2 py-3">Quota</th>
<th class="px-2 py-3">Used Quota</th>
<th class="text-right px-2 py-3"></th>
</tr>
</thead>
<tbody>
<tr v-for="token in user.tokens" :key="token.id" class="hover">
<td class="font-mono text-xs px-2 py-3">{{ token.name }}</td>
<td>
<input type="checkbox" class="toggle toggle-xs"
:class="token.active ? 'toggle-success' : 'toggle-error'" v-model="token.active"
@change="updateStatus(token)" />
</td>
<td class="font-mono text-xs px-2 py-3">{{ token.key }}</td>
<td class="px-2 py-3">{{ token.expired_at == 0 ? 'Never' : unixToDate(token.expired_at) }}</td>
<td class="px-2 py-3">
<template v-if="token.unlimited_quota">
<Infinity />
</template>
<template v-else>{{ token.quota }}</template>
</td>
<td class="px-2 py-3">{{ token.used_quota }}</td>
<td class="text-right px-2 py-3 flex justify-between items-center gap-1">
<div class="md:tooltip" data-tip="clean usedquota">
<button class="btn btn-ghost btn-xs btn-square text-sky-300" @click="cleanUsedToken(token)"
aria-label="Revoke token">
<Eraser class="w-4 h-4" />
<div class="card bg-base-100 shadow-xs overflow-x-auto dark:bg-base-200" v-if="user">
<table class="table table-sm w-full">
<thead>
<tr class="text-xs text-base-content/70 uppercase bg-base-200">
<th class="px-2 py-3">Token</th>
<th class="px-2 py-3">Status</th>
<!-- <th class="px-2 py-3">Key</th> -->
<th class="px-2 py-3">Expired</th>
<th class="px-2 py-3">Quota</th>
<th class="px-2 py-3">Used</th>
<th class="text-right px-2 py-3"></th>
</tr>
</thead>
<tbody>
<tr v-for="token in user.tokens" :key="token.id" class="hover">
<td class="font-mono text-xs px-2 py-3">{{ token.name }}</td>
<td>
<input type="checkbox" class="toggle toggle-xs"
:class="token.active ? 'toggle-success' : 'toggle-error'" v-model="token.active"
@change="updateStatus(token)" />
</td>
<!-- <td class="font-mono text-xs px-2 py-3">{{ token.key }}</td> -->
<td class="px-2 py-3">{{ token.expired_at == 0 ? 'Never' : unixToDate(token.expired_at) }}</td>
<td class="px-2 py-3">
<template v-if="token.unlimited_quota">
<Infinity />
</template>
<template v-else>{{ token.quota }}</template>
</td>
<td class="px-2 py-3">{{ token.used_quota }}</td>
<td class="text-right px-1 py-3">
<div class="flex items-center gap-2.5">
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open pt-1" data-tip="预览">
<button class="btn btn-ghost btn-xs btn-square" onclick="" @click="viewToken(token)">
<EyeIcon class="w-5 h-5 dark:text-white" />
</button>
</div>
<button v-if="token.name !== 'default'" class="btn btn-ghost btn-xs btn-square text-error"
@click="confirmRevokeToken(token)" aria-label="Revoke token">
<TrashIcon class="w-4 h-4" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="md:tooltip" data-tip="clean used">
<button class="btn btn-ghost btn-xs btn-square text-sky-300 mt-1" @click="cleanUsedToken(token)"
aria-label="Revoke token">
<Eraser class="w-5 h-5" />
</button>
</div>
<p v-else class="text-center text-base-content/70 py-4">No tokens found</p>
<button v-if="token.name !== 'default'"
class="btn btn-ghost btn-xs btn-square text-error items-center" @click="confirmRevokeToken(token)"
aria-label="Revoke token">
<TrashIcon class="w-5 h-5" />
</button>
</div>
</td>
</tr>
</tbody>
</table>
<dialog id="myToken" class="modal" ref="tokenRef">
<div class="modal-box px-0 sm:px-8">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<QRCodeCard :value="qrCodeValue" :size="120" />
</div>
<form method="dialog" class="modal-backdrop">
<button>关闭</button>
</form>
</dialog>
</div>
<p v-else class="text-center text-base-content/70 py-4">No tokens found</p>
</div>
<!-- </div> -->
</div>
</template>
<script setup>
import { ref, onMounted, inject, computed,watch } from 'vue';
import { ref, onMounted, inject, computed, watch } from 'vue';
import { useRouter } from 'vue-router';
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import QRCodeCard from '@/components/QRCodeCard.vue';
import TokenNew from '@/views/dashboard/TokenNew.vue';
import { useAuthStore } from '@/stores/auth';
import {
@@ -104,7 +125,7 @@ onMounted(async () => {
})
watch(() => authStore.user, async (newUser) => {
if (newUser.expired_at>0) {
if (newUser.expired_at > 0) {
newUser.format_expired_at = unixToDate(newUser.expired_at);
}
})
@@ -160,6 +181,24 @@ const cleanUsedToken = async (token) => {
}
}
const showTokenModel = ref(false);
const tokenRef = ref(null);
const viewToken = (token) => {
const dialog = tokenRef.value;
if (dialog) {
if (!dialog.hasAttribute('open')) {
qrCodeValue.value = token.key;
dialog.showModal();
} else {
if (dialog.hasAttribute('open')) {
dialog.close();
}
}
}
showTokenModel.value = !showTokenModel.value
}
const qrCodeValue = ref('');
// 关闭模态框

View File

@@ -8,7 +8,7 @@
<div class="flex flex-wrap gap-2 mb-4">
<div class="flex flex-1 items-center space-x-2">
<input
class="flex rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 h-8 w-[150px] lg:w-[250px]"
class="flex rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 h-8 w-[120px] lg:w-[250px]"
placeholder="Filter" value="">
<div class="dropdown">
@@ -34,21 +34,23 @@
</div>
<button class="btn btn-outline btn-success btn-sm gap-1" onclick="myModal.showModal()">
<PlusIcon class="w-3.5 h-3.5" />New
</button>
<dialog id="myModal" class="modal" ref="modalRef">
<div class="modal-box w-11/12 max-w-5xl h-screen px-0 sm:px-8">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<UserNew @closeModal="closeModal" />
<div class="flex flex-1 items-center justify-end space-x-1">
<button class="btn btn-outline btn-success btn-sm gap-1" onclick="myModal.showModal()">
<PlusIcon class="w-3.5 h-3.5" />New
</button>
<dialog id="myModal" class="modal" ref="modalRef">
<div class="modal-box w-11/12 max-w-5xl h-screen px-0 sm:px-8">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<UserNew @closeModal="closeModal" />
</div>
<form method="dialog" class="modal-backdrop">
<button>关闭</button>
</form>
</dialog>
</div>
<form method="dialog" class="modal-backdrop">
<button>关闭</button>
</form>
</dialog>
</div>
<div class="dropdown dropdown-end dropdown-hover">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm p-1 h-8 w-8">
@@ -92,7 +94,7 @@
<th>Name</th>
<th>Active</th>
<th>Quota</th>
<th>UsedQuota</th>
<th>Used</th>
<th></th>
</tr>
</thead>
@@ -107,8 +109,10 @@
<td class="text-xs dark:text-white">{{ user.id }}</td>
<td class="text-xs dark:text-white">{{ user.username }}</td>
<td>
<input type="checkbox" class="toggle toggle-xs" :class="user.active ? 'toggle-success' : 'toggle-error'"
v-model="user.active" @change="updateStatus(user)" />
<div class="flex items-center gap-1">
<input type="checkbox" class="toggle toggle-xs" :class="user.active ? 'toggle-success' : 'toggle-error'"
v-model="user.active" @change="updateStatus(user)" />
</div>
</td>
<td class="text-xs dark:text-white">
<template v-if="user.unlimited_quota">
@@ -121,13 +125,13 @@
<div class="flex gap-1">
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open" data-tip="预览">
<button class="btn btn-ghost btn-xs btn-square " @click="viewUser(user)">
<EyeIcon class="w-3.5 h-3.5 dark:text-white" />
<EyeIcon class="w-4 h-4 dark:text-white" />
</button>
</div>
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open" data-tip="删除" v-if="user.role<20">
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open" data-tip="删除" v-if="user.role < 20">
<button class="btn btn-ghost btn-xs btn-square text-error hover:bg-error/30"
@click="confirmDeleteUser(user)">
<TrashIcon class="w-3.5 h-3.5 dark:text-white" />
<TrashIcon class="w-4 h-4 dark:text-white" />
</button>
</div>
</div>
@@ -168,7 +172,7 @@ const pageSize = ref(10);
const totalItems = computed(() => userStore.totalUsers);
// 封装公共的用户列表获取方法
const listUsers = async (size = pageSize.value, page = currentPage.value, active=selectedStatuses.map(status => status.value)) => {
const listUsers = async (size = pageSize.value, page = currentPage.value, active = selectedStatuses.map(status => status.value)) => {
currentPage.value = page || currentPage.value;
// console.log('pagesize', pageSize.value, 'page', currentPage.value, 'active', selectedStatuses.map(status => status.value));
await userStore.listUser(size, page, active);
@@ -231,7 +235,7 @@ const toggleStatusFilter = async (status) => {
selectedStatuses.push({ status, value: statusValue });
}
await listUsers(undefined,1,undefined);
await listUsers(undefined, 1, undefined);
};
// 处理批量操作

View File

@@ -180,7 +180,7 @@
<th class="px-2 py-3">Key</th>
<th class="px-2 py-3">Expired At</th>
<th class="px-2 py-3">Quota</th>
<th class="px-2 py-3">Used Quota</th>
<th class="px-2 py-3">Used</th>
<th class="text-right px-2 py-3"></th>
</tr>
</thead>

3
go.mod
View File

@@ -27,6 +27,7 @@ require (
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pkoukk/tiktoken-go v0.1.7
github.com/sashabaranov/go-openai v1.32.2
github.com/spf13/cobra v1.9.1
github.com/tidwall/gjson v1.18.0
golang.org/x/crypto v0.37.0
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c
@@ -75,6 +76,7 @@ require (
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
github.com/hajimehoshi/go-mp3 v0.3.4 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.5.5 // indirect
@@ -92,6 +94,7 @@ require (
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect

8
go.sum
View File

@@ -35,6 +35,7 @@ github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJ
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/d4l3k/messagediff v1.2.2-0.20190829033028-7e0a312ae40b/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -129,6 +130,8 @@ github.com/hajimehoshi/oto v0.7.1/go.mod h1:wovJ8WWMfFKvP587mhHgot/MBr4DnNy9m6Ee
github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo=
github.com/icza/bitio v1.0.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
@@ -193,8 +196,13 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sashabaranov/go-openai v1.32.2 h1:8z9PfYaLPbRzmJIYpwcWu6z3XU8F+RwVMF1QRSeSF2M=
github.com/sashabaranov/go-openai v1.32.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=

91
internal/cli/cli.go Normal file
View File

@@ -0,0 +1,91 @@
package cli
import (
"encoding/json"
"fmt"
"log"
"opencatd-open/internal/model"
"opencatd-open/pkg/store"
"os"
"strings"
"github.com/duke-git/lancet/v2/fileutil"
"github.com/google/uuid"
"github.com/spf13/cobra"
)
var LoadCmd = &cobra.Command{
Use: "load",
Short: "import user.json -> db",
Long: "\nimport user.json -> db",
Run: func(cmd *cobra.Command, args []string) {
db := store.GetDB()
var cont int64
if err := db.Model(model.User{}).Count(&cont).Error; err != nil {
fmt.Println(err)
return
}
if cont == 0 {
fmt.Println("创建管理员之后再操作")
}
if !fileutil.IsExist("./db/user.json") {
log.Fatalln("404! user.json is not found.")
return
}
file, err := os.Open("./db/user.json")
if err != nil {
fmt.Println("Error opening file:", err)
return
}
defer file.Close()
var usermap []map[string]string
if err := json.NewDecoder(file).Decode(&usermap); err != nil {
fmt.Println("解析文件失败:", err)
return
}
for _, um := range usermap {
var name string
if um["username"] != "" {
name = um["name"]
} else if um["name"] == "" {
name = um["username"]
} else {
fmt.Println("获取不到数据")
continue
}
var user = model.User{
Username: name,
Name: name,
Tokens: []model.Token{
{
Name: "default",
Key: "sk-team-" + strings.ReplaceAll(uuid.New().String(), "-", ""),
},
{
Name: name,
Key: um["token"],
},
},
}
if err := db.Create(&user).Error; err != nil {
fmt.Printf("\nCreate User %s Error:%s", user.Username, err)
}
}
},
}
var SaveCmd = &cobra.Command{
Use: "save",
Short: "backup user info -> user.json",
Run: func(cmd *cobra.Command, args []string) {
},
}
func init() {
// SaveCmd.Flags().StringP("user", "u", "", "Save User")
}

View File

@@ -2,6 +2,20 @@ package consts
import "gorm.io/gorm"
const Logo = `
____ _____
/ __ \ |_ _|
| | | |_ __ ___ _ __ | | ___ __ _ _ __ ___
| | | | '_ \ / _ \ '_ \ | | / _ \/ _' | '_ ' _ \
| |__| | |_) | __/ | | | | || __/ (_| | | | | | |
\____/| .__/ \___|_| |_| \_/ \___|\__,_|_| |_| |_|
| |
|_|
https://github.com/mirrors2/openteam
---------------------------------------------------
`
const SecretKey = "openteam"
const Day = 24 * 60 * 60 // day := 86400

View File

@@ -1,10 +1,13 @@
package controller
import (
"bytes"
"encoding/json"
"net/http"
"opencatd-open/internal/consts"
"opencatd-open/internal/dto"
"opencatd-open/internal/model"
"opencatd-open/internal/utils"
"strconv"
"strings"
@@ -18,13 +21,22 @@ func (a Api) CreateApiKey(c *gin.Context) {
dto.Fail(c, 403, "Permission denied")
return
}
req := new(model.ApiKey)
err := c.ShouldBind(&req)
newkey := new(model.ApiKey)
err := c.ShouldBind(newkey)
if err != nil {
dto.Fail(c, 400, err.Error())
}
if slice.Contain([]string{"openai", "azure", "claude"}, *newkey.ApiType) {
sma, err := utils.FetchKeyModel(a.db, newkey)
if err == nil && len(sma) > 0 {
newkey.SupportModelsArray = sma
var buf = new(bytes.Buffer)
json.NewEncoder(buf).Encode(sma) //nolint:errcheck
newkey.SupportModels = utils.ToPtr(buf.String())
}
}
err = a.keyService.CreateApiKey(c, req)
err = a.keyService.CreateApiKey(c, newkey)
if err != nil {
dto.Fail(c, 400, err.Error())
} else {
@@ -71,6 +83,19 @@ func (a Api) ListApiKey(c *gin.Context) {
if err != nil {
dto.Fail(c, 500, err.Error())
} else {
for _, key := range keys {
str := *key.ApiKey
slen := len(str)
if slen > 20 {
slen = 20
}
str = str[:slen]
key.ApiKey = &str
var sma []string
json.NewDecoder(strings.NewReader(*key.SupportModels)).Decode(&sma) //nolint:errcheck
key.SupportModelsArray = sma
}
dto.Success(c, gin.H{
"total": total,
"keys": keys,

View File

@@ -4,15 +4,23 @@ import (
"fmt"
"net/http"
"opencatd-open/internal/dto"
"opencatd-open/internal/model"
"opencatd-open/llm"
"opencatd-open/llm/claude/v2"
"opencatd-open/llm/google/v2"
"opencatd-open/llm/openai_compatible"
"opencatd-open/pkg/tokenizer"
"github.com/gin-gonic/gin"
)
func (h *Proxy) ChatHandler(c *gin.Context) {
user := c.MustGet("user").(*model.User)
if user == nil {
dto.WrapErrorAsOpenAI(c, 401, "Unauthorized")
return
}
var chatreq llm.ChatRequest
if err := c.ShouldBindJSON(&chatreq); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -35,10 +43,10 @@ func (h *Proxy) ChatHandler(c *gin.Context) {
fallthrough
default:
llm, err = openai_compatible.NewOpenAICompatible(h.apikey)
if err != nil {
dto.WrapErrorAsOpenAI(c, 500, fmt.Errorf("create llm client error: %w", err).Error())
return
}
}
if err != nil {
dto.WrapErrorAsOpenAI(c, 500, fmt.Errorf("create llm client error: %w", err).Error())
return
}
if !chatreq.Stream {
@@ -57,4 +65,13 @@ func (h *Proxy) ChatHandler(c *gin.Context) {
c.SSEvent("", data)
}
}
llmusage := llm.GetTokenUsage()
llmusage.User = user
llmusage.TokenID = c.GetInt64("token_id")
cost := tokenizer.Cost(llmusage.Model, llmusage.PromptTokens+llmusage.ToolsTokens, llmusage.CompletionTokens)
h.SendUsage(llmusage)
defer fmt.Println("cost:", cost, "prompt_tokens:", llmusage.PromptTokens, "completion_tokens:", llmusage.CompletionTokens, "total_tokens:", llmusage.TotalTokens)
}

View File

@@ -0,0 +1,58 @@
package controller
import (
"encoding/json"
"fmt"
"net/http"
"opencatd-open/internal/dto"
"github.com/gin-gonic/gin"
)
func (p *Proxy) HandleModels(c *gin.Context) {
models, err := p.getModelCache()
if err != nil {
dto.Fail(c, http.StatusBadGateway, err.Error())
return
}
type _model struct {
ID string `json:"id"`
}
var ms []_model
for _, model := range models {
ms = append(ms, _model{ID: model})
}
dto.Success(c, ms)
}
func (p *Proxy) setModelCache() error {
apikeys, err := p.apiKeyDao.FindKeys(nil)
models := make(map[string]bool)
if err == nil && len(apikeys) > 0 {
for _, k := range apikeys {
if len(k.SupportModelsArray) > 0 {
for _, sm := range k.SupportModelsArray {
models[sm] = true
}
} else {
var sma []string
json.Unmarshal([]byte(*k.SupportModels), &sma) // nolint:errCheck
for _, sm := range sma {
models[sm] = true
}
}
}
} else {
return fmt.Errorf("empty data")
}
var support_models []string
for m, _ := range models {
support_models = append(support_models, m)
}
return p.cache.Set("models", support_models)
}
func (p *Proxy) getModelCache() ([]string, error) {
models, err := p.cache.Get("models")
return models.([]string), err
}

View File

@@ -12,17 +12,20 @@ import (
"net/url"
"opencatd-open/internal/dao"
"opencatd-open/internal/model"
"opencatd-open/internal/utils"
"opencatd-open/llm"
"opencatd-open/pkg/config"
"opencatd-open/pkg/tokenizer"
"os"
"strings"
"sync"
"time"
"github.com/bluele/gcache"
"github.com/gin-gonic/gin"
"github.com/lib/pq"
"github.com/tidwall/gjson"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Proxy struct {
@@ -30,9 +33,10 @@ type Proxy struct {
cfg *config.Config
db *gorm.DB
wg *sync.WaitGroup
usageChan chan *model.Usage // 用于异步处理的channel
usageChan chan *llm.TokenUsage // 用于异步处理的channel
apikey *model.ApiKey
httpClient *http.Client
cache gcache.Cache
userDAO *dao.UserDAO
apiKeyDao *dao.ApiKeyDAO
@@ -52,13 +56,15 @@ func NewProxy(ctx context.Context, cfg *config.Config, db *gorm.DB, wg *sync.Wai
client.Transport = tr
}
}
np := &Proxy{
ctx: ctx,
cfg: cfg,
db: db,
wg: wg,
httpClient: client,
usageChan: make(chan *model.Usage, cfg.UsageChanSize),
cache: gcache.New(1).Build(),
usageChan: make(chan *llm.TokenUsage, cfg.UsageChanSize),
userDAO: userDAO,
apiKeyDao: apiKeyDAO,
tokenDAO: tokenDAO,
@@ -68,7 +74,7 @@ func NewProxy(ctx context.Context, cfg *config.Config, db *gorm.DB, wg *sync.Wai
go np.ProcessUsage()
go np.ScheduleTask()
np.setModelCache()
return np
}
@@ -77,9 +83,13 @@ func (p *Proxy) HandleProxy(c *gin.Context) {
p.ChatHandler(c)
return
}
if strings.HasPrefix(c.Request.URL.Path, "/v1/messages") {
p.ProxyClaude(c)
return
}
}
func (p *Proxy) SendUsage(usage *model.Usage) {
func (p *Proxy) SendUsage(usage *llm.TokenUsage) {
select {
case p.usageChan <- usage:
default:
@@ -135,46 +145,90 @@ func (p *Proxy) ProcessUsage() {
}
}
func (p *Proxy) Do(usage *model.Usage) error {
func (p *Proxy) Do(llmusage *llm.TokenUsage) error {
err := p.db.Transaction(func(tx *gorm.DB) error {
now := time.Now()
today, _ := time.Parse("2006-01-02", now.Format("2006-01-02"))
cost := tokenizer.Cost(llmusage.Model, llmusage.PromptTokens, llmusage.CompletionTokens)
token, err := p.tokenDAO.GetByID(p.ctx, llmusage.TokenID)
if err != nil {
return err
}
usage := &model.Usage{
UserID: llmusage.User.ID,
TokenID: llmusage.TokenID,
Date: now,
Model: llmusage.Model,
Stream: llmusage.Stream,
PromptTokens: llmusage.PromptTokens,
CompletionTokens: llmusage.CompletionTokens,
TotalTokens: llmusage.TotalTokens,
Cost: fmt.Sprintf("%.8f", cost),
}
// 1. 记录使用记录
if err := tx.WithContext(p.ctx).Create(usage).Error; err != nil {
return fmt.Errorf("create usage error: %w", err)
}
// 2. 更新每日统计upsert 操作)
dailyUsage := model.DailyUsage{
UserID: usage.UserID,
TokenID: usage.TokenID,
Capability: usage.Capability,
Date: time.Date(usage.Date.Year(), usage.Date.Month(), usage.Date.Day(), 0, 0, 0, 0, usage.Date.Location()),
Model: usage.Model,
Stream: usage.Stream,
PromptTokens: usage.PromptTokens,
CompletionTokens: usage.CompletionTokens,
TotalTokens: usage.TotalTokens,
Cost: usage.Cost,
}
// 使用 OnConflict 实现 upsert
if err := tx.WithContext(p.ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "token_id"}, {Name: "capability"}, {Name: "date"}}, // 唯一键
DoUpdates: clause.Assignments(map[string]interface{}{
"prompt_tokens": gorm.Expr("prompt_tokens + ?", usage.PromptTokens),
"completion_tokens": gorm.Expr("completion_tokens + ?", usage.CompletionTokens),
"total_tokens": gorm.Expr("total_tokens + ?", usage.TotalTokens),
"cost": gorm.Expr("cost + ?", usage.Cost),
}),
}).Create(&dailyUsage).Error; err != nil {
return fmt.Errorf("upsert daily usage error: %w", err)
// 2. 更新每日统计
var dailyUsage model.DailyUsage
result := tx.WithContext(p.ctx).Where("user_id = ? and date = ?", llmusage.User.ID, today).First(&dailyUsage)
if result.RowsAffected == 0 {
dailyUsage.UserID = llmusage.User.ID
dailyUsage.TokenID = llmusage.TokenID
dailyUsage.Date = today
dailyUsage.Model = llmusage.Model
dailyUsage.Stream = llmusage.Stream
dailyUsage.PromptTokens = llmusage.PromptTokens
dailyUsage.CompletionTokens = llmusage.CompletionTokens
dailyUsage.TotalTokens = llmusage.TotalTokens
dailyUsage.Cost = fmt.Sprintf("%.8f", cost)
if err := tx.WithContext(p.ctx).Create(&dailyUsage).Error; err != nil {
return fmt.Errorf("create daily usage error: %w", err)
}
} else {
if err := tx.WithContext(p.ctx).Model(&model.DailyUsage{}).Where("user_id = ? and date = ?", llmusage.User.ID, today).
Updates(map[string]interface{}{
"prompt_tokens": gorm.Expr("prompt_tokens + ?", llmusage.PromptTokens),
"completion_tokens": gorm.Expr("completion_tokens + ?", llmusage.CompletionTokens),
"total_tokens": gorm.Expr("total_tokens + ?", llmusage.TotalTokens),
}).Error; err != nil {
return fmt.Errorf("update daily usage error: %w", err)
}
}
// 3. 更新用户额度
if err := tx.WithContext(p.ctx).Model(&model.User{}).Where("id = ?", usage.UserID).Updates(map[string]interface{}{
"quota": gorm.Expr("quota - ?", usage.Cost),
"used_quota": gorm.Expr("used_quota + ?", usage.Cost),
}).Error; err != nil {
return fmt.Errorf("update user quota and used_quota error: %w", err)
if *llmusage.User.UnlimitedQuota {
if err := tx.WithContext(p.ctx).Model(&model.User{}).Where("id = ?", llmusage.User.ID).Updates(map[string]interface{}{
"used_quota": gorm.Expr("used_quota + ?", fmt.Sprintf("%.8f", cost)),
}).Error; err != nil {
return fmt.Errorf("update user quota and used_quota error: %w", err)
}
} else {
if err := tx.WithContext(p.ctx).Model(&model.User{}).Where("id = ?", llmusage.User.ID).Updates(map[string]interface{}{
"quota": gorm.Expr("quota - ?", fmt.Sprintf("%.8f", cost)),
"used_quota": gorm.Expr("used_quota + ?", fmt.Sprintf("%.8f", cost)),
}).Error; err != nil {
return fmt.Errorf("update user quota and used_quota error: %w", err)
}
}
//4 . 更新token额度
if *token.UnlimitedQuota {
if err := tx.WithContext(p.ctx).Model(&model.Token{}).Where("id = ?", llmusage.TokenID).Updates(map[string]interface{}{
"used_quota": gorm.Expr("used_quota + ?", fmt.Sprintf("%.8f", cost)),
}).Error; err != nil {
return fmt.Errorf("update token quota and used_quota error: %w", err)
}
} else {
if err := tx.WithContext(p.ctx).Model(&model.Token{}).Where("id = ?", llmusage.TokenID).Updates(map[string]interface{}{
"quota": gorm.Expr("quota - ?", fmt.Sprintf("%.8f", cost)),
"used_quota": gorm.Expr("used_quota + ?", fmt.Sprintf("%.8f", cost)),
}).Error; err != nil {
return fmt.Errorf("update token quota and used_quota error: %w", err)
}
}
return nil
@@ -184,10 +238,9 @@ func (p *Proxy) Do(usage *model.Usage) error {
func (p *Proxy) SelectApiKey(model string) error {
akpikeys, err := p.apiKeyDao.FindApiKeysBySupportModel(p.db, model)
if err != nil || len(akpikeys) == 0 {
if strings.HasPrefix(model, "gpt") || strings.HasPrefix(model, "o1") || strings.HasPrefix(model, "o3") {
keys, err := p.apiKeyDao.FindKeys(map[string]any{"apitype = ?": "openai"})
if strings.HasPrefix(model, "gpt") || strings.HasPrefix(model, "o1") || strings.HasPrefix(model, "o3") || strings.HasPrefix(model, "o4") {
keys, err := p.apiKeyDao.FindKeys(map[string]any{"active = ?": true, "apitype = ?": "openai"})
if err != nil {
return err
}
@@ -195,7 +248,7 @@ func (p *Proxy) SelectApiKey(model string) error {
}
if strings.HasPrefix(model, "gemini") {
keys, err := p.apiKeyDao.FindKeys(map[string]any{"apitype = ?": "gemini"})
keys, err := p.apiKeyDao.FindKeys(map[string]any{"active = ?": true, "apitype = ?": "gemini"})
if err != nil {
return err
}
@@ -203,7 +256,7 @@ func (p *Proxy) SelectApiKey(model string) error {
}
if strings.HasPrefix(model, "claude") {
keys, err := p.apiKeyDao.FindKeys(map[string]any{"apitype = ?": "claude"})
keys, err := p.apiKeyDao.FindKeys(map[string]any{"active = ?": true, "apitype = ?": "claude"})
if err != nil {
return err
}
@@ -227,7 +280,7 @@ func (p *Proxy) SelectApiKey(model string) error {
func (p *Proxy) updateSupportModel() {
keys, err := p.apiKeyDao.FindKeys(map[string]interface{}{"apitype in ?": "openai,azure,claude"})
keys, err := p.apiKeyDao.FindKeys(map[string]interface{}{"apitype in ?": []string{"openai", "azure", "claude"}})
if err != nil {
return
}
@@ -270,7 +323,10 @@ func (p *Proxy) ScheduleTask() {
select {
case <-time.After(time.Duration(p.cfg.TaskTimeInterval) * time.Minute):
p.updateSupportModel()
case <-time.After(time.Hour * 12):
if err := p.setModelCache(); err != nil {
fmt.Println("refrash model cache err:", err)
}
case <-p.ctx.Done():
fmt.Println("schedule task done")
return
@@ -287,6 +343,9 @@ func (p *Proxy) getOpenAISupportModels(apikey model.ApiKey) ([]string, error) {
var supportModels []string
var req *http.Request
if *apikey.ApiType == "azure" {
if strings.HasSuffix(*apikey.Endpoint, "/") {
apikey.Endpoint = utils.ToPtr(strings.TrimSuffix(*apikey.Endpoint, "/"))
}
req, _ = http.NewRequest("GET", *apikey.Endpoint+azureModelsUrl, nil)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("api-key", *apikey.ApiKey)

View File

@@ -0,0 +1,14 @@
package controller
import (
"fmt"
"io"
"github.com/gin-gonic/gin"
)
func (p *Proxy) ProxyClaude(c *gin.Context) {
fmt.Println(c.Request.URL.String())
data, _ := io.ReadAll(c.Request.Body)
fmt.Println(string(data))
}

View File

@@ -1,7 +1,6 @@
package controller
import (
"fmt"
"net/http"
"opencatd-open/internal/dto"
"opencatd-open/internal/model"
@@ -142,7 +141,7 @@ func (a Api) CreateUser(c *gin.Context) {
dto.Fail(c, 400, err.Error())
return
}
fmt.Printf("user:%+v\n", user)
err = a.userService.Create(c, &user)
if err != nil {
dto.Fail(c, http.StatusInternalServerError, err.Error())

View File

@@ -90,7 +90,7 @@ func (a Api) ResetToken(c *gin.Context) {
dto.Fail(c, http.StatusNotFound, "token not found")
return
}
token.UsedQuota = utils.ToPtr(int64(0))
token.UsedQuota = utils.ToPtr(float64(0))
err = a.tokenService.UpdateToken(c, token)
if err != nil {

View File

@@ -3,6 +3,7 @@ package dao
import (
"errors"
"opencatd-open/internal/model"
"opencatd-open/internal/utils"
"opencatd-open/pkg/config"
"gorm.io/gorm"
@@ -38,6 +39,9 @@ func (dao *ApiKeyDAO) Create(apiKey *model.ApiKey) error {
if apiKey == nil {
return errors.New("apiKey is nil")
}
if len(*apiKey.SupportModels) < 2 {
apiKey.SupportModels = utils.ToPtr("[]")
}
return dao.db.Create(apiKey).Error
}
@@ -87,14 +91,21 @@ func (dao *ApiKeyDAO) FindApiKeysBySupportModel(db *gorm.DB, modelName string) (
var apiKeys []model.ApiKey
switch dao.cfg.DB_Type {
case "mysql":
return nil, errors.New("not support")
err := db.Raw(`
SELECT *
FROM apikeys
WHERE active = true
AND JSON_CONTAINS(support_models, ?, '$')`, modelName).
Scan(&apiKeys).Error
return apiKeys, err
case "postgres":
return nil, errors.New("not support")
}
err := db.Model(&model.ApiKey{}).
Joins("CROSS JOIN JSON_EACH(apikeys.support_models)").
Where("value = ?", modelName).
Find(&apiKeys).Error
err := db.Raw(`
SELECT a.*
FROM apikeys a
JOIN json_each(a.support_models) AS je ON je.value = ?
WHERE a.active = true`, modelName).Scan(&apiKeys).Error
return apiKeys, err
}

View File

@@ -212,11 +212,7 @@ func (d *DailyUsageDAO) UpsertDailyUsage(ctx context.Context, usage *model.Usage
return db.Clauses(clause.OnConflict{
Columns: []clause.Column{
{Name: "user_id"},
{Name: "token_id"},
{Name: "capability"},
{Name: "date"},
{Name: "model"},
{Name: "stream"},
},
DoUpdates: clause.Assignments(updateColumns),
}).Create(dailyUsage).Error
@@ -231,11 +227,7 @@ func (d *DailyUsageDAO) UpsertDailyUsage(ctx context.Context, usage *model.Usage
return db.Clauses(clause.OnConflict{
Columns: []clause.Column{
{Name: "user_id"},
{Name: "token_id"},
{Name: "capability"},
{Name: "date"},
{Name: "model"},
{Name: "stream"},
},
DoUpdates: clause.Assignments(updateColumns),
}).Create(dailyUsage).Error
@@ -244,8 +236,8 @@ func (d *DailyUsageDAO) UpsertDailyUsage(ctx context.Context, usage *model.Usage
default:
return db.Transaction(func(tx *gorm.DB) error {
var existing model.DailyUsage
err := tx.Where("user_id = ? AND token_id = ? AND capability = ? AND date = ? AND model = ? AND stream = ?",
usage.UserID, usage.TokenID, usage.Capability, date, usage.Model, usage.Stream).
err := tx.Where("user_id = ? AND date = ?",
usage.UserID, date).
First(&existing).Error
if err == gorm.ErrRecordNotFound {

View File

@@ -3,14 +3,14 @@ package model
import "github.com/lib/pq" //pq.StringArray
type ApiKey_PG struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id,omitempty"`
Name *string `gorm:"column:name;not null;unique;index:idx_apikey_name" json:"name,omitempty"`
ApiType *string `gorm:"column:apitype;not null;index:idx_apikey_apitype" json:"type,omitempty"`
ApiKey *string `gorm:"column:apikey;not null;index:idx_apikey_apikey" json:"apikey,omitempty"`
Active *bool `gorm:"column:active;default:true" json:"active,omitempty"`
Endpoint *string `gorm:"column:endpoint" json:"endpoint,omitempty"`
ResourceNmae *string `gorm:"column:resource_name" json:"resource_name,omitempty"`
DeploymentName *string `gorm:"column:deployment_name" json:"deployment_name,omitempty"`
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id,omitempty"`
Name *string `gorm:"column:name;not null;unique;index:idx_apikey_name" json:"name,omitempty"`
ApiType *string `gorm:"column:apitype;not null;index:idx_apikey_apitype" json:"type,omitempty"`
ApiKey *string `gorm:"column:apikey;not null;index:idx_apikey_apikey" json:"apikey,omitempty"`
Active *bool `gorm:"column:active;default:true" json:"active,omitempty"`
Endpoint *string `gorm:"column:endpoint" json:"endpoint,omitempty"`
ResourceNmae *string `gorm:"column:resource_name" json:"resource_name,omitempty"`
// DeploymentName *string `gorm:"column:deployment_name" json:"deployment_name,omitempty"`
ApiSecret *string `gorm:"column:api_secret" json:"api_secret,omitempty"`
ModelPrefix *string `gorm:"column:model_prefix" json:"model_prefix,omitempty"`
ModelAlias *string `gorm:"column:model_alias" json:"model_alias,omitempty"`
@@ -26,14 +26,14 @@ func (ApiKey_PG) TableName() string {
}
type ApiKey struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id,omitempty"`
Name *string `gorm:"column:name;not null;unique;index:idx_apikey_name" json:"name,omitempty"`
ApiType *string `gorm:"column:apitype;not null;index:idx_apikey_apitype" json:"type,omitempty"`
ApiKey *string `gorm:"column:apikey;not null;index:idx_apikey_apikey" json:"apikey,omitempty"`
Active *bool `gorm:"column:active;default:true" json:"active,omitempty"`
Endpoint *string `gorm:"column:endpoint" json:"endpoint,omitempty"`
ResourceNmae *string `gorm:"column:resource_name" json:"resource_name,omitempty"`
DeploymentName *string `gorm:"column:deployment_name" json:"deployment_name,omitempty"`
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id,omitempty"`
Name *string `gorm:"column:name;not null;unique;index:idx_apikey_name" json:"name,omitempty"`
ApiType *string `gorm:"column:apitype;not null;index:idx_apikey_apitype" json:"type,omitempty"`
ApiKey *string `gorm:"column:apikey;not null;index:idx_apikey_apikey" json:"apikey,omitempty"`
Active *bool `gorm:"column:active;default:true" json:"active,omitempty"`
Endpoint *string `gorm:"column:endpoint" json:"endpoint,omitempty"`
ResourceNmae *string `gorm:"column:resource_name" json:"resource_name,omitempty"`
// DeploymentName *string `gorm:"column:deployment_name" json:"deployment_name,omitempty"`
AccessKey *string `gorm:"column:access_key" json:"access_key,omitempty"`
SecretKey *string `gorm:"column:secret_key" json:"secret_key,omitempty"`
ModelPrefix *string `gorm:"column:model_prefix" json:"model_prefix,omitempty"`

View File

@@ -2,19 +2,19 @@ package model
// 用户的token
type Token struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id,omitempty"`
UserID int64 `gorm:"column:user_id;not null;index:idx_token_user_id" json:"userid,omitempty"`
Name string `gorm:"column:name;not null;index:idx_token_name" json:"name,omitempty" binding:"required,min=1,max=20"`
Key string `gorm:"column:key;not null;uniqueIndex:idx_token_key;comment:token key" json:"key,omitempty"`
Active *bool `gorm:"column:active;default:true" json:"active,omitempty"` //
Quota *int64 `gorm:"column:quota;type:bigint;default:0" json:"quota,omitempty"` // default 0
UnlimitedQuota *bool `gorm:"column:unlimited_quota;default:true" json:"unlimited_quota,omitempty"` // set Quota 1 unlimited
UsedQuota *int64 `gorm:"column:used_quota;type:bigint;default:0" json:"used_quota,omitempty"`
ExpiredAt *int64 `gorm:"column:expired_at;type:bigint;default:0" json:"expired_at,omitempty"`
NeverExpired *bool `gorm:"column:never_expires;type:bigint;" json:"never_expires,omitempty"`
CreatedAt int64 `gorm:"column:created_at;type:bigint;autoCreateTime" json:"created_at,omitempty"`
LastUsedAt int64 `gorm:"column:lastused_at;type:bigint;autoUpdateTime" json:"lastused_at,omitempty"`
User *User `gorm:"foreignKey:UserID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE" json:"-"`
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id,omitempty"`
UserID int64 `gorm:"column:user_id;not null;index:idx_token_user_id" json:"userid,omitempty"`
Name string `gorm:"column:name;not null;index:idx_token_name" json:"name,omitempty" binding:"required,min=1,max=20"`
Key string `gorm:"column:key;not null;uniqueIndex:idx_token_key;comment:token key" json:"key,omitempty"`
Active *bool `gorm:"column:active;default:true" json:"active,omitempty"` //
Quota *float64 `gorm:"column:quota;type:bigint;default:0" json:"quota,omitempty"` // default 0
UnlimitedQuota *bool `gorm:"column:unlimited_quota;default:true" json:"unlimited_quota,omitempty"` // set Quota 1 unlimited
UsedQuota *float64 `gorm:"column:used_quota;type:bigint;default:0" json:"used_quota,omitempty"`
ExpiredAt *int64 `gorm:"column:expired_at;type:bigint;default:0" json:"expired_at,omitempty"`
NeverExpired *bool `gorm:"column:never_expires;type:bigint;" json:"never_expires,omitempty"`
CreatedAt int64 `gorm:"column:created_at;type:bigint;autoCreateTime" json:"created_at,omitempty"`
LastUsedAt int64 `gorm:"column:lastused_at;type:bigint;autoUpdateTime" json:"lastused_at,omitempty"`
User *User `gorm:"foreignKey:UserID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE" json:"-"`
}
func (Token) TableName() string {

View File

@@ -1,11 +1,7 @@
package model
import (
"net/http"
"opencatd-open/store"
"time"
"github.com/gin-gonic/gin"
)
type Usage struct {
@@ -16,9 +12,9 @@ type Usage struct {
Date time.Time `gorm:"column:date;autoCreateTime;index:idx_date"`
Model string `gorm:"column:model"`
Stream bool `gorm:"column:stream"`
PromptTokens float64 `gorm:"column:prompt_tokens"`
CompletionTokens float64 `gorm:"column:completion_tokens"`
TotalTokens float64 `gorm:"column:total_tokens"`
PromptTokens int `gorm:"column:prompt_tokens"`
CompletionTokens int `gorm:"column:completion_tokens"`
TotalTokens int `gorm:"column:total_tokens"`
Cost string `gorm:"column:cost"`
}
@@ -28,47 +24,18 @@ func (Usage) TableName() string {
type DailyUsage struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
UserID int64 `gorm:"column:user_id;uniqueIndex:idx_daily_unique,priority:1"`
TokenID int64 `gorm:"column:token_id;index:idx_daily_token_id"`
Capability string `gorm:"column:capability;uniqueIndex:idx_daily_unique,priority:2;comment:模型能力"`
UserID int64 `gorm:"column:user_id;uniqueIndex:idx_daily_unique,priority:1"` // uniqueIndex:idx_daily_unique,priority:1
TokenID int64 `gorm:"column:token_id;uniqueIndex:idx_daily_unique,priority:2"`
Capability string `gorm:"column:capability;index:idx_daily_usage_capability;comment:模型能力"`
Date time.Time `gorm:"column:date;autoCreateTime;uniqueIndex:idx_daily_unique,priority:3"`
Model string `gorm:"column:model"`
Stream bool `gorm:"column:stream"`
PromptTokens float64 `gorm:"column:prompt_tokens"`
CompletionTokens float64 `gorm:"column:completion_tokens"`
TotalTokens float64 `gorm:"column:total_tokens"`
PromptTokens int `gorm:"column:prompt_tokens"`
CompletionTokens int `gorm:"column:completion_tokens"`
TotalTokens int `gorm:"column:total_tokens"`
Cost string `gorm:"column:cost"`
}
func (DailyUsage) TableName() string {
return "daily_usages"
}
func HandleUsage(c *gin.Context) {
fromStr := c.Query("from")
toStr := c.Query("to")
getMonthStartAndEnd := func() (start, end string) {
loc, _ := time.LoadLocation("Local")
now := time.Now().In(loc)
year, month, _ := now.Date()
startOfMonth := time.Date(year, month, 1, 0, 0, 0, 0, loc)
endOfMonth := startOfMonth.AddDate(0, 1, 0)
start = startOfMonth.Format("2006-01-02")
end = endOfMonth.Format("2006-01-02")
return
}
if fromStr == "" || toStr == "" {
fromStr, toStr = getMonthStartAndEnd()
}
usage, err := store.QueryUsage(fromStr, toStr)
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
c.JSON(200, usage)
}

View File

@@ -52,9 +52,6 @@ func (s *ApiKeyServiceImpl) UpdateApiKey(ctx context.Context, apikey *model.ApiK
if apikey.ResourceNmae != nil {
_key.ResourceNmae = apikey.ResourceNmae
}
if apikey.DeploymentName != nil {
_key.DeploymentName = apikey.DeploymentName
}
if apikey.AccessKey != nil {
_key.AccessKey = apikey.AccessKey
}

View File

@@ -272,11 +272,22 @@ func (s *WebAuthnService) FinishLogin(challenge string, response *http.Request)
// return nil, err
// }
var user *WebAuthnUser
_, err = s.WebAuthn.FinishDiscoverableLogin(s.GetWebAuthnUser(&user), *sessionData, response)
wc, err := s.WebAuthn.FinishDiscoverableLogin(s.GetWebAuthnUser(&user), *sessionData, response)
if err != nil {
return nil, err
}
// 更新Passkey的LastUsedAt
// 更新Passkey 这里SignCount应该是由验证器上传但可能为0手动+1
var pk model.Passkey
if err := s.DB.Model(&model.Passkey{}).Where("credential_id = ?", base64.StdEncoding.EncodeToString(wc.ID)).First(&pk).Error; err == nil {
if err := s.DB.Model(&model.Passkey{}).Where("id = ?", pk.ID, time.Now().Unix()).Updates(map[string]interface{}{
"sign_count": pk.SignCount + 1,
"last_used_at": time.Now().Unix(),
}).Error; err != nil {
fmt.Println(err)
}
}
return user, nil
}

View File

@@ -0,0 +1,104 @@
package utils
import (
"fmt"
"io"
"net/http"
"net/url"
"opencatd-open/internal/model"
"os"
"strings"
"time"
"github.com/tidwall/gjson"
"gorm.io/gorm"
)
var client = &http.Client{Timeout: 2 * time.Second}
func init() {
if os.Getenv("LOCAL_PROXY") != "" {
if proxyUrl, err := url.Parse(os.Getenv("LOCAL_PROXY")); err == nil {
client.Transport = &http.Transport{Proxy: http.ProxyURL(proxyUrl)}
}
}
}
func FetchKeyModel(db *gorm.DB, key *model.ApiKey) ([]string, error) {
var supportModels []string
var err error
if *key.ApiType == "openai" || *key.ApiType == "azure" {
supportModels, err = FetchOpenAISupportModels(db, key)
}
if *key.ApiType == "claude" {
supportModels, err = FetchClaudeSupportModels(db, key)
}
if err != nil {
fmt.Println(err)
}
return supportModels, err
}
func FetchOpenAISupportModels(db *gorm.DB, apikey *model.ApiKey) ([]string, error) {
openaiModelsUrl := "https://api.openai.com/v1/models"
// https://learn.microsoft.com/zh-cn/rest/api/azureopenai/models/list?view=rest-azureopenai-2025-02-01-preview&tabs=HTTP
azureModelsUrl := "/openai/deployments?api-version=2022-12-01"
var supportModels []string
var req *http.Request
if *apikey.ApiType == "azure" {
if strings.HasSuffix(*apikey.Endpoint, "/") {
apikey.Endpoint = ToPtr(strings.TrimSuffix(*apikey.Endpoint, "/"))
}
req, _ = http.NewRequest("GET", *apikey.Endpoint+azureModelsUrl, nil)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("api-key", *apikey.ApiKey)
} else {
req, _ = http.NewRequest("GET", openaiModelsUrl, nil)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+*apikey.ApiKey)
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
bytesbody, _ := io.ReadAll(resp.Body)
result := gjson.GetBytes(bytesbody, "data.#.id").Array()
for _, v := range result {
model := v.Str
model = strings.Replace(model, "-35-", "-3.5-", -1)
model = strings.Replace(model, "-41-", "-4.1-", -1)
supportModels = append(supportModels, model)
}
}
return supportModels, nil
}
func FetchClaudeSupportModels(db *gorm.DB, apikey *model.ApiKey) ([]string, error) {
// https://docs.anthropic.com/en/api/models-list
claudemodelsUrl := "https://api.anthropic.com/v1/models"
var supportModels []string
req, _ := http.NewRequest("GET", claudemodelsUrl, nil)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", *apikey.ApiKey)
req.Header.Set("anthropic-version", "2023-06-01")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
bytesbody, _ := io.ReadAll(resp.Body)
result := gjson.GetBytes(bytesbody, "data.#.id").Array()
for _, v := range result {
supportModels = append(supportModels, v.Str)
}
}
return supportModels, nil
}

View File

@@ -1,3 +1,4 @@
// https://docs.anthropic.com/en/docs/about-claude/models/all-models
package claude
import (
@@ -93,7 +94,9 @@ func (c *Claude) Chat(ctx context.Context, chatReq llm.ChatRequest) (*llm.ChatRe
if chatReq.MaxTokens > 0 {
maxTokens = chatReq.MaxTokens
} else {
if strings.Contains(chatReq.Model, "sonnet") || strings.Contains(chatReq.Model, "haiku") {
if strings.Contains(chatReq.Model, "3-7") {
maxTokens = 64000
} else if strings.Contains(chatReq.Model, "3-5") {
maxTokens = 8192
} else {
maxTokens = 4096
@@ -110,6 +113,9 @@ func (c *Claude) Chat(ctx context.Context, chatReq llm.ChatRequest) (*llm.ChatRe
return nil, err
}
if c.tokenUsage.Model == "" && resp.Model != "" {
c.tokenUsage.Model = string(resp.Model)
}
c.tokenUsage.PromptTokens += resp.Usage.InputTokens
c.tokenUsage.CompletionTokens += resp.Usage.OutputTokens
c.tokenUsage.TotalTokens += resp.Usage.InputTokens + resp.Usage.OutputTokens

View File

@@ -1,4 +1,5 @@
// https://github.com/google-gemini/api-examples/
// https://ai.google.dev/gemini-api/docs/models?hl=zh-cn
package google
@@ -109,6 +110,9 @@ func (g *Gemini) Chat(ctx context.Context, chatReq llm.ChatRequest) (*llm.ChatRe
return nil, err
}
if g.tokenUsage.Model == "" && response.ModelVersion != "" {
g.tokenUsage.Model = response.ModelVersion
}
if response.UsageMetadata != nil {
g.tokenUsage.PromptTokens += int(response.UsageMetadata.PromptTokenCount)
g.tokenUsage.CompletionTokens += int(response.UsageMetadata.CandidatesTokenCount)

View File

@@ -13,7 +13,7 @@ type LLM interface {
type llm struct {
ApiKey *model.ApiKey
Usage *model.Usage
Usage *TokenUsage
tools any // TODO
Messages []any // TODO
llm LLM

View File

@@ -14,6 +14,8 @@ import (
"opencatd-open/llm"
"os"
"strings"
"github.com/sashabaranov/go-openai"
)
// https://learn.microsoft.com/en-us/azure/ai-services/openai/api-version-deprecation#latest-preview-api-releases
@@ -86,6 +88,9 @@ func (o *OpenAICompatible) Chat(ctx context.Context, chatReq llm.ChatRequest) (*
}
var buildurl string
if *o.ApiKey.Endpoint != "" {
if strings.HasSuffix(*o.ApiKey.Endpoint, "/") {
o.ApiKey.ApiKey = utils.ToPtr(strings.TrimSuffix(*o.ApiKey.Endpoint, "/"))
}
buildurl = fmt.Sprintf("%s/openai/deployments/%s/chat/completions?api-version=%s", *o.ApiKey.Endpoint, formatModel(chatReq.Model), AzureApiVersion)
} else {
buildurl = fmt.Sprintf("https://%s.openai.azure.com/openai/deployments/%s/chat/completions?api-version=%s", *o.ApiKey.ResourceNmae, formatModel(chatReq.Model), AzureApiVersion)
@@ -116,6 +121,9 @@ func (o *OpenAICompatible) Chat(ctx context.Context, chatReq llm.ChatRequest) (*
return nil, err
}
if o.tokenUsage.Model == "" && chatResp.Model != "" {
o.tokenUsage.Model = chatResp.Model
}
o.tokenUsage.PromptTokens = chatResp.Usage.PromptTokens
o.tokenUsage.CompletionTokens = chatResp.Usage.CompletionTokens
o.tokenUsage.TotalTokens = chatResp.Usage.TotalTokens
@@ -124,6 +132,7 @@ func (o *OpenAICompatible) Chat(ctx context.Context, chatReq llm.ChatRequest) (*
func (o *OpenAICompatible) StreamChat(ctx context.Context, chatReq llm.ChatRequest) (chan *llm.StreamChatResponse, error) {
chatReq.Stream = true
chatReq.StreamOptions = &openai.StreamOptions{IncludeUsage: true}
dst, err := utils.StructToMap(chatReq)
if err != nil {
return nil, err
@@ -197,6 +206,7 @@ func (o *OpenAICompatible) StreamChat(ctx context.Context, chatReq llm.ChatReque
if err := json.Unmarshal(line, &streamResp); err != nil {
continue
}
if streamResp.Usage != nil {
o.tokenUsage.PromptTokens += streamResp.Usage.PromptTokens
o.tokenUsage.CompletionTokens += streamResp.Usage.CompletionTokens

View File

@@ -2,6 +2,7 @@ package llm
import (
"fmt"
"opencatd-open/internal/model"
"github.com/sashabaranov/go-openai"
)
@@ -15,9 +16,13 @@ type StreamChatResponse openai.ChatCompletionStreamResponse
type ChatMessage openai.ChatCompletionMessage
type TokenUsage struct {
User *model.User
TokenID int64
Model string `json:"model"`
Stream bool
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
ToolsTokens int `json:"total_tokens"`
ToolsTokens int `json:"tools_tokens"`
TotalTokens int `json:"total_tokens"`
}

View File

@@ -94,6 +94,8 @@ func AuthLLM(db *gorm.DB) gin.HandlerFunc {
}
c.Set("user", token.User)
c.Set("user_id", token.User.ID)
c.Set("token_id", token.ID)
c.Set("authed", true)
// 可以在这里对 token 进行验证并检查权限

193
router/setRouter.go Normal file
View File

@@ -0,0 +1,193 @@
package router
import (
"context"
"embed"
"fmt"
"io/fs"
"log"
"net/http"
"opencatd-open/middleware"
"opencatd-open/pkg/config"
"opencatd-open/wire"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func SetRouter(cfg *config.Config, db *gorm.DB, web *embed.FS) {
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
if cfg == nil || db == nil {
panic("cfg or db is nil")
}
sqlDB, err := db.DB()
if err != nil {
log.Fatalf("Failed to get underlying *sql.DB: %v", err)
}
team, err := wire.InitTeamHandler(ctx, cfg, db)
if err != nil {
panic(err)
}
api, err := wire.InitAPIHandler(ctx, cfg, db)
if err != nil {
panic(err)
}
proxy, err := wire.InitProxyHandler(ctx, cfg, db, &wg)
if err != nil {
panic(err)
}
r := gin.Default()
r.Use(middleware.CORS())
teamGroup := r.Group("/1")
teamGroup.Use(team.AuthMiddleware())
{
teamGroup.POST("/users/init", team.InitAdmin)
// 获取当前用户信息
teamGroup.GET("/me", team.Me)
// team.GET("/me/usages", team.HandleMeUsage)
teamGroup.POST("/keys", team.CreateKey)
teamGroup.GET("/keys", team.ListKeys)
teamGroup.POST("/keys/:id", team.UpdateKey)
teamGroup.DELETE("/keys/:id", team.DeleteKey)
teamGroup.POST("/users", team.CreateUser)
teamGroup.GET("/users", team.ListUsers)
teamGroup.POST("/users/:id/reset", team.ResetUserToken)
teamGroup.DELETE("/users/:id", team.DeleteUser)
teamGroup.GET("/1/usages", team.ListUsages)
}
public := r.Group("/api/auth")
{
public.GET("/passkey/begin", api.PasskeyAuthBegin)
public.POST("/passkey/finish", api.PasskeyAuthFinish)
public.POST("/register", api.Register)
public.POST("/login", api.Login)
}
apiGroup := r.Group("/api", middleware.Auth)
{
apiGroup.GET("/profile", api.Profile)
apiGroup.POST("/profile/update", api.UpdateProfile)
apiGroup.POST("/profile/update/password", api.UpdatePassword)
// 绑定PassKey
apiGroup.GET("/profile/passkey", api.PasskeyCreateBegin)
apiGroup.POST("/profile/passkey", api.PasskeyCreateFinish)
apiGroup.GET("/profile/passkeys", api.ListPasskey)
apiGroup.DELETE("/profile/passkeys/:id", api.DeletePasskey)
userGroup := apiGroup.Group("/users")
{
userGroup.POST("", api.CreateUser)
userGroup.GET("", api.ListUser)
userGroup.GET("/:id", api.GetUser)
userGroup.PUT("/:id", api.EditUser)
userGroup.DELETE("/:id", api.DeleteUser)
userGroup.POST("/batch/:option", api.UserOption)
}
tokenGroup := apiGroup.Group("/tokens")
tokenGroup.POST("", api.CreateToken)
tokenGroup.GET("", api.ListToken)
// tokenGroup.GET("/:id", api.GetToken)
tokenGroup.POST("/reset/:id", api.ResetToken)
tokenGroup.PUT("/:id", api.UpdateToken)
tokenGroup.DELETE("/:id", api.DeleteToken)
// tokenGroup.POST("/batch/:option", api.TokenOption)
apiGroup.POST("keys", api.CreateApiKey)
apiGroup.GET("keys", api.ListApiKey)
apiGroup.GET("keys/:id", api.GetApiKey)
apiGroup.PUT("keys/:id", api.UpdateApiKey)
apiGroup.DELETE("keys/:id", api.DeleteApiKey)
apiGroup.POST("keys/batch/:option", api.ApiKeyOption)
}
v1 := r.Group("/v1")
v1.Use(middleware.AuthLLM(db))
{
// v1.POST("/v2/*proxypath", router.HandleProxy)
v1.POST("/*proxypath", proxy.HandleProxy)
v1.GET("/models", proxy.HandleModels)
}
idxFS, err := fs.Sub(web, "dist")
if err != nil {
panic(err)
}
assetsFS, err := fs.Sub(web, "dist/assets")
if err != nil {
panic(err)
}
r.StaticFS("/assets", http.FS(assetsFS))
r.NoRoute(func(c *gin.Context) {
if c.Writer.Status() == http.StatusNotFound {
c.FileFromFS("/", http.FS(idxFS))
}
})
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Port),
Handler: r,
}
go func() {
fmt.Println("Starting server at port:", cfg.Port)
// 服务启动
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %s\n", err)
}
}()
// 等待中断信号来优雅地关闭服务器
quit := make(chan os.Signal, 1)
// kill (no param) default send syscall.SIGTERM
// kill -2 is syscall.SIGINT
// kill -9 is syscall.SIGKILL but can't be catch
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
fmt.Println("\nShutdown Server ...")
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Fatalln("Server Shutdown:", err)
}
cancel()
sqlDB.Close()
waitChan := make(chan struct{})
go func() {
wg.Wait()
close(waitChan)
}()
select {
case <-waitChan:
fmt.Println("All goroutines have finished")
case <-shutdownCtx.Done():
fmt.Println("⚠️ Shutdown timeout")
}
fmt.Println("Server exited")
}