Compare commits
58
Commits
910651ddba
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4efe6b192 | ||
|
|
ecdbc0b281 | ||
|
|
b17cc3e9c8 | ||
|
|
effe76d83f | ||
|
|
e7a4613bac | ||
|
|
1fee3e5461 | ||
|
|
0732dfddef | ||
|
|
ab8ddc8354 | ||
|
|
e69455fbfc | ||
|
|
1be1b00511 | ||
|
|
babfc71f13 | ||
|
|
ae49b95e0f | ||
|
|
33b39006c0 | ||
|
|
62365568e9 | ||
|
|
1f329bfbe1 | ||
|
|
c96f7ac3ea | ||
|
|
39dcfc96c8 | ||
|
|
d8a3003b69 | ||
|
|
a880fc3161 | ||
|
|
efe2153446 | ||
|
|
daf4605475 | ||
|
|
6fa8352455 | ||
|
|
7ff87aa5d9 | ||
|
|
e5f5b82dc6 | ||
|
|
e49fc1fc89 | ||
|
|
3d8c809393 | ||
|
|
6b4c90f876 | ||
|
|
fe78a55fe4 | ||
|
|
db5c77851d | ||
|
|
00109f83a2 | ||
|
|
bb715e2288 | ||
|
|
5c5249cb70 | ||
|
|
cc9929c716 | ||
|
|
d4adc0b6aa | ||
|
|
5dc334062e | ||
|
|
655531eaca | ||
|
|
d934dbe419 | ||
|
|
af5bf84582 | ||
|
|
90a34276ef | ||
|
|
cbdde40172 | ||
|
|
2137f9565b | ||
|
|
23bef52290 | ||
|
|
ec5dd9adf7 | ||
|
|
256eeb3234 | ||
|
|
c559e712b6 | ||
|
|
c429209e50 | ||
|
|
5a0cbeb429 | ||
|
|
2e5a1e2e72 | ||
|
|
9e69dbfc70 | ||
|
|
0631849c36 | ||
|
|
6354e629ad | ||
|
|
067ee50fb7 | ||
|
|
91e6881623 | ||
|
|
27d58cfb5d | ||
|
|
d0506b0d2e | ||
|
|
602664c122 | ||
|
|
b39173643d | ||
|
|
313d54a5ec |
@@ -64,3 +64,8 @@ Thumbs.db
|
||||
|
||||
# fast-deploy build artifact
|
||||
/frontend/dist.tar.gz
|
||||
|
||||
# ad-hoc probe/test scratch files (cookies, tokens, live probes)
|
||||
_tmp_*
|
||||
_arp_*.txt
|
||||
zz_live_probe_test.go
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type row struct {
|
||||
ID string
|
||||
Pool string
|
||||
Status string
|
||||
AccountEmail string
|
||||
ImageLimited bool
|
||||
VideoLimited bool
|
||||
}
|
||||
|
||||
func main() {
|
||||
dsn := os.Getenv("POSTGRES_DSN")
|
||||
if dsn == "" {
|
||||
fmt.Println("POSTGRES_DSN env is required, e.g. host=127.0.0.1 user=postgres password=... dbname=vivid_ai port=5432 sslmode=disable TimeZone=Asia/Shanghai")
|
||||
os.Exit(1)
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
fmt.Println("open err:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var pick row
|
||||
db.Raw(`SELECT id, pool, status, account_email, image_limited, video_limited FROM token_accounts WHERE pool='adobe' ORDER BY id LIMIT 1`).Scan(&pick)
|
||||
fmt.Printf("picked: id=%s email=%s status=%s image_limited=%v video_limited=%v\n", pick.ID, pick.AccountEmail, pick.Status, pick.ImageLimited, pick.VideoLimited)
|
||||
|
||||
if err := db.Exec(`UPDATE token_accounts SET video_limited=true, updated_at=now() WHERE id=?`, pick.ID).Error; err != nil {
|
||||
fmt.Println("update err:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("-> set video_limited=true")
|
||||
|
||||
var after row
|
||||
db.Raw(`SELECT id, pool, status, account_email, image_limited, video_limited FROM token_accounts WHERE id=?`, pick.ID).Scan(&after)
|
||||
fmt.Printf("after: id=%s status=%s image_limited=%v video_limited=%v\n", after.ID, after.Status, after.ImageLimited, after.VideoLimited)
|
||||
}
|
||||
@@ -5,25 +5,16 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"backend/internal/config"
|
||||
"backend/internal/http/handler"
|
||||
"backend/internal/http/router"
|
||||
"backend/internal/model"
|
||||
"backend/internal/provider/adobe"
|
||||
"backend/internal/provider/chatgpt"
|
||||
"backend/internal/provider/custom"
|
||||
"backend/internal/provider/creativefabrica"
|
||||
"backend/internal/provider/grok"
|
||||
"backend/internal/provider/imagine"
|
||||
"backend/internal/provider/krea"
|
||||
"backend/internal/provider/leonardo"
|
||||
"backend/internal/provider/runway"
|
||||
"backend/internal/repo"
|
||||
"backend/internal/service"
|
||||
"backend/internal/storage"
|
||||
"backend/internal/upstream"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/redis/go-redis/v9"
|
||||
@@ -51,9 +42,9 @@ func NewApp(ctx context.Context) (*App, error) {
|
||||
return nil, fmt.Errorf("create generated root %s: %w", cfg.GeneratedRoot, err)
|
||||
}
|
||||
|
||||
// TranslateError: 把驱动层错误(如 Postgres 23505 唯一冲突)翻译成 gorm.ErrDuplicatedKey,
|
||||
// 否则各 import-*(krea/adobe/leonardo/runway)里的 errors.Is(err, gorm.ErrDuplicatedKey)
|
||||
// 兜底命不中,重复导入会直接抛原始错误 → 400,而不是按预期 Update 已有行。
|
||||
// TranslateError: 把驱动层错误(如 Postgres 23505 唯一冲突)翻译成
|
||||
// gorm.ErrDuplicatedKey,否则 errors.Is(err, gorm.ErrDuplicatedKey) 兜底命不中,
|
||||
// 重复导入会直接抛原始错误 → 400,而不是按预期 Update 已有行。
|
||||
db, err := gorm.Open(postgres.Open(cfg.PostgresDSN), &gorm.Config{TranslateError: true})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open postgres: %w", err)
|
||||
@@ -97,8 +88,7 @@ func NewApp(ctx context.Context) (*App, error) {
|
||||
eventRepo := repo.NewEventRepository(db)
|
||||
cdkRepo := repo.NewCDKRepository(db)
|
||||
apiKeyRepo := repo.NewAPIKeyRepository(db)
|
||||
tokenRepo := repo.NewTokenRepository(db)
|
||||
refreshRepo := repo.NewRefreshProfileRepository(db)
|
||||
channelRepo := repo.NewChannelRepository(db)
|
||||
cgroupRepo := repo.NewConcurrencyGroupRepository(db)
|
||||
// Seed the "默认并发" group (cap 10) and bind any ungrouped users to it.
|
||||
if err := cgroupRepo.EnsureDefault(ctx); err != nil {
|
||||
@@ -117,32 +107,21 @@ func NewApp(ctx context.Context) (*App, error) {
|
||||
authSvc := service.NewAuthService(userRepo, siteRepo, sessionSvc, emailCodeSvc, smtpSvc, cgroupRepo)
|
||||
appSettingsSvc := service.NewAppSettingsService(siteRepo, eventRepo, smtpSvc, rustfsClient)
|
||||
imageAccessSvc := service.NewImageAccessService(cfg.GeneratedRoot, showcaseRepo, authSvc)
|
||||
adobeClient := adobe.NewClient("projectx_webapp", "")
|
||||
chatGPTClient := chatgpt.NewClient("")
|
||||
runwayClient := runway.NewClient("")
|
||||
leonardoClient := leonardo.NewClient("")
|
||||
kreaClient := krea.NewClient("")
|
||||
imagineClient := imagine.NewClient("")
|
||||
grokClient := grok.NewClient("")
|
||||
// Keep grok's x-statsig-id anti-bot recipe current by reading grok's own
|
||||
// headless-browser signer output. Event-driven: seed from the persisted
|
||||
// recipe, capture once at startup, then re-capture only on an anti-bot 403
|
||||
// (a reship made the recipe stale). No polling.
|
||||
startGrokStatsigRefresh(siteRepo)
|
||||
customClient := custom.NewClient()
|
||||
cfClient := creativefabrica.NewClient("")
|
||||
v1Svc := service.NewV1Service(cfg, modelRepo, userRepo, eventRepo, tokenRepo, siteRepo, cgroupRepo, concSvc, adobeClient, chatGPTClient, runwayClient, leonardoClient, kreaClient, imagineClient, grokClient, customClient, cfClient, rustfsClient)
|
||||
upClient := upstream.NewClient()
|
||||
channelSecret := cfg.ChannelSecret
|
||||
if channelSecret == "" {
|
||||
// Stable fallback so a restarted process can still decrypt existing
|
||||
// rows when CHANNEL_SECRET isn't set.
|
||||
channelSecret = cfg.PostgresDSN
|
||||
}
|
||||
channelSvc := service.NewChannelService(channelRepo, modelRepo, upClient, channelSecret)
|
||||
v1Svc := service.NewV1Service(cfg, modelRepo, userRepo, eventRepo, channelSvc, siteRepo, cgroupRepo, concSvc, rustfsClient)
|
||||
siteSvc := service.NewSiteService(siteRepo, cfg.AppTitle)
|
||||
showcaseSvc := service.NewShowcaseService(showcaseRepo)
|
||||
adminReadSvc := service.NewAdminReadService(cfg, userRepo, modelRepo, eventRepo, siteRepo, tokenRepo, cdkRepo, rustfsClient, showcaseRepo)
|
||||
adminWriteSvc := service.NewAdminWriteService(userRepo, showcaseRepo, modelRepo, eventRepo, apiKeyRepo, tokenRepo, orderRepo)
|
||||
adminReadSvc := service.NewAdminReadService(cfg, userRepo, modelRepo, eventRepo, siteRepo, channelRepo, cdkRepo, rustfsClient, showcaseRepo)
|
||||
adminWriteSvc := service.NewAdminWriteService(userRepo, showcaseRepo, modelRepo, eventRepo, apiKeyRepo, channelRepo, orderRepo)
|
||||
cdkSvc := service.NewCDKService(cdkRepo, userRepo, siteRepo, orderRepo)
|
||||
apiKeySvc := service.NewAPIKeyService(apiKeyRepo)
|
||||
tokenSvc := service.NewTokenService(tokenRepo, refreshRepo, eventRepo, siteRepo, adobeClient, chatGPTClient, runwayClient, leonardoClient, kreaClient, imagineClient, grokClient, cfClient)
|
||||
refreshSvc := service.NewRefreshProfileService(refreshRepo, tokenRepo, adobeClient)
|
||||
// Enable refresh-then-retry on a mid-request Adobe 401 (re-mint access token
|
||||
// from the cookie). Wired post-construction to avoid a ctor init cycle.
|
||||
v1Svc.SetRefresh(refreshSvc)
|
||||
bannedWordRepo := repo.NewBannedWordRepository(db)
|
||||
v1Svc.SetBannedWords(bannedWordRepo)
|
||||
userGenSvc := service.NewUserGenerationService(v1Svc, eventRepo, userRepo, modelRepo)
|
||||
@@ -150,30 +129,30 @@ func NewApp(ctx context.Context) (*App, error) {
|
||||
canvasSvc := service.NewCanvasService(canvasRepo)
|
||||
|
||||
engine := router.New(cfg, authSvc, router.Handlers{
|
||||
Health: handler.NewHealthHandler(),
|
||||
Images: handler.NewImageHandler(cfg, imageAccessSvc, rustfsClient),
|
||||
V1: handler.NewV1Handler(v1Svc),
|
||||
Site: handler.NewSiteHandler(siteSvc),
|
||||
Showcase: handler.NewShowcaseHandler(showcaseSvc),
|
||||
Auth: handler.NewAuthHandler(cfg, authSvc, rateLimitSvc),
|
||||
SiteSettings: handler.NewSiteSettingsHandler(siteSvc),
|
||||
AppSettings: handler.NewAppSettingsHandler(appSettingsSvc),
|
||||
AdminRead: handler.NewAdminReadHandler(adminReadSvc),
|
||||
AdminWrite: handler.NewAdminWriteHandler(adminWriteSvc),
|
||||
CDK: handler.NewCDKHandler(cdkSvc),
|
||||
UserTools: handler.NewUserToolsHandler(apiKeySvc, cdkSvc),
|
||||
UserGen: handler.NewUserGenerationHandler(userGenSvc, adminReadSvc),
|
||||
ProviderAdmin: handler.NewProviderAdminHandler(tokenSvc, refreshSvc),
|
||||
ConcGroups: handler.NewConcurrencyGroupHandler(cgroupSvc),
|
||||
Announcement: handler.NewAnnouncementHandler(announcementSvc),
|
||||
Payment: handler.NewPaymentHandler(paymentSvc),
|
||||
BannedWords: handler.NewBannedWordsHandler(bannedWordRepo),
|
||||
Canvas: handler.NewCanvasHandler(canvasSvc),
|
||||
Health: handler.NewHealthHandler(),
|
||||
Images: handler.NewImageHandler(cfg, imageAccessSvc, rustfsClient),
|
||||
V1: handler.NewV1Handler(v1Svc),
|
||||
Site: handler.NewSiteHandler(siteSvc),
|
||||
Showcase: handler.NewShowcaseHandler(showcaseSvc),
|
||||
Auth: handler.NewAuthHandler(cfg, authSvc, rateLimitSvc),
|
||||
SiteSettings: handler.NewSiteSettingsHandler(siteSvc),
|
||||
AppSettings: handler.NewAppSettingsHandler(appSettingsSvc),
|
||||
AdminRead: handler.NewAdminReadHandler(adminReadSvc),
|
||||
AdminWrite: handler.NewAdminWriteHandler(adminWriteSvc),
|
||||
CDK: handler.NewCDKHandler(cdkSvc),
|
||||
UserTools: handler.NewUserToolsHandler(apiKeySvc, cdkSvc),
|
||||
UserGen: handler.NewUserGenerationHandler(userGenSvc, adminReadSvc),
|
||||
ChannelAdmin: handler.NewChannelAdminHandler(channelSvc),
|
||||
ConcGroups: handler.NewConcurrencyGroupHandler(cgroupSvc),
|
||||
Announcement: handler.NewAnnouncementHandler(announcementSvc),
|
||||
Payment: handler.NewPaymentHandler(paymentSvc),
|
||||
BannedWords: handler.NewBannedWordsHandler(bannedWordRepo),
|
||||
Canvas: handler.NewCanvasHandler(canvasSvc),
|
||||
})
|
||||
|
||||
// Background self-healing sweep (quota recovery, cookie refresh, stale-pending
|
||||
// cleanup, log retention) — the Go equivalent of the Python daemon thread.
|
||||
maintenanceSvc := service.NewMaintenanceService(tokenRepo, tokenSvc, eventRepo, userRepo, refreshSvc, siteRepo, rustfsClient, v1Svc.Inflight(), showcaseRepo, orderRepo)
|
||||
// Background self-healing sweep (order expiry, stale-pending cleanup+refund,
|
||||
// log/media retention).
|
||||
maintenanceSvc := service.NewMaintenanceService(eventRepo, userRepo, siteRepo, rustfsClient, v1Svc.Inflight(), showcaseRepo, orderRepo)
|
||||
loopCtx, loopCancel := context.WithCancel(context.Background())
|
||||
go maintenanceSvc.Run(loopCtx)
|
||||
|
||||
@@ -186,33 +165,6 @@ func NewApp(ctx context.Context) (*App, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// startGrokStatsigRefresh wires grok's headless x-statsig-id refresher to the
|
||||
// site-settings store (persisted across restarts) with an app-lifetime context.
|
||||
func startGrokStatsigRefresh(siteRepo *repo.SiteSettingRepository) {
|
||||
const kHeader, kSuffix, kTrailer = "grok.statsig.header", "grok.statsig.suffix", "grok.statsig.trailer"
|
||||
grok.StartStatsigAutoRefresh(context.Background(), 0,
|
||||
func(ctx context.Context) (string, string, int, bool) {
|
||||
h, _ := siteRepo.GetValue(ctx, kHeader)
|
||||
s, _ := siteRepo.GetValue(ctx, kSuffix)
|
||||
tv, _ := siteRepo.GetValue(ctx, kTrailer)
|
||||
if h == "" || s == "" {
|
||||
return "", "", 0, false
|
||||
}
|
||||
t, _ := strconv.Atoi(tv)
|
||||
return h, s, t, true
|
||||
},
|
||||
func(ctx context.Context, h, s string, t int) {
|
||||
if err := siteRepo.UpsertValues(ctx, map[string]string{
|
||||
kHeader: h,
|
||||
kSuffix: s,
|
||||
kTrailer: strconv.Itoa(t),
|
||||
}); err != nil {
|
||||
log.Printf("grok statsig: persist recipe failed: %v", err)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (a *App) Close() error {
|
||||
if a.maintenanceCancel != nil {
|
||||
a.maintenanceCancel()
|
||||
|
||||
@@ -26,6 +26,9 @@ type Config struct {
|
||||
RustFSBucket string
|
||||
RustFSAccessKey string
|
||||
RustFSSecretKey string
|
||||
// ChannelSecret encrypts channel upstream API keys at rest (AES-GCM).
|
||||
// Optional — when empty a stable key is derived from the Postgres DSN.
|
||||
ChannelSecret string
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
@@ -60,6 +63,7 @@ func Load() (*Config, error) {
|
||||
RustFSBucket: envString("RUSTFS_BUCKET", ""),
|
||||
RustFSAccessKey: envString("RUSTFS_ACCESS_KEY", ""),
|
||||
RustFSSecretKey: envString("RUSTFS_SECRET_KEY", ""),
|
||||
ChannelSecret: envString("CHANNEL_SECRET", ""),
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"backend/internal/service"
|
||||
"backend/internal/upstream"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ChannelAdminHandler exposes channel CRUD + upstream model import under
|
||||
// /admin/api/channels*. Replaces the old token/account admin surface.
|
||||
type ChannelAdminHandler struct {
|
||||
channels *service.ChannelService
|
||||
}
|
||||
|
||||
func NewChannelAdminHandler(channels *service.ChannelService) *ChannelAdminHandler {
|
||||
return &ChannelAdminHandler{channels: channels}
|
||||
}
|
||||
|
||||
func (h *ChannelAdminHandler) List(c *gin.Context) {
|
||||
data, err := h.channels.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load channels"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": data})
|
||||
}
|
||||
|
||||
func (h *ChannelAdminHandler) Save(c *gin.Context) {
|
||||
var body service.SaveChannelDto
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
body.BaseURL = strings.TrimSpace(body.BaseURL)
|
||||
ch, err := h.channels.Save(c.Request.Context(), body)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "channel not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": ch.ID})
|
||||
}
|
||||
|
||||
func (h *ChannelAdminHandler) Delete(c *gin.Context) {
|
||||
var body struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.ID) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "channel id required"})
|
||||
return
|
||||
}
|
||||
if err := h.channels.Delete(c.Request.Context(), body.ID); err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "channel not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// FetchModels proxies GET /v1/models on a channel (stored creds or ad-hoc
|
||||
// base_url+api_key for a not-yet-saved channel form).
|
||||
func (h *ChannelAdminHandler) FetchModels(c *gin.Context) {
|
||||
var body service.FetchModelsDto
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
items, err := h.channels.FetchModels(c.Request.Context(), body)
|
||||
if err != nil {
|
||||
writeUpstreamErr(c, err)
|
||||
return
|
||||
}
|
||||
if items == nil {
|
||||
items = []upstream.RemoteModel{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
// ImportModels binds the ticked upstream models to the channel (creating local
|
||||
// model rows on first import).
|
||||
func (h *ChannelAdminHandler) ImportModels(c *gin.Context) {
|
||||
var body struct {
|
||||
ID string `json:"id"`
|
||||
Models []service.ChannelModelSpec `json:"models"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.ID) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "channel id required"})
|
||||
return
|
||||
}
|
||||
if err := h.channels.ImportModels(c.Request.Context(), body.ID, body.Models); err != nil {
|
||||
writeUpstreamErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// writeUpstreamErr translates upstream-protocol errors into sensible admin
|
||||
// responses (auth → 400 with the upstream's message, temporary → 502).
|
||||
func writeUpstreamErr(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, upstream.ErrAuth):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "上游鉴权失败:" + err.Error()})
|
||||
case errors.Is(err, upstream.ErrInvalid):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "channel not found"})
|
||||
default:
|
||||
c.JSON(http.StatusBadGateway, gin.H{"detail": "上游请求失败:" + err.Error()})
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"backend/internal/config"
|
||||
"backend/internal/service"
|
||||
"backend/internal/storage"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -55,6 +56,7 @@ func (h *ImageHandler) Serve(c *gin.Context) {
|
||||
if !public {
|
||||
authorized, err := h.imageAccess.IsAuthorized(
|
||||
c.Request.Context(),
|
||||
c.GetHeader("Authorization"),
|
||||
readCookie(c, h.cfg.SessionCookieName),
|
||||
user,
|
||||
)
|
||||
|
||||
@@ -1,534 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"backend/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ProviderAdminHandler struct {
|
||||
tokens *service.TokenService
|
||||
refresh *service.RefreshProfileService
|
||||
}
|
||||
|
||||
func NewProviderAdminHandler(tokens *service.TokenService, refresh *service.RefreshProfileService) *ProviderAdminHandler {
|
||||
return &ProviderAdminHandler{
|
||||
tokens: tokens,
|
||||
refresh: refresh,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) TokensList(c *gin.Context) {
|
||||
data, err := h.tokens.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load tokens"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": data})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) TokensCreate(c *gin.Context) {
|
||||
var body struct {
|
||||
Pool string `json:"pool"`
|
||||
Value string `json:"value"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
item, err := h.tokens.Add(c.Request.Context(), body.Pool, body.Value, body.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) ImportChatGPTToken(c *gin.Context) {
|
||||
var body struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
Value string `json:"value"`
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
token := body.AccessToken
|
||||
if token == "" {
|
||||
token = body.Value
|
||||
}
|
||||
name := body.Name
|
||||
if name == "" {
|
||||
name = body.ID
|
||||
}
|
||||
item, err := h.tokens.ImportChatGPTToken(c.Request.Context(), token, name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) ImportRunwayToken(c *gin.Context) {
|
||||
var body struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
Value string `json:"value"`
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
token := body.AccessToken
|
||||
if token == "" {
|
||||
token = body.Value
|
||||
}
|
||||
name := body.Name
|
||||
if name == "" {
|
||||
name = body.ID
|
||||
}
|
||||
item, err := h.tokens.ImportRunwayToken(c.Request.Context(), token, name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) ImportGrokToken(c *gin.Context) {
|
||||
var body struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
Value string `json:"value"`
|
||||
SSO string `json:"sso"`
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
token := body.AccessToken
|
||||
for _, v := range []string{body.Value, body.SSO} {
|
||||
if token == "" {
|
||||
token = v
|
||||
}
|
||||
}
|
||||
name := body.Name
|
||||
if name == "" {
|
||||
name = body.ID
|
||||
}
|
||||
item, err := h.tokens.ImportGrokToken(c.Request.Context(), token, name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) ImportCustomAccount(c *gin.Context) {
|
||||
var body struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
URL string `json:"url"`
|
||||
Key string `json:"key"`
|
||||
APIKey string `json:"api_key"`
|
||||
Models string `json:"models"`
|
||||
Name string `json:"name"`
|
||||
Weight int `json:"weight"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
baseURL := body.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = body.URL
|
||||
}
|
||||
key := body.Key
|
||||
if key == "" {
|
||||
key = body.APIKey
|
||||
}
|
||||
item, err := h.tokens.ImportCustomAccount(c.Request.Context(), baseURL, key, body.Models, body.Name, body.Weight, body.Concurrency, body.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) ImportKreaCookie(c *gin.Context) {
|
||||
var body struct {
|
||||
Cookie string `json:"cookie"`
|
||||
Value string `json:"value"`
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
cookie := body.Cookie
|
||||
if cookie == "" {
|
||||
cookie = body.Value
|
||||
}
|
||||
name := body.Name
|
||||
if name == "" {
|
||||
name = body.ID
|
||||
}
|
||||
item, err := h.tokens.ImportKreaCookie(c.Request.Context(), cookie, name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) ImportImagineToken(c *gin.Context) {
|
||||
var body struct {
|
||||
Cookie string `json:"cookie"`
|
||||
Value string `json:"value"`
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
cred := body.Cookie
|
||||
if cred == "" {
|
||||
cred = body.Value
|
||||
}
|
||||
name := body.Name
|
||||
if name == "" {
|
||||
name = body.ID
|
||||
}
|
||||
item, err := h.tokens.ImportImagineToken(c.Request.Context(), cred, name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) ImportLeonardoCookie(c *gin.Context) {
|
||||
var body struct {
|
||||
Cookie string `json:"cookie"`
|
||||
Value string `json:"value"`
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
cookie := body.Cookie
|
||||
if cookie == "" {
|
||||
cookie = body.Value
|
||||
}
|
||||
name := body.Name
|
||||
if name == "" {
|
||||
name = body.ID
|
||||
}
|
||||
item, err := h.tokens.ImportLeonardoCookie(c.Request.Context(), cookie, name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) ImportAdobeCookie(c *gin.Context) {
|
||||
var body struct {
|
||||
Cookie string `json:"cookie"`
|
||||
Value string `json:"value"`
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
cookie := body.Cookie
|
||||
if cookie == "" {
|
||||
cookie = body.Value
|
||||
}
|
||||
name := body.Name
|
||||
if name == "" {
|
||||
name = body.ID
|
||||
}
|
||||
item, profile, err := h.tokens.ImportAdobeCookie(c.Request.Context(), cookie, name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"profile_id": profile.ID,
|
||||
"id": item.ID,
|
||||
"status": item.Status,
|
||||
"pending": item.Status == "pending",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) ImportCreativeFabricaCookie(c *gin.Context) {
|
||||
var body struct {
|
||||
Cookie string `json:"cookie"`
|
||||
Value string `json:"value"`
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
cookie := body.Cookie
|
||||
if cookie == "" {
|
||||
cookie = body.Value
|
||||
}
|
||||
name := body.Name
|
||||
if name == "" {
|
||||
name = body.ID
|
||||
}
|
||||
item, err := h.tokens.ImportCreativeFabricaCookie(c.Request.Context(), cookie, name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"id": item.ID,
|
||||
"status": item.Status,
|
||||
"pending": item.Status == "pending",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) TokenUpdate(c *gin.Context) {
|
||||
var body map[string]any
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
item, err := h.tokens.Update(c.Request.Context(), c.Param("pool"), c.Param("id"), body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "data": item})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) TokenDelete(c *gin.Context) {
|
||||
if err := h.tokens.Delete(c.Request.Context(), c.Param("pool"), c.Param("id")); err != nil {
|
||||
if errors.Is(err, service.ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "token not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// TokenDeleteBulk removes multiple accounts in one call (account multi-select).
|
||||
func (h *ProviderAdminHandler) TokenDeleteBulk(c *gin.Context) {
|
||||
var body struct {
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
if len(body.IDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "未选择任何账号"})
|
||||
return
|
||||
}
|
||||
n, err := h.tokens.DeleteBulk(c.Request.Context(), body.IDs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "deleted": n})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) AccountsList(c *gin.Context) {
|
||||
data, err := h.tokens.Accounts(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load accounts"})
|
||||
return
|
||||
}
|
||||
|
||||
// KPI stats are computed over the FULL set (每个类型的 成功/失败/限额), independent
|
||||
// of the current filter/page — mirrors the old client-side `stats` computed.
|
||||
stats := accountsStats(data)
|
||||
|
||||
// Server-side filtering (类型 / 状态 / 搜索 邮箱·ID·类型 / dead) so pagination is
|
||||
// correct across pages. ?dead=1 returns only 异常(已失效) accounts — used by
|
||||
// 「删除异常账号」to collect every dead id regardless of the current page.
|
||||
typeFilter := strings.TrimSpace(c.Query("type"))
|
||||
statusFilter := strings.TrimSpace(c.Query("status"))
|
||||
planFilter := strings.TrimSpace(c.Query("plan"))
|
||||
deadOnly := c.Query("dead") == "1"
|
||||
q := strings.ToLower(strings.TrimSpace(c.Query("q")))
|
||||
filtered := make([]map[string]any, 0, len(data))
|
||||
for _, row := range data {
|
||||
if deadOnly && !rowBool(row, "dead") {
|
||||
continue
|
||||
}
|
||||
if typeFilter != "" && rowStr(row, "type") != typeFilter {
|
||||
continue
|
||||
}
|
||||
if statusFilter != "" && rowStr(row, "status") != statusFilter {
|
||||
continue
|
||||
}
|
||||
// plan filter: vip=非free, free=free, sub=子号, master=vip且非子号
|
||||
if planFilter != "" {
|
||||
plan := strings.ToLower(rowStr(row, "plan"))
|
||||
isSub := rowBool(row, "sub_account")
|
||||
isFree := plan == "" || plan == "free"
|
||||
switch planFilter {
|
||||
case "vip":
|
||||
if isFree { continue }
|
||||
case "free":
|
||||
if !isFree { continue }
|
||||
case "sub":
|
||||
if !isSub { continue }
|
||||
case "master":
|
||||
if isFree || isSub { continue }
|
||||
}
|
||||
}
|
||||
if q != "" {
|
||||
email := strings.ToLower(rowStr(row, "email"))
|
||||
id := strings.ToLower(rowStr(row, "id"))
|
||||
typ := strings.ToLower(rowStr(row, "type"))
|
||||
if !strings.Contains(email, q) && !strings.Contains(id, q) && !strings.Contains(typ, q) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, row)
|
||||
}
|
||||
|
||||
total := len(filtered)
|
||||
limit, offset := pageParams(c, 20)
|
||||
page := pageSlice(filtered, limit, offset)
|
||||
c.JSON(http.StatusOK, gin.H{"data": page, "total": total, "limit": limit, "offset": offset, "stats": stats})
|
||||
}
|
||||
|
||||
// accountsStats reproduces the 账号 KPI strip: per-type 正常/失效/限额 counts plus a
|
||||
// grand total and total dead count (drives 「删除异常账号 (N)」).
|
||||
func accountsStats(rows []map[string]any) gin.H {
|
||||
types := []string{"openai", "adobe", "runway", "leonardo", "krea", "imagine", "grok", "creativefabrica"}
|
||||
by := map[string]*struct{ N, Ok, Dead, Quota int }{}
|
||||
for _, t := range types {
|
||||
by[t] = &struct{ N, Ok, Dead, Quota int }{}
|
||||
}
|
||||
deadTotal := 0
|
||||
for _, row := range rows {
|
||||
dead := rowBool(row, "dead")
|
||||
if dead {
|
||||
deadTotal++
|
||||
}
|
||||
g, ok := by[rowStr(row, "type")]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
g.N++
|
||||
status := rowStr(row, "status")
|
||||
switch {
|
||||
case status == "active":
|
||||
g.Ok++
|
||||
case dead || status == "disabled":
|
||||
g.Dead++
|
||||
case status == "quota":
|
||||
g.Quota++
|
||||
}
|
||||
}
|
||||
out := gin.H{"total": len(rows), "dead_total": deadTotal}
|
||||
for _, t := range types {
|
||||
g := by[t]
|
||||
out[t] = gin.H{"n": g.N, "ok": g.Ok, "dead": g.Dead, "quota": g.Quota}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func rowStr(m map[string]any, key string) string {
|
||||
s, _ := m[key].(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func rowBool(m map[string]any, key string) bool {
|
||||
b, _ := m[key].(bool)
|
||||
return b
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) AccountQuota(c *gin.Context) {
|
||||
data, err := h.tokens.Quota(c.Request.Context(), c.Param("pool"), c.Param("id"))
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "account not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) AccountEmail(c *gin.Context) {
|
||||
data, err := h.tokens.Email(c.Request.Context(), c.Param("pool"), c.Param("id"))
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "account not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) RefreshProfiles(c *gin.Context) {
|
||||
items, err := h.refresh.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load refresh profiles"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) RefreshNow(c *gin.Context) {
|
||||
if err := h.refresh.RefreshNow(c.Request.Context(), c.Param("profile_id")); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) RefreshUpdate(c *gin.Context) {
|
||||
var body map[string]any
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
item, err := h.refresh.Update(c.Request.Context(), c.Param("profile_id"), body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "data": item})
|
||||
}
|
||||
|
||||
func (h *ProviderAdminHandler) RefreshDelete(c *gin.Context) {
|
||||
if err := h.refresh.Delete(c.Request.Context(), c.Param("profile_id")); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
@@ -1,14 +1,24 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"backend/internal/model"
|
||||
"backend/internal/repo"
|
||||
"backend/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
type UserGenerationHandler struct {
|
||||
@@ -91,6 +101,45 @@ func (h *UserGenerationHandler) MyImages(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
// UploadMyImage stores one user-supplied image (multipart field "file") under
|
||||
// the caller's owner directory, so it can be placed on the infinite canvas and
|
||||
// shows up in the 我的图片 picker alongside generated outputs. Returns the
|
||||
// storage key ("owner/name") the frontend feeds to generatedUrl/thumbUrl.
|
||||
func (h *UserGenerationHandler) UploadMyImage(c *gin.Context) {
|
||||
user := currentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"})
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 20<<20)
|
||||
f, _, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "请选择图片文件"})
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(f, 15<<20+1))
|
||||
if err != nil || len(data) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "读取文件失败"})
|
||||
return
|
||||
}
|
||||
if len(data) > 15<<20 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "图片不能超过 15MB"})
|
||||
return
|
||||
}
|
||||
_, format, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "不是有效的图片文件(支持 png/jpg/webp/gif)"})
|
||||
return
|
||||
}
|
||||
key, err := h.admin.SaveOwnedImage(c.Request.Context(), service.OwnerDir(user), format, data)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"name": key})
|
||||
}
|
||||
|
||||
// DeleteMyFile removes ONE of the caller's own generated files (plus its
|
||||
// thumbnail) and blanks the log rows referencing it, so the 画图台 grid and
|
||||
// 创作记录 gallery stop showing it. ?file= is the storage key (owner/name).
|
||||
@@ -202,7 +251,7 @@ func (h *UserGenerationHandler) Test(c *gin.Context) {
|
||||
Resolution string `json:"resolution"`
|
||||
Duration string `json:"duration"`
|
||||
ReferenceImages []string `json:"reference_images"`
|
||||
AccountID string `json:"account_id"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
@@ -216,7 +265,7 @@ func (h *UserGenerationHandler) Test(c *gin.Context) {
|
||||
Resolution: body.Resolution,
|
||||
Duration: body.Duration,
|
||||
ReferenceImages: body.ReferenceImages,
|
||||
AccountID: body.AccountID,
|
||||
ChannelID: body.ChannelID,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
@@ -405,161 +454,19 @@ func (h *UserGenerationHandler) Logs(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": []gin.H{
|
||||
{
|
||||
"key": "gemini-veo31",
|
||||
"label": "Veo31 Fast",
|
||||
"type": "video",
|
||||
"provider": "adobe",
|
||||
"durations": []string{"4s", "6s", "8s"},
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "frame",
|
||||
},
|
||||
{
|
||||
"key": "gemini-veo3.1-fast",
|
||||
"label": "Veo 3.1 Fast",
|
||||
"type": "video",
|
||||
"provider": "adobe",
|
||||
"durations": []string{"4s", "6s", "8s"},
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "frame",
|
||||
},
|
||||
{
|
||||
"key": "gemini-veo3.1",
|
||||
"label": "Veo 3.1",
|
||||
"type": "video",
|
||||
"provider": "adobe",
|
||||
"durations": []string{"4s", "6s", "8s"},
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
},
|
||||
{
|
||||
"key": "adobe-seedance-2.0-fast",
|
||||
"label": "Seedance 2.0 Fast",
|
||||
"type": "video",
|
||||
"provider": "adobe",
|
||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "style",
|
||||
},
|
||||
{
|
||||
"key": "adobe-seedance-2.0",
|
||||
"label": "Seedance 2.0",
|
||||
"type": "video",
|
||||
"provider": "adobe",
|
||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "style",
|
||||
},
|
||||
{
|
||||
"key": "seedance-2.0-fast",
|
||||
"label": "Seedance 2.0 Fast (Creative Fabrica)",
|
||||
"type": "video",
|
||||
"provider": "creativefabrica",
|
||||
"durations": []string{"14s"},
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
// Creative Fabrica 上游只有普通参考图(VIDEO_FRAME_TYPE_REFERENCE),
|
||||
// 没有首尾帧,也不收视频/音频参考。
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "asset",
|
||||
"max_videos": 0,
|
||||
"max_audios": 0,
|
||||
},
|
||||
{
|
||||
"key": "seedance-2.0",
|
||||
"label": "Seedance 2.0 (Creative Fabrica)",
|
||||
"type": "video",
|
||||
"provider": "creativefabrica",
|
||||
"durations": []string{"10s"},
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
// Creative Fabrica 上游只有普通参考图(VIDEO_FRAME_TYPE_REFERENCE),
|
||||
// 没有首尾帧,也不收视频/音频参考。
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "asset",
|
||||
"max_videos": 0,
|
||||
"max_audios": 0,
|
||||
},
|
||||
{
|
||||
"key": "seedance-2.0-不卡人脸",
|
||||
"label": "Seedance 2.0 (Leonardo 私有)",
|
||||
"type": "video",
|
||||
"provider": "leonardo",
|
||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"max_reference_images": 8,
|
||||
"max_images": 4,
|
||||
"max_videos": 3,
|
||||
"max_audios": 1,
|
||||
"reference_mode": "style",
|
||||
},
|
||||
{
|
||||
"key": "seedance-2.0-fast-不卡人脸",
|
||||
"label": "Seedance 2.0 Fast (Leonardo 私有)",
|
||||
"type": "video",
|
||||
"provider": "leonardo",
|
||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"max_reference_images": 8,
|
||||
"max_images": 4,
|
||||
"max_videos": 3,
|
||||
"max_audios": 1,
|
||||
"reference_mode": "style",
|
||||
},
|
||||
{
|
||||
"key": "minimax-h3",
|
||||
"label": "MiniMax H3 (Leonardo 私有)",
|
||||
"type": "video",
|
||||
"provider": "leonardo",
|
||||
"durations": []string{"5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"1440p"},
|
||||
"max_reference_images": 8,
|
||||
"max_images": 5,
|
||||
"max_videos": 0,
|
||||
"max_audios": 3,
|
||||
"max_audio_seconds": 15,
|
||||
"reference_mode": "style",
|
||||
},
|
||||
{
|
||||
"key": "firefly-video",
|
||||
"label": "Firefly Video",
|
||||
"type": "video",
|
||||
"provider": "adobe",
|
||||
"durations": []string{"5s"},
|
||||
"ratios": []string{"16:9", "1:1", "9:16"},
|
||||
"resolutions": []string{"540p", "720p", "1080p"},
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "frame",
|
||||
},
|
||||
{
|
||||
"key": "runway-gen4-turbo",
|
||||
"label": "Runway Gen-4 Turbo",
|
||||
"type": "video",
|
||||
"provider": "runway",
|
||||
"durations": []string{"5s", "10s"},
|
||||
"ratios": []string{"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"},
|
||||
"resolutions": []string{"2K"},
|
||||
"max_reference_images": 1,
|
||||
"reference_mode": "frame",
|
||||
// Runway is strictly image-to-video — a first-frame image is required
|
||||
// (no text2video), so the UI must block submit without one.
|
||||
"requires_reference": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
models, err := h.admin.Models(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load video presets"})
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(models))
|
||||
for _, m := range models {
|
||||
if m.Type != "video" || !m.Enabled {
|
||||
continue
|
||||
}
|
||||
out = append(out, modelPreset(m))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": out})
|
||||
}
|
||||
|
||||
func (h *UserGenerationHandler) Catalog(c *gin.Context) {
|
||||
@@ -574,7 +481,7 @@ func (h *UserGenerationHandler) Catalog(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *UserGenerationHandler) Models(c *gin.Context) {
|
||||
items, err := h.publicModels()
|
||||
items, err := h.publicModels(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load models"})
|
||||
return
|
||||
@@ -582,537 +489,85 @@ func (h *UserGenerationHandler) Models(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
// modelPreset projects one model_configs row into the preset shape the studio
|
||||
// (画图台) / model pickers consume. Database-driven now: providers are gone and
|
||||
// every model arrives via a channel import.
|
||||
func modelPreset(m model.ModelConfig) gin.H {
|
||||
durations := repo.JSONStrings(m.Durations)
|
||||
if len(durations) == 0 && m.DurationMin > 0 && m.DurationMax >= m.DurationMin {
|
||||
// Range spec → expand integer seconds (capped so absurd ranges can't
|
||||
// flood a dropdown).
|
||||
for d := m.DurationMin; d <= m.DurationMax && len(durations) < 61; d++ {
|
||||
durations = append(durations, fmt.Sprintf("%ds", d))
|
||||
}
|
||||
}
|
||||
_, perSecond := m.DurationPrices["per_second"]
|
||||
return gin.H{
|
||||
"key": m.EffectiveName(),
|
||||
"id": m.ID,
|
||||
"label": m.EffectiveName(),
|
||||
"type": m.Type,
|
||||
"provider": m.Provider,
|
||||
"ratios": repo.JSONStrings(m.Ratios),
|
||||
"resolutions": repo.JSONStrings(m.Resolutions),
|
||||
"durations": durations,
|
||||
"duration_min": m.DurationMin,
|
||||
"duration_max": m.DurationMax,
|
||||
"per_second": perSecond,
|
||||
"max_reference_images": m.MaxReferenceImages,
|
||||
"max_images": m.MaxReferenceImages,
|
||||
"max_videos": m.MaxReferenceVideos,
|
||||
"max_audios": m.MaxReferenceAudios,
|
||||
"max_reference_videos": m.MaxReferenceVideos,
|
||||
"max_reference_audios": m.MaxReferenceAudios,
|
||||
"max_video_seconds": m.MaxRefVideoSeconds,
|
||||
"max_audio_seconds": m.MaxRefAudioSeconds,
|
||||
"reference_mode": m.ReferenceMode,
|
||||
"image_to_image": m.ImageToImage,
|
||||
"description": m.Name,
|
||||
"added": true,
|
||||
}
|
||||
}
|
||||
|
||||
// catalogEntries lists the managed models (previously a static picklist of
|
||||
// offerable provider models). With channel imports the DB IS the catalog —
|
||||
// rows are already "added", so the admin picklist degrades gracefully.
|
||||
func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error) {
|
||||
items := []gin.H{
|
||||
{
|
||||
"id": "gpt-image-2",
|
||||
"provider": "chatgpt",
|
||||
"type": "image",
|
||||
// ChatGPT web backend only reliably produces 1K and honors a limited
|
||||
// ratio set; size params are advisory prompt hints. Mirrors the Python
|
||||
// reference (providers/chatgpt/provider.py) — do not offer 2K/4K.
|
||||
"ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
|
||||
"resolutions": []string{"1K"},
|
||||
"image_to_image": true,
|
||||
"max_reference_images": 6,
|
||||
"description": "ChatGPT image generation",
|
||||
},
|
||||
{
|
||||
"id": "firefly-gpt-image-2",
|
||||
"provider": "adobe",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:1", "5:4", "9:16", "21:9", "16:9", "4:3", "3:2", "4:5", "3:4", "2:3"},
|
||||
"resolutions": []string{"1K", "2K", "4K"},
|
||||
"image_to_image": true,
|
||||
"max_reference_images": 6,
|
||||
"description": "Adobe Firefly GPT Image",
|
||||
},
|
||||
{
|
||||
"id": "firefly-image-5",
|
||||
"provider": "adobe",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
|
||||
"resolutions": []string{"1K", "2K"},
|
||||
"image_to_image": true,
|
||||
"description": "Adobe Firefly Image 5",
|
||||
},
|
||||
{
|
||||
"id": "runway-nano-banana-2",
|
||||
"provider": "runway",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
|
||||
"resolutions": []string{"1K", "2K", "4K"},
|
||||
"image_to_image": true,
|
||||
"max_reference_images": 6,
|
||||
"reference_mode": "asset",
|
||||
"description": "Runway Nano Banana 2 (图/参考图)",
|
||||
},
|
||||
{
|
||||
"id": "runway-nano-banana-pro",
|
||||
"provider": "runway",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
|
||||
"resolutions": []string{"1K", "2K", "4K"},
|
||||
"image_to_image": true,
|
||||
"max_reference_images": 6,
|
||||
"reference_mode": "asset",
|
||||
"description": "Runway Nano Banana Pro (图/参考图)",
|
||||
},
|
||||
{
|
||||
"id": "nano-banana-2",
|
||||
"provider": "adobe",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
|
||||
"resolutions": []string{"1K", "2K", "4K"},
|
||||
"image_to_image": true,
|
||||
"max_reference_images": 6,
|
||||
"reference_mode": "asset",
|
||||
"description": "Nano Banana 2 (图/参考图)",
|
||||
},
|
||||
{
|
||||
"id": "nano-banana-pro",
|
||||
"provider": "adobe",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
|
||||
"resolutions": []string{"1K", "2K", "4K"},
|
||||
"image_to_image": true,
|
||||
"max_reference_images": 6,
|
||||
"reference_mode": "asset",
|
||||
"description": "Nano Banana Pro (图/参考图)",
|
||||
},
|
||||
{
|
||||
"id": "gemini-veo3.1-fast",
|
||||
"provider": "adobe",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"durations": []string{"4s", "6s", "8s"},
|
||||
"max_reference_images": 3,
|
||||
"reference_mode": "frame",
|
||||
"description": "Veo 3.1 Fast",
|
||||
},
|
||||
{
|
||||
"id": "gemini-veo3.1",
|
||||
"provider": "adobe",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"durations": []string{"4s", "6s", "8s"},
|
||||
"max_reference_images": 3,
|
||||
"reference_mode": "style",
|
||||
"description": "Veo 3.1",
|
||||
},
|
||||
{
|
||||
"id": "gemini-veo3.1",
|
||||
"provider": "adobe",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"durations": []string{"5s", "10s"},
|
||||
"max_reference_images": 2,
|
||||
"reference_mode": "frame",
|
||||
"description": "Luma Ray video",
|
||||
},
|
||||
{
|
||||
"id": "firefly-video",
|
||||
"provider": "adobe",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "1:1", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"durations": []string{"5s"},
|
||||
"max_reference_images": 2,
|
||||
"reference_mode": "frame",
|
||||
"description": "Adobe Firefly Video",
|
||||
},
|
||||
{
|
||||
"id": "adobe-seedance-2.0-fast",
|
||||
"provider": "adobe",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
||||
"per_second": true,
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "style",
|
||||
"description": "Seedance 2.0 Fast",
|
||||
},
|
||||
{
|
||||
"id": "adobe-seedance-2.0",
|
||||
"provider": "adobe",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
||||
"per_second": true,
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "style",
|
||||
"description": "Seedance 2.0",
|
||||
},
|
||||
{
|
||||
"id": "seedance-2.0-fast",
|
||||
"provider": "creativefabrica",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
// 一次性账号:积分刚好够一次生成,时长固定 14 秒。
|
||||
"durations": []string{"14s"},
|
||||
// Creative Fabrica 上游只有普通参考图,没有首尾帧,也不收视频/音频参考。
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "asset",
|
||||
"description": "Seedance 2.0 Fast (Creative Fabrica)",
|
||||
},
|
||||
{
|
||||
"id": "seedance-2.0",
|
||||
"provider": "creativefabrica",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
// 一次性账号:积分刚好够一次生成,时长固定 10 秒。
|
||||
"durations": []string{"10s"},
|
||||
// Creative Fabrica 上游只有普通参考图,没有首尾帧,也不收视频/音频参考。
|
||||
"max_reference_images": 9,
|
||||
"reference_mode": "asset",
|
||||
"description": "Seedance 2.0 (Creative Fabrica)",
|
||||
},
|
||||
{
|
||||
"id": "runway-gen4-turbo",
|
||||
"provider": "runway",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"},
|
||||
"resolutions": []string{"720p"},
|
||||
"durations": []string{"5s", "10s"},
|
||||
"max_reference_images": 1,
|
||||
"reference_mode": "frame",
|
||||
"description": "Runway Gen-4 Turbo video (图生视频)",
|
||||
},
|
||||
{
|
||||
"id": "grok-video",
|
||||
"provider": "grok",
|
||||
"type": "video",
|
||||
"ratios": []string{"2:3", "3:2", "1:1", "9:16", "16:9"},
|
||||
"resolutions": []string{"720p"},
|
||||
// Console 视频吃 1–15 秒的整数时长,所以按秒计价(同 seedance);
|
||||
// 参考图只有 1 张首帧(上游只有 image 字段,没有尾帧)。
|
||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
||||
"per_second": true,
|
||||
"max_reference_images": 1,
|
||||
"reference_mode": "frame",
|
||||
"description": "Grok Imagine video (文/图生视频)",
|
||||
},
|
||||
{
|
||||
"id": "grok-image",
|
||||
"provider": "grok",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9"},
|
||||
"resolutions": []string{"1K", "2K"},
|
||||
"image_to_image": true,
|
||||
"max_reference_images": 3,
|
||||
"reference_mode": "asset",
|
||||
"description": "Grok Imagine image (文生图 / 图生图)",
|
||||
},
|
||||
{
|
||||
"id": "seedream-4.5",
|
||||
"provider": "leonardo",
|
||||
"type": "image",
|
||||
"ratios": []string{"2:3", "1:1", "16:9", "4:3", "4:5", "9:16", "2:1"},
|
||||
"resolutions": []string{"2K", "4K"},
|
||||
"image_to_image": true,
|
||||
"max_reference_images": 6,
|
||||
"description": "Leonardo Seedream 4.5 (生图 / 图生图)",
|
||||
},
|
||||
{
|
||||
"id": "seedance-2.0-不卡人脸",
|
||||
"provider": "leonardo",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
||||
"per_second": true,
|
||||
// 参考资产总上限 8 = 4 图 + 1 音频 + 3 视频(分类上限在服务端校验)。
|
||||
"max_reference_images": 8,
|
||||
"reference_mode": "style",
|
||||
"description": "Leonardo Seedance 2.0 (私有生成 / 图音视频参考)",
|
||||
},
|
||||
{
|
||||
"id": "seedance-2.0-fast-不卡人脸",
|
||||
"provider": "leonardo",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
||||
"per_second": true,
|
||||
"max_reference_images": 8,
|
||||
"reference_mode": "style",
|
||||
"description": "Leonardo Seedance 2.0 Fast (私有生成 / 图音视频参考)",
|
||||
},
|
||||
{
|
||||
"id": "minimax-h3",
|
||||
"provider": "leonardo",
|
||||
"type": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"1440p"},
|
||||
// 上游吃 5–15 秒整数时长,所以按秒计价(后台只填一个 /s 价)。
|
||||
"durations": []string{"5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"},
|
||||
"per_second": true,
|
||||
// 参考资产总上限 8 = 5 图 + 3 音频(分类上限在服务端校验)。
|
||||
"max_reference_images": 8,
|
||||
"reference_mode": "style",
|
||||
"description": "Leonardo MiniMax H3 / hailuo-03 (1440p / 图音参考)",
|
||||
},
|
||||
{
|
||||
"id": "flux-klein-2",
|
||||
"provider": "krea",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:1", "4:3", "3:4", "16:9", "9:16"},
|
||||
"resolutions": []string{"1K", "2K"},
|
||||
"image_to_image": true,
|
||||
"max_reference_images": 4,
|
||||
"description": "Krea Flux Klein (生图 / 图生图)",
|
||||
},
|
||||
{
|
||||
"id": "imagine-1.5",
|
||||
"provider": "imagine",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:3", "9:16", "2:3", "3:4", "1:1", "4:3", "3:2", "16:9", "3:1"},
|
||||
"resolutions": []string{"2K"},
|
||||
"max_reference_images": 0,
|
||||
"description": "Imagine 1.5 (文生图)",
|
||||
},
|
||||
{
|
||||
"id": "imagine-1.5pro",
|
||||
"provider": "imagine",
|
||||
"type": "image",
|
||||
"ratios": []string{"1:3", "9:16", "2:3", "3:4", "1:1", "4:3", "3:2", "16:9", "3:1"},
|
||||
"resolutions": []string{"4K"},
|
||||
"max_reference_images": 0,
|
||||
"description": "Imagine 1.5 Pro (文生图)",
|
||||
},
|
||||
models, err := h.admin.Models(c.Request.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existing := map[string]bool{}
|
||||
if h.admin != nil {
|
||||
models, err := h.admin.Models(c.Request.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range models {
|
||||
existing[item.ID] = true
|
||||
}
|
||||
}
|
||||
for i := range items {
|
||||
items[i]["added"] = existing[items[i]["id"].(string)]
|
||||
items := make([]gin.H, 0, len(models))
|
||||
for _, m := range models {
|
||||
row := modelPreset(m)
|
||||
row["id"] = m.EffectiveName()
|
||||
items = append(items, row)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (h *UserGenerationHandler) publicModels() ([]gin.H, error) {
|
||||
items := []gin.H{
|
||||
{
|
||||
"id": "gpt-image-2",
|
||||
"provider": "chatgpt",
|
||||
"kind": "image",
|
||||
// See catalogEntries — ChatGPT only reliably does 1K and a limited
|
||||
// ratio set; matches the Python reference. Keep both lists in sync.
|
||||
"ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
|
||||
"resolutions": []string{"1K"},
|
||||
"description": "ChatGPT image generation",
|
||||
// publicModels — GET /api/models for the public site (docs/landing pickers).
|
||||
// Enabled models only, in the same shape as before minus the provider id.
|
||||
func (h *UserGenerationHandler) publicModels(c *gin.Context) ([]gin.H, error) {
|
||||
models, err := h.admin.Models(c.Request.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]gin.H, 0, len(models))
|
||||
for _, m := range models {
|
||||
if !m.Enabled {
|
||||
continue
|
||||
}
|
||||
items = append(items, gin.H{
|
||||
"id": m.EffectiveName(),
|
||||
"kind": m.Type,
|
||||
"type": m.Type,
|
||||
"ratios": repo.JSONStrings(m.Ratios),
|
||||
"resolutions": repo.JSONStrings(m.Resolutions),
|
||||
"durations": repo.JSONStrings(m.Durations),
|
||||
"description": m.Name,
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "firefly-gpt-image-2",
|
||||
"provider": "adobe",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:1", "5:4", "9:16", "21:9", "16:9", "4:3", "3:2", "4:5", "3:4", "2:3"},
|
||||
"resolutions": []string{"1K", "2K", "4K"},
|
||||
"description": "Adobe Firefly GPT Image",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "firefly-image-5",
|
||||
"provider": "adobe",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"},
|
||||
"resolutions": []string{"1K", "2K"},
|
||||
"description": "Adobe Firefly Image 5",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "runway-nano-banana-2",
|
||||
"provider": "runway",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
|
||||
"resolutions": []string{"1K", "2K", "4K"},
|
||||
"description": "Runway Nano Banana 2",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "runway-nano-banana-pro",
|
||||
"provider": "runway",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
|
||||
"resolutions": []string{"1K", "2K", "4K"},
|
||||
"description": "Runway Nano Banana Pro",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "nano-banana-2",
|
||||
"provider": "adobe",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
|
||||
"resolutions": []string{"1K", "2K", "4K"},
|
||||
"description": "Nano Banana 2",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "nano-banana-pro",
|
||||
"provider": "adobe",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
|
||||
"resolutions": []string{"1K", "2K", "4K"},
|
||||
"description": "Nano Banana Pro",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "gemini-veo3.1-fast",
|
||||
"provider": "adobe",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"description": "Veo 3.1 Fast",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "gemini-veo3.1",
|
||||
"provider": "adobe",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"description": "Veo 3.1",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "firefly-ray",
|
||||
"provider": "adobe",
|
||||
"kind": "video",
|
||||
"ratios": []string{"21:9", "16:9", "4:3", "1:1", "3:4", "9:16", "9:21"},
|
||||
"resolutions": []string{"720p"},
|
||||
"description": "Luma Ray video",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "firefly-video",
|
||||
"provider": "adobe",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "1:1", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"description": "Adobe Firefly Video",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "adobe-seedance-2.0-fast",
|
||||
"provider": "adobe",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"description": "Seedance 2.0 Fast",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "adobe-seedance-2.0",
|
||||
"provider": "adobe",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p", "1080p"},
|
||||
"description": "Seedance 2.0",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "seedance-2.0-fast",
|
||||
"provider": "creativefabrica",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"description": "Seedance 2.0 Fast (Creative Fabrica)",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "seedance-2.0",
|
||||
"provider": "creativefabrica",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"description": "Seedance 2.0 (Creative Fabrica)",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "runway-gen4-turbo",
|
||||
"provider": "runway",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"},
|
||||
"resolutions": []string{"720p"},
|
||||
"description": "Runway Gen-4 Turbo video",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "grok-video",
|
||||
"provider": "grok",
|
||||
"kind": "video",
|
||||
"ratios": []string{"2:3", "3:2", "1:1", "9:16", "16:9"},
|
||||
"resolutions": []string{"720p"},
|
||||
"description": "Grok Imagine video",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "grok-image",
|
||||
"provider": "grok",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9"},
|
||||
"resolutions": []string{"1K", "2K"},
|
||||
"description": "Grok Imagine image",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "seedream-4.5",
|
||||
"provider": "leonardo",
|
||||
"kind": "image",
|
||||
"ratios": []string{"2:3", "1:1", "16:9", "4:3", "4:5", "9:16", "2:1"},
|
||||
"resolutions": []string{"2K", "4K"},
|
||||
"description": "Leonardo Seedream 4.5",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "seedance-2.0-不卡人脸",
|
||||
"provider": "leonardo",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"description": "Leonardo Seedance 2.0 (私有生成)",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "seedance-2.0-fast-不卡人脸",
|
||||
"provider": "leonardo",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"720p"},
|
||||
"description": "Leonardo Seedance 2.0 Fast (私有生成)",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "minimax-h3",
|
||||
"provider": "leonardo",
|
||||
"kind": "video",
|
||||
"ratios": []string{"16:9", "9:16"},
|
||||
"resolutions": []string{"1440p"},
|
||||
"description": "Leonardo MiniMax H3 (1440p)",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "flux-klein-2",
|
||||
"provider": "krea",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:1", "4:3", "3:4", "16:9", "9:16"},
|
||||
"resolutions": []string{"1K", "2K"},
|
||||
"description": "Krea Flux Klein",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "imagine-1.5",
|
||||
"provider": "imagine",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:3", "9:16", "2:3", "3:4", "1:1", "4:3", "3:2", "16:9", "3:1"},
|
||||
"resolutions": []string{"2K"},
|
||||
"description": "Imagine 1.5",
|
||||
"stub": false,
|
||||
},
|
||||
{
|
||||
"id": "imagine-1.5pro",
|
||||
"provider": "imagine",
|
||||
"kind": "image",
|
||||
"ratios": []string{"1:3", "9:16", "2:3", "3:4", "1:1", "4:3", "3:2", "16:9", "3:1"},
|
||||
"resolutions": []string{"4K"},
|
||||
"description": "Imagine 1.5 Pro",
|
||||
"stub": false,
|
||||
},
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
+174
-332
@@ -1,19 +1,33 @@
|
||||
// Package handler — /v1/* 消费者 HTTP 接口(vivid 协议同款)。
|
||||
// 这套接口同时是渠道上游契约:同构实例经相同的四个端点互相对接。
|
||||
//
|
||||
// POST /v1/generate 创建生成任务(NDJSON 流:首帧 jobId → 终态帧;断流携 jobId 重发续等)
|
||||
// POST /v1/uploads 参考素材预签名直传(返回 key 随 generate 的 refs 提交)
|
||||
// GET /v1/models 可售模型裸数组(启用 + 有渠道绑定 + 全部档位已配价)
|
||||
// GET /v1/balance {balance}:余额积分
|
||||
//
|
||||
// 鉴权:Authorization: Bearer vk_...(用户 API Key,哈希落库)。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backend/internal/model"
|
||||
"backend/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// v1MaxWait 同步等待终态的上限。视频单渠道预算 30 分钟、最多换几个渠道,
|
||||
// 留足余量取 2 小时(与 vivid 一致)。
|
||||
const v1MaxWait = 2 * time.Hour
|
||||
|
||||
const v1PollInterval = time.Second
|
||||
|
||||
type V1Handler struct {
|
||||
v1 *service.V1Service
|
||||
}
|
||||
@@ -22,385 +36,213 @@ func NewV1Handler(v1 *service.V1Service) *V1Handler {
|
||||
return &V1Handler{v1: v1}
|
||||
}
|
||||
|
||||
func (h *V1Handler) Models(c *gin.Context) {
|
||||
func (h *V1Handler) auth(c *gin.Context) (*service.APIPrincipal, bool) {
|
||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
h.writeAuthError(c, err)
|
||||
return
|
||||
v1Err(c, http.StatusUnauthorized, "4011", "无效的 API Key")
|
||||
return nil, false
|
||||
}
|
||||
_ = principal
|
||||
return principal, true
|
||||
}
|
||||
|
||||
items, err := h.v1.ListModels(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load models"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"object": "list",
|
||||
"data": items,
|
||||
// v1Err writes the {"error":{"code","message"}} envelope — the wire shape every
|
||||
// vivid-protocol caller parses.
|
||||
func v1Err(c *gin.Context, httpStatus int, code, msg string) {
|
||||
c.JSON(httpStatus, gin.H{
|
||||
"error": gin.H{"code": code, "message": msg},
|
||||
})
|
||||
}
|
||||
|
||||
// UserBalance — GET /v1/user/balance. 返回 API Key 所属用户的账户级余额
|
||||
// (剩余 / 累计已用),与具体令牌无关。
|
||||
func (h *V1Handler) UserBalance(c *gin.Context) {
|
||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
h.writeAuthError(c, err)
|
||||
return
|
||||
// v1Fail maps a service error to the vivid code/HTTP status pair.
|
||||
func v1Fail(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrMissingAPIKey), errors.Is(err, service.ErrInvalidAPIKey):
|
||||
v1Err(c, http.StatusUnauthorized, "4011", "无效的 API Key")
|
||||
case errors.Is(err, service.ErrInsufficientFunds):
|
||||
v1Err(c, http.StatusBadRequest, "4002", "积分不足,请先充值")
|
||||
case errors.Is(err, service.ErrConcurrencyFull), errors.Is(err, service.ErrUserConcurrencyFull):
|
||||
v1Err(c, http.StatusTooManyRequests, "4293", "当前并发已满,请稍后重试")
|
||||
case errors.Is(err, service.ErrVideoJobNotFound):
|
||||
v1Err(c, http.StatusBadRequest, "4001", "任务不存在")
|
||||
case errors.Is(err, service.ErrReferenceTooLarge):
|
||||
v1Err(c, http.StatusRequestEntityTooLarge, "4001", "参考素材超过大小上限")
|
||||
case errors.Is(err, service.ErrUnknownModel):
|
||||
v1Err(c, http.StatusBadRequest, "4001", "模型不存在或未启用")
|
||||
case errors.Is(err, service.ErrNoProviderAccount):
|
||||
v1Err(c, http.StatusBadRequest, "4001", "模型无可用渠道")
|
||||
case errors.Is(err, service.ErrBannedPrompt):
|
||||
v1Err(c, http.StatusBadRequest, "4001", "提示词包含违禁内容")
|
||||
case errors.Is(err, service.ErrUnsupportedParams):
|
||||
v1Err(c, http.StatusBadRequest, "4001", err.Error())
|
||||
case errors.Is(err, service.ErrProviderTemporary), errors.Is(err, service.ErrProviderExecution),
|
||||
errors.Is(err, service.ErrProviderAuth):
|
||||
v1Err(c, http.StatusInternalServerError, "5001", "生成暂时不可用,积分已退还")
|
||||
default:
|
||||
v1Err(c, http.StatusBadRequest, "4001", err.Error())
|
||||
}
|
||||
resp, err := h.v1.UserBalance(c.Request.Context(), principal)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load balance"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// ImageGenerations — OpenAI POST /v1/images/generations (text-to-image only).
|
||||
// Accepts exactly OpenAI's fields; size→aspect ratio and quality→resolution tier
|
||||
// are mapped server-side. Returns {created, data:[{b64_json}]}.
|
||||
func (h *V1Handler) ImageGenerations(c *gin.Context) {
|
||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
h.writeAuthError(c, err)
|
||||
// ---- POST /v1/generate ----
|
||||
|
||||
func (h *V1Handler) Generate(c *gin.Context) {
|
||||
principal, ok := h.auth(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
N int `json:"n"`
|
||||
Size string `json:"size"`
|
||||
Quality string `json:"quality"`
|
||||
ResponseFormat string `json:"response_format"`
|
||||
Background string `json:"background"`
|
||||
OutputFormat string `json:"output_format"`
|
||||
User string `json:"user"`
|
||||
}
|
||||
var body service.V1JobRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
v1Err(c, http.StatusBadRequest, "4001", "请求体不是合法 JSON")
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.v1.PrepareImageRequest(c.Request.Context(), principal, service.V1ImageRequest{
|
||||
Model: body.Model,
|
||||
Prompt: body.Prompt,
|
||||
N: body.N,
|
||||
Size: body.Size,
|
||||
BaseURL: requestBaseURL(c),
|
||||
})
|
||||
if err != nil {
|
||||
h.writeV1Error(c, err, resp)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, openaiImageResponse(resp))
|
||||
}
|
||||
|
||||
// ImageEdits — OpenAI POST /v1/images/edits (image-to-image). multipart/form-data
|
||||
// only: image / image[] file uploads (+ optional mask), prompt, model, n, size,
|
||||
// quality. Files become reference images. Returns {created, data:[{b64_json}]}.
|
||||
func (h *V1Handler) ImageEdits(c *gin.Context) {
|
||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
h.writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(c.GetHeader("Content-Type"), "multipart/form-data") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "images/edits requires multipart/form-data"})
|
||||
return
|
||||
}
|
||||
if err := c.Request.ParseMultipartForm(64 << 20); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid multipart form"})
|
||||
return
|
||||
}
|
||||
refs := readMultipartImages(c, "image", "image[]")
|
||||
if len(refs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "images/edits requires at least one image file"})
|
||||
return
|
||||
}
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("n")))
|
||||
resp, err := h.v1.PrepareImageRequest(c.Request.Context(), principal, service.V1ImageRequest{
|
||||
Model: c.PostForm("model"),
|
||||
Prompt: c.PostForm("prompt"),
|
||||
N: n,
|
||||
Size: c.PostForm("size"),
|
||||
ReferenceImages: refs,
|
||||
BaseURL: requestBaseURL(c),
|
||||
})
|
||||
if err != nil {
|
||||
h.writeV1Error(c, err, resp)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, openaiImageResponse(resp))
|
||||
}
|
||||
|
||||
// CreateVideo — OpenAI POST /v1/videos. Creates an async job and returns the
|
||||
// video object immediately ({id, status:"queued"}). Accepts JSON {model, prompt,
|
||||
// seconds, size} or multipart (with an input_reference file). size→ratio+
|
||||
// resolution, seconds→duration.
|
||||
func (h *V1Handler) CreateVideo(c *gin.Context) {
|
||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
h.writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
var modelID, prompt, seconds, size, referenceMode string
|
||||
var refs []string
|
||||
if strings.HasPrefix(c.GetHeader("Content-Type"), "multipart/form-data") {
|
||||
if err := c.Request.ParseMultipartForm(256 << 20); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid multipart form"})
|
||||
return
|
||||
}
|
||||
modelID = c.PostForm("model")
|
||||
prompt = c.PostForm("prompt")
|
||||
seconds = c.PostForm("seconds")
|
||||
size = c.PostForm("size")
|
||||
referenceMode = c.PostForm("reference_mode")
|
||||
refs = readMultipartImages(c, "input_reference", "input_reference[]")
|
||||
var ev *model.EventLog
|
||||
var err error
|
||||
// 重连模式:只带 jobId → 不建新任务,回读该任务并等待终态。
|
||||
if body.JobID != nil && strings.TrimSpace(*body.JobID) != "" {
|
||||
ev, err = h.v1.JobForUser(c.Request.Context(), principal, *body.JobID)
|
||||
} else {
|
||||
var body struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
Seconds json.RawMessage `json:"seconds"`
|
||||
Size string `json:"size"`
|
||||
ReferenceMode string `json:"reference_mode"`
|
||||
InputReference []string `json:"input_reference"`
|
||||
ReferenceImages []string `json:"reference_images"`
|
||||
ev, err = h.v1.CreateJob(c.Request.Context(), principal, body)
|
||||
}
|
||||
if err != nil {
|
||||
v1Fail(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
wait := 300 * time.Second
|
||||
if h := strings.TrimSpace(c.GetHeader("X-Wait-Seconds")); h != "" {
|
||||
if sec, serr := strconv.Atoi(h); serr == nil && sec > 0 && time.Duration(sec)*time.Second <= v1MaxWait {
|
||||
wait = time.Duration(sec) * time.Second
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Writer.Header().Set("Content-Type", "application/x-ndjson")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache, no-transform")
|
||||
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
||||
c.Writer.WriteHeader(http.StatusOK)
|
||||
flusher, _ := c.Writer.(http.Flusher)
|
||||
|
||||
writeFrame := func(v *model.EventLog) bool {
|
||||
frame, _ := json.Marshal(v1JobBody(h.v1, v))
|
||||
if _, werr := c.Writer.Write(append(frame, '\n')); werr != nil {
|
||||
return false
|
||||
}
|
||||
modelID, prompt, size = body.Model, body.Prompt, body.Size
|
||||
seconds = rawToString(body.Seconds)
|
||||
referenceMode = strings.TrimSpace(body.ReferenceMode)
|
||||
refs = append(body.InputReference, body.ReferenceImages...)
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
return true
|
||||
}
|
||||
duration := strings.TrimSpace(seconds)
|
||||
if duration != "" && !strings.HasSuffix(duration, "s") {
|
||||
duration += "s"
|
||||
}
|
||||
aspect, resolution := videoSizeToInternal(size)
|
||||
resp, err := h.v1.StartVideoJob(c.Request.Context(), principal, service.V1VideoRequest{
|
||||
Model: modelID,
|
||||
Prompt: prompt,
|
||||
Duration: duration,
|
||||
AspectRatio: aspect,
|
||||
Resolution: resolution,
|
||||
ReferenceImages: refs,
|
||||
ReferenceMode: referenceMode,
|
||||
BaseURL: requestBaseURL(c),
|
||||
})
|
||||
if err != nil {
|
||||
h.writeV1Error(c, err, nil)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GetVideo — OpenAI GET /v1/videos/{id}. Returns the job status object.
|
||||
func (h *V1Handler) GetVideo(c *gin.Context) {
|
||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
h.writeAuthError(c, err)
|
||||
// 已终态:直接回终态帧关流。
|
||||
if st := service.JobStatus(ev); st == "succeeded" || st == "failed" {
|
||||
writeFrame(ev)
|
||||
return
|
||||
}
|
||||
resp, err := h.v1.VideoJob(c.Request.Context(), principal, c.Param("id"))
|
||||
if err != nil {
|
||||
h.writeV1Error(c, err, nil)
|
||||
// 连上即回执 jobId(断流后携它重发的凭据)。
|
||||
if !writeFrame(ev) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GetVideoContent — OpenAI GET /v1/videos/{id}/content. Streams the rendered mp4
|
||||
// by proxying the stored upstream URL (downloaded on demand, never persisted).
|
||||
func (h *V1Handler) GetVideoContent(c *gin.Context) {
|
||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
h.writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
body, contentType, err := h.v1.OpenVideoContent(c.Request.Context(), principal, c.Param("id"))
|
||||
if err != nil {
|
||||
h.writeV1Error(c, err, nil)
|
||||
return
|
||||
}
|
||||
defer body.Close()
|
||||
c.Header("Content-Type", contentType)
|
||||
c.Status(http.StatusOK)
|
||||
_, _ = io.Copy(c.Writer, body)
|
||||
}
|
||||
|
||||
// GetImageContent — GET /v1/images/{id}/content. Streams a no-store image by
|
||||
// proxying its stored (possibly auth-gated) upstream URL. Never persisted.
|
||||
func (h *V1Handler) GetImageContent(c *gin.Context) {
|
||||
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
h.writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
body, contentType, err := h.v1.OpenImageContent(c.Request.Context(), principal, c.Param("id"))
|
||||
if err != nil {
|
||||
h.writeV1Error(c, err, nil)
|
||||
return
|
||||
}
|
||||
defer body.Close()
|
||||
c.Header("Content-Type", contentType)
|
||||
c.Status(http.StatusOK)
|
||||
_, _ = io.Copy(c.Writer, body)
|
||||
}
|
||||
|
||||
// readMultipartImages reads the given file fields and returns each as base64.
|
||||
func readMultipartImages(c *gin.Context, keys ...string) []string {
|
||||
var out []string
|
||||
form := c.Request.MultipartForm
|
||||
if form == nil {
|
||||
return out
|
||||
}
|
||||
for _, key := range keys {
|
||||
for _, fh := range form.File[key] {
|
||||
f, e := fh.Open()
|
||||
if e != nil {
|
||||
deadline := time.Now().Add(wait)
|
||||
poll := time.NewTicker(v1PollInterval)
|
||||
heartbeat := time.NewTicker(25 * time.Second)
|
||||
defer poll.Stop()
|
||||
defer heartbeat.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-c.Request.Context().Done():
|
||||
return // client gone — the job keeps running; caller may reconnect with jobId
|
||||
case <-poll.C:
|
||||
latest, lerr := h.v1.JobForUser(c.Request.Context(), principal, ev.ID)
|
||||
if lerr != nil || latest == nil {
|
||||
continue
|
||||
}
|
||||
b, _ := io.ReadAll(io.LimitReader(f, 20<<20+1))
|
||||
f.Close()
|
||||
if len(b) > 0 {
|
||||
out = append(out, base64.StdEncoding.EncodeToString(b))
|
||||
ev = latest
|
||||
if st := service.JobStatus(ev); st == "succeeded" || st == "failed" {
|
||||
writeFrame(ev)
|
||||
return
|
||||
}
|
||||
case <-heartbeat.C:
|
||||
if _, werr := c.Writer.WriteString("\n"); werr != nil {
|
||||
return
|
||||
}
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
case <-time.After(time.Until(deadline)):
|
||||
writeFrame(ev) // snapshot; client reconnects with jobId
|
||||
return
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rawToString accepts OpenAI's `seconds` whether sent as a JSON string or number.
|
||||
func rawToString(raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
// v1JobBody assembles one NDJSON frame (running receipt and terminal frame
|
||||
// share the shape; succeeded adds result+creditsCharged, failed adds error).
|
||||
func v1JobBody(v1 *service.V1Service, ev *model.EventLog) map[string]any {
|
||||
body := map[string]any{"jobId": ev.ID, "status": service.JobStatus(ev)}
|
||||
switch service.JobStatus(ev) {
|
||||
case "succeeded":
|
||||
body["result"] = v1.JobResultURL(ev)
|
||||
body["creditsCharged"] = ev.Cost
|
||||
case "failed":
|
||||
body["error"] = ev.Error
|
||||
body["refunded"] = true
|
||||
}
|
||||
var s string
|
||||
if json.Unmarshal(raw, &s) == nil {
|
||||
return s
|
||||
}
|
||||
var n json.Number
|
||||
if json.Unmarshal(raw, &n) == nil {
|
||||
return n.String()
|
||||
}
|
||||
return strings.Trim(string(raw), `"`)
|
||||
return body
|
||||
}
|
||||
|
||||
// videoSizeToInternal maps OpenAI's "WxH" size to our aspect ratio + resolution
|
||||
// tier. An absent/unparsable size leaves the resolution empty so the caller can
|
||||
// fall back to whatever tier the model actually prices — hardcoding 720p here
|
||||
// rejects models that only offer 1440p.
|
||||
func videoSizeToInternal(size string) (ratio, resolution string) {
|
||||
var w, h int
|
||||
if s := strings.TrimSpace(strings.ToLower(size)); s != "" {
|
||||
_, _ = fmt.Sscanf(s, "%dx%d", &w, &h)
|
||||
// ---- POST /v1/uploads ----
|
||||
|
||||
func (h *V1Handler) Uploads(c *gin.Context) {
|
||||
principal, ok := h.auth(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if w == 0 || h == 0 {
|
||||
return "16:9", ""
|
||||
var body struct {
|
||||
Kind string `json:"kind"`
|
||||
Mime string `json:"mime"`
|
||||
}
|
||||
// The "p" resolution is the SHORT edge (720p = 1280×720, 1080p = 1920×1080),
|
||||
// so a standard 1280×720 must read as 720p — not 1080p off the long edge.
|
||||
resolution = "720p"
|
||||
switch {
|
||||
case min(w, h) >= 1440:
|
||||
resolution = "1440p"
|
||||
case min(w, h) >= 1080:
|
||||
resolution = "1080p"
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
v1Err(c, http.StatusBadRequest, "4001", "请求体不是合法 JSON")
|
||||
return
|
||||
}
|
||||
return guessRatioWH(w, h), resolution
|
||||
view, err := h.v1.PresignRefUpload(c.Request.Context(), principal, body.Kind, body.Mime)
|
||||
if err != nil {
|
||||
v1Fail(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, view)
|
||||
}
|
||||
|
||||
func guessRatioWH(w, h int) string {
|
||||
if w == h {
|
||||
return "1:1"
|
||||
// ---- GET /v1/models ----
|
||||
|
||||
func (h *V1Handler) Models(c *gin.Context) {
|
||||
if _, ok := h.auth(c); !ok {
|
||||
return
|
||||
}
|
||||
r := float64(w) / float64(h)
|
||||
cands := []struct {
|
||||
name string
|
||||
v float64
|
||||
}{{"16:9", 16.0 / 9}, {"9:16", 9.0 / 16}, {"4:3", 4.0 / 3}, {"3:4", 3.0 / 4}, {"1:1", 1}}
|
||||
best, bestD := "16:9", 1e9
|
||||
for _, cd := range cands {
|
||||
d := r - cd.v
|
||||
if d < 0 {
|
||||
d = -d
|
||||
}
|
||||
if d < bestD {
|
||||
best, bestD = cd.name, d
|
||||
}
|
||||
items, err := h.v1.SellableModels(c.Request.Context())
|
||||
if err != nil {
|
||||
v1Err(c, http.StatusInternalServerError, "5001", "模型加载失败")
|
||||
return
|
||||
}
|
||||
return best
|
||||
c.JSON(http.StatusOK, items)
|
||||
}
|
||||
|
||||
// openaiImageResponse strips our rich internal map down to OpenAI's image shape.
|
||||
func openaiImageResponse(m map[string]any) gin.H {
|
||||
out := gin.H{"created": m["created"]}
|
||||
if d, ok := m["data"]; ok && d != nil {
|
||||
out["data"] = d
|
||||
} else {
|
||||
out["data"] = []any{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
// ---- GET /v1/balance ----
|
||||
|
||||
func (h *V1Handler) writeAuthError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrMissingAPIKey):
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrInvalidAPIKey):
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to validate api key"})
|
||||
func (h *V1Handler) UserBalance(c *gin.Context) {
|
||||
principal, ok := h.auth(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h *V1Handler) writeV1Error(c *gin.Context, err error, payload map[string]any) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrUnknownModel):
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrUnsupportedParams), errors.Is(err, service.ErrBannedPrompt):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrInsufficientFunds):
|
||||
c.JSON(http.StatusPaymentRequired, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrReferenceTooLarge):
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrNoProviderAccount):
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrProviderAuth):
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrProviderQuota):
|
||||
// Match the Python contract: provider quota exhaustion maps to 401
|
||||
// (QuotaExhaustedError is handled alongside AuthError in routes.py).
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrProviderTemporary):
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrConcurrencyFull), errors.Is(err, service.ErrUserConcurrencyFull):
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrVideoJobNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrVideoNotReady):
|
||||
c.JSON(http.StatusConflict, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrProviderUnsupported):
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrProviderExecution):
|
||||
c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrGenerationPending):
|
||||
c.JSON(http.StatusNotImplemented, payload)
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
bal, err := h.v1.UserBalance(c.Request.Context(), principal)
|
||||
if err != nil {
|
||||
v1Err(c, http.StatusInternalServerError, "5001", "余额查询失败")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"balance": bal})
|
||||
}
|
||||
|
||||
// requestBaseURL derives the scheme+host of the inbound request so the service
|
||||
// layer can build absolute, directly-downloadable output URLs. Honors
|
||||
// X-Forwarded-Proto (reverse-proxy / TLS termination) before falling back to
|
||||
// the connection's TLS state. Returns "" when the host is unknown, which makes
|
||||
// the service fall back to a relative path.
|
||||
// layer can build absolute /images URLs for the session path.
|
||||
func requestBaseURL(c *gin.Context) string {
|
||||
host := c.Request.Host
|
||||
if host == "" {
|
||||
|
||||
@@ -11,25 +11,25 @@ import (
|
||||
)
|
||||
|
||||
type Handlers struct {
|
||||
Health *handler.HealthHandler
|
||||
Images *handler.ImageHandler
|
||||
V1 *handler.V1Handler
|
||||
Site *handler.SiteHandler
|
||||
Showcase *handler.ShowcaseHandler
|
||||
Auth *handler.AuthHandler
|
||||
SiteSettings *handler.SiteSettingsHandler
|
||||
AppSettings *handler.AppSettingsHandler
|
||||
AdminRead *handler.AdminReadHandler
|
||||
AdminWrite *handler.AdminWriteHandler
|
||||
CDK *handler.CDKHandler
|
||||
UserTools *handler.UserToolsHandler
|
||||
UserGen *handler.UserGenerationHandler
|
||||
ProviderAdmin *handler.ProviderAdminHandler
|
||||
ConcGroups *handler.ConcurrencyGroupHandler
|
||||
Announcement *handler.AnnouncementHandler
|
||||
Payment *handler.PaymentHandler
|
||||
BannedWords *handler.BannedWordsHandler
|
||||
Canvas *handler.CanvasHandler
|
||||
Health *handler.HealthHandler
|
||||
Images *handler.ImageHandler
|
||||
V1 *handler.V1Handler
|
||||
Site *handler.SiteHandler
|
||||
Showcase *handler.ShowcaseHandler
|
||||
Auth *handler.AuthHandler
|
||||
SiteSettings *handler.SiteSettingsHandler
|
||||
AppSettings *handler.AppSettingsHandler
|
||||
AdminRead *handler.AdminReadHandler
|
||||
AdminWrite *handler.AdminWriteHandler
|
||||
CDK *handler.CDKHandler
|
||||
UserTools *handler.UserToolsHandler
|
||||
UserGen *handler.UserGenerationHandler
|
||||
ChannelAdmin *handler.ChannelAdminHandler
|
||||
ConcGroups *handler.ConcurrencyGroupHandler
|
||||
Announcement *handler.AnnouncementHandler
|
||||
Payment *handler.PaymentHandler
|
||||
BannedWords *handler.BannedWordsHandler
|
||||
Canvas *handler.CanvasHandler
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.Engine {
|
||||
@@ -59,16 +59,12 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
|
||||
|
||||
engine.GET("/health", handlers.Health.Handle)
|
||||
engine.GET("/images/:user/:name", handlers.Images.Serve)
|
||||
// vivid-protocol public API — same four endpoints a vivid instance exposes,
|
||||
// which is what lets this service act as a channel upstream itself.
|
||||
engine.POST("/v1/generate", handlers.V1.Generate)
|
||||
engine.POST("/v1/uploads", handlers.V1.Uploads)
|
||||
engine.GET("/v1/models", handlers.V1.Models)
|
||||
engine.GET("/v1/user/balance", handlers.V1.UserBalance)
|
||||
engine.POST("/v1/images/generations", handlers.V1.ImageGenerations)
|
||||
engine.POST("/v1/images/edits", handlers.V1.ImageEdits)
|
||||
// OpenAI Sora-style async video: create job → poll → stream content.
|
||||
engine.POST("/v1/videos", handlers.V1.CreateVideo)
|
||||
engine.GET("/v1/videos/:id", handlers.V1.GetVideo)
|
||||
engine.GET("/v1/videos/:id/content", handlers.V1.GetVideoContent)
|
||||
// No-store image content proxy (auth-gated upstream URLs, e.g. chatgpt).
|
||||
engine.GET("/v1/images/:id/content", handlers.V1.GetImageContent)
|
||||
engine.GET("/v1/balance", handlers.V1.UserBalance)
|
||||
|
||||
publicAdmin := engine.Group("/admin/api")
|
||||
{
|
||||
@@ -79,7 +75,6 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
|
||||
// owner names is admin-only, see /stats/recent below.
|
||||
publicAdmin.GET("/stats", handlers.AdminRead.Stats)
|
||||
publicAdmin.GET("/video-presets", handlers.UserGen.VideoPresets)
|
||||
publicAdmin.GET("/catalog", handlers.UserGen.Catalog)
|
||||
publicAdmin.GET("/models", handlers.UserGen.Models)
|
||||
publicAdmin.GET("/deai-pricing", handlers.AppSettings.DeAIGet)
|
||||
// 易支付 async notify — called server-to-server by the pay platform, no auth.
|
||||
@@ -105,6 +100,7 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
|
||||
userAuthed.POST("/test", handlers.UserGen.Test)
|
||||
userAuthed.GET("/jobs/mine", handlers.UserGen.MyJobs)
|
||||
userAuthed.GET("/my-images", handlers.UserGen.MyImages)
|
||||
userAuthed.POST("/my-images", handlers.UserGen.UploadMyImage)
|
||||
userAuthed.DELETE("/my-files", handlers.UserGen.DeleteMyFile)
|
||||
userAuthed.GET("/announcement", handlers.Announcement.Get)
|
||||
userAuthed.POST("/announcement/seen", handlers.Announcement.MarkSeen)
|
||||
@@ -146,23 +142,11 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
|
||||
authed.POST("/cdks", handlers.CDK.Create)
|
||||
authed.POST("/cdks/delete-bulk", handlers.CDK.DeleteBulk)
|
||||
authed.DELETE("/cdks/:code", handlers.CDK.Delete)
|
||||
authed.GET("/tokens", handlers.ProviderAdmin.TokensList)
|
||||
authed.POST("/tokens", handlers.ProviderAdmin.TokensCreate)
|
||||
authed.POST("/tokens/import-chatgpt-token", handlers.ProviderAdmin.ImportChatGPTToken)
|
||||
authed.POST("/tokens/import-adobe-cookie", handlers.ProviderAdmin.ImportAdobeCookie)
|
||||
authed.POST("/tokens/import-creativefabrica-cookie", handlers.ProviderAdmin.ImportCreativeFabricaCookie)
|
||||
authed.POST("/tokens/import-runway-token", handlers.ProviderAdmin.ImportRunwayToken)
|
||||
authed.POST("/tokens/import-leonardo-cookie", handlers.ProviderAdmin.ImportLeonardoCookie)
|
||||
authed.POST("/tokens/import-krea-cookie", handlers.ProviderAdmin.ImportKreaCookie)
|
||||
authed.POST("/tokens/import-imagine-token", handlers.ProviderAdmin.ImportImagineToken)
|
||||
authed.POST("/tokens/import-grok-token", handlers.ProviderAdmin.ImportGrokToken)
|
||||
authed.POST("/tokens/import-custom-account", handlers.ProviderAdmin.ImportCustomAccount)
|
||||
authed.POST("/tokens/delete-bulk", handlers.ProviderAdmin.TokenDeleteBulk)
|
||||
authed.PATCH("/tokens/:pool/:id", handlers.ProviderAdmin.TokenUpdate)
|
||||
authed.DELETE("/tokens/:pool/:id", handlers.ProviderAdmin.TokenDelete)
|
||||
authed.GET("/accounts", handlers.ProviderAdmin.AccountsList)
|
||||
authed.GET("/accounts/:pool/:id/quota", handlers.ProviderAdmin.AccountQuota)
|
||||
authed.GET("/accounts/:pool/:id/email", handlers.ProviderAdmin.AccountEmail)
|
||||
authed.GET("/channels", handlers.ChannelAdmin.List)
|
||||
authed.POST("/channels", handlers.ChannelAdmin.Save)
|
||||
authed.POST("/channels/delete", handlers.ChannelAdmin.Delete)
|
||||
authed.POST("/channels/fetch-models", handlers.ChannelAdmin.FetchModels)
|
||||
authed.POST("/channels/import-models", handlers.ChannelAdmin.ImportModels)
|
||||
authed.GET("/providers", handlers.AdminRead.Providers)
|
||||
authed.GET("/images", handlers.AdminRead.Images)
|
||||
authed.DELETE("/images", handlers.AdminRead.DeleteImage)
|
||||
@@ -171,10 +155,6 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
|
||||
authed.POST("/banned-words/import", handlers.BannedWords.Import)
|
||||
authed.DELETE("/banned-words/:id", handlers.BannedWords.Delete)
|
||||
authed.GET("/banned-word-hits", handlers.BannedWords.Hits)
|
||||
authed.GET("/refresh/profiles", handlers.ProviderAdmin.RefreshProfiles)
|
||||
authed.POST("/refresh/profiles/:profile_id/refresh-now", handlers.ProviderAdmin.RefreshNow)
|
||||
authed.PATCH("/refresh/profiles/:profile_id", handlers.ProviderAdmin.RefreshUpdate)
|
||||
authed.DELETE("/refresh/profiles/:profile_id", handlers.ProviderAdmin.RefreshDelete)
|
||||
authed.POST("/managed-models", handlers.AdminWrite.CreateModel)
|
||||
authed.PATCH("/managed-models/:model_id", handlers.AdminWrite.UpdateModel)
|
||||
authed.DELETE("/managed-models/:model_id", handlers.AdminWrite.DeleteModel)
|
||||
|
||||
@@ -95,19 +95,20 @@ type EventLog struct {
|
||||
Duration string `gorm:"size:32"`
|
||||
Refs int `gorm:"not null;default:0"`
|
||||
DeAI bool `gorm:"not null;default:false"` // 去AI特征 was applied (image only)
|
||||
RefFiles datatypes.JSON `gorm:"type:jsonb"` // relative paths of saved reference images, for回显 on reload
|
||||
RefFiles datatypes.JSON `gorm:"type:jsonb"` // relative paths of saved reference images, for回显 on reload
|
||||
Source string `gorm:"size:32;index"`
|
||||
// AccountID is the provider token/account chosen to fulfil this generation,
|
||||
// stamped when the upstream call begins. Drives the accounts view's live
|
||||
// in-flight count (pending events per account) and lets an abandoned-event
|
||||
// purge attribute the failure back to the account it was using.
|
||||
// AccountID is the channel chosen to fulfil this generation, stamped when
|
||||
// the upstream call begins. (Field name kept from the pre-channel schema.)
|
||||
AccountID string `gorm:"size:64;index"`
|
||||
// AccountEmail denormalizes the account's email at stamp time, so log rows
|
||||
// keep showing which mailbox fulfilled the generation even after the account
|
||||
// is deleted or re-imported under a different ID.
|
||||
AccountEmail string `gorm:"size:255"`
|
||||
UserID string `gorm:"size:32;index"`
|
||||
Cost float64 `gorm:"not null;default:0"`
|
||||
// AccountEmail denormalizes the channel's display name at stamp time, so log
|
||||
// rows keep showing which channel fulfilled the generation even after the
|
||||
// channel is deleted or renamed.
|
||||
AccountEmail string `gorm:"size:255"`
|
||||
// UpstreamJob is the job id assigned by the upstream vivid instance (from
|
||||
// the NDJSON stream's jobId field) — kept for cross-instance debugging.
|
||||
UpstreamJob string `gorm:"size:64"`
|
||||
UserID string `gorm:"size:32;index"`
|
||||
Cost float64 `gorm:"not null;default:0"`
|
||||
// Refunded marks that this event's up-front charge has already been credited
|
||||
// back, so the normal failure path and the abandoned-purge sweep can never
|
||||
// double-refund the same generation.
|
||||
@@ -138,12 +139,20 @@ type ModelConfig struct {
|
||||
PricesAgent datatypes.JSONMap `gorm:"type:jsonb;column:prices_agent"`
|
||||
DurationPricesAgent datatypes.JSONMap `gorm:"type:jsonb;column:duration_prices_agent"`
|
||||
Durations datatypes.JSON `gorm:"type:jsonb"`
|
||||
MaxReferenceImages int `gorm:"not null;default:0"`
|
||||
ReferenceMode string `gorm:"size:32;not null;default:'none'"`
|
||||
// Custom-upstream models (provider="custom"): UpstreamModel is the model name
|
||||
// sent to the upstream OpenAI-compatible API; the base_url + key live on the
|
||||
// matching custom account (pool="custom", meta.base_url). Empty for built-ins.
|
||||
UpstreamModel string `gorm:"size:255;not null;default:''"`
|
||||
// DurationMin/DurationMax bound a free-form duration (seconds) for models
|
||||
// whose duration spec is a range rather than discrete options. 0 = unset.
|
||||
DurationMin int `gorm:"not null;default:0"`
|
||||
DurationMax int `gorm:"not null;default:0"`
|
||||
MaxReferenceImages int `gorm:"not null;default:0"`
|
||||
// Per-kind reference caps for upstreams that accept video/audio refs.
|
||||
// 0 = that kind is not accepted.
|
||||
MaxReferenceVideos int `gorm:"not null;default:0"`
|
||||
MaxReferenceAudios int `gorm:"not null;default:0"`
|
||||
// MaxRefVideoSeconds/MaxRefAudioSeconds cap the combined duration of
|
||||
// reference videos/audios. 0 = unlimited.
|
||||
MaxRefVideoSeconds int `gorm:"not null;default:0"`
|
||||
MaxRefAudioSeconds int `gorm:"not null;default:0"`
|
||||
ReferenceMode string `gorm:"size:32;not null;default:'none'"`
|
||||
// Weight controls display order in the model dropdown / admin list: higher
|
||||
// weight floats to the top (matches ShowcaseItem.Weight semantics). Ties fall
|
||||
// back to created_at desc. Default 0.
|
||||
@@ -176,55 +185,37 @@ type CDKCode struct {
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type TokenAccount struct {
|
||||
ID string `gorm:"primaryKey;size:64"`
|
||||
Pool string `gorm:"size:64;index;not null"`
|
||||
Value string `gorm:"type:text"`
|
||||
Status string `gorm:"size:32;index;not null"`
|
||||
Fails int `gorm:"not null;default:0"`
|
||||
FailTotal int `gorm:"not null;default:0"`
|
||||
SuccessTotal int `gorm:"not null;default:0"`
|
||||
Dead bool `gorm:"not null;default:false"`
|
||||
Meta datatypes.JSONMap `gorm:"type:jsonb"`
|
||||
AddedAt *time.Time
|
||||
LastUsedAt *time.Time
|
||||
CachedQuotaResetAfter string `gorm:"size:128"`
|
||||
QuotaRecoverAt *time.Time
|
||||
// Adobe quota is tracked separately for image vs video. An account only
|
||||
// enters the shared "quota" waiting status when BOTH are limited; a single
|
||||
// limit leaves the account usable for the other kind. Recovery time is shared
|
||||
// (QuotaRecoverAt / CachedQuotaResetAfter) since Adobe resets both at once.
|
||||
ImageLimited bool `gorm:"not null;default:false"`
|
||||
VideoLimited bool `gorm:"not null;default:false"`
|
||||
AccountEmail string `gorm:"size:255"`
|
||||
AccountDisplayName string `gorm:"size:255"`
|
||||
// Weight biases scheduling order for ANY account — higher weight is picked
|
||||
// first within its pool (ties fall back to round-robin). Default 0.
|
||||
Weight int `gorm:"not null;default:0"`
|
||||
// Concurrency is the max simultaneous jobs for THIS account. Only custom
|
||||
// (upstream) accounts honor it; built-in pools use their system default
|
||||
// (1 per account, grok 10). 0 = use the system default.
|
||||
// Channel is one upstream vivid-protocol instance this gateway routes
|
||||
// generation to. Replaces the old per-provider token account pool: a channel
|
||||
// is just a base_url + api_key plus routing knobs (enabled/weight/concurrency)
|
||||
// and local usage counters. ApiKey is stored AES-GCM encrypted.
|
||||
type Channel struct {
|
||||
ID string `gorm:"primaryKey;size:40"`
|
||||
Name string `gorm:"size:255;not null"`
|
||||
BaseURL string `gorm:"size:500;not null"`
|
||||
APIKeyEnc string `gorm:"type:text;not null"` // AES-GCM encrypted upstream API key
|
||||
Enabled bool `gorm:"not null;default:true;index"`
|
||||
// Concurrency caps simultaneous in-flight jobs on this channel. 0 = default.
|
||||
Concurrency int `gorm:"not null;default:0"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
// Weight biases weighted-random channel order: weight 2 is picked first at
|
||||
// roughly 2x the rate of weight 1.
|
||||
Weight int `gorm:"not null;default:1"`
|
||||
SuccessTotal int64 `gorm:"not null;default:0"` // locally-tracked successful generations
|
||||
UsedCredits float64 `gorm:"not null;default:0"` // upstream credits spent (reported by the upstream)
|
||||
LastUsedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type RefreshProfile struct {
|
||||
ID string `gorm:"primaryKey;size:64"`
|
||||
Name string `gorm:"size:255;not null"`
|
||||
Pool string `gorm:"size:64;index;not null"`
|
||||
Kind string `gorm:"size:64;index;not null"`
|
||||
Cookie string `gorm:"type:text"`
|
||||
Enabled bool `gorm:"not null;default:true"`
|
||||
IntervalSeconds int `gorm:"not null;default:54000"`
|
||||
ImportedAt *time.Time
|
||||
LastAttemptAt *time.Time
|
||||
LastSuccessAt *time.Time
|
||||
LastError string `gorm:"type:text"`
|
||||
NextRetryAt *time.Time
|
||||
ConsecutiveFailures int `gorm:"not null;default:0"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
// ChannelModel binds a local model_configs row to a channel and records the
|
||||
// upstream model name to send on the wire (the local row's alias stays the
|
||||
// user-facing name).
|
||||
type ChannelModel struct {
|
||||
ChannelID string `gorm:"primaryKey;size:40"`
|
||||
ModelID string `gorm:"primaryKey;size:255;index"`
|
||||
UpstreamModel string `gorm:"size:255;not null"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type SiteSetting struct {
|
||||
@@ -244,8 +235,8 @@ func AutoMigrateModels() []any {
|
||||
&EventLog{},
|
||||
&ModelConfig{},
|
||||
&CDKCode{},
|
||||
&TokenAccount{},
|
||||
&RefreshProfile{},
|
||||
&Channel{},
|
||||
&ChannelModel{},
|
||||
&SiteSetting{},
|
||||
&StatCounter{},
|
||||
&ConcurrencyGroup{},
|
||||
@@ -263,15 +254,15 @@ func AutoMigrateModels() []any {
|
||||
type Order struct {
|
||||
ID string `gorm:"primaryKey;size:40"`
|
||||
UserID string `gorm:"size:32;index;not null"`
|
||||
Amount float64 `gorm:"not null"` // 充值金额(元)
|
||||
Points int `gorm:"not null"` // 到账积分
|
||||
PayType string `gorm:"size:16"` // wxpay | alipay | admin | cdk
|
||||
Status string `gorm:"size:16;index;not null"` // pending | paid | cancelled
|
||||
Amount float64 `gorm:"not null"` // 充值金额(元)
|
||||
Points int `gorm:"not null"` // 到账积分
|
||||
PayType string `gorm:"size:16"` // wxpay | alipay | admin | cdk
|
||||
Status string `gorm:"size:16;index;not null"` // pending | paid | cancelled
|
||||
Source string `gorm:"size:16;index;not null;default:'epay'"` // epay | admin | cdk
|
||||
Remark string `gorm:"type:text"` // e.g. 兑换码 code / 管理员操作说明
|
||||
TradeNo string `gorm:"size:64;index"` // 易支付平台订单号
|
||||
PayInfo string `gorm:"type:text"` // 二维码 url / 跳转 url
|
||||
PayInfoType string `gorm:"size:16"` // qrcode | jump | html | ...
|
||||
Remark string `gorm:"type:text"` // e.g. 兑换码 code / 管理员操作说明
|
||||
TradeNo string `gorm:"size:64;index"` // 易支付平台订单号
|
||||
PayInfo string `gorm:"type:text"` // 二维码 url / 跳转 url
|
||||
PayInfoType string `gorm:"size:16"` // qrcode | jump | html | ...
|
||||
ExpiresAt time.Time `gorm:"index"`
|
||||
PaidAt *time.Time
|
||||
CreatedAt time.Time
|
||||
@@ -294,10 +285,10 @@ type ConcurrencyGroup struct {
|
||||
// node/edge graph as a versioned JSON blob; saves are whole-document
|
||||
// replacements (last write wins), which matches the single-editor frontend.
|
||||
type Canvas struct {
|
||||
ID string `gorm:"primaryKey;size:40"`
|
||||
UserID string `gorm:"size:32;index;not null"`
|
||||
Title string `gorm:"size:120;not null;default:'未命名画布'"`
|
||||
Data string `gorm:"type:text"`
|
||||
ID string `gorm:"primaryKey;size:40"`
|
||||
UserID string `gorm:"size:32;index;not null"`
|
||||
Title string `gorm:"size:120;not null;default:'未命名画布'"`
|
||||
Data string `gorm:"type:text"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time `gorm:"index"`
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
package adobe
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// captured from live firefly.adobe.com traffic:
|
||||
//
|
||||
// ark: 91818c89a54748463.1048135404|r=ap-southeast-1|…|rid=84|ag=101|…
|
||||
// ftr: dbd9d77a491b4437bc5c4d649a04a794_1785846934401_6890_UDF43-m4_31ck_YRQXWT0P1AE=-7389-v2_tt
|
||||
//
|
||||
// The Arkose slot is deliberately emitted empty rather than synthesized — see
|
||||
// buildARPSessionID — so the expected ftr ends in "_31ck__tt".
|
||||
var (
|
||||
arkPat = regexp.MustCompile(`^[0-9a-f]{17}\.[1-9][0-9]{9}\|r=ap-southeast-1\|.*\|rid=[0-9]{1,2}\|ag=101\|`)
|
||||
ftrPat = regexp.MustCompile(`^[0-9a-f]{32}_[0-9]{13}_[0-9]{4,5}_UDF43-m4_31ck__tt$`)
|
||||
)
|
||||
|
||||
func TestARPSessionIDShape(t *testing.T) {
|
||||
raw, err := base64.StdEncoding.DecodeString(buildARPSessionID("tok-a"))
|
||||
if err != nil {
|
||||
t.Fatalf("not base64: %v", err)
|
||||
}
|
||||
var got struct{ Sid, Ark, Ftr string }
|
||||
if err := json.Unmarshal(raw, &got); err != nil {
|
||||
t.Fatalf("not json: %v", err)
|
||||
}
|
||||
if !arkPat.MatchString(got.Ark) {
|
||||
t.Errorf("ark shape mismatch:\n%s", got.Ark)
|
||||
}
|
||||
if !ftrPat.MatchString(got.Ftr) {
|
||||
t.Errorf("ftr shape mismatch:\n%s", got.Ftr)
|
||||
}
|
||||
if len(got.Sid) != 36 {
|
||||
t.Errorf("sid not a uuid: %q", got.Sid)
|
||||
}
|
||||
}
|
||||
|
||||
// ark must differ per call — a frozen blob is a cross-account correlation key.
|
||||
func TestARKVariesAcrossCalls(t *testing.T) {
|
||||
if buildARKBlob() == buildARKBlob() {
|
||||
t.Error("ark is constant across calls")
|
||||
}
|
||||
}
|
||||
|
||||
// pid is stable per token but distinct across tokens.
|
||||
func TestPIDStablePerToken(t *testing.T) {
|
||||
defer ReleasePID("tok-x")
|
||||
defer ReleasePID("tok-y")
|
||||
if allocPID("tok-x") != allocPID("tok-x") {
|
||||
t.Error("pid changed for the same token")
|
||||
}
|
||||
if allocPID("tok-x") == allocPID("tok-y") {
|
||||
t.Error("two tokens share a pid")
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package adobe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
refreshURL = "https://adobeid-na1.services.adobe.com/ims/check/v6/token?jslVersion=v2-v0.48.0-1-g1e322cb"
|
||||
clientID = "projectx_webapp"
|
||||
scopeValue = "AdobeID,firefly_api,openid"
|
||||
// clioClientID / clioScopeValue mint the firefly.adobe.com token that is
|
||||
// entitled to the Firefly-native seedance models (creative_production scope).
|
||||
// Unlike the Express token this exchange requires user_id and no guest_allowed.
|
||||
clioClientID = "clio-playground-web"
|
||||
clioScopeValue = "AdobeID,firefly_api,openid,pps.read,pps.write,additional_info.projectedProductContext,additional_info.ownerOrg,uds_read,uds_write,ab.manage,read_organizations,additional_info.roles,account_cluster.read,creative_production,tk_platform,tk_platform_sync,profile"
|
||||
)
|
||||
|
||||
var ErrAdobeCookieEmpty = errors.New("cookie is empty")
|
||||
|
||||
type CookieExchangeResult struct {
|
||||
AccessToken string
|
||||
ExpiresIn int
|
||||
Raw map[string]any
|
||||
}
|
||||
|
||||
func ExchangeCookieToAccessToken(ctx context.Context, client *http.Client, cookie string) (*CookieExchangeResult, error) {
|
||||
_ = client
|
||||
tlsClient, err := NewClient(clientID, "").newTLSClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return exchangeCookieWithTLSClient(ctx, tlsClient, cookie)
|
||||
}
|
||||
|
||||
func normalizeCookie(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if strings.HasPrefix(strings.ToLower(v), "cookie:") {
|
||||
v = strings.TrimSpace(v[len("cookie:"):])
|
||||
}
|
||||
return v
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,517 +0,0 @@
|
||||
package adobe
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math/rand"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type modelSpec struct {
|
||||
UpstreamModelID string
|
||||
UpstreamModelVersion string
|
||||
}
|
||||
|
||||
var lumaSize = map[string]map[string][2]int{
|
||||
"720p": {
|
||||
"21:9": {1280, 548}, "16:9": {1280, 720}, "4:3": {960, 720},
|
||||
"1:1": {720, 720}, "3:4": {720, 960}, "9:16": {720, 1280}, "9:21": {548, 1280},
|
||||
},
|
||||
"1080p": {
|
||||
"21:9": {1920, 822}, "16:9": {1920, 1080}, "4:3": {1440, 1080},
|
||||
"1:1": {1080, 1080}, "3:4": {1080, 1440}, "9:16": {1080, 1920}, "9:21": {822, 1920},
|
||||
},
|
||||
"4k": {
|
||||
"21:9": {3840, 1646}, "16:9": {3840, 2160}, "4:3": {2880, 2160},
|
||||
"1:1": {2160, 2160}, "3:4": {2160, 2880}, "9:16": {2160, 3840}, "9:21": {1646, 3840},
|
||||
},
|
||||
}
|
||||
|
||||
var gptImageSize = map[string]map[string][2]int{
|
||||
"1K": {"1:1": {1024, 1024}, "5:4": {1120, 896}, "9:16": {720, 1280}, "21:9": {1456, 624}, "16:9": {1280, 720}, "4:3": {1152, 864}, "3:2": {1248, 832}, "4:5": {896, 1120}, "3:4": {864, 1152}, "2:3": {832, 1248}},
|
||||
"2K": {"1:1": {2048, 2048}, "5:4": {2240, 1792}, "9:16": {1440, 2560}, "21:9": {3024, 1296}, "16:9": {2560, 1440}, "4:3": {2304, 1728}, "3:2": {2496, 1664}, "4:5": {1792, 2240}, "3:4": {1728, 2304}, "2:3": {1664, 2496}},
|
||||
"4K": {"1:1": {2880, 2880}, "5:4": {3200, 2560}, "9:16": {2160, 3840}, "21:9": {3696, 1584}, "16:9": {3840, 2160}, "4:3": {3264, 2448}, "3:2": {3504, 2336}, "4:5": {2560, 3200}, "3:4": {2448, 3264}, "2:3": {2336, 3504}},
|
||||
}
|
||||
|
||||
var fluxSize = map[string][2]int{
|
||||
"1:1": {1024, 1024},
|
||||
"16:9": {1408, 768},
|
||||
"9:16": {768, 1408},
|
||||
"4:3": {1280, 896},
|
||||
"3:4": {896, 1280},
|
||||
}
|
||||
|
||||
var defaultSize = map[string]map[string][2]int{
|
||||
"1K": {"1:1": {1024, 1024}, "1:8": {384, 3072}, "1:4": {512, 2048}, "16:9": {1360, 768}, "9:16": {768, 1360}, "4:1": {2048, 512}, "4:3": {1152, 864}, "3:4": {864, 1152}, "8:1": {3072, 384}},
|
||||
"2K": {"1:1": {2048, 2048}, "1:8": {768, 6144}, "1:4": {1024, 4096}, "16:9": {2752, 1536}, "9:16": {1536, 2752}, "4:1": {4096, 1024}, "4:3": {2048, 1536}, "3:4": {1536, 2048}, "8:1": {6144, 768}},
|
||||
"4K": {"1:1": {4096, 4096}, "1:8": {1536, 12288}, "1:4": {2048, 8192}, "16:9": {5504, 3072}, "9:16": {3072, 5504}, "4:1": {8192, 2048}, "4:3": {4096, 3072}, "3:4": {3072, 4096}, "8:1": {12288, 1536}},
|
||||
}
|
||||
|
||||
func ResolveModelSpec(modelID string) modelSpec {
|
||||
switch modelID {
|
||||
case "firefly-gpt-image", "firefly-gpt-image-2":
|
||||
return modelSpec{UpstreamModelID: "gpt-image", UpstreamModelVersion: "2"}
|
||||
case "flux-kontext-max":
|
||||
return modelSpec{UpstreamModelID: "flux", UpstreamModelVersion: "fluxKontextMax"}
|
||||
case "nano-banana-2":
|
||||
return modelSpec{UpstreamModelID: "gemini-flash", UpstreamModelVersion: "nano-banana-3"}
|
||||
case "nano-banana-pro":
|
||||
return modelSpec{UpstreamModelID: "gemini-flash", UpstreamModelVersion: "nano-banana-2"}
|
||||
default:
|
||||
return modelSpec{UpstreamModelID: "gemini-flash", UpstreamModelVersion: "gemini-3-pro-image-preview"}
|
||||
}
|
||||
}
|
||||
|
||||
// buildImage5Payload builds the Adobe Firefly Image 5 request. It uses a distinct
|
||||
// schema from the firefly-3p models: NO modelId/size, a top-level aspectRatio
|
||||
// string label and a resolutionLevel (1K→1MP, 2K→4MP). Mirrors a captured
|
||||
// working image-v5.ff.adobe.io request.
|
||||
func buildImage5Payload(prompt, aspectRatio, resolution string, blobIDs []string) map[string]any {
|
||||
p := map[string]any{
|
||||
"n": 1,
|
||||
"seeds": []int{rand.Intn(999999)},
|
||||
"output": map[string]any{"storeInputs": true},
|
||||
"prompt": prompt,
|
||||
"referenceBlobs": []any{},
|
||||
"modelSpecificPayload": map[string]any{"locale": "en-US", "prompt_reasoner": "quality"},
|
||||
"modelVersion": "image5",
|
||||
"resolutionLevel": image5ResolutionLevel(resolution),
|
||||
"generationMetadata": map[string]any{"module": "text2image", "submodule": "ff-image-generate"},
|
||||
}
|
||||
if len(blobIDs) > 0 {
|
||||
// Instruct-edit: aspect ratio is derived from the reference image; sending
|
||||
// aspectRatio is rejected with a validation_error.
|
||||
p["referenceBlobs"] = blobRefs(blobIDs, "general")
|
||||
} else {
|
||||
p["aspectRatio"] = defaultString(aspectRatio, "1:1")
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// image5ResolutionLevel maps the UI resolution tier to Image 5's megapixel level.
|
||||
func image5ResolutionLevel(resolution string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(resolution)) {
|
||||
case "1K":
|
||||
return "1MP"
|
||||
case "2K":
|
||||
return "4MP"
|
||||
default:
|
||||
return "4MP"
|
||||
}
|
||||
}
|
||||
|
||||
func BuildImagePayloadCandidates(modelID, prompt, aspectRatio, outputResolution string, blobIDs []string) []map[string]any {
|
||||
spec := ResolveModelSpec(modelID)
|
||||
ratio := defaultString(aspectRatio, "1:1")
|
||||
resolution := defaultString(outputResolution, "2K")
|
||||
|
||||
switch spec.UpstreamModelID {
|
||||
case "gpt-image":
|
||||
return buildGPTImagePayloads(spec, prompt, ratio, resolution, blobIDs)
|
||||
case "flux":
|
||||
return buildFluxPayloads(spec, prompt, ratio, blobIDs)
|
||||
default:
|
||||
return buildDefaultPayloads(spec, prompt, ratio, resolution, blobIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func buildGPTImagePayloads(spec modelSpec, prompt, ratio, resolution string, blobIDs []string) []map[string]any {
|
||||
size := getSize(gptImageSize, resolution, ratio, "1:1")
|
||||
// Mirrors the captured working gpt-image request shape: modelSpecificPayload.size,
|
||||
// generationSettings.detailLevel 3, and NO top-level size / outputResolution
|
||||
// (sending those got 403). Keeps the chosen size via modelSpecificPayload.size
|
||||
// ("WxH") rather than "auto".
|
||||
base := map[string]any{
|
||||
"modelId": spec.UpstreamModelID,
|
||||
"modelVersion": spec.UpstreamModelVersion,
|
||||
"n": 1,
|
||||
"prompt": prompt,
|
||||
"seeds": []int{rand.Intn(999999)},
|
||||
"output": map[string]any{"storeInputs": true},
|
||||
"referenceBlobs": []any{},
|
||||
"generationMetadata": map[string]any{"module": "text2image", "submodule": "ff-image-generate"},
|
||||
"modelSpecificPayload": map[string]any{"size": sizeString(size)},
|
||||
"generationSettings": map[string]any{"detailLevel": 3},
|
||||
}
|
||||
if len(blobIDs) == 0 {
|
||||
return []map[string]any{base}
|
||||
}
|
||||
subject := cloneMap(base)
|
||||
subject["referenceBlobs"] = blobRefs(blobIDs, "subject")
|
||||
return []map[string]any{subject}
|
||||
}
|
||||
|
||||
func buildFluxPayloads(spec modelSpec, prompt, ratio string, blobIDs []string) []map[string]any {
|
||||
size := fluxSize[ratio]
|
||||
if size == [2]int{} {
|
||||
size = fluxSize["1:1"]
|
||||
}
|
||||
base := map[string]any{
|
||||
"modelId": spec.UpstreamModelID,
|
||||
"modelVersion": spec.UpstreamModelVersion,
|
||||
"n": 1,
|
||||
"prompt": prompt,
|
||||
"size": map[string]any{"width": size[0], "height": size[1]},
|
||||
"seeds": []int{rand.Intn(999999)},
|
||||
"output": map[string]any{"storeInputs": true},
|
||||
"referenceBlobs": []any{},
|
||||
"modelSpecificPayload": map[string]any{
|
||||
"prompt_upsampling": true,
|
||||
"safety_tolerance": 2,
|
||||
"aspect_ratio": ratio,
|
||||
},
|
||||
"generationMetadata": map[string]any{"module": "text2image", "submodule": "ff-image-generate"},
|
||||
}
|
||||
if len(blobIDs) == 0 {
|
||||
return []map[string]any{base}
|
||||
}
|
||||
edited := cloneMap(base)
|
||||
edited["generationMetadata"] = map[string]any{"module": "image2image", "submodule": "ff-image-generate"}
|
||||
edited["referenceBlobs"] = blobRefs(blobIDs, "general")
|
||||
return []map[string]any{edited}
|
||||
}
|
||||
|
||||
func buildDefaultPayloads(spec modelSpec, prompt, ratio, resolution string, blobIDs []string) []map[string]any {
|
||||
size := getSize(defaultSize, resolution, ratio, "16:9")
|
||||
// Shape mirrors a captured working firefly.adobe.com request exactly: top-level
|
||||
// size object, modelSpecificPayload only {parameters:{addWatermark:false}},
|
||||
// groundSearch:false, module "text2image" (even with a reference blob). NO
|
||||
// skipCai and NO modelSpecificPayload.aspectRatio — sending those got 403.
|
||||
base := map[string]any{
|
||||
"modelId": spec.UpstreamModelID,
|
||||
"modelVersion": spec.UpstreamModelVersion,
|
||||
"n": 1,
|
||||
"prompt": prompt,
|
||||
"size": map[string]any{"width": size[0], "height": size[1]},
|
||||
"seeds": []int{rand.Intn(999999)},
|
||||
"groundSearch": false,
|
||||
"output": map[string]any{"storeInputs": true},
|
||||
"generationMetadata": map[string]any{
|
||||
"module": "text2image",
|
||||
"submodule": "ff-image-generate",
|
||||
},
|
||||
"modelSpecificPayload": map[string]any{
|
||||
"parameters": map[string]any{"addWatermark": false},
|
||||
},
|
||||
}
|
||||
if len(blobIDs) == 0 {
|
||||
base["referenceBlobs"] = []any{}
|
||||
return []map[string]any{base}
|
||||
}
|
||||
edited := cloneMap(base)
|
||||
edited["referenceBlobs"] = blobRefs(blobIDs, "general")
|
||||
return []map[string]any{edited}
|
||||
}
|
||||
|
||||
func getSize(table map[string]map[string][2]int, resolution, ratio, fallbackRatio string) [2]int {
|
||||
level := defaultString(resolution, "2K")
|
||||
levelTable, ok := table[level]
|
||||
if !ok {
|
||||
levelTable = table["2K"]
|
||||
}
|
||||
size, ok := levelTable[ratio]
|
||||
if !ok {
|
||||
size = levelTable[fallbackRatio]
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
func sizeString(size [2]int) string {
|
||||
return itoa(size[0]) + "x" + itoa(size[1])
|
||||
}
|
||||
|
||||
func blobRefs(ids []string, usage string) []any {
|
||||
out := make([]any, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
out = append(out, map[string]any{"id": id, "usage": usage})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func referenceImagesByID(ids []string) []any {
|
||||
out := make([]any, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
out = append(out, map[string]any{"id": id})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func referenceImagesByLocal(ids []string) []any {
|
||||
out := make([]any, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
out = append(out, map[string]any{"localBlobRef": id})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneMap(in map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func BuildVideoPayload(engine, prompt, aspectRatio string, durationSeconds int, resolution, referenceMode, upstreamModel string, blobIDs, videoBlobIDs, audioBlobIDs []string) map[string]any {
|
||||
seedVal := rand.Intn(999999)
|
||||
engine = defaultString(engine, "sora2")
|
||||
resolution = defaultString(resolution, "720p")
|
||||
aspectRatio = defaultString(aspectRatio, "16:9")
|
||||
if durationSeconds <= 0 {
|
||||
durationSeconds = 5
|
||||
}
|
||||
|
||||
switch engine {
|
||||
case "firefly-video":
|
||||
// Firefly-native video model — a distinct schema (mirrors a captured
|
||||
// working video-v1.ff.adobe.io request): sizes[] carries width/height +
|
||||
// numFrames (numFrames encodes duration, ~25.6fps so 5s = 128), and
|
||||
// reference frames go under image.conditions with placement.start
|
||||
// (0 = first frame / 首帧, 1 = last frame / 末帧). NO modelId / version /
|
||||
// engine / duration / referenceBlobs fields.
|
||||
w, h, frames := fireflyVideoSize(aspectRatio, resolution, durationSeconds)
|
||||
payload := map[string]any{
|
||||
"addOnTransparentBackground": false,
|
||||
"prompt": prompt,
|
||||
"seeds": []int{seedVal},
|
||||
"sizes": []any{map[string]any{"width": w, "height": h, "numFrames": frames}},
|
||||
"videoSettings": map[string]any{},
|
||||
"locale": "en-US",
|
||||
"generationMetadata": map[string]any{"module": "text2video", "submodule": "ff-video-generate"},
|
||||
"output": map[string]any{"storeInputs": true},
|
||||
}
|
||||
if len(blobIDs) > 0 {
|
||||
conds := make([]any, 0, 2)
|
||||
conds = append(conds, map[string]any{
|
||||
"source": map[string]any{"id": blobIDs[0]},
|
||||
"placement": map[string]any{"start": 0},
|
||||
})
|
||||
if len(blobIDs) > 1 {
|
||||
conds = append(conds, map[string]any{
|
||||
"source": map[string]any{"id": blobIDs[1]},
|
||||
"placement": map[string]any{"start": 1},
|
||||
})
|
||||
}
|
||||
payload["image"] = map[string]any{"conditions": conds}
|
||||
}
|
||||
return payload
|
||||
case "veo31-fast", "veo31-standard":
|
||||
modelVersion := "3.1-fast-generate"
|
||||
if engine == "veo31-standard" {
|
||||
modelVersion = "3.1-generate"
|
||||
}
|
||||
payload := map[string]any{
|
||||
"modelId": "veo",
|
||||
"modelVersion": modelVersion,
|
||||
"size": videoSize(aspectRatio, resolution),
|
||||
"seeds": []int{seedVal},
|
||||
"prompt": prompt,
|
||||
"negativePrompt": "",
|
||||
"duration": durationSeconds,
|
||||
"generateAudio": false,
|
||||
"generationMetadata": map[string]any{
|
||||
"module": "text2video",
|
||||
"submodule": "ff-video-generate",
|
||||
},
|
||||
"output": map[string]any{"storeInputs": true},
|
||||
"referenceBlobs": []any{},
|
||||
}
|
||||
if len(blobIDs) > 0 {
|
||||
payload["generationMetadata"] = map[string]any{"module": "image2video", "submodule": "ff-video-generate"}
|
||||
refs := make([]any, 0, min(len(blobIDs), 3))
|
||||
if referenceMode == "frame" {
|
||||
for idx, id := range blobIDs[:min(len(blobIDs), 3)] {
|
||||
refs = append(refs, map[string]any{"id": id, "usage": "frame", "order": idx + 1})
|
||||
}
|
||||
} else {
|
||||
for _, id := range blobIDs[:min(len(blobIDs), 3)] {
|
||||
refs = append(refs, map[string]any{"id": id, "usage": "asset"})
|
||||
}
|
||||
}
|
||||
payload["referenceBlobs"] = refs
|
||||
}
|
||||
return payload
|
||||
case "seedance-2.0-fast", "seedance-2.0":
|
||||
modelVersion := "seedance_2.0"
|
||||
if engine == "seedance-2.0-fast" {
|
||||
modelVersion = "seedance_2.0_fast"
|
||||
}
|
||||
payload := map[string]any{
|
||||
"modelId": "seedance",
|
||||
"modelVersion": modelVersion,
|
||||
"size": videoSize(aspectRatio, resolution),
|
||||
"seeds": []int{seedVal},
|
||||
"prompt": prompt,
|
||||
"negativePrompt": "",
|
||||
"duration": durationSeconds,
|
||||
"generateAudio": true,
|
||||
"generationSettings": map[string]any{"aspectRatio": aspectRatio},
|
||||
"generationMetadata": map[string]any{"module": "text2video", "submodule": "ff-video-generate"},
|
||||
"output": map[string]any{"storeInputs": true},
|
||||
"referenceBlobs": []any{},
|
||||
}
|
||||
if len(blobIDs) > 0 {
|
||||
// Seedance keeps module "text2video" even with reference blobs
|
||||
// (HAR-confirmed) — unlike veo/luma it does not switch to image2video.
|
||||
if referenceMode == "frame" {
|
||||
refs := make([]any, 0, min(len(blobIDs), 2))
|
||||
for idx, id := range blobIDs[:min(len(blobIDs), 2)] {
|
||||
refs = append(refs, map[string]any{"id": id, "usage": "frame", "order": idx + 1})
|
||||
}
|
||||
payload["referenceBlobs"] = refs
|
||||
} else {
|
||||
refs := make([]any, 0, min(len(blobIDs), 9))
|
||||
for _, id := range blobIDs[:min(len(blobIDs), 9)] {
|
||||
refs = append(refs, map[string]any{"id": id, "usage": "style"})
|
||||
}
|
||||
payload["referenceBlobs"] = refs
|
||||
}
|
||||
}
|
||||
// Video/audio source refs (seedance)
|
||||
for _, id := range videoBlobIDs[:min(len(videoBlobIDs), 3)] {
|
||||
payload["referenceBlobs"] = append(payload["referenceBlobs"].([]any), map[string]any{"id": id, "usage": "source"})
|
||||
}
|
||||
for _, id := range audioBlobIDs[:min(len(audioBlobIDs), 3)] {
|
||||
payload["referenceBlobs"] = append(payload["referenceBlobs"].([]any), map[string]any{"id": id, "usage": "source"})
|
||||
}
|
||||
return payload
|
||||
case "luma":
|
||||
payload := map[string]any{
|
||||
"modelId": "luma",
|
||||
"modelVersion": "3.14-ray",
|
||||
"size": lumaVideoSize(aspectRatio, resolution),
|
||||
"mode": "flex_2",
|
||||
"prompt": prompt,
|
||||
"negativePrompt": "",
|
||||
"duration": durationSeconds,
|
||||
"generationMetadata": map[string]any{
|
||||
"module": "text2video",
|
||||
"submodule": "ff-video-generate",
|
||||
},
|
||||
"modelSpecificPayload": map[string]any{
|
||||
"resolution": strings.ToLower(resolution),
|
||||
"aspect_ratio": aspectRatio,
|
||||
},
|
||||
"output": map[string]any{"storeInputs": true},
|
||||
}
|
||||
if len(blobIDs) > 0 {
|
||||
payload["generationMetadata"] = map[string]any{
|
||||
"module": "image2video",
|
||||
"submodule": "ff-video-generate",
|
||||
}
|
||||
refs := make([]any, 0, min(len(blobIDs), 2))
|
||||
for idx, id := range blobIDs[:min(len(blobIDs), 2)] {
|
||||
refs = append(refs, map[string]any{"id": id, "usage": "frame", "order": idx + 1})
|
||||
}
|
||||
payload["referenceBlobs"] = refs
|
||||
}
|
||||
return payload
|
||||
default:
|
||||
upstream := defaultString(upstreamModel, "openai:firefly:colligo:sora2")
|
||||
payload := map[string]any{
|
||||
"n": 1,
|
||||
"seeds": []int{seedVal},
|
||||
"modelId": "sora",
|
||||
"modelVersion": "sora-2",
|
||||
"size": videoSize(aspectRatio, resolution),
|
||||
"duration": durationSeconds,
|
||||
"fps": 24,
|
||||
"prompt": buildVideoPromptJSON(prompt, durationSeconds),
|
||||
"generationMetadata": map[string]any{"module": "text2video"},
|
||||
"model": upstream,
|
||||
"generateAudio": true,
|
||||
"generateLoop": false,
|
||||
"transparentBackground": false,
|
||||
"seed": itoa(seedVal),
|
||||
"locale": "en-US",
|
||||
"camera": map[string]any{"angle": "none", "shotSize": "none", "motion": nil, "promptStyle": nil},
|
||||
"negativePrompt": "",
|
||||
"jobMode": "standard",
|
||||
"debugGenerationEndpoint": "",
|
||||
"referenceBlobs": []any{},
|
||||
"referenceFrames": []any{},
|
||||
"referenceVideo": nil,
|
||||
"cameraMotionReferenceVideo": nil,
|
||||
"characterReference": nil,
|
||||
"editReferenceVideo": nil,
|
||||
"output": map[string]any{"storeInputs": true},
|
||||
}
|
||||
if len(blobIDs) > 0 {
|
||||
firstID := blobIDs[0]
|
||||
payload["generationMetadata"] = map[string]any{"module": "image2video"}
|
||||
payload["referenceBlobs"] = []any{
|
||||
map[string]any{"id": firstID, "usage": "general", "promptReference": 1},
|
||||
}
|
||||
payload["referenceFrames"] = []any{map[string]any{"localBlobRef": firstID}, nil}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
}
|
||||
|
||||
// fireflyVideoSizeTable maps the firefly-video resolution tier + aspect ratio to
|
||||
// pixel dimensions. Only 1080p 9:16 (1080x1920) is HAR-confirmed; the rest follow
|
||||
// the standard 540p/720p/1080p grid for each ratio.
|
||||
var fireflyVideoSizeTable = map[string]map[string][2]int{
|
||||
"540p": {"16:9": {960, 540}, "1:1": {540, 540}, "9:16": {540, 960}},
|
||||
"720p": {"16:9": {1280, 720}, "1:1": {720, 720}, "9:16": {720, 1280}},
|
||||
"1080p": {"16:9": {1920, 1080}, "1:1": {1080, 1080}, "9:16": {1080, 1920}},
|
||||
}
|
||||
|
||||
// fireflyVideoSize returns width, height and numFrames. numFrames encodes the
|
||||
// clip length (~25.6fps; 5s = 128 frames, HAR-confirmed).
|
||||
func fireflyVideoSize(aspectRatio, resolution string, durationSeconds int) (int, int, int) {
|
||||
table, ok := fireflyVideoSizeTable[strings.ToLower(defaultString(resolution, "1080p"))]
|
||||
if !ok {
|
||||
table = fireflyVideoSizeTable["1080p"]
|
||||
}
|
||||
wh, ok := table[defaultString(aspectRatio, "9:16")]
|
||||
if !ok {
|
||||
wh = table["9:16"]
|
||||
}
|
||||
frames := durationSeconds * 128 / 5
|
||||
if frames <= 0 {
|
||||
frames = 128
|
||||
}
|
||||
return wh[0], wh[1], frames
|
||||
}
|
||||
|
||||
func videoSize(aspectRatio, resolution string) map[string]any {
|
||||
if strings.EqualFold(resolution, "1080p") {
|
||||
if aspectRatio == "16:9" {
|
||||
return map[string]any{"width": 1920, "height": 1080}
|
||||
}
|
||||
return map[string]any{"width": 1080, "height": 1920}
|
||||
}
|
||||
if aspectRatio == "16:9" {
|
||||
return map[string]any{"width": 1280, "height": 720}
|
||||
}
|
||||
return map[string]any{"width": 720, "height": 1280}
|
||||
}
|
||||
|
||||
func lumaVideoSize(aspectRatio, resolution string) map[string]any {
|
||||
table, ok := lumaSize[strings.ToLower(defaultString(resolution, "720p"))]
|
||||
if !ok {
|
||||
table = lumaSize["720p"]
|
||||
}
|
||||
size, ok := table[defaultString(aspectRatio, "16:9")]
|
||||
if !ok {
|
||||
size = table["16:9"]
|
||||
}
|
||||
return map[string]any{"width": size[0], "height": size[1]}
|
||||
}
|
||||
|
||||
func buildVideoPromptJSON(prompt string, durationSeconds int) string {
|
||||
payload := map[string]any{
|
||||
"id": 1,
|
||||
"duration_sec": durationSeconds,
|
||||
"prompt_text": prompt,
|
||||
}
|
||||
b, _ := json.Marshal(payload)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
package adobe
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"math/big"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// adobeUserIDPat matches Adobe IMS user IDs embedded in cookies (e.g.
|
||||
// "4BDA81F069FC6DA40A495FAB@AdobeID").
|
||||
var adobeUserIDPat = regexp.MustCompile(`[A-Fa-f0-9]{20,}@AdobeID`)
|
||||
|
||||
// PID pool: one PID per token, reused across requests for the same account.
|
||||
var (
|
||||
arpPIDMu sync.Mutex
|
||||
arpTokenPID = map[string]int{} // token → pid
|
||||
arpPIDToken = map[int]string{} // pid → token
|
||||
)
|
||||
|
||||
func extractUserIDFromCookie(cookie string) string {
|
||||
decoded, err := url.QueryUnescape(cookie)
|
||||
if err != nil {
|
||||
decoded = cookie
|
||||
}
|
||||
return adobeUserIDPat.FindString(decoded)
|
||||
}
|
||||
|
||||
func stringValue(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
return strings.TrimSpace(strings.ReplaceAll(toJSONScalar(x), "\n", " "))
|
||||
}
|
||||
}
|
||||
|
||||
func toJSONScalar(v any) string {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func intValue(v any) int {
|
||||
switch x := v.(type) {
|
||||
case int:
|
||||
return x
|
||||
case int64:
|
||||
return int(x)
|
||||
case float64:
|
||||
return int(x)
|
||||
case float32:
|
||||
return int(x)
|
||||
case json.Number:
|
||||
n, _ := x.Int64()
|
||||
return int(n)
|
||||
case string:
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(x))
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func defaultString(v, fallback string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func itoa(v int) string {
|
||||
return strconv.Itoa(v)
|
||||
}
|
||||
|
||||
func decodeJWTPayload(token string) map[string]any {
|
||||
parts := strings.Split(strings.TrimSpace(token), ".")
|
||||
if len(parts) < 2 {
|
||||
return map[string]any{}
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// arkHead and arkTail are the site-constant halves of the ark blob, as captured
|
||||
// from firefly.adobe.com. The leading "<17hex>.<10digits>" pair and the rid
|
||||
// field sit between them and vary per session, so buildARKBlob randomizes those
|
||||
// instead of shipping one frozen blob for every account.
|
||||
const (
|
||||
arkHead = "|r=ap-southeast-1|meta=3|metabgclr=transparent|metaiconclr=%23757575|guitextcolor=%23000000|pk=BBCC314C-4937-4CCD-B0A3-FDF0F0F7603C|at=40|sup=1"
|
||||
arkTail = "|ag=101|cdn_url=https%3A%2F%2Farks-client.adobe.com%2Fcdn%2Ffc|surl=https%3A%2F%2Farks-client.adobe.com|smurl=https%3A%2F%2Farks-client.adobe.com%2Fcdn%2Ffc%2Fassets%2Fstyle-manager"
|
||||
)
|
||||
|
||||
func buildARKBlob() string {
|
||||
return randomHex(9)[:17] + "." + randomDigits(10) +
|
||||
arkHead + "|rid=" + strconv.Itoa(randomInt(1, 99)) + arkTail
|
||||
}
|
||||
|
||||
// buildARPSessionID returns the x-arp-session-id value: base64 of
|
||||
// {"sid","ark","ftr"}, shaped to match live firefly.adobe.com traffic:
|
||||
//
|
||||
// ark: <17hex>.<10digits>|…|rid=<n>|…
|
||||
// ftr: <32hex>_<unix_ms>_<pid>_UDF43-m4_31ck__tt
|
||||
//
|
||||
// The pid is allocated per token (allocPID) so it stays stable across an
|
||||
// account's requests, the way a real browser session would.
|
||||
//
|
||||
// The Arkose segment is left empty on purpose. In a real browser that slot
|
||||
// carries a "<b64>=-<4digits>-v2" payload emitted by Adobe's Arkose bot-manager
|
||||
// script; a synthesized one has the right shape but fails Adobe's server-side
|
||||
// validation, and 3p-images/generate-async rejects it with a misleading
|
||||
// {"error_code":"timeout_error","message":"system under load"}. Sending the
|
||||
// slot empty reads as "no Arkose payload yet" and is accepted.
|
||||
func buildARPSessionID(token string) string {
|
||||
ftr := strings.Join([]string{
|
||||
randomHex(16), // 32 hex chars, matching the capture
|
||||
strconv.FormatInt(time.Now().UnixMilli(), 10),
|
||||
strconv.Itoa(allocPID(token)),
|
||||
"UDF43-m4",
|
||||
"31ck",
|
||||
"",
|
||||
"tt",
|
||||
}, "_")
|
||||
|
||||
b, _ := json.Marshal(map[string]any{
|
||||
"sid": uuid.NewString(),
|
||||
"ark": buildARKBlob(),
|
||||
"ftr": ftr,
|
||||
})
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// randomDigits returns n decimal digits with a non-zero leading digit, so the
|
||||
// rendered value keeps a constant width.
|
||||
func randomDigits(n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(n)
|
||||
b.WriteByte(byte('1' + randomInt(0, 8)))
|
||||
for i := 1; i < n; i++ {
|
||||
b.WriteByte(byte('0' + randomInt(0, 9)))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func randomBase64(n int) string {
|
||||
buf := make([]byte, n)
|
||||
rand.Read(buf)
|
||||
return base64.RawURLEncoding.EncodeToString(buf)
|
||||
}
|
||||
|
||||
func allocPID(token string) int {
|
||||
arpPIDMu.Lock()
|
||||
defer arpPIDMu.Unlock()
|
||||
if pid, ok := arpTokenPID[token]; ok {
|
||||
return pid
|
||||
}
|
||||
for {
|
||||
pid := randomInt(1000, 99999)
|
||||
if _, used := arpPIDToken[pid]; !used {
|
||||
arpPIDToken[pid] = token
|
||||
arpTokenPID[token] = pid
|
||||
return pid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ReleasePID releases the PID bound to token so it can be reused by another
|
||||
// account.
|
||||
func ReleasePID(token string) {
|
||||
arpPIDMu.Lock()
|
||||
defer arpPIDMu.Unlock()
|
||||
if pid, ok := arpTokenPID[token]; ok {
|
||||
delete(arpPIDToken, pid)
|
||||
delete(arpTokenPID, token)
|
||||
}
|
||||
}
|
||||
|
||||
func randomHex(n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
buf := make([]byte, n)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
now := time.Now().UnixNano()
|
||||
for i := range buf {
|
||||
buf[i] = byte(now >> ((i % 8) * 8))
|
||||
}
|
||||
}
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
func randomInt(min, max int) int {
|
||||
if max <= min {
|
||||
return min
|
||||
}
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(max-min+1)))
|
||||
if err != nil {
|
||||
return min
|
||||
}
|
||||
return min + int(n.Int64())
|
||||
}
|
||||
|
||||
func intOrNil(v any) any {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case int:
|
||||
return x
|
||||
case int64:
|
||||
return int(x)
|
||||
case float64:
|
||||
return int(x)
|
||||
case float32:
|
||||
return int(x)
|
||||
case json.Number:
|
||||
n, err := x.Int64()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return int(n)
|
||||
case string:
|
||||
n, err := strconv.Atoi(strings.TrimSpace(x))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return n
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func emptyStringNil(v string) any {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,151 +0,0 @@
|
||||
package chatgpt
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/sha3"
|
||||
)
|
||||
|
||||
var (
|
||||
cores = []int{8, 16, 24, 32}
|
||||
documentKeys = []string{"__reactContainer$fzelfjyxej8", "_reactListening5dehydibo78", "location"}
|
||||
screenResolutions = [][2]int{{1920, 1080}, {1440, 900}, {2560, 1440}, {3840, 2160}}
|
||||
navKeys = []string{
|
||||
"registerProtocolHandler−function registerProtocolHandler() { [native code] }",
|
||||
"storage−[object StorageManager]",
|
||||
"locks−[object LockManager]",
|
||||
"appCodeName−Mozilla",
|
||||
"permissions−[object Permissions]",
|
||||
"share−function share() { [native code] }",
|
||||
"webdriver−false",
|
||||
"vendor−Google Inc.",
|
||||
"mediaDevices−[object MediaDevices]",
|
||||
"cookieEnabled−true",
|
||||
"onLine−true",
|
||||
"mimeTypes−[object MimeTypeArray]",
|
||||
"credentials−[object CredentialsContainer]",
|
||||
"serviceWorker−[object ServiceWorkerContainer]",
|
||||
"keyboard−[object Keyboard]",
|
||||
"gpu−[object GPU]",
|
||||
"doNotTrack",
|
||||
"language−zh-CN",
|
||||
"geolocation−[object Geolocation]",
|
||||
"hardwareConcurrency−32",
|
||||
}
|
||||
winKeys = []string{
|
||||
"0", "window", "self", "document", "name", "location", "history",
|
||||
"navigation", "innerWidth", "innerHeight", "screen", "chrome",
|
||||
"navigator", "performance", "crypto", "indexedDB", "sessionStorage",
|
||||
"localStorage", "fetch", "matchMedia", "postMessage", "setTimeout",
|
||||
"caches", "__NEXT_DATA__",
|
||||
}
|
||||
)
|
||||
|
||||
func buildLegacyRequirementsToken(userAgent string, scriptSources []string, dataBuild string) string {
|
||||
cfg := buildPOWConfig(userAgent, scriptSources, dataBuild)
|
||||
body, _ := json.Marshal(cfg)
|
||||
return "gAAAAAC" + base64.StdEncoding.EncodeToString(body)
|
||||
}
|
||||
|
||||
func buildProofToken(seed, difficulty, userAgent string, scriptSources []string, dataBuild string) (string, error) {
|
||||
cfg := buildPOWConfig(userAgent, scriptSources, dataBuild)
|
||||
answer, solved := powGenerate(seed, difficulty, cfg, 500000)
|
||||
if !solved {
|
||||
return "", errors.New("failed to solve proof token")
|
||||
}
|
||||
return "gAAAAAB" + answer, nil
|
||||
}
|
||||
|
||||
func buildPOWConfig(userAgent string, scriptSources []string, dataBuild string) []any {
|
||||
// scriptSources/dataBuild are no longer part of the sentinel config array
|
||||
// (the current chatgpt.com client dropped them); kept in the signature for
|
||||
// call-site compatibility.
|
||||
_ = scriptSources
|
||||
_ = dataBuild
|
||||
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
screen := screenResolutions[rng.Intn(len(screenResolutions))]
|
||||
loc := time.FixedZone("GMT+0800", 8*3600)
|
||||
nowLocal := time.Now().In(loc).Format("Mon Jan 02 2006 15:04:05") + " GMT+0800 (中国标准时间)"
|
||||
perf := float64(time.Now().UnixNano()%1_000_000_000) / 1_000_000
|
||||
return []any{
|
||||
screen[0] + screen[1], // [0]
|
||||
nowLocal, // [1] local time, JS Date.toString() shape
|
||||
4395630592, // [2]
|
||||
1, // [3] overwritten by powGenerate counter
|
||||
userAgent, // [4]
|
||||
nil, // [5] (was script source; now null)
|
||||
defaultClientVersion, // [6] oai-client-version, must match header
|
||||
"zh-CN", // [7] matches oai-language
|
||||
"zh-CN,en,en-GB,en-US", // [8]
|
||||
rng.Float64(), // [9] overwritten by powGenerate counter
|
||||
navKeys[rng.Intn(len(navKeys))],
|
||||
documentKeys[rng.Intn(len(documentKeys))],
|
||||
winKeys[rng.Intn(len(winKeys))],
|
||||
perf, // [13]
|
||||
newUUID(), // [14]
|
||||
"", // [15]
|
||||
cores[rng.Intn(len(cores))], // [16]
|
||||
float64(timeMillis()) - perf, // [17]
|
||||
0, 0, 0, 0, 0, 0,
|
||||
0,
|
||||
}
|
||||
}
|
||||
|
||||
func powGenerate(seed, difficulty string, cfg []any, limit int) (string, bool) {
|
||||
target, err := hex.DecodeString(strings.TrimSpace(difficulty))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
diffLen := len(strings.TrimSpace(difficulty)) / 2
|
||||
seedBytes := []byte(seed)
|
||||
head1, _ := json.Marshal(cfg[:3])
|
||||
head2, _ := json.Marshal(cfg[4:9])
|
||||
head3, _ := json.Marshal(cfg[10:])
|
||||
static1 := []byte(string(head1[:len(head1)-1]) + ",")
|
||||
static2 := []byte("," + string(head2[1:len(head2)-1]) + ",")
|
||||
static3 := []byte("," + string(head3[1:]))
|
||||
|
||||
for i := 0; i < limit; i++ {
|
||||
finalJSON := append([]byte{}, static1...)
|
||||
finalJSON = append(finalJSON, []byte(strconvItoa(i))...)
|
||||
finalJSON = append(finalJSON, static2...)
|
||||
finalJSON = append(finalJSON, []byte(strconvItoa(i>>1))...)
|
||||
finalJSON = append(finalJSON, static3...)
|
||||
encoded := base64.StdEncoding.EncodeToString(finalJSON)
|
||||
sum := sha3.Sum512(append(seedBytes, []byte(encoded)...))
|
||||
if bytesCompare(sum[:diffLen], target) <= 0 {
|
||||
return encoded, true
|
||||
}
|
||||
}
|
||||
fallback := "wQ8Lk5FbGpA2NcR9dShT6gYjU7VxZ4D" + base64.StdEncoding.EncodeToString([]byte(`"`+seed+`"`))
|
||||
return fallback, false
|
||||
}
|
||||
|
||||
func bytesCompare(a, b []byte) int {
|
||||
for i := 0; i < len(a) && i < len(b); i++ {
|
||||
if a[i] < b[i] {
|
||||
return -1
|
||||
}
|
||||
if a[i] > b[i] {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
if len(a) < len(b) {
|
||||
return -1
|
||||
}
|
||||
if len(a) > len(b) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func strconvItoa(v int) string {
|
||||
return strconv.Itoa(v)
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
package chatgpt
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type orderedMap struct {
|
||||
keys []string
|
||||
values map[string]any
|
||||
}
|
||||
|
||||
func newOrderedMap() *orderedMap {
|
||||
return &orderedMap{values: map[string]any{}}
|
||||
}
|
||||
|
||||
func (m *orderedMap) add(key string, value any) {
|
||||
if _, ok := m.values[key]; !ok {
|
||||
m.keys = append(m.keys, key)
|
||||
}
|
||||
m.values[key] = value
|
||||
}
|
||||
|
||||
func solveTurnstileToken(dx, p string) string {
|
||||
decoded, err := base64.StdEncoding.DecodeString(dx)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var tokenList [][]any
|
||||
if err := json.Unmarshal([]byte(xorString(string(decoded), p)), &tokenList); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
processMap := map[int]any{16: p}
|
||||
start := time.Now()
|
||||
result := ""
|
||||
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
|
||||
toStr := func(value any) string {
|
||||
if value == nil {
|
||||
return "undefined"
|
||||
}
|
||||
if s, ok := value.(string); ok {
|
||||
special := map[string]string{
|
||||
"window.Math": "[object Math]",
|
||||
"window.Reflect": "[object Reflect]",
|
||||
"window.performance": "[object Performance]",
|
||||
"window.localStorage": "[object Storage]",
|
||||
"window.Object": "function Object() { [native code] }",
|
||||
"window.Reflect.set": "function set() { [native code] }",
|
||||
"window.performance.now": "function () { [native code] }",
|
||||
"window.Object.create": "function create() { [native code] }",
|
||||
"window.Object.keys": "function keys() { [native code] }",
|
||||
"window.Math.random": "function random() { [native code] }",
|
||||
}
|
||||
if specialValue, ok := special[s]; ok {
|
||||
return specialValue
|
||||
}
|
||||
return s
|
||||
}
|
||||
if list, ok := value.([]string); ok {
|
||||
return strings.Join(list, ",")
|
||||
}
|
||||
return stringValue(value)
|
||||
}
|
||||
|
||||
for _, token := range tokenList {
|
||||
if len(token) == 0 {
|
||||
continue
|
||||
}
|
||||
op := intValue(token[0])
|
||||
switch op {
|
||||
case 2:
|
||||
if len(token) >= 3 {
|
||||
processMap[intValue(token[1])] = token[2]
|
||||
}
|
||||
case 3:
|
||||
if len(token) >= 2 {
|
||||
result = base64.StdEncoding.EncodeToString([]byte(toStr(processMap[intValue(token[1])])))
|
||||
}
|
||||
case 5:
|
||||
if len(token) >= 3 {
|
||||
e := intValue(token[1])
|
||||
t := intValue(token[2])
|
||||
cur := processMap[e]
|
||||
inc := processMap[t]
|
||||
if list, ok := cur.([]any); ok {
|
||||
processMap[e] = append(list, inc)
|
||||
} else if _, ok := cur.(string); ok {
|
||||
processMap[e] = toStr(cur) + toStr(inc)
|
||||
} else {
|
||||
processMap[e] = "NaN"
|
||||
}
|
||||
}
|
||||
case 6, 24:
|
||||
if len(token) >= 4 {
|
||||
e := intValue(token[1])
|
||||
t := toStr(processMap[intValue(token[2])])
|
||||
n := toStr(processMap[intValue(token[3])])
|
||||
v := t + "." + n
|
||||
if op == 6 && v == "window.document.location" {
|
||||
v = "https://chatgpt.com/"
|
||||
}
|
||||
processMap[e] = v
|
||||
}
|
||||
case 8:
|
||||
if len(token) >= 3 {
|
||||
processMap[intValue(token[1])] = processMap[intValue(token[2])]
|
||||
}
|
||||
case 14:
|
||||
if len(token) >= 3 {
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(toStr(processMap[intValue(token[2])])), &parsed); err == nil {
|
||||
processMap[intValue(token[1])] = parsed
|
||||
}
|
||||
}
|
||||
case 15:
|
||||
if len(token) >= 3 {
|
||||
b, _ := json.Marshal(processMap[intValue(token[2])])
|
||||
processMap[intValue(token[1])] = string(b)
|
||||
}
|
||||
case 17:
|
||||
if len(token) >= 3 {
|
||||
e := intValue(token[1])
|
||||
target := toStr(processMap[intValue(token[2])])
|
||||
switch target {
|
||||
case "window.performance.now":
|
||||
processMap[e] = float64(time.Since(start).Nanoseconds())/1e6 + rng.Float64()
|
||||
case "window.Object.create":
|
||||
processMap[e] = newOrderedMap()
|
||||
case "window.Object.keys":
|
||||
processMap[e] = []string{
|
||||
"STATSIG_LOCAL_STORAGE_INTERNAL_STORE_V4",
|
||||
"STATSIG_LOCAL_STORAGE_STABLE_ID",
|
||||
"client-correlated-secret",
|
||||
"oai/apps/capExpiresAt",
|
||||
"oai-did",
|
||||
"STATSIG_LOCAL_STORAGE_LOGGING_REQUEST",
|
||||
"UiState.isNavigationCollapsed.1",
|
||||
}
|
||||
case "window.Math.random":
|
||||
processMap[e] = rng.Float64()
|
||||
}
|
||||
}
|
||||
case 18:
|
||||
if len(token) >= 2 {
|
||||
raw, err := base64.StdEncoding.DecodeString(toStr(processMap[intValue(token[1])]))
|
||||
if err == nil {
|
||||
processMap[intValue(token[1])] = string(raw)
|
||||
}
|
||||
}
|
||||
case 19:
|
||||
if len(token) >= 2 {
|
||||
processMap[intValue(token[1])] = base64.StdEncoding.EncodeToString([]byte(toStr(processMap[intValue(token[1])])))
|
||||
}
|
||||
case 20:
|
||||
if len(token) >= 4 {
|
||||
if toStr(processMap[intValue(token[1])]) == toStr(processMap[intValue(token[2])]) {
|
||||
if intValue(token[3]) == 3 && len(token) >= 5 {
|
||||
result = base64.StdEncoding.EncodeToString([]byte(toStr(processMap[intValue(token[4])])))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func xorString(text, key string) string {
|
||||
if key == "" {
|
||||
return text
|
||||
}
|
||||
out := make([]rune, 0, len(text))
|
||||
keyRunes := []rune(key)
|
||||
for i, ch := range text {
|
||||
out = append(out, ch^keyRunes[i%len(keyRunes)])
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
package chatgpt
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
baseURL = "https://chatgpt.com"
|
||||
defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36 Edg/149.0.0.0"
|
||||
defaultClientVersion = "prod-db390ebea64862bf1899c420a4c736e0cf639747"
|
||||
defaultClientBuildNumber = "7904904"
|
||||
defaultPOWScript = "https://chatgpt.com/backend-api/sentinel/sdk.js"
|
||||
// sseIdleGrace bounds how long startImageGeneration keeps reading the image
|
||||
// SSE with no activity at all before giving up. ChatGPT's inline image pipeline
|
||||
// holds the stream open with periodic ": ping" keepalives (~15s apart) while
|
||||
// the image renders, so this is an *idle* timeout reset on every received line
|
||||
// (see the watchdog in startImageGeneration): it only fires when the stream is
|
||||
// genuinely silent, not on the normal render wait.
|
||||
sseIdleGrace = 45 * time.Second
|
||||
|
||||
// pictureV2Command is the "@创建图片" ecosystem mention the ChatGPT web app
|
||||
// prepends to an image (picture_v2) prompt; the server identifies the image
|
||||
// command from this mention plus its custom_symbol_offset, so we send it to
|
||||
// match the browser exactly.
|
||||
pictureV2Command = "@创建图片"
|
||||
// pictureV2MentionEnd is the UTF-16 length of pictureV2Command — the
|
||||
// ecosystemMention custom_symbol_offset endIndex the web app reports.
|
||||
pictureV2MentionEnd = 5
|
||||
|
||||
// submitConnectRetries / submitRetryBackoff bound how many times the image
|
||||
// submit (the proxied /backend-api/f/conversation POST) is retried on a bare
|
||||
// connection error (EOF / reset) before surfacing it. The proxy hop
|
||||
// occasionally drops the connection; a fresh retry usually succeeds without
|
||||
// failing over to another account.
|
||||
submitConnectRetries = 2
|
||||
submitRetryBackoff = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
var (
|
||||
fileServiceIDPattern = regexp.MustCompile(`file-service://([A-Za-z0-9_-]+)`)
|
||||
sedimentIDPattern = regexp.MustCompile(`sediment://([A-Za-z0-9_-]+)`)
|
||||
realImageIDPattern = regexp.MustCompile(`\bfile_00000000[a-f0-9]{24}\b`)
|
||||
conversationIDRE = regexp.MustCompile(`"conversation_id"\s*:\s*"([^"]+)"`)
|
||||
scriptSrcRE = regexp.MustCompile(`<script[^>]+src="([^"]+)"`)
|
||||
dataBuildPathRE = regexp.MustCompile(`c/[^/]*/_`)
|
||||
htmlDataBuildRE = regexp.MustCompile(`<html[^>]*data-build="([^"]*)"`)
|
||||
|
||||
// asyncMarkers signal that ChatGPT accepted the prompt and switched to the
|
||||
// async image pipeline (image is delivered later via conversation polling
|
||||
// rather than inline in the SSE stream). Their presence means "generating —
|
||||
// keep polling", NOT failure.
|
||||
asyncMarkers = []string{"image_gen_async", "image_gen_task_id", "trigger_async_ux", "ImageGenToolTemporal", "image_gen_title"}
|
||||
|
||||
// contentPolicyMarkers are stable substrings of ChatGPT's content-audit
|
||||
// refusal message. When one appears in an assistant turn the prompt was
|
||||
// rejected upstream — no image will ever arrive, so we must fail fast
|
||||
// instead of polling until timeout.
|
||||
contentPolicyMarkers = []string{
|
||||
"this request may violate our content polic",
|
||||
"this prompt may violate our content polic",
|
||||
"may violate our content policies",
|
||||
"i can't help with",
|
||||
"i can\u2019t help with",
|
||||
"i cannot help with",
|
||||
"i can't assist with",
|
||||
"i can\u2019t assist with",
|
||||
"i cannot assist with",
|
||||
"i'm unable to help with",
|
||||
"i\u2019m unable to help with",
|
||||
"can't create images",
|
||||
"can\u2019t create images",
|
||||
"cannot create images",
|
||||
"can't generate images",
|
||||
"can\u2019t generate images",
|
||||
"cannot generate images",
|
||||
"unable to create images",
|
||||
"unable to generate images",
|
||||
}
|
||||
)
|
||||
|
||||
// containsAsyncMarker reports whether the SSE payload indicates the async image
|
||||
// pipeline was engaged.
|
||||
func containsAsyncMarker(text string) bool {
|
||||
for _, m := range asyncMarkers {
|
||||
if strings.Contains(text, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// detectContentPolicyRejection reports whether text contains a ChatGPT content
|
||||
// audit refusal. Matching is case-insensitive for the English variants.
|
||||
func detectContentPolicyRejection(text string) bool {
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(text)
|
||||
for _, m := range contentPolicyMarkers {
|
||||
if strings.Contains(text, m) || strings.Contains(lower, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// collectText concatenates every string found under value (recursively) into sb.
|
||||
func collectText(value any, sb *strings.Builder) {
|
||||
switch x := value.(type) {
|
||||
case string:
|
||||
sb.WriteString(x)
|
||||
sb.WriteByte('\n')
|
||||
case map[string]any:
|
||||
for _, item := range x {
|
||||
collectText(item, sb)
|
||||
}
|
||||
case []any:
|
||||
for _, item := range x {
|
||||
collectText(item, sb)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// conversationRejected scans assistant turns of a fetched conversation for a
|
||||
// content-policy refusal.
|
||||
func conversationRejected(conversation map[string]any) bool {
|
||||
mapping, _ := conversation["mapping"].(map[string]any)
|
||||
for _, rawNode := range mapping {
|
||||
node, _ := rawNode.(map[string]any)
|
||||
message, _ := node["message"].(map[string]any)
|
||||
if message == nil {
|
||||
continue
|
||||
}
|
||||
author, _ := message["author"].(map[string]any)
|
||||
role := strings.ToLower(strings.TrimSpace(stringValue(author["role"])))
|
||||
if role != "assistant" {
|
||||
continue
|
||||
}
|
||||
var sb strings.Builder
|
||||
collectText(message["content"], &sb)
|
||||
if detectContentPolicyRejection(sb.String()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// conversationEndedWithoutImage reports whether the model finished its turn
|
||||
// with a plain-text answer and never engaged the async image pipeline (no tool
|
||||
// turn, no image_gen task). ChatGPT localizes audit refusals, so this catches
|
||||
// rejections in ANY language structurally: a closed text-only turn can never
|
||||
// produce an image, and polling further only burns the budget.
|
||||
func conversationEndedWithoutImage(conversation map[string]any) bool {
|
||||
mapping, _ := conversation["mapping"].(map[string]any)
|
||||
if len(mapping) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, rawNode := range mapping {
|
||||
node, _ := rawNode.(map[string]any)
|
||||
message, _ := node["message"].(map[string]any)
|
||||
if message == nil {
|
||||
continue
|
||||
}
|
||||
if metadata, _ := message["metadata"].(map[string]any); metadata != nil {
|
||||
if stringValue(metadata["image_gen_task_id"]) != "" || metadata["image_gen_async"] == true {
|
||||
return false
|
||||
}
|
||||
}
|
||||
author, _ := message["author"].(map[string]any)
|
||||
role := strings.ToLower(strings.TrimSpace(stringValue(author["role"])))
|
||||
if role == "tool" {
|
||||
return false
|
||||
}
|
||||
if role == "assistant" {
|
||||
if content, _ := message["content"].(map[string]any); content != nil {
|
||||
// Only tool-invoking content types mean generation is underway.
|
||||
// Context nodes (model_editable_context, thoughts, …) also appear
|
||||
// on refused turns and must NOT suppress the detection.
|
||||
switch stringValue(content["content_type"]) {
|
||||
case "code", "multimodal_text":
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, rawNode := range mapping {
|
||||
node, _ := rawNode.(map[string]any)
|
||||
message, _ := node["message"].(map[string]any)
|
||||
if message == nil {
|
||||
continue
|
||||
}
|
||||
author, _ := message["author"].(map[string]any)
|
||||
if strings.ToLower(strings.TrimSpace(stringValue(author["role"]))) != "assistant" {
|
||||
continue
|
||||
}
|
||||
if message["end_turn"] != true || stringValue(message["status"]) != "finished_successfully" {
|
||||
continue
|
||||
}
|
||||
var sb strings.Builder
|
||||
collectText(message["content"], &sb)
|
||||
if strings.TrimSpace(sb.String()) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isConnResetErr reports whether err is a bare connection-level failure (the
|
||||
// peer/proxy closed the connection before responding) rather than an HTTP-level
|
||||
// error. Such failures are safe to retry: no response was received, so no
|
||||
// conversation was created upstream.
|
||||
func isConnResetErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
for _, m := range []string{"eof", "connection reset", "reset by peer", "broken pipe", "connection refused"} {
|
||||
if strings.Contains(msg, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func stringValue(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
|
||||
func intValue(v any) int {
|
||||
switch x := v.(type) {
|
||||
case int:
|
||||
return x
|
||||
case int64:
|
||||
return int(x)
|
||||
case float64:
|
||||
return int(x)
|
||||
case float32:
|
||||
return int(x)
|
||||
case json.Number:
|
||||
n, _ := x.Int64()
|
||||
return int(n)
|
||||
case string:
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(x))
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func decodeJWTPayload(token string) map[string]any {
|
||||
parts := strings.Split(strings.TrimSpace(token), ".")
|
||||
if len(parts) < 2 {
|
||||
return map[string]any{}
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func newUUID() string {
|
||||
return uuid.NewString()
|
||||
}
|
||||
|
||||
func clip(v []byte, n int) string {
|
||||
s := strings.TrimSpace(string(v))
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
|
||||
func parsePOWResources(html string) ([]string, string) {
|
||||
matches := scriptSrcRE.FindAllStringSubmatch(html, -1)
|
||||
sources := make([]string, 0, len(matches))
|
||||
dataBuild := ""
|
||||
for _, match := range matches {
|
||||
if len(match) < 2 {
|
||||
continue
|
||||
}
|
||||
src := strings.TrimSpace(match[1])
|
||||
if src == "" {
|
||||
continue
|
||||
}
|
||||
sources = append(sources, src)
|
||||
if dataBuild == "" {
|
||||
if path := dataBuildPathRE.FindString(src); path != "" {
|
||||
dataBuild = path
|
||||
}
|
||||
}
|
||||
}
|
||||
if dataBuild == "" {
|
||||
if match := htmlDataBuildRE.FindStringSubmatch(html); len(match) >= 2 {
|
||||
dataBuild = strings.TrimSpace(match[1])
|
||||
}
|
||||
}
|
||||
if len(sources) == 0 {
|
||||
sources = []string{defaultPOWScript}
|
||||
}
|
||||
return sources, dataBuild
|
||||
}
|
||||
|
||||
func timeMillis() int64 {
|
||||
return time.Now().UnixMilli()
|
||||
}
|
||||
@@ -1,629 +0,0 @@
|
||||
// Package creativefabrica implements the Creative Fabrica Studio
|
||||
// (studio.creativefabrica.com) video-generation upstream.
|
||||
//
|
||||
// One-shot accounts: every account's coins are just enough for exactly one
|
||||
// generation, so a successful render kills the account. The credential is a
|
||||
// .creativefabrica.com session cookie; a short-lived JWT is minted from it on
|
||||
// demand via GraphQL /query/userAuth, and every model request authenticates
|
||||
// with that JWT (the cookie is sent along as a fallback).
|
||||
package creativefabrica
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
fhttp "github.com/bogdanfinn/fhttp"
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
"github.com/bogdanfinn/tls-client/profiles"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAuth = errors.New("creativefabrica auth failed")
|
||||
ErrQuotaExhausted = errors.New("creativefabrica quota exhausted")
|
||||
ErrTemporaryUpstream = errors.New("creativefabrica upstream temporary error")
|
||||
ErrDeadUpstream = errors.New("creativefabrica upstream fatal error")
|
||||
ErrRateLimited = errors.New("creativefabrica rate limited")
|
||||
// ErrPaymentRequired marks an account whose payment intent is in a failed
|
||||
// state — it can never generate (the studio answers 400 failed_precondition
|
||||
// "payment required ... COIN_PAYMENT_INTENT_STATUS_FAILED"). It wraps ErrAuth
|
||||
// so the pool kills the account and fails over instead of burning retries.
|
||||
ErrPaymentRequired = fmt.Errorf("%w: payment required", ErrAuth)
|
||||
)
|
||||
|
||||
// isPaymentRequired reports whether a non-200 body is the account-level
|
||||
// "payment required" rejection rather than a request-level parameter error.
|
||||
//
|
||||
// Connect unary errors don't always carry the marker in plaintext: the studio
|
||||
// answers failed_precondition with the real detail base64-protobuf-encoded in
|
||||
// details[].value ("payment required. payment status: COIN_PAYMENT_INTENT_...").
|
||||
// Decode those values and scan the decoded bytes, so a failed coin intent still
|
||||
// kills the account instead of being misread as a request-level 400.
|
||||
func isPaymentRequired(status int, body string) bool {
|
||||
if status != 400 {
|
||||
return false
|
||||
}
|
||||
b := strings.ToLower(body)
|
||||
if strings.Contains(b, "payment required") || strings.Contains(b, "coin_payment_intent") {
|
||||
return true
|
||||
}
|
||||
var env struct {
|
||||
Details []struct {
|
||||
Value string `json:"value"`
|
||||
} `json:"details"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &env); err != nil {
|
||||
return false
|
||||
}
|
||||
for _, d := range env.Details {
|
||||
raw, err := base64.StdEncoding.DecodeString(d.Value)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
low := strings.ToLower(string(raw))
|
||||
if strings.Contains(low, "payment required") ||
|
||||
strings.Contains(low, "coin_payment_intent") ||
|
||||
strings.Contains(low, "coin_error_code_payment_required") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const (
|
||||
graphQLHost = "https://graphql-gw.creativefabrica.com"
|
||||
mediaMatrixHost = "https://studio-media-matrix.creativefabrica.com"
|
||||
userAuthPath = "/query/userAuth"
|
||||
userBalancePath = "/query/userBalance"
|
||||
userPath = "/query/user"
|
||||
initiatePath = "/creativefabrica.studiomediamatrix.v1.StudioMediaMatrixService/InitiateSession"
|
||||
listSessionsPath = "/creativefabrica.studiomediamatrix.v1.StudioMediaMatrixService/ListSessions"
|
||||
origin = "https://studio.creativefabrica.com"
|
||||
pollInterval = 5 * time.Second
|
||||
pollTimeout = 16 * time.Minute
|
||||
downloadTimeout = 3 * time.Minute
|
||||
videoServiceType = "SERVICE_TYPE_VIDEO_GENERATOR"
|
||||
videoFrameRef = "VIDEO_FRAME_TYPE_REFERENCE"
|
||||
visibilityPrivate = "SESSION_VISIBILITY_PRIVATE"
|
||||
)
|
||||
|
||||
// Model is one Creative Fabrica video model: the local catalog id, the upstream
|
||||
// enum, the fixed duration in seconds, and the upstream resolution label.
|
||||
type Model struct {
|
||||
ID string // local model_configs id, e.g. "seedance-2.0"
|
||||
Enum string // upstream enum, e.g. VIDEO_GENERATOR_MODEL_BYTEDANCE_SEEDDREAM_2
|
||||
Duration int // fixed seconds (account plan is fixed-length)
|
||||
Resolution string // upstream resolution label, e.g. 720p
|
||||
}
|
||||
|
||||
// Models returns the two Creative Fabrica seedance models. The upstream enum
|
||||
// differs between the two (SEEDANCE_2_FAST vs SEEDDREAM_2), matching the
|
||||
// studio frontend's InitiateSession payloads.
|
||||
func Models() map[string]Model {
|
||||
return map[string]Model{
|
||||
"seedance-2.0-fast": {
|
||||
ID: "seedance-2.0-fast",
|
||||
Enum: "VIDEO_GENERATOR_MODEL_BYTEDANCE_SEEDANCE_2_FAST",
|
||||
Duration: 14,
|
||||
Resolution: "720p",
|
||||
},
|
||||
"seedance-2.0": {
|
||||
ID: "seedance-2.0",
|
||||
Enum: "VIDEO_GENERATOR_MODEL_BYTEDANCE_SEEDDREAM_2",
|
||||
Duration: 10,
|
||||
Resolution: "720p",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// LookupModel resolves a local model id to its upstream config. Returns ok=false
|
||||
// when the id isn't a Creative Fabrica model.
|
||||
func LookupModel(modelID string) (Model, bool) {
|
||||
m, ok := Models()[modelID]
|
||||
return m, ok
|
||||
}
|
||||
|
||||
// Client talks to the Creative Fabrica Studio API through a Chrome-fingerprinted
|
||||
// TLS client so the Cloudflare-protected Connect endpoints don't reject us.
|
||||
type Client struct {
|
||||
proxy string
|
||||
}
|
||||
|
||||
func NewClient(proxy string) *Client {
|
||||
return &Client{proxy: strings.TrimSpace(proxy)}
|
||||
}
|
||||
|
||||
func (c *Client) SetProxy(proxy string) {
|
||||
c.proxy = strings.TrimSpace(proxy)
|
||||
}
|
||||
|
||||
// ExchangeToken mints the short-lived JWT from the account cookie via GraphQL
|
||||
// /query/userAuth. Returns the token and the user id. A null / missing me means
|
||||
// the cookie no longer authenticates → ErrAuth.
|
||||
func (c *Client) ExchangeToken(ctx context.Context, cookie string) (token, userID string, err error) {
|
||||
query := `{"query":"\n query userAuth {\n me {\n token\n user {\n id\n isTemporary\n }\n }\n}\n "}`
|
||||
var payload struct {
|
||||
Data struct {
|
||||
Me *struct {
|
||||
Token string `json:"token"`
|
||||
User struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"user"`
|
||||
} `json:"me"`
|
||||
} `json:"data"`
|
||||
}
|
||||
body, err := c.postGraphQL(ctx, cookie, "", userAuthPath, query)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return "", "", fmt.Errorf("%w: bad token response: %s", ErrAuth, clip(body, 300))
|
||||
}
|
||||
if payload.Data.Me == nil || strings.TrimSpace(payload.Data.Me.Token) == "" {
|
||||
return "", "", fmt.Errorf("%w: cookie did not authenticate", ErrAuth)
|
||||
}
|
||||
return payload.Data.Me.Token, payload.Data.Me.User.ID, nil
|
||||
}
|
||||
|
||||
// FetchBalance reads the coin balance via GraphQL /query/userBalance (the
|
||||
// request authenticates with the cookie alone). Negative value on error.
|
||||
func (c *Client) FetchBalance(ctx context.Context, cookie string) (int64, error) {
|
||||
query := `{"query":"\nquery userBalance {\n userBalance {\n balance\n }\n}\n\n"}`
|
||||
var payload struct {
|
||||
Data struct {
|
||||
UserBalance *struct {
|
||||
Balance json.Number `json:"balance"`
|
||||
} `json:"userBalance"`
|
||||
} `json:"data"`
|
||||
}
|
||||
body, err := c.postGraphQL(ctx, cookie, "", userBalancePath, query)
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return -1, fmt.Errorf("bad balance response: %s", clip(body, 300))
|
||||
}
|
||||
if payload.Data.UserBalance == nil {
|
||||
return -1, ErrAuth
|
||||
}
|
||||
b, _ := payload.Data.UserBalance.Balance.Int64()
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// FetchUser reads the profile (email, name) via GraphQL /query/user, which the
|
||||
// studio browser hits on every page load. The request authenticates with the
|
||||
// cookie alone. Returns the email (empty on error); a null me means the cookie
|
||||
// no longer authenticates → ErrAuth.
|
||||
func (c *Client) FetchUser(ctx context.Context, cookie string) (string, error) {
|
||||
query := `{"query":"\n query user {\n me {\n token\n user {\n id\n email\n }\n }\n}\n "}`
|
||||
var payload struct {
|
||||
Data struct {
|
||||
Me *struct {
|
||||
Token string `json:"token"`
|
||||
User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
} `json:"user"`
|
||||
} `json:"me"`
|
||||
} `json:"data"`
|
||||
}
|
||||
body, err := c.postGraphQL(ctx, cookie, "", userPath, query)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return "", fmt.Errorf("%w: bad user response: %s", ErrAuth, clip(body, 300))
|
||||
}
|
||||
if payload.Data.Me == nil || payload.Data.Me.User.ID == "" {
|
||||
return "", ErrAuth
|
||||
}
|
||||
return strings.TrimSpace(payload.Data.Me.User.Email), nil
|
||||
}
|
||||
|
||||
// GenerateVideo runs the full generation: InitiateSession → PUT reference
|
||||
// images to the presigned S3 URLs → poll ListSessions until COMPLETED → (when
|
||||
// downloadResult) fetch the MP4. Returns the bytes (nil when url-only) and the
|
||||
// previewMediaUrl. durationSeconds is ignored: the plan fixes the length per
|
||||
// model (LookupModel.Duration).
|
||||
func (c *Client) GenerateVideo(ctx context.Context, cookie, token, modelID, prompt, aspectRatio string, refs [][]byte, downloadResult bool) ([]byte, string, error) {
|
||||
m, ok := LookupModel(modelID)
|
||||
if !ok {
|
||||
return nil, "", fmt.Errorf("creativefabrica: unknown model %q", modelID)
|
||||
}
|
||||
sessionID, uploads, err := c.initiateSession(ctx, cookie, token, m, prompt, aspectRatio, refs)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
// Each ref uploads to the presigned URL the session returned for it.
|
||||
for i, u := range uploads {
|
||||
if err := c.putS3(ctx, u, refs[i]); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
videoURL, err := c.pollSession(ctx, cookie, token, sessionID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if !downloadResult {
|
||||
return nil, videoURL, nil
|
||||
}
|
||||
data, err := c.download(ctx, videoURL)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return data, videoURL, nil
|
||||
}
|
||||
|
||||
// initiateSession submits the generation and returns the session id plus the
|
||||
// presigned upload URLs (one per reference image).
|
||||
func (c *Client) initiateSession(ctx context.Context, cookie, token string, m Model, prompt, aspectRatio string, refs [][]byte) (string, []string, error) {
|
||||
frames := make([]any, 0, len(refs))
|
||||
refPrompt := strings.TrimSpace(prompt)
|
||||
for i := range refs {
|
||||
ref := fmt.Sprintf("img%d", i+1)
|
||||
frames = append(frames, map[string]any{
|
||||
"type": videoFrameRef,
|
||||
"fileSize": fmt.Sprintf("%d", len(refs[i])),
|
||||
"fileName": randomFileName(i + 1),
|
||||
"ref": ref,
|
||||
})
|
||||
if !strings.Contains(refPrompt, "["+ref+"]") {
|
||||
refPrompt += " Use [" + ref + "]"
|
||||
}
|
||||
}
|
||||
reqBody := map[string]any{
|
||||
"visibility": visibilityPrivate,
|
||||
"sessionRequestPromptToVideoGeneratorContent": map[string]any{
|
||||
"serviceType": videoServiceType,
|
||||
"promptContent": map[string]any{"prompt": refPrompt},
|
||||
"resolution": resolutionEnum(m.Resolution),
|
||||
"model": m.Enum,
|
||||
"frames": frames,
|
||||
"aspectRatio": aspectRatioEnum(aspectRatio),
|
||||
"directorConfig": map[string]any{
|
||||
"filmStock": map[string]any{"color": "DIRECTOR_FILM_STOCK_COLOR_FULL_COLOR"},
|
||||
},
|
||||
"videoDuration": map[string]any{"inSeconds": m.Duration},
|
||||
},
|
||||
}
|
||||
body, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
resp, err := c.postConnect(ctx, cookie, token, initiatePath, body, true)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
var payload struct {
|
||||
Session struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
// The presigned upload URLs are nested under promptToVideoGeneratorContent.frames
|
||||
// (each frame echoes the request frame + the S3 presigned `url`), not under a
|
||||
// top-level session.frames. Reading the wrong level yields 0 uploads.
|
||||
PromptToVideoGeneratorContent struct {
|
||||
Frames []struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"frames"`
|
||||
} `json:"promptToVideoGeneratorContent"`
|
||||
} `json:"session"`
|
||||
}
|
||||
if err := json.Unmarshal(resp, &payload); err != nil {
|
||||
return "", nil, fmt.Errorf("creativefabrica bad initiate response: %s", clip(resp, 300))
|
||||
}
|
||||
if strings.TrimSpace(payload.Session.ID) == "" {
|
||||
return "", nil, fmt.Errorf("creativefabrica initiate missing session: %s", clip(resp, 300))
|
||||
}
|
||||
respFrames := payload.Session.PromptToVideoGeneratorContent.Frames
|
||||
uploads := make([]string, 0, len(respFrames))
|
||||
for _, f := range respFrames {
|
||||
if u := strings.TrimSpace(f.URL); u != "" {
|
||||
uploads = append(uploads, u)
|
||||
}
|
||||
}
|
||||
if len(uploads) != len(refs) {
|
||||
return "", nil, fmt.Errorf("creativefabrica initiate returned %d upload urls for %d refs", len(uploads), len(refs))
|
||||
}
|
||||
return payload.Session.ID, uploads, nil
|
||||
}
|
||||
|
||||
// pollSession polls ListSessions until the session is COMPLETED / FAILED and
|
||||
// returns previewMediaUrl on success.
|
||||
func (c *Client) pollSession(ctx context.Context, cookie, token, sessionID string) (string, error) {
|
||||
reqBody, _ := json.Marshal(map[string]any{
|
||||
"serviceType": videoServiceType,
|
||||
"pagination": map[string]any{"take": 100},
|
||||
"surface": "SURFACE_STUDIO",
|
||||
})
|
||||
deadline := time.Now().Add(pollTimeout)
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return "", fmt.Errorf("creativefabrica generation timed out after %v", pollTimeout)
|
||||
}
|
||||
resp, err := c.postConnect(ctx, cookie, token, listSessionsPath, reqBody, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var payload struct {
|
||||
Sessions []map[string]any `json:"sessions"`
|
||||
}
|
||||
if err := json.Unmarshal(resp, &payload); err != nil {
|
||||
return "", fmt.Errorf("creativefabrica bad list response: %s", clip(resp, 300))
|
||||
}
|
||||
for _, s := range payload.Sessions {
|
||||
id, _ := s["id"].(string)
|
||||
if id != sessionID {
|
||||
continue
|
||||
}
|
||||
status, _ := s["status"].(string)
|
||||
switch status {
|
||||
case "SESSION_STATUS_COMPLETED":
|
||||
if u := strings.TrimSpace(stringValue(s["previewMediaUrl"])); u != "" {
|
||||
return u, nil
|
||||
}
|
||||
return "", fmt.Errorf("creativefabrica session completed without preview url")
|
||||
case "SESSION_STATUS_FAILED", "SESSION_STATUS_CANCELLED", "SESSION_STATUS_ERROR":
|
||||
detail := ""
|
||||
for _, k := range []string{"errorMessage", "failureReason", "error", "message"} {
|
||||
if v, ok := s[k]; ok {
|
||||
if d := strings.TrimSpace(stringValue(v)); d != "" {
|
||||
detail = d
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("creativefabrica session %s%s", status, withDetail(detail))
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-time.After(pollInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// putS3 uploads a reference image to the presigned URL. S3 doesn't care about
|
||||
// TLS fingerprinting, so a plain client is fine here.
|
||||
func (c *Client) putS3(ctx context.Context, presigned string, data []byte) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, presigned, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "image/png")
|
||||
req.Header.Set("Origin", origin)
|
||||
req.Header.Set("Referer", origin+"/")
|
||||
req.Header.Set("User-Agent", defaultUA())
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creativefabrica s3 upload: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("creativefabrica s3 upload failed: %d %s", resp.StatusCode, clip(body, 200))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// download fetches the finished MP4 from the public video-v2 URL.
|
||||
func (c *Client) download(parent context.Context, videoURL string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(parent), downloadTimeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, videoURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("User-Agent", defaultUA())
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: download: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("creativefabrica download failed: %d", resp.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: read body: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// postGraphQL calls a graphql-gw endpoint with the raw JSON body. Authenticated
|
||||
// by cookie (balance) and optionally the JWT too. Direct connection (no proxy).
|
||||
func (c *Client) postGraphQL(ctx context.Context, cookie, token, path, body string) ([]byte, error) {
|
||||
return c.postJSON(ctx, graphQLHost+path, token, cookie, []byte(body), false, true, false)
|
||||
}
|
||||
|
||||
// postConnect calls a Connect unary endpoint on the media-matrix service. The
|
||||
// Connect-Protocol-Version header marks the POST as a Connect RPC (distinct from
|
||||
// a plain JSON REST POST) — the studio browser always sends it. Only the
|
||||
// InitiateSession call (下单) egresses through the proxy; polling runs direct.
|
||||
func (c *Client) postConnect(ctx context.Context, cookie, token, path string, body []byte, useProxy bool) ([]byte, error) {
|
||||
return c.postJSON(ctx, mediaMatrixHost+path, token, cookie, body, true, false, useProxy)
|
||||
}
|
||||
|
||||
func (c *Client) postJSON(ctx context.Context, url, token, cookie string, body []byte, connect, graphql, useProxy bool) ([]byte, error) {
|
||||
sess, err := c.newTLSClient(useProxy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := fhttp.NewRequest(fhttp.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = fhttp.Header{
|
||||
"content-type": {"application/json"},
|
||||
"accept": {"*/*"},
|
||||
"origin": {origin},
|
||||
"referer": {origin + "/"},
|
||||
"user-agent": {defaultUA()},
|
||||
}
|
||||
if graphql {
|
||||
req.Header.Set("accept", "application/json, multipart/mixed")
|
||||
}
|
||||
if connect {
|
||||
req.Header.Set("connect-protocol-version", "1")
|
||||
}
|
||||
if cookie != "" {
|
||||
req.Header.Set("cookie", cookie)
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := sess.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creativefabrica request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch {
|
||||
case resp.StatusCode == 401 || resp.StatusCode == 403:
|
||||
return nil, fmt.Errorf("%w (%d: %s)", ErrAuth, resp.StatusCode, clip(data, 300))
|
||||
case isPaymentRequired(resp.StatusCode, string(data)):
|
||||
return nil, fmt.Errorf("%w (%d: %s)", ErrPaymentRequired, resp.StatusCode, clip(data, 300))
|
||||
case resp.StatusCode == 429:
|
||||
return nil, fmt.Errorf("%w (429)", ErrRateLimited)
|
||||
case resp.StatusCode >= 500:
|
||||
return nil, fmt.Errorf("%w (%d: %s)", ErrDeadUpstream, resp.StatusCode, clip(data, 300))
|
||||
case resp.StatusCode != 200:
|
||||
return nil, fmt.Errorf("creativefabrica %d: %s", resp.StatusCode, clip(data, 300))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func resolutionEnum(res string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(res)) {
|
||||
case "720p":
|
||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_RESOLUTION_720P"
|
||||
case "1080p":
|
||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_RESOLUTION_1080P"
|
||||
default:
|
||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_RESOLUTION_720P"
|
||||
}
|
||||
}
|
||||
|
||||
func aspectRatioEnum(ratio string) string {
|
||||
switch strings.ReplaceAll(strings.TrimSpace(ratio), " ", "") {
|
||||
case "16:9":
|
||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_16_9"
|
||||
case "9:16":
|
||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_9_16"
|
||||
case "1:1":
|
||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_1_1"
|
||||
case "4:3":
|
||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_4_3"
|
||||
case "3:4":
|
||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_3_4"
|
||||
default:
|
||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_16_9"
|
||||
}
|
||||
}
|
||||
|
||||
// randomFileName mimics the studio's client-generated reference name
|
||||
// ("_<base36-ish id>_<n>.png"). The value only needs to be unique per upload.
|
||||
func randomFileName(n int) string {
|
||||
return "_" + randomID() + "_" + fmt.Sprintf("%d", n) + ".png"
|
||||
}
|
||||
|
||||
func randomID() string {
|
||||
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
b := make([]byte, 26)
|
||||
for i := range b {
|
||||
v, _ := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
|
||||
b[i] = alphabet[v.Int64()]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// TLS client plumbing: a Chrome-fingerprinted client so the Cloudflare front of
|
||||
// the Connect endpoints sees a plausible browser handshake.
|
||||
var fingerprints = []profiles.ClientProfile{
|
||||
profiles.Chrome_146,
|
||||
profiles.Chrome_144,
|
||||
profiles.Chrome_133,
|
||||
profiles.Chrome_131,
|
||||
}
|
||||
|
||||
type tlsSession struct {
|
||||
client tlsclient.HttpClient
|
||||
}
|
||||
|
||||
func (c *Client) newTLSClient(useProxy bool) (*tlsSession, error) {
|
||||
idx, _ := rand.Int(rand.Reader, big.NewInt(int64(len(fingerprints))))
|
||||
options := []tlsclient.HttpClientOption{
|
||||
tlsclient.WithTimeoutSeconds(60),
|
||||
tlsclient.WithClientProfile(fingerprints[idx.Int64()]),
|
||||
tlsclient.WithNotFollowRedirects(),
|
||||
tlsclient.WithRandomTLSExtensionOrder(),
|
||||
}
|
||||
if useProxy && c.proxy != "" {
|
||||
options = append(options, tlsclient.WithProxyUrl(c.proxy))
|
||||
}
|
||||
client, err := tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tlsSession{client: client}, nil
|
||||
}
|
||||
|
||||
func defaultUA() string {
|
||||
return "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
|
||||
}
|
||||
|
||||
func clip(v []byte, n int) string {
|
||||
s := strings.TrimSpace(string(v))
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
|
||||
// stringValue coerces a decoded JSON value to its string form.
|
||||
func stringValue(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case float64:
|
||||
return fmt.Sprintf("%.0f", x)
|
||||
case json.Number:
|
||||
return x.String()
|
||||
case bool:
|
||||
if x {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
return fmt.Sprintf("%v", x)
|
||||
}
|
||||
}
|
||||
|
||||
// withDetail appends a failure detail to an error message when present.
|
||||
func withDetail(detail string) string {
|
||||
if detail == "" {
|
||||
return ""
|
||||
}
|
||||
return " (" + detail + ")"
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
package creativefabrica
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBalance(t *testing.T) {
|
||||
cookie := os.Getenv("CF_COOKIE")
|
||||
if cookie == "" {
|
||||
t.Skip("CF_COOKIE not set")
|
||||
}
|
||||
c := NewClient("")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
tok, uid, err := c.ExchangeToken(ctx, cookie)
|
||||
if err != nil {
|
||||
t.Fatalf("exchange: %v", err)
|
||||
}
|
||||
bal, err := c.FetchBalance(ctx, cookie)
|
||||
if err != nil {
|
||||
t.Fatalf("balance: %v", err)
|
||||
}
|
||||
t.Logf("user=%s token=%d bal=%d", uid, len(tok), bal)
|
||||
}
|
||||
@@ -1,381 +0,0 @@
|
||||
// Package custom implements a generic OpenAI-compatible upstream client. A
|
||||
// "custom" model forwards generation to any OpenAI-compatible API: the upstream
|
||||
// base_url + api_key live on a custom account (pool="custom"), the upstream model
|
||||
// name on the model config (UpstreamModel). Calls go DIRECT (no tls-client, no
|
||||
// proxy) — the upstream is a normal API with no anti-bot.
|
||||
package custom
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAuth = errors.New("custom upstream auth failed")
|
||||
ErrQuotaExhausted = errors.New("custom upstream quota exhausted")
|
||||
ErrTemporaryUpstream = errors.New("custom upstream temporary error")
|
||||
)
|
||||
|
||||
type Client struct{}
|
||||
|
||||
func NewClient() *Client { return &Client{} }
|
||||
|
||||
// sanitizeErr strips the upstream URL/host from a network error so a user's
|
||||
// private upstream URL never leaks into the event log / API response.
|
||||
func sanitizeErr(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
s := err.Error()
|
||||
switch {
|
||||
case strings.Contains(s, "context deadline exceeded"), strings.Contains(s, "Client.Timeout"), strings.Contains(s, "timeout"):
|
||||
return "request timeout"
|
||||
case strings.Contains(s, "connection refused"):
|
||||
return "connection refused"
|
||||
case strings.Contains(s, "no such host"), strings.Contains(s, "dial tcp"), strings.Contains(s, "lookup "):
|
||||
return "cannot reach upstream"
|
||||
case strings.Contains(s, "tls"), strings.Contains(s, "TLS"), strings.Contains(s, "certificate"):
|
||||
return "TLS error"
|
||||
case strings.Contains(s, "EOF"), strings.Contains(s, "reset by peer"), strings.Contains(s, "broken pipe"):
|
||||
return "connection reset"
|
||||
}
|
||||
var ue *url.Error
|
||||
if errors.As(err, &ue) {
|
||||
return strings.ToLower(ue.Op) + " upstream failed"
|
||||
}
|
||||
return "upstream request failed"
|
||||
}
|
||||
|
||||
func httpClient() *http.Client { return &http.Client{Timeout: 30 * time.Minute} }
|
||||
|
||||
// GenerateImage calls the upstream OpenAI image API. With reference images it
|
||||
// uses /v1/images/edits (multipart); otherwise /v1/images/generations. Returns
|
||||
// the raw image bytes (decoded from b64_json, or downloaded from url).
|
||||
func (c *Client) GenerateImage(ctx context.Context, baseURL, apiKey, model, prompt, size, quality string, refs [][]byte, downloadResult bool) ([]byte, string, error) {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if baseURL == "" || apiKey == "" {
|
||||
return nil, "", ErrAuth
|
||||
}
|
||||
var req *http.Request
|
||||
var err error
|
||||
if len(refs) > 0 {
|
||||
body := &bytes.Buffer{}
|
||||
w := multipart.NewWriter(body)
|
||||
_ = w.WriteField("model", model)
|
||||
_ = w.WriteField("prompt", prompt)
|
||||
// Ask the upstream for a URL (not base64) so we can pass it through directly.
|
||||
_ = w.WriteField("response_format", "url")
|
||||
if size != "" {
|
||||
_ = w.WriteField("size", size)
|
||||
}
|
||||
for i, r := range refs {
|
||||
fw, e := w.CreateFormFile("image[]", fmt.Sprintf("ref_%d.png", i+1))
|
||||
if e != nil {
|
||||
return nil, "", e
|
||||
}
|
||||
_, _ = fw.Write(r)
|
||||
}
|
||||
_ = w.Close()
|
||||
req, err = http.NewRequest(http.MethodPost, baseURL+"/v1/images/edits", body)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
} else {
|
||||
payload := map[string]any{"model": model, "prompt": prompt, "n": 1, "response_format": "url"}
|
||||
if size != "" {
|
||||
payload["size"] = size
|
||||
}
|
||||
if quality != "" {
|
||||
payload["quality"] = quality
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
req, err = http.NewRequest(http.MethodPost, baseURL+"/v1/images/generations", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
resp, err := httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if e := mapStatus(resp.StatusCode, body); e != nil {
|
||||
return nil, "", e
|
||||
}
|
||||
return imageFromResponse(ctx, body, downloadResult)
|
||||
}
|
||||
|
||||
// GenerateVideo drives the upstream Sora-style async video API:
|
||||
// POST /v1/videos → poll GET /v1/videos/{id} → GET /v1/videos/{id}/content.
|
||||
// Reference frames (image-to-video / first-last frames) are sent as multipart
|
||||
// input_reference[] files, matching the OpenAI videos API. When downloadResult
|
||||
// is false it returns the upstream content URL instead.
|
||||
func (c *Client) GenerateVideo(ctx context.Context, baseURL, apiKey, model, prompt, size string, seconds int, frames [][]byte, downloadResult bool) ([]byte, string, error) {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if baseURL == "" || apiKey == "" {
|
||||
return nil, "", ErrAuth
|
||||
}
|
||||
var created map[string]any
|
||||
var err error
|
||||
if len(frames) > 0 {
|
||||
body := &bytes.Buffer{}
|
||||
w := multipart.NewWriter(body)
|
||||
_ = w.WriteField("model", model)
|
||||
_ = w.WriteField("prompt", prompt)
|
||||
if size != "" {
|
||||
_ = w.WriteField("size", size)
|
||||
}
|
||||
if seconds > 0 {
|
||||
_ = w.WriteField("seconds", fmt.Sprintf("%d", seconds))
|
||||
}
|
||||
for i, f := range frames {
|
||||
fw, e := w.CreateFormFile("input_reference[]", fmt.Sprintf("frame_%d.png", i+1))
|
||||
if e != nil {
|
||||
return nil, "", e
|
||||
}
|
||||
_, _ = fw.Write(f)
|
||||
}
|
||||
_ = w.Close()
|
||||
created, err = c.doMultipart(ctx, baseURL+"/v1/videos", apiKey, body, w.FormDataContentType())
|
||||
} else {
|
||||
payload := map[string]any{"model": model, "prompt": prompt}
|
||||
if size != "" {
|
||||
payload["size"] = size
|
||||
}
|
||||
if seconds > 0 {
|
||||
payload["seconds"] = fmt.Sprintf("%d", seconds)
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
created, err = c.doJSON(ctx, http.MethodPost, baseURL+"/v1/videos", apiKey, raw)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
jobID := strings.TrimSpace(stringValue(created["id"]))
|
||||
if jobID == "" {
|
||||
return nil, "", fmt.Errorf("%w: video create missing id", ErrTemporaryUpstream)
|
||||
}
|
||||
// Poll until terminal.
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
job, err := c.doJSON(ctx, http.MethodGet, baseURL+"/v1/videos/"+jobID, apiKey, nil)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrTemporaryUpstream) {
|
||||
if sleepCtx(ctx, 5*time.Second) != nil {
|
||||
return nil, "", ctx.Err()
|
||||
}
|
||||
continue
|
||||
}
|
||||
return nil, "", err
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(stringValue(job["status"]))) {
|
||||
case "completed", "succeeded", "success":
|
||||
contentURL := baseURL + "/v1/videos/" + jobID + "/content"
|
||||
if !downloadResult {
|
||||
return nil, contentURL, nil
|
||||
}
|
||||
data, err := c.download(ctx, contentURL, apiKey)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return data, contentURL, nil
|
||||
case "failed", "error", "canceled", "cancelled":
|
||||
reason := stringValue(job["error"])
|
||||
if isCreditError(reason) {
|
||||
return nil, "", fmt.Errorf("%w: %s", ErrTemporaryUpstream, clip([]byte(reason), 160))
|
||||
}
|
||||
return nil, "", fmt.Errorf("custom: video %s", clip([]byte(reason), 160))
|
||||
}
|
||||
if sleepCtx(ctx, 5*time.Second) != nil {
|
||||
return nil, "", ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) doJSON(ctx context.Context, method, url, apiKey string, body []byte) (map[string]any, error) {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
reader = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequest(method, url, reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if e := mapStatus(resp.StatusCode, raw); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
var out map[string]any
|
||||
if len(raw) == 0 {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, fmt.Errorf("%w: non-json: %s", ErrTemporaryUpstream, clip(raw, 120))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Client) doMultipart(ctx context.Context, url, apiKey string, body io.Reader, contentType string) (map[string]any, error) {
|
||||
req, err := http.NewRequest(http.MethodPost, url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
resp, err := httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if e := mapStatus(resp.StatusCode, raw); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
var out map[string]any
|
||||
if len(raw) == 0 {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, fmt.Errorf("%w: non-json: %s", ErrTemporaryUpstream, clip(raw, 120))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Client) download(ctx context.Context, url, apiKey string) ([]byte, error) {
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
req = req.WithContext(ctx)
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
resp, err := httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("%w: download %d", ErrTemporaryUpstream, resp.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, fmt.Errorf("%w: empty download", ErrTemporaryUpstream)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// imageBytesFromResponse extracts image bytes from an OpenAI images response:
|
||||
// data[0].b64_json (preferred) or data[0].url (downloaded).
|
||||
// imageFromResponse parses an OpenAI image response and returns the upstream URL.
|
||||
// We always request response_format=url, so the response must carry a URL — a
|
||||
// base64-only response is treated as an error (no base64 pass-through). With
|
||||
// downloadResult=false the URL is returned directly (no download).
|
||||
func imageFromResponse(ctx context.Context, body []byte, downloadResult bool) ([]byte, string, error) {
|
||||
var out struct {
|
||||
Data []struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil || len(out.Data) == 0 {
|
||||
return nil, "", fmt.Errorf("%w: bad image response: %s", ErrTemporaryUpstream, clip(body, 160))
|
||||
}
|
||||
url := strings.TrimSpace(out.Data[0].URL)
|
||||
if url == "" {
|
||||
return nil, "", fmt.Errorf("%w: image response had no url (upstream ignored response_format=url)", ErrTemporaryUpstream)
|
||||
}
|
||||
if !downloadResult {
|
||||
return nil, url, nil
|
||||
}
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
resp, err := httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
return raw, url, err
|
||||
}
|
||||
|
||||
func mapStatus(status int, body []byte) error {
|
||||
switch {
|
||||
case status >= 200 && status < 300:
|
||||
return nil
|
||||
case status == 401 || status == 403:
|
||||
return fmt.Errorf("%w: %d %s", ErrAuth, status, clip(body, 160))
|
||||
case status == 429:
|
||||
// Custom upstreams have NO "quota exhausted" lock — a 429 is just rate
|
||||
// limiting, treated as a temporary error (fail over, account stays active).
|
||||
return fmt.Errorf("%w: 429 %s", ErrTemporaryUpstream, clip(body, 160))
|
||||
case status >= 500:
|
||||
return fmt.Errorf("%w: %d %s", ErrTemporaryUpstream, status, clip(body, 160))
|
||||
default:
|
||||
if isCreditError(string(body)) {
|
||||
return fmt.Errorf("%w: %s", ErrTemporaryUpstream, clip(body, 160))
|
||||
}
|
||||
return fmt.Errorf("custom: %d %s", status, clip(body, 160))
|
||||
}
|
||||
}
|
||||
|
||||
func isCreditError(s string) bool {
|
||||
s = strings.ToLower(s)
|
||||
return strings.Contains(s, "insufficient") || strings.Contains(s, "quota") ||
|
||||
strings.Contains(s, "credit") || strings.Contains(s, "balance")
|
||||
}
|
||||
|
||||
func stringValue(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
b, _ := json.Marshal(x)
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
}
|
||||
|
||||
func clip(b []byte, n int) string {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if len(s) > n {
|
||||
return s[:n]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func sleepCtx(ctx context.Context, d time.Duration) error {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-t.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,935 +0,0 @@
|
||||
// Package grok implements the Grok (grok.com / xAI) provider client. Auth is the
|
||||
// website "sso" session cookie (a JWT whose only claim is a session_id — no exp,
|
||||
// no refresh: when the session dies upstream the account is simply dead, never
|
||||
// renewed). grok.com gates requests with an x-statsig-id header; its value
|
||||
// is a 70-byte anti-bot record — header[49] | counter_le32 |
|
||||
// sha256("METHOD!path!counter"+suffix)[:16] | trailer — XOR-masked with one
|
||||
// random byte and base64-encoded; we reproduce it exactly in statsigID (the
|
||||
// build-specific header/suffix/trailer are env-overridable when grok rotates
|
||||
// them). Uses tls-client so the JA3/JA4 fingerprint matches Chrome.
|
||||
package grok
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
http "github.com/bogdanfinn/fhttp"
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
"github.com/bogdanfinn/tls-client/profiles"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
apiBase = "https://grok.com"
|
||||
origin = "https://grok.com"
|
||||
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"
|
||||
// fullCredits is the weekly grant — UI shows "100 满额".
|
||||
fullCredits = 100
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAuth = errors.New("grok auth failed")
|
||||
ErrQuotaExhausted = errors.New("grok quota exhausted")
|
||||
ErrTemporaryUpstream = errors.New("grok upstream temporary error")
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
proxy string
|
||||
}
|
||||
|
||||
func NewClient(proxy string) *Client {
|
||||
return &Client{proxy: strings.TrimSpace(proxy)}
|
||||
}
|
||||
|
||||
func (c *Client) SetProxy(proxy string) {
|
||||
c.proxy = strings.TrimSpace(proxy)
|
||||
}
|
||||
|
||||
// IsGrokToken reports whether a JWT looks like a Grok website "sso" cookie: a
|
||||
// payload whose ONLY claim is "session_id". That disambiguates it from a runway
|
||||
// token (id + sso claims) or a chatgpt token (openai.com claims).
|
||||
func IsGrokToken(token string) bool {
|
||||
claims := decodeJWTPayload(token)
|
||||
if len(claims) == 0 {
|
||||
return false
|
||||
}
|
||||
if _, ok := claims["session_id"]; !ok {
|
||||
return false
|
||||
}
|
||||
// Reject tokens that ALSO carry other-provider markers.
|
||||
for k := range claims {
|
||||
if k == "session_id" {
|
||||
continue
|
||||
}
|
||||
if k == "sso" || k == "id" || strings.HasPrefix(k, "https://api.openai.com/") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// SessionIDFromToken returns the sso session id (for dedup / display).
|
||||
func SessionIDFromToken(token string) string {
|
||||
return strings.TrimSpace(stringValue(decodeJWTPayload(token)["session_id"]))
|
||||
}
|
||||
|
||||
// ExtractAccountInfo returns the free (no-network) account view. grok sso has no
|
||||
// email/exp claim, so identity falls back to the session id.
|
||||
func ExtractAccountInfo(token string) map[string]any {
|
||||
sid := SessionIDFromToken(token)
|
||||
return map[string]any{
|
||||
"email": emptyStringNil(sid),
|
||||
"session_id": emptyStringNil(sid),
|
||||
"expires_at": nil,
|
||||
}
|
||||
}
|
||||
|
||||
// FetchCreditsBalance reads the account's live credit balance via the billing
|
||||
// gRPC-web endpoint GetGrokCreditsConfig (empty request). The response carries
|
||||
// the remaining credits (field 1, a float32) and the weekly reset timestamp
|
||||
// (field 5). A 401/403 maps to ErrAuth (the session is dead). Returns the
|
||||
// normalized map the TokenService quota plumbing expects.
|
||||
func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[string]any, error) {
|
||||
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
|
||||
if token == "" {
|
||||
return unknownBalance("empty token"), nil
|
||||
}
|
||||
client, err := c.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.ensureChallenge(ctx, client, token)
|
||||
// gRPC-web empty message frame: 1-byte flag + 4-byte length (both zero).
|
||||
body := []byte{0, 0, 0, 0, 0}
|
||||
req, err := http.NewRequest(http.MethodPost, apiBase+"/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig", strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
c.applyHeaders(req, token, map[string]string{
|
||||
"content-type": "application/grpc-web+proto",
|
||||
"x-grpc-web": "1",
|
||||
"accept": "application/grpc-web+proto",
|
||||
})
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return unknownBalance("network: " + err.Error()), nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return unknownBalance(fmt.Sprintf("http %d: %s", resp.StatusCode, clip(raw, 160))), nil
|
||||
}
|
||||
|
||||
// GetGrokCreditsConfig field #1 is the credits USED this period (not remaining):
|
||||
// an exhausted account reads 100, a fresh one reads ~0. Remaining = 100 - used.
|
||||
// Field #5 carries the credits' own reset timestamp (weekly grant refill) —
|
||||
// this is the 恢复时间 we surface, NOT the subscription's billing-period end.
|
||||
used, resetUnix, ok := parseCreditsConfig(raw)
|
||||
if !ok {
|
||||
return unknownBalance("unparsable credits config"), nil
|
||||
}
|
||||
if used < 0 {
|
||||
used = 0
|
||||
}
|
||||
if used > fullCredits {
|
||||
used = fullCredits
|
||||
}
|
||||
remaining := fullCredits - used
|
||||
|
||||
// 恢复时间: the credits-config weekly reset (when the free grant refills).
|
||||
reset := strings.TrimSpace(resetUnix)
|
||||
return map[string]any{
|
||||
"remaining": remaining,
|
||||
"used": used,
|
||||
"total": fullCredits,
|
||||
"reset_after": emptyStringNil(reset),
|
||||
"unknown": false,
|
||||
"error": nil,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Subscription is the membership view parsed from GET /rest/subscriptions.
|
||||
type Subscription struct {
|
||||
Member bool // a subscription with status ACTIVE exists (INACTIVE entries don't count)
|
||||
Tier string // e.g. SUBSCRIPTION_TIER_GROK_PRO ("" for free)
|
||||
Status string // e.g. SUBSCRIPTION_STATUS_ACTIVE
|
||||
BillingPeriodEnd string // RFC3339; when the plan renews / credits reset
|
||||
FreeTrial bool // currently in a free-trial offer
|
||||
}
|
||||
|
||||
// FetchSubscription reads GET /rest/subscriptions and reports the account's
|
||||
// membership. Member is true only when an entry with SUBSCRIPTION_STATUS_ACTIVE
|
||||
// exists: a lapsed membership keeps its entry but flips to
|
||||
// SUBSCRIPTION_STATUS_INACTIVE, and an empty array means never subscribed —
|
||||
// both read as Member=false (the entry's tier/status are still surfaced).
|
||||
// A 401/403 maps to ErrAuth; other transport/HTTP errors are returned so callers
|
||||
// can treat them as best-effort (they already have the credit balance).
|
||||
func (c *Client) FetchSubscription(ctx context.Context, token string) (*Subscription, error) {
|
||||
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
|
||||
if token == "" {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
client, err := c.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.ensureChallenge(ctx, client, token)
|
||||
req, err := http.NewRequest(http.MethodGet, apiBase+"/rest/subscriptions", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
c.applyHeaders(req, token, nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("%w: subscriptions http %d", ErrTemporaryUpstream, resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Subscriptions []struct {
|
||||
Tier string `json:"tier"`
|
||||
Status string `json:"status"`
|
||||
BillingPeriodEnd string `json:"billingPeriodEnd"`
|
||||
ActiveOffer struct {
|
||||
FreeTrial *struct {
|
||||
TrialDays int `json:"trialDays"`
|
||||
} `json:"freeTrial"`
|
||||
} `json:"activeOffer"`
|
||||
} `json:"subscriptions"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
return nil, fmt.Errorf("%w: subscriptions non-json", ErrTemporaryUpstream)
|
||||
}
|
||||
out := &Subscription{}
|
||||
// Surface the ACTIVE subscription if any (falling back to the first entry
|
||||
// for tier/status info), but only an ACTIVE one sets Member.
|
||||
for i, s := range body.Subscriptions {
|
||||
active := strings.EqualFold(s.Status, "SUBSCRIPTION_STATUS_ACTIVE")
|
||||
if i == 0 || active {
|
||||
out.Member = active
|
||||
out.Tier = strings.TrimSpace(s.Tier)
|
||||
out.Status = strings.TrimSpace(s.Status)
|
||||
out.BillingPeriodEnd = strings.TrimSpace(s.BillingPeriodEnd)
|
||||
out.FreeTrial = s.ActiveOffer.FreeTrial != nil
|
||||
if active {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FetchSession reads the account profile via GET /api/auth/session and returns
|
||||
// (email, userID). A 401/403 means the sso session is dead → ErrAuth.
|
||||
func (c *Client) FetchSession(ctx context.Context, token string) (email, userID string, err error) {
|
||||
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
|
||||
if token == "" {
|
||||
return "", "", ErrAuth
|
||||
}
|
||||
client, err := c.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
c.ensureChallenge(ctx, client, token)
|
||||
req, err := http.NewRequest(http.MethodGet, apiBase+"/api/auth/session", nil)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
c.applyHeaders(req, token, nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
||||
return "", "", ErrAuth
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return "", "", fmt.Errorf("%w: session http %d", ErrTemporaryUpstream, resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Status string `json:"status"`
|
||||
Session struct {
|
||||
Email string `json:"email"`
|
||||
UserID string `json:"userId"`
|
||||
} `json:"session"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
return "", "", fmt.Errorf("%w: session non-json", ErrTemporaryUpstream)
|
||||
}
|
||||
// A dead sso cookie answers 200 {"status":"unauthenticated"} rather than 401.
|
||||
if !strings.EqualFold(body.Status, "authenticated") {
|
||||
return "", "", ErrAuth
|
||||
}
|
||||
return strings.TrimSpace(body.Session.Email), strings.TrimSpace(body.Session.UserID), nil
|
||||
}
|
||||
|
||||
// grok's x-statsig-id is a per-session anti-bot token. Its 3-byte "F" tail is a
|
||||
// browser fingerprint the server recomputes from the homepage seed + curve set,
|
||||
// and the byte-indexing that derives it ROTATES on every grok web reship — so any
|
||||
// hand-ported algorithm goes stale within a day (403 anti-bot). The durable path
|
||||
// therefore runs grok's OWN signer in goja (statsig_engine.go): we fetch the seed
|
||||
// + curves from the homepage and let grok's code do all the (rotating) indexing.
|
||||
// statsigID prefers that engine; the hand-ported computeStatsigTail below and the
|
||||
// static env-overridable defaults are only a last-resort fallback. See the package
|
||||
// doc and statsig_engine.go for the full picture.
|
||||
// statsigEpoch is the challenge epoch (2023-05-01 00:00 UTC).
|
||||
const (
|
||||
statsigEpoch = 1682924400
|
||||
defaultStatsigHeader = "00a1adb5012bd32f844f4426c62680d91c6129361eb9459a759710e179a888a99b21678e1f0b1e8952de6a6b3ca019f74b"
|
||||
defaultStatsigSuffix = "obfiowerehiringd244100f5c28f5c28f5c047ae147ae147b047ae147ae147b0f5c28f5c28f5c00"
|
||||
defaultStatsigTrailer = 3
|
||||
statsigSaltPrefix = "obfiowerehiring"
|
||||
statsigAnimDuration = 4096
|
||||
statsigTTL = 5 * time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
statsigHeader = resolveStatsigHeader()
|
||||
statsigSuffix = envOr("GROK_STATSIG_SUFFIX", defaultStatsigSuffix)
|
||||
statsigTrailer = resolveStatsigTrailer()
|
||||
|
||||
statsigMetaRe = regexp.MustCompile(`name="grok[^"]*verification"[^>]*content="([^"]+)"`)
|
||||
|
||||
statsigMu sync.Mutex
|
||||
statsigCache = map[string]statsigChallenge{} // keyed by sso token
|
||||
)
|
||||
|
||||
// statsigChallenge is a resolved, self-consistent (header, salt) pair for one
|
||||
// grok session, derived from the homepage seed + curves.
|
||||
type statsigChallenge struct {
|
||||
header []byte
|
||||
suffix string
|
||||
trailer byte
|
||||
fetchedAt time.Time
|
||||
|
||||
// Inputs for the durable goja signer (statsig_js.go): the raw <meta> seed
|
||||
// content and the curves JSON. Empty when parsing failed (engine then skipped).
|
||||
seedB64 string
|
||||
curvesJSON string
|
||||
}
|
||||
|
||||
// statsigCurve is one entry of the per-load curve set injected via the Next.js
|
||||
// RSC stream; the server uses it (with the seed) to recompute F.
|
||||
type statsigCurve struct {
|
||||
Color []int `json:"color"`
|
||||
Deg int `json:"deg"`
|
||||
Bezier []int `json:"bezier"`
|
||||
}
|
||||
|
||||
func resolveStatsigHeader() []byte {
|
||||
h := envOr("GROK_STATSIG_HEADER_HEX", defaultStatsigHeader)
|
||||
b, err := hex.DecodeString(h)
|
||||
if err != nil || len(b) != 49 {
|
||||
b, _ = hex.DecodeString(defaultStatsigHeader)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func resolveStatsigTrailer() byte {
|
||||
if v := strings.TrimSpace(os.Getenv("GROK_STATSIG_TRAILER")); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 && n <= 255 {
|
||||
return byte(n)
|
||||
}
|
||||
}
|
||||
return defaultStatsigTrailer
|
||||
}
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// ensureChallenge refreshes the cached (header, salt) for the session if missing
|
||||
// or stale. Any failure is non-fatal: statsigID then falls back to the static
|
||||
// defaults. An explicit env override disables dynamic fetching entirely.
|
||||
func (c *Client) ensureChallenge(ctx context.Context, client tlsclient.HttpClient, token string) {
|
||||
if token == "" || client == nil {
|
||||
return
|
||||
}
|
||||
if os.Getenv("GROK_STATSIG_HEADER_HEX") != "" || os.Getenv("GROK_STATSIG_SUFFIX") != "" {
|
||||
return
|
||||
}
|
||||
statsigMu.Lock()
|
||||
cur, ok := statsigCache[token]
|
||||
fresh := ok && time.Since(cur.fetchedAt) < statsigTTL
|
||||
statsigMu.Unlock()
|
||||
if fresh {
|
||||
return
|
||||
}
|
||||
ch, err := fetchStatsigChallenge(ctx, client)
|
||||
if err != nil {
|
||||
// Silent fallback to static defaults is the #1 cause of a recurring
|
||||
// "403 anti-bot": the homepage structure changed and we never notice.
|
||||
// Surface it so the failure mode (fetch/parse broke vs. offsets rotated)
|
||||
// is diagnosable from logs instead of guessing.
|
||||
log.Printf("grok statsig: self-heal failed, using stale static defaults (403 likely): %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("grok statsig: self-heal ok header[:6]=%x suffix=%s", ch.header[:6], ch.suffix)
|
||||
statsigMu.Lock()
|
||||
statsigCache[token] = ch
|
||||
statsigMu.Unlock()
|
||||
}
|
||||
|
||||
// fetchStatsigChallenge does a browser-free homepage GET and derives a
|
||||
// self-consistent (header, salt) pair: header = 0x00 + seed, salt = prefix + F.
|
||||
//
|
||||
// The request is ANONYMOUS (no sso cookie): grok redirects a request carrying an
|
||||
// sso cookie to the sign-in page, whose HTML omits the anti-bot seed <meta> and
|
||||
// the curves array — so a cookied fetch silently loses the challenge inputs and
|
||||
// statsigID falls back to the stale static recipe (403 anti-bot). The seed/curves
|
||||
// are per-build and account-independent (the seed is transmitted inside the
|
||||
// x-statsig-id header the server re-verifies), so the anonymous landing page is
|
||||
// the reliable source for every session.
|
||||
func fetchStatsigChallenge(ctx context.Context, client tlsclient.HttpClient) (statsigChallenge, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, apiBase+"/", nil)
|
||||
if err != nil {
|
||||
return statsigChallenge{}, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"},
|
||||
"accept-language": {"en-US,en;q=0.9"},
|
||||
"user-agent": {userAgent},
|
||||
http.HeaderOrderKey: {"accept", "accept-language", "user-agent"},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return statsigChallenge{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return statsigChallenge{}, err
|
||||
}
|
||||
html := string(raw)
|
||||
|
||||
mm := statsigMetaRe.FindStringSubmatch(html)
|
||||
if mm == nil {
|
||||
return statsigChallenge{}, errors.New("statsig: seed meta not found")
|
||||
}
|
||||
curves, err := parseStatsigCurves(html)
|
||||
if err != nil {
|
||||
return statsigChallenge{}, err
|
||||
}
|
||||
curvesJSON, err := json.Marshal(curves)
|
||||
if err != nil {
|
||||
return statsigChallenge{}, err
|
||||
}
|
||||
|
||||
// Primary path is the goja signer (statsig_js.go); set it up / refresh it for
|
||||
// this build. Static (header, salt) below is only a last-resort fallback.
|
||||
ensureEngine(ctx, client, html)
|
||||
|
||||
ch := statsigChallenge{
|
||||
header: statsigHeader,
|
||||
suffix: statsigSuffix,
|
||||
trailer: statsigTrailer,
|
||||
fetchedAt: time.Now(),
|
||||
seedB64: mm[1],
|
||||
curvesJSON: string(curvesJSON),
|
||||
}
|
||||
// NOTE: the old hand-ported per-session derivation (0x00+seed header +
|
||||
// computeStatsigTail salt) is intentionally NOT applied here. Verified
|
||||
// 2026-07-13 against /rest/app-chat/conversations/new: that dynamic token is
|
||||
// rejected ("Request rejected by anti-bot rules.", 403), while the static
|
||||
// (header, salt) defaults are accepted (200) — so we keep the static values
|
||||
// as the fallback. When the goja signer (ensureEngine above) locates and
|
||||
// verifies grok's own chunk it still takes over via seedB64/curvesJSON in
|
||||
// statsigID; computeStatsigTail is retained only for reference/tests.
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func decodeStatsigSeed(s string) ([]byte, error) {
|
||||
if b, err := base64.StdEncoding.DecodeString(s); err == nil && len(b) == 48 {
|
||||
return b, nil
|
||||
}
|
||||
b, err := base64.RawStdEncoding.DecodeString(strings.TrimRight(s, "="))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("statsig: seed decode: %w", err)
|
||||
}
|
||||
if len(b) != 48 {
|
||||
return nil, fmt.Errorf("statsig: seed len %d", len(b))
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// parseStatsigCurves extracts the [[{color,deg,bezier}...]...] array from the
|
||||
// RSC-escaped homepage HTML (the one immediately followed by color/bezier keys).
|
||||
func parseStatsigCurves(html string) ([][]statsigCurve, error) {
|
||||
marker := -1
|
||||
for from := 0; ; {
|
||||
i := strings.Index(html[from:], "curves")
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
i += from
|
||||
end := i + 160
|
||||
if end > len(html) {
|
||||
end = len(html)
|
||||
}
|
||||
w := html[i:end]
|
||||
if strings.Contains(w, "color") && strings.Contains(w, "bezier") {
|
||||
marker = i
|
||||
break
|
||||
}
|
||||
from = i + 6
|
||||
}
|
||||
if marker < 0 {
|
||||
return nil, errors.New("statsig: curves not found")
|
||||
}
|
||||
rel := strings.IndexByte(html[marker:], '[')
|
||||
if rel < 0 {
|
||||
return nil, errors.New("statsig: curves array start not found")
|
||||
}
|
||||
start := marker + rel
|
||||
depth, stop := 0, -1
|
||||
for k := start; k < len(html); k++ {
|
||||
switch html[k] {
|
||||
case '[':
|
||||
depth++
|
||||
case ']':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
stop = k + 1
|
||||
}
|
||||
}
|
||||
if stop > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if stop < 0 {
|
||||
return nil, errors.New("statsig: curves array end not found")
|
||||
}
|
||||
sub := strings.ReplaceAll(html[start:stop], `\`, "")
|
||||
var out [][]statsigCurve
|
||||
if err := json.Unmarshal([]byte(sub), &out); err != nil {
|
||||
return nil, fmt.Errorf("statsig: curves json: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// computeStatsigTail reproduces the browser's 3-byte color + 6-number transform
|
||||
// matrix "F" tail: it selects a curve by the seed, samples the curve's keyframe
|
||||
// animation (color lerp + rotate) at a seed-derived paused currentTime, and
|
||||
// serializes getComputedStyle(color)+getComputedStyle(transform) exactly as the
|
||||
// signer does (each number -> Number(v.toFixed(2)).toString(16), '.'/'-' stripped).
|
||||
func computeStatsigTail(seed []byte, curves [][]statsigCurve) (string, error) {
|
||||
if len(seed) < 46 {
|
||||
return "", errors.New("statsig: short seed")
|
||||
}
|
||||
if len(curves) == 0 {
|
||||
return "", errors.New("statsig: no curves")
|
||||
}
|
||||
group := int(seed[5]) % len(curves)
|
||||
if len(curves[group]) == 0 {
|
||||
return "", errors.New("statsig: empty curve group")
|
||||
}
|
||||
idx := int(seed[45]) % len(curves[group])
|
||||
cv := curves[group][idx]
|
||||
if len(cv.Color) < 6 || len(cv.Bezier) < 4 {
|
||||
return "", errors.New("statsig: malformed curve")
|
||||
}
|
||||
|
||||
n := (int(seed[1]) % 16) * (int(seed[6]) % 16) * (int(seed[13]) % 16)
|
||||
currentTime := jsRound(float64(n)/10) * 10
|
||||
progress := float64(currentTime) / statsigAnimDuration
|
||||
|
||||
x1 := toFixed2(float64(cv.Bezier[0]) / 255)
|
||||
y1 := toFixed2(float64(cv.Bezier[1])*2/255 - 1)
|
||||
x2 := toFixed2(float64(cv.Bezier[2]) / 255)
|
||||
y2 := toFixed2(float64(cv.Bezier[3])*2/255 - 1)
|
||||
eased := cubicBezierEase(x1, y1, x2, y2, progress)
|
||||
|
||||
nums := make([]float64, 0, 9)
|
||||
for k := 0; k < 3; k++ {
|
||||
v := jsRound(float64(cv.Color[k]) + (float64(cv.Color[k+3])-float64(cv.Color[k]))*eased)
|
||||
if v < 0 {
|
||||
v = 0
|
||||
}
|
||||
if v > 255 {
|
||||
v = 255
|
||||
}
|
||||
nums = append(nums, float64(v))
|
||||
}
|
||||
theta := int(math.Floor(float64(cv.Deg)*300/255 + 60))
|
||||
rad := float64(theta) * eased * math.Pi / 180
|
||||
cos, sin := math.Cos(rad), math.Sin(rad)
|
||||
nums = append(nums, cos, sin, -sin, cos, 0, 0)
|
||||
|
||||
var b strings.Builder
|
||||
for _, v := range nums {
|
||||
b.WriteString(jsHex(v))
|
||||
}
|
||||
out := strings.NewReplacer(".", "", "-", "").Replace(b.String())
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// jsRound matches JavaScript Math.round (round half up toward +Inf).
|
||||
func jsRound(x float64) int {
|
||||
return int(math.Floor(x + 0.5))
|
||||
}
|
||||
|
||||
// toFixed2 matches JavaScript Number(v.toFixed(2)).
|
||||
func toFixed2(v float64) float64 {
|
||||
f, _ := strconv.ParseFloat(strconv.FormatFloat(v, 'f', 2, 64), 64)
|
||||
return f
|
||||
}
|
||||
|
||||
// jsHex matches JavaScript Number(v.toFixed(2)).toString(16).
|
||||
func jsHex(v float64) string {
|
||||
v = toFixed2(v)
|
||||
neg := ""
|
||||
if v < 0 {
|
||||
neg = "-"
|
||||
v = -v
|
||||
}
|
||||
ip := int64(math.Floor(v))
|
||||
frac := v - float64(ip)
|
||||
s := neg + strconv.FormatInt(ip, 16)
|
||||
if frac == 0 {
|
||||
return s
|
||||
}
|
||||
const digits = "0123456789abcdef"
|
||||
var b strings.Builder
|
||||
b.WriteString(s)
|
||||
b.WriteByte('.')
|
||||
for i := 0; i < 20 && frac != 0; i++ {
|
||||
frac *= 16
|
||||
d := int(frac)
|
||||
b.WriteByte(digits[d])
|
||||
frac -= float64(d)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// cubicBezierEase evaluates a CSS cubic-bezier(x1,y1,x2,y2) easing at input
|
||||
// fraction p: solve X(t)=p for t (bisection), then return Y(t).
|
||||
func cubicBezierEase(x1, y1, x2, y2, p float64) float64 {
|
||||
bez := func(t, a, b float64) float64 {
|
||||
mt := 1 - t
|
||||
return 3*a*mt*mt*t + 3*b*mt*t*t + t*t*t
|
||||
}
|
||||
lo, hi := 0.0, 1.0
|
||||
for i := 0; i < 100; i++ {
|
||||
mid := (lo + hi) / 2
|
||||
if bez(mid, x1, x2) < p {
|
||||
lo = mid
|
||||
} else {
|
||||
hi = mid
|
||||
}
|
||||
}
|
||||
return bez((lo+hi)/2, y1, y2)
|
||||
}
|
||||
|
||||
// statsigID reproduces grok.com's x-statsig-id anti-bot token for a request. The
|
||||
// token binds to the request METHOD and URL path and to a coarse timestamp, so
|
||||
// it must be regenerated per request. See the package doc for the layout. It uses
|
||||
// the session's self-healed (header, salt) when available, else static defaults.
|
||||
func statsigID(path, method, token string) string {
|
||||
header, suffix, trailer := statsigHeader, statsigSuffix, statsigTrailer
|
||||
statsigMu.Lock()
|
||||
ch, ok := statsigCache[token]
|
||||
statsigMu.Unlock()
|
||||
if ok {
|
||||
header, suffix, trailer = ch.header, ch.suffix, ch.trailer
|
||||
// Primary: run grok's own signer in goja (durable across reships). Falls
|
||||
// through to the static computation below on any failure.
|
||||
if ch.seedB64 != "" && ch.curvesJSON != "" {
|
||||
if id, err := signWithEngine(ch.seedB64, ch.curvesJSON, path, method); err == nil {
|
||||
return id
|
||||
} else if !errors.Is(err, errEngineNotReady) {
|
||||
log.Printf("grok statsig: js signer failed, using static fallback: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
counter := uint32(time.Now().Unix() - statsigEpoch)
|
||||
sig := fmt.Sprintf("%s!%s!%d%s", method, path, counter, suffix)
|
||||
hash := sha256.Sum256([]byte(sig))
|
||||
|
||||
raw := make([]byte, 0, 70)
|
||||
raw = append(raw, header...)
|
||||
raw = binary.LittleEndian.AppendUint32(raw, counter)
|
||||
raw = append(raw, hash[:16]...)
|
||||
raw = append(raw, trailer)
|
||||
|
||||
key := byte(rand.IntN(256))
|
||||
for i := range raw {
|
||||
raw[i] ^= key
|
||||
}
|
||||
return base64.RawStdEncoding.EncodeToString(raw)
|
||||
}
|
||||
|
||||
// applyHeaders sets the browser-like header set + sso cookie + spoofed statsig id.
|
||||
// extra overrides/adds per-request headers (e.g. content-type).
|
||||
func (c *Client) applyHeaders(req *http.Request, token string, extra map[string]string) {
|
||||
h := http.Header{
|
||||
"accept": {"*/*"},
|
||||
"accept-language": {"en-US,en;q=0.9"},
|
||||
"content-type": {"application/json"},
|
||||
"origin": {origin},
|
||||
"referer": {origin + "/"},
|
||||
"user-agent": {userAgent},
|
||||
"x-statsig-id": {statsigID(req.URL.Path, req.Method, token)},
|
||||
"x-xai-request-id": {uuid.NewString()},
|
||||
"sec-ch-ua": {`"Chromium";v="133", "Not(A:Brand";v="99"`},
|
||||
"sec-ch-ua-mobile": {"?0"},
|
||||
"sec-ch-ua-platform": {`"Windows"`},
|
||||
"sec-fetch-dest": {"empty"},
|
||||
"sec-fetch-mode": {"cors"},
|
||||
"sec-fetch-site": {"same-origin"},
|
||||
"cookie": {"sso=" + token + "; sso-rw=" + token},
|
||||
}
|
||||
for k, v := range extra {
|
||||
h[k] = []string{v}
|
||||
}
|
||||
h[http.HeaderOrderKey] = []string{
|
||||
"accept", "accept-language", "content-type", "origin", "referer",
|
||||
"user-agent", "x-statsig-id", "x-xai-request-id", "x-grpc-web",
|
||||
"sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform",
|
||||
"sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site", "cookie",
|
||||
}
|
||||
req.Header = h
|
||||
}
|
||||
|
||||
func (c *Client) newTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(true) }
|
||||
|
||||
// newDirectTLSClient egresses on the local IP (never the proxy). Used for
|
||||
// reference-frame upload and result (video) download; only the generate submit
|
||||
// (/rest/app-chat/conversations/new) uses the proxy.
|
||||
func (c *Client) newDirectTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(false) }
|
||||
|
||||
func (c *Client) newTLSClientP(useProxy bool) (tlsclient.HttpClient, error) {
|
||||
options := []tlsclient.HttpClientOption{
|
||||
// Video generation streams inline until progress=100; a 15s clip can take
|
||||
// several minutes, so allow up to 10m (caller's genCtx caps at 12m).
|
||||
tlsclient.WithTimeoutSeconds(600),
|
||||
tlsclient.WithClientProfile(profiles.Chrome_133),
|
||||
tlsclient.WithRandomTLSExtensionOrder(),
|
||||
}
|
||||
if useProxy && c.proxy != "" {
|
||||
options = append(options, tlsclient.WithProxyUrl(c.proxy))
|
||||
}
|
||||
return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...)
|
||||
}
|
||||
|
||||
// --- gRPC-web / protobuf decoding for GetGrokCreditsConfig ---
|
||||
|
||||
// parseCreditsConfig extracts (remaining credits, reset RFC3339-ish unix string)
|
||||
// from the gRPC-web framed protobuf. Layout (reverse-engineered):
|
||||
//
|
||||
// frame: 1-byte flag + 4-byte big-endian length + payload
|
||||
// payload: field 1 (message) {
|
||||
// field 1: float32 -> remaining credits
|
||||
// field 5: message { field 1: varint -> reset unix seconds }
|
||||
// }
|
||||
func parseCreditsConfig(buf []byte) (remaining int, resetUnix string, ok bool) {
|
||||
for len(buf) >= 5 {
|
||||
flag := buf[0]
|
||||
ln := int(buf[1])<<24 | int(buf[2])<<16 | int(buf[3])<<8 | int(buf[4])
|
||||
buf = buf[5:]
|
||||
if ln > len(buf) {
|
||||
break
|
||||
}
|
||||
payload := buf[:ln]
|
||||
buf = buf[ln:]
|
||||
if flag&0x80 != 0 { // trailer frame (grpc-status), skip
|
||||
continue
|
||||
}
|
||||
// payload: expect field 1 (wire type 2) wrapping the config message.
|
||||
fn, wt, val, rest, good := readField(payload)
|
||||
if !good || fn != 1 || wt != 2 {
|
||||
continue
|
||||
}
|
||||
rem, reset, found := scanConfigMessage(val)
|
||||
_ = rest
|
||||
if found {
|
||||
return rem, reset, true
|
||||
}
|
||||
}
|
||||
return 0, "", false
|
||||
}
|
||||
|
||||
func scanConfigMessage(msg []byte) (used int, resetUnix string, ok bool) {
|
||||
var usedF float32
|
||||
seen := false
|
||||
for len(msg) > 0 {
|
||||
fn, wt, val, rest, good := readField(msg)
|
||||
if !good {
|
||||
break
|
||||
}
|
||||
msg = rest
|
||||
seen = true
|
||||
switch {
|
||||
case fn == 1 && wt == 5: // float32 credits USED this period
|
||||
usedF = float32FromLE(val)
|
||||
case fn == 5 && wt == 2: // reset timestamp message { #1 varint=seconds }
|
||||
if sec, sok := firstVarint(val); sok {
|
||||
resetUnix = strconv.FormatInt(sec, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
// A valid config message may OMIT field #1 when used == 0 (proto3 drops zero
|
||||
// scalars) — a full-quota account. So as long as the message had any field,
|
||||
// treat it as parsed with used defaulting to 0 (= 100 remaining).
|
||||
return int(usedF), resetUnix, seen
|
||||
}
|
||||
|
||||
// readField reads one protobuf field: returns (fieldNum, wireType, value, rest, ok).
|
||||
// For wt 2 value is the length-delimited bytes; wt 5 the 4 LE bytes; wt 0 the
|
||||
// raw varint bytes; wt 1 the 8 bytes.
|
||||
func readField(b []byte) (fn int, wt int, val []byte, rest []byte, ok bool) {
|
||||
tag, n := readVarint(b)
|
||||
if n == 0 {
|
||||
return 0, 0, nil, b, false
|
||||
}
|
||||
b = b[n:]
|
||||
fn = int(tag >> 3)
|
||||
wt = int(tag & 7)
|
||||
switch wt {
|
||||
case 0:
|
||||
_, m := readVarint(b)
|
||||
if m == 0 {
|
||||
return 0, 0, nil, b, false
|
||||
}
|
||||
return fn, wt, b[:m], b[m:], true
|
||||
case 1:
|
||||
if len(b) < 8 {
|
||||
return 0, 0, nil, b, false
|
||||
}
|
||||
return fn, wt, b[:8], b[8:], true
|
||||
case 2:
|
||||
ln, m := readVarint(b)
|
||||
if m == 0 || int(ln) > len(b)-m {
|
||||
return 0, 0, nil, b, false
|
||||
}
|
||||
return fn, wt, b[m : m+int(ln)], b[m+int(ln):], true
|
||||
case 5:
|
||||
if len(b) < 4 {
|
||||
return 0, 0, nil, b, false
|
||||
}
|
||||
return fn, wt, b[:4], b[4:], true
|
||||
default:
|
||||
return 0, 0, nil, b, false
|
||||
}
|
||||
}
|
||||
|
||||
func firstVarint(b []byte) (int64, bool) {
|
||||
fn, wt, val, _, ok := readField(b)
|
||||
if !ok || fn != 1 || wt != 0 {
|
||||
return 0, false
|
||||
}
|
||||
v, _ := readVarint(val)
|
||||
return int64(v), true
|
||||
}
|
||||
|
||||
func readVarint(b []byte) (uint64, int) {
|
||||
var v uint64
|
||||
var s uint
|
||||
for i := 0; i < len(b); i++ {
|
||||
v |= uint64(b[i]&0x7f) << s
|
||||
if b[i]&0x80 == 0 {
|
||||
return v, i + 1
|
||||
}
|
||||
s += 7
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
func float32FromLE(b []byte) float32 {
|
||||
if len(b) < 4 {
|
||||
return 0
|
||||
}
|
||||
bits := uint32(b[0]) | uint32(b[1])<<8 | uint32(b[2])<<16 | uint32(b[3])<<24
|
||||
return math.Float32frombits(bits)
|
||||
}
|
||||
|
||||
// --- small helpers (mirror the other provider clients) ---
|
||||
|
||||
func decodeJWTPayload(token string) map[string]any {
|
||||
parts := strings.Split(strings.TrimSpace(strings.TrimPrefix(token, "Bearer ")), ".")
|
||||
if len(parts) < 2 {
|
||||
return map[string]any{}
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stringValue(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
b, _ := json.Marshal(x)
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
}
|
||||
|
||||
func emptyStringNil(v string) any {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func unknownBalance(reason string) map[string]any {
|
||||
return map[string]any{
|
||||
"remaining": nil, "used": nil, "total": nil,
|
||||
"unknown": true, "error": reason,
|
||||
}
|
||||
}
|
||||
|
||||
func clip(b []byte, n int) string {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if len(s) > n {
|
||||
return s[:n]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -1,609 +0,0 @@
|
||||
package grok
|
||||
|
||||
// Grok Console (console.x.ai) is xAI's own product console. It serves the same
|
||||
// grok-imagine media models as grok.com, but over a plain JSON API that is NOT
|
||||
// anti-bot gated: no x-statsig-id, no media-post + streaming-conversation dance.
|
||||
// Auth reuses the very same website "sso" cookie — POST /v1/dpop/token exchanges
|
||||
// it for a short-lived access token bound to a locally generated P-256 key, and
|
||||
// every call then carries "Authorization: DPoP <token>" plus a per-request ES256
|
||||
// DPoP proof (RFC 9449) over (method, url, token hash).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
http "github.com/bogdanfinn/fhttp"
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
consoleBase = "https://console.x.ai"
|
||||
consoleOrigin = "https://console.x.ai"
|
||||
|
||||
// Upstream model ids of the Console media catalog. The -quality image model
|
||||
// is what the reference-image (edit) path uses.
|
||||
ConsoleVideoModel = "grok-imagine-video"
|
||||
ConsoleImageModel = "grok-imagine-image"
|
||||
ConsoleImageQualityModel = "grok-imagine-image-quality"
|
||||
|
||||
// consoleTokenSkew refreshes a DPoP token slightly before it expires.
|
||||
consoleTokenSkew = 20 * time.Second
|
||||
// consoleMaxEditImages is the upstream cap for image-to-image references
|
||||
// (video takes a single 首图).
|
||||
consoleMaxEditImages = 3
|
||||
|
||||
consoleVideoPollEvery = 2 * time.Second
|
||||
consoleVideoDeadline = 30 * time.Minute
|
||||
)
|
||||
|
||||
// consoleSession is one minted DPoP token plus the key it is bound to. skew is
|
||||
// (server time − local time) learned from the mint response Date header; the
|
||||
// proof iat is shifted by it so a drifting host clock doesn't fail every proof.
|
||||
type consoleSession struct {
|
||||
accessToken string
|
||||
key *ecdsa.PrivateKey
|
||||
jwk consoleJWK
|
||||
expiresAt time.Time
|
||||
skew time.Duration
|
||||
}
|
||||
|
||||
type consoleJWK struct {
|
||||
Crv string `json:"crv"`
|
||||
Kty string `json:"kty"`
|
||||
X string `json:"x"`
|
||||
Y string `json:"y"`
|
||||
}
|
||||
|
||||
var (
|
||||
consoleMu sync.Mutex
|
||||
consoleSessions = map[string]consoleSession{} // keyed by sso token hash
|
||||
)
|
||||
|
||||
// GenerateConsoleVideo runs Console's video pipeline: POST /v1/videos/generations
|
||||
// returns a request_id, GET /v1/videos/{id} is polled until the clip is rendered
|
||||
// and reports its vidgen.x.ai URL. frames is optional (image-to-video, one 首图
|
||||
// max) and is inlined as a data URL — Console takes the image in the request, so
|
||||
// there is no separate upload step. When downloadResult is false, returns nil
|
||||
// bytes and the artifact URL in meta["video_url"]; otherwise downloads the mp4.
|
||||
func (c *Client) GenerateConsoleVideo(ctx context.Context, token, prompt, aspectRatio, resolution string, seconds int, frames [][]byte, downloadResult bool) ([]byte, map[string]any, error) {
|
||||
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
|
||||
if token == "" {
|
||||
return nil, nil, ErrAuth
|
||||
}
|
||||
prompt = strings.TrimSpace(prompt)
|
||||
if prompt == "" && len(frames) == 0 {
|
||||
return nil, nil, fmt.Errorf("grok console: prompt required")
|
||||
}
|
||||
if seconds < 1 || seconds > 15 {
|
||||
seconds = 10
|
||||
}
|
||||
submitClient, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
directClient, err := c.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
payload := map[string]any{"model": ConsoleVideoModel, "duration": seconds}
|
||||
if prompt != "" {
|
||||
payload["prompt"] = prompt
|
||||
}
|
||||
if ratio := strings.TrimSpace(aspectRatio); ratio != "" {
|
||||
payload["aspect_ratio"] = ratio
|
||||
}
|
||||
if res := consoleVideoResolution(resolution); res != "" {
|
||||
payload["resolution"] = res
|
||||
}
|
||||
for _, f := range frames {
|
||||
if len(f) == 0 {
|
||||
continue
|
||||
}
|
||||
payload["image"] = map[string]any{"url": dataURL(f)}
|
||||
break // 上游只吃 1 张首图
|
||||
}
|
||||
|
||||
created, err := c.consoleJSON(ctx, submitClient, token, http.MethodPost, "/v1/videos/generations", payload)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
requestID := strings.TrimSpace(stringValue(created["request_id"]))
|
||||
if requestID == "" {
|
||||
return nil, nil, fmt.Errorf("%w: video create missing request_id", ErrTemporaryUpstream)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(consoleVideoDeadline)
|
||||
videoURL := ""
|
||||
for {
|
||||
status, pollErr := c.consoleJSON(ctx, submitClient, token, http.MethodGet, "/v1/videos/"+url.PathEscape(requestID), nil)
|
||||
if pollErr != nil {
|
||||
return nil, nil, pollErr
|
||||
}
|
||||
url, done, sErr := parseConsoleVideoStatus(status)
|
||||
if sErr != nil {
|
||||
return nil, nil, sErr
|
||||
}
|
||||
if done {
|
||||
videoURL = url
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return nil, nil, fmt.Errorf("%w: video render did not complete in time", ErrTemporaryUpstream)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, nil, ctx.Err()
|
||||
case <-time.After(consoleVideoPollEvery):
|
||||
}
|
||||
}
|
||||
|
||||
meta := map[string]any{
|
||||
"provider": "grok",
|
||||
"request_id": requestID,
|
||||
"video_url": videoURL,
|
||||
}
|
||||
if !downloadResult {
|
||||
return nil, meta, nil
|
||||
}
|
||||
data, err := c.download(ctx, directClient, token, videoURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return data, meta, nil
|
||||
}
|
||||
|
||||
// GenerateConsoleImage runs Console's image pipeline: /v1/images/generations on
|
||||
// grok-imagine-image for text-to-image, and /v1/images/edits on the
|
||||
// grok-imagine-image-quality model (up to 3 inlined reference images) as soon as
|
||||
// references are supplied — 带参考图自动走 quality 那个上游模型. Returns the
|
||||
// artifact URL in meta["image_url"]; the bytes are downloaded unless urlOnly.
|
||||
func (c *Client) GenerateConsoleImage(ctx context.Context, token, prompt, aspectRatio, resolution string, refs [][]byte, urlOnly bool) ([]byte, map[string]any, error) {
|
||||
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
|
||||
if token == "" {
|
||||
return nil, nil, ErrAuth
|
||||
}
|
||||
if strings.TrimSpace(prompt) == "" {
|
||||
return nil, nil, fmt.Errorf("grok console: prompt required")
|
||||
}
|
||||
submitClient, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
directClient, err := c.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"model": ConsoleImageModel, "prompt": strings.TrimSpace(prompt),
|
||||
"n": 1, "response_format": "url",
|
||||
}
|
||||
|
||||
if ratio := consoleImageAspectRatio(aspectRatio); ratio != "" {
|
||||
payload["aspect_ratio"] = ratio
|
||||
}
|
||||
if res := consoleImageResolution(resolution); res != "" {
|
||||
payload["resolution"] = res
|
||||
}
|
||||
path := "/v1/images/generations"
|
||||
var images []map[string]any
|
||||
for _, r := range refs {
|
||||
if len(r) == 0 || len(images) >= consoleMaxEditImages {
|
||||
continue
|
||||
}
|
||||
images = append(images, map[string]any{"type": "image_url", "url": dataURL(r)})
|
||||
}
|
||||
if len(images) > 0 {
|
||||
path = "/v1/images/edits"
|
||||
payload["model"] = ConsoleImageQualityModel
|
||||
if len(images) == 1 {
|
||||
payload["image"] = images[0]
|
||||
} else {
|
||||
payload["images"] = images
|
||||
}
|
||||
}
|
||||
|
||||
res, err := c.consoleJSON(ctx, submitClient, token, http.MethodPost, path, payload)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
items, _ := res["data"].([]any)
|
||||
imageURL := ""
|
||||
for _, item := range items {
|
||||
entry, _ := item.(map[string]any)
|
||||
if entry == nil {
|
||||
continue
|
||||
}
|
||||
if v := strings.TrimSpace(stringValue(entry["url"])); v != "" {
|
||||
imageURL = v
|
||||
break
|
||||
}
|
||||
}
|
||||
if imageURL == "" {
|
||||
return nil, nil, fmt.Errorf("%w: image response missing url", ErrTemporaryUpstream)
|
||||
}
|
||||
meta := map[string]any{"provider": "grok", "image_url": imageURL}
|
||||
if urlOnly {
|
||||
return nil, meta, nil
|
||||
}
|
||||
data, err := c.download(ctx, directClient, token, imageURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return data, meta, nil
|
||||
}
|
||||
|
||||
// consoleJSON does an authed Console JSON call and parses the response object. A
|
||||
// 401 means the DPoP token went stale (or the sso died): the cached session is
|
||||
// dropped and the call retried once with a freshly minted token.
|
||||
func (c *Client) consoleJSON(ctx context.Context, client tlsclient.HttpClient, token, method, path string, body any) (map[string]any, error) {
|
||||
var payload []byte
|
||||
if body != nil {
|
||||
encoded, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload = encoded
|
||||
}
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
session, err := c.consoleSession(ctx, client, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var reader io.Reader
|
||||
if len(payload) > 0 {
|
||||
reader = strings.NewReader(string(payload))
|
||||
}
|
||||
req, err := http.NewRequest(method, consoleBase+path, reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
if err := c.applyConsoleHeaders(req, token, session); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
raw, readErr := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == 401 && attempt == 0 {
|
||||
consoleForget(token, session.accessToken)
|
||||
continue
|
||||
}
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, readErr)
|
||||
}
|
||||
if e := consoleMapStatus(path, resp.StatusCode, raw); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
out := map[string]any{}
|
||||
if len(raw) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, fmt.Errorf("%w: %s non-json: %s", ErrTemporaryUpstream, path, clip(raw, 120))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: console retry state invalid", ErrTemporaryUpstream)
|
||||
}
|
||||
|
||||
// consoleSession returns a live DPoP session for the sso token, minting one when
|
||||
// the cache is empty or the token is about to expire.
|
||||
func (c *Client) consoleSession(ctx context.Context, client tlsclient.HttpClient, token string) (consoleSession, error) {
|
||||
key := consoleCacheKey(token)
|
||||
consoleMu.Lock()
|
||||
cached, ok := consoleSessions[key]
|
||||
consoleMu.Unlock()
|
||||
if ok && cached.expiresAt.After(time.Now().Add(consoleTokenSkew)) {
|
||||
return cached, nil
|
||||
}
|
||||
session, err := c.mintConsoleSession(ctx, client, token)
|
||||
if err != nil {
|
||||
return consoleSession{}, err
|
||||
}
|
||||
consoleMu.Lock()
|
||||
consoleSessions[key] = session
|
||||
consoleMu.Unlock()
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// consoleForget drops a cached session (only if it is still the one that failed,
|
||||
// so a concurrent refresh isn't thrown away).
|
||||
func consoleForget(token, accessToken string) {
|
||||
key := consoleCacheKey(token)
|
||||
consoleMu.Lock()
|
||||
if current, ok := consoleSessions[key]; ok && current.accessToken == accessToken {
|
||||
delete(consoleSessions, key)
|
||||
}
|
||||
consoleMu.Unlock()
|
||||
}
|
||||
|
||||
func consoleCacheKey(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return fmt.Sprintf("%x", sum[:])
|
||||
}
|
||||
|
||||
// mintConsoleSession posts the freshly generated public JWK to /v1/dpop/token
|
||||
// with the sso cookie and keeps the returned DPoP-bound access token.
|
||||
func (c *Client) mintConsoleSession(ctx context.Context, client tlsclient.HttpClient, token string) (consoleSession, error) {
|
||||
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return consoleSession{}, err
|
||||
}
|
||||
jwk := consoleJWK{
|
||||
Crv: "P-256", Kty: "EC",
|
||||
X: base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.X.FillBytes(make([]byte, 32))),
|
||||
Y: base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.Y.FillBytes(make([]byte, 32))),
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{"jwk": jwk})
|
||||
if err != nil {
|
||||
return consoleSession{}, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, consoleBase+"/v1/dpop/token", strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return consoleSession{}, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
c.applyConsoleBrowserHeaders(req, token, nil)
|
||||
before := time.Now().UTC()
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return consoleSession{}, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
after := time.Now().UTC()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return consoleSession{}, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
if e := consoleMapStatus("/v1/dpop/token", resp.StatusCode, raw); e != nil {
|
||||
return consoleSession{}, e
|
||||
}
|
||||
var out struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return consoleSession{}, fmt.Errorf("%w: dpop token non-json: %s", ErrTemporaryUpstream, clip(raw, 120))
|
||||
}
|
||||
if strings.TrimSpace(out.AccessToken) == "" || !strings.EqualFold(strings.TrimSpace(out.TokenType), "DPoP") {
|
||||
return consoleSession{}, fmt.Errorf("%w: dpop token response invalid", ErrTemporaryUpstream)
|
||||
}
|
||||
if out.ExpiresIn <= 0 || out.ExpiresIn > 3600 {
|
||||
out.ExpiresIn = 300
|
||||
}
|
||||
return consoleSession{
|
||||
accessToken: out.AccessToken,
|
||||
key: privateKey,
|
||||
jwk: jwk,
|
||||
expiresAt: time.Now().Add(time.Duration(out.ExpiresIn) * time.Second),
|
||||
skew: consoleClockSkew(resp.Header.Get("Date"), before, after),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// applyConsoleHeaders sets the browser-like header set plus the DPoP
|
||||
// Authorization header and its per-request proof.
|
||||
func (c *Client) applyConsoleHeaders(req *http.Request, token string, session consoleSession) error {
|
||||
proof, err := consoleProof(session, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.applyConsoleBrowserHeaders(req, token, map[string]string{
|
||||
"authorization": "DPoP " + session.accessToken,
|
||||
"dpop": proof,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) applyConsoleBrowserHeaders(req *http.Request, token string, extra map[string]string) {
|
||||
h := http.Header{
|
||||
"accept": {"*/*"},
|
||||
"accept-language": {"en-US,en;q=0.9"},
|
||||
"content-type": {"application/json"},
|
||||
"origin": {consoleOrigin},
|
||||
"referer": {consoleOrigin + "/"},
|
||||
"user-agent": {userAgent},
|
||||
"sec-ch-ua": {`"Chromium";v="133", "Not(A:Brand";v="99"`},
|
||||
"sec-ch-ua-mobile": {"?0"},
|
||||
"sec-ch-ua-platform": {`"Windows"`},
|
||||
"sec-fetch-dest": {"empty"},
|
||||
"sec-fetch-mode": {"cors"},
|
||||
"sec-fetch-site": {"same-origin"},
|
||||
"cookie": {"sso=" + token + "; sso-rw=" + token},
|
||||
}
|
||||
for k, v := range extra {
|
||||
h[k] = []string{v}
|
||||
}
|
||||
h[http.HeaderOrderKey] = []string{
|
||||
"accept", "accept-language", "authorization", "content-type", "dpop",
|
||||
"origin", "referer", "user-agent", "sec-ch-ua", "sec-ch-ua-mobile",
|
||||
"sec-ch-ua-platform", "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site", "cookie",
|
||||
}
|
||||
req.Header = h
|
||||
}
|
||||
|
||||
// consoleProof builds the ES256 DPoP proof JWT for one request: it binds the
|
||||
// method + URL (without query) and the access token hash (ath), signed with the
|
||||
// key the token was issued against.
|
||||
func consoleProof(session consoleSession, req *http.Request) (string, error) {
|
||||
if session.key == nil || strings.TrimSpace(session.accessToken) == "" || req == nil || req.URL == nil {
|
||||
return "", errors.New("grok console: dpop session invalid")
|
||||
}
|
||||
path := req.URL.EscapedPath()
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
ath := sha256.Sum256([]byte(session.accessToken))
|
||||
header := map[string]any{"typ": "dpop+jwt", "alg": "ES256", "jwk": session.jwk}
|
||||
claims := map[string]any{
|
||||
"jti": uuid.NewString(),
|
||||
"htm": strings.ToUpper(req.Method),
|
||||
"htu": req.URL.Scheme + "://" + req.URL.Host + path,
|
||||
"iat": time.Now().Add(session.skew).UTC().Unix(),
|
||||
"ath": base64.RawURLEncoding.EncodeToString(ath[:]),
|
||||
}
|
||||
headerJSON, err := json.Marshal(header)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
claimsJSON, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
signingInput := base64.RawURLEncoding.EncodeToString(headerJSON) + "." + base64.RawURLEncoding.EncodeToString(claimsJSON)
|
||||
digest := sha256.Sum256([]byte(signingInput))
|
||||
r, s, err := ecdsa.Sign(rand.Reader, session.key, digest[:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
signature := make([]byte, 64)
|
||||
r.FillBytes(signature[:32])
|
||||
s.FillBytes(signature[32:])
|
||||
return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature), nil
|
||||
}
|
||||
|
||||
// consoleClockSkew mirrors the console.x.ai frontend: proof iat is local time
|
||||
// corrected by (server Date − local time) so host clock drift doesn't break
|
||||
// every proof. before/after bound the RTT around the mint response.
|
||||
func consoleClockSkew(dateHeader string, before, after time.Time) time.Duration {
|
||||
dateHeader = strings.TrimSpace(dateHeader)
|
||||
if dateHeader == "" {
|
||||
return 0
|
||||
}
|
||||
serverTime, err := http.ParseTime(dateHeader)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
if after.Before(before) {
|
||||
after = before
|
||||
}
|
||||
mid := before.Add(after.Sub(before) / 2)
|
||||
return serverTime.UTC().Sub(mid.UTC()).Round(time.Second)
|
||||
}
|
||||
|
||||
// parseConsoleVideoStatus reads one poll response: (url, done, error).
|
||||
func parseConsoleVideoStatus(res map[string]any) (string, bool, error) {
|
||||
status := strings.ToLower(strings.TrimSpace(stringValue(res["status"])))
|
||||
switch status {
|
||||
case "done", "completed", "succeeded", "success", "ready":
|
||||
video, _ := res["video"].(map[string]any)
|
||||
url := ""
|
||||
if video != nil {
|
||||
url = strings.TrimSpace(stringValue(video["url"]))
|
||||
}
|
||||
if url == "" {
|
||||
return "", false, fmt.Errorf("%w: video done without content url", ErrTemporaryUpstream)
|
||||
}
|
||||
return url, true, nil
|
||||
case "pending", "processing", "in_progress", "queued", "":
|
||||
return "", false, nil
|
||||
default:
|
||||
message := consoleErrorMessage(res["error"])
|
||||
if isCreditError(message) {
|
||||
return "", false, fmt.Errorf("%w: %s", ErrQuotaExhausted, message)
|
||||
}
|
||||
if message == "" {
|
||||
message = status
|
||||
}
|
||||
return "", false, fmt.Errorf("grok console: video generation failed: %s", message)
|
||||
}
|
||||
}
|
||||
|
||||
// consoleMapStatus maps a Console HTTP status to the shared provider sentinels.
|
||||
// Console is not anti-bot gated, so a 403 is a real rejection (dead sso / no
|
||||
// access) unless the body is a Cloudflare interstitial.
|
||||
func consoleMapStatus(path string, status int, raw []byte) error {
|
||||
body := string(raw)
|
||||
switch {
|
||||
case status >= 200 && status < 300:
|
||||
return nil
|
||||
case status == 401:
|
||||
return fmt.Errorf("%w: %s 401 %s", ErrAuth, path, clip(raw, 160))
|
||||
case status == 403:
|
||||
if isBotChallenge(body) {
|
||||
return fmt.Errorf("%w: %s 403 %s", ErrTemporaryUpstream, path, clip(raw, 160))
|
||||
}
|
||||
return fmt.Errorf("%w: %s 403 %s", ErrAuth, path, clip(raw, 160))
|
||||
case status == 429:
|
||||
if isCreditError(body) {
|
||||
return fmt.Errorf("%w: %s 429 %s", ErrQuotaExhausted, path, clip(raw, 160))
|
||||
}
|
||||
return fmt.Errorf("%w: %s 429 %s", ErrTemporaryUpstream, path, clip(raw, 160))
|
||||
case status >= 500:
|
||||
return fmt.Errorf("%w: %s %d %s", ErrTemporaryUpstream, path, status, clip(raw, 160))
|
||||
default:
|
||||
if isCreditError(body) {
|
||||
return fmt.Errorf("%w: %s", ErrQuotaExhausted, clip(raw, 160))
|
||||
}
|
||||
return fmt.Errorf("grok console: %s %d %s", path, status, clip(raw, 160))
|
||||
}
|
||||
}
|
||||
|
||||
func consoleErrorMessage(value any) string {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(v)
|
||||
case map[string]any:
|
||||
for _, key := range []string{"message", "code", "type"} {
|
||||
if text := strings.TrimSpace(stringValue(v[key])); text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// consoleImageAspectRatio keeps only the ratios Console's image models accept;
|
||||
// anything else is dropped so the upstream picks its default instead of 400ing.
|
||||
func consoleImageAspectRatio(value string) string {
|
||||
switch strings.TrimSpace(value) {
|
||||
case "1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "2:1", "1:2":
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// consoleImageResolution maps our tier to the Console enum (only 1k / 2k exist).
|
||||
func consoleImageResolution(value string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(value)) {
|
||||
case "1K":
|
||||
return "1k"
|
||||
case "2K", "4K":
|
||||
return "2k"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// consoleVideoResolution clamps to the two tiers Console accepts.
|
||||
func consoleVideoResolution(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "480p":
|
||||
return "480p"
|
||||
default:
|
||||
return "720p"
|
||||
}
|
||||
}
|
||||
|
||||
// dataURL inlines reference image bytes; Console takes images in the request
|
||||
// body, so no upload endpoint is involved.
|
||||
func dataURL(img []byte) string {
|
||||
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(img)
|
||||
}
|
||||
@@ -1,398 +0,0 @@
|
||||
package grok
|
||||
|
||||
// This file makes x-statsig-id durable across grok web reships by executing grok's
|
||||
// OWN obfuscated signer (a Turbopack chunk) inside an embedded JS engine (goja),
|
||||
// under a synthesized DOM + Web-Animations getComputedStyle shim (statsig_shim.js).
|
||||
// grok's code does all the per-build byte-indexing / curve-selection; we only supply
|
||||
// the stable browser primitives. This replaces the brittle hand-ported byte-offset
|
||||
// algorithm in computeStatsigTail (kept as a last-resort fallback). See the package
|
||||
// doc and the grok-statsig-signer memory for the reverse-engineering details.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
_ "embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
http "github.com/bogdanfinn/fhttp"
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
//go:embed statsig_shim.js
|
||||
var statsigShimJS string
|
||||
|
||||
const sigPoolSize = 4
|
||||
|
||||
// errEngineNotReady means the durable signer engine has not been built yet (no
|
||||
// homepage fetched, or chunk location failed). Callers fall back to the static path.
|
||||
var errEngineNotReady = errors.New("statsig engine not ready")
|
||||
|
||||
// locateConcurrency bounds parallel chunk fetches during signer discovery.
|
||||
const locateConcurrency = 24
|
||||
|
||||
var (
|
||||
// chunkPathRe matches chunk URLs in the homepage; allChunkRefRe additionally
|
||||
// matches the loader-manifest's lazy refs (which drop the /_next/ prefix).
|
||||
// The Turbopack content-hash alphabet includes '~' (e.g. 0~4v1h2zpw7n0.js) -
|
||||
// the signer chunk is frequently one of these, so the class MUST list it or
|
||||
// the signer is never fetched/located (403 anti-bot, static fallback goes stale).
|
||||
chunkPathRe = regexp.MustCompile(`/_next/static/chunks/[a-zA-Z0-9_.~\-/]+\.js`)
|
||||
allChunkRefRe = regexp.MustCompile(`(?:/_next/)?static/chunks/[a-zA-Z0-9_.~\-/]+\.js`)
|
||||
// goja's parser tries to fetch //# sourceMappingURL=... from disk and errors.
|
||||
sourceMapRe = regexp.MustCompile(`(?m)//[#@]\s*sourceMappingURL=\S*`)
|
||||
|
||||
sigMgrMu sync.Mutex
|
||||
sigBuildKey string // hash of the homepage chunk list; changes on reship
|
||||
sigChunkSrc string // current signer chunk source
|
||||
sigPool chan *sigEngine // pool of ready engines for sigChunkSrc
|
||||
)
|
||||
|
||||
// sigEngine wraps one goja runtime with grok's signer chunk loaded. A goja runtime
|
||||
// is not safe for concurrent use; the pool hands each engine to one goroutine at a
|
||||
// time so no per-engine locking is needed.
|
||||
type sigEngine struct {
|
||||
rt *goja.Runtime
|
||||
fire goja.Callable // __grokSignInto
|
||||
}
|
||||
|
||||
func newSigEngine(chunkSrc string) (*sigEngine, error) {
|
||||
rt := goja.New()
|
||||
// SHA-256 bridge for crypto.subtle.digest.
|
||||
if err := rt.Set("__goSha256", func(call goja.FunctionCall) goja.Value {
|
||||
data := jsBytes(rt, call.Argument(0))
|
||||
sum := sha256.Sum256(data)
|
||||
return rt.ToValue(rt.NewArrayBuffer(sum[:]))
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := rt.RunString(statsigShimJS); err != nil {
|
||||
return nil, fmt.Errorf("shim: %w", err)
|
||||
}
|
||||
if _, err := rt.RunString(sourceMapRe.ReplaceAllString(chunkSrc, "")); err != nil {
|
||||
return nil, fmt.Errorf("chunk eval: %w", err)
|
||||
}
|
||||
if _, err := rt.RunString("__grokBootstrap()"); err != nil {
|
||||
return nil, fmt.Errorf("bootstrap: %w", err)
|
||||
}
|
||||
fire, ok := goja.AssertFunction(rt.Get("__grokSignInto"))
|
||||
if !ok {
|
||||
return nil, errors.New("statsig js: __grokSignInto missing")
|
||||
}
|
||||
return &sigEngine{rt: rt, fire: fire}, nil
|
||||
}
|
||||
|
||||
// statsigID runs grok's signer for one request. seedB64 is the raw <meta> content;
|
||||
// curvesJSON is [[{color,deg,bezier}...]...].
|
||||
func (e *sigEngine) statsigID(seedB64, curvesJSON, path, method string) (string, error) {
|
||||
_ = e.rt.Set("__SEED", seedB64)
|
||||
_ = e.rt.Set("__CURVES", curvesJSON)
|
||||
_ = e.rt.Set("__PATH", path)
|
||||
_ = e.rt.Set("__METHOD", method)
|
||||
// RunString drains goja's microtask queue, settling the async signer's promise.
|
||||
if _, err := e.fire(goja.Undefined()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if errv := e.rt.Get("__grokErr"); errv != nil && !goja.IsNull(errv) && !goja.IsUndefined(errv) {
|
||||
return "", fmt.Errorf("statsig js: %s", errv.String())
|
||||
}
|
||||
res := e.rt.Get("__grokResult")
|
||||
if res == nil || goja.IsNull(res) || goja.IsUndefined(res) {
|
||||
return "", errors.New("statsig js: promise did not settle")
|
||||
}
|
||||
id := res.String()
|
||||
if id == "" {
|
||||
return "", errors.New("statsig js: empty id")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// jsBytes extracts the byte contents of a JS Uint8Array / ArrayBuffer value.
|
||||
func jsBytes(rt *goja.Runtime, v goja.Value) []byte {
|
||||
if ab, ok := v.Export().(goja.ArrayBuffer); ok {
|
||||
return ab.Bytes()
|
||||
}
|
||||
obj := v.ToObject(rt)
|
||||
if buf := obj.Get("buffer"); buf != nil {
|
||||
if ab, ok := buf.Export().(goja.ArrayBuffer); ok {
|
||||
return ab.Bytes()
|
||||
}
|
||||
}
|
||||
n := int(obj.Get("length").ToInteger())
|
||||
out := make([]byte, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = byte(obj.Get(strconv.Itoa(i)).ToInteger())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// signWithEngine borrows an engine from the pool (building one on demand), signs,
|
||||
// and returns it. Returns an error if the engine subsystem is not ready.
|
||||
func signWithEngine(seedB64, curvesJSON, path, method string) (string, error) {
|
||||
sigMgrMu.Lock()
|
||||
src, pool := sigChunkSrc, sigPool
|
||||
sigMgrMu.Unlock()
|
||||
if src == "" || pool == nil {
|
||||
return "", errEngineNotReady
|
||||
}
|
||||
var eng *sigEngine
|
||||
select {
|
||||
case eng = <-pool:
|
||||
default:
|
||||
var err error
|
||||
if eng, err = newSigEngine(src); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
id, err := eng.statsigID(seedB64, curvesJSON, path, method)
|
||||
select {
|
||||
case pool <- eng:
|
||||
default:
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
// ensureEngine refreshes the global engine pool when the homepage's chunk set
|
||||
// changes (i.e. grok reshipped). It locates the signer chunk build-agnostically and
|
||||
// rebuilds the pool. Cheap no-op when the build is unchanged.
|
||||
func ensureEngine(ctx context.Context, client tlsclient.HttpClient, homeHTML string) {
|
||||
paths := chunkPathRe.FindAllString(homeHTML, -1)
|
||||
if len(paths) == 0 {
|
||||
return
|
||||
}
|
||||
key := hashStrings(paths)
|
||||
|
||||
sigMgrMu.Lock()
|
||||
unchanged := key == sigBuildKey && sigPool != nil
|
||||
sigMgrMu.Unlock()
|
||||
if unchanged {
|
||||
return
|
||||
}
|
||||
|
||||
// Inputs for build-agnostic behavioral verification of candidate chunks.
|
||||
mm := statsigMetaRe.FindStringSubmatch(homeHTML)
|
||||
if mm == nil {
|
||||
log.Printf("grok statsig: no seed meta in homepage; cannot locate signer")
|
||||
return
|
||||
}
|
||||
seed, err := decodeStatsigSeed(mm[1])
|
||||
if err != nil {
|
||||
log.Printf("grok statsig: seed decode failed: %v", err)
|
||||
return
|
||||
}
|
||||
curves, err := parseStatsigCurves(homeHTML)
|
||||
if err != nil {
|
||||
log.Printf("grok statsig: curves parse failed: %v", err)
|
||||
return
|
||||
}
|
||||
cj, err := json.Marshal(curves)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
src, err := locateSignerChunk(ctx, client, dedupe(paths), mm[1], string(cj), seed)
|
||||
if err != nil {
|
||||
log.Printf("grok statsig: locate signer chunk failed (will use static fallback): %v", err)
|
||||
return
|
||||
}
|
||||
// smoke-test: a build must produce a loadable engine before we commit to it.
|
||||
eng, err := newSigEngine(src)
|
||||
if err != nil {
|
||||
log.Printf("grok statsig: signer chunk did not load in goja (static fallback): %v", err)
|
||||
return
|
||||
}
|
||||
pool := make(chan *sigEngine, sigPoolSize)
|
||||
pool <- eng // reuse the smoke-test engine instead of discarding it
|
||||
sigMgrMu.Lock()
|
||||
sigBuildKey = key
|
||||
sigChunkSrc = src
|
||||
sigPool = pool
|
||||
sigMgrMu.Unlock()
|
||||
log.Printf("grok statsig: self-heal engine ready (build %s..)", key[:8])
|
||||
}
|
||||
|
||||
// locateSignerChunk finds grok's obfuscated anti-bot signer chunk build-agnostically,
|
||||
// with no dependency on rotating literals (header name, class names, module ids).
|
||||
// It screens every reachable chunk with a cheap obfuscator.io fingerprint, then
|
||||
// confirms the true signer by RUNNING it in goja and checking its output embeds the
|
||||
// homepage seed (signerEmbedsSeed). The signer is lazily loaded (not referenced in
|
||||
// the HTML directly), so candidates also include the chunk paths named inside the
|
||||
// homepage's Turbopack loader manifest.
|
||||
func locateSignerChunk(ctx context.Context, client tlsclient.HttpClient, homeChunks []string, seedB64, curvesJSON string, seed []byte) (string, error) {
|
||||
seen := map[string]bool{}
|
||||
var lazy []string
|
||||
for _, p := range homeChunks {
|
||||
seen[p] = true
|
||||
}
|
||||
|
||||
// Pass 1 (sequential): fetch the homepage chunks, verify them directly, and
|
||||
// harvest every chunk path they reference (the loader manifest lists the signer).
|
||||
for _, p := range homeChunks {
|
||||
body, err := fetchChunk(ctx, client, p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if src, ok := verifySignerChunk(body, seedB64, curvesJSON, seed); ok {
|
||||
return src, nil
|
||||
}
|
||||
for _, ref := range allChunkRefRe.FindAllString(body, -1) {
|
||||
np := normalizeChunkPath(ref)
|
||||
if !seen[np] {
|
||||
seen[np] = true
|
||||
lazy = append(lazy, np)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2 (concurrent): fetch the lazily-referenced chunks, cheap-fingerprint each,
|
||||
// behaviorally verify the matches, and stop at the first chunk that round-trips seed.
|
||||
ctx2, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
found := make(chan string, 1)
|
||||
sem := make(chan struct{}, locateConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
for _, p := range lazy {
|
||||
if ctx2.Err() != nil {
|
||||
break
|
||||
}
|
||||
sem <- struct{}{}
|
||||
wg.Add(1)
|
||||
go func(p string) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
if ctx2.Err() != nil {
|
||||
return
|
||||
}
|
||||
body, err := fetchChunk(ctx2, client, p)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if src, ok := verifySignerChunk(body, seedB64, curvesJSON, seed); ok {
|
||||
select {
|
||||
case found <- src:
|
||||
cancel()
|
||||
default:
|
||||
}
|
||||
}
|
||||
}(p)
|
||||
}
|
||||
go func() { wg.Wait(); close(found) }()
|
||||
if src, ok := <-found; ok {
|
||||
return src, nil
|
||||
}
|
||||
return "", fmt.Errorf("statsig signer chunk not found among %d candidates", len(homeChunks)+len(lazy))
|
||||
}
|
||||
|
||||
// verifySignerChunk returns the goja-ready source if body is grok's signer: it must
|
||||
// carry the obfuscator.io fingerprint AND, when executed, produce an x-statsig-id
|
||||
// whose decoded record embeds the homepage seed.
|
||||
func verifySignerChunk(body, seedB64, curvesJSON string, seed []byte) (string, bool) {
|
||||
if !isObfuscatedSigner(body) {
|
||||
return "", false
|
||||
}
|
||||
clean := sourceMapRe.ReplaceAllString(body, "")
|
||||
eng, err := newSigEngine(clean)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
id, err := eng.statsigID(seedB64, curvesJSON, "/rest/app-chat/conversations/new", "POST")
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if !signerEmbedsSeed(id, seed) {
|
||||
return "", false
|
||||
}
|
||||
return clean, true
|
||||
}
|
||||
|
||||
// normalizeChunkPath turns a bare or /_next/-prefixed chunk ref into a fetch path.
|
||||
func normalizeChunkPath(ref string) string {
|
||||
return "/_next/" + strings.TrimPrefix(ref, "/_next/")
|
||||
}
|
||||
|
||||
// isObfuscatedSigner cheaply screens a chunk for the obfuscator.io string-array
|
||||
// decoder that grok applies ONLY to its anti-bot signer (the rest of the app is
|
||||
// plain Turbopack output). The RC4-style byte decoder (`...^...%256`) is the stable
|
||||
// tell; it matches a handful of chunks, which behavioral verification then narrows
|
||||
// to exactly one. This is deliberately build-agnostic (no rotating class/id/header).
|
||||
func isObfuscatedSigner(src string) bool {
|
||||
return strings.Contains(src, "%256") &&
|
||||
strings.Contains(src, "String.fromCharCode") &&
|
||||
strings.Contains(src, "charCodeAt")
|
||||
}
|
||||
|
||||
// signerEmbedsSeed decodes a candidate x-statsig-id, strips the per-call XOR mask
|
||||
// (plaintext[0] is 0x00, so masked[0] is the key), and checks the plaintext record
|
||||
// begins with 0x00 + the exact homepage seed. Only grok's real signer round-trips
|
||||
// OUR seed, so this uniquely identifies the signer chunk regardless of obfuscation.
|
||||
func signerEmbedsSeed(id string, seed []byte) bool {
|
||||
raw, err := base64.RawStdEncoding.DecodeString(id)
|
||||
if err != nil || len(raw) < 1+len(seed) {
|
||||
return false
|
||||
}
|
||||
key := raw[0] // plaintext[0] is 0x00, so the mask key == masked byte 0
|
||||
for i := 0; i < len(seed); i++ {
|
||||
if raw[1+i]^key != seed[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func fetchChunk(ctx context.Context, client tlsclient.HttpClient, path string) (string, error) {
|
||||
if !strings.HasPrefix(path, "http") {
|
||||
path = apiBase + path
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"*/*"},
|
||||
"user-agent": {userAgent},
|
||||
http.HeaderOrderKey: {"accept", "user-agent"},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("chunk http %d", resp.StatusCode)
|
||||
}
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
func hashStrings(ss []string) string {
|
||||
uniq := dedupe(ss)
|
||||
h := sha512.New()
|
||||
for _, s := range uniq {
|
||||
_, _ = io.WriteString(h, s)
|
||||
_, _ = io.WriteString(h, "\n")
|
||||
}
|
||||
return fmt.Sprintf("%x", h.Sum(nil))
|
||||
}
|
||||
|
||||
func dedupe(ss []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := ss[:0:0]
|
||||
for _, s := range ss {
|
||||
if !seen[s] {
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
package grok
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/cdproto/page"
|
||||
"github.com/chromedp/chromedp"
|
||||
)
|
||||
|
||||
// The x-statsig-id recipe (49-byte header + salt suffix) encodes grok's per-load
|
||||
// browser fingerprint "F". grok recomputes F server-side from the homepage
|
||||
// seed+curves and the byte-indexing that derives it ROTATES on every web reship,
|
||||
// so any hand-ported or baked-in recipe goes stale (403 "Request rejected by
|
||||
// anti-bot rules.") within days. The durable fix is to let grok's OWN signer
|
||||
// produce the recipe: we drive a headless Chrome to grok.com, read the exact
|
||||
// string grok feeds to crypto.subtle.digest (reveals the current suffix) and one
|
||||
// of the x-statsig-id headers its client attaches (reveals the current 49-byte
|
||||
// header + trailer), then feed those into statsigID's existing per-request
|
||||
// computation. Because we read grok's live output we never reverse-engineer the
|
||||
// (rotating) obfuscation; a reship just needs a re-capture, which the auto-refresh
|
||||
// loop does on a timer and whenever a live request hits an anti-bot 403.
|
||||
//
|
||||
// The captured recipe is persisted (site settings) so a restart keeps the last
|
||||
// good values, and every failure is non-fatal — statsigID keeps using whatever
|
||||
// recipe is currently live (persisted → last capture → static defaults).
|
||||
|
||||
// hookJS is installed before grok's own scripts run. It records (a) every
|
||||
// crypto.subtle.digest input that contains the statsig salt marker — the plaintext
|
||||
// "METHOD!/path!counter<suffix>" — and (b) every 70-byte x-statsig-id header grok
|
||||
// attaches to its API calls. Both are only produced once grok's signer succeeds
|
||||
// (after its keyframe animation settles), so a short wait yields valid values.
|
||||
const statsigHookJS = `
|
||||
(() => {
|
||||
window.__statsigDigests = []; window.__statsigCaps = [];
|
||||
try {
|
||||
const td = new TextDecoder('utf-8', {fatal:false});
|
||||
const orig = crypto.subtle.digest.bind(crypto.subtle);
|
||||
crypto.subtle.digest = function(alg, data) {
|
||||
try {
|
||||
let buf = data; if (data && data.buffer) buf = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
|
||||
const txt = td.decode(new Uint8Array(buf.slice ? buf.slice(0) : buf));
|
||||
if (txt.indexOf('` + statsigSaltPrefix + `') >= 0) window.__statsigDigests.push(txt);
|
||||
} catch (e) {}
|
||||
return orig(alg, data);
|
||||
};
|
||||
const of = window.fetch;
|
||||
window.fetch = function(input, init) {
|
||||
try {
|
||||
let hdrs = (init && init.headers); let sid = null;
|
||||
if (hdrs) { if (typeof hdrs.get === 'function') sid = hdrs.get('x-statsig-id'); else for (const k in hdrs) if (k.toLowerCase() === 'x-statsig-id') sid = hdrs[k]; }
|
||||
if (sid && sid.length === 94) window.__statsigCaps.push(sid);
|
||||
} catch (e) {}
|
||||
return of.apply(this, arguments);
|
||||
};
|
||||
} catch (e) {}
|
||||
})();
|
||||
`
|
||||
|
||||
var (
|
||||
statsigRefreshTrigger = make(chan struct{}, 1)
|
||||
statsigRefreshStarted sync.Once
|
||||
statsigLastCapture time.Time
|
||||
statsigLastCaptureMu sync.Mutex
|
||||
)
|
||||
|
||||
// SetStatsigRecipe atomically replaces the live (header, suffix, trailer) used by
|
||||
// statsigID and drops the per-session challenge cache so the next request adopts
|
||||
// the new values. headerHex must decode to 49 bytes; suffix must be non-empty.
|
||||
func SetStatsigRecipe(headerHex, suffix string, trailer int) error {
|
||||
h, err := hex.DecodeString(strings.TrimSpace(headerHex))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(h) != 49 {
|
||||
return errors.New("statsig: header must be 49 bytes")
|
||||
}
|
||||
if strings.TrimSpace(suffix) == "" {
|
||||
return errors.New("statsig: empty suffix")
|
||||
}
|
||||
if trailer < 0 || trailer > 255 {
|
||||
return errors.New("statsig: trailer out of range")
|
||||
}
|
||||
statsigMu.Lock()
|
||||
statsigHeader = h
|
||||
statsigSuffix = suffix
|
||||
statsigTrailer = byte(trailer)
|
||||
statsigCache = map[string]statsigChallenge{}
|
||||
statsigMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// chromeExecPath resolves the Chrome/Chromium binary for headless capture, from
|
||||
// GROK_STATSIG_CHROME or the usual names on PATH. Empty means none available.
|
||||
func chromeExecPath() string {
|
||||
if p := strings.TrimSpace(os.Getenv("GROK_STATSIG_CHROME")); p != "" {
|
||||
return p
|
||||
}
|
||||
for _, name := range []string{"google-chrome-stable", "google-chrome", "chromium", "chromium-browser"} {
|
||||
if p, err := exec.LookPath(name); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type statsigCapResult struct {
|
||||
Digs []string `json:"digs"`
|
||||
Caps []string `json:"caps"`
|
||||
}
|
||||
|
||||
// CaptureStatsigRecipe drives a headless Chrome to grok.com and reads grok's own
|
||||
// signer output, returning the current (headerHex, suffix, trailer). It never
|
||||
// mutates global state. Returns an error if Chrome is unavailable or grok's
|
||||
// signer output could not be observed (e.g. blocked before the signer ran).
|
||||
func CaptureStatsigRecipe(ctx context.Context) (headerHex, suffix string, trailer int, err error) {
|
||||
chromePath := chromeExecPath()
|
||||
if chromePath == "" {
|
||||
return "", "", 0, errors.New("statsig: no chrome binary (set GROK_STATSIG_CHROME)")
|
||||
}
|
||||
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||
chromedp.ExecPath(chromePath),
|
||||
chromedp.Flag("headless", "new"),
|
||||
chromedp.Flag("no-sandbox", true),
|
||||
chromedp.Flag("disable-gpu", true),
|
||||
chromedp.Flag("disable-dev-shm-usage", true),
|
||||
chromedp.UserAgent(userAgent),
|
||||
)
|
||||
if proxy := strings.TrimSpace(os.Getenv("GROK_STATSIG_PROXY")); proxy != "" {
|
||||
opts = append(opts, chromedp.ProxyServer(proxy))
|
||||
}
|
||||
allocCtx, cancel := chromedp.NewExecAllocator(ctx, opts...)
|
||||
defer cancel()
|
||||
bctx, cancel2 := chromedp.NewContext(allocCtx)
|
||||
defer cancel2()
|
||||
|
||||
var res statsigCapResult
|
||||
runErr := chromedp.Run(bctx,
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
_, err := page.AddScriptToEvaluateOnNewDocument(statsigHookJS).Do(ctx)
|
||||
return err
|
||||
}),
|
||||
chromedp.Navigate(apiBase+"/"),
|
||||
chromedp.Sleep(14*time.Second),
|
||||
chromedp.Evaluate(`({digs: window.__statsigDigests || [], caps: window.__statsigCaps || []})`, &res),
|
||||
)
|
||||
if runErr != nil {
|
||||
return "", "", 0, runErr
|
||||
}
|
||||
return parseStatsigCapture(res)
|
||||
}
|
||||
|
||||
// parseStatsigCapture extracts (headerHex, suffix, trailer) from the observed
|
||||
// digest plaintexts and x-statsig-id headers of one page load. Both must be
|
||||
// present and are self-consistent (same per-load fingerprint F).
|
||||
func parseStatsigCapture(res statsigCapResult) (headerHex, suffix string, trailer int, err error) {
|
||||
for _, d := range res.Digs {
|
||||
if i := strings.Index(d, statsigSaltPrefix); i >= 0 {
|
||||
suffix = d[i:]
|
||||
break
|
||||
}
|
||||
}
|
||||
if suffix == "" {
|
||||
return "", "", 0, errors.New("statsig: no signer digest observed (blocked before signer ran?)")
|
||||
}
|
||||
var header []byte
|
||||
for _, sid := range res.Caps {
|
||||
raw, derr := base64.RawStdEncoding.DecodeString(sid)
|
||||
if derr != nil || len(raw) != 70 {
|
||||
continue
|
||||
}
|
||||
key := raw[0] // header[0] is 0x00, so the XOR mask key == byte 0
|
||||
r := make([]byte, 70)
|
||||
for i := range raw {
|
||||
r[i] = raw[i] ^ key
|
||||
}
|
||||
if r[0] != 0x00 {
|
||||
continue
|
||||
}
|
||||
header = r[0:49]
|
||||
trailer = int(r[69])
|
||||
break
|
||||
}
|
||||
if header == nil {
|
||||
return "", "", 0, errors.New("statsig: no valid x-statsig-id header observed")
|
||||
}
|
||||
return hex.EncodeToString(header), suffix, trailer, nil
|
||||
}
|
||||
|
||||
// TriggerStatsigRefresh asks the auto-refresh loop to re-capture soon (coalesced).
|
||||
// Called when a live request hits an anti-bot 403 — the recipe likely went stale.
|
||||
func TriggerStatsigRefresh() {
|
||||
select {
|
||||
case statsigRefreshTrigger <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// StartStatsigAutoRefresh launches the headless capture loop once. It is
|
||||
// event-driven, NOT a polling loop: it seeds the live recipe from persisted
|
||||
// values (load), captures once at startup so the process starts fresh, and then
|
||||
// re-captures ONLY when a live request hits an anti-bot 403 (TriggerStatsigRefresh)
|
||||
// — i.e. exactly when the recipe actually went stale on a grok reship. The recipe
|
||||
// has no fixed clock expiry, so there is nothing useful to poll on. An optional
|
||||
// safetyInterval>0 adds a slow backstop re-capture; pass 0 to disable it (default).
|
||||
// Captured recipes are handed to `save` for persistence. No-op when a manual
|
||||
// GROK_STATSIG_* override is set or no Chrome binary is available.
|
||||
func StartStatsigAutoRefresh(ctx context.Context, safetyInterval time.Duration,
|
||||
load func(context.Context) (headerHex, suffix string, trailer int, ok bool),
|
||||
save func(ctx context.Context, headerHex, suffix string, trailer int)) {
|
||||
|
||||
statsigRefreshStarted.Do(func() {
|
||||
if os.Getenv("GROK_STATSIG_HEADER_HEX") != "" || os.Getenv("GROK_STATSIG_SUFFIX") != "" {
|
||||
log.Printf("grok statsig: manual GROK_STATSIG_* override set — headless auto-refresh disabled")
|
||||
return
|
||||
}
|
||||
if chromeExecPath() == "" {
|
||||
log.Printf("grok statsig: no chrome binary — headless auto-refresh disabled, using static recipe (set GROK_STATSIG_CHROME to enable)")
|
||||
return
|
||||
}
|
||||
if load != nil {
|
||||
if h, s, t, ok := load(ctx); ok {
|
||||
if err := SetStatsigRecipe(h, s, t); err != nil {
|
||||
log.Printf("grok statsig: persisted recipe invalid, ignoring: %v", err)
|
||||
} else {
|
||||
log.Printf("grok statsig: loaded persisted recipe header[:6]=%s", safePrefix(h, 12))
|
||||
}
|
||||
}
|
||||
}
|
||||
go statsigRefreshLoop(ctx, safetyInterval, save)
|
||||
})
|
||||
}
|
||||
|
||||
func statsigRefreshLoop(ctx context.Context, safetyInterval time.Duration, save func(context.Context, string, string, int)) {
|
||||
refresh := func(reason string) {
|
||||
statsigLastCaptureMu.Lock()
|
||||
if time.Since(statsigLastCapture) < 30*time.Second {
|
||||
statsigLastCaptureMu.Unlock()
|
||||
return // debounce bursts (e.g. a 403 storm across concurrent requests)
|
||||
}
|
||||
statsigLastCapture = time.Now()
|
||||
statsigLastCaptureMu.Unlock()
|
||||
|
||||
cctx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
||||
defer cancel()
|
||||
h, s, t, err := CaptureStatsigRecipe(cctx)
|
||||
if err != nil {
|
||||
log.Printf("grok statsig: headless capture (%s) failed, keeping current recipe: %v", reason, err)
|
||||
return
|
||||
}
|
||||
if err := SetStatsigRecipe(h, s, t); err != nil {
|
||||
log.Printf("grok statsig: captured recipe invalid: %v", err)
|
||||
return
|
||||
}
|
||||
if save != nil {
|
||||
save(ctx, h, s, t)
|
||||
}
|
||||
log.Printf("grok statsig: headless recipe refreshed (%s) header[:6]=%s suffix[:24]=%s", reason, safePrefix(h, 12), safePrefix(s, 24))
|
||||
}
|
||||
|
||||
initial := time.NewTimer(10 * time.Second)
|
||||
defer initial.Stop()
|
||||
|
||||
// Optional slow backstop only; disabled (nil channel blocks forever) when
|
||||
// safetyInterval<=0 so the loop stays purely event-driven.
|
||||
var tickC <-chan time.Time
|
||||
if safetyInterval > 0 {
|
||||
ticker := time.NewTicker(safetyInterval)
|
||||
defer ticker.Stop()
|
||||
tickC = ticker.C
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-initial.C:
|
||||
refresh("startup")
|
||||
case <-tickC:
|
||||
refresh("safety-interval")
|
||||
case <-statsigRefreshTrigger:
|
||||
refresh("anti-bot 403")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func safePrefix(s string, n int) string {
|
||||
if len(s) < n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
package grok
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Ground-truth (seed, curves, F) triples. The first row is server-verified
|
||||
// (captured from a real grok browser token that returned 200); the remainder
|
||||
// exercise every curve group / currentTime branch and are checked against the
|
||||
// reference implementation the algorithm was reverse-engineered against.
|
||||
var statsigTriples = []struct {
|
||||
seedHex string
|
||||
wantM string
|
||||
}{
|
||||
{seedHex: "732c9800d181e47c9b2a2705713306f42a51a10d69c6a6c32e53b26d1599b0b75399035a9953fedf05903cb9eb70a989", wantM: "ff75470a3d70a3d70a3d80c51eb851eb8520c51eb851eb8520a3d70a3d70a3d800"},
|
||||
{seedHex: "732c9800d1802530bb1d6d057133064d2aa5a1ca69c6a6c32e53b26d1599b0b75399035a9953fe1805903cb9eb70a989", wantM: "27fa10fd70a3d70a3d7028f5c28f5c28f6028f5c28f5c28f60fd70a3d70a3d700"},
|
||||
{seedHex: "732c9800d180237b2ed91e057133062c2a13a1de69c6a6c32e53b26d1599b0b75399035a9953fed605903cb9eb70a989", wantM: "079ff0d1eb851eb851e80947ae147ae14780947ae147ae14780d1eb851eb851e800"},
|
||||
{seedHex: "732c9800d180197117449405713306722a3fa11f69c6a6c32e53b26d1599b0b75399035a9953fecb05903cb9eb70a989", wantM: "195fe0fae147ae147ae035c28f5c28f5c2035c28f5c28f5c20fae147ae147ae00"},
|
||||
{seedHex: "732c9800d1815c3460be3105713306492ad6a13c69c6a6c32e53b26d1599b0b75399035a9953fe9d05903cb9eb70a989", wantM: "ff6ab5035c28f5c28f5c20fae147ae147ae0fae147ae147ae035c28f5c28f5c200"},
|
||||
{seedHex: "732c9800d181daa0eee8b9057133061e2a20a16969c6a6c32e53b26d1599b0b75399035a9953fefe05903cb9eb70a989", wantM: "3da0d70e3d70a3d70a3d8075c28f5c28f5c4075c28f5c28f5c40e3d70a3d70a3d800"},
|
||||
{seedHex: "732c9800d1812999fdafe5057133067f2a99a15c69c6a6c32e53b26d1599b0b75399035a9953fe7c05903cb9eb70a989", wantM: "1cac4c0451eb851eb8520f5c28f5c28f5c0f5c28f5c28f5c0451eb851eb85200"},
|
||||
{seedHex: "732c9800d18254af4dfad705713306252a93a13c69c6a6c32e53b26d1599b0b75399035a9953fed605903cb9eb70a989", wantM: "b40480d70a3d70a3d70808a3d70a3d70a408a3d70a3d70a40d70a3d70a3d70800"},
|
||||
{seedHex: "732c9800d182b3fee9232f05713306272a14a1a069c6a6c32e53b26d1599b0b75399035a9953feae05903cb9eb70a989", wantM: "13757a100100"},
|
||||
{seedHex: "732c9800d1829ee491c5b105713306f22a8aa12169c6a6c32e53b26d1599b0b75399035a9953fe1f05903cb9eb70a989", wantM: "eb4fb9100a3d70a3d70a3d800a3d70a3d70a3d8100"},
|
||||
{seedHex: "732c9800d1833bfc1e6f9305713306ec2a0ba1b569c6a6c32e53b26d1599b0b75399035a9953fe5605903cb9eb70a989", wantM: "8a56650fae147ae147ae02b851eb851eb8602b851eb851eb860fae147ae147ae00"},
|
||||
{seedHex: "732c9800d183fe2955e5cd057133067e2a42a1cb69c6a6c32e53b26d1599b0b75399035a9953fec805903cb9eb70a989", wantM: "b242950deb851eb851eb807d70a3d70a3d707d70a3d70a3d70deb851eb851eb800"},
|
||||
{seedHex: "732c9800d183d4b7c2764d05713306462a8ea1dc69c6a6c32e53b26d1599b0b75399035a9953fe8e05903cb9eb70a989", wantM: "1e70d8070a3d70a3d70a40e66666666666680e6666666666668070a3d70a3d70a400"}}
|
||||
|
||||
const statsigTestCurves = `[[{"color":[48,44,6,37,198,15],"deg":192,"bezier":[118,76,158,16]},{"color":[224,216,196,111,43,97],"deg":119,"bezier":[67,167,95,219]},{"color":[90,235,250,5,223,64],"deg":104,"bezier":[100,57,106,204]},{"color":[6,109,253,44,29,224],"deg":151,"bezier":[204,60,142,122]},{"color":[81,0,121,208,228,133],"deg":181,"bezier":[182,89,105,123]},{"color":[31,3,160,181,226,184],"deg":98,"bezier":[207,150,215,136]},{"color":[231,243,81,28,109,131],"deg":175,"bezier":[17,103,7,81]},{"color":[222,250,130,169,55,247],"deg":141,"bezier":[21,238,12,84]},{"color":[74,62,116,145,209,185],"deg":109,"bezier":[0,74,58,233]},{"color":[204,168,203,138,107,125],"deg":214,"bezier":[41,13,239,45]},{"color":[246,116,162,162,84,234],"deg":127,"bezier":[160,202,52,76]},{"color":[138,202,210,36,61,195],"deg":234,"bezier":[31,50,177,229]},{"color":[47,46,196,176,79,255],"deg":221,"bezier":[39,14,9,76]},{"color":[245,164,227,71,201,110],"deg":190,"bezier":[193,208,125,9]},{"color":[199,253,44,33,130,240],"deg":191,"bezier":[168,247,61,37]},{"color":[19,91,5,180,202,243],"deg":216,"bezier":[96,152,42,123]}],[{"color":[42,154,230,170,197,128],"deg":108,"bezier":[160,52,34,184]},{"color":[224,132,83,0,231,162],"deg":238,"bezier":[227,37,122,102]},{"color":[24,82,237,199,100,74],"deg":130,"bezier":[186,106,188,209]},{"color":[47,50,169,65,124,44],"deg":228,"bezier":[198,22,146,225]},{"color":[111,131,95,195,131,73],"deg":207,"bezier":[134,146,202,172]},{"color":[192,10,166,28,236,3],"deg":219,"bezier":[85,77,157,235]},{"color":[236,139,199,72,233,250],"deg":197,"bezier":[180,9,79,147]},{"color":[146,195,78,16,231,211],"deg":93,"bezier":[123,18,87,27]},{"color":[168,211,49,42,124,18],"deg":172,"bezier":[232,171,120,118]},{"color":[43,146,96,11,203,53],"deg":146,"bezier":[11,4,83,198]},{"color":[183,97,101,38,115,41],"deg":159,"bezier":[69,223,197,236]},{"color":[78,73,207,132,109,134],"deg":49,"bezier":[59,18,133,168]},{"color":[239,153,225,207,127,157],"deg":194,"bezier":[13,206,154,182]},{"color":[19,146,127,191,68,199],"deg":165,"bezier":[148,212,7,16]},{"color":[93,225,249,144,88,255],"deg":215,"bezier":[62,30,77,69]},{"color":[203,79,164,145,3,20],"deg":81,"bezier":[228,28,93,130]}],[{"color":[171,53,4,125,232,43],"deg":122,"bezier":[136,9,128,97]},{"color":[143,97,205,57,63,69],"deg":192,"bezier":[232,20,219,189]},{"color":[28,173,18,107,158,44],"deg":255,"bezier":[93,187,184,198]},{"color":[170,17,62,142,200,32],"deg":29,"bezier":[14,31,12,97]},{"color":[19,117,122,173,239,66],"deg":74,"bezier":[117,176,139,212]},{"color":[213,151,230,112,224,255],"deg":25,"bezier":[1,223,72,233]},{"color":[153,131,51,105,69,47],"deg":108,"bezier":[123,177,126,140]},{"color":[120,114,44,151,88,83],"deg":165,"bezier":[16,104,134,75]},{"color":[245,145,194,75,120,26],"deg":142,"bezier":[79,235,38,43]},{"color":[147,63,50,255,239,106],"deg":190,"bezier":[122,143,160,150]},{"color":[233,78,184,130,25,123],"deg":54,"bezier":[87,18,184,226]},{"color":[162,180,233,70,57,249],"deg":87,"bezier":[123,238,61,124]},{"color":[146,95,56,171,38,240],"deg":239,"bezier":[241,134,228,44]},{"color":[111,11,149,62,208,177],"deg":70,"bezier":[103,149,4,37]},{"color":[159,128,118,21,197,153],"deg":175,"bezier":[246,215,172,236]},{"color":[194,131,68,247,215,108],"deg":30,"bezier":[23,91,151,231]}],[{"color":[239,129,141,243,85,208],"deg":38,"bezier":[252,248,245,195]},{"color":[205,56,138,49,126,99],"deg":107,"bezier":[72,85,228,91]},{"color":[185,54,148,122,170,158],"deg":192,"bezier":[33,88,51,136]},{"color":[7,14,75,26,23,41],"deg":93,"bezier":[153,21,55,147]},{"color":[245,211,213,64,5,253],"deg":49,"bezier":[253,19,106,155]},{"color":[144,232,165,21,114,130],"deg":200,"bezier":[193,179,133,226]},{"color":[34,196,100,42,114,0],"deg":52,"bezier":[4,4,71,65]},{"color":[195,8,130,102,201,141],"deg":210,"bezier":[8,173,23,33]},{"color":[86,136,44,95,223,62],"deg":249,"bezier":[220,98,68,113]},{"color":[205,48,9,247,236,71],"deg":75,"bezier":[163,240,28,25]},{"color":[43,190,29,239,55,135],"deg":146,"bezier":[109,245,34,188]},{"color":[146,91,92,2,3,251],"deg":97,"bezier":[183,188,95,157]},{"color":[40,213,196,70,81,174],"deg":120,"bezier":[153,197,61,201]},{"color":[131,92,180,68,131,214],"deg":251,"bezier":[94,191,198,89]},{"color":[47,113,219,96,115,228],"deg":238,"bezier":[22,35,60,63]},{"color":[246,244,203,196,78,136],"deg":44,"bezier":[88,23,205,184]}]]`
|
||||
|
||||
// TestComputeStatsigTail was the offline regression for the hand-ported F
|
||||
// derivation (computeStatsigTail). That algorithm is now only a last-resort
|
||||
// fallback: the durable path runs grok's own signer in goja (statsig_engine.go),
|
||||
// which self-heals across web reships. grok rotates the F algorithm on every
|
||||
// reship, so these browser-captured (seed,F) triples go stale and CANNOT be
|
||||
// regenerated offline. TestSelfHealStatsigE2E (live, via goja) is the real
|
||||
// regression now. Kept skipped so the reference vectors aren't lost.
|
||||
func TestComputeStatsigTail(t *testing.T) {
|
||||
t.Skip("static F derivation is a deprecated fallback; see TestSelfHealStatsigE2E (goja)")
|
||||
var curves [][]statsigCurve
|
||||
if err := json.Unmarshal([]byte(statsigTestCurves), &curves); err != nil {
|
||||
t.Fatalf("curves: %v", err)
|
||||
}
|
||||
for i, tc := range statsigTriples {
|
||||
seed, err := hex.DecodeString(tc.seedHex)
|
||||
if err != nil {
|
||||
t.Fatalf("[%d] seed hex: %v", i, err)
|
||||
}
|
||||
got, err := computeStatsigTail(seed, curves)
|
||||
if err != nil {
|
||||
t.Fatalf("[%d] computeStatsigTail: %v", i, err)
|
||||
}
|
||||
if got != tc.wantM {
|
||||
t.Errorf("[%d] group=%d\n got=%s\nwant=%s", i, int(seed[5])%len(curves), got, tc.wantM)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelfHealStatsigE2E exercises the full browser-free self-healing path:
|
||||
// fetch the homepage, derive seed+F, cache the challenge, then hit the
|
||||
// anti-bot-gated conversations/new endpoint. Requires a live GROK_TOK and no
|
||||
// GROK_STATSIG_* env overrides.
|
||||
func TestSelfHealStatsigE2E(t *testing.T) {
|
||||
token := strings.TrimSpace(os.Getenv("GROK_TOK"))
|
||||
if token == "" {
|
||||
t.Skip("no GROK_TOK")
|
||||
}
|
||||
c := NewClient("")
|
||||
client, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
|
||||
c.ensureChallenge(ctx, client, token)
|
||||
statsigMu.Lock()
|
||||
ch, ok := statsigCache[token]
|
||||
statsigMu.Unlock()
|
||||
if !ok {
|
||||
t.Fatal("challenge not cached (homepage fetch/derive failed)")
|
||||
}
|
||||
t.Logf("dynamic header[:6]=%x suffix=%s", ch.header[:6], ch.suffix)
|
||||
|
||||
body, err := c.postStream(ctx, client, token, "/rest/app-chat/conversations/new", map[string]any{
|
||||
"temporary": true,
|
||||
"modelName": "grok-3",
|
||||
"message": "hi",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("conversations/new: %v", err)
|
||||
}
|
||||
t.Logf("OK bytes=%d head=%.80s", len(body), strings.ReplaceAll(body, "\n", " "))
|
||||
}
|
||||
|
||||
// TestStatsigEngineConcurrent fires many concurrent goja signs through the pool to
|
||||
// catch data races / engine cross-talk (run with -race). Requires a live GROK_TOK
|
||||
// so the engine is built and the challenge cached.
|
||||
func TestStatsigEngineConcurrent(t *testing.T) {
|
||||
token := strings.TrimSpace(os.Getenv("GROK_TOK"))
|
||||
if token == "" {
|
||||
t.Skip("no GROK_TOK")
|
||||
}
|
||||
c := NewClient("")
|
||||
client, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
c.ensureChallenge(ctx, client, token)
|
||||
statsigMu.Lock()
|
||||
ch, ok := statsigCache[token]
|
||||
statsigMu.Unlock()
|
||||
if !ok || ch.seedB64 == "" || ch.curvesJSON == "" {
|
||||
t.Fatal("engine inputs not cached")
|
||||
}
|
||||
|
||||
const n = 40
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, n)
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
id, err := signWithEngine(ch.seedB64, ch.curvesJSON, "/rest/app-chat/conversations/new", "POST")
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
if len(id) < 40 {
|
||||
errs <- errTooShort
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for e := range errs {
|
||||
t.Fatalf("concurrent sign: %v", e)
|
||||
}
|
||||
}
|
||||
|
||||
var errTooShort = errors.New("statsig id too short")
|
||||
|
||||
// TestGenerateVideoE2E generates a real grok video using only the dynamic
|
||||
// self-healed statsig (no env overrides). Requires a live GROK_TOK.
|
||||
func TestGenerateVideoE2E(t *testing.T) {
|
||||
token := strings.TrimSpace(os.Getenv("GROK_TOK"))
|
||||
if token == "" {
|
||||
t.Skip("no GROK_TOK")
|
||||
}
|
||||
c := NewClient("")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
data, meta, err := c.GenerateVideo(ctx, token, "a cat playing piano", "16:9", "720p", 6, nil, true)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateVideo: %v", err)
|
||||
}
|
||||
t.Logf("video bytes=%d meta=%v", len(data), meta)
|
||||
if len(data) < 1<<20 {
|
||||
t.Fatalf("video too small: %d bytes", len(data))
|
||||
}
|
||||
if !strings.Contains(string(data[:16]), "ftyp") {
|
||||
t.Fatalf("not an mp4: % x", data[:16])
|
||||
}
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
// statsig_shim.js — browser-environment shim that lets grok.com's own obfuscated
|
||||
// x-statsig-id signer run inside goja. grok's code does all the (per-build,
|
||||
// rotating) byte-indexing/curve-selection; we only provide the STABLE browser
|
||||
// primitives it reads from: the seed <meta>, the .r-aufz1o SVG curve DOM, and a
|
||||
// standard Web-Animations getComputedStyle sampler. Inputs arrive via globals set
|
||||
// by Go before each sign: __SEED (base64 str), __CURVES (JSON [[{color,deg,bezier}]]),
|
||||
// __PATH, __METHOD. Go also injects __goSha256(Uint8Array)->ArrayBuffer.
|
||||
(function () {
|
||||
'use strict';
|
||||
var g = globalThis;
|
||||
|
||||
// ---- base64 (goja has no atob/btoa) ----
|
||||
var B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
g.atob = function (s) {
|
||||
s = String(s).replace(/=+$/, '');
|
||||
var out = '', bits = 0, val = 0;
|
||||
for (var i = 0; i < s.length; i++) {
|
||||
var c = B64.indexOf(s.charAt(i));
|
||||
if (c < 0) continue;
|
||||
val = (val << 6) | c; bits += 6;
|
||||
if (bits >= 8) { bits -= 8; out += String.fromCharCode((val >> bits) & 0xff); }
|
||||
}
|
||||
return out;
|
||||
};
|
||||
g.btoa = function (s) {
|
||||
s = String(s); var out = '';
|
||||
for (var i = 0; i < s.length; i += 3) {
|
||||
var b0 = s.charCodeAt(i), b1 = s.charCodeAt(i + 1), b2 = s.charCodeAt(i + 2);
|
||||
var h0 = b0 >> 2, h1 = ((b0 & 3) << 4) | (b1 >> 4);
|
||||
var h2 = ((b1 & 15) << 2) | (b2 >> 6), h3 = b2 & 63;
|
||||
out += B64[h0] + B64[h1];
|
||||
out += isNaN(b1) ? '=' : B64[h2];
|
||||
out += isNaN(b2) ? '=' : B64[h3];
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// ---- TextEncoder (goja has no TextEncoder) ----
|
||||
if (typeof g.TextEncoder === 'undefined') {
|
||||
g.TextEncoder = function () {};
|
||||
g.TextEncoder.prototype.encode = function (str) {
|
||||
str = String(str);
|
||||
var bytes = [];
|
||||
for (var i = 0; i < str.length; i++) {
|
||||
var c = str.charCodeAt(i);
|
||||
if (c < 0x80) bytes.push(c);
|
||||
else if (c < 0x800) { bytes.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); }
|
||||
else if (c >= 0xd800 && c <= 0xdbff) { // surrogate pair
|
||||
var c2 = str.charCodeAt(++i);
|
||||
var cp = 0x10000 + ((c & 0x3ff) << 10) + (c2 & 0x3ff);
|
||||
bytes.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f));
|
||||
} else { bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); }
|
||||
}
|
||||
return Uint8Array.from(bytes);
|
||||
};
|
||||
}
|
||||
|
||||
// ---- crypto.subtle.digest, backed by Go SHA-256 ----
|
||||
g.crypto = g.crypto || {};
|
||||
g.crypto.subtle = g.crypto.subtle || {};
|
||||
g.crypto.subtle.digest = function (algo, data) {
|
||||
// grok only ever asks for sha-256; g.__goSha256 returns an ArrayBuffer
|
||||
var bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
|
||||
return Promise.resolve(g.__goSha256(bytes));
|
||||
};
|
||||
|
||||
// ---- Web Animations getComputedStyle sampler (the only real math we own) ----
|
||||
var K = 4096;
|
||||
function cubicBezier(x1, y1, x2, y2, p) {
|
||||
if (p <= 0) return 0; if (p >= 1) return 1;
|
||||
function bez(t, a, b) { var mt = 1 - t; return 3 * a * mt * mt * t + 3 * b * mt * t * t + t * t * t; }
|
||||
var lo = 0, hi = 1;
|
||||
for (var i = 0; i < 100; i++) { var m = (lo + hi) / 2; if (bez(m, x1, x2) < p) lo = m; else hi = m; }
|
||||
return bez((lo + hi) / 2, y1, y2);
|
||||
}
|
||||
function hexToRgb(h) { h = h.replace('#', ''); return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; }
|
||||
function sample(anim) {
|
||||
var kf = anim.keyframes, dur = anim.duration || K;
|
||||
var frac = anim.currentTime / dur; if (frac < 0) frac = 0; if (frac > 1) frac = 1;
|
||||
var bm = /cubic-bezier\(([^)]+)\)/.exec(kf.easing || '');
|
||||
var eased = frac;
|
||||
if (bm) { var p = bm[1].split(',').map(Number); eased = cubicBezier(p[0], p[1], p[2], p[3], frac); }
|
||||
var c0 = hexToRgb(kf.color[0]), c1 = hexToRgb(kf.color[1]);
|
||||
var col = [0, 1, 2].map(function (i) { return Math.round(c0[i] + (c1[i] - c0[i]) * eased); });
|
||||
var d0 = parseFloat(/rotate\(([-\d.]+)deg\)/.exec(kf.transform[0])[1]);
|
||||
var d1 = parseFloat(/rotate\(([-\d.]+)deg\)/.exec(kf.transform[1])[1]);
|
||||
var ang = (d0 + (d1 - d0) * eased) * Math.PI / 180;
|
||||
var cos = Math.cos(ang), sin = Math.sin(ang);
|
||||
return { color: 'rgb(' + col[0] + ', ' + col[1] + ', ' + col[2] + ')',
|
||||
transform: 'matrix(' + cos + ', ' + sin + ', ' + (-sin) + ', ' + cos + ', 0, 0)' };
|
||||
}
|
||||
|
||||
// ---- minimal DOM ----
|
||||
function makeEl(props) {
|
||||
var e = {
|
||||
nodeName: 'DIV', style: {}, childNodes: [], attrs: {}, _anim: null, _parent: null,
|
||||
setAttribute: function (k, v) { e.attrs[k] = v; },
|
||||
getAttribute: function (k) { return (k in e.attrs) ? e.attrs[k] : (props && props.attrs && k in props.attrs ? props.attrs[k] : null); },
|
||||
appendChild: function (c) { e.childNodes.push(c); return c; },
|
||||
append: function (c) { e.childNodes.push(c); return c; },
|
||||
removeChild: function (c) { return c; },
|
||||
remove: function () {},
|
||||
animate: function (keyframes, opts) {
|
||||
var anim = {
|
||||
keyframes: keyframes,
|
||||
duration: (opts && opts.duration) || (typeof opts === 'number' ? opts : K),
|
||||
currentTime: 0, pause: function () {}, play: function () {}, cancel: function () {},
|
||||
effect: { getKeyframes: function () { return Array.isArray(keyframes) ? keyframes : [keyframes]; } },
|
||||
};
|
||||
e._anim = anim; return anim;
|
||||
},
|
||||
getAnimations: function () { return e._anim ? [e._anim] : []; },
|
||||
};
|
||||
if (props) for (var k in props) if (k !== 'attrs') e[k] = props[k];
|
||||
Object.defineProperty(e, 'parentElement', { get: function () { return e._parent; } });
|
||||
Object.defineProperty(e, 'innerHTML', { set: function (v) { e._html = v; }, get: function () { return e._html; } });
|
||||
Object.defineProperty(e, 'textContent', { set: function (v) { e._text = v; }, get: function () { return e._text; } });
|
||||
return e;
|
||||
}
|
||||
|
||||
// .r-aufz1o group: g.childNodes[0].childNodes[1].getAttribute('d') = svg path whose
|
||||
// numbers (after substring(9), split('C')) decode back to each curve [c0..c5,deg,b0..b3].
|
||||
function groupEl(flatCurves) {
|
||||
var d = '_________' + flatCurves.map(function (c) { return c.join(' '); }).join('C');
|
||||
var path = makeEl({ attrs: { d: d } });
|
||||
var inner = makeEl(); inner.childNodes = [makeEl(), path];
|
||||
var outer = makeEl(); outer.childNodes = [inner]; outer._parent = makeEl();
|
||||
return outer;
|
||||
}
|
||||
|
||||
var docBody = makeEl();
|
||||
g.document = {
|
||||
currentScript: null, body: docBody, head: makeEl(),
|
||||
createElement: function (tag) { return makeEl({ nodeName: String(tag || 'div').toUpperCase() }); },
|
||||
querySelectorAll: function (sel) {
|
||||
sel = String(sel);
|
||||
// The seed <meta> is selected by a name/verification attribute selector
|
||||
// (e.g. [name^=gr] or [name*=verification]); the curve group container is
|
||||
// selected by a per-build hashed CLASS selector (e.g. .r-aufz1o, .r-3nqkqc)
|
||||
// which rotates on every reship — so match by shape, not literal class.
|
||||
if (/verification|name/i.test(sel)) {
|
||||
var seed = g.__SEED;
|
||||
return [{ nodeName: 'META', getAttribute: function (a) { return a === 'content' ? seed : null; },
|
||||
get content() { return seed; } }];
|
||||
}
|
||||
if (/(^|\s|,)\./.test(sel)) {
|
||||
var curves = JSON.parse(g.__CURVES);
|
||||
return curves.map(function (grp) {
|
||||
return groupEl(grp.map(function (cv) { return cv.color.concat([cv.deg], cv.bezier); }));
|
||||
});
|
||||
}
|
||||
return [];
|
||||
},
|
||||
querySelector: function (sel) { var r = this.querySelectorAll(sel); return r[0] || null; },
|
||||
};
|
||||
g.window = g;
|
||||
g.self = g;
|
||||
g.getComputedStyle = function (el) { return el && el._anim ? sample(el._anim) : { color: 'rgb(0, 0, 0)', transform: 'none' }; };
|
||||
g.navigator = g.navigator || { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36' };
|
||||
g.location = g.location || { href: 'https://grok.com/', origin: 'https://grok.com', pathname: '/' };
|
||||
|
||||
// ---- Turbopack capture + bootstrap ----
|
||||
var TP = []; TP.push = function (entry) { TP._entry = entry; return 0; };
|
||||
g.TURBOPACK = TP;
|
||||
|
||||
// Called by Go AFTER the signer chunk is eval'd: run the module factory, grab default.
|
||||
g.__grokBootstrap = function () {
|
||||
var entry = TP._entry;
|
||||
if (!entry) throw new Error('turbopack entry not registered');
|
||||
var factory = entry[2];
|
||||
var exports = {};
|
||||
var ctx = {
|
||||
s: function () {
|
||||
var flat = Array.prototype.slice.call(arguments).flat(Infinity);
|
||||
var name = null;
|
||||
for (var i = 0; i < flat.length; i++) {
|
||||
var x = flat[i];
|
||||
if (typeof x === 'string') name = x;
|
||||
else if (typeof x === 'function' && name != null) {
|
||||
(function (nm, getter) { Object.defineProperty(exports, nm, { get: getter, configurable: true, enumerable: true }); })(name, x);
|
||||
name = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
factory(ctx);
|
||||
// exports.default is a GETTER that invokes the module factory on every read,
|
||||
// returning a FRESH async signer (path,method)=>Promise<id> with a fresh internal
|
||||
// curve cache. Keep the exports object; read .default fresh per sign so different
|
||||
// sessions' curves never leak through the closure cache.
|
||||
g.__grokExports = exports;
|
||||
if (typeof exports.default !== 'function') throw new Error('no default export');
|
||||
};
|
||||
|
||||
// Called by Go per sign. Fresh signer each time via the re-invoking getter.
|
||||
g.__grokSign = function () {
|
||||
var signer = g.__grokExports.default; // fresh async signer
|
||||
return signer(g.__PATH, g.__METHOD); // returns Promise<string>
|
||||
};
|
||||
|
||||
// Synchronous bridge: Go calls this via RunString (which drains goja's job queue),
|
||||
// then reads __grokResult / __grokErr. Works because crypto.subtle.digest resolves
|
||||
// synchronously (Promise.resolve over a Go SHA-256), so the whole await chain settles
|
||||
// within the microtask drain.
|
||||
g.__grokResult = null;
|
||||
g.__grokErr = null;
|
||||
g.__grokSignInto = function () {
|
||||
g.__grokResult = null; g.__grokErr = null;
|
||||
try {
|
||||
g.__grokSign().then(
|
||||
function (r) { g.__grokResult = r; },
|
||||
function (e) { g.__grokErr = (e && e.stack) ? String(e.stack) : String(e); }
|
||||
);
|
||||
} catch (e) { g.__grokErr = (e && e.stack) ? String(e.stack) : String(e); }
|
||||
};
|
||||
})();
|
||||
@@ -1,528 +0,0 @@
|
||||
package grok
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/textproto"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
http "github.com/bogdanfinn/fhttp"
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// assetBase is where generated media artifacts live (the stream returns a
|
||||
// path like "users/<uid>/generated/<id>/generated_video.mp4").
|
||||
const assetBase = "https://assets.grok.com/"
|
||||
|
||||
var videoURLRe = regexp.MustCompile(`"videoUrl":"([^"]+)"`)
|
||||
|
||||
// GenerateVideo runs grok's imagine video pipeline:
|
||||
// 1. POST /rest/media/post/create -> a media post id (parentPostId)
|
||||
// 2. POST /rest/app-chat/conversations/new with modelName "imagine-video-gen"
|
||||
// and a videoGenModelConfig referencing that post id; the streaming response
|
||||
// reports progress and, at completion, the artifact videoUrl.
|
||||
//
|
||||
// frames (optional, up to the model's max) enable image-to-video: each image is
|
||||
// uploaded to grok and referenced as an imageReference. aspectRatio is passed
|
||||
// through ("9:16" etc.); resolution is the tier ("720p"); seconds is the clip
|
||||
// length (6 or 10). When downloadResult is false, returns nil bytes and the
|
||||
// artifact URL in meta["video_url"]; otherwise downloads the mp4.
|
||||
func (c *Client) GenerateVideo(ctx context.Context, token, prompt, aspectRatio, resolution string, seconds int, frames [][]byte, downloadResult bool) ([]byte, map[string]any, error) {
|
||||
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
|
||||
if token == "" {
|
||||
return nil, nil, ErrAuth
|
||||
}
|
||||
if strings.TrimSpace(prompt) == "" {
|
||||
return nil, nil, fmt.Errorf("grok: prompt required")
|
||||
}
|
||||
if strings.TrimSpace(aspectRatio) == "" {
|
||||
aspectRatio = "16:9"
|
||||
}
|
||||
if strings.TrimSpace(resolution) == "" {
|
||||
resolution = "720p"
|
||||
}
|
||||
if seconds != 6 && seconds != 10 && seconds != 15 {
|
||||
seconds = 10
|
||||
}
|
||||
|
||||
// Only the conversations/new submit egresses via the proxy; reference-frame
|
||||
// upload and the mp4 download run on the local IP.
|
||||
submitClient, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
directClient, err := c.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Self-heal the x-statsig-id anti-bot challenge for THIS session before the
|
||||
// gated submit. Without it the header falls back to the static defaults,
|
||||
// which go stale on a new grok web build and make conversations/new answer
|
||||
// 403 (anti-bot). Fetch on the local IP (the proxy is rotating, so pinning
|
||||
// the seed to a proxy egress IP is pointless). Failure is non-fatal
|
||||
// (statsigID then uses defaults).
|
||||
c.ensureChallenge(ctx, directClient, token)
|
||||
|
||||
// Image-to-video: upload each reference frame and collect its asset URL.
|
||||
var imageRefs []string
|
||||
for _, f := range frames {
|
||||
if len(f) == 0 {
|
||||
continue
|
||||
}
|
||||
url, upErr := c.uploadImage(ctx, directClient, token, f)
|
||||
if upErr != nil {
|
||||
return nil, nil, upErr
|
||||
}
|
||||
imageRefs = append(imageRefs, url)
|
||||
}
|
||||
|
||||
// grok streams progress inline, but the stream is fragile: it regularly dies
|
||||
// mid-body ("unexpected EOF") after only the conversation object / an early
|
||||
// progress event — while grok keeps rendering the clip server-side. The
|
||||
// rendered clip always lands at the deterministic URL
|
||||
// assets.grok.com/users/{userId}/generated/{postId}/generated_video.mp4, so a
|
||||
// dead stream is NOT a failed generation: poll that URL until the clip is
|
||||
// ready instead of failing the job (which would also fail over and burn
|
||||
// another account's credits for a video that already exists).
|
||||
postID, userID, err := c.createPost(ctx, submitClient, token, prompt)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
videoCfg := map[string]any{
|
||||
"parentPostId": postID,
|
||||
"aspectRatio": aspectRatio,
|
||||
"videoLength": seconds,
|
||||
"resolutionName": resolution,
|
||||
"isReferenceToVideo": len(imageRefs) > 0,
|
||||
}
|
||||
if len(imageRefs) > 0 {
|
||||
videoCfg["imageReferences"] = imageRefs
|
||||
}
|
||||
payload := map[string]any{
|
||||
"temporary": true,
|
||||
"modelName": "imagine-video-gen",
|
||||
"message": prompt + " --mode=custom",
|
||||
"enableSideBySide": true,
|
||||
"responseMetadata": map[string]any{
|
||||
"modelConfigOverride": map[string]any{
|
||||
"modelMap": map[string]any{"videoGenModelConfig": videoCfg},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// body may be partial when psErr != nil — still worth scanning.
|
||||
body, psErr := c.postStream(ctx, submitClient, token, "/rest/app-chat/conversations/new", payload)
|
||||
if dir := strings.TrimSpace(os.Getenv("GROK_DUMP")); dir != "" {
|
||||
_ = os.WriteFile(dir+"/grok_body_"+postID+".txt", []byte(body), 0o644)
|
||||
}
|
||||
// Out-of-credits surfaces as a stream error (HTTP is still 200) — not retryable.
|
||||
if strings.Contains(body, "usagePoolExhausted") || strings.Contains(body, "media generation credits") {
|
||||
return nil, nil, fmt.Errorf("%w: media generation credits exhausted", ErrQuotaExhausted)
|
||||
}
|
||||
if psErr != nil && (errors.Is(psErr, ErrAuth) || errors.Is(psErr, ErrQuotaExhausted)) {
|
||||
return nil, nil, psErr
|
||||
}
|
||||
// A fatal stream error means grok definitively rejected this generation
|
||||
// (content moderation, an unsupported parameter, etc.). Retrying — on this
|
||||
// or any other account — fails identically and only burns the pool, so fail
|
||||
// fast with a non-temporary error the pool won't fail over on.
|
||||
if strings.Contains(body, "STREAM_ERROR_SEVERITY_FATAL") {
|
||||
return nil, nil, fmt.Errorf("grok: video generation rejected by upstream (fatal stream error): %s", clip([]byte(body), 200))
|
||||
}
|
||||
// Fast path: the artifact appears as "videoUrl":"users/.../generated_video.mp4".
|
||||
artifact := ""
|
||||
for _, m := range videoURLRe.FindAllStringSubmatch(body, -1) {
|
||||
if v := strings.TrimSpace(m[1]); v != "" {
|
||||
artifact = v // keep the last (progress=100) one
|
||||
}
|
||||
}
|
||||
if artifact == "" && userID != "" {
|
||||
// Stream ended without the artifact (dropped connection or early close):
|
||||
// the render continues upstream — wait for the deterministic asset URL.
|
||||
url := fmt.Sprintf("%susers/%s/generated/%s/generated_video.mp4", assetBase, userID, postID)
|
||||
if werr := c.waitForAsset(ctx, directClient, token, url); werr == nil {
|
||||
artifact = url
|
||||
} else if errors.Is(werr, ErrAuth) || errors.Is(werr, context.Canceled) || errors.Is(werr, context.DeadlineExceeded) {
|
||||
return nil, nil, werr
|
||||
}
|
||||
}
|
||||
if artifact == "" {
|
||||
if psErr != nil {
|
||||
return nil, nil, psErr
|
||||
}
|
||||
return nil, nil, fmt.Errorf("%w: no video artifact in response: %s", ErrTemporaryUpstream, clip([]byte(body), 200))
|
||||
}
|
||||
fullURL := artifact
|
||||
if !strings.HasPrefix(fullURL, "http") {
|
||||
fullURL = assetBase + strings.TrimPrefix(artifact, "/")
|
||||
}
|
||||
|
||||
meta := map[string]any{
|
||||
"provider": "grok",
|
||||
"post_id": postID,
|
||||
"video_url": fullURL,
|
||||
}
|
||||
if !downloadResult {
|
||||
return nil, meta, nil
|
||||
}
|
||||
data, err := c.download(ctx, directClient, token, fullURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return data, meta, nil
|
||||
}
|
||||
|
||||
// uploadImage uploads one reference frame via /http/upload-file-v2/direct and
|
||||
// returns its asset content URL for imageReferences. Cloudflare's bot score is
|
||||
// per-request, so retry transient failures with backoff instead of failing the
|
||||
// whole task.
|
||||
func (c *Client) uploadImage(ctx context.Context, client tlsclient.HttpClient, token string, img []byte) (string, error) {
|
||||
var res map[string]any
|
||||
var err error
|
||||
backoffs := []time.Duration{0, 2 * time.Second, 5 * time.Second, 10 * time.Second}
|
||||
for _, wait := range backoffs {
|
||||
if wait > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-time.After(wait):
|
||||
}
|
||||
}
|
||||
res, err = c.uploadFileV2(ctx, client, token, img)
|
||||
if err == nil || !errors.Is(err, ErrTemporaryUpstream) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
meta, _ := res["fileMetadata"].(map[string]any)
|
||||
fileURI := ""
|
||||
if meta != nil {
|
||||
fileURI = strings.TrimSpace(stringValue(meta["fileUri"]))
|
||||
}
|
||||
if fileURI == "" {
|
||||
return "", fmt.Errorf("%w: upload missing fileUri", ErrTemporaryUpstream)
|
||||
}
|
||||
if strings.HasPrefix(fileURI, "http") {
|
||||
return fileURI, nil
|
||||
}
|
||||
return assetBase + strings.TrimPrefix(fileURI, "/"), nil
|
||||
}
|
||||
|
||||
func (c *Client) uploadFileV2(ctx context.Context, client tlsclient.HttpClient, token string, img []byte) (map[string]any, error) {
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
partHeader := textproto.MIMEHeader{}
|
||||
partHeader.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, uuid.NewString()+".png"))
|
||||
partHeader.Set("Content-Type", "image/png")
|
||||
part, err := mw.CreatePart(partHeader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := part.Write(img); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := mw.WriteField("file_source", "IMAGINE_SELF_UPLOAD_FILE_SOURCE"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, apiBase+"/http/upload-file-v2/direct", bytes.NewReader(buf.Bytes()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
c.applyHeaders(req, token, map[string]string{"content-type": mw.FormDataContentType()})
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
if e := mapStatus("/http/upload-file-v2/direct", resp.StatusCode, raw); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, fmt.Errorf("%w: upload non-json: %s", ErrTemporaryUpstream, clip(raw, 120))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// createPost registers a video media post and returns its id (parentPostId).
|
||||
// createPost creates the parent media post. grok uses its id as the videoId,
|
||||
// and the rendered clip is stored under users/{userId}/generated/{postId}/.
|
||||
func (c *Client) createPost(ctx context.Context, client tlsclient.HttpClient, token, prompt string) (postID, userID string, err error) {
|
||||
res, err := c.postJSON(ctx, client, token, "/rest/media/post/create", map[string]any{
|
||||
"mediaType": "MEDIA_POST_TYPE_VIDEO",
|
||||
"prompt": prompt,
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
post, _ := res["post"].(map[string]any)
|
||||
postID = strings.TrimSpace(stringValue(post["id"]))
|
||||
userID = strings.TrimSpace(stringValue(post["userId"]))
|
||||
if postID == "" {
|
||||
return "", "", fmt.Errorf("%w: media post missing id", ErrTemporaryUpstream)
|
||||
}
|
||||
return postID, userID, nil
|
||||
}
|
||||
|
||||
// waitForAsset polls a generated-asset URL (ranged GET on the local IP —
|
||||
// assets.grok.com is not anti-bot gated) until grok finishes rendering it.
|
||||
// 404 means "still rendering"; auth failures abort.
|
||||
func (c *Client) waitForAsset(ctx context.Context, client tlsclient.HttpClient, token, url string) error {
|
||||
deadline := time.Now().Add(30 * time.Minute)
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"user-agent": {userAgent},
|
||||
"referer": {origin + "/"},
|
||||
"cookie": {"sso=" + token + "; sso-rw=" + token},
|
||||
"range": {"bytes=0-0"},
|
||||
}
|
||||
if resp, derr := client.Do(req); derr == nil {
|
||||
code := resp.StatusCode
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
switch {
|
||||
case code == http.StatusOK || code == http.StatusPartialContent:
|
||||
return nil
|
||||
case code == http.StatusUnauthorized || code == http.StatusForbidden:
|
||||
return fmt.Errorf("%w: asset %d", ErrAuth, code)
|
||||
}
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("%w: video render did not complete in time", ErrTemporaryUpstream)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(4 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// postJSON does an authed JSON POST and parses a single JSON object response.
|
||||
func (c *Client) postJSON(ctx context.Context, client tlsclient.HttpClient, token, path string, body any) (map[string]any, error) {
|
||||
raw, status, err := c.doPost(ctx, client, token, path, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if e := mapStatus(path, status, raw); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
var out map[string]any
|
||||
if len(raw) == 0 {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, fmt.Errorf("%w: %s non-json: %s", ErrTemporaryUpstream, path, clip(raw, 120))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// postStream does an authed JSON POST and returns the full (streamed) text body.
|
||||
func (c *Client) postStream(ctx context.Context, client tlsclient.HttpClient, token, path string, body any) (string, error) {
|
||||
raw, status, err := c.doPost(ctx, client, token, path, body)
|
||||
if err != nil {
|
||||
return string(raw), err
|
||||
}
|
||||
if e := mapStatus(path, status, raw); e != nil {
|
||||
return string(raw), e
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
func (c *Client) doPost(ctx context.Context, client tlsclient.HttpClient, token, path string, body any) ([]byte, int, error) {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
reader = strings.NewReader(string(b))
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, apiBase+path, reader)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
c.applyHeaders(req, token, nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
// Mid-body stream drops ("unexpected EOF", HTTP/2 resets) are common on
|
||||
// long renders — return what was received so callers can still use it.
|
||||
return raw, resp.StatusCode, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
return raw, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// OpenAsset streams a grok asset (e.g. a generated video) authenticated with the
|
||||
// account token — used by the async /v1/videos /content proxy. The caller MUST
|
||||
// close the returned ReadCloser.
|
||||
func (c *Client) OpenAsset(ctx context.Context, token, url string) (io.ReadCloser, string, error) {
|
||||
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
|
||||
if token == "" {
|
||||
return nil, "", ErrAuth
|
||||
}
|
||||
// Asset streaming is pure bandwidth (no anti-bot) — egress on the local IP.
|
||||
client, err := c.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"user-agent": {userAgent},
|
||||
"referer": {origin + "/"},
|
||||
"cookie": {"sso=" + token + "; sso-rw=" + token},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
||||
return nil, "", fmt.Errorf("%w: asset %d", ErrAuth, resp.StatusCode)
|
||||
}
|
||||
return nil, "", fmt.Errorf("%w: asset %d", ErrTemporaryUpstream, resp.StatusCode)
|
||||
}
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if ct == "" {
|
||||
ct = "video/mp4"
|
||||
}
|
||||
return resp.Body, ct, nil
|
||||
}
|
||||
|
||||
// download fetches the rendered artifact. The clip is already generated at this
|
||||
// point, so a transient failure here (HTTP/2 stream reset, CF hiccup) must not
|
||||
// fail the whole task — retry with backoff.
|
||||
func (c *Client) download(ctx context.Context, client tlsclient.HttpClient, token, url string) ([]byte, error) {
|
||||
var data []byte
|
||||
var err error
|
||||
for _, wait := range []time.Duration{0, 2 * time.Second, 5 * time.Second, 10 * time.Second} {
|
||||
if wait > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(wait):
|
||||
}
|
||||
}
|
||||
data, err = c.downloadOnce(ctx, client, token, url)
|
||||
if err == nil || !errors.Is(err, ErrTemporaryUpstream) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
|
||||
func (c *Client) downloadOnce(ctx context.Context, client tlsclient.HttpClient, token, url string) ([]byte, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"user-agent": {userAgent},
|
||||
"referer": {origin + "/"},
|
||||
"cookie": {"sso=" + token + "; sso-rw=" + token},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("%w: download %d", ErrTemporaryUpstream, resp.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, fmt.Errorf("%w: empty video download", ErrTemporaryUpstream)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// mapStatus maps an HTTP status to the shared provider error sentinels.
|
||||
func mapStatus(path string, status int, raw []byte) error {
|
||||
switch {
|
||||
case status == 200:
|
||||
return nil
|
||||
case status == 403 && isBotChallenge(string(raw)):
|
||||
// grok bot-detection or a Cloudflare challenge page ("Just a moment…"),
|
||||
// NOT a dead token — transient, so a good account isn't killed by an
|
||||
// IP/anti-bot hiccup. A statsig anti-bot rejection means the recipe likely
|
||||
// went stale on a reship; kick the headless refresher to re-capture.
|
||||
TriggerStatsigRefresh()
|
||||
return fmt.Errorf("%w: %s 403 %s", ErrTemporaryUpstream, path, clip(raw, 160))
|
||||
case status == 401 || status == 403:
|
||||
return fmt.Errorf("%w: %s %d %s", ErrAuth, path, status, clip(raw, 160))
|
||||
case status == 429:
|
||||
// 429 is grok RATE-LIMITING ("Too many requests") — a transient error that
|
||||
// must NOT kill the account. Only a body that names a credit/usage-pool
|
||||
// exhaustion is a real quota wall.
|
||||
if isCreditError(string(raw)) {
|
||||
return fmt.Errorf("%w: %s 429 %s", ErrQuotaExhausted, path, clip(raw, 160))
|
||||
}
|
||||
return fmt.Errorf("%w: %s 429 %s", ErrTemporaryUpstream, path, clip(raw, 160))
|
||||
case status >= 500:
|
||||
return fmt.Errorf("%w: %s %d %s", ErrTemporaryUpstream, path, status, clip(raw, 160))
|
||||
default:
|
||||
if isCreditError(string(raw)) {
|
||||
return fmt.Errorf("%w: %s", ErrQuotaExhausted, clip(raw, 160))
|
||||
}
|
||||
return fmt.Errorf("grok: %s %d %s", path, status, clip(raw, 160))
|
||||
}
|
||||
}
|
||||
|
||||
// isBotChallenge reports whether a 403 body is an anti-bot interstitial rather
|
||||
// than a real auth rejection: grok's own "anti-bot" marker or a Cloudflare
|
||||
// challenge page ("Just a moment…" / cf-chl / challenge-platform).
|
||||
func isBotChallenge(s string) bool {
|
||||
s = strings.ToLower(s)
|
||||
return strings.Contains(s, "anti-bot") ||
|
||||
strings.Contains(s, "just a moment") ||
|
||||
strings.Contains(s, "cf-chl") ||
|
||||
strings.Contains(s, "challenge-platform") ||
|
||||
strings.Contains(s, "cf_chl")
|
||||
}
|
||||
|
||||
func isCreditError(s string) bool {
|
||||
s = strings.ToLower(s)
|
||||
return strings.Contains(s, "usagepoolexhausted") || strings.Contains(s, "credit") || strings.Contains(s, "insufficient") || strings.Contains(s, "quota")
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package grok
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
http "github.com/bogdanfinn/fhttp"
|
||||
)
|
||||
|
||||
var innerChunkRe = regexp.MustCompile(`static/chunks/[a-zA-Z0-9_.~\-/]+\.js`)
|
||||
|
||||
func hnew(ctx context.Context, url, token string) (*http.Request, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"},
|
||||
"accept-language": {"en-US,en;q=0.9"},
|
||||
"user-agent": {userAgent},
|
||||
"cookie": {"sso=" + token + "; sso-rw=" + token},
|
||||
http.HeaderOrderKey: {"accept", "accept-language", "user-agent", "cookie"},
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func TestGrokCacheChunks(t *testing.T) {
|
||||
token := os.Getenv("DIAG_SSO")
|
||||
if token == "" {
|
||||
t.Skip("no DIAG_SSO")
|
||||
}
|
||||
dir := os.Getenv("DIAG_CACHE")
|
||||
if dir == "" {
|
||||
dir = `f:\ai-gateway\vivid-ai\backend\_chunks`
|
||||
}
|
||||
os.MkdirAll(dir, 0o755)
|
||||
logf, _ := os.Create(filepath.Join(dir, "_log.txt"))
|
||||
defer logf.Close()
|
||||
lg := func(f string, a ...any) {
|
||||
fmt.Fprintf(logf, f+"\n", a...)
|
||||
logf.Sync()
|
||||
}
|
||||
|
||||
c := NewClient(os.Getenv("DIAG_PROXY"))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3000*time.Second)
|
||||
defer cancel()
|
||||
client, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hreq, _ := hnew(ctx, apiBase+"/", token)
|
||||
hresp, err := client.Do(hreq)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := io.ReadAll(hresp.Body)
|
||||
hresp.Body.Close()
|
||||
html := string(body)
|
||||
os.WriteFile(filepath.Join(dir, "_home.html"), body, 0o644)
|
||||
|
||||
seen := map[string]bool{}
|
||||
queue := dedupe(chunkPathRe.FindAllString(html, -1))
|
||||
lg("home chunk refs: %d", len(queue))
|
||||
|
||||
saved := 0
|
||||
for len(queue) > 0 && saved < 3000 {
|
||||
p := queue[0]
|
||||
queue = queue[1:]
|
||||
norm := p
|
||||
if !strings.HasPrefix(norm, "/_next/") {
|
||||
norm = "/_next/" + strings.TrimPrefix(norm, "/")
|
||||
}
|
||||
if seen[norm] {
|
||||
continue
|
||||
}
|
||||
seen[norm] = true
|
||||
src, err := fetchChunk(ctx, client, norm)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
saved++
|
||||
base := norm[strings.LastIndex(norm, "/")+1:]
|
||||
fn := fmt.Sprintf("%x_%s", sha1.Sum([]byte(norm)), base)
|
||||
if len(fn) > 120 {
|
||||
fn = fn[:120]
|
||||
}
|
||||
os.WriteFile(filepath.Join(dir, fn), []byte(src), 0o644)
|
||||
for _, m := range innerChunkRe.FindAllString(src, -1) {
|
||||
nn := "/_next/" + m
|
||||
if !seen[nn] {
|
||||
queue = append(queue, nn)
|
||||
}
|
||||
}
|
||||
if saved%50 == 0 {
|
||||
lg("saved=%d queue=%d", saved, len(queue))
|
||||
}
|
||||
}
|
||||
lg("CRAWL DONE saved=%d queue_left=%d", saved, len(queue))
|
||||
fmt.Printf("CRAWL DONE saved=%d\n", saved)
|
||||
}
|
||||
@@ -1,452 +0,0 @@
|
||||
// Package imagine implements the Imagine.art (vyro.ai) provider client. The
|
||||
// durable credential is a JSON blob {"token","refreshToken"}: `token` is a ~6h
|
||||
// access JWT used as Authorization: Bearer for the API, and `refreshToken` is a
|
||||
// ~7d JWT that mints a fresh pair via /apis/v1/auth/other/refresh/web when the
|
||||
// access token expires. Both rotate on refresh, so the new pair MUST be saved.
|
||||
// tls-client gives a Chrome JA3/JA4 so vyro's edge doesn't flag the requests.
|
||||
package imagine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
http "github.com/bogdanfinn/fhttp"
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
"github.com/bogdanfinn/tls-client/profiles"
|
||||
)
|
||||
|
||||
const (
|
||||
apiBase = "https://imagine.vyro.ai"
|
||||
teamsBase = "https://teams-imagine.vyro.ai"
|
||||
authBase = "https://auth.vyro.ai"
|
||||
webOrigin = "https://www.imagine.art"
|
||||
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAuth = errors.New("imagine auth failed")
|
||||
ErrQuotaExhausted = errors.New("imagine quota exhausted")
|
||||
ErrTemporaryUpstream = errors.New("imagine upstream temporary error")
|
||||
)
|
||||
|
||||
// refreshLeadSeconds renews the access token this many seconds BEFORE it expires
|
||||
// (proactive, not lazy at expiry) — the maintenance sweep keeps tokens fresh so a
|
||||
// dormant account's rotating refreshToken never lapses.
|
||||
const refreshLeadSeconds = 600 // 10 minutes
|
||||
|
||||
type Client struct {
|
||||
proxy string
|
||||
// freshest credential per account (key: user id) + a per-account refresh lock,
|
||||
// so concurrent callers don't each spend the rotating refresh_token — the first
|
||||
// refreshes, the rest reuse the cached fresh credential.
|
||||
mu sync.Mutex
|
||||
creds map[string]string
|
||||
locks map[string]*sync.Mutex
|
||||
}
|
||||
|
||||
func NewClient(proxy string) *Client {
|
||||
return &Client{proxy: strings.TrimSpace(proxy), creds: map[string]string{}, locks: map[string]*sync.Mutex{}}
|
||||
}
|
||||
|
||||
func (c *Client) SetProxy(proxy string) {
|
||||
c.proxy = strings.TrimSpace(proxy)
|
||||
}
|
||||
|
||||
func (c *Client) userLock(userID string) *sync.Mutex {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
m, ok := c.locks[userID]
|
||||
if !ok {
|
||||
m = &sync.Mutex{}
|
||||
c.locks[userID] = m
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Credential helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type credential struct {
|
||||
Token string `json:"token"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
// Email is the real account email — supplied at import, used for display and
|
||||
// (pool,email) dedup. It is NOT in the JWT (which only carries userId), so it
|
||||
// must be carried across refreshes (the refresh response omits it).
|
||||
Email string `json:"email,omitempty"`
|
||||
// ParentID is a canvas node the account OWNS, used as the generation's
|
||||
// parent_id. Imagine rejects any parent the account doesn't own ("user does
|
||||
// not have access to parent asset") and silently orphans a parent-less
|
||||
// generation (charged but never produced) — so it's supplied at import and
|
||||
// carried across refreshes.
|
||||
ParentID string `json:"parentId,omitempty"`
|
||||
}
|
||||
|
||||
func parseCred(s string) (credential, bool) {
|
||||
var cr credential
|
||||
if json.Unmarshal([]byte(strings.TrimSpace(s)), &cr) != nil {
|
||||
return cr, false
|
||||
}
|
||||
if strings.TrimSpace(cr.Token) == "" || strings.TrimSpace(cr.RefreshToken) == "" {
|
||||
return cr, false
|
||||
}
|
||||
return cr, true
|
||||
}
|
||||
|
||||
func buildCred(token, refresh, email, parentID string) string {
|
||||
b, _ := json.Marshal(credential{
|
||||
Token: strings.TrimSpace(token),
|
||||
RefreshToken: strings.TrimSpace(refresh),
|
||||
Email: strings.TrimSpace(email),
|
||||
ParentID: strings.TrimSpace(parentID),
|
||||
})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ParentIDFromCred returns the canvas parent node id supplied at import.
|
||||
func ParentIDFromCred(cred string) string {
|
||||
cr, ok := parseCred(cred)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(cr.ParentID)
|
||||
}
|
||||
|
||||
func looksLikeJWT(s string) bool {
|
||||
return len(strings.Split(strings.TrimSpace(s), ".")) == 3
|
||||
}
|
||||
|
||||
// IsImagineToken reports whether a pasted credential is an Imagine.art account:
|
||||
// a JSON object carrying a non-empty token + refreshToken that both look like
|
||||
// JWTs. Distinguishes it from adobe/leonardo/krea cookies.
|
||||
func IsImagineToken(value string) bool {
|
||||
cr, ok := parseCred(value)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return looksLikeJWT(cr.Token) && looksLikeJWT(cr.RefreshToken)
|
||||
}
|
||||
|
||||
// jwtClaims base64url-decodes the JWT payload (segment 1) into a claims map.
|
||||
func jwtClaims(token string) map[string]any {
|
||||
parts := strings.Split(strings.TrimSpace(token), ".")
|
||||
if len(parts) < 2 {
|
||||
return nil
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
// tolerate padded variants
|
||||
if raw, err = base64.URLEncoding.DecodeString(parts[1]); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
var m map[string]any
|
||||
if json.Unmarshal(raw, &m) != nil {
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func userIDFromToken(token string) string {
|
||||
claims := jwtClaims(token)
|
||||
if claims == nil {
|
||||
return ""
|
||||
}
|
||||
if v := strings.TrimSpace(stringValue(claims["userId"])); v != "" {
|
||||
return v
|
||||
}
|
||||
return strings.TrimSpace(stringValue(claims["sub"]))
|
||||
}
|
||||
|
||||
func tokenExp(token string) int64 {
|
||||
claims := jwtClaims(token)
|
||||
if claims == nil {
|
||||
return 0
|
||||
}
|
||||
return toInt64(claims["exp"])
|
||||
}
|
||||
|
||||
// EmailFromCred returns the real account email supplied at import; if absent it
|
||||
// falls back to the JWT userId so (pool,email) dedup still has a stable key.
|
||||
func EmailFromCred(cred string) string {
|
||||
cr, ok := parseCred(cred)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if e := strings.TrimSpace(cr.Email); e != "" {
|
||||
return e
|
||||
}
|
||||
return userIDFromToken(cr.Token)
|
||||
}
|
||||
|
||||
// UserIDFromCred returns the JWT userId (== org_id used for credit/generation).
|
||||
func UserIDFromCred(cred string) string {
|
||||
cr, ok := parseCred(cred)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return userIDFromToken(cr.Token)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Refresh
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// RefreshIfNeeded returns a credential whose access token is still valid: if the
|
||||
// stored one is (near) expired it spends the refreshToken to mint a fresh pair
|
||||
// and rebuilds the credential. Returns (cred, changed, err); changed=true means
|
||||
// the caller must persist the new credential (both tokens rotate). ErrAuth means
|
||||
// the refreshToken is dead → the account is gone.
|
||||
func (c *Client) RefreshIfNeeded(ctx context.Context, cred string) (string, bool, error) {
|
||||
cr, ok := parseCred(cred)
|
||||
if !ok {
|
||||
return cred, false, nil // unparseable — let the downstream call surface the error
|
||||
}
|
||||
userID := userIDFromToken(cr.Token)
|
||||
now := time.Now().Unix()
|
||||
|
||||
lk := c.userLock(userID)
|
||||
lk.Lock()
|
||||
defer lk.Unlock()
|
||||
|
||||
// A concurrent caller may already have refreshed this account.
|
||||
if userID != "" {
|
||||
c.mu.Lock()
|
||||
cached := c.creds[userID]
|
||||
c.mu.Unlock()
|
||||
if cc, ok := parseCred(cached); ok && tokenExp(cc.Token)-refreshLeadSeconds > now {
|
||||
return cached, cached != cred, nil
|
||||
}
|
||||
}
|
||||
if tokenExp(cr.Token)-refreshLeadSeconds > now {
|
||||
return cred, false, nil // still valid
|
||||
}
|
||||
|
||||
respBody, status, err := c.refreshPost(ctx, cr.RefreshToken)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("%w: refresh: %s", ErrTemporaryUpstream, err.Error())
|
||||
}
|
||||
if status == 400 || status == 401 || status == 403 {
|
||||
return "", false, ErrAuth
|
||||
}
|
||||
if status != 200 {
|
||||
return "", false, fmt.Errorf("%w: refresh http %d: %s", ErrTemporaryUpstream, status, clip(respBody, 120))
|
||||
}
|
||||
var rb struct {
|
||||
Result struct {
|
||||
SessionToken string `json:"sessionToken"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if json.Unmarshal(respBody, &rb) != nil || strings.TrimSpace(rb.Result.SessionToken) == "" {
|
||||
return "", false, ErrAuth
|
||||
}
|
||||
newRefresh := rb.Result.RefreshToken
|
||||
if strings.TrimSpace(newRefresh) == "" {
|
||||
newRefresh = cr.RefreshToken // some responses may omit it — keep the old one
|
||||
}
|
||||
newCred := buildCred(rb.Result.SessionToken, newRefresh, cr.Email, cr.ParentID)
|
||||
if userID != "" {
|
||||
c.mu.Lock()
|
||||
c.creds[userID] = newCred
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return newCred, true, nil
|
||||
}
|
||||
|
||||
func (c *Client) refreshPost(ctx context.Context, refreshToken string) ([]byte, int, error) {
|
||||
client, err := c.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, authBase+"/apis/v1/auth/other/refresh/web", nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"application/json, text/plain, */*"},
|
||||
"authorization": {"Bearer " + refreshToken},
|
||||
"origin": {webOrigin},
|
||||
"referer": {webOrigin + "/"},
|
||||
"user-agent": {userAgent},
|
||||
http.HeaderOrderKey: {
|
||||
"accept", "authorization", "origin", "referer", "user-agent",
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
return b, resp.StatusCode, err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Credits
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// FetchCreditsBalance reads the account's credit balance via /v1/credit.
|
||||
// remaining is the `total` field. 401/403 → ErrAuth (token dead). Returns the
|
||||
// normalized map shared by all providers.
|
||||
func (c *Client) FetchCreditsBalance(ctx context.Context, cred string) (map[string]any, error) {
|
||||
cr, ok := parseCred(cred)
|
||||
if !ok {
|
||||
return unknownBalance("bad credential"), nil
|
||||
}
|
||||
userID := userIDFromToken(cr.Token)
|
||||
body, status, err := c.apiGetP(ctx, cr.Token, apiBase+"/v1/credit?org_id="+userID, false)
|
||||
if err != nil {
|
||||
return unknownBalance("network: " + err.Error()), nil
|
||||
}
|
||||
if status == 401 || status == 403 {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
if status != 200 {
|
||||
return unknownBalance(fmt.Sprintf("http %d: %s", status, clip(body, 160))), nil
|
||||
}
|
||||
var cb struct {
|
||||
Status string `json:"status"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &cb); err != nil {
|
||||
return unknownBalance("non-json"), nil
|
||||
}
|
||||
return map[string]any{
|
||||
"remaining": cb.Total,
|
||||
"used": nil,
|
||||
"total": nil,
|
||||
"unknown": false,
|
||||
"error": nil,
|
||||
"email": emptyStringNil(EmailFromCred(cred)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (c *Client) apiGet(ctx context.Context, token, url string) ([]byte, int, error) {
|
||||
return c.apiGetP(ctx, token, url, true)
|
||||
}
|
||||
|
||||
// apiGetP picks the egress: polling runs direct (local IP).
|
||||
func (c *Client) apiGetP(ctx context.Context, token, url string, useProxy bool) ([]byte, int, error) {
|
||||
client, err := c.newTLSClientP(useProxy)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"application/json, text/plain, */*"},
|
||||
"authorization": {"Bearer " + token},
|
||||
"origin": {webOrigin},
|
||||
"referer": {webOrigin + "/"},
|
||||
"user-agent": {userAgent},
|
||||
http.HeaderOrderKey: {
|
||||
"accept", "authorization", "origin", "referer", "user-agent",
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
return b, resp.StatusCode, err
|
||||
}
|
||||
|
||||
func (c *Client) newTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(true) }
|
||||
|
||||
// newDirectTLSClient egresses on the local IP (never the proxy). Used for
|
||||
// polling and result download.
|
||||
func (c *Client) newDirectTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(false) }
|
||||
|
||||
func (c *Client) newTLSClientP(useProxy bool) (tlsclient.HttpClient, error) {
|
||||
options := []tlsclient.HttpClientOption{
|
||||
tlsclient.WithTimeoutSeconds(60),
|
||||
tlsclient.WithClientProfile(profiles.Chrome_120),
|
||||
}
|
||||
if useProxy && c.proxy != "" {
|
||||
options = append(options, tlsclient.WithProxyUrl(c.proxy))
|
||||
}
|
||||
return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small util
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func uuid4() string {
|
||||
var b [16]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||
}
|
||||
|
||||
func unknownBalance(reason string) map[string]any {
|
||||
return map[string]any{
|
||||
"remaining": nil, "used": nil, "total": nil, "unknown": true, "error": reason,
|
||||
}
|
||||
}
|
||||
|
||||
func stringValue(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
b, _ := json.Marshal(x)
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
}
|
||||
|
||||
func toInt64(v any) int64 {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return int64(x)
|
||||
case int64:
|
||||
return x
|
||||
case int:
|
||||
return int64(x)
|
||||
case json.Number:
|
||||
n, _ := x.Int64()
|
||||
return n
|
||||
case string:
|
||||
n, _ := strconv.ParseInt(strings.TrimSpace(x), 10, 64)
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func emptyStringNil(v string) any {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func clip(b []byte, n int) string {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if len(s) > n {
|
||||
return s[:n]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
package imagine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
http "github.com/bogdanfinn/fhttp"
|
||||
)
|
||||
|
||||
// GenerateImage runs the full Imagine.art pipeline: submit the txt2img job, poll
|
||||
// the org objects feed until the batch finishes, then download the produced
|
||||
// image. styleID picks the model (41001 = 1.5 / 2K, 41004 = 1.5pro / 4K).
|
||||
// HTTP 402 → ErrQuotaExhausted. These models are pure text2img (no refs).
|
||||
func (c *Client) GenerateImage(ctx context.Context, cred string, styleID int, resolution, aspectRatio, prompt string, downloadResult bool) ([]byte, map[string]any, error) {
|
||||
cr, ok := parseCred(cred)
|
||||
if !ok {
|
||||
return nil, nil, ErrAuth
|
||||
}
|
||||
userID := userIDFromToken(cr.Token)
|
||||
|
||||
metadata, _ := json.Marshal(map[string]any{
|
||||
"placeholderUuid": uuid4(),
|
||||
"promptWithoutManipulation": prompt,
|
||||
"modeId": 0,
|
||||
})
|
||||
// parent_id MUST be a canvas node this account owns — the server rejects a
|
||||
// foreign id ("user does not have access to parent asset") and silently
|
||||
// orphans a parent-less generation (charged but never produced). It's supplied
|
||||
// at import (credential.parentId).
|
||||
fields := map[string]string{
|
||||
"style_id": strconv.Itoa(styleID),
|
||||
"aspect_ratio": aspectRatio,
|
||||
"resolution": resolution,
|
||||
"variation": "txt2img",
|
||||
"prompt": prompt,
|
||||
"is_enhance": "0",
|
||||
"count": "1",
|
||||
"clientVersion": "1",
|
||||
"org_id": userID,
|
||||
"use_plugin": "false",
|
||||
"metadata": string(metadata),
|
||||
}
|
||||
if pid := strings.TrimSpace(cr.ParentID); pid != "" {
|
||||
fields["parent_id"] = pid
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
for k, v := range fields {
|
||||
_ = w.WriteField(k, v)
|
||||
}
|
||||
_ = w.Close()
|
||||
|
||||
body, status, err := c.apiPost(ctx, cr.Token, apiBase+"/v1/image/generations/upload", w.FormDataContentType(), buf.Bytes())
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%w: submit: %s", ErrTemporaryUpstream, err.Error())
|
||||
}
|
||||
if status == 401 || status == 403 {
|
||||
return nil, nil, ErrAuth
|
||||
}
|
||||
if status == 402 {
|
||||
return nil, nil, ErrQuotaExhausted
|
||||
}
|
||||
if status != 200 && status != 201 {
|
||||
return nil, nil, fmt.Errorf("%w: submit http %d: %s", ErrTemporaryUpstream, status, clip(body, 200))
|
||||
}
|
||||
var jobs []struct {
|
||||
BatchID string `json:"batchId"`
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &jobs); err != nil || len(jobs) == 0 || jobs[0].BatchID == "" {
|
||||
return nil, nil, fmt.Errorf("%w: no batch id: %s", ErrTemporaryUpstream, clip(body, 200))
|
||||
}
|
||||
batchID := jobs[0].BatchID
|
||||
|
||||
imageURL, err := c.pollImage(ctx, cr.Token, userID, batchID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
meta := map[string]any{"batch_id": batchID, "image_url": imageURL, "org_id": userID}
|
||||
if !downloadResult {
|
||||
return nil, meta, nil
|
||||
}
|
||||
data, err := c.download(ctx, imageURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return data, meta, nil
|
||||
}
|
||||
|
||||
// pollImage polls the org objects feed until the entry for our batch finishes,
|
||||
// then extracts its asset URL (image_url is a JSON-encoded array string).
|
||||
func (c *Client) pollImage(ctx context.Context, token, userID, batchID string) (string, error) {
|
||||
ticker := time.NewTicker(3 * time.Second)
|
||||
defer ticker.Stop()
|
||||
// Poll for the full generation budget (caller's genCtx), leaving headroom for
|
||||
// the download, instead of a shorter hardcoded cap that killed slow jobs early.
|
||||
deadline := time.Now().Add(4 * time.Minute)
|
||||
if dl, ok := ctx.Deadline(); ok {
|
||||
deadline = dl.Add(-60 * time.Second)
|
||||
}
|
||||
|
||||
url := teamsBase + "/v1/org/" + userID + "/objects?batch=true&limit=50&service=image,chat-image"
|
||||
for {
|
||||
body, status, err := c.apiGetP(ctx, token, url, false)
|
||||
if err == nil && status == 200 {
|
||||
var resp struct {
|
||||
Data []struct {
|
||||
BatchID string `json:"batch_id"`
|
||||
Status string `json:"status"`
|
||||
Code int `json:"code"`
|
||||
// Current shape: the produced asset lives at url.generation[0].
|
||||
URL struct {
|
||||
Generation []string `json:"generation"`
|
||||
} `json:"url"`
|
||||
ImageURL string `json:"image_url"` // legacy fallback
|
||||
} `json:"data"`
|
||||
}
|
||||
if json.Unmarshal(body, &resp) == nil {
|
||||
for _, o := range resp.Data {
|
||||
if o.BatchID != batchID {
|
||||
continue
|
||||
}
|
||||
st := strings.ToLower(strings.TrimSpace(o.Status))
|
||||
switch {
|
||||
case st == "finished" || o.Code == 2:
|
||||
if u := firstNonEmpty(o.URL.Generation); u != "" {
|
||||
return u, nil
|
||||
}
|
||||
if u := firstImageURL(o.ImageURL); u != "" {
|
||||
return u, nil
|
||||
}
|
||||
case st == "failed" || st == "error":
|
||||
return "", fmt.Errorf("%w: job %s", ErrTemporaryUpstream, st)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if status == 401 || status == 403 {
|
||||
return "", ErrAuth
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return "", fmt.Errorf("%w: generation timed out", ErrTemporaryUpstream)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// firstNonEmpty returns the first non-blank string in a slice.
|
||||
func firstNonEmpty(ss []string) string {
|
||||
for _, s := range ss {
|
||||
if strings.TrimSpace(s) != "" {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// firstImageURL parses the image_url field — a JSON-encoded array of URLs — and
|
||||
// returns the first one. Tolerates a bare string too.
|
||||
func firstImageURL(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
var urls []string
|
||||
if json.Unmarshal([]byte(raw), &urls) == nil {
|
||||
for _, u := range urls {
|
||||
if strings.TrimSpace(u) != "" {
|
||||
return strings.TrimSpace(u)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(raw, "http") {
|
||||
return raw
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *Client) download(ctx context.Context, url string) ([]byte, error) {
|
||||
client, err := c.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"image/avif,image/webp,image/png,image/*,*/*;q=0.8"},
|
||||
"user-agent": {userAgent},
|
||||
"referer": {webOrigin + "/"},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("%w: image download http %d", ErrTemporaryUpstream, resp.StatusCode)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// apiPost issues a POST with a raw body + content-type, carrying the bearer token.
|
||||
func (c *Client) apiPost(ctx context.Context, token, url, contentType string, body []byte) ([]byte, int, error) {
|
||||
client, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"application/json, text/plain, */*"},
|
||||
"authorization": {"Bearer " + token},
|
||||
"content-type": {contentType},
|
||||
"origin": {webOrigin},
|
||||
"referer": {webOrigin + "/"},
|
||||
"user-agent": {userAgent},
|
||||
http.HeaderOrderKey: {
|
||||
"accept", "authorization", "content-type", "origin", "referer", "user-agent",
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
return b, resp.StatusCode, err
|
||||
}
|
||||
@@ -1,499 +0,0 @@
|
||||
// Package krea implements the Krea.ai (krea.ai) provider client. The durable
|
||||
// credential is the browser cookie (Supabase "sb-superb-auth-token"); Krea's own
|
||||
// Next.js backend reads it directly, so quota and generation just forward the
|
||||
// cookie — there's no separate token-exchange step. tls-client gives a Chrome
|
||||
// JA3/JA4 so Krea's Cloudflare edge doesn't flag the requests.
|
||||
package krea
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
http "github.com/bogdanfinn/fhttp"
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
"github.com/bogdanfinn/tls-client/profiles"
|
||||
)
|
||||
|
||||
// kreaAnonKey is Krea's public Supabase anon key (fixed, embedded in their
|
||||
// frontend) — required as the apikey/bearer when refreshing a session token.
|
||||
const kreaAnonKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzc1Mjc4ODU3LCJleHAiOjE5MzI5NTg4NTd9.NUiqEOd__QsCCMjo3D1zrCAda5dLV2F5p6Kf584sZKc"
|
||||
|
||||
const (
|
||||
apiBase = "https://www.krea.ai"
|
||||
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAuth = errors.New("krea auth failed")
|
||||
ErrQuotaExhausted = errors.New("krea quota exhausted")
|
||||
ErrTemporaryUpstream = errors.New("krea upstream temporary error")
|
||||
)
|
||||
|
||||
// refreshLeadSeconds renews the access token this many seconds BEFORE it expires
|
||||
// (not lazily at expiry). The maintenance sweep refreshes proactively, so a
|
||||
// dormant account's token never lapses — once the rotating refresh_token is gone
|
||||
// (expired/consumed) the account can't recover, so we keep it perpetually fresh.
|
||||
const refreshLeadSeconds = 600 // 10 minutes
|
||||
|
||||
type Client struct {
|
||||
proxy string
|
||||
// freshest cookie per account (key: user id) + a per-account refresh lock, so
|
||||
// concurrent callers don't each spend the single-use (rotating) refresh_token —
|
||||
// the first refreshes, the rest reuse the cached fresh cookie.
|
||||
mu sync.Mutex
|
||||
cookies map[string]string
|
||||
locks map[string]*sync.Mutex
|
||||
// actAt = last /app activation time per account (key: user id); actLocks gives
|
||||
// a per-account lock so concurrent generations wait for the first to finish the
|
||||
// (once-per-daily-reset) activation instead of each loading /app.
|
||||
actAt map[string]int64
|
||||
actLocks map[string]*sync.Mutex
|
||||
}
|
||||
|
||||
func NewClient(proxy string) *Client {
|
||||
return &Client{
|
||||
proxy: strings.TrimSpace(proxy),
|
||||
cookies: map[string]string{},
|
||||
locks: map[string]*sync.Mutex{},
|
||||
actAt: map[string]int64{},
|
||||
actLocks: map[string]*sync.Mutex{},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) userLock(userID string) *sync.Mutex {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
m, ok := c.locks[userID]
|
||||
if !ok {
|
||||
m = &sync.Mutex{}
|
||||
c.locks[userID] = m
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// RefreshIfNeeded returns a cookie whose access_token is still valid: if the
|
||||
// stored one is (near) expired it spends the refresh_token to mint a new session
|
||||
// and rebuilds the cookie. Returns (cookie, changed, err); changed=true means the
|
||||
// caller must persist the new cookie (the refresh_token rotated). ErrAuth means
|
||||
// the refresh_token is dead → the account is gone.
|
||||
func (c *Client) RefreshIfNeeded(ctx context.Context, cookie string) (string, bool, error) {
|
||||
authVal := authCookieValue(cookie)
|
||||
sess, ok := decodeSession(authVal)
|
||||
if !ok {
|
||||
return cookie, false, nil // unparseable — let the downstream call surface the error
|
||||
}
|
||||
userID := nestedStr(sess, "user", "id")
|
||||
now := time.Now().Unix()
|
||||
|
||||
lk := c.userLock(userID)
|
||||
lk.Lock()
|
||||
defer lk.Unlock()
|
||||
|
||||
// A concurrent caller may already have refreshed this account.
|
||||
if userID != "" {
|
||||
c.mu.Lock()
|
||||
cached := c.cookies[userID]
|
||||
c.mu.Unlock()
|
||||
if cs, ok := decodeSession(authCookieValue(cached)); ok && toInt64(cs["expires_at"])-refreshLeadSeconds > now {
|
||||
return cached, cached != cookie, nil
|
||||
}
|
||||
}
|
||||
if toInt64(sess["expires_at"])-refreshLeadSeconds > now {
|
||||
return cookie, false, nil // still valid
|
||||
}
|
||||
refreshTok := strings.TrimSpace(stringValue(sess["refresh_token"]))
|
||||
if refreshTok == "" {
|
||||
return "", false, ErrAuth
|
||||
}
|
||||
|
||||
respBody, status, err := c.refreshPost(ctx, refreshTok)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("%w: refresh: %s", ErrTemporaryUpstream, err.Error())
|
||||
}
|
||||
if status == 400 || status == 401 || status == 403 {
|
||||
return "", false, ErrAuth
|
||||
}
|
||||
if status != 200 {
|
||||
return "", false, fmt.Errorf("%w: refresh http %d: %s", ErrTemporaryUpstream, status, clip(respBody, 120))
|
||||
}
|
||||
var ns map[string]any
|
||||
if json.Unmarshal(respBody, &ns) != nil || strings.TrimSpace(stringValue(ns["access_token"])) == "" {
|
||||
return "", false, ErrAuth
|
||||
}
|
||||
newCookie := replaceAuthCookie(cookie, "base64-"+base64.StdEncoding.EncodeToString(respBody))
|
||||
if userID != "" {
|
||||
c.mu.Lock()
|
||||
c.cookies[userID] = newCookie
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return newCookie, true, nil
|
||||
}
|
||||
|
||||
func (c *Client) refreshPost(ctx context.Context, refreshToken string) ([]byte, int, error) {
|
||||
client, err := c.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"refresh_token": refreshToken})
|
||||
req, err := http.NewRequest(http.MethodPost, apiBase+"/auth/v1/token?grant_type=refresh_token", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"*/*"},
|
||||
"content-type": {"application/json;charset=UTF-8"},
|
||||
"apikey": {kreaAnonKey},
|
||||
"authorization": {"Bearer " + kreaAnonKey},
|
||||
"x-client-info": {"supabase-ssr/0.6.1 createBrowserClient"},
|
||||
"x-supabase-api-version": {"2024-01-01"},
|
||||
"origin": {apiBase},
|
||||
"referer": {apiBase + "/"},
|
||||
"user-agent": {userAgent},
|
||||
http.HeaderOrderKey: {
|
||||
"accept", "content-type", "apikey", "authorization", "x-client-info",
|
||||
"x-supabase-api-version", "origin", "referer", "user-agent",
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
return b, resp.StatusCode, err
|
||||
}
|
||||
|
||||
// chunkSize is supabase-ssr's per-cookie chunk limit; larger sessions (e.g.
|
||||
// Google-OAuth accounts) are split into sb-superb-auth-token.0/.1/...
|
||||
const chunkSize = 3600
|
||||
|
||||
func cookieVal(cookie, name string) string {
|
||||
for _, p := range strings.Split(cookie, ";") {
|
||||
p = strings.TrimSpace(p)
|
||||
if v, ok := strings.CutPrefix(p, name+"="); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// authCookieValue returns the full auth value, transparently reassembling a
|
||||
// chunked cookie (sb-superb-auth-token.0 + .1 + ...) or returning the single one.
|
||||
func authCookieValue(cookie string) string {
|
||||
if v := cookieVal(cookie, "sb-superb-auth-token"); v != "" {
|
||||
return v
|
||||
}
|
||||
var b strings.Builder
|
||||
for i := 0; ; i++ {
|
||||
v := cookieVal(cookie, fmt.Sprintf("sb-superb-auth-token.%d", i))
|
||||
if v == "" {
|
||||
break
|
||||
}
|
||||
b.WriteString(v)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// replaceAuthCookie drops every sb-superb-auth-token[.N] cookie and re-adds the
|
||||
// new value (chunked the same way supabase-ssr would if it's large), preserving
|
||||
// all other cookies (krea-workspace-id, etc.).
|
||||
func replaceAuthCookie(cookie, newValue string) string {
|
||||
var out []string
|
||||
for _, p := range strings.Split(cookie, ";") {
|
||||
t := strings.TrimSpace(p)
|
||||
if t == "" || strings.HasPrefix(t, "sb-superb-auth-token=") || strings.HasPrefix(t, "sb-superb-auth-token.") {
|
||||
continue
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
if len(newValue) <= chunkSize {
|
||||
out = append(out, "sb-superb-auth-token="+newValue)
|
||||
} else {
|
||||
for i, off := 0, 0; off < len(newValue); i++ {
|
||||
end := off + chunkSize
|
||||
if end > len(newValue) {
|
||||
end = len(newValue)
|
||||
}
|
||||
out = append(out, fmt.Sprintf("sb-superb-auth-token.%d=%s", i, newValue[off:end]))
|
||||
off = end
|
||||
}
|
||||
}
|
||||
return strings.Join(out, "; ")
|
||||
}
|
||||
|
||||
// decodeSession base64-decodes the auth cookie value into the session JSON map.
|
||||
func decodeSession(authValue string) (map[string]any, bool) {
|
||||
v := strings.TrimPrefix(strings.TrimSpace(authValue), "base64-")
|
||||
if v == "" {
|
||||
return nil, false
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(v)
|
||||
if err != nil {
|
||||
raw, err = base64.RawURLEncoding.DecodeString(v)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
var m map[string]any
|
||||
if json.Unmarshal(raw, &m) != nil {
|
||||
return nil, false
|
||||
}
|
||||
return m, true
|
||||
}
|
||||
|
||||
func nestedStr(m map[string]any, k1, k2 string) string {
|
||||
if sub, ok := m[k1].(map[string]any); ok {
|
||||
return strings.TrimSpace(stringValue(sub[k2]))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func toInt64(v any) int64 {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return int64(x)
|
||||
case int64:
|
||||
return x
|
||||
case int:
|
||||
return int64(x)
|
||||
case json.Number:
|
||||
n, _ := x.Int64()
|
||||
return n
|
||||
case string:
|
||||
n, _ := strconv.ParseInt(strings.TrimSpace(x), 10, 64)
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) SetProxy(proxy string) {
|
||||
c.proxy = strings.TrimSpace(proxy)
|
||||
}
|
||||
|
||||
// IsKreaCookie reports whether a pasted credential is a Krea cookie: it carries
|
||||
// the Supabase auth cookie. Distinguishes it from adobe/leonardo cookies.
|
||||
func IsKreaCookie(value string) bool {
|
||||
return strings.Contains(value, "sb-superb-auth-token")
|
||||
}
|
||||
|
||||
// EmailFromCookie decodes the account email straight out of the cookie's embedded
|
||||
// Supabase session (no network), handling chunked cookies too.
|
||||
func EmailFromCookie(cookie string) string {
|
||||
sess, ok := decodeSession(authCookieValue(cookie))
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return nestedStr(sess, "user", "email")
|
||||
}
|
||||
|
||||
// FetchCreditsBalance reads the account's free-credit balance via /api/billing-data.
|
||||
// remaining is the integer floor of balance.free (per spec: 17.94 → 17). 401 →
|
||||
// ErrAuth (cookie dead). Returns the normalized map shared by all providers.
|
||||
func (c *Client) FetchCreditsBalance(ctx context.Context, cookie string) (map[string]any, error) {
|
||||
// Detach from the request ctx so a page refresh can't cancel the probe
|
||||
// mid-flight (which left accounts stuck at "—").
|
||||
probeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
||||
defer cancel()
|
||||
// NOTE: no /app here — the heavy SSR activation is done separately (on recovery
|
||||
// and at generation via Activate). This probe just reads the current balance.
|
||||
body, status, err := c.apiGetP(probeCtx, cookie, "/api/billing-data", false)
|
||||
if err != nil {
|
||||
return unknownBalance("network: " + err.Error()), nil
|
||||
}
|
||||
if status == 401 || status == 403 {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
if status != 200 {
|
||||
return unknownBalance(fmt.Sprintf("http %d: %s", status, clip(body, 160))), nil
|
||||
}
|
||||
// 余额在 balance.free(真实剩余,小数,随用量递减)。krea 的这个字段一直都在,
|
||||
// 只是排在很长的 entitlements 之后 —— 只读它,不用套餐配额兜底。
|
||||
var bd struct {
|
||||
Balance struct {
|
||||
Free float64 `json:"free"`
|
||||
Total float64 `json:"total"`
|
||||
} `json:"balance"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &bd); err != nil {
|
||||
return unknownBalance("non-json"), nil
|
||||
}
|
||||
remaining := int(bd.Balance.Free) // floor
|
||||
return map[string]any{
|
||||
"remaining": remaining,
|
||||
"used": nil,
|
||||
"total": int(bd.Balance.Total),
|
||||
"unknown": false,
|
||||
"error": nil,
|
||||
"email": emptyStringNil(EmailFromCookie(cookie)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Activate loads the authenticated SSR app page (/app), which is what makes krea
|
||||
// grant the account's DAILY free balance — a cold API-only call (billing-data /
|
||||
// generate) otherwise sees balance.free=0 and 402s. Done at most ONCE per account
|
||||
// per daily reset, under a per-account lock: the first caller loads /app while
|
||||
// concurrent callers wait, then everyone proceeds (no redundant /app). Called
|
||||
// before each generation and by the daily activation sweep. Best-effort.
|
||||
func (c *Client) Activate(ctx context.Context, cookie string) {
|
||||
key := accountKey(cookie)
|
||||
lastReset := (time.Now().Unix() / 86400) * 86400
|
||||
c.mu.Lock()
|
||||
doneToday := key != "" && c.actAt[key] >= lastReset
|
||||
c.mu.Unlock()
|
||||
if doneToday {
|
||||
return
|
||||
}
|
||||
lk := c.actLock(key)
|
||||
lk.Lock()
|
||||
defer lk.Unlock()
|
||||
// Re-check after acquiring the lock — another caller may have just activated.
|
||||
c.mu.Lock()
|
||||
doneToday = key != "" && c.actAt[key] >= lastReset
|
||||
c.mu.Unlock()
|
||||
if doneToday {
|
||||
return
|
||||
}
|
||||
actCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 60*time.Second)
|
||||
defer cancel()
|
||||
_, _, _ = c.apiGetP(actCtx, cookie, "/app", false)
|
||||
c.mu.Lock()
|
||||
c.actAt[key] = time.Now().Unix()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Client) actLock(key string) *sync.Mutex {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
m, ok := c.actLocks[key]
|
||||
if !ok {
|
||||
m = &sync.Mutex{}
|
||||
c.actLocks[key] = m
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// accountKey is the stable per-account id (the Supabase user id from the session
|
||||
// cookie) used to key activation state — survives cookie rotation.
|
||||
func accountKey(cookie string) string {
|
||||
if sess, ok := decodeSession(authCookieValue(cookie)); ok {
|
||||
return nestedStr(sess, "user", "id")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// apiGet issues a GET to a krea.ai API path carrying the account cookie.
|
||||
func (c *Client) apiGet(ctx context.Context, cookie, path string) ([]byte, int, error) {
|
||||
return c.apiGetP(ctx, cookie, path, true)
|
||||
}
|
||||
|
||||
// apiGetP picks the egress: polling / asset resolution run direct (local IP).
|
||||
func (c *Client) apiGetP(ctx context.Context, cookie, path string, useProxy bool) ([]byte, int, error) {
|
||||
client, err := c.newTLSClientP(useProxy)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, apiBase+path, nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"*/*"},
|
||||
"accept-language": {"en-US,en;q=0.9"},
|
||||
"cookie": {cookie},
|
||||
"referer": {apiBase + "/"},
|
||||
"user-agent": {userAgent},
|
||||
"sec-fetch-dest": {"empty"},
|
||||
"sec-fetch-mode": {"cors"},
|
||||
"sec-fetch-site": {"same-origin"},
|
||||
http.HeaderOrderKey: {
|
||||
"accept", "accept-language", "cookie", "referer", "user-agent",
|
||||
"sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site",
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
return b, resp.StatusCode, err
|
||||
}
|
||||
|
||||
func (c *Client) newTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(true) }
|
||||
|
||||
// newDirectTLSClient egresses on the local IP (never the proxy). Used for
|
||||
// reference-image upload, polling and result download.
|
||||
func (c *Client) newDirectTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(false) }
|
||||
|
||||
func (c *Client) newTLSClientP(useProxy bool) (tlsclient.HttpClient, error) {
|
||||
options := []tlsclient.HttpClientOption{
|
||||
tlsclient.WithTimeoutSeconds(60),
|
||||
tlsclient.WithClientProfile(profiles.Chrome_120),
|
||||
}
|
||||
if useProxy && c.proxy != "" {
|
||||
options = append(options, tlsclient.WithProxyUrl(c.proxy))
|
||||
}
|
||||
return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...)
|
||||
}
|
||||
|
||||
func unknownBalance(reason string) map[string]any {
|
||||
return map[string]any{
|
||||
"remaining": nil, "used": nil, "total": nil, "unknown": true, "error": reason,
|
||||
}
|
||||
}
|
||||
|
||||
func stringValue(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
b, _ := json.Marshal(x)
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
}
|
||||
|
||||
func intValue(v any) int {
|
||||
switch x := v.(type) {
|
||||
case int:
|
||||
return x
|
||||
case float64:
|
||||
return int(x)
|
||||
case json.Number:
|
||||
n, _ := x.Int64()
|
||||
return int(n)
|
||||
case string:
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(x))
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func emptyStringNil(v string) any {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func clip(b []byte, n int) string {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if len(s) > n {
|
||||
return s[:n]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
package krea
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
http "github.com/bogdanfinn/fhttp"
|
||||
)
|
||||
|
||||
const (
|
||||
genModel = "flux2-klein4b" // the single exposed Krea model
|
||||
genEndpoint = "/api/jobs/v2/new/fluxKlein4b"
|
||||
refStrength = 0.4
|
||||
)
|
||||
|
||||
// ensureProject returns a flux project id for the account: the first existing
|
||||
// project, or a freshly created one. Generation requires a project.
|
||||
func (c *Client) ensureProject(ctx context.Context, cookie string) (string, error) {
|
||||
body, status, err := c.apiGetP(ctx, cookie, "/api/flux-projects", false)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: list projects: %s", ErrTemporaryUpstream, err.Error())
|
||||
}
|
||||
if status == 401 || status == 403 {
|
||||
return "", ErrAuth
|
||||
}
|
||||
if status == 200 {
|
||||
var projs []struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if json.Unmarshal(body, &projs) == nil {
|
||||
for _, p := range projs {
|
||||
if strings.TrimSpace(p.ID) != "" {
|
||||
return p.ID, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// No project on this account (e.g. brand-new). Try to create one; if that
|
||||
// fails, fall back to generating WITHOUT a project (Krea assigns a default).
|
||||
cb, cs, cerr := c.apiPostJSON(ctx, cookie, "/api/flux-projects", map[string]any{"title": "vivid"})
|
||||
if cerr == nil && (cs == 200 || cs == 201) {
|
||||
var pr struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if json.Unmarshal(cb, &pr) == nil && pr.ID != "" {
|
||||
return pr.ID, nil
|
||||
}
|
||||
}
|
||||
return "", nil // generate without an explicit project
|
||||
}
|
||||
|
||||
// uploadImage uploads a reference image (i2i) and returns its app-uploads URL.
|
||||
func (c *Client) uploadImage(ctx context.Context, cookie string, img []byte) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
fw, err := w.CreateFormFile("file", "image.png")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := fw.Write(img); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = w.Close()
|
||||
body, status, err := c.apiPostP(ctx, cookie, "/api/upload?", w.FormDataContentType(), buf.Bytes(), false)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: upload: %s", ErrTemporaryUpstream, err.Error())
|
||||
}
|
||||
if status == 401 || status == 403 {
|
||||
return "", ErrAuth
|
||||
}
|
||||
if status != 200 {
|
||||
return "", fmt.Errorf("%w: upload http %d: %s", ErrTemporaryUpstream, status, clip(body, 160))
|
||||
}
|
||||
var ur struct {
|
||||
ImageURL string `json:"imageUrl"`
|
||||
}
|
||||
if json.Unmarshal(body, &ur) != nil || ur.ImageURL == "" {
|
||||
return "", fmt.Errorf("%w: no imageUrl", ErrTemporaryUpstream)
|
||||
}
|
||||
return ur.ImageURL, nil
|
||||
}
|
||||
|
||||
// GenerateImage runs the full Krea image pipeline: ensure a project, (for i2i)
|
||||
// upload reference images, submit the job, poll until done, then resolve and
|
||||
// download the produced image. 402 INSUFFICIENT_BALANCE → ErrQuotaExhausted.
|
||||
func (c *Client) GenerateImage(ctx context.Context, cookie, prompt string, width, height int, refImages [][]byte, downloadResult bool) ([]byte, map[string]any, error) {
|
||||
// Ensure the daily free balance is granted (load /app) before generating, so a
|
||||
// not-yet-activated account doesn't 402 INSUFFICIENT_BALANCE. Lock-guarded and
|
||||
// once-per-daily-reset — concurrent gens wait for the first activation, already
|
||||
// activated ones skip straight through.
|
||||
c.Activate(ctx, cookie)
|
||||
|
||||
projectID, err := c.ensureProject(ctx, cookie)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var styleImages []map[string]any
|
||||
for _, img := range refImages {
|
||||
if len(img) == 0 {
|
||||
continue
|
||||
}
|
||||
url, upErr := c.uploadImage(ctx, cookie, img)
|
||||
if upErr != nil {
|
||||
return nil, nil, upErr
|
||||
}
|
||||
styleImages = append(styleImages, map[string]any{"url": url, "strength": refStrength, "source": "upload"})
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"provider": genModel,
|
||||
"prompt": prompt,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"strength": 1,
|
||||
"steps": 28,
|
||||
"guidance_scale_flux": 3.5,
|
||||
"presetStyles": []any{},
|
||||
"batchSize": 2,
|
||||
"guidance": 3.5,
|
||||
}
|
||||
if projectID != "" {
|
||||
payload["project"] = projectID
|
||||
}
|
||||
if len(styleImages) > 0 {
|
||||
payload["styleImages"] = styleImages
|
||||
}
|
||||
payloadJSON, _ := json.Marshal(payload)
|
||||
|
||||
// Submit (multipart with a single "payload" field).
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
_ = w.WriteField("payload", string(payloadJSON))
|
||||
_ = w.Close()
|
||||
body, status, err := c.apiPost(ctx, cookie, genEndpoint, w.FormDataContentType(), buf.Bytes())
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%w: submit: %s", ErrTemporaryUpstream, err.Error())
|
||||
}
|
||||
if status == 401 || status == 403 {
|
||||
return nil, nil, ErrAuth
|
||||
}
|
||||
if status == 402 || strings.Contains(string(body), "INSUFFICIENT_BALANCE") {
|
||||
return nil, nil, ErrQuotaExhausted
|
||||
}
|
||||
if status != 200 && status != 201 {
|
||||
return nil, nil, fmt.Errorf("%w: submit http %d: %s", ErrTemporaryUpstream, status, clip(body, 200))
|
||||
}
|
||||
var jobs []struct {
|
||||
JobID string `json:"job_id"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &jobs); err != nil || len(jobs) == 0 || jobs[0].JobID == "" {
|
||||
return nil, nil, fmt.Errorf("%w: no job id: %s", ErrTemporaryUpstream, clip(body, 200))
|
||||
}
|
||||
// batchSize=2 returns two jobs; keep the SECOND image and discard the first
|
||||
// (per spec). Fall back to the first if only one came back.
|
||||
jobID := jobs[0].JobID
|
||||
if len(jobs) >= 2 && jobs[1].JobID != "" {
|
||||
jobID = jobs[1].JobID
|
||||
}
|
||||
|
||||
// Poll until terminal, then resolve the produced image.
|
||||
imageURL, err := c.pollImage(ctx, cookie, jobID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
meta := map[string]any{"job_id": jobID, "image_url": imageURL, "project": projectID}
|
||||
if !downloadResult {
|
||||
return nil, meta, nil
|
||||
}
|
||||
data, err := c.download(ctx, imageURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return data, meta, nil
|
||||
}
|
||||
|
||||
// pollImage polls job-status until the job leaves the queue, then matches the
|
||||
// produced asset by generation_job_id and returns its image URL.
|
||||
func (c *Client) pollImage(ctx context.Context, cookie, jobID string) (string, error) {
|
||||
ticker := time.NewTicker(3 * time.Second)
|
||||
defer ticker.Stop()
|
||||
// Poll for the full generation budget (caller's genCtx), leaving headroom for
|
||||
// the download, instead of a shorter hardcoded cap that killed slow jobs early.
|
||||
deadline := time.Now().Add(4 * time.Minute)
|
||||
if dl, ok := ctx.Deadline(); ok {
|
||||
deadline = dl.Add(-60 * time.Second)
|
||||
}
|
||||
|
||||
for {
|
||||
body, status, err := c.apiGetP(ctx, cookie, "/api/job-status?id="+jobID, false)
|
||||
if err == nil && status == 200 {
|
||||
var js struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if json.Unmarshal(body, &js) == nil {
|
||||
switch strings.ToLower(js.Status) {
|
||||
case "complete", "completed", "succeeded", "success", "done", "finished":
|
||||
if url, e := c.assetForJob(ctx, cookie, jobID); e == nil && url != "" {
|
||||
return url, nil
|
||||
}
|
||||
case "failed", "error", "cancelled", "canceled":
|
||||
return "", fmt.Errorf("%w: job %s", ErrTemporaryUpstream, js.Status)
|
||||
}
|
||||
}
|
||||
} else if status == 401 || status == 403 {
|
||||
return "", ErrAuth
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return "", fmt.Errorf("%w: generation timed out", ErrTemporaryUpstream)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assetForJob finds the generated asset produced by a job and returns its URL.
|
||||
func (c *Client) assetForJob(ctx context.Context, cookie, jobID string) (string, error) {
|
||||
body, status, err := c.apiGetP(ctx, cookie, "/api/assets?filter=generated&offset=0", false)
|
||||
if err != nil || status != 200 {
|
||||
return "", fmt.Errorf("assets http %d", status)
|
||||
}
|
||||
var assets []struct {
|
||||
ImageURL string `json:"image_url"`
|
||||
Metadata struct {
|
||||
GenerationJobID string `json:"generation_job_id"`
|
||||
} `json:"metadata"`
|
||||
}
|
||||
if json.Unmarshal(body, &assets) != nil {
|
||||
return "", fmt.Errorf("assets non-json")
|
||||
}
|
||||
for _, a := range assets {
|
||||
if a.Metadata.GenerationJobID == jobID && strings.TrimSpace(a.ImageURL) != "" {
|
||||
return a.ImageURL, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("asset not found yet")
|
||||
}
|
||||
|
||||
func (c *Client) download(ctx context.Context, url string) ([]byte, error) {
|
||||
client, err := c.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"image/avif,image/webp,image/png,image/*,*/*;q=0.8"},
|
||||
"user-agent": {userAgent},
|
||||
"referer": {apiBase + "/"},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("%w: image download http %d", ErrTemporaryUpstream, resp.StatusCode)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// apiPost issues a POST with a raw body + content-type, carrying the cookie.
|
||||
func (c *Client) apiPost(ctx context.Context, cookie, path, contentType string, body []byte) ([]byte, int, error) {
|
||||
return c.apiPostP(ctx, cookie, path, contentType, body, true)
|
||||
}
|
||||
|
||||
// apiPostP picks the egress: reference-image upload runs direct (local IP), the
|
||||
// generate submit uses the proxy.
|
||||
func (c *Client) apiPostP(ctx context.Context, cookie, path, contentType string, body []byte, useProxy bool) ([]byte, int, error) {
|
||||
client, err := c.newTLSClientP(useProxy)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, apiBase+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"*/*"},
|
||||
"accept-language": {"en-US,en;q=0.9"},
|
||||
"content-type": {contentType},
|
||||
"cookie": {cookie},
|
||||
"origin": {apiBase},
|
||||
"referer": {apiBase + "/"},
|
||||
"user-agent": {userAgent},
|
||||
"sec-fetch-dest": {"empty"},
|
||||
"sec-fetch-mode": {"cors"},
|
||||
"sec-fetch-site": {"same-origin"},
|
||||
http.HeaderOrderKey: {
|
||||
"accept", "accept-language", "content-type", "cookie", "origin",
|
||||
"referer", "user-agent", "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site",
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
return b, resp.StatusCode, err
|
||||
}
|
||||
|
||||
func (c *Client) apiPostJSON(ctx context.Context, cookie, path string, payload any) ([]byte, int, error) {
|
||||
// project bootstrap runs on the local IP; only the generate submit uses the proxy.
|
||||
b, _ := json.Marshal(payload)
|
||||
return c.apiPostP(ctx, cookie, path, "application/json", b, false)
|
||||
}
|
||||
@@ -1,732 +0,0 @@
|
||||
// Package leonardo implements the Leonardo.ai (app.leonardo.ai) provider client.
|
||||
// Unlike chatgpt/runway (whose JWT IS the stored credential), Leonardo's durable
|
||||
// credential is the browser COOKIE (better-auth session): the bearer access token
|
||||
// it mints lives only ~1h. So every call here takes the cookie and derives a
|
||||
// fresh JWT on the fly via /api/auth/get-session — there is no long-lived token to
|
||||
// store or a separate refresh profile to maintain. tls-client gives a Chrome
|
||||
// JA3/JA4 fingerprint so the requests aren't flagged.
|
||||
package leonardo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
http "github.com/bogdanfinn/fhttp"
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
"github.com/bogdanfinn/tls-client/profiles"
|
||||
)
|
||||
|
||||
const (
|
||||
appBase = "https://app.leonardo.ai"
|
||||
graphqlURL = "https://api.leonardo.ai/v1/graphql"
|
||||
schemaVersion = "1.255.2"
|
||||
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"
|
||||
// sec-ch-ua must agree with userAgent's major version — a mismatch is itself a
|
||||
// bot signal.
|
||||
secChUA = `"Not(A:Brand";v="99", "Google Chrome";v="133", "Chromium";v="133"`
|
||||
// get-session only answers with a token once better-auth's cookie cache has
|
||||
// been refreshed by a cross-origin-cookie call; called cold it returns 200
|
||||
// null, which looks exactly like a dead cookie. It also sits behind Vercel's
|
||||
// checkpoint, which 429s machine requests from some exit IPs — so a failed
|
||||
// attempt is retried, falling back to the proxy for a different exit IP.
|
||||
getSessionAttempts = 10
|
||||
getSessionRetryDelay = 2 * time.Second
|
||||
// A warmed 200 null usually means the session is gone server-side, but the
|
||||
// same answer also comes back when the checkpoint silently serves a session-
|
||||
// less page to an exit IP — so retry it a few times (later attempts through
|
||||
// the proxy) before calling the account dead. Fewer attempts than the 429
|
||||
// budget: each one costs two requests and a truly dead cookie never recovers.
|
||||
getSessionNullAttempts = 3
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAuth = errors.New("leonardo auth failed")
|
||||
ErrQuotaExhausted = errors.New("leonardo quota exhausted")
|
||||
ErrTemporaryUpstream = errors.New("leonardo upstream temporary error")
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
proxy string
|
||||
// sessions caches the short-lived access token per cookie so we don't hit
|
||||
// /api/auth/get-session on every call — Leonardo rate-limits that endpoint
|
||||
// (429) hard, so re-using the ~1h JWT is essential.
|
||||
mu sync.Mutex
|
||||
sessions map[string]*Session
|
||||
// rotated maps a stored cookie to the freshest value Leonardo handed back via
|
||||
// Set-Cookie (better-auth rotates its session_data cookie cache). The service
|
||||
// persists it; keeping it here means an unpersisted rotation still works for
|
||||
// the rest of the process's life.
|
||||
rotated map[string]string
|
||||
// refreshing serialises get-session per cookie. Two concurrent refreshes hand
|
||||
// Cognito the same refresh token twice and its reuse detection revokes the
|
||||
// whole session — the account then answers 401 forever.
|
||||
refreshing map[string]*sync.Mutex
|
||||
}
|
||||
|
||||
func NewClient(proxy string) *Client {
|
||||
return &Client{proxy: strings.TrimSpace(proxy), sessions: map[string]*Session{}, rotated: map[string]string{}, refreshing: map[string]*sync.Mutex{}}
|
||||
}
|
||||
|
||||
func (c *Client) SetProxy(proxy string) {
|
||||
c.proxy = strings.TrimSpace(proxy)
|
||||
}
|
||||
|
||||
// IsLeonardoCookie reports whether a pasted credential is a Leonardo cookie: it
|
||||
// carries the better-auth session cookie name. This is what disambiguates it from
|
||||
// an Adobe cookie at import time.
|
||||
func IsLeonardoCookie(value string) bool {
|
||||
return strings.Contains(value, "__Secure-better-auth.session_token") ||
|
||||
strings.Contains(value, "better-auth.session_data")
|
||||
}
|
||||
|
||||
// HasSessionData reports whether the cookie carries better-auth's session_data
|
||||
// cache. Leonardo authenticates get-session off THAT cookie: session_token alone
|
||||
// answers 200 null (no bearer), which looks exactly like a dead account — so a
|
||||
// cookie without it must be rejected at import instead of dying later.
|
||||
func HasSessionData(value string) bool {
|
||||
return strings.Contains(value, "better-auth.session_data")
|
||||
}
|
||||
|
||||
// RotatedCookie returns the freshest value for a stored cookie when Leonardo
|
||||
// rotated its session_data cache, so the caller can persist it.
|
||||
func (c *Client) RotatedCookie(cookie string) (string, bool) {
|
||||
key := strings.TrimSpace(cookie)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
fresh, ok := c.rotated[key]
|
||||
return fresh, ok && fresh != key
|
||||
}
|
||||
|
||||
// mergeCookies applies a response's Set-Cookie pairs onto a request cookie
|
||||
// string, keeping the original order and appending new names.
|
||||
func mergeCookies(cookie string, setCookies []string) string {
|
||||
if len(setCookies) == 0 {
|
||||
return cookie
|
||||
}
|
||||
updates := map[string]string{}
|
||||
order := []string{}
|
||||
for _, sc := range setCookies {
|
||||
pair := strings.TrimSpace(strings.Split(sc, ";")[0])
|
||||
name, value, ok := strings.Cut(pair, "=")
|
||||
name = strings.TrimSpace(name)
|
||||
if !ok || name == "" {
|
||||
continue
|
||||
}
|
||||
if _, seen := updates[name]; !seen {
|
||||
order = append(order, name)
|
||||
}
|
||||
updates[name] = value
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return cookie
|
||||
}
|
||||
var out []string
|
||||
used := map[string]bool{}
|
||||
for _, part := range strings.Split(cookie, ";") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
name, _, _ := strings.Cut(part, "=")
|
||||
name = strings.TrimSpace(name)
|
||||
if v, ok := updates[name]; ok {
|
||||
used[name] = true
|
||||
if v == "" { // a cleared cookie drops out
|
||||
continue
|
||||
}
|
||||
out = append(out, name+"="+v)
|
||||
continue
|
||||
}
|
||||
out = append(out, part)
|
||||
}
|
||||
for _, name := range order {
|
||||
if used[name] || updates[name] == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, name+"="+updates[name])
|
||||
}
|
||||
return strings.Join(out, "; ")
|
||||
}
|
||||
|
||||
// keepsSession reports whether a merged cookie still carries BOTH components
|
||||
// get-session needs: the session token and better-auth's session_data cache.
|
||||
// A merge that loses either one (a Set-Cookie clearing a cache chunk) must be
|
||||
// discarded — sending it would answer 200 null, i.e. look like a dead account.
|
||||
func keepsSession(cookie string) bool {
|
||||
return strings.Contains(cookie, "__Secure-better-auth.session_token") && HasSessionData(cookie)
|
||||
}
|
||||
|
||||
// Session is the result of /api/auth/get-session: the short-lived bearer plus the
|
||||
// ids the GraphQL API needs (cognitoSub for the quota query, userId for the feed
|
||||
// and the CDN image path) and the human-facing account fields.
|
||||
type Session struct {
|
||||
AccessToken string
|
||||
CognitoSub string
|
||||
UserID string
|
||||
Email string
|
||||
Name string
|
||||
ExpiresAt int64
|
||||
// Cookie is the cookie that produced this session, with any Set-Cookie
|
||||
// rotation applied — persist it so the account keeps authenticating.
|
||||
Cookie string
|
||||
}
|
||||
|
||||
// GetSession exchanges the cookie for a fresh access token + account ids. Only a
|
||||
// 401 or a 200 carrying no access token means the session is dead → ErrAuth;
|
||||
// everything else (notably the 403/429 人机校验 page) is a temporary error.
|
||||
func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error) {
|
||||
cookie = strings.TrimSpace(cookie)
|
||||
if cookie == "" {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
// Re-use a cached, still-valid access token (keep a 60s safety margin) instead
|
||||
// of hitting the heavily rate-limited get-session endpoint again.
|
||||
c.mu.Lock()
|
||||
if cs, ok := c.sessions[cookie]; ok && cs.ExpiresAt-60 > time.Now().Unix() {
|
||||
c.mu.Unlock()
|
||||
return cs, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
// Only one refresh per cookie at a time; the others wait and then re-use the
|
||||
// token it minted.
|
||||
gate := c.refreshGate(cookie)
|
||||
gate.Lock()
|
||||
defer gate.Unlock()
|
||||
c.mu.Lock()
|
||||
if cs, ok := c.sessions[cookie]; ok && cs.ExpiresAt-60 > time.Now().Unix() {
|
||||
c.mu.Unlock()
|
||||
return cs, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
// Use the freshest known value (an earlier response may have rotated the
|
||||
// better-auth cookie cache) rather than the possibly stale stored cookie.
|
||||
send := cookie
|
||||
c.mu.Lock()
|
||||
if fresh, ok := c.rotated[cookie]; ok && fresh != "" {
|
||||
send = fresh
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
var body []byte
|
||||
var status int
|
||||
var setCookies []string
|
||||
var err error
|
||||
var warmed bool
|
||||
// 401 from cross-origin-cookie means Leonardo已经作废了这个 session(不是人机校验),
|
||||
// 记下来好把日志写成"会话被吊销"而不是含糊的 get-session null。
|
||||
warmStatus := 0
|
||||
for attempt := 0; attempt < getSessionAttempts; attempt++ {
|
||||
if attempt > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(getSessionRetryDelay):
|
||||
}
|
||||
}
|
||||
// The local IP normally passes the checkpoint; only once it doesn't is the
|
||||
// proxy worth its latency (and its own share of 429s).
|
||||
useProxy := attempt > 0 && c.proxy != ""
|
||||
client, cerr := c.newTLSClientP(useProxy)
|
||||
if cerr != nil {
|
||||
err = cerr
|
||||
continue
|
||||
}
|
||||
// Warm up on the same connection: cross-origin-cookie hands back a fresh
|
||||
// better-auth cookie cache (and CF_Access_Token). Without it get-session
|
||||
// answers 200 null even for a perfectly healthy cookie.
|
||||
warmed = false
|
||||
warmCookies, wstatus, werr := c.warmSession(ctx, client, send)
|
||||
warmStatus = wstatus
|
||||
if werr == nil {
|
||||
warmed = true
|
||||
if merged := mergeCookies(send, warmCookies); merged != send && keepsSession(merged) {
|
||||
send = merged
|
||||
}
|
||||
}
|
||||
status, body, setCookies, err = c.fetchSession(ctx, client, send)
|
||||
if err == nil && warmed && status != 429 && status != 403 {
|
||||
if status == 200 && sessionAccessToken(body) == "" && attempt < getSessionNullAttempts-1 {
|
||||
continue // retry a null session on another exit IP before giving up
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, err.Error())
|
||||
}
|
||||
// Only a real app answer may rotate the stored cookie. The 403/429 人机校验 页
|
||||
// also sends Set-Cookie (often CLEARING better-auth cookies), and persisting
|
||||
// that would strip the session_data cache — after which get-session answers
|
||||
// 200 null and a perfectly healthy account looks dead.
|
||||
if status == 200 {
|
||||
if merged := mergeCookies(send, setCookies); keepsSession(merged) {
|
||||
send = merged
|
||||
}
|
||||
if send != cookie {
|
||||
c.mu.Lock()
|
||||
c.rotated[cookie] = send
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
if status == 401 {
|
||||
return nil, fmt.Errorf("%w: get-session http 401: %s", ErrAuth, clip(body, 160))
|
||||
}
|
||||
if status != 200 {
|
||||
// 403 / 429 here is the Vercel / Cloudflare 人机校验 页,不是 cookie 失效 —
|
||||
// 当成临时错误,否则健康的号会被误判死。
|
||||
return nil, fmt.Errorf("%w: get-session http %d: %s", ErrTemporaryUpstream, status, clip(body, 160))
|
||||
}
|
||||
var raw struct {
|
||||
Session struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
CognitoSub string `json:"cognitoSub"`
|
||||
UserID string `json:"userId"`
|
||||
HasuraUserID string `json:"hasuraUserId"`
|
||||
TokenExpiry int64 `json:"accessTokenExpiry"`
|
||||
} `json:"session"`
|
||||
User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
} `json:"user"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return nil, fmt.Errorf("%w: get-session non-json", ErrTemporaryUpstream)
|
||||
}
|
||||
if strings.TrimSpace(raw.Session.AccessToken) == "" {
|
||||
if warmStatus == 401 {
|
||||
// Leonardo 明确拒了这份 session_token:服务端已把会话吊销,重新导入 cookie 才能恢复。
|
||||
return nil, fmt.Errorf("%w: session revoked (cross-origin-cookie 401)", ErrAuth)
|
||||
}
|
||||
if !warmed {
|
||||
// A cold get-session (the cookie cache was never refreshed) answers null
|
||||
// for healthy accounts too — temporary, never a reason to kill the account.
|
||||
return nil, fmt.Errorf("%w: get-session 200 without accessToken (no warmup): %s", ErrTemporaryUpstream, clip(body, 160))
|
||||
}
|
||||
// No bearer despite 200 → the cookie no longer authenticates. Carry the body
|
||||
// so the log says WHICH shape it was (null session vs a session without a
|
||||
// token) instead of a bare "auth failed".
|
||||
return nil, fmt.Errorf("%w: get-session 200 without accessToken: %s", ErrAuth, clip(body, 160))
|
||||
}
|
||||
uid := raw.Session.UserID
|
||||
if uid == "" {
|
||||
uid = raw.Session.HasuraUserID
|
||||
}
|
||||
if uid == "" {
|
||||
uid = raw.User.ID
|
||||
}
|
||||
sess := &Session{
|
||||
AccessToken: raw.Session.AccessToken,
|
||||
CognitoSub: raw.Session.CognitoSub,
|
||||
UserID: uid,
|
||||
Email: strings.TrimSpace(raw.User.Email),
|
||||
Name: strings.TrimSpace(raw.User.Name),
|
||||
ExpiresAt: raw.Session.TokenExpiry,
|
||||
Cookie: send,
|
||||
}
|
||||
if sess.ExpiresAt > time.Now().Unix() {
|
||||
c.mu.Lock()
|
||||
c.sessions[cookie] = sess
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// refreshGate returns the per-cookie lock that serialises get-session refreshes.
|
||||
func (c *Client) refreshGate(cookie string) *sync.Mutex {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
gate, ok := c.refreshing[cookie]
|
||||
if !ok {
|
||||
gate = &sync.Mutex{}
|
||||
c.refreshing[cookie] = gate
|
||||
}
|
||||
return gate
|
||||
}
|
||||
|
||||
// warmSession calls cross-origin-cookie, whose response refreshes better-auth's
|
||||
// cookie cache and CF_Access_Token. It returns the Set-Cookie headers; a
|
||||
// checkpoint answer (403/429) is an error, since get-session would then be cold.
|
||||
func (c *Client) warmSession(ctx context.Context, client tlsclient.HttpClient, cookie string) ([]string, int, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, appBase+"/api/auth/cross-origin-cookie", nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = sessionHeader(cookie)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
if resp.StatusCode == 403 || resp.StatusCode == 429 {
|
||||
return nil, resp.StatusCode, fmt.Errorf("cross-origin-cookie http %d", resp.StatusCode)
|
||||
}
|
||||
return resp.Header["Set-Cookie"], resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// fetchSession performs one get-session call and returns its status, body and
|
||||
// Set-Cookie headers.
|
||||
func (c *Client) fetchSession(ctx context.Context, client tlsclient.HttpClient, cookie string) (int, []byte, []string, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, appBase+"/api/auth/get-session", nil)
|
||||
if err != nil {
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = sessionHeader(cookie)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, body, resp.Header["Set-Cookie"], nil
|
||||
}
|
||||
|
||||
// sessionAccessToken pulls the bearer out of a get-session body; "" means the
|
||||
// answer carried none (a null session, or a session without a token).
|
||||
func sessionAccessToken(body []byte) string {
|
||||
var raw struct {
|
||||
Session struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
} `json:"session"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(raw.Session.AccessToken)
|
||||
}
|
||||
|
||||
// sessionHeader is the auth endpoints' request shape, copied from a real
|
||||
// browser's call (HAR): a same-origin GET carries NO origin header and DOES
|
||||
// carry the ua client hints + priority — sending origin while omitting the
|
||||
// hints is exactly the shape Vercel's checkpoint 429s.
|
||||
func sessionHeader(cookie string) http.Header {
|
||||
return http.Header{
|
||||
"accept": {"*/*"},
|
||||
"accept-language": {"en-US,en;q=0.9"},
|
||||
"cache-control": {"no-cache"},
|
||||
"cookie": {cookie},
|
||||
"pragma": {"no-cache"},
|
||||
"priority": {"u=1, i"},
|
||||
"referer": {appBase + "/"},
|
||||
"sec-ch-ua": {secChUA},
|
||||
"sec-ch-ua-mobile": {"?0"},
|
||||
"sec-ch-ua-platform": {`"Windows"`},
|
||||
"sec-fetch-dest": {"empty"},
|
||||
"sec-fetch-mode": {"cors"},
|
||||
"sec-fetch-site": {"same-origin"},
|
||||
"user-agent": {userAgent},
|
||||
http.HeaderOrderKey: {
|
||||
"accept", "accept-language", "cache-control", "cookie", "pragma",
|
||||
"priority", "referer", "sec-ch-ua", "sec-ch-ua-mobile",
|
||||
"sec-ch-ua-platform", "sec-fetch-dest", "sec-fetch-mode",
|
||||
"sec-fetch-site", "user-agent",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// session returns the cookie's access token, optionally forcing a fresh mint
|
||||
// (dropping the cache) — used when the upstream rejected the current bearer.
|
||||
func (c *Client) session(ctx context.Context, cookie string, force bool) (*Session, error) {
|
||||
if force {
|
||||
c.mu.Lock()
|
||||
delete(c.sessions, strings.TrimSpace(cookie))
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return c.GetSession(ctx, cookie)
|
||||
}
|
||||
|
||||
// ProbeSession force-mints a session from the cookie, bypassing the cached
|
||||
// bearer. Callers use it to double-check an auth failure before killing an
|
||||
// account: a rejected bearer (rotation race / expired token) still yields a
|
||||
// working cookie here, only a genuinely dead cookie returns ErrAuth.
|
||||
func (c *Client) ProbeSession(ctx context.Context, cookie string) (*Session, error) {
|
||||
return c.session(ctx, cookie, true)
|
||||
}
|
||||
|
||||
// callGraphQL runs one GraphQL call for an account cookie. The bearer only lives
|
||||
// ~1h, so a rejected token (401/403 or a JWTExpired GraphQL error) is re-minted
|
||||
// from the cookie and the call retried once. Only a cookie that itself stops
|
||||
// authenticating yields ErrAuth — an upstream bearer rejection stays temporary so
|
||||
// the account is never killed for it.
|
||||
func (c *Client) callGraphQL(ctx context.Context, cookie string, payload []byte, useProxy bool, label string) ([]byte, error) {
|
||||
var lastStatus int
|
||||
var lastBody []byte
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
sess, err := c.session(ctx, cookie, attempt > 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, status, err := c.graphqlP(ctx, sess.AccessToken, payload, useProxy)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s: %s", ErrTemporaryUpstream, label, err.Error())
|
||||
}
|
||||
lastStatus, lastBody = status, body
|
||||
stale := status == 401 || status == 403
|
||||
var gqlErr error
|
||||
if !stale && status == 200 {
|
||||
gqlErr = graphqlError(body)
|
||||
stale = errors.Is(gqlErr, ErrAuth)
|
||||
}
|
||||
if stale {
|
||||
if attempt == 0 {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if status != 200 {
|
||||
return nil, fmt.Errorf("%w: %s http %d: %s", ErrTemporaryUpstream, label, status, clip(body, 160))
|
||||
}
|
||||
if gqlErr != nil {
|
||||
return nil, gqlErr
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s rejected a freshly minted bearer (http %d): %s",
|
||||
ErrTemporaryUpstream, label, lastStatus, clip(lastBody, 160))
|
||||
}
|
||||
|
||||
const qGetTokens = `query GetUserTokensFromSub($sub: String) {
|
||||
user_details(where: {cognitoId: {_eq: $sub}}) {
|
||||
id
|
||||
plan
|
||||
subscriptionTokens
|
||||
paidTokens
|
||||
rolloverTokens
|
||||
tokenRenewalDate
|
||||
__typename
|
||||
}
|
||||
}`
|
||||
|
||||
// FetchCreditsBalance derives a JWT from the cookie then reads the account's image
|
||||
// token balance. Returns a normalized map mirroring the other providers so the
|
||||
// TokenService quota plumbing is uniform. remaining = subscription+paid+rollover
|
||||
// (the spendable image tokens); available_until carries the daily renewal time so
|
||||
// the maintenance sweep can auto-recover a 限额 account.
|
||||
func (c *Client) FetchCreditsBalance(ctx context.Context, cookie string) (map[string]any, error) {
|
||||
sess, err := c.GetSession(ctx, cookie)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrAuth) {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
return unknownBalance(err.Error()), nil
|
||||
}
|
||||
if sess.CognitoSub == "" {
|
||||
return unknownBalance("no cognitoSub"), nil
|
||||
}
|
||||
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"operationName": "GetUserTokensFromSub",
|
||||
"variables": map[string]any{"sub": sess.CognitoSub},
|
||||
"query": qGetTokens,
|
||||
})
|
||||
body, err := c.callGraphQL(ctx, cookie, payload, false, "credits")
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrAuth) {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
return unknownBalance(err.Error()), nil
|
||||
}
|
||||
var result struct {
|
||||
Data struct {
|
||||
UserDetails []struct {
|
||||
Plan string `json:"plan"`
|
||||
SubscriptionTokens int `json:"subscriptionTokens"`
|
||||
PaidTokens int `json:"paidTokens"`
|
||||
RolloverTokens int `json:"rolloverTokens"`
|
||||
TokenRenewalDate string `json:"tokenRenewalDate"`
|
||||
} `json:"user_details"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return unknownBalance("non-json"), nil
|
||||
}
|
||||
if len(result.Data.UserDetails) == 0 {
|
||||
return unknownBalance("no user_details"), nil
|
||||
}
|
||||
ud := result.Data.UserDetails[0]
|
||||
remaining := ud.SubscriptionTokens + ud.PaidTokens + ud.RolloverTokens
|
||||
return map[string]any{
|
||||
"remaining": remaining,
|
||||
"used": nil,
|
||||
"total": nil,
|
||||
"unknown": false,
|
||||
"error": nil,
|
||||
"plan": ud.Plan,
|
||||
"available_until": strings.TrimSpace(ud.TokenRenewalDate),
|
||||
"email": emptyStringNil(sess.Email),
|
||||
"display_name": emptyStringNil(sess.Name),
|
||||
"user_id": emptyStringNil(sess.UserID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// graphqlP runs a GraphQL call; callers pick the egress: only the generate submit
|
||||
// uses the proxy; reference-image upload and polling run direct (local IP).
|
||||
func (c *Client) graphqlP(ctx context.Context, accessToken string, payload []byte, useProxy bool) ([]byte, int, error) {
|
||||
client, err := c.newTLSClientP(useProxy)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, graphqlURL, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"content-type": {"application/json"},
|
||||
"accept": {"*/*"},
|
||||
"accept-language": {"en-US,en;q=0.9"},
|
||||
"origin": {appBase},
|
||||
"priority": {"u=1, i"},
|
||||
"referer": {appBase + "/"},
|
||||
"sec-ch-ua": {secChUA},
|
||||
"sec-ch-ua-mobile": {"?0"},
|
||||
"sec-ch-ua-platform": {`"Windows"`},
|
||||
"user-agent": {userAgent},
|
||||
"authorization": {"Bearer " + accessToken},
|
||||
"x-leo-schema-version": {schemaVersion},
|
||||
"sec-fetch-dest": {"empty"},
|
||||
"sec-fetch-mode": {"cors"},
|
||||
"sec-fetch-site": {"same-site"},
|
||||
http.HeaderOrderKey: {
|
||||
"content-type", "accept", "accept-language", "origin", "priority",
|
||||
"referer", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform",
|
||||
"user-agent", "authorization", "x-leo-schema-version",
|
||||
"sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site",
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
return body, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func unknownBalance(reason string) map[string]any {
|
||||
return map[string]any{
|
||||
"remaining": nil,
|
||||
"used": nil,
|
||||
"total": nil,
|
||||
"unknown": true,
|
||||
"error": reason,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) newTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(true) }
|
||||
|
||||
// newDirectTLSClient egresses on the local IP (never the proxy). Used for
|
||||
// reference-image upload, polling and result download.
|
||||
func (c *Client) newDirectTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(false) }
|
||||
|
||||
func (c *Client) newTLSClientP(useProxy bool) (tlsclient.HttpClient, error) {
|
||||
// Match the fingerprint proven to work against Leonardo's Cloudflare edge:
|
||||
// Chrome_120, fixed extension order. A randomized JA3 (Chrome_133 +
|
||||
// WithRandomTLSExtensionOrder) gets flagged and 429'd at get-session.
|
||||
options := []tlsclient.HttpClientOption{
|
||||
tlsclient.WithTimeoutSeconds(60),
|
||||
tlsclient.WithClientProfile(profiles.Chrome_120),
|
||||
}
|
||||
if useProxy && c.proxy != "" {
|
||||
options = append(options, tlsclient.WithProxyUrl(c.proxy))
|
||||
}
|
||||
return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...)
|
||||
}
|
||||
|
||||
// downloadImage fetches a generated image (cdn.leonardo.ai) and returns the bytes.
|
||||
func (c *Client) downloadImage(ctx context.Context, imageURL string) ([]byte, error) {
|
||||
if _, err := url.Parse(imageURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client, err := c.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, imageURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"image/avif,image/webp,image/png,image/*,*/*;q=0.8"},
|
||||
"user-agent": {userAgent},
|
||||
"referer": {appBase + "/"},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("%w: image download http %d", ErrTemporaryUpstream, resp.StatusCode)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func stringValue(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
b, _ := json.Marshal(x)
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
}
|
||||
|
||||
func intValue(v any) int {
|
||||
switch x := v.(type) {
|
||||
case int:
|
||||
return x
|
||||
case int64:
|
||||
return int(x)
|
||||
case float64:
|
||||
return int(x)
|
||||
case json.Number:
|
||||
n, _ := x.Int64()
|
||||
return int(n)
|
||||
case string:
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(x))
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func emptyStringNil(v string) any {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func clip(b []byte, n int) string {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if len(s) > n {
|
||||
return s[:n]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -1,401 +0,0 @@
|
||||
package leonardo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
http "github.com/bogdanfinn/fhttp"
|
||||
)
|
||||
|
||||
// defaultStyleID is the "Dynamic" style applied when the caller doesn't specify
|
||||
// one — Leonardo's Generate mutation expects a style_ids entry.
|
||||
const defaultStyleID = "111dc692-d470-4eec-b791-3475abac4c46"
|
||||
|
||||
const mGenerate = `mutation Generate($request: CreateGenerationRequest!) {
|
||||
generate(request: $request) {
|
||||
apiCreditCost
|
||||
generationId
|
||||
__typename
|
||||
}
|
||||
}`
|
||||
|
||||
// qGenerationImages polls one generation's status AND its produced images in a
|
||||
// single round-trip (where: id _in [genId]).
|
||||
const qGenerationImages = `query GenerationImages($where: generations_bool_exp = {}) {
|
||||
generations(where: $where) {
|
||||
id
|
||||
status
|
||||
generated_images {
|
||||
id
|
||||
url
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
}`
|
||||
|
||||
const mUploadImage = `mutation UploadImage($uploadImageInput: UploadImageInput!) {
|
||||
uploadImage(arg1: $uploadImageInput) {
|
||||
uploadId
|
||||
url
|
||||
fields
|
||||
__typename
|
||||
}
|
||||
}`
|
||||
|
||||
const mUploadInitImage = `mutation UploadInitImage($arg1: InitImageUploadInput!) {
|
||||
uploadInitImage(arg1: $arg1) {
|
||||
id
|
||||
url
|
||||
fields
|
||||
__typename
|
||||
}
|
||||
}`
|
||||
|
||||
// initImageExtension narrows a sniffed extension to what uploadInitImage accepts
|
||||
// (png / jpg / jpeg / webp); anything else is sent as png.
|
||||
func initImageExtension(extension string) string {
|
||||
switch strings.TrimPrefix(strings.ToLower(strings.TrimSpace(extension)), ".") {
|
||||
case "jpg":
|
||||
return "jpg"
|
||||
case "jpeg":
|
||||
return "jpeg"
|
||||
case "webp":
|
||||
return "webp"
|
||||
default:
|
||||
return "png"
|
||||
}
|
||||
}
|
||||
|
||||
// uploadInitImage uploads a reference image and returns the init image id to put
|
||||
// in a Generate request's image_reference guidance. It has to go through
|
||||
// uploadInitImage (permanent init-image bucket): the uploadImage mutation only
|
||||
// hands out temporary-bucket ids, which the generation service can't resolve.
|
||||
func (c *Client) uploadInitImage(ctx context.Context, cookie, extension string, img []byte) (string, error) {
|
||||
extension = initImageExtension(extension)
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"operationName": "UploadInitImage",
|
||||
"query": mUploadInitImage,
|
||||
"variables": map[string]any{"arg1": map[string]any{"extension": extension}},
|
||||
})
|
||||
body, err := c.callGraphQL(ctx, cookie, payload, false, "upload-init-image")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var ur struct {
|
||||
Data struct {
|
||||
UploadInitImage struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Fields string `json:"fields"`
|
||||
} `json:"uploadInitImage"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &ur); err != nil {
|
||||
return "", fmt.Errorf("%w: upload-init-image non-json", ErrTemporaryUpstream)
|
||||
}
|
||||
up := ur.Data.UploadInitImage
|
||||
if up.ID == "" || up.URL == "" {
|
||||
return "", fmt.Errorf("%w: no upload url", ErrTemporaryUpstream)
|
||||
}
|
||||
if err := c.putPresigned(ctx, up.URL, up.Fields, "asset."+extension, img); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return up.ID, nil
|
||||
}
|
||||
|
||||
// putPresigned performs the presigned S3 POST: all policy fields first, the file
|
||||
// part LAST.
|
||||
func (c *Client) putPresigned(ctx context.Context, url, fieldsJSON, filename string, asset []byte) error {
|
||||
var fields map[string]string
|
||||
if err := json.Unmarshal([]byte(fieldsJSON), &fields); err != nil {
|
||||
return fmt.Errorf("%w: bad upload fields", ErrTemporaryUpstream)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
for k, v := range fields {
|
||||
_ = w.WriteField(k, v)
|
||||
}
|
||||
fw, err := w.CreateFormFile("file", filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fw.Write(asset); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = w.Close()
|
||||
|
||||
client, err := c.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, url, &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"content-type": {w.FormDataContentType()},
|
||||
"user-agent": {userAgent},
|
||||
"origin": {appBase},
|
||||
"referer": {appBase + "/"},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: s3 upload: %s", ErrTemporaryUpstream, err.Error())
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 204 && resp.StatusCode != 200 && resp.StatusCode != 201 {
|
||||
return fmt.Errorf("%w: s3 upload http %d", ErrTemporaryUpstream, resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// uploadAsset uploads one reference asset (extension png / mp3 / mp4 …) through
|
||||
// the same UploadImage presigned-S3 flow images use, and returns its upload id.
|
||||
func (c *Client) uploadAsset(ctx context.Context, cookie, extension string, asset []byte) (string, error) {
|
||||
extension = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(extension)), ".")
|
||||
if extension == "" {
|
||||
extension = "png"
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"operationName": "UploadImage",
|
||||
"query": mUploadImage,
|
||||
// originalFilename is mandatory for audio uploads and harmless otherwise.
|
||||
"variables": map[string]any{"uploadImageInput": map[string]any{
|
||||
"uploadType": "INIT",
|
||||
"extension": extension,
|
||||
"originalFilename": "asset." + extension,
|
||||
}},
|
||||
})
|
||||
body, err := c.callGraphQL(ctx, cookie, payload, false, "upload-init")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var ur struct {
|
||||
Data struct {
|
||||
UploadImage struct {
|
||||
UploadID string `json:"uploadId"`
|
||||
URL string `json:"url"`
|
||||
Fields string `json:"fields"`
|
||||
} `json:"uploadImage"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &ur); err != nil {
|
||||
return "", fmt.Errorf("%w: upload-init non-json", ErrTemporaryUpstream)
|
||||
}
|
||||
up := ur.Data.UploadImage
|
||||
if up.UploadID == "" || up.URL == "" {
|
||||
return "", fmt.Errorf("%w: no upload url", ErrTemporaryUpstream)
|
||||
}
|
||||
if err := c.putPresigned(ctx, up.URL, up.Fields, "asset."+extension, asset); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return up.UploadID, nil
|
||||
}
|
||||
|
||||
// GenerateImage runs the full Leonardo image pipeline against one account cookie:
|
||||
// mint a JWT, (for image-to-image) upload each reference image, submit the
|
||||
// Generate mutation, poll until COMPLETE, then download the first produced image.
|
||||
// Returns the image bytes, an info map, and a classified error.
|
||||
func (c *Client) GenerateImage(ctx context.Context, cookie, model, prompt string, width, height int, styleIDs []string, refImages [][]byte, downloadResult bool) ([]byte, map[string]any, error) {
|
||||
sess, err := c.GetSession(ctx, cookie)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(styleIDs) == 0 {
|
||||
styleIDs = []string{defaultStyleID}
|
||||
}
|
||||
if strings.TrimSpace(model) == "" {
|
||||
model = "seedream-4.5"
|
||||
}
|
||||
|
||||
// Image-to-image: upload each reference and collect its guidance entry.
|
||||
var imageRefs []map[string]any
|
||||
for _, img := range refImages {
|
||||
if len(img) == 0 {
|
||||
continue
|
||||
}
|
||||
uploadID, upErr := c.uploadInitImage(ctx, cookie, assetExtension(img, "png"), img)
|
||||
if upErr != nil {
|
||||
return nil, nil, upErr
|
||||
}
|
||||
imageRefs = append(imageRefs, map[string]any{
|
||||
"image": map[string]any{"id": uploadID, "type": "UPLOADED"},
|
||||
"strength": "MID",
|
||||
})
|
||||
}
|
||||
|
||||
promptEnhance := "AUTO"
|
||||
parameters := map[string]any{
|
||||
"height": height,
|
||||
"width": width,
|
||||
"prompt_enhance": promptEnhance,
|
||||
"quantity": 1,
|
||||
"style_ids": styleIDs,
|
||||
"prompt": prompt,
|
||||
}
|
||||
if len(imageRefs) > 0 {
|
||||
// Preserve the reference when image-guided (matches the web app).
|
||||
parameters["prompt_enhance"] = "OFF"
|
||||
parameters["guidances"] = map[string]any{"image_reference": imageRefs}
|
||||
}
|
||||
|
||||
// 1. submit
|
||||
genReq := map[string]any{
|
||||
"operationName": "Generate",
|
||||
"query": mGenerate,
|
||||
"variables": map[string]any{
|
||||
"request": map[string]any{
|
||||
"model": model,
|
||||
"public": true,
|
||||
"parameters": parameters,
|
||||
},
|
||||
},
|
||||
}
|
||||
payload, _ := json.Marshal(genReq)
|
||||
body, err := c.callGraphQL(ctx, cookie, payload, true, "generate")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var genResp struct {
|
||||
Data struct {
|
||||
Generate struct {
|
||||
GenerationID string `json:"generationId"`
|
||||
} `json:"generate"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &genResp); err != nil {
|
||||
return nil, nil, fmt.Errorf("%w: generate non-json", ErrTemporaryUpstream)
|
||||
}
|
||||
genID := strings.TrimSpace(genResp.Data.Generate.GenerationID)
|
||||
if genID == "" {
|
||||
return nil, nil, fmt.Errorf("%w: no generationId: %s", ErrTemporaryUpstream, clip(body, 200))
|
||||
}
|
||||
|
||||
// 2. poll until COMPLETE, then read the image url.
|
||||
imageURL, err := c.pollImage(ctx, cookie, genID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
info := map[string]any{
|
||||
"generation_id": genID,
|
||||
"image_url": imageURL,
|
||||
"user_id": sess.UserID,
|
||||
}
|
||||
if !downloadResult {
|
||||
return nil, info, nil
|
||||
}
|
||||
// 3. download bytes
|
||||
data, err := c.downloadImage(ctx, imageURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return data, info, nil
|
||||
}
|
||||
|
||||
// pollImage polls one generation until it reports COMPLETE (returning the first
|
||||
// image url) or FAILED (error). Honors ctx cancellation / deadline.
|
||||
func (c *Client) pollImage(ctx context.Context, cookie, genID string) (string, error) {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"operationName": "GenerationImages",
|
||||
"query": qGenerationImages,
|
||||
"variables": map[string]any{
|
||||
"where": map[string]any{"id": map[string]any{"_in": []string{genID}}},
|
||||
},
|
||||
})
|
||||
|
||||
ticker := time.NewTicker(3 * time.Second)
|
||||
defer ticker.Stop()
|
||||
// Poll for the full generation budget (caller's genCtx), leaving headroom for
|
||||
// the download, instead of a shorter hardcoded cap that killed slow jobs early.
|
||||
// ctx already bounds the wait, so a stuck job still can't hang indefinitely.
|
||||
deadline := time.Now().Add(5 * time.Minute)
|
||||
if dl, ok := ctx.Deadline(); ok {
|
||||
deadline = dl.Add(-60 * time.Second)
|
||||
}
|
||||
|
||||
for {
|
||||
body, err := c.callGraphQL(ctx, cookie, payload, false, "poll")
|
||||
if errors.Is(err, ErrAuth) {
|
||||
return "", err
|
||||
}
|
||||
// 其它错误(含上游临时抖动)不中断轮询,等 deadline 再判超时。
|
||||
if err == nil {
|
||||
var pr struct {
|
||||
Data struct {
|
||||
Generations []struct {
|
||||
Status string `json:"status"`
|
||||
GeneratedImages []struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"generated_images"`
|
||||
} `json:"generations"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &pr); err == nil && len(pr.Data.Generations) > 0 {
|
||||
g := pr.Data.Generations[0]
|
||||
switch strings.ToUpper(g.Status) {
|
||||
case "COMPLETE":
|
||||
for _, img := range g.GeneratedImages {
|
||||
if u := strings.TrimSpace(img.URL); u != "" {
|
||||
return u, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("%w: complete but no image url", ErrTemporaryUpstream)
|
||||
case "FAILED":
|
||||
return "", fmt.Errorf("%w: generation failed", ErrTemporaryUpstream)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
return "", fmt.Errorf("%w: generation timed out", ErrTemporaryUpstream)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// graphqlError inspects a GraphQL response body for an "errors" array and maps the
|
||||
// first message to a classified sentinel (auth / quota / temporary). Returns nil
|
||||
// when there are no errors.
|
||||
func graphqlError(body []byte) error {
|
||||
var env struct {
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
Extensions struct {
|
||||
Code string `json:"code"`
|
||||
Details struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"details"`
|
||||
} `json:"extensions"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env); err != nil || len(env.Errors) == 0 {
|
||||
return nil
|
||||
}
|
||||
msg := strings.TrimSpace(env.Errors[0].Message)
|
||||
// The generic "An error occurred." hides the real reason in extensions.
|
||||
if detail := strings.TrimSpace(env.Errors[0].Extensions.Details.Message); detail != "" && detail != msg {
|
||||
msg = msg + " (" + detail + ")"
|
||||
}
|
||||
low := strings.ToLower(msg)
|
||||
switch {
|
||||
case strings.Contains(low, "unauthor") || strings.Contains(low, "jwt") || strings.Contains(low, "token is") || strings.Contains(low, "forbidden"):
|
||||
return ErrAuth
|
||||
case strings.Contains(low, "token") || strings.Contains(low, "credit") || strings.Contains(low, "quota") || strings.Contains(low, "insufficient") || strings.Contains(low, "not enough"):
|
||||
return ErrQuotaExhausted
|
||||
default:
|
||||
return fmt.Errorf("leonardo: %s", clip([]byte(msg), 200))
|
||||
}
|
||||
}
|
||||
@@ -1,412 +0,0 @@
|
||||
package leonardo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// qGenerationVideos polls one video generation's status AND its produced clip in
|
||||
// a single round-trip (motionMP4URL carries the mp4).
|
||||
const qGenerationVideos = `query GenerationVideos($where: generations_bool_exp = {}) {
|
||||
generations(where: $where) {
|
||||
id
|
||||
status
|
||||
generated_images {
|
||||
id
|
||||
motionMP4URL
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
}`
|
||||
|
||||
// VideoAssets are the decoded reference assets a video request can carry:
|
||||
// image references (strength MID), one audio track and video references. The
|
||||
// caller enforces the per-type caps; durations are derived from the bytes here
|
||||
// because Leonardo requires them for audio/video guidances.
|
||||
type VideoAssets struct {
|
||||
Images [][]byte
|
||||
Audios [][]byte
|
||||
Videos [][]byte
|
||||
}
|
||||
|
||||
// GenerateVideo runs the Leonardo video pipeline (seedance-2.0 / -fast, hailuo) against
|
||||
// one account cookie: upload every reference asset, submit the Generate mutation
|
||||
// as a PRIVATE generation, poll until COMPLETE, then optionally download the mp4.
|
||||
func (c *Client) GenerateVideo(ctx context.Context, cookie, model, prompt string, width, height, durationSeconds int, refs VideoAssets, downloadResult bool) ([]byte, map[string]any, error) {
|
||||
sess, err := c.GetSession(ctx, cookie)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if strings.TrimSpace(model) == "" {
|
||||
model = "seedance-2.0-fast"
|
||||
}
|
||||
|
||||
guidances := map[string]any{}
|
||||
var imageRefs []map[string]any
|
||||
for _, img := range refs.Images {
|
||||
if len(img) == 0 {
|
||||
continue
|
||||
}
|
||||
uploadID, upErr := c.uploadInitImage(ctx, cookie, assetExtension(img, "png"), img)
|
||||
if upErr != nil {
|
||||
return nil, nil, upErr
|
||||
}
|
||||
imageRefs = append(imageRefs, map[string]any{
|
||||
"image": map[string]any{"id": uploadID, "type": "UPLOADED"},
|
||||
"strength": "MID",
|
||||
})
|
||||
}
|
||||
if len(imageRefs) > 0 {
|
||||
guidances["image_reference"] = imageRefs
|
||||
}
|
||||
var videoRefs []map[string]any
|
||||
for _, vid := range refs.Videos {
|
||||
if len(vid) == 0 {
|
||||
continue
|
||||
}
|
||||
uploadID, upErr := c.uploadAsset(ctx, cookie, assetExtension(vid, "mp4"), vid)
|
||||
if upErr != nil {
|
||||
return nil, nil, upErr
|
||||
}
|
||||
video := map[string]any{"id": uploadID, "type": "UPLOADED"}
|
||||
if secs := MediaDurationSeconds(vid); secs > 0 {
|
||||
video["duration"] = secs
|
||||
}
|
||||
videoRefs = append(videoRefs, map[string]any{"video": video})
|
||||
}
|
||||
if len(videoRefs) > 0 {
|
||||
guidances["video_reference_base"] = videoRefs
|
||||
}
|
||||
// Leonardo rejects an audio reference that isn't paired with an image or
|
||||
// video reference (audio_reference_only_compatible_with_image_or_video_reference).
|
||||
var audioRefs []map[string]any
|
||||
for _, aud := range refs.Audios {
|
||||
if len(aud) == 0 {
|
||||
continue
|
||||
}
|
||||
if len(imageRefs) == 0 && len(videoRefs) == 0 {
|
||||
return nil, nil, errors.New("leonardo: audio reference requires an image or video reference")
|
||||
}
|
||||
uploadID, upErr := c.uploadAsset(ctx, cookie, assetExtension(aud, "mp3"), aud)
|
||||
if upErr != nil {
|
||||
return nil, nil, upErr
|
||||
}
|
||||
audio := map[string]any{"id": uploadID, "type": "UPLOADED"}
|
||||
if secs := MediaDurationSeconds(aud); secs > 0 {
|
||||
audio["duration"] = secs
|
||||
}
|
||||
audioRefs = append(audioRefs, map[string]any{"audio": audio})
|
||||
}
|
||||
if len(audioRefs) > 0 {
|
||||
guidances["audio_reference"] = audioRefs
|
||||
}
|
||||
|
||||
parameters := map[string]any{
|
||||
"height": height,
|
||||
"width": width,
|
||||
"duration": durationSeconds,
|
||||
"motion_has_audio": true,
|
||||
"quantity": 1,
|
||||
"prompt": prompt,
|
||||
"guidances": guidances,
|
||||
}
|
||||
// seedance 走随机种子;hailuo 的生成页 seedEnabled=false,请求里不带 seed。
|
||||
if !strings.HasPrefix(model, "hailuo") {
|
||||
parameters["seed"] = -1
|
||||
}
|
||||
genReq := map[string]any{
|
||||
"operationName": "Generate",
|
||||
"query": mGenerate,
|
||||
"variables": map[string]any{
|
||||
"request": map[string]any{
|
||||
"model": model,
|
||||
// 私有生成:不进公开 feed。
|
||||
"public": false,
|
||||
"parameters": parameters,
|
||||
},
|
||||
},
|
||||
}
|
||||
payload, _ := json.Marshal(genReq)
|
||||
body, err := c.callGraphQL(ctx, cookie, payload, true, "generate-video")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var genResp struct {
|
||||
Data struct {
|
||||
Generate struct {
|
||||
GenerationID string `json:"generationId"`
|
||||
} `json:"generate"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &genResp); err != nil {
|
||||
return nil, nil, fmt.Errorf("%w: generate non-json", ErrTemporaryUpstream)
|
||||
}
|
||||
genID := strings.TrimSpace(genResp.Data.Generate.GenerationID)
|
||||
if genID == "" {
|
||||
return nil, nil, fmt.Errorf("%w: no generationId: %s", ErrTemporaryUpstream, clip(body, 200))
|
||||
}
|
||||
|
||||
videoURL, err := c.pollVideo(ctx, cookie, genID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
info := map[string]any{
|
||||
"generation_id": genID,
|
||||
"video_url": videoURL,
|
||||
"user_id": sess.UserID,
|
||||
}
|
||||
if !downloadResult {
|
||||
return nil, info, nil
|
||||
}
|
||||
data, err := c.downloadImage(ctx, videoURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return data, info, nil
|
||||
}
|
||||
|
||||
// pollVideo polls one generation until COMPLETE (returning the mp4 url) or
|
||||
// FAILED. Temporary upstream hiccups don't abort the wait — only ctx/deadline do.
|
||||
func (c *Client) pollVideo(ctx context.Context, cookie, genID string) (string, error) {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"operationName": "GenerationVideos",
|
||||
"query": qGenerationVideos,
|
||||
"variables": map[string]any{
|
||||
"where": map[string]any{"id": map[string]any{"_in": []string{genID}}},
|
||||
},
|
||||
})
|
||||
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
deadline := time.Now().Add(10 * time.Minute)
|
||||
if dl, ok := ctx.Deadline(); ok {
|
||||
deadline = dl.Add(-60 * time.Second)
|
||||
}
|
||||
|
||||
for {
|
||||
body, err := c.callGraphQL(ctx, cookie, payload, false, "poll-video")
|
||||
if errors.Is(err, ErrAuth) {
|
||||
return "", err
|
||||
}
|
||||
if err == nil {
|
||||
var pr struct {
|
||||
Data struct {
|
||||
Generations []struct {
|
||||
Status string `json:"status"`
|
||||
GeneratedImages []struct {
|
||||
MotionMP4URL string `json:"motionMP4URL"`
|
||||
} `json:"generated_images"`
|
||||
} `json:"generations"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &pr); err == nil && len(pr.Data.Generations) > 0 {
|
||||
g := pr.Data.Generations[0]
|
||||
switch strings.ToUpper(g.Status) {
|
||||
case "COMPLETE":
|
||||
for _, img := range g.GeneratedImages {
|
||||
if u := strings.TrimSpace(img.MotionMP4URL); u != "" {
|
||||
return u, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("%w: complete but no video url", ErrTemporaryUpstream)
|
||||
case "FAILED":
|
||||
return "", fmt.Errorf("%w: generation failed", ErrTemporaryUpstream)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
return "", fmt.Errorf("%w: generation timed out", ErrTemporaryUpstream)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assetExtension sniffs the upload extension Leonardo expects for a reference
|
||||
// asset; fallback is used when the bytes aren't recognized.
|
||||
func assetExtension(data []byte, fallback string) string {
|
||||
n := len(data)
|
||||
switch {
|
||||
case n >= 8 && string(data[1:4]) == "PNG":
|
||||
return "png"
|
||||
case n >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF:
|
||||
return "jpg"
|
||||
case n >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WEBP":
|
||||
return "webp"
|
||||
case n >= 12 && string(data[4:8]) == "ftyp":
|
||||
if n >= 12 && strings.HasPrefix(string(data[8:12]), "qt") {
|
||||
return "mov"
|
||||
}
|
||||
if n >= 12 && strings.HasPrefix(string(data[8:12]), "M4A") {
|
||||
return "m4a"
|
||||
}
|
||||
return "mp4"
|
||||
case n >= 4 && data[0] == 0x1A && data[1] == 0x45 && data[2] == 0xDF && data[3] == 0xA3:
|
||||
return "webm"
|
||||
case n >= 3 && string(data[0:3]) == "ID3":
|
||||
return "mp3"
|
||||
case n >= 2 && data[0] == 0xFF && (data[1]&0xE0) == 0xE0:
|
||||
return "mp3"
|
||||
case n >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WAVE":
|
||||
return "wav"
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// MediaDurationSeconds returns a media asset's duration in seconds (0 when it
|
||||
// can't be determined). Leonardo's audio_reference / video_reference_base
|
||||
// guidances carry the duration, and the caller validates video lengths with it,
|
||||
// so it's derived from the uploaded bytes: mp4/mov via the mvhd box, wav via the
|
||||
// fmt byte rate, mp3 from the first frame's bitrate.
|
||||
func MediaDurationSeconds(data []byte) float64 {
|
||||
if secs := mp4Duration(data); secs > 0 {
|
||||
return secs
|
||||
}
|
||||
if secs := wavDuration(data); secs > 0 {
|
||||
return secs
|
||||
}
|
||||
return mp3Duration(data)
|
||||
}
|
||||
|
||||
// mp4Duration walks the ISOBMFF box tree to moov/mvhd and reads timescale+duration.
|
||||
func mp4Duration(data []byte) float64 {
|
||||
if len(data) < 12 || string(data[4:8]) != "ftyp" {
|
||||
return 0
|
||||
}
|
||||
moov := findBox(data, "moov")
|
||||
if moov == nil {
|
||||
return 0
|
||||
}
|
||||
mvhd := findBox(moov, "mvhd")
|
||||
if len(mvhd) < 20 {
|
||||
return 0
|
||||
}
|
||||
version := mvhd[0]
|
||||
if version == 1 {
|
||||
if len(mvhd) < 32 {
|
||||
return 0
|
||||
}
|
||||
timescale := binary.BigEndian.Uint32(mvhd[20:24])
|
||||
duration := binary.BigEndian.Uint64(mvhd[24:32])
|
||||
if timescale == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(duration) / float64(timescale)
|
||||
}
|
||||
timescale := binary.BigEndian.Uint32(mvhd[12:16])
|
||||
duration := binary.BigEndian.Uint32(mvhd[16:20])
|
||||
if timescale == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(duration) / float64(timescale)
|
||||
}
|
||||
|
||||
// findBox returns the payload of the first box named typ among data's boxes
|
||||
// (callers descend one level at a time by passing a parent's payload back in).
|
||||
func findBox(data []byte, typ string) []byte {
|
||||
for off := 0; off+8 <= len(data); {
|
||||
size := int(binary.BigEndian.Uint32(data[off : off+4]))
|
||||
name := string(data[off+4 : off+8])
|
||||
header := 8
|
||||
if size == 1 { // 64-bit size
|
||||
if off+16 > len(data) {
|
||||
return nil
|
||||
}
|
||||
size = int(binary.BigEndian.Uint64(data[off+8 : off+16]))
|
||||
header = 16
|
||||
} else if size == 0 {
|
||||
size = len(data) - off
|
||||
}
|
||||
if size < header || off+size > len(data) {
|
||||
return nil
|
||||
}
|
||||
if name == typ {
|
||||
return data[off+header : off+size]
|
||||
}
|
||||
off += size
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// wavDuration reads the RIFF fmt chunk's byte rate and the data chunk size.
|
||||
func wavDuration(data []byte) float64 {
|
||||
if len(data) < 44 || string(data[0:4]) != "RIFF" || string(data[8:12]) != "WAVE" {
|
||||
return 0
|
||||
}
|
||||
byteRate := 0
|
||||
dataSize := 0
|
||||
for off := 12; off+8 <= len(data); {
|
||||
name := string(data[off : off+4])
|
||||
size := int(binary.LittleEndian.Uint32(data[off+4 : off+8]))
|
||||
body := off + 8
|
||||
if size < 0 || body > len(data) {
|
||||
break
|
||||
}
|
||||
switch name {
|
||||
case "fmt ":
|
||||
if body+16 <= len(data) {
|
||||
byteRate = int(binary.LittleEndian.Uint32(data[body+8 : body+12]))
|
||||
}
|
||||
case "data":
|
||||
dataSize = size
|
||||
if body+size > len(data) {
|
||||
dataSize = len(data) - body
|
||||
}
|
||||
}
|
||||
off = body + size + size%2
|
||||
}
|
||||
if byteRate <= 0 || dataSize <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(dataSize) / float64(byteRate)
|
||||
}
|
||||
|
||||
// mp3Bitrates are the Layer III bitrate tables (kbps) indexed by the frame
|
||||
// header's bitrate index: 1 = MPEG1, 2 = MPEG2/2.5.
|
||||
var mp3Bitrates = map[int][]int{
|
||||
1: {0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0},
|
||||
2: {0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0},
|
||||
}
|
||||
|
||||
// mp3Duration estimates the duration from the first frame header's bitrate
|
||||
// (constant-bitrate assumption — good enough for the guidance duration field).
|
||||
func mp3Duration(data []byte) float64 {
|
||||
off := 0
|
||||
if len(data) >= 10 && string(data[0:3]) == "ID3" {
|
||||
// syncsafe int: 7 bits per byte
|
||||
tagSize := int(data[6]&0x7F)<<21 | int(data[7]&0x7F)<<14 | int(data[8]&0x7F)<<7 | int(data[9]&0x7F)
|
||||
off = 10 + tagSize
|
||||
}
|
||||
for ; off+4 <= len(data); off++ {
|
||||
if data[off] != 0xFF || data[off+1]&0xE0 != 0xE0 {
|
||||
continue
|
||||
}
|
||||
versionBits := (data[off+1] >> 3) & 0x03
|
||||
layerBits := (data[off+1] >> 1) & 0x03
|
||||
if layerBits != 0x01 { // Layer III only
|
||||
continue
|
||||
}
|
||||
table := 1
|
||||
if versionBits != 0x03 { // MPEG2 / 2.5
|
||||
table = 2
|
||||
}
|
||||
idx := int((data[off+2] >> 4) & 0x0F)
|
||||
rates := mp3Bitrates[table]
|
||||
if idx <= 0 || idx >= len(rates) || rates[idx] == 0 {
|
||||
continue
|
||||
}
|
||||
kbps := rates[idx]
|
||||
return float64(len(data)-off) * 8 / float64(kbps*1000)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -1,373 +0,0 @@
|
||||
// Package runway implements the Runway (runwayml.com) provider client. For now
|
||||
// it only covers account management — JWT detection, workspace/team id
|
||||
// extraction and credit-balance probing — mirroring the curl_cffi reference in
|
||||
// query_credits.py with tls-client so the JA3/JA4 fingerprint matches Chrome.
|
||||
package runway
|
||||
|
||||
import (
|
||||
"context"
|
||||
crand "crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
http "github.com/bogdanfinn/fhttp"
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
"github.com/bogdanfinn/tls-client/profiles"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
apiBase = "https://api.runwayml.com"
|
||||
origin = "https://app.runwayml.com"
|
||||
|
||||
// userAgent / secChUA mirror the real browser that produced a successful
|
||||
// registration + generation in the reference HAR (Edge 150 on Windows).
|
||||
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36 Edg/150.0.0.0"
|
||||
secChUA = `"Not;A=Brand";v="8", "Chromium";v="150", "Microsoft Edge";v="150"`
|
||||
|
||||
// sourceApp / buildHash are the x-runway-source-application[-version] the real
|
||||
// web app stamps on EVERY authed API call (319/336 requests in the reference
|
||||
// HAR). buildHash is the web bundle's git sha; it also appears as the
|
||||
// `sentry-release` segment of the baggage header. Runway anti-abuse keys on
|
||||
// these being present + client-id (see clientIDFromToken): a write/upload that
|
||||
// lacks them is judged a non-web client and the account's free credits are
|
||||
// zeroed on the FIRST upload ("上传清零积分"). buildHash tracks Runway's web
|
||||
// releases — refresh it from a current HAR if uploads start getting flagged.
|
||||
sourceApp = "web"
|
||||
buildHash = "3e96b2f0f85b8c0cafb7c0dcd7a6878305aaa0f0"
|
||||
)
|
||||
|
||||
// clientIDOverride, when non-empty, forces the x-runway-client-id (used to pin an
|
||||
// account to the exact persistent device id its real browser session used).
|
||||
var clientIDOverride string
|
||||
|
||||
// clientIDUUIDNamespace is a fixed namespace so clientIDFromToken is stable
|
||||
// across processes/restarts — the same account always derives the same
|
||||
// x-runway-client-id, exactly like the browser's localStorage-persisted id.
|
||||
var clientIDUUIDNamespace = uuid.MustParse("6ba7b811-9dad-11d1-80b4-00c04fd430c8")
|
||||
|
||||
// clientIDFromToken derives the persistent per-account x-runway-client-id. The
|
||||
// real SPA generates this UUID once and stores it in localStorage; every request
|
||||
// from that browser reuses the SAME value (5b89c786-… appears on all 319 authed
|
||||
// calls in the HAR). We reproduce that stability by deterministically deriving a
|
||||
// v5 UUID from the account's JWT "id" claim, so one account == one client-id for
|
||||
// its whole lifetime without needing a DB column. Falls back to the raw token if
|
||||
// the id claim is missing.
|
||||
func clientIDFromToken(token string) string {
|
||||
if clientIDOverride != "" {
|
||||
return clientIDOverride
|
||||
}
|
||||
seed := TeamIDFromToken(token)
|
||||
if seed == "" {
|
||||
seed = strings.TrimPrefix(token, "Bearer ")
|
||||
}
|
||||
return uuid.NewSHA1(clientIDUUIDNamespace, []byte("runway-client-id:"+seed)).String()
|
||||
}
|
||||
|
||||
// randHex returns n random bytes hex-encoded (2n chars), for sentry trace ids.
|
||||
func randHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
if _, err := crand.Read(b); err != nil {
|
||||
return strings.Repeat("0", n*2)
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// browserHeaders builds the full Chrome/Edge header set the Runway web app sends
|
||||
// on its JSON API calls. tls-client only spoofs the TLS/JA3 fingerprint — it does
|
||||
// NOT inject a User-Agent or the client-hint / fetch-metadata headers. Without
|
||||
// them a request looks like a headless bot and Runway's anti-abuse revokes the
|
||||
// account's free credits within minutes ("秒死"). teamID is optional.
|
||||
func browserHeaders(token, teamID string) http.Header {
|
||||
traceID := randHex(16)
|
||||
spanID := randHex(8)
|
||||
h := http.Header{
|
||||
"accept": {"application/json"},
|
||||
"accept-language": {"zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6"},
|
||||
"authorization": {"Bearer " + strings.TrimPrefix(token, "Bearer ")},
|
||||
"baggage": {"sentry-environment=production,sentry-release=" + buildHash + ",sentry-public_key=8ea832c064ed4bbcb4b8952c02ba119a,sentry-trace_id=" + traceID},
|
||||
"content-type": {"application/json"},
|
||||
"origin": {origin},
|
||||
"priority": {"u=1, i"},
|
||||
"referer": {origin + "/"},
|
||||
"sec-ch-ua": {secChUA},
|
||||
"sec-ch-ua-mobile": {"?0"},
|
||||
"sec-ch-ua-platform": {`"Windows"`},
|
||||
"sec-fetch-dest": {"empty"},
|
||||
"sec-fetch-mode": {"cors"},
|
||||
"sec-fetch-site": {"same-site"},
|
||||
"sentry-trace": {traceID + "-" + spanID},
|
||||
"user-agent": {userAgent},
|
||||
// x-runway-* identify this as the real web app to Runway's anti-abuse.
|
||||
// client-id must be persistent per account (see clientIDFromToken);
|
||||
// omitting these is what zeroes an account's credits on first upload.
|
||||
"x-runway-client-id": {clientIDFromToken(token)},
|
||||
"x-runway-source-application": {sourceApp},
|
||||
"x-runway-source-application-version": {buildHash},
|
||||
"x-runway-workspace": {teamID},
|
||||
http.HeaderOrderKey: {
|
||||
"accept", "accept-language", "authorization", "baggage",
|
||||
"content-type", "origin", "priority", "referer",
|
||||
"sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform",
|
||||
"sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site",
|
||||
"sentry-trace", "user-agent",
|
||||
"x-runway-client-id", "x-runway-source-application",
|
||||
"x-runway-source-application-version", "x-runway-workspace",
|
||||
},
|
||||
}
|
||||
if strings.TrimSpace(teamID) == "" {
|
||||
h.Del("x-runway-workspace")
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// browserAssetHeaders is the lighter header set the browser sends on cross-site
|
||||
// asset transfers (presigned S3 upload / CloudFront artifact download): a
|
||||
// User-Agent and client hints, but no Runway authorization.
|
||||
func browserAssetHeaders() http.Header {
|
||||
return http.Header{
|
||||
"accept": {"*/*"},
|
||||
"accept-language": {"zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6"},
|
||||
"origin": {origin},
|
||||
"referer": {origin + "/"},
|
||||
"sec-ch-ua": {secChUA},
|
||||
"sec-ch-ua-mobile": {"?0"},
|
||||
"sec-ch-ua-platform": {`"Windows"`},
|
||||
"sec-fetch-dest": {"empty"},
|
||||
"sec-fetch-mode": {"cors"},
|
||||
"sec-fetch-site": {"cross-site"},
|
||||
"user-agent": {userAgent},
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
ErrAuth = errors.New("runway auth failed")
|
||||
ErrQuotaExhausted = errors.New("runway quota exhausted")
|
||||
ErrTemporaryUpstream = errors.New("runway upstream temporary error")
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
proxy string
|
||||
}
|
||||
|
||||
func NewClient(proxy string) *Client {
|
||||
return &Client{proxy: strings.TrimSpace(proxy)}
|
||||
}
|
||||
|
||||
func (c *Client) SetProxy(proxy string) {
|
||||
c.proxy = strings.TrimSpace(proxy)
|
||||
}
|
||||
|
||||
// IsRunwayToken reports whether a JWT looks like a Runway access token: a
|
||||
// top-level numeric "id" plus an "sso" claim, and crucially NO OpenAI
|
||||
// (https://api.openai.com/*) claims — that's what disambiguates it from a
|
||||
// ChatGPT token, which is otherwise also an opaque three-part JWT.
|
||||
func IsRunwayToken(token string) bool {
|
||||
claims := decodeJWTPayload(token)
|
||||
if len(claims) == 0 {
|
||||
return false
|
||||
}
|
||||
for k := range claims {
|
||||
if strings.HasPrefix(k, "https://api.openai.com/") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
_, hasSSO := claims["sso"]
|
||||
return hasSSO && claims["id"] != nil
|
||||
}
|
||||
|
||||
// TeamIDFromToken returns the Runway workspace/team id, which equals the JWT
|
||||
// "id" claim (query_credits.py / gen_video.py both derive teamId this way).
|
||||
func TeamIDFromToken(token string) string {
|
||||
claims := decodeJWTPayload(token)
|
||||
switch v := claims["id"].(type) {
|
||||
case float64:
|
||||
return strconv.FormatInt(int64(v), 10)
|
||||
case json.Number:
|
||||
return v.String()
|
||||
case string:
|
||||
return strings.TrimSpace(v)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// ExtractAccountInfo decodes the free (no-network) JWT claims for the accounts
|
||||
// view: email, team id and expiry.
|
||||
func ExtractAccountInfo(token string) map[string]any {
|
||||
claims := decodeJWTPayload(token)
|
||||
return map[string]any{
|
||||
"email": emptyStringNil(strings.TrimSpace(stringValue(claims["email"]))),
|
||||
"team_id": emptyStringNil(TeamIDFromToken(token)),
|
||||
"expires_at": claims["exp"],
|
||||
}
|
||||
}
|
||||
|
||||
// FetchCreditsBalance probes the account's plan credits via /v1/profile/features
|
||||
// (query_credits.py). Returns a normalized map mirroring the Adobe client so the
|
||||
// TokenService quota plumbing can treat all providers uniformly. A 401/403 maps
|
||||
// to ErrAuth (token dead); any other failure is reported as unknown without
|
||||
// killing the account.
|
||||
func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[string]any, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return unknownBalance("empty token"), nil
|
||||
}
|
||||
teamID := TeamIDFromToken(token)
|
||||
if teamID == "" {
|
||||
return unknownBalance("no team id"), nil
|
||||
}
|
||||
|
||||
client, err := c.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
url := apiBase + "/v1/profile/features?asTeamId=" + teamID
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = browserHeaders(token, teamID)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return unknownBalance("network: " + err.Error()), nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Per ops decision a rate-limit (403) is treated as a dead account too, same as
|
||||
// a 401 — a throttled Runway token is considered done.
|
||||
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return unknownBalance(fmt.Sprintf("http %d: %s", resp.StatusCode, clip(body, 160))), nil
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return unknownBalance("non-json"), nil
|
||||
}
|
||||
features, _ := payload["features"].(map[string]any)
|
||||
permitted, _ := features["permitted"].(map[string]any)
|
||||
used, _ := features["used"].(map[string]any)
|
||||
total := intValue(permitted["numPlanCredits"])
|
||||
spent := intValue(used["numPlanCredits"])
|
||||
remaining := total - spent
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
return map[string]any{
|
||||
"remaining": remaining,
|
||||
"used": spent,
|
||||
"total": total,
|
||||
"unknown": false,
|
||||
"error": nil,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func unknownBalance(reason string) map[string]any {
|
||||
return map[string]any{
|
||||
"remaining": nil,
|
||||
"used": nil,
|
||||
"total": nil,
|
||||
"unknown": true,
|
||||
"error": reason,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) newTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(true) }
|
||||
|
||||
// newDirectTLSClient egresses on the local IP (never the proxy). Used for
|
||||
// reference-image upload, polling and result download.
|
||||
func (c *Client) newDirectTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(false) }
|
||||
|
||||
func (c *Client) newTLSClientP(useProxy bool) (tlsclient.HttpClient, error) {
|
||||
options := []tlsclient.HttpClientOption{
|
||||
// Whole-request timeout, incl. reading the response body. 30s was too
|
||||
// tight for downloading a rendered image/video artifact off a slow CDN,
|
||||
// surfacing as "request canceled (Client.Timeout ... while reading body)".
|
||||
// The caller's genCtx still bounds the overall generation.
|
||||
tlsclient.WithTimeoutSeconds(300),
|
||||
tlsclient.WithClientProfile(profiles.Chrome_133),
|
||||
tlsclient.WithRandomTLSExtensionOrder(),
|
||||
}
|
||||
if useProxy && c.proxy != "" {
|
||||
options = append(options, tlsclient.WithProxyUrl(c.proxy))
|
||||
}
|
||||
return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...)
|
||||
}
|
||||
|
||||
func decodeJWTPayload(token string) map[string]any {
|
||||
parts := strings.Split(strings.TrimSpace(strings.TrimPrefix(token, "Bearer ")), ".")
|
||||
if len(parts) < 2 {
|
||||
return map[string]any{}
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stringValue(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
b, _ := json.Marshal(x)
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
}
|
||||
|
||||
func intValue(v any) int {
|
||||
switch x := v.(type) {
|
||||
case int:
|
||||
return x
|
||||
case int64:
|
||||
return int(x)
|
||||
case float64:
|
||||
return int(x)
|
||||
case json.Number:
|
||||
n, _ := x.Int64()
|
||||
return int(n)
|
||||
case string:
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(x))
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func emptyStringNil(v string) any {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func clip(b []byte, n int) string {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if len(s) > n {
|
||||
return s[:n]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
package runway
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
)
|
||||
|
||||
// GenerateImage runs a Runway gemini image text/image-to-image pipeline:
|
||||
// upload each reference image (DATASET + DATASET_PREVIEW → dataset) to obtain
|
||||
// its {assetId, url}, create a gemini image task and poll it to completion,
|
||||
// then download the rendered PNG. modelID selects the variant: "runway-nano-banana-2"
|
||||
// → gemini_3_1_flash_image / gemini-3.1-flash-image-preview (with aspect_ratio),
|
||||
// anything else → workflow_gemini_image / gemini-3-pro-image-preview. imageSize
|
||||
// is the "1K"/"2K"/"4K" tier. teamID is the workspace id; if empty it's derived
|
||||
// from the token. refs may be empty (pure text-to-image).
|
||||
func (c *Client) GenerateImage(ctx context.Context, token, teamID, modelID, prompt, aspectRatio, imageSize string, refs [][]byte, downloadResult bool) ([]byte, map[string]any, error) {
|
||||
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
|
||||
if token == "" {
|
||||
return nil, nil, ErrAuth
|
||||
}
|
||||
if teamID == "" {
|
||||
teamID = TeamIDFromToken(token)
|
||||
}
|
||||
if teamID == "" {
|
||||
return nil, nil, errors.New("runway: no team id")
|
||||
}
|
||||
if strings.TrimSpace(imageSize) == "" {
|
||||
imageSize = "1K"
|
||||
}
|
||||
|
||||
// Only the task-create (generate submit) egresses via the proxy; reference
|
||||
// upload, polling and download run on the local IP.
|
||||
submitClient, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
directClient, err := c.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var refImages []map[string]any
|
||||
var refAssetIDs []string
|
||||
for i, raw := range refs {
|
||||
if len(raw) == 0 {
|
||||
continue
|
||||
}
|
||||
filename := fmt.Sprintf("ref_%s_%d.png", time.Now().UTC().Format("20060102_150405"), i+1)
|
||||
assetID, url, upErr := c.uploadReference(ctx, directClient, token, teamID, filename, raw)
|
||||
if upErr != nil {
|
||||
return nil, nil, upErr
|
||||
}
|
||||
refImages = append(refImages, map[string]any{
|
||||
"tag": fmt.Sprintf("IMG_%d", i+1),
|
||||
"assetId": assetID,
|
||||
"url": url,
|
||||
})
|
||||
refAssetIDs = append(refAssetIDs, assetID)
|
||||
}
|
||||
|
||||
// Browser order: create the real session FIRST, attach the references and its
|
||||
// own asset group, THEN submit the task into it (see createSession). The task
|
||||
// carries this session's assetGroupId, NOT the account-wide "Generations"
|
||||
// group.
|
||||
sessionID, err := c.createSession(ctx, submitClient, token, teamID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, aid := range refAssetIDs {
|
||||
c.attachReference(ctx, submitClient, token, teamID, sessionID, aid)
|
||||
}
|
||||
assetGroupID, _ := c.sessionAssetGroup(ctx, submitClient, token, teamID, sessionID) // best-effort
|
||||
|
||||
taskID, err := c.createImageTask(ctx, submitClient, token, teamID, modelID, prompt, aspectRatio, imageSize, sessionID, assetGroupID, refImages)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
artifactURL, err := c.pollTask(ctx, directClient, token, teamID, taskID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
meta := map[string]any{
|
||||
"provider": "runway",
|
||||
"task_id": taskID,
|
||||
"team_id": teamID,
|
||||
"image_url": artifactURL,
|
||||
}
|
||||
// downloadResult=false (API-key url-only mode): return the upstream artifact
|
||||
// URL without downloading the PNG.
|
||||
if !downloadResult {
|
||||
return nil, meta, nil
|
||||
}
|
||||
data, err := c.download(ctx, directClient, artifactURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return data, meta, nil
|
||||
}
|
||||
|
||||
// uploadReference uploads one reference image through the dataset pipeline
|
||||
// (DATASET_PREVIEW + DATASET uploads → /v1/datasets) and returns its asset id
|
||||
// (= dataset id) and the cloudfront URL the task references.
|
||||
func (c *Client) uploadReference(ctx context.Context, client tlsclient.HttpClient, token, teamID, filename string, data []byte) (string, string, error) {
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return "", "", errors.New("runway: failed to decode reference image")
|
||||
}
|
||||
previewUploadID, _, err := c.uploadFile(ctx, client, token, teamID, filename, "DATASET_PREVIEW", data)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
// The DATASET upload's completed URL is exactly what the task references.
|
||||
datasetUploadID, refURL, err := c.uploadFile(ctx, client, token, teamID, filename, "DATASET", data)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
assetID, _, err := c.createDataset(ctx, client, token, teamID, filename, datasetUploadID, previewUploadID, cfg.Width, cfg.Height)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return assetID, refURL, nil
|
||||
}
|
||||
|
||||
// createImageTask submits a gemini image task (workflow_gemini_image for Pro,
|
||||
// gemini_3_1_flash_image for Nano Banana 2) INTO the pre-created session and
|
||||
// returns its id. sessionID must be a real server-side session (see
|
||||
// createSession) and assetGroupID must be that session's own group.
|
||||
func (c *Client) createImageTask(ctx context.Context, client tlsclient.HttpClient, token, teamID, modelID, prompt, aspectRatio, imageSize, sessionID, assetGroupID string, refImages []map[string]any) (string, error) {
|
||||
if strings.TrimSpace(aspectRatio) == "" {
|
||||
aspectRatio = "16:9"
|
||||
}
|
||||
taskType := "workflow_gemini_image"
|
||||
opts := map[string]any{
|
||||
"name": "Nano Banana Pro - " + prompt,
|
||||
"text_prompt": prompt,
|
||||
"aspect_ratio": aspectRatio,
|
||||
"num_images": 1,
|
||||
"image_size": imageSize,
|
||||
"model": "gemini-3-pro-image-preview",
|
||||
"exploreMode": false,
|
||||
"creationSource": "tool-mode",
|
||||
}
|
||||
if modelID == "runway-nano-banana-2" {
|
||||
taskType = "gemini_3_1_flash_image"
|
||||
opts["name"] = "Nano Banana 2 - " + prompt
|
||||
opts["model"] = "gemini-3.1-flash-image-preview"
|
||||
}
|
||||
if assetGroupID != "" {
|
||||
opts["assetGroupId"] = assetGroupID
|
||||
}
|
||||
if len(refImages) > 0 {
|
||||
opts["reference_images"] = refImages
|
||||
}
|
||||
// Pre-flight cost estimate, exactly as the web app does before every spend.
|
||||
c.estimateCost(ctx, client, token, teamID, "gemini_image", opts)
|
||||
|
||||
res, err := c.submitTask(ctx, client, token, teamID, map[string]any{
|
||||
"taskType": taskType,
|
||||
"options": opts,
|
||||
"asTeamId": jsonNumberOrString(teamID),
|
||||
"sessionId": sessionID,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
task, _ := res["task"].(map[string]any)
|
||||
id := strings.TrimSpace(stringValue(task["id"]))
|
||||
if id == "" {
|
||||
return "", fmt.Errorf("%w: image task missing id", ErrTemporaryUpstream)
|
||||
}
|
||||
// Post-submit: generation record + session play (session already exists).
|
||||
c.recordGeneration(ctx, client, token, teamID, id, sessionID, prompt, opts)
|
||||
return id, nil
|
||||
}
|
||||
@@ -1,516 +0,0 @@
|
||||
package runway
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
mrand "math/rand/v2"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
http "github.com/bogdanfinn/fhttp"
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
)
|
||||
|
||||
// ratioDimensions maps an aspect ratio to the Gen-4 Turbo native output size.
|
||||
// These are the only dimensions gen4_turbo accepts; "2K" is a UI label over this
|
||||
// native tier (see runway-video-gen-spec). Unknown ratios fall back to 16:9.
|
||||
func ratioDimensions(aspectRatio string) (int, int) {
|
||||
switch strings.TrimSpace(strings.ReplaceAll(aspectRatio, "x", ":")) {
|
||||
case "16:9":
|
||||
return 1280, 720
|
||||
case "9:16":
|
||||
return 720, 1280
|
||||
case "1:1":
|
||||
return 960, 960
|
||||
case "4:3":
|
||||
return 1104, 832
|
||||
case "3:4":
|
||||
return 832, 1104
|
||||
case "21:9":
|
||||
return 1584, 672
|
||||
default:
|
||||
return 1280, 720
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateVideo runs the full i2v pipeline (gen_video.py): upload the first-frame
|
||||
// image (preview + dataset), create a dataset, create a gen4_turbo task and poll
|
||||
// it to completion, then download the rendered MP4. teamID is the workspace id
|
||||
// (meta["team_id"]); if empty it's derived from the token. seconds must be 5 or
|
||||
// 10; aspectRatio picks the native output size.
|
||||
// GenerateVideo renders the clip and (when downloadResult) downloads the MP4.
|
||||
// With downloadResult=false it returns nil bytes and the upstream artifact URL in
|
||||
// meta["video_url"] — used by the async /v1/videos job, which proxies that URL on
|
||||
// /content instead of persisting the file.
|
||||
func (c *Client) GenerateVideo(ctx context.Context, token, teamID, prompt, aspectRatio string, seconds int, frame []byte, downloadResult bool) ([]byte, map[string]any, error) {
|
||||
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
|
||||
if token == "" {
|
||||
return nil, nil, ErrAuth
|
||||
}
|
||||
if teamID == "" {
|
||||
teamID = TeamIDFromToken(token)
|
||||
}
|
||||
if teamID == "" {
|
||||
return nil, nil, errors.New("runway: no team id")
|
||||
}
|
||||
if len(frame) == 0 {
|
||||
return nil, nil, errors.New("runway: first-frame image required")
|
||||
}
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(frame))
|
||||
if err != nil {
|
||||
return nil, nil, errors.New("runway: failed to decode first-frame image")
|
||||
}
|
||||
|
||||
// Only the task-create (generate submit) egresses via the proxy; reference
|
||||
// upload, dataset create, polling and download run on the local IP (matches
|
||||
// the image pipeline).
|
||||
submitClient, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
directClient, err := c.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
filename := "frame_" + time.Now().UTC().Format("20060102_150405") + ".png"
|
||||
previewUploadID, _, err := c.uploadFile(ctx, directClient, token, teamID, filename, "DATASET_PREVIEW", frame)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
datasetUploadID, _, err := c.uploadFile(ctx, directClient, token, teamID, filename, "DATASET", frame)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
assetID, imageURL, err := c.createDataset(ctx, directClient, token, teamID, filename, datasetUploadID, previewUploadID, cfg.Width, cfg.Height)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// Browser order: real session first, attach the first-frame asset + its own
|
||||
// asset group, THEN submit the task into it (see createSession).
|
||||
sessionID, err := c.createSession(ctx, submitClient, token, teamID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
c.attachReference(ctx, submitClient, token, teamID, sessionID, assetID)
|
||||
assetGroupID, _ := c.sessionAssetGroup(ctx, submitClient, token, teamID, sessionID) // best-effort
|
||||
|
||||
taskID, err := c.createTask(ctx, submitClient, token, teamID, prompt, imageURL, assetID, assetGroupID, sessionID, aspectRatio, seconds)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
artifactURL, err := c.pollTask(ctx, directClient, token, teamID, taskID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
meta := map[string]any{
|
||||
"provider": "runway",
|
||||
"task_id": taskID,
|
||||
"team_id": teamID,
|
||||
"video_url": artifactURL,
|
||||
}
|
||||
if !downloadResult {
|
||||
return nil, meta, nil
|
||||
}
|
||||
data, err := c.download(ctx, directClient, artifactURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return data, meta, nil
|
||||
}
|
||||
|
||||
// uploadFile mirrors gen_video.upload_file: register the upload, PUT the bytes to
|
||||
// the returned S3 URL, then complete. Returns the upload id and final url.
|
||||
func (c *Client) uploadFile(ctx context.Context, client tlsclient.HttpClient, token, teamID, filename, uploadType string, data []byte) (string, string, error) {
|
||||
info, err := c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/uploads", map[string]any{
|
||||
"filename": filename,
|
||||
"numberOfParts": 1,
|
||||
"type": uploadType,
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
uploadID := strings.TrimSpace(stringValue(info["id"]))
|
||||
urls, _ := info["uploadUrls"].([]any)
|
||||
if uploadID == "" || len(urls) == 0 {
|
||||
return "", "", fmt.Errorf("%w: upload register missing fields", ErrTemporaryUpstream)
|
||||
}
|
||||
putURL := strings.TrimSpace(stringValue(urls[0]))
|
||||
contentType := "application/octet-stream"
|
||||
if hdrs, ok := info["uploadHeaders"].(map[string]any); ok {
|
||||
if ct := strings.TrimSpace(stringValue(hdrs["Content-Type"])); ct != "" {
|
||||
contentType = ct
|
||||
}
|
||||
}
|
||||
|
||||
etag, err := c.putBytes(ctx, client, putURL, contentType, data)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
res, err := c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/uploads/"+uploadID+"/complete", map[string]any{
|
||||
"parts": []map[string]any{{"PartNumber": 1, "ETag": etag}},
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return uploadID, strings.TrimSpace(stringValue(res["url"])), nil
|
||||
}
|
||||
|
||||
func (c *Client) createDataset(ctx context.Context, client tlsclient.HttpClient, token, teamID, filename, datasetUploadID, previewUploadID string, w, h int) (string, string, error) {
|
||||
teamIDNum := jsonNumberOrString(teamID)
|
||||
res, err := c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/datasets", map[string]any{
|
||||
"fileCount": 1,
|
||||
"name": filename,
|
||||
"uploadId": datasetUploadID,
|
||||
"previewUploadIds": []string{previewUploadID},
|
||||
"metadata": map[string]any{"size": map[string]any{"width": w, "height": h}},
|
||||
"type": map[string]any{"name": "image", "type": "image", "isDirectory": false},
|
||||
"asTeamId": teamIDNum,
|
||||
"privateInTeam": true,
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
ds, _ := res["dataset"].(map[string]any)
|
||||
id := strings.TrimSpace(stringValue(ds["id"]))
|
||||
url := strings.TrimSpace(stringValue(ds["url"]))
|
||||
if id == "" || url == "" {
|
||||
return "", "", fmt.Errorf("%w: dataset missing fields", ErrTemporaryUpstream)
|
||||
}
|
||||
return id, url, nil
|
||||
}
|
||||
|
||||
func (c *Client) assetGroupID(ctx context.Context, client tlsclient.HttpClient, token, teamID string) (string, error) {
|
||||
res, err := c.apiJSON(ctx, client, token, teamID, http.MethodGet,
|
||||
"/v1/asset_groups/by_name?name=Generations&asTeamId="+teamID+"&privateInTeam=true", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ag, _ := res["assetGroup"].(map[string]any)
|
||||
return strings.TrimSpace(stringValue(ag["id"])), nil
|
||||
}
|
||||
|
||||
func (c *Client) createTask(ctx context.Context, client tlsclient.HttpClient, token, teamID, prompt, imageURL, assetID, assetGroupID, sessionID, aspectRatio string, seconds int) (string, error) {
|
||||
w, h := ratioDimensions(aspectRatio)
|
||||
opts := map[string]any{
|
||||
"route": "i2v",
|
||||
"name": "Gen-4 Turbo - " + prompt,
|
||||
"text_prompt": prompt,
|
||||
"seconds": seconds,
|
||||
"width": w,
|
||||
"height": h,
|
||||
"init_image": imageURL,
|
||||
"imageAssetId": assetID,
|
||||
"exploreMode": false,
|
||||
"creationSource": "tool-mode",
|
||||
"seed": mrand.IntN(999999999) + 1,
|
||||
"watermark": true,
|
||||
}
|
||||
if assetGroupID != "" {
|
||||
opts["assetGroupId"] = assetGroupID
|
||||
}
|
||||
res, err := c.submitTask(ctx, client, token, teamID, map[string]any{
|
||||
"taskType": "gen4_turbo",
|
||||
"options": opts,
|
||||
"asTeamId": jsonNumberOrString(teamID),
|
||||
"sessionId": sessionID,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
task, _ := res["task"].(map[string]any)
|
||||
id := strings.TrimSpace(stringValue(task["id"]))
|
||||
if id == "" {
|
||||
return "", fmt.Errorf("%w: task missing id", ErrTemporaryUpstream)
|
||||
}
|
||||
// Post-submit: generation record + session play (session already exists).
|
||||
c.recordGeneration(ctx, client, token, teamID, id, sessionID, prompt, opts)
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (c *Client) pollTask(ctx context.Context, client tlsclient.HttpClient, token, teamID, taskID string) (string, error) {
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
res, err := c.apiJSON(ctx, client, token, teamID, http.MethodGet, "/v1/tasks/"+taskID+"?asTeamId="+teamID, nil)
|
||||
if err != nil {
|
||||
// A transient blip shouldn't kill a render that may still succeed.
|
||||
if errors.Is(err, ErrTemporaryUpstream) {
|
||||
if sleepCtx(ctx, 5*time.Second) != nil {
|
||||
return "", ctx.Err()
|
||||
}
|
||||
continue
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
task, _ := res["task"].(map[string]any)
|
||||
status := strings.ToUpper(strings.TrimSpace(stringValue(task["status"])))
|
||||
switch status {
|
||||
case "SUCCEEDED":
|
||||
arts, _ := task["artifacts"].([]any)
|
||||
for _, raw := range arts {
|
||||
art, _ := raw.(map[string]any)
|
||||
if url := strings.TrimSpace(stringValue(art["url"])); url != "" {
|
||||
return url, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("runway: task succeeded with no artifact url")
|
||||
case "FAILED", "CANCELED":
|
||||
reason := strings.TrimSpace(stringValue(task["error"]))
|
||||
if isCreditError(reason) {
|
||||
return "", fmt.Errorf("%w: %s", ErrQuotaExhausted, reason)
|
||||
}
|
||||
return "", fmt.Errorf("runway: task %s: %s", status, reason)
|
||||
}
|
||||
if sleepCtx(ctx, 5*time.Second) != nil {
|
||||
return "", ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// estimateCost fires the /v1/billing/estimate_feature_cost_credits pre-flight the
|
||||
// web app ALWAYS does before a spend (52 times in the reference HAR, always with
|
||||
// the exact taskOptions right before /v1/tasks). It returns no reservation token,
|
||||
// so its purpose is purely to mark the spend as coming from the real UI flow;
|
||||
// submitting a task with no preceding estimate for that spec is a scripted-abuse
|
||||
// tell. Best-effort — the estimate itself costs nothing.
|
||||
func (c *Client) estimateCost(ctx context.Context, client tlsclient.HttpClient, token, teamID, feature string, taskOptions map[string]any) {
|
||||
_, _ = c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/billing/estimate_feature_cost_credits", map[string]any{
|
||||
"feature": feature,
|
||||
"count": 1,
|
||||
"asTeamId": jsonNumberOrString(teamID),
|
||||
"taskOptions": taskOptions,
|
||||
})
|
||||
}
|
||||
|
||||
// createSession creates an EMPTY tool-mode session (POST /v1/sessions with an
|
||||
// empty taskIds array) and returns its real server-side id. This is the crux of
|
||||
// the whole flow: the browser creates the session BEFORE submitting the first
|
||||
// task, then submits the task INTO that real session. Submitting /v1/tasks with a
|
||||
// sessionId that was never created server-side is a forgery tell that makes
|
||||
// Runway revoke the account's free credits (permitted numPlanCredits 500 -> 0)
|
||||
// the instant the task lands ("提交生图就清零"). Note taskIds must be [] — passing
|
||||
// a bogus id yields "Invalid task IDs".
|
||||
func (c *Client) createSession(ctx context.Context, client tlsclient.HttpClient, token, teamID string) (string, error) {
|
||||
res, err := c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/sessions", map[string]any{
|
||||
"asTeamId": jsonNumberOrString(teamID),
|
||||
"taskIds": []string{},
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sess, _ := res["session"].(map[string]any)
|
||||
id := strings.TrimSpace(stringValue(sess["id"]))
|
||||
if id == "" {
|
||||
return "", fmt.Errorf("%w: session missing id", ErrTemporaryUpstream)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// attachReference attaches an uploaded asset to the session (the browser does
|
||||
// this before the task, so the task's reference_images belong to a real session).
|
||||
func (c *Client) attachReference(ctx context.Context, client tlsclient.HttpClient, token, teamID, sessionID, assetID string) {
|
||||
if strings.TrimSpace(assetID) == "" {
|
||||
return
|
||||
}
|
||||
_, _ = c.apiJSON(ctx, client, token, teamID, http.MethodPost,
|
||||
"/v1/sessions/"+sessionID+"/references", map[string]any{
|
||||
"assetId": assetID,
|
||||
"asTeamId": jsonNumberOrString(teamID),
|
||||
})
|
||||
}
|
||||
|
||||
// sessionAssetGroup creates the session's OWN asset group (POST
|
||||
// /v1/sessions/{id}/assetGroup) and returns its id — this, NOT the account-wide
|
||||
// "Generations" group, is the assetGroupId the browser puts on the task.
|
||||
func (c *Client) sessionAssetGroup(ctx context.Context, client tlsclient.HttpClient, token, teamID, sessionID string) (string, error) {
|
||||
res, err := c.apiJSON(ctx, client, token, teamID, http.MethodPost,
|
||||
"/v1/sessions/"+sessionID+"/assetGroup", map[string]any{"asTeamId": jsonNumberOrString(teamID)})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ag, _ := res["assetGroup"].(map[string]any)
|
||||
return strings.TrimSpace(stringValue(ag["id"])), nil
|
||||
}
|
||||
|
||||
// recordGeneration is the post-submit lifecycle the browser fires AFTER the task
|
||||
// lands: the generation record (POST /v1/generations, recordingEnabled=true) and
|
||||
// a session /play. The session itself already exists (createSession ran before
|
||||
// the task), so this no longer creates it. Best-effort.
|
||||
func (c *Client) recordGeneration(ctx context.Context, client tlsclient.HttpClient, token, teamID, taskID, sessionID, prompt string, taskOptions map[string]any) {
|
||||
settings := map[string]any{"taskId": taskID, "recordingEnabled": true}
|
||||
for k, v := range taskOptions {
|
||||
if k == "exploreMode" { // not present on the browser's generation record
|
||||
continue
|
||||
}
|
||||
settings[k] = v
|
||||
}
|
||||
// The generation record carries the model id WITHOUT the "-preview" suffix
|
||||
// the task uses (gemini-3-pro-image-preview -> gemini-3-pro-image in the HAR).
|
||||
if m, ok := settings["model"].(string); ok {
|
||||
settings["model"] = strings.TrimSuffix(m, "-preview")
|
||||
}
|
||||
_, _ = c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/generations", map[string]any{
|
||||
"toolId": "generate",
|
||||
"prompt": prompt,
|
||||
"outputs": map[string]any{"outputUrls": []any{}},
|
||||
"settings": settings,
|
||||
})
|
||||
_, _ = c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/sessions/"+sessionID+"/play",
|
||||
map[string]any{"asTeamId": jsonNumberOrString(teamID), "taskId": taskID})
|
||||
}
|
||||
|
||||
// submitTask POSTs a /v1/tasks create. Transient (network / 5xx) failures
|
||||
// surface as ErrTemporaryUpstream so the pool fails over to the NEXT account
|
||||
// (换号重试) instead of retrying this one.
|
||||
func (c *Client) submitTask(ctx context.Context, client tlsclient.HttpClient, token, teamID string, body map[string]any) (map[string]any, error) {
|
||||
return c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/tasks", body)
|
||||
}
|
||||
|
||||
// apiJSON performs an authed JSON request against the Runway API and returns the
|
||||
// parsed body, mapping status codes to the shared provider error sentinels.
|
||||
func (c *Client) apiJSON(ctx context.Context, client tlsclient.HttpClient, token, teamID, method, path string, body any) (map[string]any, error) {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
raw, _ := json.Marshal(body)
|
||||
reader = bytes.NewReader(raw)
|
||||
}
|
||||
req, err := http.NewRequest(method, apiBase+path, reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = browserHeaders(token, teamID)
|
||||
// The generate-submit endpoint is the ONE authed call the real web bundle
|
||||
// sends WITHOUT x-runway-client-id / source-application[-version] — every
|
||||
// other endpoint carries them, /v1/tasks carries only x-runway-workspace
|
||||
// (verified across the whole reference HAR). Leaving them on /v1/tasks is a
|
||||
// bot tell that gets the account's free credits revoked (permitted
|
||||
// numPlanCredits 500 -> 0) the instant a task is submitted. Strip them here so
|
||||
// the submit matches the browser exactly.
|
||||
if method == http.MethodPost && path == "/v1/tasks" {
|
||||
// NOTE: raw map delete, NOT req.Header.Del() — fhttp canonicalizes the key
|
||||
// in Del() ("X-Runway-Client-Id") but our headers are stored lowercase (so
|
||||
// they go on the h2 wire lowercase like Chrome), so Del() silently no-ops.
|
||||
delete(req.Header, "x-runway-client-id")
|
||||
delete(req.Header, "x-runway-source-application")
|
||||
delete(req.Header, "x-runway-source-application-version")
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch {
|
||||
case resp.StatusCode == 401 || resp.StatusCode == 403:
|
||||
// Rate-limit (403) is treated as a dead account too, same as a 401.
|
||||
return nil, fmt.Errorf("%w: %s %d %s", ErrAuth, path, resp.StatusCode, clip(raw, 200))
|
||||
case resp.StatusCode == 429:
|
||||
return nil, fmt.Errorf("%w: %s 429 %s", ErrQuotaExhausted, path, clip(raw, 200))
|
||||
case resp.StatusCode >= 500:
|
||||
return nil, fmt.Errorf("%w: %s %d %s", ErrTemporaryUpstream, path, resp.StatusCode, clip(raw, 200))
|
||||
case resp.StatusCode < 200 || resp.StatusCode >= 300:
|
||||
if isCreditError(string(raw)) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrQuotaExhausted, clip(raw, 200))
|
||||
}
|
||||
return nil, fmt.Errorf("runway: %s %d %s", path, resp.StatusCode, clip(raw, 200))
|
||||
}
|
||||
var out map[string]any
|
||||
if len(raw) == 0 {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, fmt.Errorf("%w: %s non-json: %s", ErrTemporaryUpstream, path, clip(raw, 120))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// putBytes uploads raw bytes to a presigned S3 URL (no auth) and returns the
|
||||
// ETag, mirroring the plain requests.Session().put in gen_video.py.
|
||||
func (c *Client) putBytes(ctx context.Context, client tlsclient.HttpClient, url, contentType string, data []byte) (string, error) {
|
||||
req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = browserAssetHeaders()
|
||||
req.Header.Set("content-type", contentType)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("%w: s3 put %d", ErrTemporaryUpstream, resp.StatusCode)
|
||||
}
|
||||
return strings.Trim(resp.Header.Get("ETag"), `"`), nil
|
||||
}
|
||||
|
||||
func (c *Client) download(ctx context.Context, client tlsclient.HttpClient, url string) ([]byte, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = browserAssetHeaders()
|
||||
req.Header.Set("accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
|
||||
req.Header.Set("sec-fetch-dest", "image")
|
||||
req.Header.Set("sec-fetch-mode", "no-cors")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("%w: download %d", ErrTemporaryUpstream, resp.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, errors.New("runway: empty artifact download")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// jsonNumberOrString returns the team id as a JSON number when it's purely
|
||||
// numeric (Runway's asTeamId is an integer in the reference payloads), else the
|
||||
// raw string.
|
||||
func jsonNumberOrString(teamID string) any {
|
||||
return json.Number(strings.TrimSpace(teamID))
|
||||
}
|
||||
|
||||
func isCreditError(s string) bool {
|
||||
s = strings.ToLower(s)
|
||||
return strings.Contains(s, "credit") || strings.Contains(s, "insufficient") || strings.Contains(s, "quota")
|
||||
}
|
||||
|
||||
// sleepCtx sleeps for d or until ctx is done; returns ctx.Err() if cancelled.
|
||||
func sleepCtx(ctx context.Context, d time.Duration) error {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-t.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"backend/internal/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type ChannelRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewChannelRepository(db *gorm.DB) *ChannelRepository {
|
||||
return &ChannelRepository{db: db}
|
||||
}
|
||||
|
||||
// ChannelWithUpstream pairs a channel row with the upstream model name a
|
||||
// binding carries — the shape dispatch needs in one fetch.
|
||||
type ChannelWithUpstream struct {
|
||||
Channel model.Channel
|
||||
UpstreamModel string
|
||||
}
|
||||
|
||||
func (r *ChannelRepository) List(ctx context.Context) ([]model.Channel, error) {
|
||||
var items []model.Channel
|
||||
if err := r.db.WithContext(ctx).Order("created_at asc").Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *ChannelRepository) Get(ctx context.Context, id string) (*model.Channel, error) {
|
||||
var item model.Channel
|
||||
if err := r.db.WithContext(ctx).First(&item, "id = ?", id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *ChannelRepository) Create(ctx context.Context, item *model.Channel) error {
|
||||
return r.db.WithContext(ctx).Create(item).Error
|
||||
}
|
||||
|
||||
func (r *ChannelRepository) Update(ctx context.Context, id string, patch map[string]any) (*model.Channel, error) {
|
||||
patch["updated_at"] = time.Now()
|
||||
if err := r.db.WithContext(ctx).Model(&model.Channel{}).Where("id = ?", id).Updates(patch).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (r *ChannelRepository) Delete(ctx context.Context, id string) (int64, error) {
|
||||
res := r.db.WithContext(ctx).Delete(&model.Channel{}, "id = ?", id)
|
||||
return res.RowsAffected, res.Error
|
||||
}
|
||||
|
||||
// Bindings returns all channel_models rows for a channel.
|
||||
func (r *ChannelRepository) Bindings(ctx context.Context, channelID string) ([]model.ChannelModel, error) {
|
||||
var items []model.ChannelModel
|
||||
if err := r.db.WithContext(ctx).Where("channel_id = ?", channelID).Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// Bind upserts one channel↔model binding (upstream model name is refreshed on
|
||||
// conflict so re-import can rename it).
|
||||
func (r *ChannelRepository) Bind(ctx context.Context, channelID, modelID, upstreamModel string) error {
|
||||
row := model.ChannelModel{ChannelID: channelID, ModelID: modelID, UpstreamModel: upstreamModel}
|
||||
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "channel_id"}, {Name: "model_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"upstream_model", "updated_at"}),
|
||||
}).Create(&row).Error
|
||||
}
|
||||
|
||||
func (r *ChannelRepository) Unbind(ctx context.Context, channelID, modelID string) error {
|
||||
return r.db.WithContext(ctx).Delete(&model.ChannelModel{}, "channel_id = ? AND model_id = ?", channelID, modelID).Error
|
||||
}
|
||||
|
||||
func (r *ChannelRepository) DeleteBindingsByChannel(ctx context.Context, channelID string) error {
|
||||
return r.db.WithContext(ctx).Delete(&model.ChannelModel{}, "channel_id = ?", channelID).Error
|
||||
}
|
||||
|
||||
func (r *ChannelRepository) DeleteBindingsByModel(ctx context.Context, modelID string) error {
|
||||
return r.db.WithContext(ctx).Delete(&model.ChannelModel{}, "model_id = ?", modelID).Error
|
||||
}
|
||||
|
||||
// ForModel returns enabled channels bound to modelID (channel rows + the
|
||||
// upstream model name each binding carries), stable-ordered for dispatch.
|
||||
func (r *ChannelRepository) ForModel(ctx context.Context, modelID string) ([]ChannelWithUpstream, error) {
|
||||
var rows []struct {
|
||||
model.Channel
|
||||
UpstreamModel string `gorm:"column:upstream_model"`
|
||||
}
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("channels").
|
||||
Select("channels.*, channel_models.upstream_model").
|
||||
Joins("JOIN channel_models ON channel_models.channel_id = channels.id").
|
||||
Where("channel_models.model_id = ? AND channels.enabled = ?", modelID, true).
|
||||
Order("channels.id asc").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ChannelWithUpstream, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, ChannelWithUpstream{Channel: row.Channel, UpstreamModel: row.UpstreamModel})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ForModelAnyState is ForModel without the enabled filter — used by admin
|
||||
// channel-pinned tests which deliberately target one channel even when it's
|
||||
// disabled for traffic.
|
||||
func (r *ChannelRepository) ForModelAnyState(ctx context.Context, modelID string) ([]ChannelWithUpstream, error) {
|
||||
var rows []struct {
|
||||
model.Channel
|
||||
UpstreamModel string `gorm:"column:upstream_model"`
|
||||
}
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("channels").
|
||||
Select("channels.*, channel_models.upstream_model").
|
||||
Joins("JOIN channel_models ON channel_models.channel_id = channels.id").
|
||||
Where("channel_models.model_id = ?", modelID).
|
||||
Order("channels.id asc").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ChannelWithUpstream, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, ChannelWithUpstream{Channel: row.Channel, UpstreamModel: row.UpstreamModel})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ModelIDsByChannel lists local model ids bound to a channel.
|
||||
func (r *ChannelRepository) ModelIDsByChannel(ctx context.Context, channelID string) ([]string, error) {
|
||||
var ids []string
|
||||
err := r.db.WithContext(ctx).Model(&model.ChannelModel{}).
|
||||
Where("channel_id = ?", channelID).Pluck("model_id", &ids).Error
|
||||
return ids, err
|
||||
}
|
||||
|
||||
// UpstreamModelMap returns model_id → upstream_model for a channel.
|
||||
func (r *ChannelRepository) UpstreamModelMap(ctx context.Context, channelID string) (map[string]string, error) {
|
||||
var rows []model.ChannelModel
|
||||
if err := r.db.WithContext(ctx).Where("channel_id = ?", channelID).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]string, len(rows))
|
||||
for _, row := range rows {
|
||||
out[row.ModelID] = row.UpstreamModel
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ChannelIDsByModel lists channel ids bound to a model (any enabled state).
|
||||
func (r *ChannelRepository) ChannelIDsByModel(ctx context.Context, modelID string) ([]string, error) {
|
||||
var ids []string
|
||||
err := r.db.WithContext(ctx).Model(&model.ChannelModel{}).
|
||||
Where("model_id = ?", modelID).Pluck("channel_id", &ids).Error
|
||||
return ids, err
|
||||
}
|
||||
|
||||
// OrphanModelIDs returns local model ids that no channel binds anymore.
|
||||
func (r *ChannelRepository) OrphanModelIDs(ctx context.Context) ([]string, error) {
|
||||
var ids []string
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("model_configs").
|
||||
Select("model_configs.id").
|
||||
Joins("LEFT JOIN channel_models ON channel_models.model_id = model_configs.id").
|
||||
Where("channel_models.model_id IS NULL").
|
||||
Scan(&ids).Error
|
||||
return ids, err
|
||||
}
|
||||
|
||||
// BumpSuccess records one successful upstream call (count + reported credits
|
||||
// spent + last-used timestamp). Best-effort.
|
||||
func (r *ChannelRepository) BumpSuccess(ctx context.Context, channelID string, credits float64) error {
|
||||
now := time.Now()
|
||||
return r.db.WithContext(ctx).Model(&model.Channel{}).Where("id = ?", channelID).Updates(map[string]any{
|
||||
"success_total": gorm.Expr("success_total + 1"),
|
||||
"used_credits": gorm.Expr("used_credits + ?", credits),
|
||||
"last_used_at": now,
|
||||
}).Error
|
||||
}
|
||||
@@ -24,8 +24,8 @@ type EventListFilter struct {
|
||||
UserID string
|
||||
UserIDs []string // when set, keep ONLY rows whose user_id is in this list (admin 用户搜索)
|
||||
Query string // free-text search over prompt / model / error (server-side, 跨页)
|
||||
ExcludeSource string // when set, omit rows with this source (e.g. hide API-key "v1" usage from the customer logs page)
|
||||
Source string // when set, keep ONLY rows with this source (admin 来源 filter): "v1" (API key) / "user" (前台) / "admin" (测试模型)
|
||||
ExcludeSource string // when set, omit rows with this source (e.g. hide API-key "v1" usage from the customer logs page)
|
||||
Source string // when set, keep ONLY rows with this source (admin 来源 filter): "v1" (API key) / "user" (前台) / "admin" (测试模型)
|
||||
HasFile bool // when true, keep ONLY rows with a non-empty file (the 创作记录 gallery — paginates over real media)
|
||||
ExcludeFiles []string // when set, omit rows whose file is in this list (e.g. hide homepage showcase media from user galleries)
|
||||
MediaOnly bool // when true, keep only rows that are pending or have a stored file — the 画图台 grid, so deleted works don't eat a slot
|
||||
@@ -618,9 +618,16 @@ func (r *EventRepository) MarkRefunded(ctx context.Context, eventID string) (boo
|
||||
return res.RowsAffected == 1, nil
|
||||
}
|
||||
|
||||
// SetAccount stamps which provider account is fulfilling an in-flight event.
|
||||
// Called when generation commits to a token, so the accounts view can count
|
||||
// pending events per account and an abandoned-event purge can attribute back.
|
||||
// SetUpstreamJob records the job id the upstream assigned (NDJSON jobId frame)
|
||||
// for cross-instance debugging. Best-effort.
|
||||
func (r *EventRepository) SetUpstreamJob(ctx context.Context, eventID, jobID string) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&model.EventLog{}).
|
||||
Where("id = ?", eventID).
|
||||
Update("upstream_job", jobID).Error
|
||||
}
|
||||
|
||||
// SetAccount stamps which channel is fulfilling an in-flight event.
|
||||
func (r *EventRepository) SetAccount(ctx context.Context, eventID, accountID, accountEmail string) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&model.EventLog{}).
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"backend/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type RefreshProfileRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRefreshProfileRepository(db *gorm.DB) *RefreshProfileRepository {
|
||||
return &RefreshProfileRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *RefreshProfileRepository) List(ctx context.Context) ([]model.RefreshProfile, error) {
|
||||
var items []model.RefreshProfile
|
||||
if err := r.db.WithContext(ctx).
|
||||
Order("created_at desc").
|
||||
Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *RefreshProfileRepository) Get(ctx context.Context, id string) (*model.RefreshProfile, error) {
|
||||
var item model.RefreshProfile
|
||||
if err := r.db.WithContext(ctx).First(&item, "id = ?", id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *RefreshProfileRepository) Create(ctx context.Context, item *model.RefreshProfile) error {
|
||||
return r.db.WithContext(ctx).Create(item).Error
|
||||
}
|
||||
|
||||
func (r *RefreshProfileRepository) Update(ctx context.Context, id string, patch map[string]any) (*model.RefreshProfile, error) {
|
||||
patch["updated_at"] = time.Now()
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&model.RefreshProfile{}).
|
||||
Where("id = ?", id).
|
||||
Updates(patch).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (r *RefreshProfileRepository) Delete(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).Delete(&model.RefreshProfile{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
func (r *RefreshProfileRepository) DeleteByIDs(ctx context.Context, ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.WithContext(ctx).Delete(&model.RefreshProfile{}, "id IN ?", ids).Error
|
||||
}
|
||||
|
||||
// ListDue returns enabled profiles whose next_retry_at has passed (or is unset,
|
||||
// e.g. freshly imported). The background maintenance loop refreshes these.
|
||||
func (r *RefreshProfileRepository) ListDue(ctx context.Context, now time.Time) ([]model.RefreshProfile, error) {
|
||||
var items []model.RefreshProfile
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("enabled = ? AND (next_retry_at IS NULL OR next_retry_at <= ?)", true, now).
|
||||
Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -1,529 +0,0 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backend/internal/model"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type TokenRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewTokenRepository(db *gorm.DB) *TokenRepository {
|
||||
return &TokenRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *TokenRepository) List(ctx context.Context) ([]model.TokenAccount, error) {
|
||||
var items []model.TokenAccount
|
||||
if err := r.db.WithContext(ctx).
|
||||
Order("pool asc, created_at desc").
|
||||
Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *TokenRepository) ListByPool(ctx context.Context, pool string) ([]model.TokenAccount, error) {
|
||||
var items []model.TokenAccount
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("pool = ?", pool).
|
||||
Order("created_at desc").
|
||||
Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *TokenRepository) Get(ctx context.Context, pool, id string) (*model.TokenAccount, error) {
|
||||
var item model.TokenAccount
|
||||
if err := r.db.WithContext(ctx).
|
||||
First(&item, "pool = ? AND id = ?", pool, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
// GetByPoolEmail finds an account in a pool by its account_email (the logical
|
||||
// identity for import dedup). Returns (nil, nil) when none / email is blank.
|
||||
func (r *TokenRepository) GetByPoolEmail(ctx context.Context, pool, email string) (*model.TokenAccount, error) {
|
||||
email = strings.TrimSpace(email)
|
||||
if email == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var item model.TokenAccount
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("pool = ? AND account_email = ?", pool, email).
|
||||
First(&item).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *TokenRepository) Create(ctx context.Context, item *model.TokenAccount) error {
|
||||
return r.db.WithContext(ctx).Create(item).Error
|
||||
}
|
||||
|
||||
func (r *TokenRepository) Update(ctx context.Context, pool, id string, patch map[string]any) (*model.TokenAccount, error) {
|
||||
patch["updated_at"] = time.Now()
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&model.TokenAccount{}).
|
||||
Where("pool = ? AND id = ?", pool, id).
|
||||
Updates(patch).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Get(ctx, pool, id)
|
||||
}
|
||||
|
||||
// SwapValue replaces an account's credential only while the stored one is still
|
||||
// the value the caller started from. A rotating cookie is minted from whatever
|
||||
// was in the row, so a goroutine that has been holding an older copy (a long
|
||||
// render, a slow quota probe) must NOT be allowed to write it back over a newer
|
||||
// rotation — that older copy no longer authenticates, and the account then looks
|
||||
// dead. Reports whether the row was updated.
|
||||
func (r *TokenRepository) SwapValue(ctx context.Context, pool, id, from, to string) (bool, error) {
|
||||
res := r.db.WithContext(ctx).
|
||||
Model(&model.TokenAccount{}).
|
||||
Where("pool = ? AND id = ? AND value = ?", pool, id, from).
|
||||
Updates(map[string]any{"value": to, "updated_at": time.Now()})
|
||||
if res.Error != nil {
|
||||
return false, res.Error
|
||||
}
|
||||
return res.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
// ReserveQuota atomically pre-deducts `amount` from an account's cached image
|
||||
// token balance under a row lock, so concurrent picks of the same near-empty
|
||||
// account can never over-commit it. Returns:
|
||||
// - allowed=true, deducted=true: balance was known and ≥ amount → decremented.
|
||||
// - allowed=true, deducted=false: balance unknown → allowed without a hold
|
||||
// (benefit of the doubt; a post-render reconcile writes the real value).
|
||||
// - allowed=false: balance known and < amount → caller should fail over.
|
||||
// RefundQuota releases a hold made with deducted=true when the render fails.
|
||||
func (r *TokenRepository) ReserveQuota(ctx context.Context, pool, id string, amount int) (allowed, deducted bool, err error) {
|
||||
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var item model.TokenAccount
|
||||
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(&item, "pool = ? AND id = ?", pool, id).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
rem, known := metaInt(item.Meta, "cached_quota_remaining")
|
||||
if !known {
|
||||
allowed, deducted = true, false
|
||||
return nil
|
||||
}
|
||||
if rem < amount {
|
||||
allowed, deducted = false, false
|
||||
return nil
|
||||
}
|
||||
meta := cloneMeta(item.Meta)
|
||||
meta["cached_quota_remaining"] = rem - amount
|
||||
if e := tx.Model(&model.TokenAccount{}).
|
||||
Where("pool = ? AND id = ?", pool, id).
|
||||
Updates(map[string]any{"meta": meta, "updated_at": time.Now()}).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
allowed, deducted = true, true
|
||||
return nil
|
||||
})
|
||||
return allowed, deducted, err
|
||||
}
|
||||
|
||||
// Grok accounts carry a forced local quota instead of an upstream balance:
|
||||
// Console 没有额度接口,所以导入时写死 图 5 / 视频 2,下单预扣、失败退回,两个都归零直接判死。
|
||||
const (
|
||||
GrokImageQuotaKey = "grok_image_remaining"
|
||||
GrokVideoQuotaKey = "grok_video_remaining"
|
||||
GrokImageQuota = 5
|
||||
GrokVideoQuota = 2
|
||||
)
|
||||
|
||||
// grokQuotas reads an account's local per-kind counters, falling back to the
|
||||
// forced defaults for accounts imported before the counters existed.
|
||||
func grokQuotas(meta datatypes.JSONMap) (images, videos int) {
|
||||
images, known := metaInt(meta, GrokImageQuotaKey)
|
||||
if !known {
|
||||
images = GrokImageQuota
|
||||
}
|
||||
videos, known = metaInt(meta, GrokVideoQuotaKey)
|
||||
if !known {
|
||||
videos = GrokVideoQuota
|
||||
}
|
||||
return images, videos
|
||||
}
|
||||
|
||||
// ReserveGrokQuota pre-deducts one unit of a grok account's local per-kind quota
|
||||
// under a row lock, so concurrent orders on the same near-empty account can't
|
||||
// over-commit it(下单即扣,不会超扣)。allowed=false 表示这一份已经用光,调用方换号。
|
||||
// 归零的那一份打上 image_limited / video_limited 让调度跳过;判死留给
|
||||
// FinalizeGrokQuota(生成真的成功了才锁号),失败时用 RefundGrokQuota 退回。
|
||||
func (r *TokenRepository) ReserveGrokQuota(ctx context.Context, id, kind string) (allowed bool, err error) {
|
||||
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var item model.TokenAccount
|
||||
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(&item, "pool = ? AND id = ?", "grok", id).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
images, videos := grokQuotas(item.Meta)
|
||||
if kind == "video" {
|
||||
if videos <= 0 {
|
||||
return nil
|
||||
}
|
||||
videos--
|
||||
} else {
|
||||
if images <= 0 {
|
||||
return nil
|
||||
}
|
||||
images--
|
||||
}
|
||||
meta := cloneMeta(item.Meta)
|
||||
meta[GrokImageQuotaKey] = images
|
||||
meta[GrokVideoQuotaKey] = videos
|
||||
if e := tx.Model(&model.TokenAccount{}).
|
||||
Where("pool = ? AND id = ?", "grok", id).
|
||||
Updates(map[string]any{
|
||||
"meta": meta,
|
||||
"image_limited": images <= 0,
|
||||
"video_limited": videos <= 0,
|
||||
"updated_at": time.Now(),
|
||||
}).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
allowed = true
|
||||
return nil
|
||||
})
|
||||
return allowed, err
|
||||
}
|
||||
|
||||
// RefundGrokQuota gives back a unit reserved by ReserveGrokQuota when the render
|
||||
// failed, clearing that kind's limited flag again. Capped at the forced default so
|
||||
// repeated refunds can't inflate the quota.
|
||||
func (r *TokenRepository) RefundGrokQuota(ctx context.Context, id, kind string) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var item model.TokenAccount
|
||||
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(&item, "pool = ? AND id = ?", "grok", id).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
images, videos := grokQuotas(item.Meta)
|
||||
if kind == "video" {
|
||||
videos = min(videos+1, GrokVideoQuota)
|
||||
} else {
|
||||
images = min(images+1, GrokImageQuota)
|
||||
}
|
||||
meta := cloneMeta(item.Meta)
|
||||
meta[GrokImageQuotaKey] = images
|
||||
meta[GrokVideoQuotaKey] = videos
|
||||
return tx.Model(&model.TokenAccount{}).
|
||||
Where("pool = ? AND id = ?", "grok", id).
|
||||
Updates(map[string]any{
|
||||
"meta": meta,
|
||||
"image_limited": images <= 0,
|
||||
"video_limited": videos <= 0,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// FinalizeGrokQuota locks an account down once a successful generation left both
|
||||
// local counters at zero (no reset time — 用完就废).
|
||||
func (r *TokenRepository) FinalizeGrokQuota(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var item model.TokenAccount
|
||||
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(&item, "pool = ? AND id = ?", "grok", id).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
images, videos := grokQuotas(item.Meta)
|
||||
if images > 0 || videos > 0 {
|
||||
return nil
|
||||
}
|
||||
return tx.Model(&model.TokenAccount{}).
|
||||
Where("pool = ? AND id = ?", "grok", id).
|
||||
Updates(map[string]any{
|
||||
"status": "disabled",
|
||||
"dead": true,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// RefundQuota atomically adds `amount` back to cached_quota_remaining (releasing a
|
||||
// hold from a reservation whose render then failed). No-op if the balance is
|
||||
// unknown. Row-locked like ReserveQuota.
|
||||
func (r *TokenRepository) RefundQuota(ctx context.Context, pool, id string, amount int) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var item model.TokenAccount
|
||||
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(&item, "pool = ? AND id = ?", pool, id).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
rem, known := metaInt(item.Meta, "cached_quota_remaining")
|
||||
if !known {
|
||||
return nil
|
||||
}
|
||||
meta := cloneMeta(item.Meta)
|
||||
meta["cached_quota_remaining"] = rem + amount
|
||||
return tx.Model(&model.TokenAccount{}).
|
||||
Where("pool = ? AND id = ?", pool, id).
|
||||
Updates(map[string]any{"meta": meta, "updated_at": time.Now()}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func cloneMeta(m datatypes.JSONMap) datatypes.JSONMap {
|
||||
out := datatypes.JSONMap{}
|
||||
for k, v := range m {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func metaInt(m datatypes.JSONMap, key string) (int, bool) {
|
||||
if m == nil {
|
||||
return 0, false
|
||||
}
|
||||
v, ok := m[key]
|
||||
if !ok || v == nil {
|
||||
return 0, false
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case int:
|
||||
return x, true
|
||||
case int64:
|
||||
return int(x), true
|
||||
case float64:
|
||||
return int(x), true
|
||||
case json.Number:
|
||||
n, e := x.Int64()
|
||||
if e != nil {
|
||||
return 0, false
|
||||
}
|
||||
return int(n), true
|
||||
case string:
|
||||
n, e := strconv.Atoi(strings.TrimSpace(x))
|
||||
if e != nil {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// TouchLastUsed stamps last_used_at at the moment a token is SELECTED, so the
|
||||
// accounts view reflects an accurate "last used" time. Rotation order is driven
|
||||
// by the in-memory strict round-robin cursor in the service layer (see
|
||||
// V1Service.rotateRoundRobin), not by this timestamp.
|
||||
func (r *TokenRepository) TouchLastUsed(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&model.TokenAccount{}).
|
||||
Where("id = ?", id).
|
||||
Update("last_used_at", time.Now()).Error
|
||||
}
|
||||
|
||||
// IncrementFail bumps an account's failure counters by one. Used to attribute
|
||||
// an abandoned (purged) generation's failure back to the account it was using,
|
||||
// since that generation never reached the normal markTokenFailure path.
|
||||
func (r *TokenRepository) IncrementFail(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&model.TokenAccount{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"fail_total": gorm.Expr("fail_total + 1"),
|
||||
"fails": gorm.Expr("fails + 1"),
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *TokenRepository) Delete(ctx context.Context, pool, id string) (int64, error) {
|
||||
res := r.db.WithContext(ctx).
|
||||
Delete(&model.TokenAccount{}, "pool = ? AND id = ?", pool, id)
|
||||
return res.RowsAffected, res.Error
|
||||
}
|
||||
|
||||
// DeleteByIDs removes accounts by id across pools (ids are globally unique),
|
||||
// for bulk delete. Returns the number of rows removed.
|
||||
func (r *TokenRepository) DeleteByIDs(ctx context.Context, ids []string) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
res := r.db.WithContext(ctx).Delete(&model.TokenAccount{}, "id IN ?", ids)
|
||||
return res.RowsAffected, res.Error
|
||||
}
|
||||
|
||||
// leonardoDailyTokens is the free-tier daily allowance restored at each reset.
|
||||
// A paid account's true balance is reconciled on its next successful render.
|
||||
const leonardoDailyTokens = 150
|
||||
|
||||
// RecoverQuota reactivates quota-exhausted tokens whose reset time has passed.
|
||||
// Reset source: cached_quota_reset_after (upstream marker) first, else the
|
||||
// quota_recover_at fallback stamped when the token was marked quota-exhausted.
|
||||
// Mirrors Python TokenPool.recover_quota; returns the count reactivated.
|
||||
// RecoverQuota reactivates quota-exhausted tokens whose reset time has passed and
|
||||
// returns the accounts it recovered, so the caller can re-sync their real balance
|
||||
// (the providers only sync quota when accessed).
|
||||
func (r *TokenRepository) RecoverQuota(ctx context.Context) ([]model.TokenAccount, error) {
|
||||
// Also pick up accounts that are only single-kind limited (image_limited /
|
||||
// video_limited) — those keep status "active" and would otherwise never have
|
||||
// their per-kind flag cleared. This is legacy support; Adobe no longer sets
|
||||
// per-kind limits.
|
||||
var items []model.TokenAccount
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("status = ? OR image_limited = ? OR video_limited = ?", "quota", true, true).
|
||||
Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
var recovered []model.TokenAccount
|
||||
for i := range items {
|
||||
t := &items[i]
|
||||
// Runway's reset marker is the JWT expiry, not a quota-refresh time, and
|
||||
// there's no way to refresh a bare JWT — so a runway account is never
|
||||
// "recovered"; it's expired-to-dead by ExpireByReset instead.
|
||||
if t.Pool == "runway" {
|
||||
continue
|
||||
}
|
||||
reset := parseResetMarker(t.CachedQuotaResetAfter)
|
||||
if reset == nil {
|
||||
reset = t.QuotaRecoverAt
|
||||
}
|
||||
if reset == nil || now.Before(*reset) {
|
||||
continue
|
||||
}
|
||||
patch := map[string]any{
|
||||
"fails": 0,
|
||||
"quota_recover_at": nil,
|
||||
}
|
||||
// For Adobe, also clear any legacy per-kind limit flags.
|
||||
if t.Pool == "adobe" {
|
||||
patch["image_limited"] = false
|
||||
patch["video_limited"] = false
|
||||
}
|
||||
// Only flip status back to active if it was sunk to "quota" (both kinds
|
||||
// limited); a single-kind limit left status untouched.
|
||||
if t.Status == "quota" {
|
||||
patch["status"] = "active"
|
||||
}
|
||||
// Leonardo's free tokens fully renew at each daily reset — restore the
|
||||
// balance and advance the reset marker to the next 08:00 Beijing (== next
|
||||
// UTC midnight), so the account is immediately usable instead of stuck at a
|
||||
// stale 0. A paid account's real balance is corrected on its next render.
|
||||
if t.Pool == "leonardo" || t.Pool == "krea" || t.Pool == "imagine" {
|
||||
meta := cloneMeta(t.Meta)
|
||||
if t.Pool == "leonardo" {
|
||||
meta["cached_quota_remaining"] = leonardoDailyTokens
|
||||
} else {
|
||||
// Krea/Imagine balances re-sync from upstream (billing-data / v1/credit)
|
||||
// on next probe — drop the stale value so the account isn't shown as
|
||||
// empty after reset.
|
||||
delete(meta, "cached_quota_remaining")
|
||||
}
|
||||
meta["cached_quota_at"] = int(now.Unix())
|
||||
patch["meta"] = meta
|
||||
patch["cached_quota_reset_after"] = time.Unix((now.Unix()/86400+1)*86400, 0).UTC().Format(time.RFC3339)
|
||||
}
|
||||
if _, err := r.Update(ctx, t.Pool, t.ID, patch); err != nil {
|
||||
return recovered, err
|
||||
}
|
||||
recovered = append(recovered, *t)
|
||||
}
|
||||
return recovered, nil
|
||||
}
|
||||
|
||||
// RollResetMarkers advances a stale (past) daily-reset marker to its next future
|
||||
// occurrence — same time-of-day, +N whole days — for ACTIVE accounts of the given
|
||||
// daily-reset pools, so the 恢复时间 column always shows the upcoming reset rather
|
||||
// than yesterday's. Only active accounts are rolled: a 限额 account must keep its
|
||||
// past marker so RecoverQuota can recover it (rolling it forward early would
|
||||
// prevent recovery). Returns the number advanced.
|
||||
func (r *TokenRepository) RollResetMarkers(ctx context.Context, pools []string) (int, error) {
|
||||
var items []model.TokenAccount
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("pool IN ? AND dead = ? AND status = ? AND image_limited = ? AND video_limited = ? AND cached_quota_reset_after <> ''",
|
||||
pools, false, "active", false, false).
|
||||
Find(&items).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
now := time.Now()
|
||||
n := 0
|
||||
for i := range items {
|
||||
t := &items[i]
|
||||
reset := parseResetMarker(t.CachedQuotaResetAfter)
|
||||
if reset == nil || !reset.Before(now) {
|
||||
continue // unparseable or already in the future
|
||||
}
|
||||
next := *reset
|
||||
for !next.After(now) {
|
||||
next = next.Add(24 * time.Hour)
|
||||
}
|
||||
if _, err := r.Update(ctx, t.Pool, t.ID, map[string]any{
|
||||
"cached_quota_reset_after": next.UTC().Format(time.RFC3339),
|
||||
}); err != nil {
|
||||
return n, err
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ExpireByReset marks accounts of a pool dead once their reset marker has passed.
|
||||
// For runway the marker IS the JWT expiry and there's no refresh, so an expired
|
||||
// token can only 401 — we proactively flip it to disabled+dead (the same end
|
||||
// state a 401 would produce) instead of leaving a doomed account "active".
|
||||
func (r *TokenRepository) ExpireByReset(ctx context.Context, pool string) (int, error) {
|
||||
var items []model.TokenAccount
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("pool = ? AND dead = ?", pool, false).
|
||||
Find(&items).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
now := time.Now()
|
||||
expired := 0
|
||||
for i := range items {
|
||||
t := &items[i]
|
||||
reset := parseResetMarker(t.CachedQuotaResetAfter)
|
||||
if reset == nil || now.Before(*reset) {
|
||||
continue
|
||||
}
|
||||
if _, err := r.Update(ctx, t.Pool, t.ID, map[string]any{
|
||||
"status": "disabled",
|
||||
"dead": true,
|
||||
}); err != nil {
|
||||
return expired, err
|
||||
}
|
||||
expired++
|
||||
}
|
||||
return expired, nil
|
||||
}
|
||||
|
||||
// parseResetMarker best-effort parses a quota reset marker into a time. Accepts
|
||||
// epoch seconds (numeric string) or ISO-8601 (e.g. Adobe's available_until
|
||||
// "2026-06-16T23:59:59.999Z"). Returns nil if unparseable.
|
||||
func parseResetMarker(v string) *time.Time {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil && f > 946684800 {
|
||||
t := time.Unix(int64(f), 0)
|
||||
return &t
|
||||
}
|
||||
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05.999Z07:00", "2006-01-02T15:04:05Z07:00"} {
|
||||
if t, err := time.Parse(layout, v); err == nil {
|
||||
return &t
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -19,20 +19,20 @@ type AdminReadService struct {
|
||||
models *repo.ModelRepository
|
||||
events *repo.EventRepository
|
||||
settings *repo.SiteSettingRepository
|
||||
tokens *repo.TokenRepository
|
||||
channels *repo.ChannelRepository
|
||||
cdks *repo.CDKRepository
|
||||
store *storage.Client
|
||||
showcase *repo.ShowcaseRepository
|
||||
}
|
||||
|
||||
func NewAdminReadService(cfg *config.Config, users *repo.UserRepository, models *repo.ModelRepository, events *repo.EventRepository, settings *repo.SiteSettingRepository, tokens *repo.TokenRepository, cdks *repo.CDKRepository, store *storage.Client, showcase *repo.ShowcaseRepository) *AdminReadService {
|
||||
func NewAdminReadService(cfg *config.Config, users *repo.UserRepository, models *repo.ModelRepository, events *repo.EventRepository, settings *repo.SiteSettingRepository, channels *repo.ChannelRepository, cdks *repo.CDKRepository, store *storage.Client, showcase *repo.ShowcaseRepository) *AdminReadService {
|
||||
return &AdminReadService{
|
||||
cfg: cfg,
|
||||
users: users,
|
||||
models: models,
|
||||
events: events,
|
||||
settings: settings,
|
||||
tokens: tokens,
|
||||
channels: channels,
|
||||
cdks: cdks,
|
||||
store: store,
|
||||
showcase: showcase,
|
||||
@@ -102,6 +102,12 @@ func (s *AdminReadService) ModelsView(ctx context.Context) ([]map[string]any, er
|
||||
"duration_prices_agent": map[string]any(item.DurationPricesAgent),
|
||||
"durations": repo.JSONStrings(item.Durations),
|
||||
"max_reference_images": item.MaxReferenceImages,
|
||||
"max_reference_videos": item.MaxReferenceVideos,
|
||||
"max_reference_audios": item.MaxReferenceAudios,
|
||||
"duration_min": item.DurationMin,
|
||||
"duration_max": item.DurationMax,
|
||||
"max_ref_video_seconds": item.MaxRefVideoSeconds,
|
||||
"max_ref_audio_seconds": item.MaxRefAudioSeconds,
|
||||
"reference_mode": item.ReferenceMode,
|
||||
"weight": item.Weight,
|
||||
"generation_count": item.GenerationCount,
|
||||
@@ -194,24 +200,24 @@ func (s *AdminReadService) UserNameMap(ctx context.Context) (map[string]string,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AccountNameMap builds a token-account id -> display label lookup (account
|
||||
// email, else display name, else id) used to annotate log rows with which
|
||||
// provider account fulfilled each generation (event_logs.account_id).
|
||||
// AccountNameMap builds a channel id -> display name lookup used to annotate
|
||||
// log rows with which channel fulfilled each generation (event_logs.account_id
|
||||
// now stores the channel id; account_email carries a denormalized name stamp).
|
||||
func (s *AdminReadService) AccountNameMap(ctx context.Context) (map[string]string, error) {
|
||||
accounts, err := s.tokens.List(ctx)
|
||||
if s.channels == nil {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
channels, err := s.channels.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]string, len(accounts))
|
||||
for _, a := range accounts {
|
||||
label := strings.TrimSpace(a.AccountEmail)
|
||||
out := make(map[string]string, len(channels))
|
||||
for _, ch := range channels {
|
||||
label := strings.TrimSpace(ch.Name)
|
||||
if label == "" {
|
||||
label = strings.TrimSpace(a.AccountDisplayName)
|
||||
label = ch.ID
|
||||
}
|
||||
if label == "" {
|
||||
label = a.ID
|
||||
}
|
||||
out[a.ID] = label
|
||||
out[ch.ID] = label
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -420,66 +426,30 @@ func (s *AdminReadService) Invites(ctx context.Context) ([]repo.InviteRecord, *r
|
||||
return s.users.AllInvites(ctx, reward)
|
||||
}
|
||||
|
||||
// Providers is now a channel summary (the account pools are gone): one row per
|
||||
// channel with its bound-model count and local usage stats.
|
||||
func (s *AdminReadService) Providers(ctx context.Context) ([]map[string]any, error) {
|
||||
models, err := s.models.List(ctx)
|
||||
if s.channels == nil {
|
||||
return []map[string]any{}, nil
|
||||
}
|
||||
channels, err := s.channels.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokens, err := s.tokens.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
modelCounts := map[string]int{}
|
||||
for _, item := range models {
|
||||
modelCounts[item.Provider]++
|
||||
}
|
||||
type aggregate struct {
|
||||
active int
|
||||
disabled int
|
||||
quota int
|
||||
}
|
||||
tokenCounts := map[string]*aggregate{}
|
||||
for _, item := range tokens {
|
||||
if _, ok := tokenCounts[item.Pool]; !ok {
|
||||
tokenCounts[item.Pool] = &aggregate{}
|
||||
}
|
||||
switch item.Status {
|
||||
case "active":
|
||||
tokenCounts[item.Pool].active++
|
||||
case "quota":
|
||||
tokenCounts[item.Pool].quota++
|
||||
default:
|
||||
tokenCounts[item.Pool].disabled++
|
||||
}
|
||||
}
|
||||
providers := []struct {
|
||||
Name string
|
||||
Pool string
|
||||
Type string
|
||||
}{
|
||||
{Name: "chatgpt", Pool: "chatgpt", Type: "openai"},
|
||||
{Name: "adobe", Pool: "adobe", Type: "adobe"},
|
||||
{Name: "runway", Pool: "runway", Type: "runway"},
|
||||
{Name: "leonardo", Pool: "leonardo", Type: "leonardo"},
|
||||
{Name: "krea", Pool: "krea", Type: "krea"},
|
||||
{Name: "imagine", Pool: "imagine", Type: "imagine"},
|
||||
{Name: "grok", Pool: "grok", Type: "grok"},
|
||||
}
|
||||
out := make([]map[string]any, 0, len(providers))
|
||||
for _, item := range providers {
|
||||
count := tokenCounts[item.Pool]
|
||||
if count == nil {
|
||||
count = &aggregate{}
|
||||
}
|
||||
out := make([]map[string]any, 0, len(channels))
|
||||
for _, ch := range channels {
|
||||
modelIDs, _ := s.channels.ModelIDsByChannel(ctx, ch.ID)
|
||||
out = append(out, map[string]any{
|
||||
"name": item.Name,
|
||||
"token_pool": item.Pool,
|
||||
"type": item.Type,
|
||||
"model_count": modelCounts[item.Name],
|
||||
"tokens_total": count.active + count.disabled + count.quota,
|
||||
"tokens_active": count.active,
|
||||
"tokens_disabled": count.disabled,
|
||||
"tokens_quota": count.quota,
|
||||
"id": ch.ID,
|
||||
"name": ch.Name,
|
||||
"base_url": ch.BaseURL,
|
||||
"enabled": ch.Enabled,
|
||||
"concurrency": ch.Concurrency,
|
||||
"weight": ch.Weight,
|
||||
"model_count": len(modelIDs),
|
||||
"success_total": ch.SuccessTotal,
|
||||
"used_credits": ch.UsedCredits,
|
||||
"type": "vivid",
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
|
||||
@@ -28,18 +28,18 @@ type AdminWriteService struct {
|
||||
models *repo.ModelRepository
|
||||
events *repo.EventRepository
|
||||
apiKeys *repo.APIKeyRepository
|
||||
tokens *repo.TokenRepository
|
||||
channels *repo.ChannelRepository
|
||||
orders *repo.OrderRepository
|
||||
}
|
||||
|
||||
func NewAdminWriteService(users *repo.UserRepository, showcase *repo.ShowcaseRepository, models *repo.ModelRepository, events *repo.EventRepository, apiKeys *repo.APIKeyRepository, tokens *repo.TokenRepository, orders *repo.OrderRepository) *AdminWriteService {
|
||||
func NewAdminWriteService(users *repo.UserRepository, showcase *repo.ShowcaseRepository, models *repo.ModelRepository, events *repo.EventRepository, apiKeys *repo.APIKeyRepository, channels *repo.ChannelRepository, orders *repo.OrderRepository) *AdminWriteService {
|
||||
return &AdminWriteService{
|
||||
users: users,
|
||||
showcase: showcase,
|
||||
models: models,
|
||||
events: events,
|
||||
apiKeys: apiKeys,
|
||||
tokens: tokens,
|
||||
channels: channels,
|
||||
orders: orders,
|
||||
}
|
||||
}
|
||||
@@ -371,7 +371,7 @@ func (s *AdminWriteService) CreateModel(ctx context.Context, body map[string]any
|
||||
return nil, errors.New("type must be image or video")
|
||||
}
|
||||
if provider == "" {
|
||||
return nil, errors.New("provider required")
|
||||
provider = "channel"
|
||||
}
|
||||
if err := s.validateModelNameSpace(ctx, "", modelID, alias); err != nil {
|
||||
return nil, err
|
||||
@@ -401,6 +401,8 @@ func (s *AdminWriteService) CreateModel(ctx context.Context, body map[string]any
|
||||
DurationPricesAgent: jsonMap(body["duration_prices_agent"]),
|
||||
Durations: jsonArray(body["durations"]),
|
||||
MaxReferenceImages: intValue(body["max_reference_images"]),
|
||||
MaxReferenceVideos: intValue(body["max_reference_videos"]),
|
||||
MaxReferenceAudios: intValue(body["max_reference_audios"]),
|
||||
ReferenceMode: defaultString(strings.TrimSpace(stringValue(body["reference_mode"])), "none"),
|
||||
Weight: intValue(body["weight"]),
|
||||
CreatedAt: time.Now(),
|
||||
@@ -434,10 +436,9 @@ func (s *AdminWriteService) UpdateModel(ctx context.Context, modelID string, bod
|
||||
}
|
||||
if _, ok := body["provider"]; ok {
|
||||
provider := strings.TrimSpace(stringValue(body["provider"]))
|
||||
if provider == "" {
|
||||
return nil, errors.New("provider required")
|
||||
if provider != "" {
|
||||
patch["provider"] = provider
|
||||
}
|
||||
patch["provider"] = provider
|
||||
}
|
||||
// Only touch `enabled` when the caller explicitly sends a non-null value;
|
||||
// mirrors Python models_store.update ("enabled" in fields and is not None).
|
||||
@@ -477,9 +478,27 @@ func (s *AdminWriteService) UpdateModel(ctx context.Context, modelID string, bod
|
||||
if _, ok := body["max_reference_images"]; ok {
|
||||
patch["max_reference_images"] = intValue(body["max_reference_images"])
|
||||
}
|
||||
if _, ok := body["max_reference_videos"]; ok {
|
||||
patch["max_reference_videos"] = intValue(body["max_reference_videos"])
|
||||
}
|
||||
if _, ok := body["max_reference_audios"]; ok {
|
||||
patch["max_reference_audios"] = intValue(body["max_reference_audios"])
|
||||
}
|
||||
if _, ok := body["reference_mode"]; ok {
|
||||
patch["reference_mode"] = defaultString(strings.TrimSpace(stringValue(body["reference_mode"])), "none")
|
||||
}
|
||||
if _, ok := body["duration_min"]; ok {
|
||||
patch["duration_min"] = intValue(body["duration_min"])
|
||||
}
|
||||
if _, ok := body["duration_max"]; ok {
|
||||
patch["duration_max"] = intValue(body["duration_max"])
|
||||
}
|
||||
if _, ok := body["max_ref_video_seconds"]; ok {
|
||||
patch["max_ref_video_seconds"] = intValue(body["max_ref_video_seconds"])
|
||||
}
|
||||
if _, ok := body["max_ref_audio_seconds"]; ok {
|
||||
patch["max_ref_audio_seconds"] = intValue(body["max_ref_audio_seconds"])
|
||||
}
|
||||
if _, ok := body["weight"]; ok {
|
||||
patch["weight"] = intValue(body["weight"])
|
||||
}
|
||||
@@ -524,52 +543,14 @@ func (s *AdminWriteService) DeleteModel(ctx context.Context, modelID string) err
|
||||
if rows == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
// Clean up: strip this model id from every custom upstream account's
|
||||
// supported-models list so no orphan reference is left behind.
|
||||
s.removeModelFromUpstreams(ctx, modelID)
|
||||
// Clean up: drop every channel binding pointing at the deleted model so no
|
||||
// orphan reference is left behind.
|
||||
if s.channels != nil {
|
||||
_ = s.channels.DeleteBindingsByModel(ctx, modelID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeModelFromUpstreams drops modelID from the CSV in each custom account's
|
||||
// Meta["models"]. Best-effort — a failure here doesn't undo the model delete.
|
||||
func (s *AdminWriteService) removeModelFromUpstreams(ctx context.Context, modelID string) {
|
||||
if s.tokens == nil {
|
||||
return
|
||||
}
|
||||
items, err := s.tokens.ListByPool(ctx, "custom")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, it := range items {
|
||||
raw, _ := it.Meta["models"].(string)
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
continue
|
||||
}
|
||||
kept := make([]string, 0, len(strings.Split(raw, ",")))
|
||||
changed := false
|
||||
for _, p := range strings.Split(raw, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if p == modelID {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, p)
|
||||
}
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
meta := datatypes.JSONMap{}
|
||||
for k, v := range it.Meta {
|
||||
meta[k] = v
|
||||
}
|
||||
meta["models"] = strings.Join(kept, ",")
|
||||
_, _ = s.tokens.Update(ctx, "custom", it.ID, map[string]any{"meta": meta})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AdminWriteService) ClearLogs(ctx context.Context) (int64, error) {
|
||||
return s.events.DeleteAll(ctx)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
@@ -109,8 +110,15 @@ func (s *APIKeyService) DeleteOne(ctx context.Context, userID, keyID string) err
|
||||
return s.keys.DeleteByID(ctx, userID, keyID)
|
||||
}
|
||||
|
||||
// generatePlainAPIKey mints a vivid-shaped key: vk_ + 48 lowercase hex chars
|
||||
// (upstream channel api_keys use the same shape, so one key format rules
|
||||
// everything — no per-direction prefixes).
|
||||
func generatePlainAPIKey() (string, error) {
|
||||
return "sk-" + randomUpper(38), nil
|
||||
b := make([]byte, 24)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "vk_" + hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func previewAPIKey(plain string) string {
|
||||
|
||||
@@ -100,6 +100,11 @@ func ownsImageDir(user *model.User, owner string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// OwnsImageDir is the exported alias used by other services (image access, etc.)
|
||||
func (s *AuthService) OwnsImageDir(user *model.User, owner string) bool {
|
||||
return ownsImageDir(user, owner)
|
||||
}
|
||||
|
||||
// checkOwnerDirCollision rejects a signup whose sanitized username or email
|
||||
// local-part collides with any existing user's directory candidates. Media
|
||||
// authorization (ownsImageDir) matches on those candidates, so without this
|
||||
@@ -481,21 +486,21 @@ func (s *AuthService) PublicUser(ctx context.Context, user *model.User) (map[str
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
"role": user.Role,
|
||||
"status": user.Status,
|
||||
"credits": user.Credits,
|
||||
"recharge_total": user.RechargeTotal,
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
"role": user.Role,
|
||||
"status": user.Status,
|
||||
"credits": user.Credits,
|
||||
"recharge_total": user.RechargeTotal,
|
||||
"concurrency_group": concName,
|
||||
"concurrency_limit": concMax,
|
||||
"checkin_last": user.CheckinLast,
|
||||
"checkin_streak": user.CheckinStreak,
|
||||
"checkin_today": user.CheckinLast == time.Now().Format("2006-01-02"),
|
||||
"invite_code": user.InviteCode,
|
||||
"invite_count": stats.InviteCount,
|
||||
"invite_earned": stats.InviteEarned,
|
||||
"checkin_last": user.CheckinLast,
|
||||
"checkin_streak": user.CheckinStreak,
|
||||
"checkin_today": user.CheckinLast == time.Now().Format("2006-01-02"),
|
||||
"invite_code": user.InviteCode,
|
||||
"invite_count": stats.InviteCount,
|
||||
"invite_earned": stats.InviteEarned,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"backend/internal/model"
|
||||
"backend/internal/repo"
|
||||
"backend/internal/upstream"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ChannelService manages upstream vivid-protocol channels: CRUD, upstream key
|
||||
// encryption at rest, model fetching/import/binding, and live balance probes.
|
||||
// The actual request dispatch lives on V1Service (it owns concurrency + events).
|
||||
type ChannelService struct {
|
||||
channels *repo.ChannelRepository
|
||||
models *repo.ModelRepository
|
||||
up *upstream.Client
|
||||
secret []byte
|
||||
}
|
||||
|
||||
func NewChannelService(channels *repo.ChannelRepository, models *repo.ModelRepository, up *upstream.Client, secret string) *ChannelService {
|
||||
sum := sha256.Sum256([]byte("image2api-channel-keys|" + secret))
|
||||
return &ChannelService{channels: channels, models: models, up: up, secret: sum[:]}
|
||||
}
|
||||
|
||||
// ChannelView is the admin list row: local stats + bound model names + a live
|
||||
// upstream balance probe (nil when the upstream doesn't implement /v1/balance
|
||||
// or the probe failed).
|
||||
type ChannelView struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
Weight int `json:"weight"`
|
||||
Models []string `json:"models"`
|
||||
SuccessTotal int64 `json:"success_total"`
|
||||
UsedCredits float64 `json:"used_credits"`
|
||||
UpstreamBalance *int64 `json:"upstream_balance"`
|
||||
LastUsedAt *int64 `json:"last_used_at"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (s *ChannelService) List(ctx context.Context) ([]ChannelView, error) {
|
||||
items, err := s.channels.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ChannelView, len(items))
|
||||
var wg sync.WaitGroup
|
||||
for i, ch := range items {
|
||||
out[i] = ChannelView{
|
||||
ID: ch.ID,
|
||||
Name: ch.Name,
|
||||
BaseURL: ch.BaseURL,
|
||||
Enabled: ch.Enabled,
|
||||
Concurrency: ch.Concurrency,
|
||||
Weight: ch.Weight,
|
||||
SuccessTotal: ch.SuccessTotal,
|
||||
UsedCredits: ch.UsedCredits,
|
||||
CreatedAt: ch.CreatedAt.Unix(),
|
||||
UpdatedAt: ch.UpdatedAt.Unix(),
|
||||
}
|
||||
if ch.LastUsedAt != nil {
|
||||
t := ch.LastUsedAt.Unix()
|
||||
out[i].LastUsedAt = &t
|
||||
}
|
||||
if ids, err := s.channels.ModelIDsByChannel(ctx, ch.ID); err == nil {
|
||||
names := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if m, err := s.models.Get(ctx, id); err == nil {
|
||||
names = append(names, m.EffectiveName())
|
||||
} else {
|
||||
names = append(names, id)
|
||||
}
|
||||
}
|
||||
out[i].Models = names
|
||||
}
|
||||
if key := s.decrypt(ch.APIKeyEnc); key != "" {
|
||||
wg.Add(1)
|
||||
go func(i int, ch model.Channel, key string) {
|
||||
defer wg.Done()
|
||||
bctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancel()
|
||||
if bal, err := s.up.Balance(bctx, ch.BaseURL, key); err == nil {
|
||||
out[i].UpstreamBalance = bal
|
||||
}
|
||||
}(i, ch, key)
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SaveChannelDto is the admin create/update payload. ApiKey empty or the mask
|
||||
// placeholder keeps the existing key on update; required on create.
|
||||
type SaveChannelDto struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
Weight int `json:"weight"`
|
||||
}
|
||||
|
||||
func (s *ChannelService) Save(ctx context.Context, d SaveChannelDto) (*model.Channel, error) {
|
||||
name := strings.TrimSpace(d.Name)
|
||||
base := strings.TrimRight(strings.TrimSpace(d.BaseURL), "/")
|
||||
if base == "" {
|
||||
return nil, errors.New("base_url required")
|
||||
}
|
||||
if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
|
||||
return nil, errors.New("base_url must start with http:// or https://")
|
||||
}
|
||||
weight := d.Weight
|
||||
if weight < 1 {
|
||||
weight = 1
|
||||
}
|
||||
if d.ID == "" {
|
||||
if strings.TrimSpace(d.APIKey) == "" {
|
||||
return nil, errors.New("api_key required")
|
||||
}
|
||||
enc, err := s.encrypt(d.APIKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch := &model.Channel{
|
||||
ID: "ch-" + randomUpper(10),
|
||||
Name: name,
|
||||
BaseURL: base,
|
||||
APIKeyEnc: enc,
|
||||
Enabled: d.Enabled,
|
||||
Concurrency: d.Concurrency,
|
||||
Weight: weight,
|
||||
}
|
||||
if ch.Name == "" {
|
||||
ch.Name = base
|
||||
}
|
||||
return ch, s.channels.Create(ctx, ch)
|
||||
}
|
||||
patch := map[string]any{
|
||||
"name": name,
|
||||
"base_url": base,
|
||||
"enabled": d.Enabled,
|
||||
"concurrency": d.Concurrency,
|
||||
"weight": weight,
|
||||
}
|
||||
if key := strings.TrimSpace(d.APIKey); key != "" && !strings.Contains(key, "•") && !strings.Contains(key, "*") {
|
||||
enc, err := s.encrypt(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
patch["api_key_enc"] = enc
|
||||
}
|
||||
return s.channels.Update(ctx, d.ID, patch)
|
||||
}
|
||||
|
||||
// Delete removes a channel, its bindings, and any local model that ends up
|
||||
// bound to no channel at all (same semantics as vivid).
|
||||
func (s *ChannelService) Delete(ctx context.Context, id string) error {
|
||||
modelIDs, _ := s.channels.ModelIDsByChannel(ctx, id)
|
||||
if err := s.channels.DeleteBindingsByChannel(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
res := 0
|
||||
if n, err := s.channels.Delete(ctx, id); err != nil {
|
||||
return err
|
||||
} else {
|
||||
res = int(n)
|
||||
}
|
||||
if res == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
for _, mid := range modelIDs {
|
||||
rest, _ := s.channels.ChannelIDsByModel(ctx, mid)
|
||||
if len(rest) == 0 {
|
||||
_, _ = s.models.Delete(ctx, mid)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FetchModelsDto resolves credentials for a model fetch: Id uses the stored
|
||||
// channel's key; otherwise BaseURL+ApiKey preview an unsaved channel.
|
||||
type FetchModelsDto struct {
|
||||
ID string `json:"id"`
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
}
|
||||
|
||||
// FetchModels lists the upstream's sellable models and flags which are already
|
||||
// imported locally.
|
||||
func (s *ChannelService) FetchModels(ctx context.Context, d FetchModelsDto) ([]upstream.RemoteModel, error) {
|
||||
base, key, err := s.resolveCreds(ctx, d.ID, d.BaseURL, d.APIKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := s.up.Models(ctx, base, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// ChannelModelSpec is one model the admin ticks for import (same shape as the
|
||||
// upstream /v1/models item plus an optional local alias).
|
||||
type ChannelModelSpec struct {
|
||||
ID string `json:"id"`
|
||||
Alias string `json:"alias"`
|
||||
Kind string `json:"kind"`
|
||||
Qualities []string `json:"qualities"`
|
||||
Duration *upstream.RemoteDuration `json:"duration,omitempty"`
|
||||
RefImages int64 `json:"refImages"`
|
||||
RefVideos int64 `json:"refVideos"`
|
||||
RefAudios int64 `json:"refAudios"`
|
||||
RefVideoSeconds int64 `json:"refVideoSeconds,omitempty"`
|
||||
RefAudioSeconds int64 `json:"refAudioSeconds,omitempty"`
|
||||
}
|
||||
|
||||
func (s *ChannelService) resolveCreds(ctx context.Context, id, base, key string) (string, string, error) {
|
||||
if strings.TrimSpace(id) != "" {
|
||||
ch, err := s.channels.Get(ctx, id)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return strings.TrimRight(ch.BaseURL, "/"), s.decrypt(ch.APIKeyEnc), nil
|
||||
}
|
||||
base = strings.TrimRight(strings.TrimSpace(base), "/")
|
||||
if base == "" || strings.TrimSpace(key) == "" {
|
||||
return "", "", errors.New("id or base_url+api_key required")
|
||||
}
|
||||
return base, strings.TrimSpace(key), nil
|
||||
}
|
||||
|
||||
// ImportModels creates/updates local model rows for the ticked specs and binds
|
||||
// them to the channel. Pricing stays whatever the local row already has
|
||||
// (empty for new rows — admin prices them in 模型管理).
|
||||
func (s *ChannelService) ImportModels(ctx context.Context, channelID string, specs []ChannelModelSpec) error {
|
||||
ch, err := s.channels.Get(ctx, channelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, spec := range specs {
|
||||
upID := strings.TrimSpace(spec.ID)
|
||||
if upID == "" {
|
||||
continue
|
||||
}
|
||||
localID := strings.TrimSpace(spec.Alias)
|
||||
if localID == "" {
|
||||
localID = upID
|
||||
}
|
||||
existing, gerr := s.models.Get(ctx, localID)
|
||||
if gerr != nil && !errors.Is(gerr, gorm.ErrRecordNotFound) {
|
||||
existing = nil
|
||||
}
|
||||
var local *model.ModelConfig
|
||||
if existing != nil && existing.ID == localID {
|
||||
local = existing
|
||||
}
|
||||
patch := modelPatchFromSpec(spec, local)
|
||||
if local == nil {
|
||||
local = &model.ModelConfig{
|
||||
ID: localID,
|
||||
Name: localID,
|
||||
Type: spec.Kind,
|
||||
Provider: "channel",
|
||||
Enabled: true,
|
||||
Ratios: jsonArray([]string{"16:9"}),
|
||||
Prices: datatypes.JSONMap{},
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
applySpec(local, patch)
|
||||
if err := s.models.Create(ctx, local); err != nil {
|
||||
return fmt.Errorf("model %s: %w", localID, err)
|
||||
}
|
||||
} else {
|
||||
if _, err := s.models.Update(ctx, localID, patch); err != nil {
|
||||
return fmt.Errorf("model %s: %w", localID, err)
|
||||
}
|
||||
}
|
||||
if err := s.channels.Bind(ctx, ch.ID, localID, upID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// modelPatchFromSpec maps an upstream model spec onto model_configs columns.
|
||||
// Prices/enabled/alias are intentionally untouched on update so admin pricing
|
||||
// survives re-import.
|
||||
func modelPatchFromSpec(spec ChannelModelSpec, existing *model.ModelConfig) map[string]any {
|
||||
patch := map[string]any{
|
||||
"type": spec.Kind,
|
||||
"resolutions": jsonArray(spec.Qualities),
|
||||
"max_reference_images": spec.RefImages,
|
||||
"max_reference_videos": spec.RefVideos,
|
||||
"max_reference_audios": spec.RefAudios,
|
||||
"max_ref_video_seconds": spec.RefVideoSeconds,
|
||||
"max_ref_audio_seconds": spec.RefAudioSeconds,
|
||||
}
|
||||
if spec.RefImages+spec.RefVideos+spec.RefAudios > 0 {
|
||||
patch["reference_mode"] = "asset"
|
||||
patch["image_to_image"] = spec.RefImages > 0
|
||||
}
|
||||
if d := spec.Duration; d != nil {
|
||||
switch d.Mode {
|
||||
case "fixed":
|
||||
patch["durations"] = jsonArray([]string{fmt.Sprintf("%ds", d.Seconds)})
|
||||
patch["duration_min"], patch["duration_max"] = int64(0), int64(0)
|
||||
case "options":
|
||||
opts := make([]string, 0, len(d.Options))
|
||||
for _, s := range d.Options {
|
||||
opts = append(opts, fmt.Sprintf("%ds", s))
|
||||
}
|
||||
patch["durations"] = jsonArray(opts)
|
||||
patch["duration_min"], patch["duration_max"] = int64(0), int64(0)
|
||||
case "range":
|
||||
patch["duration_min"], patch["duration_max"] = d.Min, d.Max
|
||||
}
|
||||
}
|
||||
return patch
|
||||
}
|
||||
|
||||
func applySpec(m *model.ModelConfig, patch map[string]any) {
|
||||
if v, ok := patch["resolutions"]; ok {
|
||||
m.Resolutions = datatypes.JSON(mustJSON(v))
|
||||
}
|
||||
if v, ok := patch["durations"]; ok {
|
||||
m.Durations = datatypes.JSON(mustJSON(v))
|
||||
}
|
||||
if v, ok := patch["duration_min"]; ok {
|
||||
m.DurationMin = int(v.(int64))
|
||||
}
|
||||
if v, ok := patch["duration_max"]; ok {
|
||||
m.DurationMax = int(v.(int64))
|
||||
}
|
||||
m.MaxReferenceImages = int(specInt(patch, "max_reference_images"))
|
||||
m.MaxReferenceVideos = int(specInt(patch, "max_reference_videos"))
|
||||
m.MaxReferenceAudios = int(specInt(patch, "max_reference_audios"))
|
||||
m.MaxRefVideoSeconds = int(specInt(patch, "max_ref_video_seconds"))
|
||||
m.MaxRefAudioSeconds = int(specInt(patch, "max_ref_audio_seconds"))
|
||||
if v, ok := patch["reference_mode"].(string); ok {
|
||||
m.ReferenceMode = v
|
||||
}
|
||||
if v, ok := patch["image_to_image"].(bool); ok {
|
||||
m.ImageToImage = v
|
||||
}
|
||||
}
|
||||
|
||||
func specInt(patch map[string]any, key string) int64 {
|
||||
switch v := patch[key].(type) {
|
||||
case int64:
|
||||
return v
|
||||
case int:
|
||||
return int64(v)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON(v any) []byte {
|
||||
b, _ := json.Marshal(v)
|
||||
return b
|
||||
}
|
||||
|
||||
// ChannelsForModel returns enabled channels bound to the model with their
|
||||
// upstream model names — dispatch order is decided by the caller.
|
||||
func (s *ChannelService) ChannelsForModel(ctx context.Context, modelID string) ([]repo.ChannelWithUpstream, error) {
|
||||
return s.channels.ForModel(ctx, modelID)
|
||||
}
|
||||
|
||||
// ChannelsForModelAnyState includes disabled channels (admin pin tests).
|
||||
func (s *ChannelService) ChannelsForModelAnyState(ctx context.Context, modelID string) ([]repo.ChannelWithUpstream, error) {
|
||||
return s.channels.ForModelAnyState(ctx, modelID)
|
||||
}
|
||||
|
||||
// Decrypt exposes the upstream key for dispatch.
|
||||
func (s *ChannelService) Decrypt(ch model.Channel) string { return s.decrypt(ch.APIKeyEnc) }
|
||||
|
||||
// Upstream returns the shared protocol client (dispatch needs it for
|
||||
// UploadRef/Generate/Download; admin handlers for balance probes).
|
||||
func (s *ChannelService) Upstream() *upstream.Client { return s.up }
|
||||
|
||||
// BumpSuccess forwards local stats updates to the repo.
|
||||
func (s *ChannelService) BumpSuccess(ctx context.Context, channelID string, credits float64) {
|
||||
_ = s.channels.BumpSuccess(ctx, channelID, credits)
|
||||
}
|
||||
|
||||
// --- AES-GCM key encryption ---
|
||||
|
||||
const encPrefix = "enc:v1:"
|
||||
|
||||
func (s *ChannelService) encrypt(plain string) (string, error) {
|
||||
block, err := aes.NewCipher(s.secret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return encPrefix + base64.StdEncoding.EncodeToString(gcm.Seal(nonce, nonce, []byte(plain), nil)), nil
|
||||
}
|
||||
|
||||
func (s *ChannelService) decrypt(stored string) string {
|
||||
if !strings.HasPrefix(stored, encPrefix) {
|
||||
return stored // plaintext rows (e.g. seeded by hand)
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(stored, encPrefix))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
block, err := aes.NewCipher(s.secret)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if len(raw) < gcm.NonceSize() {
|
||||
return ""
|
||||
}
|
||||
plain, err := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(plain)
|
||||
}
|
||||
@@ -48,6 +48,21 @@ func (s *ImageAccessService) IsPublic(ctx context.Context, rel string) (bool, er
|
||||
return s.showcase.IsPublicFile(ctx, rel)
|
||||
}
|
||||
|
||||
func (s *ImageAccessService) IsAuthorized(ctx context.Context, sessionCookie, owner string) (bool, error) {
|
||||
func (s *ImageAccessService) IsAuthorized(ctx context.Context, authHeader, sessionCookie, owner string) (bool, error) {
|
||||
// Private images may be requested by an <img> tag with the session cookie
|
||||
// OR by the SPA via a Bearer token. Admins may view anyone's images.
|
||||
if authHeader != "" {
|
||||
user, _, err := s.auth.CurrentUserFromBearer(ctx, authHeader)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if user == nil {
|
||||
return false, nil
|
||||
}
|
||||
if user.Role == "admin" {
|
||||
return true, nil
|
||||
}
|
||||
return s.auth.OwnsImageDir(user, owner), nil
|
||||
}
|
||||
return s.auth.IsAuthorizedForPrivateImage(ctx, sessionCookie, owner)
|
||||
}
|
||||
|
||||
@@ -5,25 +5,19 @@ import (
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"backend/internal/model"
|
||||
"backend/internal/repo"
|
||||
"backend/internal/storage"
|
||||
)
|
||||
|
||||
// MaintenanceService runs the periodic self-healing sweep that the Python
|
||||
// original did via a 60s daemon thread plus read-time lazy cleanup. Without it
|
||||
// the Go token pool only ever loses capacity: tokens never re-activate after a
|
||||
// quota reset, cookies never auto-renew, stale pending events permanently block
|
||||
// a user's generation gate, and old media/logs accumulate unbounded.
|
||||
// MaintenanceService runs the periodic self-healing sweep. Without it stale
|
||||
// pending events permanently block a user's generation gate, and old media /
|
||||
// logs / temp objects accumulate unbounded. Token-pool and cookie-refresh
|
||||
// upkeep is gone — channels are stateless upstream credentials.
|
||||
type MaintenanceService struct {
|
||||
tokens *repo.TokenRepository
|
||||
tokenSvc *TokenService
|
||||
events *repo.EventRepository
|
||||
users *repo.UserRepository
|
||||
refresh *RefreshProfileService
|
||||
settings *repo.SiteSettingRepository
|
||||
store *storage.Client
|
||||
inflight *InflightRegistry
|
||||
@@ -36,20 +30,17 @@ type MaintenanceService struct {
|
||||
lastMediaPrune time.Time
|
||||
}
|
||||
|
||||
func NewMaintenanceService(tokens *repo.TokenRepository, tokenSvc *TokenService, events *repo.EventRepository, users *repo.UserRepository, refresh *RefreshProfileService, settings *repo.SiteSettingRepository, store *storage.Client, inflight *InflightRegistry, showcase *repo.ShowcaseRepository, orders *repo.OrderRepository) *MaintenanceService {
|
||||
func NewMaintenanceService(events *repo.EventRepository, users *repo.UserRepository, settings *repo.SiteSettingRepository, store *storage.Client, inflight *InflightRegistry, showcase *repo.ShowcaseRepository, orders *repo.OrderRepository) *MaintenanceService {
|
||||
return &MaintenanceService{
|
||||
tokens: tokens,
|
||||
tokenSvc: tokenSvc,
|
||||
events: events,
|
||||
users: users,
|
||||
refresh: refresh,
|
||||
settings: settings,
|
||||
store: store,
|
||||
inflight: inflight,
|
||||
showcase: showcase,
|
||||
orders: orders,
|
||||
interval: 60 * time.Second,
|
||||
stalePending: 600 * time.Second,
|
||||
events: events,
|
||||
users: users,
|
||||
settings: settings,
|
||||
store: store,
|
||||
inflight: inflight,
|
||||
showcase: showcase,
|
||||
orders: orders,
|
||||
interval: 60 * time.Second,
|
||||
stalePending: 600 * time.Second,
|
||||
// 视频比图片慢得多,单条执行预算就是 30 分钟(videoGenBudget),用图片的
|
||||
// 10 分钟去扫会在它出片前判死并退款。
|
||||
stalePendingVid: 30 * time.Minute,
|
||||
@@ -63,10 +54,6 @@ func NewMaintenanceService(tokens *repo.TokenRepository, tokenSvc *TokenService,
|
||||
func (m *MaintenanceService) Run(ctx context.Context) {
|
||||
ticker := time.NewTicker(m.interval)
|
||||
defer ticker.Stop()
|
||||
// The leonardo session keep-alive gets its own loop: one tick() can take tens
|
||||
// of minutes (219 adobe cookie profiles alone), which would stretch a 5-minute
|
||||
// keep-alive to the tick's real duration.
|
||||
go m.runLeonardoKeepalive(ctx)
|
||||
m.tick(ctx)
|
||||
for {
|
||||
select {
|
||||
@@ -78,55 +65,6 @@ func (m *MaintenanceService) Run(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// runLeonardoKeepalive re-checks every minute which leonardo accounts are due for
|
||||
// a session renewal (the 5-minute due-ness itself is read per account from the DB,
|
||||
// so a restart can't skip one).
|
||||
func (m *MaintenanceService) runLeonardoKeepalive(ctx context.Context) {
|
||||
if m.tokenSvc == nil {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
m.tokenSvc.RefreshLeonardoSessions(ctx)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// syncRecoveredQuota re-probes each just-recovered account so its displayed
|
||||
// balance reflects the post-reset value (these providers only sync quota when
|
||||
// accessed). krea additionally needs /app (Activate) to actually grant the daily
|
||||
// free balance before billing-data reports it. Bounded concurrency avoids a
|
||||
// thundering herd at the daily reset.
|
||||
func (m *MaintenanceService) syncRecoveredQuota(accs []model.TokenAccount) {
|
||||
sem := make(chan struct{}, 4)
|
||||
var wg sync.WaitGroup
|
||||
for _, acc := range accs {
|
||||
switch acc.Pool {
|
||||
case "chatgpt", "leonardo", "krea", "imagine":
|
||||
default:
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(a model.TokenAccount) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
if a.Pool == "krea" && m.tokenSvc.krea != nil {
|
||||
m.tokenSvc.krea.Activate(ctx, a.Value)
|
||||
}
|
||||
_, _ = m.tokenSvc.Quota(ctx, a.Pool, a.ID)
|
||||
}(acc)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (m *MaintenanceService) tick(ctx context.Context) {
|
||||
// 0. Auto-cancel unpaid recharge orders past their 30-min TTL.
|
||||
if m.orders != nil {
|
||||
@@ -137,74 +75,7 @@ func (m *MaintenanceService) tick(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Re-activate quota-exhausted tokens whose reset time has passed, then
|
||||
// auto-sync their real balance — these providers only refresh quota when
|
||||
// accessed, so recovery alone would leave a stale 0/—. For krea the sync
|
||||
// must first load /app (Activate) to grant the daily free balance.
|
||||
if recovered, err := m.tokens.RecoverQuota(ctx); err != nil {
|
||||
log.Printf("maintenance: recover_quota: %v", err)
|
||||
} else if len(recovered) > 0 {
|
||||
log.Printf("maintenance: recovered %d quota token(s)", len(recovered))
|
||||
if m.tokenSvc != nil {
|
||||
go m.syncRecoveredQuota(recovered)
|
||||
}
|
||||
}
|
||||
|
||||
// 1a. Roll the 恢复时间 marker of ACTIVE daily-reset accounts forward to the next
|
||||
// future reset (same time-of-day, +1 day) so the column never shows a stale
|
||||
// past time. Limited accounts are intentionally skipped (RecoverQuota owns
|
||||
// their marker). adobe/leonardo/krea/imagine all renew daily.
|
||||
if _, err := m.tokens.RollResetMarkers(ctx, []string{"adobe", "leonardo", "krea", "imagine"}); err != nil {
|
||||
log.Printf("maintenance: roll_reset: %v", err)
|
||||
}
|
||||
|
||||
// 1b. Runway tokens have no refresh — once the JWT expiry marker passes the
|
||||
// token can only 401, so flip it to disabled+dead proactively instead of
|
||||
// leaving a doomed account "active". Grok is intentionally NOT swept here:
|
||||
// its reset marker is billingPeriodEnd (a credits-renewal date), not a
|
||||
// death deadline — a grok sso keeps working past billingPeriodEnd, so
|
||||
// expiring on it kills live accounts. Grok death is caught for real by the
|
||||
// import-time FetchSession check and by marking dead on a 401 at use.
|
||||
for _, pool := range []string{"runway"} {
|
||||
if n, err := m.tokens.ExpireByReset(ctx, pool); err != nil {
|
||||
log.Printf("maintenance: expire_%s: %v", pool, err)
|
||||
} else if n > 0 {
|
||||
log.Printf("maintenance: expired %d %s token(s)", n, pool)
|
||||
}
|
||||
}
|
||||
|
||||
// 1c. Proactively renew krea/imagine sessions ~10min before expiry so a
|
||||
// dormant account's rotating refresh_token never lapses (a dead token
|
||||
// can't be recovered and, for krea, blocks the daily free-credit meter
|
||||
// from being re-created). Only near-expiry accounts hit the network.
|
||||
if m.tokenSvc != nil {
|
||||
m.tokenSvc.RefreshExpiringTokens(ctx)
|
||||
// 1d. Once-per-day krea /app activation for accounts not yet synced since the
|
||||
// daily reset — krea only grants the free balance after /app loads, so an
|
||||
// always-active account (never went 限额) would otherwise read 0 / 402
|
||||
// after each reset. Self-guarded + background; no-op once all are done.
|
||||
m.tokenSvc.ActivateKreaDue(ctx)
|
||||
// 1e. Re-validate grok accounts: an empty /rest/subscriptions (or 401)
|
||||
// means the membership lapsed → disable+dead; otherwise re-sync the
|
||||
// credits balance and 恢复时间 (from the credits' weekly reset).
|
||||
m.tokenSvc.RefreshGrokLiveness(ctx)
|
||||
}
|
||||
|
||||
// 2. Auto-renew Adobe cookies whose refresh interval has elapsed.
|
||||
if m.refresh != nil {
|
||||
if m.settings != nil {
|
||||
if proxy, err := m.settings.GetValue(ctx, "proxy.url"); err == nil && proxy != "" {
|
||||
m.refresh.SetProxy(proxy)
|
||||
}
|
||||
}
|
||||
if n, err := m.refresh.RefreshDue(ctx); err != nil {
|
||||
log.Printf("maintenance: refresh_due: %v", err)
|
||||
} else if n > 0 {
|
||||
log.Printf("maintenance: refreshed %d cookie profile(s)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fail long-pending events so they stop blocking the per-user gate, and
|
||||
// 1. Fail long-pending events so they stop blocking the per-user gate, and
|
||||
// refund the credits debited up-front for each abandoned generation (the
|
||||
// normal failure-refund path never ran for a process-restart orphan).
|
||||
if purged, err := m.events.PurgeStale(ctx, m.stalePending, m.stalePendingVid); err != nil {
|
||||
@@ -219,13 +90,6 @@ func (m *MaintenanceService) tick(ctx context.Context) {
|
||||
if m.inflight != nil && m.inflight.Cancel(e.ID) {
|
||||
cancelled++
|
||||
}
|
||||
// Attribute the abandoned failure back to the account it was using
|
||||
// (the normal markTokenFailure path never ran for an orphaned job).
|
||||
if e.AccountID != "" {
|
||||
if err := m.tokens.IncrementFail(ctx, e.AccountID); err != nil {
|
||||
log.Printf("maintenance: fail-count abandoned event %s (account %s): %v", e.ID, e.AccountID, err)
|
||||
}
|
||||
}
|
||||
if e.UserID == "" || e.Cost <= 0 {
|
||||
continue
|
||||
}
|
||||
@@ -248,10 +112,11 @@ func (m *MaintenanceService) tick(ctx context.Context) {
|
||||
log.Printf("maintenance: marked %d stale pending event(s) failed, refunded %d, cancelled %d in-flight", len(purged), refunded, cancelled)
|
||||
}
|
||||
|
||||
// 4. Enforce the admin-configured log retention window.
|
||||
// 2. Enforce the admin-configured log retention window.
|
||||
m.pruneLogs(ctx)
|
||||
|
||||
// 5. Enforce the media retention window. Runs every 60s like the log prune;
|
||||
// 3. Enforce the media retention window + sweep expired tmp/ (v1 API job
|
||||
// results) and stale refs/ objects. Runs every 60s like the log prune;
|
||||
// mediaPruneEvery still gates it in case the interval is ever shortened.
|
||||
if time.Since(m.lastMediaPrune) >= m.mediaPruneEvery {
|
||||
m.pruneMedia(ctx)
|
||||
@@ -274,10 +139,11 @@ func (m *MaintenanceService) pruneMedia(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
days := m.retentionDays(ctx, "media.retention_days")
|
||||
if days <= 0 {
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().Add(-time.Duration(days) * 24 * time.Hour)
|
||||
// tmp/ holds /v1 job results: presigned for 30 min, pruned shortly after.
|
||||
tmpCutoff := time.Now().Add(-40 * time.Minute)
|
||||
// <uid>/refs/ holds uploaded reference material: live for the presign TTL.
|
||||
refCutoff := time.Now().Add(-2 * PresignTTL)
|
||||
objs, err := m.store.List(ctx, "")
|
||||
if err != nil {
|
||||
log.Printf("maintenance: list media: %v", err)
|
||||
@@ -303,10 +169,20 @@ func (m *MaintenanceService) pruneMedia(ctx context.Context) {
|
||||
removed, skipped := 0, 0
|
||||
var clearedKeys []string
|
||||
for _, o := range objs {
|
||||
if !o.LastModified.Before(cutoff) {
|
||||
key := strings.TrimLeft(o.Key, "/")
|
||||
expired := false
|
||||
switch {
|
||||
case strings.HasPrefix(key, "tmp/"):
|
||||
expired = o.LastModified.Before(tmpCutoff)
|
||||
case strings.Contains(key, "/refs/"):
|
||||
expired = o.LastModified.Before(refCutoff)
|
||||
case days > 0:
|
||||
expired = o.LastModified.Before(cutoff)
|
||||
}
|
||||
if !expired {
|
||||
continue
|
||||
}
|
||||
if _, ok := pinned[strings.TrimLeft(o.Key, "/")]; ok {
|
||||
if _, ok := pinned[key]; ok {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backend/internal/model"
|
||||
"backend/internal/provider/adobe"
|
||||
"backend/internal/repo"
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
type RefreshProfileService struct {
|
||||
profiles *repo.RefreshProfileRepository
|
||||
tokens *repo.TokenRepository
|
||||
adobe *adobe.Client
|
||||
}
|
||||
|
||||
func NewRefreshProfileService(profiles *repo.RefreshProfileRepository, tokens *repo.TokenRepository, adobeClient *adobe.Client) *RefreshProfileService {
|
||||
return &RefreshProfileService{
|
||||
profiles: profiles,
|
||||
tokens: tokens,
|
||||
adobe: adobeClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RefreshProfileService) SetProxy(proxy string) {
|
||||
if s.adobe != nil {
|
||||
s.adobe.SetProxy(proxy)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RefreshProfileService) List(ctx context.Context) ([]model.RefreshProfile, error) {
|
||||
return s.profiles.List(ctx)
|
||||
}
|
||||
|
||||
func (s *RefreshProfileService) RefreshNow(ctx context.Context, id string) error {
|
||||
if s.adobe == nil || s.tokens == nil {
|
||||
return errors.New("refresh client not configured")
|
||||
}
|
||||
profile, err := s.profiles.Get(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if profile.Pool != "adobe" || profile.Kind != "adobe_cookie" {
|
||||
return errors.New("unsupported refresh profile")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
_, _ = s.profiles.Update(ctx, id, map[string]any{
|
||||
"last_attempt_at": now,
|
||||
})
|
||||
|
||||
result, err := s.adobe.ExchangeCookie(ctx, profile.Cookie)
|
||||
if err != nil {
|
||||
failures := profile.ConsecutiveFailures + 1
|
||||
// Exponential backoff: 60s per consecutive failure, capped at 1h.
|
||||
secs := 60 * failures
|
||||
if secs > 3600 {
|
||||
secs = 3600
|
||||
}
|
||||
msg := err.Error()
|
||||
if len(msg) > 300 {
|
||||
msg = msg[:300]
|
||||
}
|
||||
_, _ = s.profiles.Update(ctx, id, map[string]any{
|
||||
"last_error": msg,
|
||||
"consecutive_failures": failures,
|
||||
"next_retry_at": now.Add(time.Duration(secs) * time.Second),
|
||||
})
|
||||
// Mark the token dead when the cookie exchange keeps failing:
|
||||
// - 5 consecutive failures → cookie is likely expired/revoked
|
||||
// - ride_AdobeID_acct_actreq (Adobe requires account action) → permanently broken
|
||||
if failures >= 5 || strings.Contains(msg, "ride_AdobeID_acct_actreq") {
|
||||
_, _ = s.tokens.Update(ctx, "adobe", id, map[string]any{
|
||||
"status": "disabled",
|
||||
"dead": true,
|
||||
})
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
tokenPatch := map[string]any{
|
||||
"value": result.AccessToken,
|
||||
"status": "active",
|
||||
"dead": false,
|
||||
"fails": 0,
|
||||
"updated_at": now,
|
||||
}
|
||||
email, _ := parseJWTEmailExpiry(result.AccessToken)
|
||||
if email != "" {
|
||||
tokenPatch["account_email"] = email
|
||||
}
|
||||
if profile.IntervalSeconds > 0 {
|
||||
tokenPatch["cached_quota_reset_after"] = now.Add(time.Duration(profile.IntervalSeconds) * time.Second).Format(time.RFC3339)
|
||||
} else {
|
||||
tokenPatch["cached_quota_reset_after"] = now.Add(54000 * time.Second).Format(time.RFC3339)
|
||||
}
|
||||
if profileData, profileErr := s.adobe.FetchAccountProfile(ctx, result.AccessToken); profileErr == nil {
|
||||
if email := strings.TrimSpace(stringValue(profileData["email"])); email != "" {
|
||||
tokenPatch["account_email"] = email
|
||||
}
|
||||
if displayName := strings.TrimSpace(stringValue(profileData["display_name"])); displayName != "" {
|
||||
tokenPatch["account_display_name"] = displayName
|
||||
}
|
||||
}
|
||||
planKnown := false
|
||||
if quotaData, quotaErr := s.adobe.FetchCreditsBalance(ctx, result.AccessToken); quotaErr == nil {
|
||||
meta := datatypes.JSONMap{
|
||||
"cached_quota_at": int(time.Now().Unix()),
|
||||
}
|
||||
if remaining, ok := quotaData["remaining"].(int); ok {
|
||||
meta["cached_quota_remaining"] = remaining
|
||||
}
|
||||
if used, ok := quotaData["used"].(int); ok {
|
||||
meta["cached_quota_used"] = used
|
||||
}
|
||||
if total, ok := quotaData["total"].(int); ok {
|
||||
meta["cached_quota_total"] = total
|
||||
}
|
||||
tokenPatch["meta"] = meta
|
||||
|
||||
planCap := strings.ToLower(strings.TrimSpace(stringValue(quotaData["plan"])))
|
||||
isVIP := planCap != "" && !strings.EqualFold(planCap, "free")
|
||||
planKnown = planCap != ""
|
||||
// 额度打到 0 的 VIP(母号/子号) 视为普号:写死 plan=free,额度恢复(>0)后下次刷新自动还原真实身份。
|
||||
if isVIP {
|
||||
if rem, ok := quotaData["remaining"].(int); ok && rem <= 0 {
|
||||
isVIP = false
|
||||
planCap = "free"
|
||||
}
|
||||
}
|
||||
meta["plan"] = planCap
|
||||
|
||||
// VIP: use Adobe's reset time; set concurrency 5.
|
||||
// 母号/子号 身份固定,不随积分动态变化——只有降级普号才刷新身份。
|
||||
if isVIP {
|
||||
if resetAfter := strings.TrimSpace(stringValue(quotaData["available_until"])); resetAfter != "" {
|
||||
tokenPatch["cached_quota_reset_after"] = resetAfter
|
||||
}
|
||||
tokenPatch["concurrency"] = 5
|
||||
// 身份固定:这里重建了整个 meta,若不带上 is_sub_account,刷新会把导入时
|
||||
// 确定的 母号/子号 标记冲掉。身份只在导入时确定,刷新只沿用、绝不靠积分猜:
|
||||
// 已有标记就带过来;取不到(没 meta / 缺字段)说明身份未知,直接置死号。
|
||||
if existing, gerr := s.tokens.Get(ctx, "adobe", id); gerr == nil {
|
||||
if v, ok := existing.Meta["is_sub_account"]; ok {
|
||||
meta["is_sub_account"] = v
|
||||
}
|
||||
}
|
||||
if _, ok := meta["is_sub_account"]; !ok {
|
||||
tokenPatch["status"] = "disabled"
|
||||
tokenPatch["dead"] = true
|
||||
}
|
||||
} else {
|
||||
// Free account: concurrency 1, limit image+video
|
||||
tokenPatch["concurrency"] = 1
|
||||
tokenPatch["image_limited"] = true
|
||||
tokenPatch["video_limited"] = true
|
||||
meta["is_sub_account"] = false
|
||||
}
|
||||
}
|
||||
// 探测不到会员身份(credits 接口失败或没返回 plan)的号既不算普号也不算会员号,
|
||||
// 留在池里只会在需要会员的请求上撞 403 —— 直接置死号。
|
||||
if !planKnown {
|
||||
tokenPatch["status"] = "disabled"
|
||||
tokenPatch["dead"] = true
|
||||
}
|
||||
if _, err := s.tokens.Update(ctx, "adobe", id, tokenPatch); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
interval := profile.IntervalSeconds
|
||||
if interval <= 0 {
|
||||
interval = 54000
|
||||
}
|
||||
_, err = s.profiles.Update(ctx, id, map[string]any{
|
||||
"last_success_at": now,
|
||||
"next_retry_at": now.Add(time.Duration(interval) * time.Second),
|
||||
"last_error": "",
|
||||
"consecutive_failures": 0,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// RefreshDue refreshes every enabled profile whose next_retry_at has passed.
|
||||
// Driven by the background maintenance loop so Adobe cookies auto-renew without
|
||||
// an admin clicking "refresh". Individual failures are recorded on the profile
|
||||
// (backoff + dead escalation) and don't abort the sweep.
|
||||
func (s *RefreshProfileService) RefreshDue(ctx context.Context) (int, error) {
|
||||
if s.adobe == nil || s.tokens == nil {
|
||||
return 0, nil
|
||||
}
|
||||
due, err := s.profiles.ListDue(ctx, time.Now())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
refreshed := 0
|
||||
for _, p := range due {
|
||||
if p.Pool != "adobe" || p.Kind != "adobe_cookie" {
|
||||
continue
|
||||
}
|
||||
if err := s.RefreshNow(ctx, p.ID); err != nil {
|
||||
continue
|
||||
}
|
||||
refreshed++
|
||||
}
|
||||
return refreshed, nil
|
||||
}
|
||||
|
||||
func (s *RefreshProfileService) Update(ctx context.Context, id string, body map[string]any) (*model.RefreshProfile, error) {
|
||||
patch := map[string]any{}
|
||||
if raw, ok := body["enabled"]; ok {
|
||||
patch["enabled"] = boolValueWithDefault(raw, false)
|
||||
}
|
||||
if raw, ok := body["name"]; ok {
|
||||
patch["name"] = stringValue(raw)
|
||||
}
|
||||
if raw, ok := body["interval_seconds"]; ok {
|
||||
n := intValue(raw)
|
||||
if n <= 0 {
|
||||
return nil, errors.New("interval_seconds must be positive")
|
||||
}
|
||||
patch["interval_seconds"] = n
|
||||
}
|
||||
if len(patch) == 0 {
|
||||
return s.profiles.Get(ctx, id)
|
||||
}
|
||||
return s.profiles.Update(ctx, id, patch)
|
||||
}
|
||||
|
||||
func (s *RefreshProfileService) Delete(ctx context.Context, id string) error {
|
||||
return s.profiles.Delete(ctx, id)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
// SaveOwnedImage stores a user-uploaded image under their owner directory so it
|
||||
// behaves like a generated output: it shows up in the 我的图片 picker, can be
|
||||
// dropped onto the infinite canvas, and is served by /images/<owner>/<name>.
|
||||
// format must be a decoder-registered image format ("png", "jpeg", ...).
|
||||
func (s *AdminReadService) SaveOwnedImage(ctx context.Context, owner, format string, data []byte) (string, error) {
|
||||
owner = strings.TrimSpace(owner)
|
||||
if owner == "" || s.store == nil || !s.store.Configured() {
|
||||
return "", errors.New("storage not configured")
|
||||
}
|
||||
ext := strings.ToLower(strings.TrimSpace(format))
|
||||
contentType := ""
|
||||
switch ext {
|
||||
case "jpeg":
|
||||
ext = "jpg"
|
||||
contentType = "image/jpeg"
|
||||
case "png", "gif", "webp":
|
||||
contentType = "image/" + ext
|
||||
default:
|
||||
return "", errors.New("unsupported image format")
|
||||
}
|
||||
if _, _, err := image.DecodeConfig(bytes.NewReader(data)); err != nil {
|
||||
return "", errors.New("invalid image data")
|
||||
}
|
||||
key := fmt.Sprintf("%s/up_%d%s.%s", owner, time.Now().Unix(), randomUpper(6), ext)
|
||||
if err := s.store.Put(ctx, key, data, contentType); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Best-effort thumbnail, same convention as generated outputs.
|
||||
if thumb, terr := makeThumbnail(data); terr == nil {
|
||||
_ = s.store.Put(ctx, ThumbKey(key), thumb, "image/jpeg")
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
@@ -40,8 +40,8 @@ type UserGenerateRequest struct {
|
||||
// DeAI applies 去AI特征 post-processing to the generated image (image only)
|
||||
// and charges the per-tier surcharge on top of the model price.
|
||||
DeAI bool
|
||||
// AccountID pins an admin test to one specific provider account (账号生图测试).
|
||||
AccountID string
|
||||
// ChannelID pins an admin test to one specific channel (渠道生图测试).
|
||||
ChannelID string
|
||||
}
|
||||
|
||||
func (s *UserGenerationService) Generate(ctx context.Context, user *model.User, in UserGenerateRequest) (map[string]any, error) {
|
||||
@@ -95,9 +95,10 @@ func (s *UserGenerationService) Generate(ctx context.Context, user *model.User,
|
||||
}
|
||||
}
|
||||
|
||||
// validateReferenceMode mirrors the /v1 checks: the override must be "frame"
|
||||
// or "asset", the model must support references at all, and frame mode carries
|
||||
// at most 2 images (first+last frame).
|
||||
// validateReferenceMode mirrors the /v1 checks: the override must be "frame",
|
||||
// "asset" or "media", the model must support references at all, and frame mode
|
||||
// carries at most 2 images (first+last frame). "media" = 参考视频/音频 — refs
|
||||
// may be video or audio blobs (vivid-style upstreams that accept all kinds).
|
||||
func validateReferenceMode(rm string, modelItem *model.ModelConfig, refCount int) error {
|
||||
rm = strings.TrimSpace(rm)
|
||||
if rm == "" {
|
||||
@@ -107,8 +108,8 @@ func validateReferenceMode(rm string, modelItem *model.ModelConfig, refCount int
|
||||
if supported == "" || supported == "none" {
|
||||
return fmt.Errorf("%w: reference_mode not supported for this model", ErrUnsupportedParams)
|
||||
}
|
||||
if rm != "frame" && rm != "asset" {
|
||||
return fmt.Errorf("%w: reference_mode must be 'frame' or 'asset'", ErrUnsupportedParams)
|
||||
if rm != "frame" && rm != "asset" && rm != "media" {
|
||||
return fmt.Errorf("%w: reference_mode must be 'frame', 'asset' or 'media'", ErrUnsupportedParams)
|
||||
}
|
||||
if rm == "frame" && refCount > 2 {
|
||||
return fmt.Errorf("%w: frame mode supports at most 2 reference images (first+last frame), got %d", ErrUnsupportedParams, refCount)
|
||||
@@ -137,7 +138,7 @@ func (s *UserGenerationService) AdminTest(ctx context.Context, user *model.User,
|
||||
AspectRatio: in.Ratio,
|
||||
Resolution: in.Resolution,
|
||||
ReferenceImages: in.ReferenceImages,
|
||||
AccountID: in.AccountID,
|
||||
ChannelID: in.ChannelID,
|
||||
})
|
||||
default:
|
||||
return s.v1.prepareAdminTestImage(ctx, principal, V1ImageRequest{
|
||||
@@ -146,7 +147,7 @@ func (s *UserGenerationService) AdminTest(ctx context.Context, user *model.User,
|
||||
AspectRatio: in.Ratio,
|
||||
Resolution: in.Resolution,
|
||||
ReferenceImages: in.ReferenceImages,
|
||||
AccountID: in.AccountID,
|
||||
ChannelID: in.ChannelID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+938
-3165
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,83 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PresignGet issues a query-string SigV4 URL that lets anyone holding it GET
|
||||
// the object for ttl. Used for /v1/generate result URLs and reference
|
||||
// read-back.
|
||||
func (c *Client) PresignGet(key string, ttl time.Duration) (string, error) {
|
||||
return c.presign(http.MethodGet, key, "", ttl)
|
||||
}
|
||||
|
||||
// PresignPut issues a query-string SigV4 URL bound to PUT with the exact
|
||||
// contentType signed in — the uploader must send that Content-Type verbatim
|
||||
// (same contract vivid upstreams expect on /v1/uploads sessions).
|
||||
func (c *Client) PresignPut(key, contentType string, ttl time.Duration) (string, error) {
|
||||
if strings.TrimSpace(contentType) == "" {
|
||||
return "", fmt.Errorf("presign put requires contentType")
|
||||
}
|
||||
return c.presign(http.MethodPut, key, contentType, ttl)
|
||||
}
|
||||
|
||||
// presign builds an AWS SigV4 presigned URL (UNSIGNED-PAYLOAD). Signed headers
|
||||
// are host (+content-type for PUT). Validity is capped at 7 days by SigV4.
|
||||
func (c *Client) presign(method, key, contentType string, ttl time.Duration) (string, error) {
|
||||
if !c.Configured() {
|
||||
return "", fmt.Errorf("rustfs not configured")
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = 30 * time.Minute
|
||||
}
|
||||
if ttl > 7*24*time.Hour {
|
||||
ttl = 7 * 24 * time.Hour
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
amzDate := now.Format("20060102T150405Z")
|
||||
dateStamp := now.Format("20060102")
|
||||
scope := dateStamp + "/" + region + "/" + service + "/aws4_request"
|
||||
|
||||
signedHeaders := "host"
|
||||
if contentType != "" {
|
||||
signedHeaders = "content-type;host"
|
||||
}
|
||||
|
||||
q := map[string]string{
|
||||
"X-Amz-Algorithm": "AWS4-HMAC-SHA256",
|
||||
"X-Amz-Credential": c.ak + "/" + scope,
|
||||
"X-Amz-Date": amzDate,
|
||||
"X-Amz-Expires": strconv.FormatInt(int64(ttl.Seconds()), 10),
|
||||
"X-Amz-SignedHeaders": signedHeaders,
|
||||
}
|
||||
canonicalURI := "/" + uriEncode(c.bucket+"/"+strings.TrimPrefix(key, "/"), true)
|
||||
canonicalQuery := canonicalQueryString(q)
|
||||
|
||||
var canonHeaders strings.Builder
|
||||
if contentType != "" {
|
||||
canonHeaders.WriteString("content-type:" + contentType + "\n")
|
||||
}
|
||||
canonHeaders.WriteString("host:" + c.host + "\n")
|
||||
|
||||
canonicalRequest := strings.Join([]string{
|
||||
method, canonicalURI, canonicalQuery, canonHeaders.String(), signedHeaders, "UNSIGNED-PAYLOAD",
|
||||
}, "\n")
|
||||
stringToSign := strings.Join([]string{
|
||||
"AWS4-HMAC-SHA256", amzDate, scope, hexSHA256([]byte(canonicalRequest)),
|
||||
}, "\n")
|
||||
sig := hex.EncodeToString(hmacSHA256(signingKey(c.sk, dateStamp), []byte(stringToSign)))
|
||||
|
||||
// Rebuild the query with url.Values so the signature chars get escaped
|
||||
// correctly, then append X-Amz-Signature verbatim (hex, safe).
|
||||
vals := url.Values{}
|
||||
for k, v := range q {
|
||||
vals.Set(k, v)
|
||||
}
|
||||
return c.endpoint + canonicalURI + "?" + vals.Encode() + "&X-Amz-Signature=" + sig, nil
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
// Package upstream is the vivid-protocol client used to talk to channel
|
||||
// upstreams (other same-protocol instances). It covers the full public
|
||||
// surface: POST /v1/generate (NDJSON stream with jobId reconnect), POST
|
||||
// /v1/uploads (presigned reference upload), GET /v1/models, GET /v1/balance,
|
||||
// plus presigned-URL result download.
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Error classes. Dispatch decisions (retry / fail over / fail the job) key off
|
||||
// these sentinels via errors.Is — KindOf maps them for log messages.
|
||||
var (
|
||||
// ErrAuth = channel credentials rejected (4011 / HTTP 401 / 403). The
|
||||
// channel is skipped without consuming a retry budget.
|
||||
ErrAuth = errors.New("upstream auth rejected")
|
||||
// ErrInvalid = upstream rejected the request itself (4001 / HTTP 4xx).
|
||||
// Retrying on another channel would fail identically — terminal.
|
||||
ErrInvalid = errors.New("upstream rejected request")
|
||||
// ErrJobFailed = the upstream ran the job and returned a failed frame.
|
||||
// The frame's error text is user-safe and surfaced as-is.
|
||||
ErrJobFailed = errors.New("upstream job failed")
|
||||
// ErrRetryable = transient upstream failure (4293 / 5001 / 5xx / quota
|
||||
// / timeout / transport). Fail over to the next channel.
|
||||
ErrRetryable = errors.New("upstream temporary failure")
|
||||
// ErrAmbiguous = the generate request was sent but the stream dropped
|
||||
// before any jobId arrived — the job may or may not exist upstream.
|
||||
// Treated as retryable for dispatch purposes.
|
||||
ErrAmbiguous = errors.New("upstream submission ambiguous")
|
||||
)
|
||||
|
||||
// Kind is the coarse dispatch classification of an upstream error.
|
||||
type Kind int
|
||||
|
||||
const (
|
||||
KindRetryable Kind = iota
|
||||
KindAuth
|
||||
KindInvalid
|
||||
KindJobFailed
|
||||
)
|
||||
|
||||
// KindOf classifies an error returned by this package.
|
||||
func KindOf(err error) Kind {
|
||||
switch {
|
||||
case errors.Is(err, ErrAuth):
|
||||
return KindAuth
|
||||
case errors.Is(err, ErrInvalid):
|
||||
return KindInvalid
|
||||
case errors.Is(err, ErrJobFailed):
|
||||
return KindJobFailed
|
||||
default:
|
||||
return KindRetryable
|
||||
}
|
||||
}
|
||||
|
||||
// Failoverable reports whether the request should be re-tried on another
|
||||
// channel. ErrInvalid and ErrJobFailed are deterministic — same request, same
|
||||
// result — so they are terminal.
|
||||
func Failoverable(err error) bool {
|
||||
return !errors.Is(err, ErrInvalid) && !errors.Is(err, ErrJobFailed)
|
||||
}
|
||||
|
||||
// GenParams is the POST /v1/generate request body.
|
||||
type GenParams struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
Quality string `json:"quality,omitempty"`
|
||||
Ratio string `json:"ratio,omitempty"`
|
||||
Duration int64 `json:"duration,omitempty"`
|
||||
Refs []string `json:"refs,omitempty"`
|
||||
JobID string `json:"jobId,omitempty"`
|
||||
}
|
||||
|
||||
// RemoteModel mirrors one item of a vivid GET /v1/models response.
|
||||
type RemoteModel struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Qualities []string `json:"qualities"`
|
||||
Duration *RemoteDuration `json:"duration,omitempty"`
|
||||
// Per-kind reference caps; 0 = that kind is not accepted.
|
||||
RefImages int64 `json:"refImages"`
|
||||
RefVideos int64 `json:"refVideos"`
|
||||
RefAudios int64 `json:"refAudios"`
|
||||
// Combined reference duration caps in seconds; 0 = unlimited.
|
||||
RefVideoSeconds int64 `json:"refVideoSeconds,omitempty"`
|
||||
RefAudioSeconds int64 `json:"refAudioSeconds,omitempty"`
|
||||
}
|
||||
|
||||
// RemoteDuration mirrors the duration spec of a vivid model item.
|
||||
type RemoteDuration struct {
|
||||
Mode string `json:"mode"` // fixed | options | range
|
||||
Seconds int64 `json:"seconds,omitempty"`
|
||||
Min int64 `json:"min,omitempty"`
|
||||
Max int64 `json:"max,omitempty"`
|
||||
Options []int64 `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
// Client is stateless; per-call baseURL/key come from the channel row.
|
||||
type Client struct {
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
func NewClient() *Client {
|
||||
return &Client{HTTP: &http.Client{Timeout: 30 * time.Minute}}
|
||||
}
|
||||
|
||||
func (c *Client) http() *http.Client {
|
||||
if c != nil && c.HTTP != nil {
|
||||
return c.HTTP
|
||||
}
|
||||
return NewClient().HTTP
|
||||
}
|
||||
|
||||
// maxStreamFailures caps reconnect attempts after a jobId is known — each
|
||||
// reconnect re-opens /v1/generate with {jobId} and never double-charges.
|
||||
const maxStreamFailures = 3
|
||||
|
||||
// Generate submits a vivid job and reads the NDJSON stream to a terminal
|
||||
// frame, reconnecting with the reported jobId when the stream drops mid-run
|
||||
// (upstream heartbeat keeps us attached; transport blips don't lose the job).
|
||||
// onJobID, when non-nil, fires once the upstream assigns a job id. Returns the
|
||||
// presigned result URL and the credits the upstream reports charging us.
|
||||
func (c *Client) Generate(ctx context.Context, baseURL, apiKey string, p GenParams, waitSeconds int64, onJobID func(string)) (resultURL string, credits int64, err error) {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if baseURL == "" || apiKey == "" {
|
||||
return "", 0, ErrAuth
|
||||
}
|
||||
var last map[string]any
|
||||
failures := 0
|
||||
for {
|
||||
term, ferr := c.streamOnce(ctx, baseURL, apiKey, &p, waitSeconds, onJobID)
|
||||
if ctx.Err() != nil {
|
||||
return "", 0, ctx.Err()
|
||||
}
|
||||
if ferr != nil && !errors.Is(ferr, ErrRetryable) && !errors.Is(ferr, ErrAmbiguous) {
|
||||
return "", 0, ferr
|
||||
}
|
||||
if ferr != nil {
|
||||
failures++
|
||||
} else {
|
||||
failures = 0
|
||||
}
|
||||
if ferr == nil && term != nil {
|
||||
status := strings.ToLower(strings.TrimSpace(str(term["status"])))
|
||||
if status != "running" && status != "" {
|
||||
last = term
|
||||
break
|
||||
}
|
||||
}
|
||||
if p.JobID == "" {
|
||||
if ferr != nil {
|
||||
return "", 0, ferr
|
||||
}
|
||||
return "", 0, ErrAmbiguous
|
||||
}
|
||||
if failures >= maxStreamFailures {
|
||||
if ferr != nil {
|
||||
return "", 0, ferr
|
||||
}
|
||||
return "", 0, fmt.Errorf("%w: stream dropped repeatedly", ErrRetryable)
|
||||
}
|
||||
if sleepCtx(ctx, 2*time.Second) != nil {
|
||||
return "", 0, ctx.Err()
|
||||
}
|
||||
}
|
||||
status := strings.ToLower(strings.TrimSpace(str(last["status"])))
|
||||
switch status {
|
||||
case "succeeded", "completed", "success":
|
||||
resultURL = strings.TrimSpace(str(last["result"]))
|
||||
if resultURL == "" {
|
||||
return "", 0, fmt.Errorf("%w: succeeded frame missing result", ErrRetryable)
|
||||
}
|
||||
return resultURL, int64(num(last["creditsCharged"])), nil
|
||||
default:
|
||||
reason := strings.TrimSpace(str(last["error"]))
|
||||
if reason == "" {
|
||||
reason = "upstream job " + status
|
||||
}
|
||||
if isCreditError(reason) {
|
||||
return "", 0, fmt.Errorf("%w: %s", ErrRetryable, clip([]byte(reason), 160))
|
||||
}
|
||||
// failed 帧的 error 是上游给用户看的中文文案 — 透传
|
||||
return "", 0, fmt.Errorf("%w: %s", ErrJobFailed, clip([]byte(reason), 160))
|
||||
}
|
||||
}
|
||||
|
||||
// streamOnce performs one /v1/generate call and scans the NDJSON body. It
|
||||
// records the upstream jobId into p.JobID as soon as a frame carries it.
|
||||
func (c *Client) streamOnce(ctx context.Context, baseURL, apiKey string, p *GenParams, waitSeconds int64, onJobID func(string)) (map[string]any, error) {
|
||||
raw, _ := json.Marshal(p)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/v1/generate", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if waitSeconds <= 0 {
|
||||
waitSeconds = 300
|
||||
}
|
||||
req.Header.Set("X-Wait-Seconds", fmt.Sprintf("%d", waitSeconds))
|
||||
resp, err := c.http().Do(req)
|
||||
if err != nil {
|
||||
if p.JobID == "" {
|
||||
return nil, fmt.Errorf("%w: %s", ErrAmbiguous, sanitizeErr(err))
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrRetryable, sanitizeErr(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, vividError(resp.StatusCode, body)
|
||||
}
|
||||
var last map[string]any
|
||||
sc := bufio.NewScanner(resp.Body)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||
for sc.Scan() {
|
||||
line := bytes.TrimSpace(sc.Bytes())
|
||||
if len(line) == 0 {
|
||||
continue // 25s heartbeat empty line
|
||||
}
|
||||
var frame map[string]any
|
||||
if json.Unmarshal(line, &frame) != nil {
|
||||
continue
|
||||
}
|
||||
last = frame
|
||||
if job := strings.TrimSpace(str(frame["jobId"])); job != "" && job != p.JobID {
|
||||
p.JobID = job
|
||||
if onJobID != nil {
|
||||
onJobID(job)
|
||||
}
|
||||
}
|
||||
if status := strings.ToLower(strings.TrimSpace(str(frame["status"]))); status == "succeeded" || status == "failed" {
|
||||
return frame, nil
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
if p.JobID == "" {
|
||||
return nil, fmt.Errorf("%w: %s", ErrAmbiguous, sanitizeErr(err))
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrRetryable, sanitizeErr(err))
|
||||
}
|
||||
return last, nil
|
||||
}
|
||||
|
||||
// UploadRef runs the two-step reference upload: POST /v1/uploads {kind,mime}
|
||||
// then PUT the bytes to the presigned url with the exact signed Content-Type.
|
||||
// Returns the object key for refs[].
|
||||
func (c *Client) UploadRef(ctx context.Context, baseURL, apiKey string, data []byte) (string, error) {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if baseURL == "" || apiKey == "" {
|
||||
return "", ErrAuth
|
||||
}
|
||||
kind, mime := RefKindMime(data)
|
||||
if kind == "" {
|
||||
return "", fmt.Errorf("%w: 参考素材格式不支持", ErrInvalid)
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]any{"kind": kind, "mime": mime})
|
||||
up, err := c.postJSON(ctx, baseURL+"/v1/uploads", apiKey, raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
putURL, key := UploadSession(up)
|
||||
if putURL == "" || key == "" {
|
||||
rawUp, _ := json.Marshal(up)
|
||||
return "", fmt.Errorf("%w: upload session missing url/key (got %s)", ErrRetryable, clip(rawUp, 200))
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, putURL, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", mime) // must match the signed mime exactly
|
||||
resp, err := c.http().Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %s", ErrRetryable, sanitizeErr(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("%w: ref upload %d %s", ErrRetryable, resp.StatusCode, clip(body, 120))
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// UploadSession extracts the presigned PUT url + object key from a
|
||||
// /v1/uploads response. Implementations vary: some return the pair at top
|
||||
// level, others wrap it in {"data":{...}} / {"result":{...}}, and field
|
||||
// spellings differ (upload_url / presigned_url / put_url; key / file_key /
|
||||
// object_key). Search top level first, then one layer deep.
|
||||
func UploadSession(up map[string]any) (putURL, key string) {
|
||||
// Go map 查找区分大小写,而上游实现可能返回 PascalCase 字段;按去分隔符+
|
||||
// 小写归一化匹配。
|
||||
urlKeys := map[string]bool{"url": true, "uploadurl": true, "presignedurl": true, "puturl": true, "signedurl": true}
|
||||
keyKeys := map[string]bool{"key": true, "filekey": true, "objectkey": true, "uploadkey": true}
|
||||
norm := func(s string) string { return strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(s)) }
|
||||
pick := func(m map[string]any) (string, string) {
|
||||
var u, k string
|
||||
for name, v := range m {
|
||||
s := strings.TrimSpace(str(v))
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case u == "" && urlKeys[norm(name)]:
|
||||
u = s
|
||||
case k == "" && keyKeys[norm(name)]:
|
||||
k = s
|
||||
}
|
||||
}
|
||||
return u, k
|
||||
}
|
||||
levels := []map[string]any{up}
|
||||
for _, nest := range []string{"data", "result", "upload", "session", "upload_session"} {
|
||||
if sub, ok := up[nest].(map[string]any); ok {
|
||||
levels = append(levels, sub)
|
||||
}
|
||||
}
|
||||
for _, m := range levels {
|
||||
u, k := pick(m)
|
||||
if putURL == "" {
|
||||
putURL = u
|
||||
}
|
||||
if key == "" {
|
||||
key = k
|
||||
}
|
||||
}
|
||||
return putURL, key
|
||||
}
|
||||
|
||||
// Models fetches the upstream's sellable model list (GET /v1/models → bare
|
||||
// array of RemoteModel).
|
||||
func (c *Client) Models(ctx context.Context, baseURL, apiKey string) ([]RemoteModel, error) {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if baseURL == "" || apiKey == "" {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/v1/models", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
resp, err := c.http().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", ErrRetryable, sanitizeErr(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, vividError(resp.StatusCode, body)
|
||||
}
|
||||
var items []RemoteModel
|
||||
if err := json.Unmarshal(body, &items); err != nil {
|
||||
return nil, fmt.Errorf("%w: bad /v1/models body", ErrRetryable)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// Balance fetches the upstream balance (GET /v1/balance → {"balance":N}).
|
||||
// Returns nil when the upstream does not implement the endpoint.
|
||||
func (c *Client) Balance(ctx context.Context, baseURL, apiKey string) (*int64, error) {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if baseURL == "" || apiKey == "" {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/v1/balance", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
resp, err := c.http().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", ErrRetryable, sanitizeErr(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, vividError(resp.StatusCode, body)
|
||||
}
|
||||
var env struct {
|
||||
Balance *int64 `json:"balance"`
|
||||
}
|
||||
if json.Unmarshal(body, &env) != nil || env.Balance == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return env.Balance, nil
|
||||
}
|
||||
|
||||
// Download fetches a presigned result URL — no Authorization header.
|
||||
func (c *Client) Download(ctx context.Context, url string) ([]byte, string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
resp, err := c.http().Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("%w: %s", ErrRetryable, sanitizeErr(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, "", fmt.Errorf("%w: result download %d", ErrRetryable, resp.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, "", fmt.Errorf("%w: empty result", ErrRetryable)
|
||||
}
|
||||
ct := strings.TrimSpace(resp.Header.Get("Content-Type"))
|
||||
if ct == "" || ct == "application/octet-stream" {
|
||||
ct = http.DetectContentType(data)
|
||||
}
|
||||
return data, ct, nil
|
||||
}
|
||||
|
||||
func (c *Client) postJSON(ctx context.Context, url, apiKey string, body []byte) (map[string]any, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.http().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", ErrRetryable, sanitizeErr(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, vividError(resp.StatusCode, raw)
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, fmt.Errorf("%w: bad response %s", ErrRetryable, clip(raw, 120))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// vividError maps the {"error":{"code":"NNNN","message":...}} envelope to our
|
||||
// error classes; falls back to plain HTTP status mapping.
|
||||
func vividError(status int, body []byte) error {
|
||||
var e struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
code := ""
|
||||
msg := clip(body, 160)
|
||||
if json.Unmarshal(body, &e) == nil {
|
||||
code = strings.TrimSpace(e.Error.Code)
|
||||
if m := strings.TrimSpace(e.Error.Message); m != "" {
|
||||
msg = clip([]byte(m), 160)
|
||||
}
|
||||
}
|
||||
switch code {
|
||||
case "4011":
|
||||
return fmt.Errorf("%w: %s", ErrAuth, msg)
|
||||
case "4002":
|
||||
return fmt.Errorf("%w: 积分不足 %s", ErrRetryable, msg)
|
||||
case "4293", "5001":
|
||||
return fmt.Errorf("%w: %s", ErrRetryable, msg)
|
||||
case "4001":
|
||||
return fmt.Errorf("%w: %s", ErrInvalid, msg)
|
||||
}
|
||||
switch {
|
||||
case status == http.StatusUnauthorized || status == http.StatusForbidden:
|
||||
return fmt.Errorf("%w: http %d", ErrAuth, status)
|
||||
case status == http.StatusTooManyRequests || status >= 500:
|
||||
return fmt.Errorf("%w: http %d %s", ErrRetryable, status, msg)
|
||||
default:
|
||||
return fmt.Errorf("%w: http %d %s", ErrInvalid, status, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// RefKindMime classifies a reference blob into the vivid "kind" (image /
|
||||
// video / audio) plus a concrete mime. Magic bytes first (DetectContentType
|
||||
// misses mov/m4a/ogg-audio), then sniffing, then a png default.
|
||||
func RefKindMime(data []byte) (kind, mime string) {
|
||||
n := len(data)
|
||||
// ISOBMFF box: ftyp — mp4/mov/m4a share the container; the brand decides.
|
||||
if n >= 12 && string(data[4:8]) == "ftyp" {
|
||||
brand := string(data[8:12])
|
||||
switch {
|
||||
case strings.HasPrefix(brand, "M4A"), strings.HasPrefix(brand, "M4B"), strings.HasPrefix(brand, "F4A"), hasBMFFHandler(data, "soun") && !hasBMFFHandler(data, "vide"):
|
||||
return "audio", "audio/mp4"
|
||||
case brand == "qt ":
|
||||
return "video", "video/quicktime"
|
||||
default:
|
||||
return "video", "video/mp4"
|
||||
}
|
||||
}
|
||||
// WebM/MKV EBML
|
||||
if n >= 4 && data[0] == 0x1A && data[1] == 0x45 && data[2] == 0xDF && data[3] == 0xA3 && bytes.Contains(bytes.ToLower(data[:minInt(n, 4096)]), []byte("webm")) {
|
||||
return "video", "video/webm"
|
||||
}
|
||||
// MP3: ID3 tag or MPEG sync
|
||||
if n >= 3 && string(data[:3]) == "ID3" {
|
||||
return "audio", "audio/mpeg"
|
||||
}
|
||||
if n >= 4 && data[0] == 0xFF && (data[1]&0xE0) == 0xE0 &&
|
||||
(data[1]>>3)&3 != 1 && (data[1]>>1)&3 != 0 && data[2]>>4 != 0 && data[2]>>4 != 15 && (data[2]>>2)&3 != 3 {
|
||||
return "audio", "audio/mpeg"
|
||||
}
|
||||
// WAV / OGG
|
||||
if n >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WAVE" {
|
||||
return "audio", "audio/wav"
|
||||
}
|
||||
if n >= 27 && string(data[:4]) == "OggS" && (bytes.Contains(data[:minInt(n, 4096)], []byte("OpusHead")) ||
|
||||
bytes.Contains(data[:minInt(n, 4096)], []byte("vorbis")) || bytes.Contains(data[:minInt(n, 4096)], []byte("Speex ")) ||
|
||||
bytes.Contains(data[:minInt(n, 4096)], []byte("fLaC"))) {
|
||||
return "audio", "audio/ogg"
|
||||
}
|
||||
if m := http.DetectContentType(data); m == "image/png" || m == "image/jpeg" || m == "image/webp" {
|
||||
return "image", m
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func hasBMFFHandler(data []byte, handler string) bool {
|
||||
data = data[:minInt(len(data), 4096)]
|
||||
for offset := 0; offset < len(data); {
|
||||
i := bytes.Index(data[offset:], []byte("hdlr"))
|
||||
if i < 0 {
|
||||
return false
|
||||
}
|
||||
i += offset
|
||||
if i+16 <= len(data) && string(data[i+12:i+16]) == handler {
|
||||
return true
|
||||
}
|
||||
offset = i + 4
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isCreditError(s string) bool {
|
||||
m := strings.ToLower(s)
|
||||
return strings.Contains(m, "credit") || strings.Contains(m, "quota") ||
|
||||
strings.Contains(m, "insufficient") || strings.Contains(s, "积分不足") ||
|
||||
strings.Contains(s, "余额")
|
||||
}
|
||||
|
||||
func str(v any) string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case fmt.Stringer:
|
||||
return t.String()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func num(v any) float64 {
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
return t
|
||||
case int64:
|
||||
return float64(t)
|
||||
case json.Number:
|
||||
f, _ := t.Float64()
|
||||
return f
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func clip(b []byte, n int) string {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// sanitizeErr strips the upstream URL/host from a network error so a channel's
|
||||
// private upstream URL never leaks into the event log / API response.
|
||||
func sanitizeErr(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
s := err.Error()
|
||||
switch {
|
||||
case strings.Contains(s, "context deadline exceeded"), strings.Contains(s, "Client.Timeout"), strings.Contains(s, "timeout"):
|
||||
return "request timeout"
|
||||
case strings.Contains(s, "connection refused"):
|
||||
return "connection refused"
|
||||
case strings.Contains(s, "no such host"), strings.Contains(s, "dial tcp"), strings.Contains(s, "lookup "):
|
||||
return "cannot reach upstream"
|
||||
case strings.Contains(s, "tls"), strings.Contains(s, "TLS"), strings.Contains(s, "certificate"):
|
||||
return "TLS error"
|
||||
case strings.Contains(s, "EOF"), strings.Contains(s, "reset by peer"), strings.Contains(s, "broken pipe"):
|
||||
return "connection reset"
|
||||
}
|
||||
var ue *url.Error
|
||||
if errors.As(err, &ue) {
|
||||
return strings.ToLower(ue.Op) + " upstream failed"
|
||||
}
|
||||
return "upstream request failed"
|
||||
}
|
||||
|
||||
func sleepCtx(ctx context.Context, d time.Duration) error {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-t.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"math"
|
||||
)
|
||||
|
||||
// maxMediaDurationSec 时长 sanity 上限:解析出超过 24h 视为垃圾数据丢弃。
|
||||
const maxMediaDurationSec = 24 * 3600
|
||||
|
||||
// MediaDurationSeconds 从已读片段尽力解析媒体时长(秒)。
|
||||
// mp4/mov/m4a 扫描 mvhd 盒子;webm 解 EBML Duration×TimecodeScale;wav 按
|
||||
// byteRate 换算。解析不出返回 false——调用方可用文件尾片段再试一次
|
||||
// (未 faststart 的 mp4 moov 盒子常在文件尾)。mp3/ogg 无廉价可靠头长度,跳过。
|
||||
func MediaDurationSeconds(mime string, buf []byte) (float64, bool) {
|
||||
if len(buf) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
var sec float64
|
||||
var ok bool
|
||||
switch mime {
|
||||
case "video/mp4", "video/quicktime", "audio/mp4":
|
||||
sec, ok = mvhdDuration(buf)
|
||||
case "video/webm":
|
||||
sec, ok = webmDuration(buf)
|
||||
case "audio/wav":
|
||||
sec, ok = wavDuration(buf)
|
||||
}
|
||||
if !ok || sec <= 0 || sec > maxMediaDurationSec {
|
||||
return 0, false
|
||||
}
|
||||
return sec, true
|
||||
}
|
||||
|
||||
// mvhdDuration 在缓冲里扫 "mvhd" 盒子并解析 duration/timescale。
|
||||
// 盒子布局:[size4][type4=mvhd][version1][flags3]
|
||||
// v0: creation4 mod4 timescale4 duration4 → timescale@idx+16, duration@idx+20
|
||||
// v1: creation8 mod8 timescale4 duration8 → timescale@idx+24, duration@idx+28
|
||||
func mvhdDuration(buf []byte) (float64, bool) {
|
||||
for off := 0; ; {
|
||||
i := bytes.Index(buf[off:], []byte("mvhd"))
|
||||
if i < 0 {
|
||||
return 0, false
|
||||
}
|
||||
i += off
|
||||
if ver := buf[i+4]; ver == 0 && i+24 <= len(buf) {
|
||||
ts := binary.BigEndian.Uint32(buf[i+16 : i+20])
|
||||
dur := binary.BigEndian.Uint32(buf[i+20 : i+24])
|
||||
if ts > 0 && dur > 0 {
|
||||
return float64(dur) / float64(ts), true
|
||||
}
|
||||
} else if ver == 1 && i+36 <= len(buf) {
|
||||
ts := binary.BigEndian.Uint32(buf[i+24 : i+28])
|
||||
dur := binary.BigEndian.Uint64(buf[i+28 : i+36])
|
||||
if ts > 0 && dur > 0 {
|
||||
return float64(dur) / float64(ts), true
|
||||
}
|
||||
}
|
||||
off = i + 4
|
||||
}
|
||||
}
|
||||
|
||||
// webmDuration 扫 EBML Duration 元素(0x4489,float)和 TimecodeScale
|
||||
// (0x2AD7B1,uint,缺省 1,000,000ns)。秒 = duration × scale / 1e9。
|
||||
func webmDuration(buf []byte) (float64, bool) {
|
||||
scale := uint64(1_000_000)
|
||||
if i := bytes.Index(buf, []byte{0x2a, 0xd7, 0xb1}); i >= 0 {
|
||||
if v, n := ebmlVint(buf[i+3:]); n > 0 && i+3+n+v <= len(buf) {
|
||||
scale = 0
|
||||
for _, b := range buf[i+3+n : i+3+n+v] {
|
||||
scale = scale<<8 | uint64(b)
|
||||
}
|
||||
if scale == 0 {
|
||||
scale = 1_000_000
|
||||
}
|
||||
}
|
||||
}
|
||||
i := bytes.Index(buf, []byte{0x44, 0x89})
|
||||
if i < 0 {
|
||||
return 0, false
|
||||
}
|
||||
v, n := ebmlVint(buf[i+2:])
|
||||
if n == 0 || i+2+n+v > len(buf) {
|
||||
return 0, false
|
||||
}
|
||||
f := buf[i+2+n : i+2+n+v]
|
||||
var dur float64
|
||||
switch v {
|
||||
case 4:
|
||||
dur = float64(math.Float32frombits(binary.BigEndian.Uint32(f)))
|
||||
case 8:
|
||||
dur = math.Float64frombits(binary.BigEndian.Uint64(f))
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
return dur * float64(scale) / 1e9, true
|
||||
}
|
||||
|
||||
// ebmlVint 读 EBML 变长整数的长度值:返回 (负载长度, vint 本身字节数)。
|
||||
func ebmlVint(buf []byte) (int, int) {
|
||||
if len(buf) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
for n := 1; n <= 8; n++ {
|
||||
mask := byte(0x80 >> (n - 1))
|
||||
if buf[0]&mask != 0 {
|
||||
if len(buf) < n {
|
||||
return 0, 0
|
||||
}
|
||||
v := int(buf[0] &^ mask)
|
||||
for i := 1; i < n; i++ {
|
||||
v = v<<8 | int(buf[i])
|
||||
}
|
||||
return v, n
|
||||
}
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// wavDuration 解析 RIFF/WAVE:byteRate@28(LE u32) + "data" 块大小换算秒数。
|
||||
func wavDuration(buf []byte) (float64, bool) {
|
||||
if len(buf) < 44 || string(buf[:4]) != "RIFF" || string(buf[8:12]) != "WAVE" {
|
||||
return 0, false
|
||||
}
|
||||
rate := binary.LittleEndian.Uint32(buf[28:32])
|
||||
if rate == 0 {
|
||||
return 0, false
|
||||
}
|
||||
for off := 12; off+8 <= len(buf); {
|
||||
size := int(binary.LittleEndian.Uint32(buf[off+4 : off+8]))
|
||||
if string(buf[off:off+4]) == "data" {
|
||||
return float64(size) / float64(rate), true
|
||||
}
|
||||
off += 8 + size + size&1 // 块按 2 字节对齐
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
+51
-4
@@ -11,7 +11,13 @@ const BASE = import.meta.env.VITE_API_BASE || ''
|
||||
export async function api(path, opts = {}) {
|
||||
const headers = { ...(opts.headers || {}) }
|
||||
if (auth.token) headers.Authorization = `Bearer ${auth.token}`
|
||||
const r = await fetch(`${BASE}/admin/api${path}`, { ...opts, headers })
|
||||
// 请求不能无限挂起(比如上传走丢的网关连接)——120s 超时兜底
|
||||
let signal = opts.signal
|
||||
if (typeof AbortSignal !== 'undefined' && AbortSignal.timeout) {
|
||||
const t = AbortSignal.timeout(120000)
|
||||
signal = signal && AbortSignal.any ? AbortSignal.any([signal, t]) : t
|
||||
}
|
||||
const r = await fetch(`${BASE}/admin/api${path}`, { ...opts, headers, signal })
|
||||
let data = null
|
||||
try {
|
||||
data = await r.json()
|
||||
@@ -47,13 +53,54 @@ export async function fetchHealth() {
|
||||
return r.json()
|
||||
}
|
||||
|
||||
/** Absolute URL for a generated artifact (works in dev via proxy too). */
|
||||
/** Absolute URL for a generated artifact (works in dev via proxy too).
|
||||
* Older canvas documents may contain "/images/..." or a full URL instead of
|
||||
* the storage key; normalize all three forms instead of double-prefixing. */
|
||||
export function generatedUrl(name) {
|
||||
return `${BASE}/images/${name}`
|
||||
const s = String(name || '').trim()
|
||||
if (!s) return ''
|
||||
const marker = '/images/'
|
||||
const i = s.indexOf(marker)
|
||||
if (i >= 0) return /^https?:\/\//i.test(s) ? s : `${BASE}${s.slice(i)}`
|
||||
if (/^https?:\/\//i.test(s)) return s
|
||||
return `${BASE}/images/${s.replace(/^\/?images\//, '')}`
|
||||
}
|
||||
|
||||
/** Fetch a private image (thumbnail first, original as fallback) with the
|
||||
* SPA's Bearer token. Returns { blob, status } — throws on real failure. */
|
||||
async function fetchImageBlob(name, signal) {
|
||||
const headers = auth.token ? { Authorization: `Bearer ${auth.token}` } : {}
|
||||
let u = thumbUrl(name)
|
||||
let r = await fetch(u, { headers, credentials: 'include', signal })
|
||||
if (!r.ok || r.status === 404) {
|
||||
u = generatedUrl(name)
|
||||
r = await fetch(u, { headers, credentials: 'include', signal })
|
||||
}
|
||||
if (!r.ok) throw new Error(`图片加载失败 (${r.status})`)
|
||||
return { blob: await r.blob(), status: r.status }
|
||||
}
|
||||
|
||||
/** Same as fetchImageBlob but returns a revocable blob: URL for <img>/<video>. */
|
||||
export async function loadImageBlob(name, signal) {
|
||||
const { blob, status } = await fetchImageBlob(name, signal)
|
||||
return { url: URL.createObjectURL(blob), status }
|
||||
}
|
||||
|
||||
/** Same as fetchImageBlob but returns a data: URL (用于参考图提交/复制)。 */
|
||||
export async function loadImageData(name) {
|
||||
const { blob } = await fetchImageBlob(name)
|
||||
return new Promise((resolve, reject) => {
|
||||
const fr = new FileReader()
|
||||
fr.onload = () => resolve(fr.result)
|
||||
fr.onerror = reject
|
||||
fr.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
/** Small thumbnail URL for list views. The server falls back to the original
|
||||
* when no thumbnail object exists (old images), so this is always safe. */
|
||||
export function thumbUrl(name) {
|
||||
return `${BASE}/images/${name}.thumb.jpg`
|
||||
const url = generatedUrl(name)
|
||||
if (!url || url.endsWith('.thumb.jpg') || !url.includes('/images/')) return url
|
||||
return `${url}.thumb.jpg`
|
||||
}
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { api, jsonBody } from '../api'
|
||||
import Icon from './Icon.vue'
|
||||
|
||||
const props = defineProps({ account: { type: Object, required: true } })
|
||||
const emit = defineEmits(['close', 'saved'])
|
||||
|
||||
// concurrency is only adjustable for custom upstreams — others are system-fixed
|
||||
// (adobe is set from the plan on every quota refresh: 会员号 5 / 普号 1).
|
||||
const canEditConcurrency = props.account.type === 'custom'
|
||||
const fixedConcurrency =
|
||||
props.account.type === 'adobe'
|
||||
? (props.account.plan === 'free' ? 1 : Number(props.account.concurrency) || 5)
|
||||
: props.account.type === 'grok'
|
||||
? 10
|
||||
: 1
|
||||
|
||||
const weight = ref(Number(props.account.weight) || 0)
|
||||
const concurrency = ref(Number(props.account.concurrency) || 1)
|
||||
const status = ref('')
|
||||
const isError = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
async function submit() {
|
||||
submitting.value = true; status.value = ''; isError.value = false
|
||||
const payload = { weight: Number(weight.value) || 0 }
|
||||
if (canEditConcurrency) payload.concurrency = Math.max(1, Number(concurrency.value) || 1)
|
||||
try {
|
||||
const r = await api(`/tokens/${props.account.pool}/${props.account.id}`, jsonBody('PATCH', payload))
|
||||
if (r.ok) {
|
||||
status.value = '✓ 已保存'; emit('saved', payload)
|
||||
setTimeout(() => emit('close'), 600)
|
||||
} else {
|
||||
status.value = r.data?.detail || '保存失败'; isError.value = true
|
||||
}
|
||||
} catch (e) {
|
||||
status.value = String(e); isError.value = true
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center p-4 overflow-y-auto"
|
||||
@click.self="emit('close')">
|
||||
<div class="card !shadow-2xl my-12 w-full max-w-md">
|
||||
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold">编辑账号</h2>
|
||||
<button @click="emit('close')" class="text-white/40 hover:text-white">
|
||||
<Icon name="close" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-5 space-y-3">
|
||||
<div>
|
||||
<label class="lbl">账户</label>
|
||||
<input :value="account.email || account.id" disabled class="field font-mono" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">类型</label>
|
||||
<input :value="account.type" disabled class="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">权重 <span class="text-white/35">(高的优先)</span></label>
|
||||
<input v-model.number="weight" type="number" class="field" placeholder="0" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">并发数 <span class="text-white/35">{{ canEditConcurrency ? '(单账号)' : '(系统固定,不可调整)' }}</span></label>
|
||||
<input v-if="canEditConcurrency" v-model.number="concurrency" type="number" min="1" class="field" placeholder="1" />
|
||||
<input v-else :value="fixedConcurrency" disabled class="field" />
|
||||
</div>
|
||||
<p v-if="status" class="text-xs" :class="isError ? 'text-rose-400' : 'text-emerald-400'">{{ status }}</p>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button @click="emit('close')" class="btn-soft">取消</button>
|
||||
<button @click="submit" :disabled="submitting" class="btn-primary">{{ submitting ? '保存中…' : '保存' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* mirror the 用户管理 edit modal so the two are visually identical */
|
||||
.lbl {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
color: rgb(255 255 255 / 0.55);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
/* disabled inputs — readable, but visually 'cool' so the admin knows they
|
||||
can't change them (matches UsersView). */
|
||||
.field:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
}
|
||||
</style>
|
||||
@@ -1,168 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
import { api, jsonBody } from '../api'
|
||||
import Icon from './Icon.vue'
|
||||
import SelectMenu from './SelectMenu.vue'
|
||||
|
||||
// 账号生成测试:固定使用某个 provider 账号跑一次生成(图像或视频)。
|
||||
// 模型列表由 AccountsView 打开页面时预加载好传入,弹窗即开即用。
|
||||
const props = defineProps({
|
||||
account: { type: Object, required: true }, // { pool, id, email, type }
|
||||
allModels: { type: Array, default: () => [] },
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// 该账号所属 provider 的全部模型(图像 + 视频)。
|
||||
const models = computed(() =>
|
||||
props.allModels.filter((m) => (m.provider || '') === props.account.pool))
|
||||
const selectedModel = ref('')
|
||||
if (models.value.length) selectedModel.value = models.value[0].alias || models.value[0].id
|
||||
|
||||
const modelOptions = computed(() => models.value.map((m) => ({
|
||||
value: m.alias || m.id,
|
||||
label: `${m.alias || m.id}${m.type === 'video' ? ' · 视频' : ''}`,
|
||||
})))
|
||||
const currentModel = computed(() =>
|
||||
models.value.find((m) => (m.alias || m.id) === selectedModel.value) || null)
|
||||
const isVideo = computed(() => currentModel.value?.type === 'video')
|
||||
|
||||
const prompt = ref('a cute cat sitting on a desk, studio lighting')
|
||||
|
||||
const busy = ref(false)
|
||||
const status = ref('')
|
||||
const error = ref('')
|
||||
const resultUrl = ref('')
|
||||
const resultKind = ref('')
|
||||
|
||||
// Gateway-timeout recovery — same policy as TestModal: the backend detaches the
|
||||
// render from the request, so on a 524/504 we keep polling /jobs/mine?source=admin.
|
||||
const GATEWAY_TIMEOUT = new Set([0, 408, 504, 520, 521, 522, 523, 524, 525])
|
||||
let recoverTimer = null
|
||||
let recoverJobId = ''
|
||||
let recoverSubmitTs = 0
|
||||
onUnmounted(() => clearTimeout(recoverTimer))
|
||||
|
||||
function firstDuration(m) {
|
||||
const keys = Object.keys(m?.duration_prices || {})
|
||||
.sort((a, b) => parseFloat(a) - parseFloat(b))
|
||||
return keys[0] || '5s'
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!selectedModel.value) { error.value = '请选择模型'; return }
|
||||
if (!prompt.value.trim()) { error.value = '请输入指令'; return }
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
status.value = isVideo.value ? '正在生成视频 (约 1–3 分钟)…' : '正在生成…'
|
||||
resultUrl.value = ''
|
||||
resultKind.value = ''
|
||||
const m = currentModel.value
|
||||
const payload = {
|
||||
model: selectedModel.value,
|
||||
prompt: prompt.value,
|
||||
ratio: (m?.ratios && m.ratios[0]) || (isVideo.value ? '16x9' : '1:1'),
|
||||
resolution: (m?.resolutions && m.resolutions[0]) || '',
|
||||
account_id: props.account.id,
|
||||
}
|
||||
if (isVideo.value) payload.duration = firstDuration(m)
|
||||
recoverSubmitTs = Date.now()
|
||||
const r = await api('/test', jsonBody('POST', payload))
|
||||
if (r.ok && r.data?.url) {
|
||||
busy.value = false
|
||||
resultUrl.value = r.data.url
|
||||
resultKind.value = r.data.kind || (isVideo.value ? 'video' : 'image')
|
||||
status.value = `完成 · ${r.data.provider} · ${(r.data.elapsed_ms / 1000).toFixed(1)}s`
|
||||
} else if (GATEWAY_TIMEOUT.has(r.status)) {
|
||||
status.value = isVideo.value ? '生成视频中 (约 1–3 分钟)…' : '生成中…'
|
||||
recoverJobId = ''
|
||||
clearTimeout(recoverTimer)
|
||||
recoverTimer = setTimeout(recover, 3000)
|
||||
} else {
|
||||
busy.value = false
|
||||
status.value = ''
|
||||
error.value = r.data?.detail || `失败 (${r.status})`
|
||||
}
|
||||
}
|
||||
|
||||
async function recover() {
|
||||
const r = await api('/jobs/mine?source=admin')
|
||||
if (!r.ok) { recoverTimer = setTimeout(recover, 3000); return }
|
||||
const { pending, latest } = r.data || {}
|
||||
if (pending) {
|
||||
recoverJobId = pending.id
|
||||
recoverTimer = setTimeout(recover, 3000)
|
||||
return
|
||||
}
|
||||
const mine = latest && (
|
||||
(recoverJobId && latest.id === recoverJobId) ||
|
||||
(!recoverJobId && latest.ts && latest.ts * 1000 >= recoverSubmitTs - 2000)
|
||||
)
|
||||
if (mine && latest.status === 'success' && latest.url) {
|
||||
busy.value = false
|
||||
resultUrl.value = latest.url
|
||||
resultKind.value = latest.kind || (isVideo.value ? 'video' : 'image')
|
||||
status.value = `完成 · ${(latest.elapsed_ms / 1000).toFixed(1)}s`
|
||||
return
|
||||
}
|
||||
if (mine && latest.status === 'failed') {
|
||||
busy.value = false
|
||||
status.value = ''
|
||||
error.value = latest.error || '生成失败'
|
||||
return
|
||||
}
|
||||
recoverTimer = setTimeout(recover, 3000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4"
|
||||
@click.self="emit('close')">
|
||||
<div class="card !shadow-2xl my-12 w-full max-w-lg">
|
||||
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-sm font-semibold">账号生成测试</h2>
|
||||
<div class="text-xs text-white/45 font-mono truncate">{{ account.email || account.id }} · {{ account.pool }}</div>
|
||||
</div>
|
||||
<button @click="emit('close')" class="text-white/40 hover:text-white transition-colors">
|
||||
<Icon name="close" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-5 space-y-4">
|
||||
<div>
|
||||
<label class="lbl">模型</label>
|
||||
<div v-if="!models.length" class="text-xs text-amber-300">该账号所属 provider ({{ account.pool }}) 没有可用的模型</div>
|
||||
<SelectMenu v-else v-model="selectedModel" :options="modelOptions" placeholder="选择模型" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="lbl">指令</label>
|
||||
<textarea v-model="prompt" rows="3" class="field resize-none" placeholder="输入测试指令…"></textarea>
|
||||
</div>
|
||||
|
||||
<button @click="run" :disabled="busy || !models.length" class="btn-primary w-full">
|
||||
<Icon name="spark" class="w-4 h-4" /> {{ busy ? (isVideo ? '生成中…(请耐心等待)' : '生成中…') : '生成' }}
|
||||
</button>
|
||||
|
||||
<p v-if="status" class="text-xs text-white/55">{{ status }}</p>
|
||||
<p v-if="error" class="text-xs text-rose-300 break-all">{{ error }}</p>
|
||||
|
||||
<div v-if="resultUrl" class="rounded-xl ring-1 ring-white/10 bg-white/[0.03] overflow-hidden grid place-items-center min-h-[220px]">
|
||||
<video v-if="resultKind === 'video'" :src="resultUrl" controls autoplay
|
||||
class="max-w-full max-h-[420px] object-contain" />
|
||||
<img v-else :src="resultUrl" class="max-w-full max-h-[360px] object-contain" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lbl {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
color: rgb(255 255 255 / 0.55);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
</style>
|
||||
@@ -13,22 +13,24 @@ const html = computed(() => marked.parse(announcement.content.trim() || '暂无
|
||||
<div v-if="announcement.show"
|
||||
class="fixed inset-0 z-[80] bg-slate-950/70 backdrop-blur-sm flex items-center justify-center p-4"
|
||||
@click.self="dismissAnnouncement">
|
||||
<div class="w-full max-w-lg rounded-2xl bg-white text-slate-800 shadow-2xl overflow-hidden flex flex-col max-h-[80vh]">
|
||||
<div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between gap-3 shrink-0">
|
||||
<div class="w-full max-w-lg rounded-2xl bg-[var(--menu-bg)] backdrop-blur-xl text-[color:var(--fg)] border border-[color:var(--glass-border)] shadow-[var(--glass-shadow-lg)] overflow-hidden flex flex-col max-h-[80vh]">
|
||||
<div class="px-6 py-4 border-b border-b-[color:var(--ink-soft)] flex items-center justify-between gap-3 shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-7 h-7 rounded-lg bg-violet-500/15 text-violet-600 grid place-items-center">
|
||||
<span class="w-7 h-7 rounded-lg text-white grid place-items-center"
|
||||
style="background: linear-gradient(135deg, #7c5cfc, #22d3ee); box-shadow: 0 4px 12px var(--accent-glow)">
|
||||
<Icon name="spark" class="w-4 h-4" />
|
||||
</span>
|
||||
<h2 class="text-base font-semibold">公告</h2>
|
||||
<h2 class="text-base font-extrabold">公告</h2>
|
||||
</div>
|
||||
<button @click="dismissAnnouncement" class="text-slate-400 hover:text-slate-700 transition-colors">
|
||||
<button @click="dismissAnnouncement" class="text-[color:var(--fg-3)] hover:text-[color:var(--fg)] transition-colors">
|
||||
<Icon name="close" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="ann-body px-6 py-5 overflow-y-auto" v-html="html"></div>
|
||||
<div class="px-6 py-4 border-t border-slate-100 flex justify-end shrink-0">
|
||||
<div class="px-6 py-4 border-t border-t-[color:var(--ink-soft)] flex justify-end shrink-0">
|
||||
<button @click="dismissAnnouncement"
|
||||
class="rounded-lg bg-slate-900 text-white hover:bg-slate-700 px-5 py-2 text-sm font-medium transition-colors">
|
||||
class="rounded-lg text-white border border-white/20 px-5 py-2 text-sm font-extrabold hover:-translate-y-0.5 active:translate-y-0.5 transition-all"
|
||||
style="background: linear-gradient(120deg, #7c5cfc, #22d3ee); box-shadow: 0 8px 20px var(--accent-glow)">
|
||||
我知道了
|
||||
</button>
|
||||
</div>
|
||||
@@ -42,21 +44,21 @@ const html = computed(() => marked.parse(announcement.content.trim() || '暂无
|
||||
.ann-fade-enter-from, .ann-fade-leave-to { opacity: 0; }
|
||||
|
||||
/* Minimal markdown typography for the announcement body. */
|
||||
.ann-body { font-size: 0.9rem; line-height: 1.65; color: rgb(51 65 85); }
|
||||
.ann-body :deep(h1) { font-size: 1.25rem; font-weight: 700; margin: 0.6em 0 0.4em; color: rgb(15 23 42); }
|
||||
.ann-body :deep(h2) { font-size: 1.1rem; font-weight: 700; margin: 0.6em 0 0.4em; color: rgb(15 23 42); }
|
||||
.ann-body :deep(h3) { font-size: 1rem; font-weight: 600; margin: 0.6em 0 0.3em; color: rgb(15 23 42); }
|
||||
.ann-body { font-size: 0.9rem; line-height: 1.65; color: var(--fg-2); }
|
||||
.ann-body :deep(h1) { font-size: 1.25rem; font-weight: 700; margin: 0.6em 0 0.4em; color: var(--fg); }
|
||||
.ann-body :deep(h2) { font-size: 1.1rem; font-weight: 700; margin: 0.6em 0 0.4em; color: var(--fg); }
|
||||
.ann-body :deep(h3) { font-size: 1rem; font-weight: 600; margin: 0.6em 0 0.3em; color: var(--fg); }
|
||||
.ann-body :deep(p) { margin: 0.5em 0; }
|
||||
.ann-body :deep(ul), .ann-body :deep(ol) { margin: 0.5em 0; padding-left: 1.4em; }
|
||||
.ann-body :deep(ul) { list-style: disc; }
|
||||
.ann-body :deep(ol) { list-style: decimal; }
|
||||
.ann-body :deep(li) { margin: 0.2em 0; }
|
||||
.ann-body :deep(a) { color: rgb(124 58 237); text-decoration: underline; }
|
||||
.ann-body :deep(strong) { font-weight: 700; color: rgb(15 23 42); }
|
||||
.ann-body :deep(code) { background: rgb(241 245 249); padding: 0.1em 0.35em; border-radius: 0.3rem; font-size: 0.85em; }
|
||||
.ann-body :deep(pre) { background: rgb(241 245 249); padding: 0.8em; border-radius: 0.5rem; overflow-x: auto; margin: 0.6em 0; }
|
||||
.ann-body :deep(a) { color: var(--m-orange); text-decoration: underline; }
|
||||
.ann-body :deep(strong) { font-weight: 700; color: var(--fg); }
|
||||
.ann-body :deep(code) { background: rgb(124 92 252 / 0.12); padding: 0.1em 0.35em; border-radius: 0.3rem; font-size: 0.85em; }
|
||||
.ann-body :deep(pre) { background: #111; color: #f5f5f5; padding: 0.8em; border-radius: 0.5rem; overflow-x: auto; margin: 0.6em 0; }
|
||||
.ann-body :deep(pre code) { background: none; padding: 0; }
|
||||
.ann-body :deep(blockquote) { border-left: 3px solid rgb(196 181 253); padding-left: 0.9em; color: rgb(100 116 139); margin: 0.6em 0; }
|
||||
.ann-body :deep(hr) { border: none; border-top: 1px solid rgb(226 232 240); margin: 1em 0; }
|
||||
.ann-body :deep(blockquote) { border-left: 3px solid var(--m-orange); padding-left: 0.9em; color: var(--fg-3); margin: 0.6em 0; }
|
||||
.ann-body :deep(hr) { border: none; border-top: 1px solid var(--hairline); margin: 1em 0; }
|
||||
.ann-body :deep(img) { max-width: 100%; border-radius: 0.5rem; margin: 0.5em 0; }
|
||||
</style>
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { api, jsonBody } from '../api'
|
||||
import Icon from './Icon.vue'
|
||||
import SelectMenu from './SelectMenu.vue'
|
||||
|
||||
const emit = defineEmits(['close', 'saved'])
|
||||
|
||||
// 17 ratios — the union of what our models actually support; kept in sync with
|
||||
// the backend guessRatio() and the docs 对照表. 9:21 removed (no image provider accepts it).
|
||||
const RATIO_OPTS = ['1:1', '5:4', '4:3', '3:2', '16:9', '2:1', '21:9', '3:1', '4:1', '8:1', '4:5', '3:4', '2:3', '9:16', '1:3', '1:4', '1:8']
|
||||
const IMG_RES = ['1K', '2K', '4K']
|
||||
const VID_RES = ['720p', '1080p', '2K', '4K']
|
||||
const ALL_RES = ['1K', '2K', '4K', '720p', '1080p']
|
||||
const DUR_OPTS = ['5s', '6s', '8s', '10s', '15s']
|
||||
|
||||
const id = ref('')
|
||||
const alias = ref('')
|
||||
const type = ref('image')
|
||||
const ratios = ref(['1:1', '16:9', '9:16'])
|
||||
const maxRefs = ref(0)
|
||||
const refMode = ref('none')
|
||||
const weight = ref(0)
|
||||
// tier -> { price, agent } — blank price means the tier is NOT supported.
|
||||
const res = ref(Object.fromEntries(ALL_RES.map((r) => [r, { price: '', agent: '' }])))
|
||||
const dur = ref(Object.fromEntries(DUR_OPTS.map((d) => [d, { price: '', agent: '' }])))
|
||||
// Duration rows shown: the presets above + any custom seconds the admin adds.
|
||||
const durList = ref([...DUR_OPTS])
|
||||
const customDurInput = ref('')
|
||||
const error = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
// Add a custom duration (any positive integer seconds, e.g. 12 → "12s").
|
||||
function addCustomDur() {
|
||||
const n = parseInt(customDurInput.value, 10)
|
||||
if (!(n > 0)) return
|
||||
const key = n + 's'
|
||||
if (!durList.value.includes(key)) {
|
||||
if (!dur.value[key]) dur.value[key] = { price: '', agent: '' }
|
||||
durList.value.push(key)
|
||||
}
|
||||
customDurInput.value = ''
|
||||
}
|
||||
// Custom (non-preset) durations can be removed; presets stay.
|
||||
function removeDur(key) {
|
||||
if (DUR_OPTS.includes(key)) return
|
||||
durList.value = durList.value.filter((k) => k !== key)
|
||||
delete dur.value[key]
|
||||
}
|
||||
|
||||
const isVideo = computed(() => type.value === 'video')
|
||||
// Resolution tiers depend on type: image = 1K/2K/4K, video = 540p/720p/1080p/2K/4K.
|
||||
const resOpts = computed(() => (isVideo.value ? VID_RES : IMG_RES))
|
||||
|
||||
// 首尾帧(frame) only has first+last slots → cap reference images at 2.
|
||||
const refsCap = computed(() => (refMode.value === 'frame' ? 2 : 99))
|
||||
watch([refMode, maxRefs], () => {
|
||||
if (refMode.value === 'frame' && Number(maxRefs.value) > 2) maxRefs.value = 2
|
||||
})
|
||||
|
||||
function toggleRatio(r) {
|
||||
const i = ratios.value.indexOf(r)
|
||||
if (i >= 0) ratios.value.splice(i, 1)
|
||||
else ratios.value.push(r)
|
||||
}
|
||||
|
||||
// collect checked tiers into { tier: price } and { tier: agentPrice }
|
||||
// blank price = tier not supported (skipped), matching the edit form.
|
||||
function collect(tiers, allowed) {
|
||||
const prices = {}, agent = {}, keys = []
|
||||
for (const [k, v] of Object.entries(tiers)) {
|
||||
if (allowed && !allowed.includes(k)) continue
|
||||
const raw = String(v.price ?? '').trim()
|
||||
if (raw === '') continue
|
||||
const n = Number(raw)
|
||||
if (Number.isNaN(n) || n < 0) continue
|
||||
prices[k] = n; keys.push(k)
|
||||
const ar = String(v.agent ?? '').trim()
|
||||
if (ar !== '') { const a = Number(ar); if (!Number.isNaN(a) && a >= 0) agent[k] = a }
|
||||
}
|
||||
return { prices, agent, keys }
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const mid = id.value.trim()
|
||||
if (!mid) { error.value = '请填写模型 id'; return }
|
||||
if (refMode.value === 'frame' && Number(maxRefs.value) > 2) maxRefs.value = 2
|
||||
const r = collect(res.value, resOpts.value)
|
||||
if (!r.keys.length) { error.value = '请至少勾选一个分辨率并填价格'; return }
|
||||
const body = {
|
||||
id: mid,
|
||||
name: mid,
|
||||
alias: alias.value.trim(),
|
||||
type: type.value,
|
||||
provider: 'custom',
|
||||
prices: r.prices,
|
||||
prices_agent: r.agent,
|
||||
ratios: ratios.value.slice(),
|
||||
max_reference_images: Number(maxRefs.value) || 0,
|
||||
reference_mode: refMode.value,
|
||||
weight: Number(weight.value) || 0,
|
||||
image_to_image: (Number(maxRefs.value) || 0) > 0,
|
||||
}
|
||||
if (isVideo.value) {
|
||||
body.resolutions = r.keys
|
||||
const d = collect(dur.value)
|
||||
if (!d.keys.length) { error.value = '视频请至少勾选一个时长并填价格'; return }
|
||||
body.duration_prices = d.prices
|
||||
body.duration_prices_agent = d.agent
|
||||
body.durations = d.keys
|
||||
}
|
||||
saving.value = true; error.value = ''
|
||||
try {
|
||||
const resp = await api('/managed-models', jsonBody('POST', body))
|
||||
if (resp.ok || resp.data?.ok || resp.status === 200) emit('saved')
|
||||
else error.value = resp.data?.detail || '创建失败'
|
||||
} catch (e) { error.value = String(e) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 bg-slate-900/40 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4"
|
||||
@click.self="emit('close')">
|
||||
<div class="card !shadow-xl mt-10 mb-10 w-full max-w-xl">
|
||||
<div class="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold">添加自定义模型(上游 / provider=custom)</h2>
|
||||
<button @click="emit('close')" class="text-slate-400 hover:text-slate-700"><Icon name="close" class="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<p class="text-xs text-slate-500 leading-relaxed">
|
||||
id 要与上游模型名<strong class="text-slate-700">一致</strong> —— 生成时按 id 自动路由到「支持该 id 的上游账号」。价格按本地价计费。设了别名后,对外只用别名调用(原 id 调不到),但内部仍按 id 路由到上游,不影响。
|
||||
</p>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-slate-500 block mb-1">模型 id <span class="text-rose-500">*</span></label>
|
||||
<input v-model="id" class="field font-mono text-xs h-10" placeholder="gpt-image-2" />
|
||||
</div>
|
||||
<div class="w-40">
|
||||
<label class="text-xs text-slate-500 block mb-1">别名(选填)</label>
|
||||
<input v-model="alias" class="field font-mono text-xs h-10" placeholder="对外调用名" />
|
||||
</div>
|
||||
<div class="w-28">
|
||||
<label class="text-xs text-slate-500 block mb-1">类型</label>
|
||||
<SelectMenu v-model="type" :options="[{value:'image',label:'图像'},{value:'video',label:'视频'}]" />
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<label class="text-xs text-slate-500 block mb-1">权重</label>
|
||||
<input v-model.number="weight" type="number" class="field h-10" placeholder="0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 block mb-1.5">比例(多选)</label>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button v-for="r in RATIO_OPTS" :key="r" type="button" @click="toggleRatio(r)"
|
||||
class="px-2 py-1 rounded text-xs ring-1 transition-colors"
|
||||
:class="ratios.includes(r) ? 'bg-indigo-500/15 text-indigo-600 ring-indigo-300' : 'bg-slate-50 text-slate-500 ring-slate-200 hover:ring-slate-300'">{{ r }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 block mb-1.5">分辨率 · 价格(填普通价 = 支持该档,<strong class="text-slate-600">留空 = 不支持</strong>)</label>
|
||||
<div class="space-y-1.5">
|
||||
<div v-for="r in resOpts" :key="r" class="flex items-center gap-2">
|
||||
<span class="w-16 text-xs font-mono text-slate-500">{{ r }}</span>
|
||||
<input v-model="res[r].price" type="number" class="field !py-1 flex-1" placeholder="普通价(留空=不支持)" />
|
||||
<input v-model="res[r].agent" type="number" class="field !py-1 flex-1" placeholder="代理价(留空跟随)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isVideo">
|
||||
<label class="text-xs text-slate-500 block mb-1.5">时长 · 价格(总价 = 分辨率价 + 时长价;<strong class="text-slate-600">留空 = 不支持</strong>)</label>
|
||||
<div class="space-y-1.5">
|
||||
<div v-for="d in durList" :key="d" class="flex items-center gap-2">
|
||||
<span class="w-16 text-xs font-mono text-slate-500">{{ d }}</span>
|
||||
<input v-model="dur[d].price" type="number" class="field !py-1 flex-1" placeholder="普通价(留空=不支持)" />
|
||||
<input v-model="dur[d].agent" type="number" class="field !py-1 flex-1" placeholder="代理价(留空跟随)" />
|
||||
<button v-if="!DUR_OPTS.includes(d)" type="button" @click="removeDur(d)"
|
||||
class="shrink-0 w-7 h-7 grid place-items-center rounded text-slate-400 hover:text-rose-500 hover:bg-rose-50" title="删除该时长">
|
||||
<Icon name="close" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 自定义时长:输入任意秒数,Sora 类上游支持的任意时长都能加 -->
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<input v-model="customDurInput" type="number" min="1" @keydown.enter.prevent="addCustomDur"
|
||||
class="field !py-1 w-32" placeholder="自定义秒数" />
|
||||
<button type="button" @click="addCustomDur" class="btn-soft text-xs whitespace-nowrap">+ 添加时长</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-slate-500 block mb-1">参考图张数<span v-if="refMode==='frame'" class="text-white/40">(首尾帧最多 2)</span></label>
|
||||
<input v-model.number="maxRefs" type="number" min="0" :max="refsCap" class="field h-10" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-slate-500 block mb-1">参考模式</label>
|
||||
<SelectMenu v-model="refMode" :options="[{value:'none',label:'无'},{value:'asset',label:'参考图'},{value:'frame',label:'首尾帧(视频)'}]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="save" :disabled="saving" class="btn-primary w-full">{{ saving ? '创建中…' : '创建模型' }}</button>
|
||||
<p v-if="error" class="text-xs text-rose-600">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,166 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { api, jsonBody } from '../api'
|
||||
import { parseImportInput } from '../utils/import'
|
||||
import Icon from './Icon.vue'
|
||||
|
||||
const emit = defineEmits(['close', 'imported'])
|
||||
|
||||
const input = ref('')
|
||||
const weight = ref(0)
|
||||
const status = ref('')
|
||||
const isError = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
// type → token pool (for the post-import weight PATCH).
|
||||
const TYPE_POOL = { openai: 'chatgpt', adobe: 'adobe', runway: 'runway', leonardo: 'leonardo', krea: 'krea', imagine: 'imagine', grok: 'grok', creativefabrica: 'creativefabrica' }
|
||||
|
||||
// Live preview of what the parser would extract — updates as the user types
|
||||
// so they can see whether their paste was understood before clicking import.
|
||||
const detected = computed(() => {
|
||||
const items = parseImportInput(input.value)
|
||||
const openai = items.filter((x) => x.type === 'openai').length
|
||||
const adobe = items.filter((x) => x.type === 'adobe').length
|
||||
const runway = items.filter((x) => x.type === 'runway').length
|
||||
const leonardo = items.filter((x) => x.type === 'leonardo').length
|
||||
const krea = items.filter((x) => x.type === 'krea').length
|
||||
const imagine = items.filter((x) => x.type === 'imagine').length
|
||||
const grok = items.filter((x) => x.type === 'grok').length
|
||||
const cf = items.filter((x) => x.type === 'creativefabrica').length
|
||||
return { total: items.length, openai, adobe, runway, leonardo, krea, imagine, grok, cf }
|
||||
})
|
||||
|
||||
function setStatus(text, err = false) {
|
||||
status.value = text || ''
|
||||
isError.value = err
|
||||
}
|
||||
|
||||
async function doSmartImport() {
|
||||
const items = parseImportInput(input.value)
|
||||
if (!items.length) {
|
||||
setStatus('未识别到任何 Cookie 或 JWT', true)
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
let ok = 0, fail = 0
|
||||
const errs = []
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const it = items[i]
|
||||
try {
|
||||
const r = it.type === 'openai'
|
||||
? await api('/tokens/import-chatgpt-token', jsonBody('POST', { access_token: it.value }))
|
||||
: it.type === 'grok'
|
||||
? await api('/tokens/import-grok-token', jsonBody('POST', { access_token: it.value }))
|
||||
: it.type === 'runway'
|
||||
? await api('/tokens/import-runway-token', jsonBody('POST', { access_token: it.value }))
|
||||
: it.type === 'leonardo'
|
||||
? await api('/tokens/import-leonardo-cookie', jsonBody('POST', { cookie: it.value }))
|
||||
: it.type === 'krea'
|
||||
? await api('/tokens/import-krea-cookie', jsonBody('POST', { cookie: it.value }))
|
||||
: it.type === 'imagine'
|
||||
? await api('/tokens/import-imagine-token', jsonBody('POST', { value: it.value }))
|
||||
: it.type === 'creativefabrica'
|
||||
? await api('/tokens/import-creativefabrica-cookie', jsonBody('POST', { cookie: it.value }))
|
||||
: await api('/tokens/import-adobe-cookie', jsonBody('POST', { cookie: it.value }))
|
||||
if (r.ok) {
|
||||
ok++
|
||||
// Apply the chosen weight to the freshly-imported account (best-effort).
|
||||
const w = Number(weight.value) || 0
|
||||
const pool = TYPE_POOL[it.type]
|
||||
if (w !== 0 && pool && r.data?.id) {
|
||||
try { await api(`/tokens/${pool}/${r.data.id}`, jsonBody('PATCH', { weight: w })) } catch (_) {}
|
||||
}
|
||||
} else { fail++; errs.push(`${it.type}: ${r.data?.detail || r.status}`) }
|
||||
} catch (e) {
|
||||
fail++; errs.push(`${it.type}: ${e}`)
|
||||
}
|
||||
}
|
||||
submitting.value = false
|
||||
// Quota isn't checked here — the server probes each token off-thread and the
|
||||
// account list flips pending → active/dead on its own.
|
||||
if (fail === 0) {
|
||||
// Close immediately on success — the account lands in the table right away
|
||||
// and the server backfills quota/email off-thread (pending → active/dead).
|
||||
emit('imported')
|
||||
emit('close')
|
||||
} else {
|
||||
setStatus(`成功 ${ok} · 失败 ${fail} · ${errs.slice(0, 3).join(' | ')}`, true)
|
||||
emit('imported')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 bg-slate-900/40 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4"
|
||||
@click.self="emit('close')">
|
||||
<div class="card !shadow-xl mt-14 mb-14 w-full max-w-2xl">
|
||||
<div class="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold">导入账号</h2>
|
||||
<button @click="emit('close')" class="text-slate-400 hover:text-slate-700 transition-colors">
|
||||
<Icon name="close" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-5">
|
||||
<p class="text-xs text-slate-500 mb-3 leading-relaxed">自动识别:
|
||||
<strong class="text-slate-700">Adobe Cookie 字符串</strong>(<code class="px-1 bg-slate-100 rounded">k=v; k=v; ...</code>)、
|
||||
<strong class="text-slate-700">Cookie JSON 对象</strong>、
|
||||
<strong class="text-slate-700">Cookie 数组</strong>(多 Adobe 批量)、
|
||||
<strong class="text-slate-700">ChatGPT JWT</strong>(<code class="px-1 bg-slate-100 rounded">eyJhbGciOi...</code>)、
|
||||
<strong class="text-slate-700">Runway JWT</strong>(自动与 ChatGPT 区分)、
|
||||
<strong class="text-slate-700">Leonardo Cookie</strong>(须含 better-auth.session_data)、
|
||||
<strong class="text-slate-700">Krea Cookie</strong>(含 sb-superb-auth)、
|
||||
<strong class="text-slate-700">Creative Fabrica Cookie</strong>(含 cfauth_* 或 wordpress_logged_in_)、
|
||||
<strong class="text-slate-700">Imagine Token</strong>(<code class="px-1 bg-slate-100 rounded">{"token","refreshToken","email","parentId"}</code>)、
|
||||
<strong class="text-slate-700">Grok SSO</strong>(grok.com 的 <code class="px-1 bg-slate-100 rounded">sso</code> 值,仅含 session_id,自动与 ChatGPT/Runway 区分)、
|
||||
<strong class="text-slate-700">多个 JWT</strong>(换行分隔)。
|
||||
全粘进来即可,无需任何前缀。
|
||||
</p>
|
||||
<p class="text-xs text-rose-500 mb-3 leading-relaxed bg-rose-50 rounded px-3 py-2 border border-rose-200">
|
||||
📢 需要购买 Adobe 号请联系 QQ:28998306
|
||||
</p>
|
||||
<textarea v-model="input" rows="10"
|
||||
class="field font-mono text-xs resize-none"
|
||||
placeholder="直接粘 Cookie 字符串 / JWT / JSON,自动识别"></textarea>
|
||||
<div v-if="input.trim()" class="mt-2 flex items-center gap-2 text-xs">
|
||||
<template v-if="detected.total">
|
||||
<span class="text-emerald-600">✓ 识别到 <strong class="tabular-nums">{{ detected.total }}</strong> 个账号</span>
|
||||
<span v-if="detected.openai" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-emerald-700 bg-emerald-50 ring-1 ring-emerald-200">
|
||||
OpenAI · <span class="tabular-nums">{{ detected.openai }}</span>
|
||||
</span>
|
||||
<span v-if="detected.adobe" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-rose-700 bg-rose-50 ring-1 ring-rose-200">
|
||||
Adobe · <span class="tabular-nums">{{ detected.adobe }}</span>
|
||||
</span>
|
||||
<span v-if="detected.runway" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-violet-700 bg-violet-50 ring-1 ring-violet-200">
|
||||
Runway · <span class="tabular-nums">{{ detected.runway }}</span>
|
||||
</span>
|
||||
<span v-if="detected.leonardo" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-amber-700 bg-amber-50 ring-1 ring-amber-200">
|
||||
Leonardo · <span class="tabular-nums">{{ detected.leonardo }}</span>
|
||||
</span>
|
||||
<span v-if="detected.krea" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-sky-700 bg-sky-50 ring-1 ring-sky-200">
|
||||
Krea · <span class="tabular-nums">{{ detected.krea }}</span>
|
||||
</span>
|
||||
<span v-if="detected.imagine" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-teal-700 bg-teal-50 ring-1 ring-teal-200">
|
||||
Imagine · <span class="tabular-nums">{{ detected.imagine }}</span>
|
||||
</span>
|
||||
<span v-if="detected.grok" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-slate-700 bg-slate-100 ring-1 ring-slate-300">
|
||||
Grok · <span class="tabular-nums">{{ detected.grok }}</span>
|
||||
</span>
|
||||
<span v-if="detected.cf" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-fuchsia-700 bg-fuchsia-50 ring-1 ring-fuchsia-200">
|
||||
Creative Fabrica · <span class="tabular-nums">{{ detected.cf }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<span v-else class="text-rose-600">未识别到任何 Cookie 或 JWT</span>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<label class="text-xs text-slate-500 whitespace-nowrap">权重(本批账号,高的优先)</label>
|
||||
<input v-model.number="weight" type="number" class="field !w-24" placeholder="0" />
|
||||
</div>
|
||||
<button @click="doSmartImport" :disabled="submitting || !detected.total" class="btn-primary w-full mt-3">
|
||||
{{ submitting ? '导入中…' : (detected.total ? `识别并导入 (${detected.total})` : '识别并导入') }}
|
||||
</button>
|
||||
<p v-if="status" class="text-xs mt-2" :class="isError ? 'text-rose-600' : 'text-emerald-600'">{{ status }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -164,9 +164,9 @@ async function submit() {
|
||||
<div class="absolute inset-0 bg-black/70 backdrop-blur-md" @click="closeLogin"></div>
|
||||
|
||||
<div class="card relative z-10 w-full max-w-[26rem]">
|
||||
<!-- ambient brand glow -->
|
||||
<div class="pointer-events-none absolute -top-24 -right-16 h-56 w-56 rounded-full bg-fuchsia-500/20 blur-3xl"></div>
|
||||
<div class="pointer-events-none absolute -bottom-24 -left-16 h-56 w-56 rounded-full bg-violet-500/20 blur-3xl"></div>
|
||||
<!-- ambient aurora washes -->
|
||||
<div class="pointer-events-none absolute -top-24 -right-16 h-56 w-56 rounded-full bg-[#8b5cf6]/30 blur-3xl"></div>
|
||||
<div class="pointer-events-none absolute -bottom-24 -left-16 h-56 w-56 rounded-full bg-[#22d3ee]/25 blur-3xl"></div>
|
||||
|
||||
<div class="relative p-7">
|
||||
<button class="absolute top-4 right-4 grid h-7 w-7 place-items-center rounded-lg text-[color:var(--fg-3)] hover:text-[color:var(--fg)] hover:bg-[var(--hover)] transition-colors"
|
||||
@@ -195,7 +195,7 @@ async function submit() {
|
||||
<transition name="swap" mode="out-in">
|
||||
<p v-if="mode === 'register' && auth.pendingInvite" class="invite-banner">
|
||||
<svg viewBox="0 0 24 24" class="h-4 w-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 12v10H4V12"/><path d="M2 7h20v5H2z"/><path d="M12 22V7"/><path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"/><path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"/></svg>
|
||||
<span>已应用邀请码 <span class="font-mono font-semibold text-emerald-200">{{ auth.pendingInvite }}</span>,完成首次生图后邀请人得 3 积分</span>
|
||||
<span>已应用邀请码 <span class="font-mono font-bold text-emerald-800 dark:text-emerald-200">{{ auth.pendingInvite }}</span>,完成首次生图后邀请人得 3 积分</span>
|
||||
</p>
|
||||
</transition>
|
||||
|
||||
@@ -262,44 +262,44 @@ async function submit() {
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
border-radius: 1.25rem;
|
||||
background: linear-gradient(165deg, #ffffff 0%, #f6f6fb 100%);
|
||||
border-radius: 1.2rem;
|
||||
background: var(--menu-bg);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
backdrop-filter: blur(20px);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--hairline);
|
||||
box-shadow: 0 30px 70px -22px rgb(15 23 42 / 0.25);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--glass-shadow-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
html.dark .card {
|
||||
background: linear-gradient(165deg, #14161f 0%, #0d0f15 100%);
|
||||
color: rgb(255 255 255 / 0.9);
|
||||
border: none;
|
||||
box-shadow: 0 30px 70px -20px rgb(0 0 0 / 0.7);
|
||||
}
|
||||
|
||||
.logo-halo {
|
||||
position: relative;
|
||||
filter: drop-shadow(0 8px 18px rgb(168 85 247 / 0.45));
|
||||
filter: drop-shadow(0 6px 14px rgb(124 92 252 / 0.4));
|
||||
}
|
||||
|
||||
/* segmented tab control */
|
||||
.tabs {
|
||||
display: flex; gap: 0.25rem; padding: 0.25rem;
|
||||
border-radius: 0.75rem;
|
||||
display: flex; gap: 0.3rem; padding: 0.3rem;
|
||||
border-radius: 0.7rem;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--hairline);
|
||||
border: 1px solid var(--ink-soft);
|
||||
}
|
||||
.tab {
|
||||
flex: 1; padding: 0.45rem 0; border-radius: 0.55rem;
|
||||
font-size: 0.8125rem; color: var(--fg-3);
|
||||
transition: background 0.18s, color 0.18s, box-shadow 0.18s;
|
||||
flex: 1; padding: 0.45rem 0; border-radius: 0.5rem;
|
||||
font-size: 0.8125rem; font-weight: 700; color: var(--fg-3);
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.18s, color 0.18s, box-shadow 0.18s, border-color 0.18s;
|
||||
}
|
||||
.tab:hover { color: var(--fg-2); }
|
||||
.tab:hover { color: var(--fg); }
|
||||
.tab-on {
|
||||
color: rgb(124 58 237);
|
||||
background: linear-gradient(135deg, rgb(167 139 250 / 0.22), rgb(236 72 153 / 0.18));
|
||||
box-shadow: 0 1px 0 rgb(255 255 255 / 0.08) inset, 0 4px 12px -4px rgb(168 85 247 / 0.5);
|
||||
color: #fff;
|
||||
background: linear-gradient(120deg, #7c5cfc, #5f6cf8);
|
||||
border-color: transparent;
|
||||
box-shadow: 0 4px 14px var(--accent-glow);
|
||||
}
|
||||
html.dark .tab-on { color: white; }
|
||||
|
||||
/* icon-prefixed input */
|
||||
.lm-field { position: relative; }
|
||||
@@ -308,21 +308,18 @@ html.dark .tab-on { color: white; }
|
||||
width: 1.05rem; height: 1.05rem; color: var(--fg-3);
|
||||
pointer-events: none; transition: color 0.18s;
|
||||
}
|
||||
.lm-field:focus-within .ic { color: rgb(124 58 237 / 0.95); }
|
||||
html.dark .lm-field:focus-within .ic { color: rgb(196 181 253 / 0.95); }
|
||||
.lm-field:focus-within .ic { color: var(--m-orange); }
|
||||
.fld {
|
||||
width: 100%; padding: 0.7rem 0.85rem 0.7rem 2.4rem; border-radius: 0.7rem;
|
||||
background: rgb(15 23 42 / 0.03); border: 1px solid var(--hairline);
|
||||
width: 100%; padding: 0.7rem 0.85rem 0.7rem 2.4rem; border-radius: 0.6rem;
|
||||
background: var(--surface); border: 1px solid var(--ink-soft);
|
||||
color: var(--fg); font-size: 0.875rem; outline: none;
|
||||
transition: border-color 0.18s, box-shadow 0.18s, background 0.18s;
|
||||
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
|
||||
}
|
||||
html.dark .fld { background: rgb(255 255 255 / 0.04); }
|
||||
html.dark .fld { background: rgb(255 255 255 / 0.05); }
|
||||
.fld:focus {
|
||||
border-color: rgb(167 139 250 / 0.65);
|
||||
background: rgb(124 58 237 / 0.04);
|
||||
box-shadow: 0 0 0 3px rgb(167 139 250 / 0.18);
|
||||
border-color: rgb(124 92 252 / 0.65);
|
||||
box-shadow: 0 0 0 3px rgb(124 92 252 / 0.18);
|
||||
}
|
||||
html.dark .fld:focus { background: rgb(255 255 255 / 0.06); }
|
||||
.fld::placeholder { color: var(--fg-faint); }
|
||||
/* Keep autofill on-theme in dark (otherwise it paints white/yellow). */
|
||||
html.dark .fld:-webkit-autofill,
|
||||
@@ -343,16 +340,17 @@ html.dark .fld:-webkit-autofill:focus {
|
||||
.eye:hover { color: var(--fg); background: var(--hover); }
|
||||
|
||||
.code-btn {
|
||||
flex-shrink: 0; height: 2.7rem; padding: 0 0.85rem; border-radius: 0.7rem;
|
||||
flex-shrink: 0; height: 2.7rem; padding: 0 0.85rem; border-radius: 0.6rem;
|
||||
min-width: 5.5rem; /* keep width stable between text / spinner */
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 0.75rem; white-space: nowrap;
|
||||
color: rgb(124 58 237); /* violet-600 — readable on the light card */
|
||||
background: rgb(167 139 250 / 0.12); border: 1px solid rgb(167 139 250 / 0.4);
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
font-size: 0.75rem; font-weight: 800; white-space: nowrap;
|
||||
color: var(--fg);
|
||||
background: var(--hover); border: 1px solid rgb(124 92 252 / 0.4);
|
||||
box-shadow: 0 2px 10px rgb(124 92 252 / 0.14);
|
||||
transition: background 0.15s, box-shadow 0.15s, transform 0.15s, opacity 0.15s;
|
||||
}
|
||||
html.dark .code-btn { color: rgb(196 181 253 / 0.95); background: rgb(167 139 250 / 0.1); border-color: rgb(167 139 250 / 0.3); }
|
||||
.code-btn:hover:not(:disabled) { background: rgb(167 139 250 / 0.2); }
|
||||
.code-btn:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 4px 14px rgb(124 92 252 / 0.22); }
|
||||
.code-btn:active:not(:disabled) { transform: scale(0.96) translateY(1px); box-shadow: none; }
|
||||
/* Disabled (sending / countdown): just dim — keep the theme text color so it
|
||||
never washes out to invisible white on the light card. */
|
||||
.code-btn:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
@@ -361,14 +359,15 @@ html.dark .code-btn { color: rgb(196 181 253 / 0.95); background: rgb(167 139 25
|
||||
|
||||
.btn-primary {
|
||||
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
|
||||
padding: 0.72rem 0; border-radius: 0.7rem; font-size: 0.9rem; font-weight: 600;
|
||||
padding: 0.72rem 0; border-radius: 0.65rem; font-size: 0.9rem; font-weight: 800;
|
||||
letter-spacing: 0.02em; color: white; margin-top: 0.35rem;
|
||||
background: linear-gradient(135deg, #a855f7 0%, #7c3aed 50%, #ec4899 100%);
|
||||
box-shadow: 0 10px 24px -8px rgb(168 85 247 / 0.6);
|
||||
transition: transform 0.12s, box-shadow 0.18s, filter 0.18s;
|
||||
background: linear-gradient(120deg, #7c5cfc, #5f6cf8 55%, #22d3ee 140%);
|
||||
border: 1px solid rgb(255 255 255 / 0.16);
|
||||
box-shadow: 0 8px 22px var(--accent-glow);
|
||||
transition: transform 0.13s, box-shadow 0.13s, filter 0.15s;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) { filter: brightness(1.08); box-shadow: 0 12px 28px -8px rgb(168 85 247 / 0.75); }
|
||||
.btn-primary:active:not(:disabled) { transform: translateY(1px); }
|
||||
.btn-primary:hover:not(:disabled) { filter: brightness(1.07); transform: translateY(-1px); box-shadow: 0 12px 30px var(--accent-glow); }
|
||||
.btn-primary:active:not(:disabled) { transform: scale(0.98) translateY(1px); box-shadow: 0 4px 12px var(--accent-glow); }
|
||||
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
.spinner {
|
||||
@@ -380,18 +379,22 @@ html.dark .code-btn { color: rgb(196 181 253 / 0.95); background: rgb(167 139 25
|
||||
|
||||
.invite-banner {
|
||||
display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.85rem;
|
||||
padding: 0.6rem 0.75rem; border-radius: 0.7rem; font-size: 0.75rem; line-height: 1.4;
|
||||
color: rgb(167 243 208);
|
||||
background: rgb(16 185 129 / 0.1); border: 1px solid rgb(16 185 129 / 0.25);
|
||||
padding: 0.6rem 0.75rem; border-radius: 0.6rem; font-size: 0.75rem; line-height: 1.4;
|
||||
color: #065f46; font-weight: 600;
|
||||
background: rgb(52 211 153 / 0.14); border: 1px solid rgb(52 211 153 / 0.4);
|
||||
}
|
||||
.invite-banner svg { color: rgb(110 231 183); }
|
||||
html.dark .invite-banner { color: rgb(167 243 208); background: rgb(16 185 129 / 0.15); }
|
||||
.invite-banner svg { color: rgb(5 150 105); }
|
||||
html.dark .invite-banner svg { color: rgb(110 231 183); }
|
||||
|
||||
.msg {
|
||||
display: flex; align-items: center; gap: 0.4rem; margin-top: 0.85rem;
|
||||
font-size: 0.78rem; line-height: 1.4;
|
||||
font-size: 0.78rem; font-weight: 600; line-height: 1.4;
|
||||
}
|
||||
.msg-err { color: rgb(253 164 175); }
|
||||
.msg-ok { color: rgb(110 231 183); }
|
||||
.msg-err { color: rgb(220 38 38); }
|
||||
html.dark .msg-err { color: rgb(253 164 175); }
|
||||
.msg-ok { color: rgb(5 150 105); }
|
||||
html.dark .msg-ok { color: rgb(110 231 183); }
|
||||
|
||||
/* modal in/out */
|
||||
.modal-enter-active { transition: opacity 0.2s ease; }
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup>
|
||||
// Vivid mark: an open spectrum ring (lens/aperture) around a folded "V", with a
|
||||
// shutter dot closing the gap — image + video in one glyph. Transparent
|
||||
// background so it reads on both the dark and light shells.
|
||||
// Vivid mark: an open spectrum ring (lens/aperture) around a play triangle,
|
||||
// with a shutter dot closing the gap — image + video in one glyph.
|
||||
// Transparent background so it reads on both the dark and light shells.
|
||||
// Colors follow the aurora palette (violet / pink / cyan).
|
||||
// Each instance gets unique gradient ids (useId) so multiple logos on one
|
||||
// page don't share/clobber defs.
|
||||
import { useId } from 'vue'
|
||||
@@ -10,7 +11,6 @@ defineProps({ size: { type: [Number, String], default: 40 } })
|
||||
|
||||
const uid = useId()
|
||||
const gRing = `vring-${uid}`
|
||||
const gV = `vv-${uid}`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -18,24 +18,21 @@ const gV = `vv-${uid}`
|
||||
xmlns="http://www.w3.org/2000/svg" class="shrink-0">
|
||||
<defs>
|
||||
<linearGradient :id="gRing" x1="6" y1="6" x2="42" y2="42" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#e879f9" />
|
||||
<stop offset="0.5" stop-color="#8b5cf6" />
|
||||
<stop offset="1" stop-color="#38bdf8" />
|
||||
</linearGradient>
|
||||
<linearGradient :id="gV" x1="14" y1="14" x2="34" y2="34" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#f0abfc" />
|
||||
<stop offset="1" stop-color="#8b5cf6" />
|
||||
<stop stop-color="#7c5cfc" />
|
||||
<stop offset="0.5" stop-color="#e879f9" />
|
||||
<stop offset="1" stop-color="#22d3ee" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g transform="translate(24 24)">
|
||||
<!-- open ring: dash gap leaves room for the shutter dot -->
|
||||
<circle r="18.5" fill="none" :stroke="`url(#${gRing})`" stroke-width="3.2"
|
||||
stroke-linecap="round" stroke-dasharray="86 30" transform="rotate(-58)" />
|
||||
<!-- folded V -->
|
||||
<path d="M-8 -7.5 L0 9 L8 -7.5" fill="none" :stroke="`url(#${gV})`" stroke-width="4.8"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
<circle cx="13" cy="-13" r="3.4" fill="#38bdf8" />
|
||||
<circle r="17.5" fill="none" :stroke="`url(#${gRing})`" stroke-width="4.4"
|
||||
stroke-linecap="round" stroke-dasharray="84 32" transform="rotate(-58)" />
|
||||
<!-- play triangle = image + video -->
|
||||
<path d="M-3 -6.5 L8.5 0 L-3 6.5 Z" fill="#7c5cfc" stroke="#111" stroke-width="1.4"
|
||||
stroke-linejoin="round" />
|
||||
<!-- shutter dot -->
|
||||
<circle cx="13" cy="-13" r="4.2" fill="#22d3ee" stroke="#111" stroke-width="1.6" />
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
<script setup>
|
||||
// Vivi — the platform's original chibi mascot. Pure inline SVG (no raster
|
||||
// asset, no external dependency), so it scales crisply and inherits the
|
||||
// pink/violet/sky brand palette. `size` sets the square px box; the halo of
|
||||
// twin-tails + a camera-lens hair ring ties her to the B-ring logo.
|
||||
import { computed } from 'vue'
|
||||
const props = defineProps({
|
||||
size: { type: [Number, String], default: 160 },
|
||||
float: { type: Boolean, default: true },
|
||||
uid: { type: String, default: () => 'm' + Math.random().toString(36).slice(2, 8) },
|
||||
})
|
||||
const px = computed(() => (typeof props.size === 'number' ? props.size + 'px' : props.size))
|
||||
const g = (k) => `${props.uid}-${k}`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :width="px" :height="px" viewBox="0 0 200 200" fill="none"
|
||||
:class="['mascot', { 'mascot-float': float }]" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient :id="g('hair')" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#f0abfc" />
|
||||
<stop offset="0.55" stop-color="#c084fc" />
|
||||
<stop offset="1" stop-color="#818cf8" />
|
||||
</linearGradient>
|
||||
<linearGradient :id="g('eye')" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#7dd3fc" />
|
||||
<stop offset="1" stop-color="#a855f7" />
|
||||
</linearGradient>
|
||||
<linearGradient :id="g('ring')" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#f472b6" />
|
||||
<stop offset="0.5" stop-color="#a78bfa" />
|
||||
<stop offset="1" stop-color="#38bdf8" />
|
||||
</linearGradient>
|
||||
<radialGradient :id="g('glow')" cx="0.5" cy="0.5" r="0.5">
|
||||
<stop offset="0" stop-color="#e879f9" stop-opacity="0.55" />
|
||||
<stop offset="1" stop-color="#e879f9" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- soft aura -->
|
||||
<circle cx="100" cy="104" r="86" :fill="`url(#${g('glow')})`" />
|
||||
|
||||
<!-- back twin-tail -->
|
||||
<path d="M56 78 C22 84 20 140 44 168 C58 150 60 120 74 104 Z" :fill="`url(#${g('hair')})`" opacity="0.92" />
|
||||
<path d="M144 78 C178 84 180 140 156 168 C142 150 140 120 126 104 Z" :fill="`url(#${g('hair')})`" opacity="0.92" />
|
||||
|
||||
<!-- hair back mass -->
|
||||
<path d="M100 34 C60 34 44 66 46 100 C47 116 56 128 62 132 L138 132 C144 128 153 116 154 100 C156 66 140 34 100 34 Z"
|
||||
:fill="`url(#${g('hair')})`" />
|
||||
|
||||
<!-- face -->
|
||||
<path d="M64 92 C64 66 80 52 100 52 C120 52 136 66 136 92 C136 118 120 134 100 134 C80 134 64 118 64 92 Z"
|
||||
fill="#fff5fb" />
|
||||
<!-- fringe -->
|
||||
<path d="M62 92 C60 66 78 50 100 50 C122 50 140 66 138 92 C132 78 120 74 118 90 C112 74 100 72 100 72 C100 72 88 74 82 90 C80 74 68 78 62 92 Z"
|
||||
:fill="`url(#${g('hair')})`" />
|
||||
|
||||
<!-- ears blush -->
|
||||
<circle cx="76" cy="106" r="7" fill="#fbb6ce" opacity="0.75" />
|
||||
<circle cx="124" cy="106" r="7" fill="#fbb6ce" opacity="0.75" />
|
||||
|
||||
<!-- eyes -->
|
||||
<g>
|
||||
<ellipse cx="83" cy="98" rx="9" ry="12" :fill="`url(#${g('eye')})`" />
|
||||
<ellipse cx="117" cy="98" rx="9" ry="12" :fill="`url(#${g('eye')})`" />
|
||||
<circle cx="85.5" cy="93.5" r="3.2" fill="#fff" />
|
||||
<circle cx="119.5" cy="93.5" r="3.2" fill="#fff" />
|
||||
<circle cx="81" cy="101" r="1.6" fill="#fff" opacity="0.85" />
|
||||
<circle cx="115" cy="101" r="1.6" fill="#fff" opacity="0.85" />
|
||||
</g>
|
||||
<!-- brows / smile -->
|
||||
<path d="M74 84 q9 -5 18 -1" stroke="#c084fc" stroke-width="2.4" stroke-linecap="round" />
|
||||
<path d="M108 83 q9 -4 18 1" stroke="#c084fc" stroke-width="2.4" stroke-linecap="round" />
|
||||
<path d="M92 114 q8 7 16 0" stroke="#f472b6" stroke-width="2.6" stroke-linecap="round" fill="none" />
|
||||
|
||||
<!-- camera-lens hair ring (echoes the logo) -->
|
||||
<g transform="translate(140 58)">
|
||||
<circle r="15" fill="none" :stroke="`url(#${g('ring')})`" stroke-width="4"
|
||||
stroke-linecap="round" stroke-dasharray="70 24" transform="rotate(-55)" />
|
||||
<circle r="5.5" fill="#38bdf8" />
|
||||
<circle r="2.2" fill="#fff" opacity="0.9" />
|
||||
</g>
|
||||
|
||||
<!-- sparkles -->
|
||||
<g fill="#fef08a">
|
||||
<path d="M46 60 l2.2 5 5 2.2 -5 2.2 -2.2 5 -2.2 -5 -5 -2.2 5 -2.2 Z" />
|
||||
<path d="M158 128 l1.6 3.6 3.6 1.6 -3.6 1.6 -1.6 3.6 -1.6 -3.6 -3.6 -1.6 3.6 -1.6 Z" />
|
||||
</g>
|
||||
<circle cx="52" cy="150" r="2.4" fill="#7dd3fc" />
|
||||
<circle cx="150" cy="44" r="2" fill="#f0abfc" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mascot { display: block; }
|
||||
.mascot-float { animation: mascotFloat 5s ease-in-out infinite; transform-origin: center; }
|
||||
@keyframes mascotFloat {
|
||||
0%, 100% { transform: translateY(0) rotate(-1deg); }
|
||||
50% { transform: translateY(-8px) rotate(1.5deg); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .mascot-float { animation: none; } }
|
||||
</style>
|
||||
@@ -15,6 +15,14 @@ const isEdit = computed(() => !!props.model)
|
||||
|
||||
const REF_MODE_LABEL = { none: '无', frame: '首帧/首尾帧', asset: '参考图模式' }
|
||||
|
||||
// provider=custom 的模型没有目录约束,档位由价格表本身决定——编辑时给出
|
||||
// 完整候选面板(与 CustomModelModal 的 VID_RES/IMG_RES/DUR_OPTS 保持一致),
|
||||
// 留空的档位 = 不支持。已存的自定义档(如 "540p"/"12s")也会并入显示。
|
||||
const CUSTOM_IMG_RES = ['1K', '2K', '4K']
|
||||
const CUSTOM_VID_RES = ['720p', '768p', '1080p', '2K', '4K']
|
||||
const CUSTOM_DUR = ['5s', '6s', '8s', '10s', '15s']
|
||||
const bySeconds = (a, b) => parseFloat(a) - parseFloat(b)
|
||||
|
||||
const catalog = ref([])
|
||||
const loading = ref(true)
|
||||
const selectedId = ref(props.model?.id || '')
|
||||
@@ -37,15 +45,28 @@ const entry = computed(() => {
|
||||
return catalog.value.find((e) => e.id === selectedId.value) || null
|
||||
})
|
||||
const isVideo = computed(() => entry.value?.type === 'video')
|
||||
const isCustom = computed(() => entry.value?.provider === 'custom')
|
||||
// Display tiers in canonical ascending order (720p before 1080p; 1K<2K<4K)
|
||||
// regardless of how the catalog/stored record happens to list them.
|
||||
const resolutions = computed(() => sortResolutions(entry.value?.resolutions || []))
|
||||
// regardless of how the catalog/stored record happens to list them. For
|
||||
// custom models the stored list is just the currently-priced subset — merge
|
||||
// in the full palette so the admin can light up a new tier (e.g. 768p) by
|
||||
// typing a price, or retire one by blanking it.
|
||||
const resolutions = computed(() => {
|
||||
const stored = entry.value?.resolutions || []
|
||||
if (!isCustom.value) return sortResolutions(stored)
|
||||
const palette = isVideo.value ? CUSTOM_VID_RES : CUSTOM_IMG_RES
|
||||
return sortResolutions([...new Set([...palette, ...stored])])
|
||||
})
|
||||
// Read-only summary shows what's actually supported (stored list), not the
|
||||
// offerable palette — for custom models the palette is just the input grid.
|
||||
const storedResolutions = computed(() => sortResolutions(entry.value?.resolutions || []))
|
||||
// 按秒计价的视频模型(一个 /s 价,实付 = 分辨率价 + 每秒价 × 秒数)。目录用
|
||||
// per_second 声明;已入库的模型则看它是否已存了 per_second 价。
|
||||
// per_second 声明;已入库的模型则看它是否已存了 per_second 价。渠道导入的
|
||||
// duration range 模型(duration_min/max,无固定档)也只能走 per_second。
|
||||
const isPerSecond = computed(() => {
|
||||
const e = entry.value
|
||||
if (!e) return false
|
||||
return !!e.per_second || e.duration_prices?.per_second != null
|
||||
return !!e.per_second || e.duration_prices?.per_second != null || (e.type === 'video' && (e.duration_max || 0) > 0 && !(e.durations || []).length)
|
||||
})
|
||||
|
||||
const durationTiers = computed(() => {
|
||||
@@ -53,7 +74,9 @@ const durationTiers = computed(() => {
|
||||
if (!e) return []
|
||||
if (isPerSecond.value) return ['per_second']
|
||||
const ds = e.durations || []
|
||||
return ds.length ? ds : Object.keys(e.duration_prices || {})
|
||||
const stored = ds.length ? ds : Object.keys(e.duration_prices || {})
|
||||
if (e.provider !== 'custom') return stored
|
||||
return [...new Set([...stored, ...CUSTOM_DUR])].sort(bySeconds)
|
||||
})
|
||||
|
||||
// Dropdown options: catalog rows not already in the managed store.
|
||||
@@ -71,14 +94,9 @@ function resetPrices(e) {
|
||||
if (!e) return
|
||||
// Both image and video price per resolution; video ALSO prices per duration
|
||||
// (real video price = resolution price + duration price).
|
||||
for (const r of (e.resolutions || [])) { imagePrices.value[r] = ''; imagePricesAgent.value[r] = '' }
|
||||
for (const r of resolutions.value) { imagePrices.value[r] = ''; imagePricesAgent.value[r] = '' }
|
||||
if (e.type === 'video') {
|
||||
if (isPerSecond.value) {
|
||||
videoPrices.value['per_second'] = ''
|
||||
videoPricesAgent.value['per_second'] = ''
|
||||
} else {
|
||||
for (const d of (e.durations || [])) { videoPrices.value[d] = ''; videoPricesAgent.value[d] = '' }
|
||||
}
|
||||
for (const d of durationTiers.value) { videoPrices.value[d] = ''; videoPricesAgent.value[d] = '' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,20 +112,16 @@ onMounted(async () => {
|
||||
const m = props.model
|
||||
// Resolution prices apply to both; video additionally has duration prices.
|
||||
// Agent prices are an optional overlay (blank = follows the normal price).
|
||||
for (const r of (m.resolutions || [])) {
|
||||
// Iterate the DISPLAYED tier lists (for custom models that's the full
|
||||
// palette ∪ stored), so unpriced-but-offerable tiers get blank inputs.
|
||||
for (const r of resolutions.value) {
|
||||
imagePrices.value[r] = m.prices?.[r] ?? ''
|
||||
imagePricesAgent.value[r] = m.prices_agent?.[r] ?? ''
|
||||
}
|
||||
if (m.type === 'video') {
|
||||
if (m.duration_prices?.per_second != null) {
|
||||
videoPrices.value['per_second'] = m.duration_prices.per_second
|
||||
videoPricesAgent.value['per_second'] = m.duration_prices_agent?.per_second ?? ''
|
||||
} else {
|
||||
const durs = (m.durations && m.durations.length) ? m.durations : Object.keys(m.duration_prices || {})
|
||||
for (const d of durs) {
|
||||
videoPrices.value[d] = m.duration_prices?.[d] ?? ''
|
||||
videoPricesAgent.value[d] = m.duration_prices_agent?.[d] ?? ''
|
||||
}
|
||||
for (const d of durationTiers.value) {
|
||||
videoPrices.value[d] = m.duration_prices?.[d] ?? ''
|
||||
videoPricesAgent.value[d] = m.duration_prices_agent?.[d] ?? ''
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,9 +146,9 @@ async function save() {
|
||||
|
||||
let payload
|
||||
if (e.type === 'video') {
|
||||
const prices = collect(imagePrices.value, e.resolutions || [])
|
||||
const prices = collect(imagePrices.value, resolutions.value)
|
||||
const duration_prices = collect(videoPrices.value, durationTiers.value)
|
||||
const prices_agent = collect(imagePricesAgent.value, e.resolutions || [])
|
||||
const prices_agent = collect(imagePricesAgent.value, resolutions.value)
|
||||
const duration_prices_agent = collect(videoPricesAgent.value, durationTiers.value)
|
||||
if (!Object.keys(prices).length) { error.value = '至少填写一个分辨率价格'; return }
|
||||
if (!Object.keys(duration_prices).length) { error.value = '至少填写一个时长价格'; return }
|
||||
@@ -142,9 +156,12 @@ async function save() {
|
||||
type: 'video',
|
||||
provider: e.provider,
|
||||
ratios: e.ratios || [],
|
||||
resolutions: e.resolutions || [],
|
||||
// catalog models keep their declared tier list; custom models' supported
|
||||
// tiers ARE the priced keys — a blanked price retires the tier.
|
||||
resolutions: isCustom.value ? sortResolutions(Object.keys(prices)) : (e.resolutions || []),
|
||||
prices,
|
||||
durations: isPerSecond.value ? (e.durations || []) : Object.keys(duration_prices),
|
||||
durations: isPerSecond.value ? (e.durations || [])
|
||||
: (isCustom.value ? Object.keys(duration_prices).sort(bySeconds) : Object.keys(duration_prices)),
|
||||
duration_prices,
|
||||
prices_agent,
|
||||
duration_prices_agent,
|
||||
@@ -154,8 +171,8 @@ async function save() {
|
||||
weight: Number(weight.value) || 0,
|
||||
}
|
||||
} else {
|
||||
const prices = collect(imagePrices.value, e.resolutions || [])
|
||||
const prices_agent = collect(imagePricesAgent.value, e.resolutions || [])
|
||||
const prices = collect(imagePrices.value, resolutions.value)
|
||||
const prices_agent = collect(imagePricesAgent.value, resolutions.value)
|
||||
if (!Object.keys(prices).length) { error.value = '至少填写一个画质价格'; return }
|
||||
payload = {
|
||||
type: 'image',
|
||||
@@ -205,7 +222,7 @@ async function save() {
|
||||
placeholder="选择一个支持的模型" />
|
||||
<div v-else class="field font-mono !cursor-default opacity-90">{{ entry?.id }}</div>
|
||||
<p v-if="!isEdit && !addOptions.length" class="text-[11px] text-amber-300/80 mt-1.5">
|
||||
所有支持的模型都已添加。
|
||||
模型经「渠道管理 → 拉取上游模型」导入。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -219,12 +236,12 @@ async function save() {
|
||||
<div v-if="entry" class="rounded-xl bg-white/[0.03] ring-1 ring-white/[0.06] p-3.5 space-y-2.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1"
|
||||
:class="isVideo ? 'bg-fuchsia-500/10 text-fuchsia-300 ring-fuchsia-400/30'
|
||||
: 'bg-indigo-500/10 text-indigo-300 ring-indigo-400/30'">
|
||||
:class="isVideo ? 'bg-rose-500/10 text-rose-300 ring-rose-400/30'
|
||||
: 'bg-sky-500/10 text-sky-300 ring-sky-400/30'">
|
||||
{{ isVideo ? '生视频' : '生图' }}
|
||||
</span>
|
||||
<span class="text-[11px] text-white/45 capitalize">{{ entry.provider }}</span>
|
||||
<span class="ml-auto text-[10px] text-white/30">参数自动加载,不可改</span>
|
||||
<span class="ml-auto text-[10px] text-white/30">{{ isCustom ? '档位由价格表决定 · 留空=不支持' : '参数自动加载,不可改' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[3.5rem_1fr] gap-x-3 gap-y-1.5 text-[11px]">
|
||||
@@ -237,13 +254,14 @@ async function save() {
|
||||
<template v-if="isVideo">
|
||||
<span class="text-white/40">分辨率</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span v-for="r in resolutions" :key="r" class="ro-chip">{{ r }}</span>
|
||||
<span v-if="!resolutions.length" class="text-white/30">—</span>
|
||||
<span v-for="r in storedResolutions" :key="r" class="ro-chip">{{ r }}</span>
|
||||
<span v-if="!storedResolutions.length" class="text-white/30">—</span>
|
||||
</div>
|
||||
<span class="text-white/40">时长</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span v-for="d in (entry.durations || [])" :key="d" class="ro-chip">{{ d }}</span>
|
||||
<span v-if="!(entry.durations || []).length" class="text-white/30">—</span>
|
||||
<span v-if="!(entry.durations || []).length && (entry.duration_max || 0) > 0" class="ro-chip">{{ entry.duration_min }}–{{ entry.duration_max }}s(区间)</span>
|
||||
<span v-if="!(entry.durations || []).length && !(entry.duration_max || 0)" class="text-white/30">—</span>
|
||||
</div>
|
||||
<span class="text-white/40">参考图</span>
|
||||
<div class="text-white/70">
|
||||
@@ -256,8 +274,8 @@ async function save() {
|
||||
<template v-else>
|
||||
<span class="text-white/40">画质</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span v-for="r in resolutions" :key="r" class="ro-chip">{{ r }}</span>
|
||||
<span v-if="!resolutions.length" class="text-white/30">—</span>
|
||||
<span v-for="r in storedResolutions" :key="r" class="ro-chip">{{ r }}</span>
|
||||
<span v-if="!storedResolutions.length" class="text-white/30">—</span>
|
||||
</div>
|
||||
<span class="text-white/40">参考图</span>
|
||||
<div class="text-white/70">{{ entry.max_reference_images > 0 ? `${entry.max_reference_images} 张` : (entry.image_to_image ? '支持' : '不支持') }}</div>
|
||||
|
||||
@@ -103,31 +103,33 @@ onUnmounted(clearTimers)
|
||||
<transition name="pay-fade">
|
||||
<div v-if="payment.show" class="fixed inset-0 z-[85] bg-slate-950/70 backdrop-blur-sm flex items-center justify-center p-4"
|
||||
@click.self="!paid && finish()">
|
||||
<div class="w-full max-w-sm rounded-2xl bg-white text-slate-800 shadow-2xl overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||
<div class="w-full max-w-sm rounded-2xl bg-[var(--menu-bg)] backdrop-blur-xl text-[color:var(--fg)] border border-[color:var(--glass-border)] shadow-[var(--glass-shadow-lg)] overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-b-[color:var(--ink-soft)] flex items-center justify-between">
|
||||
<h2 class="text-base font-semibold">{{ paid ? '支付成功' : dead ? '支付超时' : (isJump ? '支付监控中' : '扫码支付') }}</h2>
|
||||
<button v-if="!paid" @click="finish" class="text-slate-400 hover:text-slate-700"><Icon name="close" class="w-5 h-5" /></button>
|
||||
<button v-if="!paid" @click="finish" class="text-[color:var(--fg-3)] hover:text-[color:var(--fg)] transition-colors"><Icon name="close" class="w-5 h-5" /></button>
|
||||
</div>
|
||||
|
||||
<!-- paid -->
|
||||
<div v-if="paid" class="px-6 py-10 text-center">
|
||||
<div class="w-16 h-16 mx-auto rounded-full bg-emerald-100 text-emerald-600 grid place-items-center mb-4">
|
||||
<div class="w-16 h-16 mx-auto rounded-full text-white grid place-items-center mb-4"
|
||||
style="background: linear-gradient(135deg, #34d399, #2dd4bf); box-shadow: 0 10px 28px rgb(52 211 153 / 0.4)">
|
||||
<Icon name="spark" class="w-8 h-8" />
|
||||
</div>
|
||||
<p class="text-lg font-semibold text-slate-800">充值成功</p>
|
||||
<p class="text-sm text-slate-500 mt-1">已到账 <strong class="text-emerald-600">{{ order.points }}</strong> 积分</p>
|
||||
<p class="text-xs text-slate-400 mt-4">{{ closeIn }} 秒后自动关闭</p>
|
||||
<p class="text-lg font-semibold text-[color:var(--fg)]">充值成功</p>
|
||||
<p class="text-sm text-[color:var(--fg-3)] mt-1">已到账 <strong class="text-emerald-500">{{ order.points }}</strong> 积分</p>
|
||||
<p class="text-xs text-[color:var(--fg-faint)] mt-4">{{ closeIn }} 秒后自动关闭</p>
|
||||
</div>
|
||||
|
||||
<!-- expired / cancelled -->
|
||||
<div v-else-if="dead" class="px-6 py-10 text-center">
|
||||
<div class="w-16 h-16 mx-auto rounded-full bg-slate-100 text-slate-400 grid place-items-center mb-4">
|
||||
<div class="w-16 h-16 mx-auto rounded-full bg-[var(--surface-2)] text-[color:var(--fg-3)] grid place-items-center mb-4">
|
||||
<Icon name="close" class="w-8 h-8" />
|
||||
</div>
|
||||
<p class="text-lg font-semibold text-slate-700">支付超时</p>
|
||||
<p class="text-sm text-slate-500 mt-1">二维码已失效,订单已取消</p>
|
||||
<p class="text-xs text-slate-400 mt-1">如需充值请重新下单</p>
|
||||
<button @click="finish" class="mt-5 rounded-lg bg-slate-900 text-white hover:bg-slate-700 px-5 py-2 text-sm font-medium transition-colors">关闭</button>
|
||||
<p class="text-lg font-semibold text-[color:var(--fg)]">支付超时</p>
|
||||
<p class="text-sm text-[color:var(--fg-3)] mt-1">二维码已失效,订单已取消</p>
|
||||
<p class="text-xs text-[color:var(--fg-faint)] mt-1">如需充值请重新下单</p>
|
||||
<button @click="finish" class="mt-5 rounded-lg text-white border border-white/20 px-5 py-2 text-sm font-extrabold hover:-translate-y-0.5 active:translate-y-0.5 transition-all"
|
||||
style="background: linear-gradient(120deg, #7c5cfc, #22d3ee); box-shadow: 0 8px 20px var(--accent-glow)">关闭</button>
|
||||
</div>
|
||||
|
||||
<!-- pending -->
|
||||
@@ -135,31 +137,31 @@ onUnmounted(clearTimers)
|
||||
<!-- qrcode: scan to pay -->
|
||||
<template v-if="!isJump">
|
||||
<div class="flex justify-center mb-3">
|
||||
<div class="w-[228px] h-[228px] rounded-2xl ring-1 ring-slate-200 shadow-sm grid place-items-center overflow-hidden bg-white p-3.5">
|
||||
<div class="w-[228px] h-[228px] rounded-xl border border-[color:var(--glass-border)] shadow-[var(--glass-shadow)] grid place-items-center overflow-hidden bg-white p-3.5">
|
||||
<img v-if="qrUrl" :src="qrUrl" alt="支付二维码" class="w-full h-full rounded-lg" />
|
||||
<span v-else class="text-xs text-slate-400">二维码生成中…</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-center text-xs text-slate-500 mb-4">请使用<strong class="text-slate-700">{{ methodLabel }}</strong>扫码支付</p>
|
||||
<p class="text-center text-xs text-[color:var(--fg-3)] mb-4">请使用<strong class="text-[color:var(--fg)]">{{ methodLabel }}</strong>扫码支付</p>
|
||||
</template>
|
||||
<!-- jump (no qrcode): the cashier opened in a new tab — just monitor -->
|
||||
<template v-else>
|
||||
<div class="flex flex-col items-center justify-center py-7 mb-2">
|
||||
<div class="w-12 h-12 rounded-full border-2 border-violet-200 border-t-violet-500 animate-spin mb-3"></div>
|
||||
<p class="text-sm font-medium text-slate-700">支付监控中…</p>
|
||||
<p class="text-xs text-slate-400 mt-1">已打开支付页面,完成后自动到账</p>
|
||||
<div class="w-12 h-12 rounded-full border-[3px] border-[rgb(124_92_252/0.2)] border-t-[#7c5cfc] animate-spin mb-3"></div>
|
||||
<p class="text-sm font-medium text-[color:var(--fg)]">支付监控中…</p>
|
||||
<p class="text-xs text-[color:var(--fg-faint)] mt-1">已打开支付页面,完成后自动到账</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<dl class="text-sm space-y-2">
|
||||
<div class="flex justify-between"><dt class="text-slate-400">订单号</dt><dd class="font-mono text-xs text-slate-700">{{ order.id }}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-slate-400">状态</dt><dd class="text-amber-600 font-medium">{{ statusLabel }}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-slate-400">金额</dt><dd class="font-semibold text-slate-800">¥{{ order.amount }}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-slate-400">充值积分</dt><dd class="text-violet-600 font-semibold">{{ order.points }}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-slate-400">下单时间</dt><dd class="text-xs text-slate-600">{{ fmtTime(order.created_at) }}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-slate-400">支付倒计时</dt><dd class="tabular-nums font-medium" :class="remaining > 0 ? 'text-slate-700' : 'text-rose-500'">{{ remaining > 0 ? mmss : '已超时' }}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-[color:var(--fg-faint)]">订单号</dt><dd class="font-mono text-xs text-[color:var(--fg)]">{{ order.id }}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-[color:var(--fg-faint)]">状态</dt><dd class="text-amber-500 font-medium">{{ statusLabel }}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-[color:var(--fg-faint)]">金额</dt><dd class="font-semibold text-[color:var(--fg)]">¥{{ order.amount }}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-[color:var(--fg-faint)]">充值积分</dt><dd class="text-[color:var(--m-orange)] font-semibold">{{ order.points }}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-[color:var(--fg-faint)]">下单时间</dt><dd class="text-xs text-[color:var(--fg-2)]">{{ fmtTime(order.created_at) }}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-[color:var(--fg-faint)]">支付倒计时</dt><dd class="tabular-nums font-medium" :class="remaining > 0 ? 'text-[color:var(--fg)]' : 'text-rose-500'">{{ remaining > 0 ? mmss : '已超时' }}</dd></div>
|
||||
</dl>
|
||||
<p class="text-[11px] text-slate-400 mt-4 text-center">支付完成后将自动到账,请勿关闭本窗口</p>
|
||||
<p class="text-[11px] text-[color:var(--fg-faint)] mt-4 text-center">支付完成后将自动到账,请勿关闭本窗口</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ const props = defineProps({
|
||||
placeholder: { type: String, default: '请选择' },
|
||||
mono: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
up: { type: Boolean, default: false }, // 面板向上展开(底部居中的输入框用)
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
@@ -90,17 +91,18 @@ onUnmounted(() => document.removeEventListener('mousedown', onDocClick))
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 -translate-y-1">
|
||||
<div v-if="open" ref="panel"
|
||||
class="absolute z-30 mt-2 w-full max-h-64 overflow-auto rounded-xl border border-[color:var(--hairline)]
|
||||
bg-[var(--menu-bg)] backdrop-blur-xl p-1.5 shadow-2xl shadow-black/20 ring-1 ring-[color:var(--hairline)]">
|
||||
class="absolute z-30 w-full max-h-64 overflow-auto rounded-xl border border-[color:var(--glass-border)]
|
||||
bg-[var(--menu-bg)] backdrop-blur-xl p-1.5 shadow-[var(--glass-shadow-lg)]"
|
||||
:class="up ? 'bottom-full mb-2' : 'mt-2'">
|
||||
<button v-for="(o, i) in options" :key="o.value" type="button"
|
||||
:data-idx="i" @click="pick(o)" @mouseenter="active = i"
|
||||
class="w-full flex items-center justify-between gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors"
|
||||
class="w-full flex items-center justify-between gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors"
|
||||
:class="[
|
||||
mono ? 'font-mono' : '',
|
||||
i === active ? 'bg-[var(--hover)] text-[color:var(--fg)]' : 'text-[color:var(--fg-2)]',
|
||||
i === active ? 'bg-[var(--m-yellow)] text-[#111]' : 'text-[color:var(--fg-2)]',
|
||||
]">
|
||||
<span class="truncate">{{ o.label }}</span>
|
||||
<Icon v-if="o.value === modelValue" name="check" class="w-4 h-4 shrink-0 text-violet-400" />
|
||||
<Icon v-if="o.value === modelValue" name="check" class="w-4 h-4 shrink-0 text-[var(--m-orange)]" />
|
||||
</button>
|
||||
<div v-if="!options.length" class="px-3 py-2 text-xs text-[color:var(--fg-3)]">无选项</div>
|
||||
</div>
|
||||
|
||||
@@ -58,12 +58,12 @@ function remove(i) {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
border-radius: 9999px;
|
||||
color: rgb(238 224 255); /* near-white violet */
|
||||
background: linear-gradient(135deg, rgb(167 139 250 / 0.18), rgb(236 72 153 / 0.14));
|
||||
box-shadow: inset 0 0 0 1px rgb(167 139 250 / 0.3);
|
||||
color: rgb(255 237 213); /* near-white orange */
|
||||
background: linear-gradient(135deg, rgb(253 186 116 / 0.18), rgb(251 113 133 / 0.14));
|
||||
box-shadow: inset 0 0 0 1px rgb(253 186 116 / 0.3);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.chip:hover { background: linear-gradient(135deg, rgb(167 139 250 / 0.25), rgb(236 72 153 / 0.2)); }
|
||||
.chip:hover { background: linear-gradient(135deg, rgb(253 186 116 / 0.25), rgb(251 113 133 / 0.2)); }
|
||||
|
||||
.chip-x {
|
||||
display: grid;
|
||||
|
||||
@@ -16,11 +16,16 @@ const isVideo = props.model.type === 'video'
|
||||
// (core/video_models.FAMILIES), not the user-edited managed-model record
|
||||
// which may have stale max_reference_images / reference_mode values.
|
||||
const familyPreset = ref(null)
|
||||
const channels = ref([]) // 渠道 pin 候选(空 = 自动调度)
|
||||
const channelId = ref('')
|
||||
onMounted(async () => {
|
||||
if (!isVideo) return
|
||||
const r = await api('/video-presets')
|
||||
const list = r.data?.data || []
|
||||
familyPreset.value = list.find((p) => p.key === props.model.id) || null
|
||||
if (isVideo) {
|
||||
const r = await api('/video-presets')
|
||||
const list = r.data?.data || []
|
||||
familyPreset.value = list.find((p) => p.key === props.model.id) || null
|
||||
}
|
||||
const cr = await api('/channels')
|
||||
channels.value = (cr.data?.data || []).filter((c) => c.enabled)
|
||||
})
|
||||
|
||||
const ratios = (props.model.ratios && props.model.ratios.length)
|
||||
@@ -137,6 +142,7 @@ async function run() {
|
||||
// Backend accepts raw base64 only — strip the "data:...;base64," prefix.
|
||||
payload.reference_images = refImages.value.map((r) => r.dataUrl.replace(/^data:[^,]*,/, ''))
|
||||
}
|
||||
if (channelId.value) payload.channel_id = channelId.value
|
||||
recoverSubmitTs = Date.now()
|
||||
const r = await api('/test', jsonBody('POST', payload))
|
||||
if (r.ok && r.data?.url) {
|
||||
@@ -239,6 +245,15 @@ async function recover() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 渠道 pin:默认自动调度,选定了就只走该渠道(不故障转移) -->
|
||||
<div v-if="channels.length > 0">
|
||||
<label class="lbl">渠道</label>
|
||||
<select v-model="channelId" class="field">
|
||||
<option value="">自动调度</option>
|
||||
<option v-for="c in channels" :key="c.id" :value="c.id">{{ c.name }}({{ c.base_url }})</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- reference images -->
|
||||
<div v-if="maxRefs > 0">
|
||||
<label class="lbl">
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api, jsonBody } from '../api'
|
||||
import Icon from './Icon.vue'
|
||||
|
||||
const props = defineProps({ account: { type: Object, default: null } }) // edit mode when set
|
||||
const emit = defineEmits(['close', 'imported'])
|
||||
|
||||
const isEdit = !!props.account
|
||||
const name = ref(props.account?.email || '')
|
||||
const baseUrl = ref(props.account?.base_url || '')
|
||||
const key = ref('') // edit: blank = keep existing key
|
||||
const allModels = ref([]) // existing models to pick from
|
||||
const selected = ref(props.account?.models ? String(props.account.models).split(',').map((x) => x.trim()).filter(Boolean) : [])
|
||||
const weight = ref(Number(props.account?.weight) || 0)
|
||||
const concurrency = ref(Number(props.account?.concurrency) || 1)
|
||||
const status = ref('')
|
||||
const isError = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const r = await api('/managed-models')
|
||||
allModels.value = (r.data?.data || []).map((m) => ({ id: m.id, type: m.type, alias: m.alias }))
|
||||
} catch (_) {}
|
||||
})
|
||||
|
||||
function toggle(id) {
|
||||
const i = selected.value.indexOf(id)
|
||||
if (i >= 0) selected.value.splice(i, 1)
|
||||
else selected.value.push(id)
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!baseUrl.value.trim() || (!isEdit && !key.value.trim())) {
|
||||
status.value = isEdit ? '请填写 URL' : '请填写 URL 和 Key'; isError.value = true; return
|
||||
}
|
||||
submitting.value = true; status.value = ''; isError.value = false
|
||||
try {
|
||||
const r = await api('/tokens/import-custom-account', jsonBody('POST', {
|
||||
id: isEdit ? props.account.id : undefined,
|
||||
name: name.value.trim(),
|
||||
base_url: baseUrl.value.trim(),
|
||||
key: key.value.trim(), // blank in edit = keep existing
|
||||
models: selected.value.join(','),
|
||||
weight: Number(weight.value) || 0,
|
||||
concurrency: Number(concurrency.value) || 1,
|
||||
}))
|
||||
if (r.ok) {
|
||||
status.value = isEdit ? '✓ 已保存' : '✓ 已添加上游'; emit('imported')
|
||||
setTimeout(() => emit('close'), 700)
|
||||
} else {
|
||||
status.value = r.data?.detail || '保存失败'; isError.value = true
|
||||
}
|
||||
} catch (e) {
|
||||
status.value = String(e); isError.value = true
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 bg-slate-900/40 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4"
|
||||
@click.self="emit('close')">
|
||||
<div class="card !shadow-xl mt-14 mb-14 w-full max-w-lg">
|
||||
<div class="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold">{{ isEdit ? '编辑上游' : '添加上游(自定义 OpenAI 兼容)' }}</h2>
|
||||
<button @click="emit('close')" class="text-slate-400 hover:text-slate-700 transition-colors">
|
||||
<Icon name="close" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-5 space-y-3">
|
||||
<p class="text-xs text-slate-500 leading-relaxed">
|
||||
上游就是一个账号:填 v1 URL + Key。模型按 <strong class="text-slate-700">id 相同</strong>自动路由 ——
|
||||
在「模型管理」加一个 provider=custom、id 与上游一致的模型即可从这个上游调用。调用<strong class="text-slate-700">直连不走代理</strong>。
|
||||
</p>
|
||||
<div>
|
||||
<label class="text-xs text-slate-500">备注名</label>
|
||||
<input v-model="name" class="field" placeholder="例如:我的中转 / xx-api" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-slate-500">v1 URL <span class="text-rose-500">*</span></label>
|
||||
<input v-model="baseUrl" class="field font-mono text-xs" placeholder="https://api.example.com(无需 /v1 结尾)" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-slate-500">Key <span v-if="!isEdit" class="text-rose-500">*</span><span v-else class="text-white/40">(留空=不改)</span></label>
|
||||
<input v-model="key" class="field font-mono text-xs" :placeholder="isEdit ? '留空保持原 key' : 'sk-...'" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<label class="text-xs text-slate-500">支持的模型(多选,不选 = 全部)</label>
|
||||
<span class="text-[11px] text-slate-400">{{ selected.length ? `已选 ${selected.length}` : '全部' }}</span>
|
||||
</div>
|
||||
<div v-if="!allModels.length" class="text-xs text-slate-400 rounded-lg ring-1 ring-slate-200 bg-slate-50/60 p-3">
|
||||
暂无模型 —— 可先去模型管理加自定义模型
|
||||
</div>
|
||||
<div v-else class="flex flex-wrap gap-1.5 max-h-44 overflow-y-auto rounded-lg ring-1 ring-slate-200 bg-slate-50/60 p-2">
|
||||
<button v-for="m in allModels" :key="m.id" type="button" @click="toggle(m.id)"
|
||||
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs ring-1 transition-colors"
|
||||
:class="selected.includes(m.id) ? 'bg-indigo-500/15 text-indigo-700 ring-indigo-300 font-medium' : 'bg-white text-slate-600 ring-slate-200 hover:ring-slate-300'">
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="m.type === 'video' ? 'bg-violet-400' : 'bg-emerald-400'"></span>{{ m.alias || m.id }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-slate-500">权重(高的优先)</label>
|
||||
<input v-model.number="weight" type="number" class="field" placeholder="0" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-slate-500">并发数(单账号)</label>
|
||||
<input v-model.number="concurrency" type="number" min="1" class="field" placeholder="1" />
|
||||
</div>
|
||||
</div>
|
||||
<button @click="submit" :disabled="submitting" class="btn-primary w-full mt-1">
|
||||
{{ submitting ? '保存中…' : (isEdit ? '保存' : '添加上游') }}
|
||||
</button>
|
||||
<p v-if="status" class="text-xs" :class="isError ? 'text-rose-600' : 'text-emerald-600'">{{ status }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,19 +2,53 @@
|
||||
// 单个画布节点。位置/尺寸由 stage 的世界层 transform 统一缩放,
|
||||
// 这里只负责节点自身内容:image / note / gen(生成中占位)。
|
||||
// 性能:contain 隔离布局;图片 lazy + async 解码 + 缩略图。
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { thumbUrl } from '../../api'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { generatedUrl, loadImageBlob, thumbUrl } from '../../api'
|
||||
|
||||
const props = defineProps({
|
||||
node: { type: Object, required: true },
|
||||
selected: { type: Boolean, default: false },
|
||||
linkSourceSide: { type: String, default: '' },
|
||||
})
|
||||
const emit = defineEmits(['imgload', 'note-commit', 'node-action'])
|
||||
const emit = defineEmits(['imgload', 'note-commit', 'node-action', 'port-click'])
|
||||
const portSides = ['top', 'right', 'bottom', 'left']
|
||||
const portLabels = { top: '上', right: '右', bottom: '下', left: '左' }
|
||||
|
||||
const imgLoaded = ref(false)
|
||||
const imgBroken = ref(false)
|
||||
const imgStatus = ref('')
|
||||
const imgBlob = ref('')
|
||||
const imgOriginal = computed(() => generatedUrl(props.node.src))
|
||||
const noteEl = ref(null)
|
||||
const editing = ref(false)
|
||||
|
||||
let imgController = null
|
||||
async function loadSrc() {
|
||||
if (!['image', 'video'].includes(props.node.type) || !props.node.src) return
|
||||
imgController?.abort()
|
||||
imgController = new AbortController()
|
||||
imgLoaded.value = false
|
||||
imgBroken.value = false
|
||||
if (imgBlob.value) { URL.revokeObjectURL(imgBlob.value); imgBlob.value = '' }
|
||||
try {
|
||||
const { url } = await loadImageBlob(props.node.src, imgController.signal)
|
||||
imgBlob.value = url
|
||||
} catch (e) {
|
||||
imgBroken.value = true
|
||||
const msg = e?.message || ''
|
||||
const m = msg.match(/\((\d+)\)/)
|
||||
imgStatus.value = m ? m[1] : '失败'
|
||||
imgLoaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => loadSrc())
|
||||
onUnmounted(() => {
|
||||
imgController?.abort()
|
||||
if (imgBlob.value) { URL.revokeObjectURL(imgBlob.value); imgBlob.value = '' }
|
||||
})
|
||||
watch(() => props.node.src, loadSrc)
|
||||
|
||||
onMounted(() => {
|
||||
// 非受控 contenteditable:只在挂载时写初值,避免输入时光标被 v-model 回写打跳。
|
||||
if (props.node.type === 'note' && noteEl.value) noteEl.value.innerText = props.node.text || ''
|
||||
@@ -33,6 +67,7 @@ watch(
|
||||
|
||||
function onImgLoad(e) {
|
||||
imgLoaded.value = true
|
||||
imgBroken.value = false
|
||||
const img = e.target
|
||||
// 按真实长宽比校正节点尺寸(以长边 512 归一,保持画布视觉密度一致)。
|
||||
if (img.naturalWidth && img.naturalHeight) {
|
||||
@@ -44,6 +79,19 @@ function onImgLoad(e) {
|
||||
})
|
||||
}
|
||||
}
|
||||
function onVidMeta(e) {
|
||||
imgLoaded.value = true
|
||||
imgBroken.value = false
|
||||
const v = e.target
|
||||
if (v.videoWidth && v.videoHeight) {
|
||||
const long = Math.max(v.videoWidth, v.videoHeight)
|
||||
const k = 512 / long
|
||||
emit('imgload', {
|
||||
w: Math.round(v.videoWidth * k),
|
||||
h: Math.round(v.videoHeight * k),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit() {
|
||||
if (props.node.type !== 'note') return
|
||||
@@ -99,20 +147,48 @@ defineExpose({ startEdit, commitNote })
|
||||
:data-node-id="node.id"
|
||||
@dblclick="onSelfDblClick"
|
||||
>
|
||||
<!-- 连线出口:拖到目标节点创建连线(舞台接管手势) -->
|
||||
<span v-if="node.type !== 'gen'" class="cv-port" data-port="out" />
|
||||
<!-- 连线出口:点选起点和终点建立连线 -->
|
||||
<button v-for="side in portSides" :key="side"
|
||||
type="button" class="cv-port" :class="[`cv-port--${side}`, { 'is-active': linkSourceSide === side }]"
|
||||
:aria-label="`${portLabels[side]}侧连接点`" :aria-pressed="linkSourceSide === side"
|
||||
:title="`${portLabels[side]}侧连接点`"
|
||||
@pointerdown.stop @dblclick.stop @click.stop="emit('port-click', side)" />
|
||||
<!-- 右下角缩放手柄 + 悬浮工具条:仅选中时显示,舞台接管 resize 手势 -->
|
||||
<span v-if="selected && node.type !== 'gen'" class="cv-size" data-resize />
|
||||
<div v-if="node.type !== 'gen'" class="cv-tools" @dblclick.stop @pointerdown.stop>
|
||||
<button v-if="node.type === 'image'" title="添加为参考图" @click.stop="emit('node-action', { type: 'ref' })">+</button>
|
||||
<button class="cv-tools__del" title="删除" @click.stop="emit('node-action', { type: 'delete' })">✕</button>
|
||||
</div>
|
||||
<div class="cv-node__clip">
|
||||
<!-- 图片节点 -->
|
||||
<template v-if="node.type === 'image'">
|
||||
<img
|
||||
v-if="!imgBroken && imgBlob"
|
||||
class="cv-node__img"
|
||||
:src="thumbUrl(node.src)"
|
||||
loading="lazy"
|
||||
:src="imgBlob"
|
||||
decoding="async"
|
||||
draggable="false"
|
||||
alt=""
|
||||
@load="onImgLoad"
|
||||
@error="imgLoaded = true"
|
||||
>
|
||||
<div v-else class="cv-node__missing">图片不可用<small v-if="imgStatus">HTTP {{ imgStatus }}</small></div>
|
||||
<div v-if="node.prompt" class="cv-node__cap" :title="node.prompt">{{ node.prompt }}</div>
|
||||
</template>
|
||||
|
||||
<!-- 视频节点 -->
|
||||
<template v-else-if="node.type === 'video'">
|
||||
<video
|
||||
v-if="!imgBroken && imgBlob"
|
||||
class="cv-node__img"
|
||||
:src="imgBlob"
|
||||
controls
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
@loadedmetadata="onVidMeta"
|
||||
@error="imgBroken = true"
|
||||
/>
|
||||
<div v-else class="cv-node__missing">{{ imgBroken ? '视频不可用' : '加载中…' }}<small v-if="imgStatus">HTTP {{ imgStatus }}</small></div>
|
||||
<div v-if="node.prompt" class="cv-node__cap" :title="node.prompt">{{ node.prompt }}</div>
|
||||
</template>
|
||||
|
||||
@@ -131,30 +207,41 @@ defineExpose({ startEdit, commitNote })
|
||||
<div v-else class="cv-node__gen">
|
||||
<template v-if="node.status === 'running'">
|
||||
<span class="cv-node__spin" />
|
||||
<span>生成中…</span>
|
||||
<span class="cv-node__genlabel">生成中…</span>
|
||||
<span class="cv-node__gensub">{{ node.model || 'AI' }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="cv-node__x">✕</span>
|
||||
<span class="cv-node__err" :title="node.error || ''">生成失败</span>
|
||||
<span>生成失败</span>
|
||||
<span class="cv-node__err" :title="node.error || ''">{{ node.error || '请检查提示词后重试' }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cv-node {
|
||||
position: absolute;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
contain: layout style paint;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--glass-border);
|
||||
contain: layout style;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
box-shadow: 0 2px 10px rgb(0 0 0 / 0.35);
|
||||
background: var(--surface-solid);
|
||||
box-shadow: var(--glass-shadow);
|
||||
transition: box-shadow 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.cv-node__clip {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 13px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cv-node.is-selected {
|
||||
outline: 2px solid #7c6cff;
|
||||
outline-offset: 2px;
|
||||
border-color: rgb(124 92 252 / 0.7);
|
||||
box-shadow: var(--glass-shadow-lg), 0 0 0 3px rgb(124 92 252 / 0.25), 0 10px 30px var(--accent-glow);
|
||||
}
|
||||
.cv-node:active {
|
||||
cursor: grabbing;
|
||||
@@ -165,7 +252,20 @@ defineExpose({ startEdit, commitNote })
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
background: #14161d;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.cv-node__missing small { display: block; margin-top: 4px; font-weight: 500; opacity: 0.7; }
|
||||
.cv-node__missing {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-content: center;
|
||||
text-align: center;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--fg-3);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.cv-node__cap {
|
||||
@@ -173,32 +273,39 @@ defineExpose({ startEdit, commitNote })
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 14px 8px 5px;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
color: #d6d9e2;
|
||||
background: linear-gradient(transparent, rgb(8 9 13 / 0.92));
|
||||
padding: 36px 14px 12px;
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 4px rgb(0 0 0 / 0.65);
|
||||
background: linear-gradient(to top, rgb(0 0 0 / 0.66), transparent);
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 笔记 = 淡黄色贴纸 */
|
||||
.cv-node--note {
|
||||
background: #23262f;
|
||||
border: 1px solid #343947;
|
||||
background: #fdf6c9;
|
||||
border-color: rgb(180 140 20 / 0.35);
|
||||
cursor: text;
|
||||
}
|
||||
html.dark .cv-node--note {
|
||||
background: rgb(250 204 21 / 0.14);
|
||||
border-color: rgb(250 204 21 / 0.35);
|
||||
}
|
||||
.cv-node--note:not(.is-editing) {
|
||||
cursor: grab;
|
||||
}
|
||||
.cv-node__note {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #e8eaf0;
|
||||
padding: 12px 14px;
|
||||
font-size: 32px;
|
||||
line-height: 1.35;
|
||||
color: #111;
|
||||
outline: none;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
@@ -211,56 +318,172 @@ defineExpose({ startEdit, commitNote })
|
||||
}
|
||||
|
||||
.cv-node--gen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #1a1d26;
|
||||
border: 1.5px dashed #4a4f61;
|
||||
color: #9aa1b2;
|
||||
border-style: dashed;
|
||||
color: var(--fg-2);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cv-node--gen.is-failed {
|
||||
border-color: #6b3b44;
|
||||
color: #e58c96;
|
||||
border-color: #e5484d;
|
||||
color: #e5484d;
|
||||
}
|
||||
/* 生成占位内容:居中排布 + 微光扫过 */
|
||||
.cv-node__gen {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
background:
|
||||
radial-gradient(130% 130% at 15% 0%, rgb(124 92 252 / 0.14), transparent 55%),
|
||||
radial-gradient(130% 130% at 85% 100%, rgb(34 211 238 / 0.12), transparent 55%),
|
||||
var(--surface);
|
||||
}
|
||||
.cv-node__gen::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(105deg, transparent 38%, rgb(255 255 255 / 0.18) 50%, transparent 62%);
|
||||
background-size: 260% 100%;
|
||||
background-position: 260% 0;
|
||||
animation: cv-gen-shine 2.4s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
.is-failed .cv-node__gen {
|
||||
background:
|
||||
radial-gradient(130% 130% at 15% 0%, rgb(229 72 77 / 0.1), transparent 55%),
|
||||
var(--surface);
|
||||
}
|
||||
.is-failed .cv-node__gen::after {
|
||||
display: none;
|
||||
}
|
||||
.cv-node__genlabel {
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.cv-node__gensub {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--fg-faint);
|
||||
}
|
||||
.cv-node__spin {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
border: 2.5px solid #3b4152;
|
||||
border-top-color: #7c6cff;
|
||||
border: 3px solid rgb(124 92 252 / 0.2);
|
||||
border-top-color: var(--m-orange);
|
||||
border-right-color: var(--m-orange-2);
|
||||
animation: cv-spin 0.9s linear infinite;
|
||||
filter: drop-shadow(0 0 10px var(--accent-glow));
|
||||
}
|
||||
@keyframes cv-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@keyframes cv-gen-shine {
|
||||
to { background-position: -260% 0; }
|
||||
}
|
||||
.cv-node__err {
|
||||
font-size: 11px;
|
||||
max-width: 90%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.5;
|
||||
max-width: min(90%, 320px);
|
||||
max-height: 5em;
|
||||
overflow: auto;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: center;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.cv-port {
|
||||
position: absolute;
|
||||
right: -8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 50%;
|
||||
background: #7c6cff;
|
||||
border: 2.5px solid #0e1015;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s;
|
||||
cursor: crosshair;
|
||||
z-index: 2;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
z-index: 3;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.cv-node:hover .cv-port,
|
||||
.cv-node.is-selected .cv-port {
|
||||
.cv-port::after {
|
||||
content: '';
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
border: 3px solid var(--m-orange);
|
||||
box-shadow: 0 1px 4px rgb(0 0 0 / 0.35), 0 0 0 2px rgb(255 255 255 / 0.8);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.cv-port:hover::after, .cv-port:focus-visible::after, .cv-port.is-active::after {
|
||||
transform: scale(1.3);
|
||||
box-shadow: 0 0 0 4px rgb(124 92 252 / 0.25), 0 2px 10px var(--accent-glow);
|
||||
}
|
||||
.cv-port.is-active::after { background: var(--m-orange); }
|
||||
.cv-port:focus-visible { outline: 2px solid var(--m-orange); outline-offset: -2px; border-radius: 50%; }
|
||||
.cv-port--top { top: -17px; left: 50%; transform: translateX(-50%); }
|
||||
.cv-port--right { right: -17px; top: 50%; transform: translateY(-50%); }
|
||||
.cv-port--bottom { bottom: -17px; left: 50%; transform: translateX(-50%); }
|
||||
.cv-port--left { left: -17px; top: 50%; transform: translateY(-50%); }
|
||||
|
||||
/* 右下角缩放方块(节点 contain:paint,只能放内侧) */
|
||||
.cv-size {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
bottom: 4px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 4px;
|
||||
background: var(--surface-solid);
|
||||
border: 1px solid var(--ink);
|
||||
cursor: nwse-resize;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
/* 悬浮工具条:鼠标放上节点就显示(选中时常显),贴节点内上缘 */
|
||||
.cv-tools {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
z-index: 4;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.12s ease;
|
||||
}
|
||||
.cv-node:hover .cv-tools,
|
||||
.cv-node.is-selected .cv-tools,
|
||||
.cv-tools:focus-within {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.cv-tools button {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--glass-border);
|
||||
background: var(--menu-bg);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
backdrop-filter: blur(10px);
|
||||
color: var(--fg);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--glass-shadow);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
transition: transform 0.12s ease, background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
.cv-tools button:hover {
|
||||
transform: translateY(-1px);
|
||||
background: var(--hover);
|
||||
}
|
||||
.cv-tools__del {
|
||||
color: #e5484d;
|
||||
}
|
||||
.cv-tools__del:hover {
|
||||
background: rgb(229 72 77 / 0.14) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
zoomAt,
|
||||
rectsIntersect,
|
||||
nodeRect,
|
||||
BASE_SCALE,
|
||||
MIN_SCALE,
|
||||
MAX_SCALE,
|
||||
} from '../../utils/canvas'
|
||||
import CanvasNodeView from './CanvasNodeView.vue'
|
||||
|
||||
@@ -27,6 +30,7 @@ const emit = defineEmits([
|
||||
'img-size', // 图片真实尺寸校正 -> { id, w, h }
|
||||
'viewport-rest', // 视口静止 700ms -> viewport
|
||||
'pan-zoom', // 视口变化(用于百分比显示,高频)
|
||||
'blank-dblclick', // 双击空白 -> { x, y } 世界坐标(父级建笔记)
|
||||
])
|
||||
|
||||
const stageEl = ref(null)
|
||||
@@ -35,7 +39,7 @@ const stageSize = reactive({ w: 0, h: 0 })
|
||||
const selection = ref(new Set())
|
||||
const edgeSel = ref(new Set())
|
||||
const marquee = ref(null)
|
||||
const linkLine = ref(null) // 手动连线进行中 {x1,y1,x2,y2} 世界坐标
|
||||
const linkSource = ref(null)
|
||||
const spaceHeld = ref(false)
|
||||
const panning = ref(false)
|
||||
|
||||
@@ -53,39 +57,173 @@ const nodeById = computed(() => {
|
||||
return m
|
||||
})
|
||||
|
||||
function portPoint(n, side) {
|
||||
if (side === 'top') return { x: n.x + n.w / 2, y: n.y }
|
||||
if (side === 'right') return { x: n.x + n.w, y: n.y + n.h / 2 }
|
||||
if (side === 'bottom') return { x: n.x + n.w / 2, y: n.y + n.h }
|
||||
if (side === 'left') return { x: n.x, y: n.y + n.h / 2 }
|
||||
return { x: n.x + n.w / 2, y: n.y + n.h / 2 }
|
||||
}
|
||||
function portDirection(side, fallback) {
|
||||
return {
|
||||
top: { x: 0, y: -1 }, right: { x: 1, y: 0 },
|
||||
bottom: { x: 0, y: 1 }, left: { x: -1, y: 0 },
|
||||
}[side] || fallback
|
||||
}
|
||||
|
||||
const EDGE_PAD = 30
|
||||
|
||||
function shifted(p, v, d) {
|
||||
return { x: p.x + v.x * d, y: p.y + v.y * d }
|
||||
}
|
||||
function cubicPoint(p0, c1, c2, p1, t) {
|
||||
const u = 1 - t
|
||||
return {
|
||||
x: u * u * u * p0.x + 3 * u * u * t * c1.x + 3 * u * t * t * c2.x + t * t * t * p1.x,
|
||||
y: u * u * u * p0.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * p1.y,
|
||||
}
|
||||
}
|
||||
function expandedNode(n) {
|
||||
return { x: n.x - EDGE_PAD, y: n.y - EDGE_PAD, w: n.w + EDGE_PAD * 2, h: n.h + EDGE_PAD * 2 }
|
||||
}
|
||||
function segmentHitsRect(a, b, r) {
|
||||
for (let i = 0; i <= 20; i++) {
|
||||
const t = i / 20
|
||||
const x = a.x + (b.x - a.x) * t
|
||||
const y = a.y + (b.y - a.y) * t
|
||||
if (x > r.x && x < r.x + r.w && y > r.y && y < r.y + r.h) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
function polylineHits(points, obstacles) {
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
for (const r of obstacles) if (segmentHitsRect(points[i], points[i + 1], r)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
function cubicHits(p0, c1, c2, p1, obstacles) {
|
||||
for (let i = 1; i < 28; i++) {
|
||||
const p = cubicPoint(p0, c1, c2, p1, i / 28)
|
||||
for (const r of obstacles) {
|
||||
if (p.x > r.x && p.x < r.x + r.w && p.y > r.y && p.y < r.y + r.h) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
function polylineLength(points) {
|
||||
let n = 0
|
||||
for (let i = 0; i < points.length - 1; i++) n += Math.hypot(points[i + 1].x - points[i].x, points[i + 1].y - points[i].y)
|
||||
return n
|
||||
}
|
||||
function routePolyline(a, b, obstacles) {
|
||||
const mx = (a.x + b.x) / 2
|
||||
const my = (a.y + b.y) / 2
|
||||
const candidates = [
|
||||
[a, b],
|
||||
[a, { x: b.x, y: a.y }, b],
|
||||
[a, { x: a.x, y: b.y }, b],
|
||||
[a, { x: mx, y: a.y }, { x: mx, y: b.y }, b],
|
||||
[a, { x: a.x, y: my }, { x: b.x, y: my }, b],
|
||||
]
|
||||
if (obstacles.length) {
|
||||
const box = obstacles.reduce((o, r) => ({
|
||||
x: Math.min(o.x, r.x), y: Math.min(o.y, r.y),
|
||||
x2: Math.max(o.x2, r.x + r.w), y2: Math.max(o.y2, r.y + r.h),
|
||||
}), { x: Infinity, y: Infinity, x2: -Infinity, y2: -Infinity })
|
||||
for (const y of [box.y - EDGE_PAD, box.y2 + EDGE_PAD]) {
|
||||
candidates.push([a, { x: a.x, y }, { x: b.x, y }, b])
|
||||
}
|
||||
for (const x of [box.x - EDGE_PAD, box.x2 + EDGE_PAD]) {
|
||||
candidates.push([a, { x, y: a.y }, { x, y: b.y }, b])
|
||||
}
|
||||
}
|
||||
let best = null
|
||||
let bestScore = Infinity
|
||||
for (const c of candidates) {
|
||||
if (polylineHits(c, obstacles)) continue
|
||||
const score = polylineLength(c) + (c.length - 2) * 80
|
||||
if (score < bestScore) {
|
||||
best = c
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
return best || [a, b]
|
||||
}
|
||||
function roundedPath(points) {
|
||||
const pts = points.filter((p, i) => !i || Math.hypot(p.x - points[i - 1].x, p.y - points[i - 1].y) > 0.5)
|
||||
if (pts.length < 2) return ''
|
||||
let d = `M ${pts[0].x} ${pts[0].y}`
|
||||
for (let i = 1; i < pts.length - 1; i++) {
|
||||
const prev = pts[i - 1]
|
||||
const cur = pts[i]
|
||||
const next = pts[i + 1]
|
||||
const d1 = Math.hypot(cur.x - prev.x, cur.y - prev.y)
|
||||
const d2 = Math.hypot(next.x - cur.x, next.y - cur.y)
|
||||
const r = Math.min(20, d1 / 2, d2 / 2)
|
||||
if (r < 1) continue
|
||||
const inX = cur.x - ((cur.x - prev.x) / d1) * r
|
||||
const inY = cur.y - ((cur.y - prev.y) / d1) * r
|
||||
const outX = cur.x + ((next.x - cur.x) / d2) * r
|
||||
const outY = cur.y + ((next.y - cur.y) / d2) * r
|
||||
d += ` L ${inX} ${inY} Q ${cur.x} ${cur.y} ${outX} ${outY}`
|
||||
}
|
||||
const last = pts[pts.length - 1]
|
||||
return `${d} L ${last.x} ${last.y}`
|
||||
}
|
||||
|
||||
const edgePaths = computed(() => {
|
||||
const out = []
|
||||
for (const e of props.edges) {
|
||||
const a = nodeById.value.get(e.from)
|
||||
const b = nodeById.value.get(e.to)
|
||||
if (!a || !b) continue
|
||||
const x1 = a.x + a.w / 2
|
||||
const y1 = a.y + a.h / 2
|
||||
const x2 = b.x + b.w / 2
|
||||
const y2 = b.y + b.h / 2
|
||||
const dx = Math.max(60, Math.abs(x2 - x1) * 0.45)
|
||||
const p1 = portPoint(a, e.fromSide)
|
||||
const p2 = portPoint(b, e.toSide)
|
||||
const v1 = portDirection(e.fromSide, { x: 1, y: 0 })
|
||||
const v2 = portDirection(e.toSide, { x: -1, y: 0 })
|
||||
const manualBend = Number.isFinite(e.bendX) && Number.isFinite(e.bendY)
|
||||
? { x: (p1.x + p2.x) / 2 + e.bendX, y: (p1.y + p2.y) / 2 + e.bendY }
|
||||
: null
|
||||
const d = Math.max(60, Math.hypot(p2.x - p1.x, p2.y - p1.y) * 0.45)
|
||||
const c1 = shifted(p1, v1, d)
|
||||
const c2 = shifted(p2, v2, d)
|
||||
let path
|
||||
if (manualBend) {
|
||||
const L = Math.hypot(p2.x - p1.x, p2.y - p1.y) || 1
|
||||
const tx = (p2.x - p1.x) / L
|
||||
const ty = (p2.y - p1.y) / L
|
||||
const d1 = Math.max(40, Math.hypot(manualBend.x - p1.x, manualBend.y - p1.y) * 0.5)
|
||||
const d2 = Math.max(40, Math.hypot(p2.x - manualBend.x, p2.y - manualBend.y) * 0.5)
|
||||
path = `M ${p1.x} ${p1.y} C ${p1.x + v1.x * d1} ${p1.y + v1.y * d1}, ${manualBend.x - tx * d1} ${manualBend.y - ty * d1}, ${manualBend.x} ${manualBend.y} `
|
||||
+ `C ${manualBend.x + tx * d2} ${manualBend.y + ty * d2}, ${p2.x + v2.x * d2} ${p2.y + v2.y * d2}, ${p2.x} ${p2.y}`
|
||||
} else {
|
||||
path = `M ${p1.x} ${p1.y} C ${c1.x} ${c1.y}, ${c2.x} ${c2.y}, ${p2.x} ${p2.y}`
|
||||
const obstacles = props.nodes
|
||||
.filter((n) => n.id !== a.id && n.id !== b.id)
|
||||
.map(expandedNode)
|
||||
if (obstacles.length && cubicHits(p1, c1, c2, p2, obstacles)) {
|
||||
const start = shifted(p1, v1, EDGE_PAD)
|
||||
const end = shifted(p2, v2, EDGE_PAD)
|
||||
path = roundedPath([p1, ...routePolyline(start, end, obstacles), p2])
|
||||
}
|
||||
}
|
||||
out.push({
|
||||
id: e.id,
|
||||
to: e.to,
|
||||
d: `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`,
|
||||
d: path,
|
||||
bend: manualBend,
|
||||
handle: manualBend || { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 },
|
||||
})
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const worldStyle = computed(() => ({
|
||||
transform: `translate3d(${vp.x}px, ${vp.y}px, 0) scale(${vp.scale})`,
|
||||
transform: `translate3d(${vp.x}px, ${vp.y}px, 0) scale(${vp.scale * BASE_SCALE})`,
|
||||
}))
|
||||
|
||||
// 手动连线进行中的预览路径
|
||||
const linkPath = computed(() => {
|
||||
const l = linkLine.value
|
||||
if (!l) return ''
|
||||
const dx = Math.max(60, Math.abs(l.x2 - l.x1) * 0.45)
|
||||
return `M ${l.x1} ${l.y1} C ${l.x1 + dx} ${l.y1}, ${l.x2 - dx} ${l.y2}, ${l.x2} ${l.y2}`
|
||||
})
|
||||
const gridStyle = computed(() => {
|
||||
const cell = Math.max(14, 24 * vp.scale)
|
||||
const cell = Math.max(14, 24 * vp.scale * BASE_SCALE)
|
||||
return {
|
||||
backgroundSize: `${cell}px ${cell}px`,
|
||||
backgroundPosition: `${vp.x}px ${vp.y}px`,
|
||||
@@ -186,43 +324,60 @@ function onPointerDown(e) {
|
||||
// 文本编辑中完全放行
|
||||
if (e.target.closest?.('.cv-node--note.is-editing')) return
|
||||
|
||||
// 1) 连线出口:拖到目标节点建边
|
||||
const port = e.target.closest?.('[data-port]')
|
||||
if (port && e.button === 0) {
|
||||
// 0) 缩放手柄:右下角墨边方块,拖动能改节点尺寸
|
||||
const sizeGrip = e.target.closest?.('[data-resize]')
|
||||
if (sizeGrip && e.button === 0) {
|
||||
const node = hitNode(e)
|
||||
if (node) {
|
||||
clearEdgeSel()
|
||||
setSelection(new Set([node.id]))
|
||||
act = { mode: 'link', fromId: node.id }
|
||||
linkLine.value = {
|
||||
x1: node.x + node.w,
|
||||
y1: node.y + node.h / 2,
|
||||
x2: node.x + node.w,
|
||||
y2: node.y + node.h / 2,
|
||||
const start = screenToWorld(localXY(e).x, localXY(e).y, vp)
|
||||
act = {
|
||||
mode: 'resize', node,
|
||||
startX: start.x, startY: start.y,
|
||||
w0: node.w, h0: node.h,
|
||||
aspect: node.h > 0 ? node.w / node.h : 1,
|
||||
}
|
||||
try { stageEl.value.setPointerCapture(e.pointerId) } catch { /* 合成/失效指针 */ }
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 点击连线:切换选中(不再落入平移)
|
||||
// 2) 点住连线:轻点切换选中,拖动则添加/移动弯折点
|
||||
const edgeHit = e.target.closest?.('.cv-edge-hit')
|
||||
if (edgeHit && e.button === 0) {
|
||||
const id = edgeHit.dataset.edgeId
|
||||
const s = new Set(edgeSel.value)
|
||||
if (s.has(id)) s.delete(id)
|
||||
else s.add(id)
|
||||
edgeSel.value = s
|
||||
if (s.size) clearSelection()
|
||||
const edge = props.edges.find((x) => x.id === edgeHit.dataset.edgeId)
|
||||
if (edge) {
|
||||
linkSource.value = null
|
||||
const p = localXY(e)
|
||||
const w = screenToWorld(p.x, p.y, vp)
|
||||
act = {
|
||||
mode: 'edge', edge,
|
||||
id: edge.id,
|
||||
wasSelected: edgeSel.value.has(edge.id),
|
||||
start: w,
|
||||
bendX: Number.isFinite(edge.bendX) ? edge.bendX : 0,
|
||||
bendY: Number.isFinite(edge.bendY) ? edge.bendY : 0,
|
||||
moved: false,
|
||||
px: p.x, py: p.y,
|
||||
}
|
||||
try { stageEl.value.setPointerCapture(e.pointerId) } catch { /* 合成/失效指针 */ }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const node = hitNode(e)
|
||||
if (!node || node.id === linkSource.value?.id) linkSource.value = null
|
||||
const p = localXY(e)
|
||||
|
||||
const wantPan =
|
||||
e.button === 1 || e.button === 2 || spaceHeld.value || (!node && !e.shiftKey && e.button === 0)
|
||||
if (wantPan) {
|
||||
// 空白左键点击 = 平移画布,同时清空节点/连线选中(点别处失焦)
|
||||
if (!node && e.button === 0 && !spaceHeld.value) {
|
||||
clearSelection()
|
||||
clearEdgeSel()
|
||||
}
|
||||
panning.value = true
|
||||
act = { mode: 'pan', px: p.x, py: p.y, vx: vp.x, vy: vp.y }
|
||||
try { stageEl.value.setPointerCapture(e.pointerId) } catch { /* 合成/失效指针 */ }
|
||||
@@ -292,10 +447,35 @@ function applyMove(e) {
|
||||
}
|
||||
return
|
||||
}
|
||||
if (act.mode === 'link' && linkLine.value) {
|
||||
if (act.mode === 'edge') {
|
||||
if (!act.moved) {
|
||||
if (Math.hypot(p.x - act.px, p.y - act.py) < 4) return
|
||||
act.moved = true
|
||||
edgeSel.value = new Set([act.id])
|
||||
clearSelection()
|
||||
}
|
||||
const cur = screenToWorld(p.x, p.y, vp)
|
||||
linkLine.value.x2 = cur.x
|
||||
linkLine.value.y2 = cur.y
|
||||
const a = nodeById.value.get(act.edge.from)
|
||||
const b = nodeById.value.get(act.edge.to)
|
||||
if (!a || !b) return
|
||||
const p1 = portPoint(a, act.edge.fromSide)
|
||||
const p2 = portPoint(b, act.edge.toSide)
|
||||
act.edge.bendX = Math.round(cur.x - (p1.x + p2.x) / 2)
|
||||
act.edge.bendY = Math.round(cur.y - (p1.y + p2.y) / 2)
|
||||
return
|
||||
}
|
||||
if (act.mode === 'resize') {
|
||||
const cur = screenToWorld(p.x, p.y, vp)
|
||||
const n = act.node
|
||||
if (n.type === 'note') {
|
||||
n.w = Math.max(140, Math.round(act.w0 + cur.x - act.startX))
|
||||
n.h = Math.max(90, Math.round(act.h0 + cur.y - act.startY))
|
||||
} else {
|
||||
// 图片锁长宽比:以宽度为准推导高度
|
||||
const w = Math.max(96, act.w0 + cur.x - act.startX)
|
||||
n.w = Math.round(w)
|
||||
n.h = Math.round(w / act.aspect)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (act.mode === 'marquee') {
|
||||
@@ -320,26 +500,49 @@ function onPointerUp(e) {
|
||||
panning.value = false
|
||||
try { stageEl.value.releasePointerCapture(e.pointerId) } catch { /* 已释放 */ }
|
||||
if (a.mode === 'drag' && a.moved) emit('move-end')
|
||||
if (a.mode === 'link') {
|
||||
const target = hitNode(e)
|
||||
if (target && target.id !== a.fromId) emit('link-create', { from: a.fromId, to: target.id })
|
||||
linkLine.value = null
|
||||
if (a.mode === 'resize') emit('move-end') // 尺寸进了快照,可撤销
|
||||
if (a.mode === 'edge') {
|
||||
if (a.moved) {
|
||||
emit('move-end')
|
||||
} else {
|
||||
const s = new Set(edgeSel.value)
|
||||
if (a.wasSelected) s.delete(a.id)
|
||||
else s.add(a.id)
|
||||
edgeSel.value = s
|
||||
if (s.size) clearSelection()
|
||||
}
|
||||
}
|
||||
}
|
||||
function onPointerCancel() {
|
||||
if (act?.mode === 'drag' && act.moved) emit('move-end')
|
||||
if (act?.mode === 'resize') emit('move-end')
|
||||
if (act?.mode === 'edge' && act.moved) emit('move-end')
|
||||
act = null
|
||||
marquee.value = null
|
||||
linkLine.value = null
|
||||
panning.value = false
|
||||
}
|
||||
|
||||
function onPortClick(id, side) {
|
||||
if (linkSource.value && !nodeById.value.has(linkSource.value.id)) linkSource.value = null
|
||||
if (!linkSource.value) {
|
||||
clearEdgeSel()
|
||||
linkSource.value = { id, side }
|
||||
return
|
||||
}
|
||||
if (linkSource.value.id === id) {
|
||||
linkSource.value = null
|
||||
return
|
||||
}
|
||||
emit('link-create', {
|
||||
from: linkSource.value.id, to: id,
|
||||
fromSide: linkSource.value.side, toSide: side,
|
||||
})
|
||||
linkSource.value = null
|
||||
}
|
||||
|
||||
// 取消进行中的连线(Esc)
|
||||
function cancelLink() {
|
||||
if (act?.mode === 'link') {
|
||||
act = null
|
||||
linkLine.value = null
|
||||
}
|
||||
linkSource.value = null
|
||||
}
|
||||
|
||||
// 返回并清空选中的连线(父级 Delete 处理用)
|
||||
@@ -355,12 +558,37 @@ function hasSelectedEdges() {
|
||||
return edgeSel.value.size > 0
|
||||
}
|
||||
|
||||
function nextZoom(scale, direction) {
|
||||
if (direction < 0 && scale < 0.1) return scale
|
||||
const level = scale * 10
|
||||
const step = direction > 0 ? Math.floor(level + 1e-6) + 1 : Math.ceil(level - 1e-6) - 1
|
||||
return Math.min(4, Math.max(0.1, step / 10))
|
||||
}
|
||||
let wheelAccum = 0
|
||||
let lastWheelAt = 0
|
||||
function onWheel(e) {
|
||||
e.preventDefault()
|
||||
const p = localXY(e)
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
Object.assign(vp, zoomAt(vp, p.x, p.y, vp.scale * Math.exp(-e.deltaY * 0.0018)))
|
||||
const now = Date.now()
|
||||
if (now - lastWheelAt > 200) wheelAccum = 0
|
||||
lastWheelAt = now
|
||||
const delta = e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 400 : 1)
|
||||
let steps
|
||||
if (Math.abs(delta) >= 80) {
|
||||
steps = Math.sign(delta) * Math.max(1, Math.round(Math.abs(delta) / 120))
|
||||
wheelAccum = 0
|
||||
} else {
|
||||
wheelAccum += delta
|
||||
steps = Math.trunc(wheelAccum / 80)
|
||||
wheelAccum -= steps * 80
|
||||
}
|
||||
let scale = vp.scale
|
||||
for (let i = 0; i < Math.min(Math.abs(steps), 20); i++) scale = nextZoom(scale, -steps)
|
||||
if (scale !== vp.scale) Object.assign(vp, zoomAt(vp, p.x, p.y, scale))
|
||||
else return
|
||||
} else {
|
||||
wheelAccum = 0
|
||||
vp.x -= e.deltaX
|
||||
vp.y -= e.deltaY
|
||||
}
|
||||
@@ -390,28 +618,86 @@ function setSelection(s) {
|
||||
selection.value = s
|
||||
emit('selection', [...s])
|
||||
}
|
||||
// 父级复制/成组操作后同步舞台内部选择集
|
||||
function selectIds(ids) {
|
||||
setSelection(new Set(ids))
|
||||
}
|
||||
function bumpNode(id) {
|
||||
const n = nodeById.value.get(id)
|
||||
if (n) bumpZ(n)
|
||||
}
|
||||
|
||||
// ---- 对外 API ----
|
||||
function setViewport(v) {
|
||||
Object.assign(vp, v)
|
||||
notifyViewport()
|
||||
}
|
||||
function zoomBy(factor) {
|
||||
function zoomStep(direction) {
|
||||
const r = stageEl.value?.getBoundingClientRect()
|
||||
const cx = r ? r.width / 2 : 0
|
||||
const cy = r ? r.height / 2 : 0
|
||||
Object.assign(vp, zoomAt(vp, cx, cy, vp.scale * factor))
|
||||
const scale = nextZoom(vp.scale, direction)
|
||||
if (scale === vp.scale) return
|
||||
Object.assign(vp, zoomAt(vp, cx, cy, scale))
|
||||
notifyViewport()
|
||||
}
|
||||
// 直接缩放到指定比例(围绕视口中心)
|
||||
function zoomTo(scale) {
|
||||
const r = stageEl.value?.getBoundingClientRect()
|
||||
const cx = r ? r.width / 2 : 0
|
||||
const cy = r ? r.height / 2 : 0
|
||||
scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale))
|
||||
if (scale === vp.scale) return
|
||||
Object.assign(vp, zoomAt(vp, cx, cy, scale))
|
||||
notifyViewport()
|
||||
}
|
||||
function centerOn(x, y) {
|
||||
const r = stageEl.value?.getBoundingClientRect()
|
||||
vp.x = (r ? r.width / 2 : 400) - x * vp.scale
|
||||
vp.y = (r ? r.height / 2 : 300) - y * vp.scale
|
||||
const s = vp.scale * BASE_SCALE
|
||||
vp.x = (r ? r.width / 2 : 400) - x * s
|
||||
vp.y = (r ? r.height / 2 : 300) - y * s
|
||||
notifyViewport()
|
||||
}
|
||||
function clearSelection() {
|
||||
if (selection.value.size) setSelection(new Set())
|
||||
}
|
||||
// 双击空白:交给父级在落点建笔记
|
||||
function onDblClick(e) {
|
||||
if (e.target.closest?.('[data-node-id]') || e.target.closest?.('.cv-edge-hit')) return
|
||||
const p = localXY(e)
|
||||
const w = screenToWorld(p.x, p.y, vp)
|
||||
emit('blank-dblclick', { x: w.x, y: w.y })
|
||||
}
|
||||
// 适应内容:把所有节点缩放进视口(留 80px 边距,最大放到 1.2x)
|
||||
function fitToContent() {
|
||||
const r = stageEl.value?.getBoundingClientRect()
|
||||
const sw = r ? r.width : 800
|
||||
// 底部悬浮输入框会遮住舞台下缘:把它的高度从可用区域里扣掉,内容避开输入框居中
|
||||
const dockEl = document.querySelector('.cv-dock')
|
||||
const dockH = dockEl ? dockEl.getBoundingClientRect().height + 32 : 0
|
||||
const sh = Math.max(120, (r ? r.height : 600) - dockH)
|
||||
const nodes = props.nodes
|
||||
if (!nodes.length) {
|
||||
setViewport({ x: 0, y: 0, scale: 1 })
|
||||
return
|
||||
}
|
||||
let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity
|
||||
for (const n of nodes) {
|
||||
x1 = Math.min(x1, n.x); y1 = Math.min(y1, n.y)
|
||||
x2 = Math.max(x2, n.x + n.w); y2 = Math.max(y2, n.y + n.h)
|
||||
}
|
||||
const pad = 80
|
||||
const bw = Math.max(1, x2 - x1 + pad * 2)
|
||||
const bh = Math.max(1, y2 - y1 + pad * 2)
|
||||
const s = Math.min(sw / bw, sh / bh, 1.2) // 期望的有效缩放
|
||||
// 对齐 10% 档位:向下取整保证内容完整可见
|
||||
const target = Math.min(MAX_SCALE, Math.max(MIN_SCALE, Math.floor(s / BASE_SCALE * 10 + 1e-6) / 10))
|
||||
const eff = target * BASE_SCALE
|
||||
vp.scale = target
|
||||
vp.x = sw / 2 - (x1 - pad + bw / 2) * eff
|
||||
vp.y = sh / 2 - (y1 - pad + bh / 2) * eff
|
||||
notifyViewport()
|
||||
}
|
||||
function selectedIds() {
|
||||
return [...selection.value]
|
||||
}
|
||||
@@ -429,8 +715,8 @@ function screenRectToWorld(r) {
|
||||
}
|
||||
}
|
||||
defineExpose({
|
||||
setViewport, zoomBy, centerOn, clearSelection, selectedIds, selectAll, syncZTop, vp,
|
||||
cancelLink, consumeSelectedEdges, hasSelectedEdges,
|
||||
setViewport, zoomStep, zoomTo, centerOn, clearSelection, selectedIds, selectAll, syncZTop, vp,
|
||||
cancelLink, consumeSelectedEdges, hasSelectedEdges, fitToContent, selectIds, bumpNode,
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -444,6 +730,7 @@ defineExpose({
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@pointercancel="onPointerCancel"
|
||||
@dblclick="onDblClick"
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<div class="cv-world" :style="worldStyle">
|
||||
@@ -459,14 +746,22 @@ defineExpose({
|
||||
:class="{ 'cv-edge--hot': selection.has(p.to), 'cv-edge--sel': edgeSel.has(p.id) }"
|
||||
:d="p.d"
|
||||
/>
|
||||
<circle
|
||||
v-if="edgeSel.has(p.id)"
|
||||
class="cv-edge-bend"
|
||||
:cx="p.handle.x"
|
||||
:cy="p.handle.y"
|
||||
r="8"
|
||||
/>
|
||||
</g>
|
||||
<path v-if="linkLine" class="cv-linkline" :d="linkPath" />
|
||||
</svg>
|
||||
<CanvasNodeView
|
||||
v-for="n in visibleNodes"
|
||||
:key="n.id"
|
||||
:node="n"
|
||||
:selected="selection.has(n.id)"
|
||||
:link-source-side="linkSource?.id === n.id ? linkSource.side : ''"
|
||||
@port-click="(side) => onPortClick(n.id, side)"
|
||||
@imgload="(s) => emit('img-size', { id: n.id, ...s })"
|
||||
@note-commit="(text) => emit('note-commit', { id: n.id, text })"
|
||||
@node-action="(a) => emit('node-action', { ...a, id: n.id })"
|
||||
@@ -474,7 +769,6 @@ defineExpose({
|
||||
</div>
|
||||
|
||||
<div v-if="marquee" class="cv-marquee" :style="marquee" />
|
||||
<div class="cv-hint">拖拽空白平移 · Ctrl+滚轮缩放 · 拖节点右侧圆点连线 · 点击连线可选中删除 · 双击笔记编辑</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -484,8 +778,12 @@ defineExpose({
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
background-color: #0e1015;
|
||||
background-image: radial-gradient(circle, rgb(255 255 255 / 0.07) 1px, transparent 1px);
|
||||
background-color: var(--app-bg);
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
color-mix(in srgb, var(--fg) 16%, transparent) 1.4px,
|
||||
transparent 1.4px
|
||||
);
|
||||
}
|
||||
.cv-stage.is-panning {
|
||||
cursor: grab;
|
||||
@@ -507,49 +805,47 @@ defineExpose({
|
||||
height: 1px;
|
||||
overflow: visible;
|
||||
}
|
||||
/* 常规连线:蓝色虚线;选中时改紫色实线加粗 */
|
||||
.cv-edge {
|
||||
fill: none;
|
||||
stroke: rgb(140 146 165 / 0.5);
|
||||
stroke-width: 1.5;
|
||||
stroke: rgb(95 108 248 / 0.85);
|
||||
stroke-width: 2.4;
|
||||
stroke-dasharray: 10 6;
|
||||
filter: drop-shadow(0 0 5px rgb(124 92 252 / 0.3));
|
||||
}
|
||||
.cv-edge--hot {
|
||||
stroke: #7c6cff;
|
||||
stroke-width: 2;
|
||||
stroke: var(--m-orange);
|
||||
stroke-width: 2.8;
|
||||
filter: drop-shadow(0 0 7px var(--accent-glow));
|
||||
}
|
||||
.cv-edge--sel {
|
||||
stroke: #ff7b8f;
|
||||
stroke-width: 2.5;
|
||||
filter: drop-shadow(0 0 3px rgb(255 123 143 / 0.6));
|
||||
stroke: var(--m-orange);
|
||||
stroke-width: 3.5;
|
||||
stroke-dasharray: none;
|
||||
filter: drop-shadow(0 0 8px var(--accent-glow));
|
||||
}
|
||||
.cv-edge-hit {
|
||||
fill: none;
|
||||
stroke: transparent;
|
||||
stroke-width: 16;
|
||||
stroke-width: 18;
|
||||
pointer-events: stroke;
|
||||
cursor: pointer;
|
||||
cursor: grab;
|
||||
}
|
||||
.cv-linkline {
|
||||
fill: none;
|
||||
stroke: #a99dff;
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 7 6;
|
||||
.cv-edge-hit:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.cv-edge-bend {
|
||||
fill: var(--surface-solid);
|
||||
stroke: var(--m-orange);
|
||||
stroke-width: 3;
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(0 0 6px var(--accent-glow));
|
||||
}
|
||||
.cv-marquee {
|
||||
position: absolute;
|
||||
border: 1px solid #7c6cff;
|
||||
background: rgb(124 108 255 / 0.12);
|
||||
border: 2px dashed rgb(124 92 252 / 0.5);
|
||||
background: rgb(124 92 252 / 0.08);
|
||||
pointer-events: none;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.cv-hint {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 10px;
|
||||
transform: translateX(-50%);
|
||||
font-size: 11px;
|
||||
color: rgb(255 255 255 / 0.28);
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,7 +11,7 @@ const route = useRoute()
|
||||
const tabs = [
|
||||
{ label: '概览', to: '/admin/overview', icon: 'overview' },
|
||||
{ label: '模型管理', to: '/admin/models', icon: 'models' },
|
||||
{ label: '账号管理', to: '/admin/accounts', icon: 'plug' },
|
||||
{ label: '渠道管理', to: '/admin/channels', icon: 'plug' },
|
||||
{ label: '用户管理', to: '/admin/users', icon: 'accounts' },
|
||||
{ label: '并发分组', to: '/admin/concurrency', icon: 'shield' },
|
||||
{ label: '违禁词管理', icon: 'ban', children: [
|
||||
@@ -47,15 +47,15 @@ watch(() => route.path, () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="theme-x h-screen flex bg-[var(--app-bg)] text-[color:var(--fg-2)] selection:bg-violet-400/30 overflow-hidden">
|
||||
<div class="theme-x h-screen flex bg-[var(--app-bg)] text-[color:var(--fg-2)] selection:bg-orange-400/30 overflow-hidden">
|
||||
<!-- ===== Sidebar ===== -->
|
||||
<aside class="w-60 shrink-0 border-r border-[color:var(--hairline)] bg-[var(--surface)] backdrop-blur-md flex flex-col">
|
||||
<router-link to="/" class="h-16 flex items-center gap-2.5 px-5 border-b border-[color:var(--hairline)] group">
|
||||
<img v-if="site.logo" :src="site.logo" :alt="site.title" class="w-8 h-8 rounded-[10px] object-contain shadow-lg shadow-violet-500/20 ring-1 ring-white/10" />
|
||||
<aside class="w-60 shrink-0 border-r border-r-[color:var(--ink-soft)] bg-[var(--surface)] backdrop-blur-xl flex flex-col">
|
||||
<router-link to="/" class="h-16 flex items-center gap-2.5 px-5 border-b border-b-[color:var(--ink-soft)] group">
|
||||
<img v-if="site.logo" :src="site.logo" :alt="site.title" class="w-8 h-8 rounded-[10px] object-contain ring-1 ring-[color:var(--ink)]" />
|
||||
<Logo v-else :size="32" />
|
||||
<div class="leading-tight min-w-0">
|
||||
<div class="text-sm font-semibold truncate tracking-tight text-[color:var(--fg)]">{{ site.title }}</div>
|
||||
<div class="text-[11px] text-[color:var(--fg-3)] truncate">Admin</div>
|
||||
<div class="text-sm font-extrabold truncate tracking-tight text-[color:var(--fg)]">{{ site.title }}</div>
|
||||
<div class="admin-tag text-[11px] font-bold uppercase tracking-widest truncate">Admin</div>
|
||||
</div>
|
||||
</router-link>
|
||||
|
||||
@@ -64,16 +64,16 @@ watch(() => route.path, () => {
|
||||
<router-link v-if="!t.children" :to="t.to"
|
||||
class="admin-link group"
|
||||
active-class="active">
|
||||
<span class="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-5 rounded-r-full opacity-0 transition-opacity"
|
||||
style="background: linear-gradient(180deg, #f0abfc, #a78bfa)"></span>
|
||||
<span class="absolute left-1 top-1/2 -translate-y-1/2 w-[6px] h-6 rounded-sm opacity-0 transition-opacity"
|
||||
style="background: var(--m-orange)"></span>
|
||||
<Icon :name="t.icon" class="w-4 h-4 shrink-0 opacity-70 group-hover:opacity-100 transition-opacity" />
|
||||
<span class="text-sm">{{ t.label }}</span>
|
||||
</router-link>
|
||||
<div v-else>
|
||||
<button type="button" @click="toggleGroup(t.label)"
|
||||
class="admin-link group w-full text-left" :class="groupActive(t) && 'active'">
|
||||
<span class="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-5 rounded-r-full opacity-0 transition-opacity"
|
||||
style="background: linear-gradient(180deg, #f0abfc, #a78bfa)"></span>
|
||||
<span class="absolute left-1 top-1/2 -translate-y-1/2 w-[6px] h-6 rounded-sm opacity-0 transition-opacity"
|
||||
style="background: var(--m-orange)"></span>
|
||||
<Icon :name="t.icon" class="w-4 h-4 shrink-0 opacity-70 group-hover:opacity-100 transition-opacity" />
|
||||
<span class="text-sm">{{ t.label }}</span>
|
||||
<svg class="w-3 h-3 ml-auto transition-transform" :class="openGroups.has(t.label) && 'rotate-90'"
|
||||
@@ -89,7 +89,7 @@ watch(() => route.path, () => {
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<div class="p-3 border-t border-[color:var(--hairline)] space-y-1">
|
||||
<div class="p-3 border-t border-t-[color:var(--ink-soft)] space-y-1">
|
||||
<button type="button" @click="toggleTheme"
|
||||
class="w-full flex items-center gap-2.5 rounded-lg px-3 py-2 text-xs text-[color:var(--fg-2)] hover:bg-[var(--hover)] hover:text-[color:var(--fg)] transition-colors">
|
||||
<svg v-if="isDark" class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
|
||||
@@ -106,18 +106,19 @@ watch(() => route.path, () => {
|
||||
|
||||
<!-- ===== Main ===== -->
|
||||
<div class="flex-1 min-w-0 flex flex-col relative">
|
||||
<!-- soft background mesh, mirrors the public shell -->
|
||||
<!-- aurora backdrop: drifting gradient orbs + faint grain -->
|
||||
<div aria-hidden="true" class="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div class="absolute -top-32 left-1/3 w-[40rem] h-[40rem] rounded-full opacity-[0.12]"
|
||||
style="background: radial-gradient(circle, #a855f7, transparent 60%); filter: blur(110px)"></div>
|
||||
<div class="absolute top-1/2 -right-40 w-[36rem] h-[36rem] rounded-full opacity-[0.10]"
|
||||
style="background: radial-gradient(circle, #06b6d4, transparent 60%); filter: blur(110px)"></div>
|
||||
<div class="absolute bottom-0 left-0 w-[32rem] h-[32rem] rounded-full opacity-[0.08]"
|
||||
style="background: radial-gradient(circle, #f43f5e, transparent 60%); filter: blur(110px)"></div>
|
||||
<div class="memphis-dots absolute inset-0 opacity-40"></div>
|
||||
<div class="absolute -top-40 left-1/4 w-[40rem] h-[40rem] rounded-full opacity-[0.45]"
|
||||
style="background: radial-gradient(circle, rgb(139 92 246 / 0.3), transparent 60%); filter: blur(110px)"></div>
|
||||
<div class="absolute top-1/2 -right-48 w-[36rem] h-[36rem] rounded-full opacity-[0.4]"
|
||||
style="background: radial-gradient(circle, rgb(34 211 238 / 0.24), transparent 60%); filter: blur(110px)"></div>
|
||||
<div class="absolute bottom-0 -left-24 w-[32rem] h-[32rem] rounded-full opacity-[0.35]"
|
||||
style="background: radial-gradient(circle, rgb(232 121 249 / 0.2), transparent 60%); filter: blur(110px)"></div>
|
||||
</div>
|
||||
|
||||
<header class="relative z-10 h-14 shrink-0 border-b border-[color:var(--hairline)] bg-[var(--app-bg)]/70 backdrop-blur-md flex items-center px-8">
|
||||
<div class="text-[10px] uppercase tracking-[0.25em] text-[color:var(--fg-3)] font-medium mr-3">Admin</div>
|
||||
<header class="relative z-10 h-14 shrink-0 border-b border-b-[color:var(--ink-soft)] bg-[var(--app-bg)]/60 backdrop-blur-xl flex items-center px-8">
|
||||
<div class="admin-badge text-[10px] uppercase tracking-[0.25em] font-extrabold mr-3 px-2.5 py-1 rounded-lg text-white select-none">Admin</div>
|
||||
<div class="text-[color:var(--fg-faint)] mr-3">/</div>
|
||||
<h1 class="text-sm font-semibold tracking-tight text-[color:var(--fg)]">{{ currentLabel }}</h1>
|
||||
</header>
|
||||
@@ -136,6 +137,16 @@ watch(() => route.path, () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-tag {
|
||||
background: linear-gradient(120deg, #7c5cfc, #22d3ee);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.admin-badge {
|
||||
background: linear-gradient(120deg, #7c5cfc, #5f6cf8 60%, #22d3ee 140%);
|
||||
box-shadow: 0 4px 14px var(--accent-glow);
|
||||
}
|
||||
.admin-link {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -143,25 +154,32 @@ watch(() => route.path, () => {
|
||||
gap: 0.75rem;
|
||||
padding: 0.55rem 0.875rem;
|
||||
border-radius: 0.625rem;
|
||||
border: 1px solid transparent;
|
||||
color: var(--fg-2);
|
||||
font-weight: 500;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
font-weight: 600;
|
||||
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.admin-link:hover { background: var(--hover); color: var(--fg); }
|
||||
.admin-link.active { color: var(--fg); background: var(--hover); }
|
||||
.admin-link.active > span:first-child { opacity: 1; }
|
||||
.admin-link:hover { background: var(--hover); color: var(--fg); border-color: rgb(124 92 252 / 0.3); }
|
||||
.admin-link.active {
|
||||
color: #fff;
|
||||
background: linear-gradient(120deg, #7c5cfc, #5f6cf8);
|
||||
border-color: transparent;
|
||||
box-shadow: 0 4px 14px var(--accent-glow);
|
||||
}
|
||||
.admin-link.active > span:first-child { opacity: 0; }
|
||||
|
||||
.admin-sublink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.45rem 0.875rem 0.45rem 2.6rem;
|
||||
border-radius: 0.625rem;
|
||||
border: 1px solid transparent;
|
||||
color: var(--fg-2);
|
||||
font-weight: 500;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
font-weight: 600;
|
||||
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.admin-sublink:hover { background: var(--hover); color: var(--fg); }
|
||||
.admin-sublink.active { color: var(--fg); background: var(--hover); }
|
||||
.admin-sublink:hover { background: var(--hover); color: var(--fg); border-color: rgb(124 92 252 / 0.3); }
|
||||
.admin-sublink.active { color: var(--m-orange); background: var(--hover); border-color: rgb(124 92 252 / 0.35); font-weight: 700; }
|
||||
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease, transform 0.15s ease; }
|
||||
.fade-enter-from { opacity: 0; transform: translateY(4px); }
|
||||
|
||||
@@ -60,6 +60,9 @@ const currentLabel = computed(() => {
|
||||
// shell plus a route-label header.
|
||||
const isHome = computed(() => route.path === '/')
|
||||
|
||||
// CanvasView's root is a <Teleport to="body"> — keep it outside the
|
||||
// out-in transition so leaving the canvas cannot stall the next page.
|
||||
|
||||
async function onLogout() {
|
||||
menuOpen.value = false
|
||||
await logout()
|
||||
@@ -67,9 +70,9 @@ async function onLogout() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="theme-x min-h-screen bg-[var(--app-bg)] text-[color:var(--fg-2)] selection:bg-violet-400/30">
|
||||
<div class="theme-x min-h-screen bg-[var(--app-bg)] text-[color:var(--fg-2)] selection:bg-orange-400/30">
|
||||
<!-- ===== Top bar ===== -->
|
||||
<header class="fixed top-0 inset-x-0 z-40 border-b border-transparent backdrop-blur-xl top-bar">
|
||||
<header class="fixed top-0 inset-x-0 z-40 backdrop-blur-xl top-bar">
|
||||
<div class="mx-auto max-w-[1400px] px-4 md:px-8 h-[58px] flex items-center gap-3">
|
||||
<!-- brand -->
|
||||
<router-link to="/" class="flex items-center gap-2.5 shrink-0 transition-transform hover:scale-[1.03]">
|
||||
@@ -142,7 +145,7 @@ async function onLogout() {
|
||||
</div>
|
||||
|
||||
<!-- mobile panel -->
|
||||
<div v-if="mobileOpen" class="md:hidden border-t border-[color:var(--hairline)] px-3 py-3 bg-[var(--menu-bg)]">
|
||||
<div v-if="mobileOpen" class="md:hidden border-t border-[color:var(--ink-soft)] px-3 py-3 bg-[var(--menu-bg)]">
|
||||
<router-link v-for="n in nav" :key="n.to" :to="n.to"
|
||||
:exact-active-class="n.to === '/' ? 'm-on' : ''"
|
||||
:active-class="n.to === '/' ? '' : 'm-on'"
|
||||
@@ -164,14 +167,15 @@ async function onLogout() {
|
||||
|
||||
<!-- ===== Main column ===== -->
|
||||
<div class="relative">
|
||||
<!-- soft background mesh -->
|
||||
<!-- aurora backdrop: drifting gradient orbs + faint grain -->
|
||||
<div aria-hidden="true" class="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div class="absolute -top-32 left-1/3 w-[40rem] h-[40rem] rounded-full opacity-[0.16]"
|
||||
style="background: radial-gradient(circle, #a855f7, transparent 60%); filter: blur(100px)"></div>
|
||||
<div class="absolute top-1/2 -right-40 w-[36rem] h-[36rem] rounded-full opacity-[0.14]"
|
||||
style="background: radial-gradient(circle, #06b6d4, transparent 60%); filter: blur(100px)"></div>
|
||||
<div class="absolute bottom-0 left-0 w-[32rem] h-[32rem] rounded-full opacity-[0.10]"
|
||||
style="background: radial-gradient(circle, #f43f5e, transparent 60%); filter: blur(100px)"></div>
|
||||
<div class="memphis-dots absolute inset-0 opacity-50"></div>
|
||||
<div class="absolute -top-40 left-1/4 w-[42rem] h-[42rem] rounded-full opacity-[0.5]"
|
||||
style="background: radial-gradient(circle, rgb(139 92 246 / 0.35), transparent 60%); filter: blur(100px)"></div>
|
||||
<div class="absolute top-1/3 -right-48 w-[38rem] h-[38rem] rounded-full opacity-[0.45]"
|
||||
style="background: radial-gradient(circle, rgb(34 211 238 / 0.3), transparent 60%); filter: blur(100px)"></div>
|
||||
<div class="absolute bottom-0 -left-24 w-[34rem] h-[34rem] rounded-full opacity-[0.4]"
|
||||
style="background: radial-gradient(circle, rgb(232 121 249 / 0.28), transparent 60%); filter: blur(100px)"></div>
|
||||
<div class="anime-stars"></div>
|
||||
</div>
|
||||
|
||||
@@ -183,8 +187,9 @@ async function onLogout() {
|
||||
<main :class="['relative z-10 mx-auto max-w-[1400px] pb-24',
|
||||
isHome ? 'pt-[58px] px-0' : 'px-4 md:px-8 pt-2',
|
||||
{ 'public-dark': isDark }]">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<router-view v-slot="{ Component, route: viewRoute }">
|
||||
<component :is="Component" v-if="viewRoute.path === '/canvas'" />
|
||||
<transition v-else name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
@@ -195,42 +200,35 @@ async function onLogout() {
|
||||
|
||||
<style scoped>
|
||||
.top-bar {
|
||||
background: color-mix(in srgb, var(--app-bg) 72%, transparent);
|
||||
background: color-mix(in srgb, var(--app-bg) 62%, transparent);
|
||||
border-bottom: 1px solid var(--ink-soft);
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
.top-bar:hover { border-bottom-color: var(--hairline); }
|
||||
|
||||
.top-link {
|
||||
padding: 0.45rem 0.8rem;
|
||||
border-radius: 999px;
|
||||
border-radius: 0.6rem;
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
color: var(--fg-2);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, background 0.15s ease;
|
||||
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.top-link:hover { color: var(--fg); background: var(--hover); }
|
||||
.top-link:hover { color: var(--fg); background: var(--hover); border-color: rgb(124 92 252 / 0.3); }
|
||||
.top-link.on {
|
||||
color: #a21caf;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, rgb(244 114 182 / 0.16), rgb(129 140 248 / 0.16));
|
||||
box-shadow: inset 0 0 0 1px rgb(217 70 239 / 0.3);
|
||||
}
|
||||
html.dark .top-link.on {
|
||||
color: #f5d0fe;
|
||||
background: linear-gradient(135deg, rgb(244 114 182 / 0.2), rgb(129 140 248 / 0.2));
|
||||
box-shadow: inset 0 0 0 1px rgb(217 70 239 / 0.35), 0 0 16px -6px rgb(217 70 239 / 0.55);
|
||||
background: linear-gradient(120deg, #7c5cfc, #5f6cf8);
|
||||
border-color: transparent;
|
||||
box-shadow: 0 4px 14px var(--accent-glow);
|
||||
}
|
||||
|
||||
.brand-spark {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
background: linear-gradient(135deg, #f472b6, #38bdf8);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
color: var(--m-orange);
|
||||
animation: brandSpark 3.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes brandSpark {
|
||||
@@ -242,34 +240,39 @@ html.dark .top-link.on {
|
||||
.m-link {
|
||||
display: block;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: 0.85rem;
|
||||
border-radius: 0.6rem;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--fg-2);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, background 0.15s ease;
|
||||
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.m-link:hover { color: var(--fg); background: var(--hover); }
|
||||
.m-link:hover { color: var(--fg); background: var(--hover); border-color: rgb(124 92 252 / 0.3); }
|
||||
.m-link.m-on {
|
||||
color: #a21caf;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, rgb(244 114 182 / 0.16), rgb(129 140 248 / 0.16));
|
||||
background: linear-gradient(120deg, #7c5cfc, #5f6cf8);
|
||||
border-color: transparent;
|
||||
box-shadow: 0 4px 14px var(--accent-glow);
|
||||
}
|
||||
html.dark .m-link.m-on { color: #f5d0fe; background: linear-gradient(135deg, rgb(244 114 182 / 0.2), rgb(129 140 248 / 0.2)); }
|
||||
|
||||
.round-btn {
|
||||
width: 2.125rem;
|
||||
height: 2.125rem;
|
||||
border-radius: 999px;
|
||||
border-radius: 0.6rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--fg-3);
|
||||
background: var(--surface);
|
||||
box-shadow: inset 0 0 0 1px var(--hairline);
|
||||
transition: color 0.15s ease, background 0.15s ease;
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--ink-soft);
|
||||
transition: color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.round-btn:hover { color: var(--fg); background: var(--hover); }
|
||||
.round-btn:hover { color: var(--fg); background: var(--hover); border-color: rgb(124 92 252 / 0.4); box-shadow: 0 4px 14px rgb(124 92 252 / 0.16); transform: translateY(-1px); }
|
||||
.round-btn:active { transform: scale(0.95) translateY(1px); box-shadow: none; }
|
||||
|
||||
/* mobile-only: .round-btn's display:grid outranks Tailwind's md:hidden, so
|
||||
the breakpoint lives here instead */
|
||||
@@ -280,17 +283,18 @@ html.dark .m-link.m-on { color: #f5d0fe; background: linear-gradient(135deg, rgb
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 1.1rem;
|
||||
border-radius: 999px;
|
||||
border-radius: 0.65rem;
|
||||
font-size: 13.5px;
|
||||
font-weight: 700;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #f472b6, #a855f7 55%, #38bdf8);
|
||||
border: none;
|
||||
background: linear-gradient(120deg, #7c5cfc, #5f6cf8 55%, #22d3ee 140%);
|
||||
border: 1px solid rgb(255 255 255 / 0.16);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 12px 26px -14px rgb(168 85 247 / 0.75);
|
||||
box-shadow: 0 6px 18px var(--accent-glow);
|
||||
transition: transform 0.15s ease, filter 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.solid-btn:hover { filter: brightness(1.06); transform: translateY(-1px); box-shadow: 0 16px 30px -14px rgb(168 85 247 / 0.85); }
|
||||
.solid-btn:hover { filter: brightness(1.07); transform: translateY(-1px); box-shadow: 0 10px 26px var(--accent-glow); }
|
||||
.solid-btn:active { transform: scale(0.97) translateY(1px); box-shadow: 0 3px 10px var(--accent-glow); }
|
||||
|
||||
.name-btn {
|
||||
display: inline-flex;
|
||||
@@ -299,19 +303,22 @@ html.dark .m-link.m-on { color: #f5d0fe; background: linear-gradient(135deg, rgb
|
||||
max-width: 10rem;
|
||||
height: 2.125rem;
|
||||
padding: 0 0.7rem 0 0.85rem;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
border-radius: 0.6rem;
|
||||
border: 1px solid var(--ink-soft);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
color: var(--fg);
|
||||
background: var(--surface);
|
||||
box-shadow: inset 0 0 0 1px var(--hairline);
|
||||
transition: background 0.15s ease, box-shadow 0.15s ease;
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
backdrop-filter: blur(10px);
|
||||
transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.name-btn:hover {
|
||||
background: linear-gradient(135deg, rgb(244 114 182 / 0.14), rgb(129 140 248 / 0.14));
|
||||
box-shadow: inset 0 0 0 1px rgb(217 70 239 / 0.3);
|
||||
background: var(--hover);
|
||||
border-color: rgb(124 92 252 / 0.4);
|
||||
box-shadow: 0 4px 14px rgb(124 92 252 / 0.16);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.name-txt {
|
||||
overflow: hidden;
|
||||
@@ -325,16 +332,20 @@ html.dark .m-link.m-on { color: #f5d0fe; background: linear-gradient(135deg, rgb
|
||||
top: 2.75rem;
|
||||
width: 11.5rem;
|
||||
padding: 0.4rem;
|
||||
border-radius: 0.9rem;
|
||||
border-radius: 0.85rem;
|
||||
background: var(--menu-bg);
|
||||
box-shadow: inset 0 0 0 1px var(--hairline), 0 20px 44px rgb(0 0 0 / 0.24);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
backdrop-filter: blur(18px);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--glass-shadow-lg);
|
||||
z-index: 50;
|
||||
}
|
||||
.menu-item {
|
||||
display: block;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border-radius: 0.55rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--fg-2);
|
||||
background: transparent;
|
||||
border: none;
|
||||
|
||||
@@ -24,7 +24,7 @@ const DocsView = () => import('./views/DocsView.vue')
|
||||
const AboutView = () => import('./views/AboutView.vue')
|
||||
const OverviewView = () => import('./views/OverviewView.vue')
|
||||
const ModelsView = () => import('./views/ModelsView.vue')
|
||||
const AccountsView = () => import('./views/AccountsView.vue')
|
||||
const ChannelsView = () => import('./views/ChannelsView.vue')
|
||||
const UsersView = () => import('./views/UsersView.vue')
|
||||
const ConcurrencyView = () => import('./views/ConcurrencyView.vue')
|
||||
const BannedWordsView = () => import('./views/BannedWordsView.vue')
|
||||
@@ -61,7 +61,7 @@ const routes = [
|
||||
{ path: '', redirect: '/admin/overview' },
|
||||
{ path: 'overview', component: OverviewView, meta: { label: '概览' } },
|
||||
{ path: 'models', component: ModelsView, meta: { label: '模型管理' } },
|
||||
{ path: 'accounts', component: AccountsView, meta: { label: '账号管理' } },
|
||||
{ path: 'channels', component: ChannelsView, meta: { label: '渠道管理' } },
|
||||
{ path: 'users', component: UsersView, meta: { label: '用户管理' } },
|
||||
{ path: 'concurrency', component: ConcurrencyView, meta: { label: '并发分组' } },
|
||||
{ path: 'banned-words', component: BannedWordsView, meta: { label: '违禁词列表' } },
|
||||
@@ -81,7 +81,7 @@ const routes = [
|
||||
{ path: '/overview', redirect: '/admin/overview' },
|
||||
{ path: '/models', redirect: '/admin/models' },
|
||||
{ path: '/video-models', redirect: '/admin/models' },
|
||||
{ path: '/accounts', redirect: '/admin/accounts' },
|
||||
{ path: '/accounts', redirect: '/admin/channels' },
|
||||
// NOTE: /images is the generated-artifact path (served by the backend), so the
|
||||
// old "/images → /admin/images" shortcut is gone. Use /files for that shortcut.
|
||||
{ path: '/files', redirect: '/admin/images' },
|
||||
|
||||
+348
-156
@@ -9,59 +9,95 @@ video {
|
||||
|
||||
:root {
|
||||
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
--tw-ring-color: rgb(15 23 42 / 0.08);
|
||||
--tw-ring-color: rgb(124 92 252 / 0.25);
|
||||
|
||||
/* ===== Theme palette — LIGHT (default) =====
|
||||
/* ===== Theme palette — LIGHT (default): frosted glass / aurora =====
|
||||
Drives the app chrome (rails/header) and the public marketing pages.
|
||||
The `.dark` class on <html> flips these to the dark values below.
|
||||
Text over generated images keeps a literal text-white (handled in markup). */
|
||||
--app-bg: rgb(250 250 251); /* page background */
|
||||
--fg: rgb(15 23 42); /* primary heading/text */
|
||||
--fg-2: rgb(15 23 42 / 0.62); /* body / secondary */
|
||||
--fg-3: rgb(15 23 42 / 0.45); /* muted */
|
||||
--fg-faint: rgb(15 23 42 / 0.3); /* labels / hints */
|
||||
--surface: rgb(255 255 255 / 0.7); /* raised card on page bg */
|
||||
--surface-2: rgb(15 23 42 / 0.035); /* subtle inset panel (counter cells) */
|
||||
--hairline: rgb(15 23 42 / 0.08); /* borders / rings */
|
||||
--hover: rgb(15 23 42 / 0.05); /* hover wash */
|
||||
--app-bg: #eef0f8; /* cool porcelain */
|
||||
--fg: #181524; /* primary heading/text */
|
||||
--fg-2: rgb(24 21 36 / 0.66); /* body / secondary */
|
||||
--fg-3: rgb(24 21 36 / 0.48); /* muted */
|
||||
--fg-faint: rgb(24 21 36 / 0.36); /* labels / hints */
|
||||
--surface: rgb(255 255 255 / 0.66); /* frosted card on page bg */
|
||||
--surface-solid: #ffffff; /* opaque version where blur is unwanted */
|
||||
--surface-2: rgb(24 21 36 / 0.045); /* subtle inset panel (counter cells) */
|
||||
--hairline: rgb(24 21 36 / 0.1); /* faint borders / separators */
|
||||
--hover: rgb(124 92 252 / 0.1); /* hover wash — violet tint */
|
||||
/* ink = the hairline edge color; shadow-ink = the soft shadow tint.
|
||||
Kept under their legacy names so existing markup keeps working — only the
|
||||
values changed from memphis-solid to glass-subtle. */
|
||||
--ink: rgb(24 21 36 / 0.14);
|
||||
--ink-soft: rgb(24 21 36 / 0.09);
|
||||
--shadow-ink: rgb(88 80 200 / 0.14);
|
||||
/* aurora accent crayons — primary violet + soft pastel companions */
|
||||
--m-orange: #7c5cfc;
|
||||
--m-orange-2: #22d3ee;
|
||||
--m-yellow: #ece7fe;
|
||||
--m-pink: #fad9f0;
|
||||
--m-blue: #d8eefb;
|
||||
--m-mint: #d9f4e8;
|
||||
/* solid CTA button that sits on the page bg — inverts per theme */
|
||||
--btn-solid-bg: rgb(15 23 42);
|
||||
--btn-solid-bg-h: rgb(30 41 59);
|
||||
--btn-solid-bg: #181524;
|
||||
--btn-solid-bg-h: #000000;
|
||||
--btn-solid-fg: #fff;
|
||||
--menu-bg: #ffffff; /* dropdown / popover panel */
|
||||
--menu-bg: rgb(255 255 255 / 0.82); /* dropdown / popover panel */
|
||||
/* shared glass treatments */
|
||||
--glass-border: rgb(255 255 255 / 0.55);
|
||||
--glass-shadow: 0 8px 28px rgb(80 74 190 / 0.1), 0 2px 8px rgb(24 21 36 / 0.05);
|
||||
--glass-shadow-lg: 0 16px 44px rgb(80 74 190 / 0.16), 0 4px 12px rgb(24 21 36 / 0.06);
|
||||
--accent-glow: rgb(124 92 252 / 0.35);
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--app-bg: #08090d;
|
||||
--fg: rgb(255 255 255 / 0.92);
|
||||
--fg-2: rgb(255 255 255 / 0.55);
|
||||
--fg-3: rgb(255 255 255 / 0.4);
|
||||
--app-bg: #08070f;
|
||||
--fg: rgb(255 255 255 / 0.93);
|
||||
--fg-2: rgb(255 255 255 / 0.6);
|
||||
--fg-3: rgb(255 255 255 / 0.42);
|
||||
--fg-faint: rgb(255 255 255 / 0.3);
|
||||
--surface: rgb(255 255 255 / 0.03);
|
||||
--surface-2: rgb(255 255 255 / 0.04);
|
||||
--hairline: rgb(255 255 255 / 0.06);
|
||||
--hover: rgb(255 255 255 / 0.06);
|
||||
--btn-solid-bg: #fff;
|
||||
--btn-solid-bg-h: rgb(255 255 255 / 0.9);
|
||||
--btn-solid-fg: rgb(2 6 23);
|
||||
--menu-bg: #15171f;
|
||||
--surface: rgb(255 255 255 / 0.05);
|
||||
--surface-solid: #14131f;
|
||||
--surface-2: rgb(255 255 255 / 0.05);
|
||||
--hairline: rgb(255 255 255 / 0.1);
|
||||
--hover: rgb(139 92 246 / 0.16);
|
||||
--ink: rgb(255 255 255 / 0.14);
|
||||
--ink-soft: rgb(255 255 255 / 0.09);
|
||||
--shadow-ink: rgb(0 0 0 / 0.4);
|
||||
--m-orange: #8b5cf6;
|
||||
--m-orange-2: #22d3ee;
|
||||
--m-yellow: #a78bfa;
|
||||
--m-pink: #f0a6d8;
|
||||
--m-blue: #6cd0f2;
|
||||
--m-mint: #5fd9ac;
|
||||
--btn-solid-bg: #8b5cf6;
|
||||
--btn-solid-bg-h: #7c3aed;
|
||||
--btn-solid-fg: #fff;
|
||||
--menu-bg: rgb(24 23 36 / 0.86);
|
||||
--glass-border: rgb(255 255 255 / 0.12);
|
||||
--glass-shadow: 0 8px 28px rgb(0 0 0 / 0.35), 0 2px 8px rgb(0 0 0 / 0.2);
|
||||
--glass-shadow-lg: 0 18px 50px rgb(0 0 0 / 0.5), 0 4px 14px rgb(0 0 0 / 0.3);
|
||||
--accent-glow: rgb(139 92 246 / 0.45);
|
||||
}
|
||||
|
||||
/* Smooth the flip so toggling doesn't hard-cut. */
|
||||
body, .theme-x { transition: background-color 0.25s ease, color 0.25s ease; }
|
||||
|
||||
/* Footer CTA band — soft tinted panel that flips with the theme. */
|
||||
/* Footer CTA band — aurora gradient panel with a glass edge. */
|
||||
.cta-band {
|
||||
position: relative;
|
||||
background:
|
||||
radial-gradient(at 30% 20%, rgb(168 85 247 / 0.16) 0%, transparent 50%),
|
||||
radial-gradient(at 80% 70%, rgb(244 114 182 / 0.14) 0%, transparent 55%),
|
||||
linear-gradient(180deg, rgb(255 255 255), rgb(246 244 251));
|
||||
radial-gradient(120% 160% at 0% 0%, rgb(124 92 252 / 0.2), transparent 55%),
|
||||
radial-gradient(120% 160% at 100% 100%, rgb(34 211 238 / 0.16), transparent 55%),
|
||||
var(--surface);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--glass-shadow-lg);
|
||||
}
|
||||
html.dark .cta-band {
|
||||
background:
|
||||
radial-gradient(at 30% 20%, rgb(168 85 247 / 0.35) 0%, transparent 50%),
|
||||
radial-gradient(at 80% 70%, rgb(244 114 182 / 0.3) 0%, transparent 55%),
|
||||
linear-gradient(180deg, #0f1117, #080a10);
|
||||
radial-gradient(120% 160% at 0% 0%, rgb(139 92 246 / 0.22), transparent 55%),
|
||||
radial-gradient(120% 160% at 100% 100%, rgb(34 211 238 / 0.14), transparent 55%),
|
||||
rgb(255 255 255 / 0.04);
|
||||
}
|
||||
|
||||
/* ===== Light-mode rescue for hardcoded-dark pages =====
|
||||
@@ -70,10 +106,10 @@ html.dark .cta-band {
|
||||
LIGHT mode, remap those neutral whites to the dark-on-light palette. Scoped
|
||||
to a `.theme-text` wrapper so the marketing homepage's over-image whites are
|
||||
never touched. Un-layered, so these win over Tailwind's @layer utilities. */
|
||||
html:not(.dark) .theme-text :is(.text-white, [class*="text-white/9"], [class*="text-white/8"]) { color: rgb(15 23 42 / 0.95); }
|
||||
html:not(.dark) .theme-text :is([class*="text-white/7"], [class*="text-white/6"]) { color: rgb(15 23 42 / 0.7); }
|
||||
html:not(.dark) .theme-text :is([class*="text-white/5"], [class*="text-white/4"]) { color: rgb(15 23 42 / 0.52); }
|
||||
html:not(.dark) .theme-text :is([class*="text-white/3"], [class*="text-white/2"], [class*="text-white/1"]) { color: rgb(15 23 42 / 0.4); }
|
||||
html:not(.dark) .theme-text :is(.text-white, [class*="text-white/9"], [class*="text-white/8"]) { color: rgb(24 21 36 / 0.95); }
|
||||
html:not(.dark) .theme-text :is([class*="text-white/7"], [class*="text-white/6"]) { color: rgb(24 21 36 / 0.7); }
|
||||
html:not(.dark) .theme-text :is([class*="text-white/5"], [class*="text-white/4"]) { color: rgb(24 21 36 / 0.52); }
|
||||
html:not(.dark) .theme-text :is([class*="text-white/3"], [class*="text-white/2"], [class*="text-white/1"]) { color: rgb(24 21 36 / 0.4); }
|
||||
html:not(.dark) .theme-text [class*="bg-white/"] { background-color: var(--surface-2); }
|
||||
html:not(.dark) .theme-text [class*="border-white/"] { border-color: var(--hairline); }
|
||||
html:not(.dark) .theme-text [class*="ring-white/"] { --tw-ring-color: var(--hairline); }
|
||||
@@ -88,9 +124,9 @@ html:not(.dark) .theme-text :is([class*="bg-slate-900"], [class*="bg-slate-800"]
|
||||
shell and washes out on white. In LIGHT mode, darken to the -600 tone. The
|
||||
matcher also catches the /80 opacity variants. Badges keep their pale tinted
|
||||
bg (bg-*-500/10) which reads fine on white. */
|
||||
html:not(.dark) .theme-text :is([class*="text-indigo-300"], [class*="text-indigo-200"]) { color: rgb(79 70 229); }
|
||||
html:not(.dark) .theme-text :is([class*="text-violet-300"], [class*="text-violet-200"]) { color: rgb(124 58 237); }
|
||||
html:not(.dark) .theme-text :is([class*="text-fuchsia-300"], [class*="text-fuchsia-200"]) { color: rgb(192 38 211); }
|
||||
html:not(.dark) .theme-text :is([class*="text-sky-300"], [class*="text-sky-200"]) { color: rgb(79 70 229); }
|
||||
html:not(.dark) .theme-text :is([class*="text-orange-300"], [class*="text-orange-200"]) { color: rgb(124 58 237); }
|
||||
html:not(.dark) .theme-text :is([class*="text-rose-300"], [class*="text-rose-200"]) { color: rgb(225 29 72); }
|
||||
html:not(.dark) .theme-text :is([class*="text-emerald-300"], [class*="text-emerald-200"]) { color: rgb(5 150 105); }
|
||||
html:not(.dark) .theme-text :is([class*="text-sky-300"], [class*="text-sky-200"]) { color: rgb(2 132 199); }
|
||||
html:not(.dark) .theme-text :is([class*="text-amber-300"], [class*="text-amber-200"]) { color: rgb(217 119 6); }
|
||||
@@ -107,35 +143,38 @@ html:not(.dark) .theme-text :is(.fp, .pg, .act, .filter-pill, .kind-btn, .preset
|
||||
html:not(.dark) .theme-text :is(.fp, .pg, .act, .filter-pill, .kind-btn, .preset-btn, .opt, .chip-x):hover {
|
||||
color: var(--fg); background: var(--hover);
|
||||
}
|
||||
html:not(.dark) .theme-text :is(.fp-on, .pg-on, .seg-on, .opt-on) { background: rgb(15 23 42); color: #fff; box-shadow: none; }
|
||||
/* Tag chips (TagInput) — near-white violet text is unreadable on white; keep
|
||||
the violet tint but switch the label to dark ink. */
|
||||
html:not(.dark) .theme-text .chip { color: rgb(15 23 42 / 0.9); }
|
||||
html:not(.dark) .theme-text .chip .chip-x { color: rgb(15 23 42 / 0.55); background: transparent; box-shadow: none; }
|
||||
html:not(.dark) .theme-text :is(.fp-on, .pg-on, .seg-on, .opt-on) {
|
||||
background: linear-gradient(120deg, #7c5cfc, #5f6cf8); color: #fff;
|
||||
box-shadow: 0 4px 16px var(--accent-glow);
|
||||
}
|
||||
/* Tag chips (TagInput) — near-white orange text is unreadable on white; keep
|
||||
the tint but switch the label to dark ink. */
|
||||
html:not(.dark) .theme-text .chip { color: rgb(24 21 36 / 0.9); }
|
||||
html:not(.dark) .theme-text .chip .chip-x { color: rgb(24 21 36 / 0.55); background: transparent; box-shadow: none; }
|
||||
html:not(.dark) .theme-text .chip .chip-x:hover { color: #fff; background: rgb(244 63 94 / 0.6); }
|
||||
/* ShowcaseView marks the selected filter/kind tab with an `.on` modifier on
|
||||
`.filter-pill`/`.kind-btn` (not the `-on` class above). Without this, the
|
||||
neutral `.filter-pill`/`.kind-btn` rescue paints selected == unselected and
|
||||
the highlight vanishes on a white page. Equal-or-higher specificity, later. */
|
||||
html:not(.dark) .theme-text :is(.filter-pill, .kind-btn).on { background: rgb(15 23 42); color: #fff; box-shadow: none; }
|
||||
html:not(.dark) .theme-text :is(.filter-pill, .kind-btn).on {
|
||||
background: linear-gradient(120deg, #7c5cfc, #5f6cf8); color: #fff;
|
||||
box-shadow: 0 4px 16px var(--accent-glow);
|
||||
}
|
||||
/* Selected COLORED filter pills — their scoped (dark) selected colors get
|
||||
overridden by the neutral `.fp` rescue above, so re-state a light-mode
|
||||
variant (tinted bg + dark-enough text + colored ring) with equal specificity
|
||||
placed later, so the selection reads on a white page. */
|
||||
html:not(.dark) .theme-text .fp-emerald { background: rgb(16 185 129 / 0.14); color: rgb(4 120 87); box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.45); }
|
||||
html:not(.dark) .theme-text .fp-rose { background: rgb(244 63 94 / 0.12); color: rgb(190 18 60); box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.45); }
|
||||
html:not(.dark) .theme-text .fp-fuchsia { background: rgb(217 70 239 / 0.12); color: rgb(162 28 175); box-shadow: inset 0 0 0 1px rgb(217 70 239 / 0.45); }
|
||||
html:not(.dark) .theme-text .fp-amber { background: rgb(245 158 11 / 0.16); color: rgb(180 83 9); box-shadow: inset 0 0 0 1px rgb(245 158 11 / 0.5); }
|
||||
html:not(.dark) .theme-text .fp-violet { background: rgb(139 92 246 / 0.14); color: rgb(109 40 217); box-shadow: inset 0 0 0 1px rgb(139 92 246 / 0.45); }
|
||||
html:not(.dark) .theme-text .fp-purple { background: rgb(168 85 247 / 0.14); color: rgb(107 33 168); box-shadow: inset 0 0 0 1px rgb(168 85 247 / 0.45); }
|
||||
html:not(.dark) .theme-text .fp-orange { background: rgb(139 92 246 / 0.14); color: rgb(109 40 217); box-shadow: inset 0 0 0 1px rgb(139 92 246 / 0.45); }
|
||||
html:not(.dark) .theme-text .fp-pink { background: rgb(244 114 182 / 0.14); color: rgb(190 24 93); box-shadow: inset 0 0 0 1px rgb(244 114 182 / 0.45); }
|
||||
html:not(.dark) .theme-text .fp-slate { background: rgb(100 116 139 / 0.14); color: rgb(51 65 85); box-shadow: inset 0 0 0 1px rgb(100 116 139 / 0.45); }
|
||||
html:not(.dark) .theme-text .fp-sky { background: rgb(14 165 233 / 0.14); color: rgb(3 105 161); box-shadow: inset 0 0 0 1px rgb(14 165 233 / 0.45); }
|
||||
html:not(.dark) .theme-text .fp-teal { background: rgb(20 184 166 / 0.14); color: rgb(15 118 110); box-shadow: inset 0 0 0 1px rgb(20 184 166 / 0.45); }
|
||||
|
||||
/* Custom checkbox for admin tables (<input type="checkbox" class="chk">).
|
||||
Replaces the plain accent-color box with a rounded, branded one + white check.
|
||||
Neutral border + transparent bg so it reads on both the dark admin shell and
|
||||
light mode; fills fuchsia when checked. */
|
||||
Rounded glass box + white check; fills with the violet gradient when on. */
|
||||
.chk {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
@@ -149,12 +188,14 @@ html:not(.dark) .theme-text .fp-teal { background: rgb(20 184 166 / 0.14); color
|
||||
display: inline-grid;
|
||||
place-content: center;
|
||||
vertical-align: middle;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.chk:hover:not(:checked) { border-color: rgb(217 70 239 / 0.7); }
|
||||
.chk { border-color: var(--ink); }
|
||||
.chk:hover:not(:checked) { border-color: var(--m-orange); }
|
||||
.chk:checked {
|
||||
background: rgb(217 70 239);
|
||||
border-color: rgb(217 70 239);
|
||||
background: linear-gradient(135deg, #7c5cfc, #22d3ee);
|
||||
border-color: transparent;
|
||||
box-shadow: 0 2px 10px var(--accent-glow);
|
||||
}
|
||||
.chk:checked::after {
|
||||
content: "";
|
||||
@@ -165,7 +206,7 @@ html:not(.dark) .theme-text .fp-teal { background: rgb(20 184 166 / 0.14); color
|
||||
transform: rotate(45deg);
|
||||
margin-top: -0.12rem;
|
||||
}
|
||||
.chk:focus-visible { outline: 2px solid rgb(217 70 239 / 0.45); outline-offset: 1px; }
|
||||
.chk:focus-visible { outline: 2px solid rgb(124 92 252 / 0.5); outline-offset: 1px; }
|
||||
/* Account identity tags (AccountsView: 子号 / 母号 / 普号 / 限额). The colors live
|
||||
here rather than as inline dark-palette utilities, which were invisible on the
|
||||
light theme: light values first, html.dark override after. */
|
||||
@@ -210,7 +251,7 @@ html:not(.dark) .theme-text .price-chip {
|
||||
background: var(--surface-2);
|
||||
box-shadow: inset 0 0 0 1px var(--hairline);
|
||||
}
|
||||
/* fp-white selected pill: on white bg the all-white fill vanishes — use a neutral dark variant. */
|
||||
/* fp-white selected pill: on white bg the all-white fill vanishes — use a neutral variant. */
|
||||
html:not(.dark) .theme-text .fp-white {
|
||||
background: var(--surface-2);
|
||||
color: var(--fg-2);
|
||||
@@ -240,9 +281,9 @@ html:not(.dark) .media-card :is([class*="border-white/"], [class*="ring-white/"]
|
||||
/* Accent text inside a media-card sits on dark imagery too — keep it at its
|
||||
bright (dark-mode) shade instead of the darkened light-theme rescue above. */
|
||||
html:not(.dark) .media-card :is([class*="text-emerald-3"], [class*="text-emerald-2"]) { color: rgb(110 231 183) !important; }
|
||||
html:not(.dark) .media-card :is([class*="text-fuchsia-3"], [class*="text-fuchsia-2"]) { color: rgb(240 171 252) !important; }
|
||||
html:not(.dark) .media-card :is([class*="text-indigo-3"], [class*="text-indigo-2"]) { color: rgb(165 180 252) !important; }
|
||||
html:not(.dark) .media-card :is([class*="text-violet-3"], [class*="text-violet-2"]) { color: rgb(196 181 253) !important; }
|
||||
html:not(.dark) .media-card :is([class*="text-rose-3"], [class*="text-rose-2"]) { color: rgb(251 207 232) !important; }
|
||||
html:not(.dark) .media-card :is([class*="text-sky-3"], [class*="text-sky-2"]) { color: rgb(147 197 253) !important; }
|
||||
html:not(.dark) .media-card :is([class*="text-orange-3"], [class*="text-orange-2"]) { color: rgb(196 181 253) !important; }
|
||||
html:not(.dark) .media-card :is([class*="text-sky-3"], [class*="text-sky-2"]) { color: rgb(125 211 252) !important; }
|
||||
html:not(.dark) .media-card :is([class*="text-amber-3"], [class*="text-amber-2"]) { color: rgb(252 211 77) !important; }
|
||||
|
||||
@@ -257,8 +298,8 @@ html:not(.dark) .doc-code code { background: rgb(255 255 255 / 0.08); color: rgb
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
/* Kill the bounce/glow when scrolling past the top or bottom of any
|
||||
scroll surface (the dark admin shell has fixed viewport height and we
|
||||
don't want a rubber-band-y feel at the edges of inner scroll areas). */
|
||||
scroll surface (the dark admin shell has fixed viewport height and we
|
||||
don't want a rubber-band-y feel at the edges of inner scroll areas). */
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
@@ -267,10 +308,11 @@ body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
overscroll-behavior: none;
|
||||
/* Subtle ambient gradient + grain (used by PublicLayout via .ambient-bg) */
|
||||
/* Aurora ambient gradient + grain (used by PublicLayout via .ambient-bg) */
|
||||
}
|
||||
|
||||
::selection { background: rgb(99 102 241 / 0.22); color: rgb(15 23 42); }
|
||||
::selection { background: rgb(139 92 246 / 0.32); color: inherit; }
|
||||
html:not(.dark) ::selection { color: #181524; }
|
||||
|
||||
/* Tailwind v4 / preflight leaves <button> on the default arrow cursor, so
|
||||
admins miss the affordance on icon-only actions (复制 / 删除 / 生成 等).
|
||||
@@ -285,31 +327,68 @@ button:not(:disabled), [role="button"]:not([aria-disabled="true"]) { cursor: poi
|
||||
own utility so vertical clipping / rounded corners are preserved. */
|
||||
.card:has(> table) { overflow-x: auto; }
|
||||
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-thumb { background: rgb(203 213 225); border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgb(148 163 184); }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
/* ===== Ambient background — used by the public shell ===== */
|
||||
.ambient-bg {
|
||||
background:
|
||||
radial-gradient(at 15% 10%, rgb(238 242 255) 0%, transparent 45%),
|
||||
radial-gradient(at 85% 25%, rgb(253 244 255) 0%, transparent 50%),
|
||||
radial-gradient(at 50% 90%, rgb(236 254 255) 0%, transparent 50%),
|
||||
rgb(250 250 251);
|
||||
/* ===== Admin table polish =====
|
||||
Every data table lives in a `.card` and views hand-roll their own
|
||||
th/td utilities — these rules unify the reading of all of them:
|
||||
a faint violet band on the header row and a violet wash on row hover.
|
||||
Scoped to `.theme-text` (the admin main wrapper) so public pages with
|
||||
over-media tables are untouched; specificity outranks the per-view
|
||||
Tailwind hover utilities and the light-mode white rescue. */
|
||||
.theme-text .card table thead {
|
||||
background: linear-gradient(180deg, rgb(124 92 252 / 0.07), rgb(124 92 252 / 0.025));
|
||||
}
|
||||
html.dark .theme-text .card table thead {
|
||||
background: linear-gradient(180deg, rgb(139 92 246 / 0.13), rgb(139 92 246 / 0.05));
|
||||
}
|
||||
.theme-text .card table tbody tr { transition: background-color 0.15s ease; }
|
||||
.theme-text .card table tbody tr:hover { background-color: rgb(124 92 252 / 0.08); }
|
||||
html.dark .theme-text .card table tbody tr:hover { background-color: rgb(139 92 246 / 0.11); }
|
||||
/* Checkbox column + first data column get a hair more air against the
|
||||
card edge so dense tables don't feel cramped. */
|
||||
.theme-text .card table tbody tr:hover :is(td, th):first-child {
|
||||
box-shadow: inset 2px 0 0 rgb(124 92 252 / 0.55);
|
||||
}
|
||||
|
||||
/* ===== 二次元 (anime) design kit =====
|
||||
Soft pink/violet/sky gradients, rounded bubbly surfaces, sparkle overlays and
|
||||
sticker labels. Reusable across the workbench + user subpages + public pages
|
||||
so the whole front-end shares one cute, neon-tinged look. */
|
||||
::-webkit-scrollbar { width: 9px; height: 9px; }
|
||||
::-webkit-scrollbar-thumb { background: rgb(24 21 36 / 0.2); border-radius: 5px; border: 2px solid transparent; background-clip: content-box; }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgb(24 21 36 / 0.38); background-clip: content-box; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
html.dark ::-webkit-scrollbar-thumb { background: rgb(255 255 255 / 0.18); background-clip: content-box; }
|
||||
html.dark ::-webkit-scrollbar-thumb:hover { background: rgb(255 255 255 / 0.32); background-clip: content-box; }
|
||||
|
||||
/* Gradient text used for eyebrows / accents. */
|
||||
/* ===== Ambient background — aurora wash used by the public shell ===== */
|
||||
.ambient-bg {
|
||||
background-color: var(--app-bg);
|
||||
background-image:
|
||||
radial-gradient(60rem 40rem at 12% -10%, rgb(124 92 252 / 0.14), transparent 60%),
|
||||
radial-gradient(52rem 36rem at 92% 8%, rgb(34 211 238 / 0.11), transparent 60%),
|
||||
radial-gradient(46rem 32rem at 8% 96%, rgb(240 171 252 / 0.1), transparent 60%);
|
||||
}
|
||||
html.dark .ambient-bg {
|
||||
background-image:
|
||||
radial-gradient(60rem 40rem at 12% -10%, rgb(139 92 246 / 0.2), transparent 60%),
|
||||
radial-gradient(52rem 36rem at 92% 8%, rgb(34 211 238 / 0.13), transparent 60%),
|
||||
radial-gradient(46rem 32rem at 8% 96%, rgb(232 121 249 / 0.1), transparent 60%);
|
||||
}
|
||||
|
||||
/* ===== Glass / aurora design kit =====
|
||||
Frosted surfaces, hairline edges, soft layered shadows and violet→cyan
|
||||
gradient accents. Same class names as the previous memphis kit so every
|
||||
view picks the new look up without markup changes. */
|
||||
|
||||
/* Gradient headline text — violet → cyan swipe. */
|
||||
.anime-grad-text {
|
||||
background: linear-gradient(100deg, #f472b6, #a855f7 46%, #38bdf8);
|
||||
background: linear-gradient(100deg, #7c5cfc 0%, #a855f7 45%, #22d3ee 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
color: transparent;
|
||||
padding: 0 0.05em;
|
||||
}
|
||||
html.dark .anime-grad-text {
|
||||
background: linear-gradient(100deg, #a78bfa 0%, #e879f9 45%, #67e8f9 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Section eyebrow — tiny uppercase kicker with a leading star. */
|
||||
@@ -318,71 +397,70 @@ button:not(:disabled), [role="button"]:not([aria-disabled="true"]) { cursor: poi
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.anime-eyebrow::before { content: '✦'; font-size: 0.85em; }
|
||||
|
||||
/* Sticker / kawaii pill label. */
|
||||
/* Sticker label — glass chip with a soft glow. */
|
||||
.sticker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.28rem 0.7rem;
|
||||
gap: 0.4rem;
|
||||
padding: 0.34rem 0.85rem;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #a21caf;
|
||||
background: linear-gradient(135deg, rgb(244 114 182 / 0.16), rgb(129 140 248 / 0.16));
|
||||
box-shadow: inset 0 0 0 1px rgb(217 70 239 / 0.28);
|
||||
}
|
||||
html.dark .sticker {
|
||||
color: #f5d0fe;
|
||||
background: linear-gradient(135deg, rgb(244 114 182 / 0.2), rgb(129 140 248 / 0.2));
|
||||
box-shadow: inset 0 0 0 1px rgb(217 70 239 / 0.35), 0 0 18px -6px rgb(217 70 239 / 0.5);
|
||||
color: var(--fg);
|
||||
background: var(--surface);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--glass-shadow);
|
||||
}
|
||||
|
||||
/* Bubbly gradient CTA — pill button with a squishy hover. */
|
||||
/* Chunky CTA — violet→cyan gradient with a glow. */
|
||||
.bubble-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.7rem 1.4rem;
|
||||
border-radius: 999px;
|
||||
border-radius: 0.8rem;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border: 1px solid rgb(255 255 255 / 0.18);
|
||||
cursor: pointer;
|
||||
background: linear-gradient(135deg, #f472b6, #a855f7 55%, #38bdf8);
|
||||
box-shadow: 0 14px 30px -12px rgb(168 85 247 / 0.7);
|
||||
transition: transform 0.15s ease, filter 0.15s ease, box-shadow 0.15s ease;
|
||||
background: linear-gradient(120deg, #7c5cfc, #5f6cf8 55%, #22d3ee);
|
||||
box-shadow: 0 8px 24px var(--accent-glow);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease, filter 0.15s ease;
|
||||
}
|
||||
.bubble-btn:hover { transform: translateY(-2px) scale(1.02); filter: brightness(1.06); }
|
||||
.bubble-btn:active { transform: translateY(0) scale(0.99); }
|
||||
.bubble-btn:hover { transform: translateY(-2px); box-shadow: 0 12px 32px var(--accent-glow); filter: brightness(1.06); }
|
||||
.bubble-btn:active { transform: scale(0.97) translateY(1px); box-shadow: 0 4px 12px var(--accent-glow); }
|
||||
|
||||
/* Rounded anime card surface with a soft pastel wash + neon ring in dark. */
|
||||
/* Glass card — frosted surface, hairline edge, soft shadow. */
|
||||
.anime-card {
|
||||
position: relative;
|
||||
border-radius: 1.5rem;
|
||||
border-radius: 1.1rem;
|
||||
background: var(--surface);
|
||||
box-shadow: inset 0 0 0 1px var(--hairline);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
backdrop-filter: blur(14px);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--glass-shadow);
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
.anime-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: inset 0 0 0 1px rgb(217 70 239 / 0.4), 0 22px 44px -26px rgb(168 85 247 / 0.5);
|
||||
}
|
||||
html.dark .anime-card:hover {
|
||||
box-shadow: inset 0 0 0 1px rgb(217 70 239 / 0.5), 0 0 30px -10px rgb(217 70 239 / 0.45);
|
||||
box-shadow: var(--glass-shadow-lg);
|
||||
}
|
||||
|
||||
/* Selected pill — replaces the old bg-slate-900 look with a candy gradient. */
|
||||
/* Selected pill — gradient fill with a glow. */
|
||||
.chip-on {
|
||||
color: #fff !important;
|
||||
background: linear-gradient(135deg, #e879f9, #8b5cf6) !important;
|
||||
box-shadow: 0 8px 20px -10px rgb(168 85 247 / 0.7) !important;
|
||||
background: linear-gradient(120deg, #7c5cfc, #5f6cf8) !important;
|
||||
border: 1px solid transparent !important;
|
||||
box-shadow: 0 4px 16px var(--accent-glow) !important;
|
||||
}
|
||||
|
||||
/* Twinkling star field — drop into any relatively-positioned decorative layer.
|
||||
@@ -392,12 +470,12 @@ html.dark .anime-card:hover {
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
radial-gradient(1.5px 1.5px at 12% 22%, rgb(217 70 239 / 0.7), transparent),
|
||||
radial-gradient(1.5px 1.5px at 78% 12%, rgb(56 189 248 / 0.7), transparent),
|
||||
radial-gradient(1px 1px at 42% 62%, rgb(168 85 247 / 0.65), transparent),
|
||||
radial-gradient(1.5px 1.5px at 88% 72%, rgb(244 114 182 / 0.6), transparent),
|
||||
radial-gradient(1px 1px at 24% 84%, rgb(56 189 248 / 0.55), transparent),
|
||||
radial-gradient(1px 1px at 62% 34%, rgb(250 204 21 / 0.55), transparent);
|
||||
radial-gradient(1.5px 1.5px at 12% 22%, rgb(139 92 246 / 0.7), transparent),
|
||||
radial-gradient(1.5px 1.5px at 78% 12%, rgb(34 211 238 / 0.7), transparent),
|
||||
radial-gradient(1px 1px at 42% 62%, rgb(139 92 246 / 0.6), transparent),
|
||||
radial-gradient(1.5px 1.5px at 88% 72%, rgb(232 121 249 / 0.6), transparent),
|
||||
radial-gradient(1px 1px at 24% 84%, rgb(34 211 238 / 0.55), transparent),
|
||||
radial-gradient(1px 1px at 62% 34%, rgb(167 139 250 / 0.55), transparent);
|
||||
animation: twinkle 4.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes twinkle { 0%, 100% { opacity: 0.85; } 50% { opacity: 0.4; } }
|
||||
@@ -422,46 +500,105 @@ html.dark .anime-card:hover {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* ===== Glass utilities ===== */
|
||||
/* Dot texture layer — kept faint so it reads as grain, not polka dots. */
|
||||
.memphis-dots {
|
||||
background-image: radial-gradient(circle, rgb(24 21 36 / 0.07) 1.1px, transparent 1.1px);
|
||||
background-size: 26px 26px;
|
||||
}
|
||||
html.dark .memphis-dots {
|
||||
background-image: radial-gradient(circle, rgb(255 255 255 / 0.05) 1.1px, transparent 1.1px);
|
||||
}
|
||||
/* Frosted surface helpers for one-off markup. */
|
||||
.ink-card {
|
||||
background: var(--surface);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
backdrop-filter: blur(14px);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--glass-shadow-lg);
|
||||
}
|
||||
.ink-card-sm {
|
||||
background: var(--surface);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--glass-shadow);
|
||||
}
|
||||
/* Lift on hover — the signature card interaction. */
|
||||
.chunky-hover { transition: transform 0.18s ease, box-shadow 0.18s ease; }
|
||||
.chunky-hover:hover { transform: translateY(-3px); box-shadow: var(--glass-shadow-lg); }
|
||||
/* Squishy press for buttons. */
|
||||
.memphis-press { transition: transform 0.15s ease; }
|
||||
.memphis-press:active { transform: scale(0.97) translateY(1px); }
|
||||
|
||||
/* Empty-state badge — frosted tile with a gradient star. */
|
||||
.empty-badge {
|
||||
width: 4.5rem;
|
||||
height: 4.5rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 1.8rem;
|
||||
font-weight: 900;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #7c5cfc, #22d3ee);
|
||||
border: 1px solid rgb(255 255 255 / 0.2);
|
||||
border-radius: 1.2rem;
|
||||
box-shadow: 0 12px 32px var(--accent-glow);
|
||||
animation: memphis-float 6s ease-in-out infinite;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .empty-badge { animation: none; } }
|
||||
|
||||
@keyframes memphis-wiggle { 0%, 100% { transform: rotate(-3deg); } 50% { transform: rotate(3deg); } }
|
||||
@keyframes memphis-float { 0%, 100% { transform: translateY(0) rotate(0deg); } 50% { transform: translateY(-10px) rotate(1.5deg); } }
|
||||
@keyframes memphis-bounce { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-8px); } }
|
||||
.animate-memphis-wiggle { animation: memphis-wiggle 2s ease-in-out infinite; }
|
||||
.animate-memphis-float { animation: memphis-float 6s ease-in-out infinite; }
|
||||
.animate-memphis-bounce { animation: memphis-bounce 2s ease-in-out infinite; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-memphis-wiggle, .animate-memphis-float, .animate-memphis-bounce { animation: none; }
|
||||
}
|
||||
|
||||
/* ===== Dark public-shell theme overrides =====
|
||||
Apply by adding `.public-dark` to a wrapper element. Views can keep their
|
||||
existing Tailwind classes (text-slate-*, .card, .field) — these rules
|
||||
remap them to the dark palette. */
|
||||
.public-dark { color: rgb(255 255 255 / 0.9); }
|
||||
.public-dark .card {
|
||||
background: rgb(255 255 255 / 0.03);
|
||||
border-color: rgb(255 255 255 / 0.07);
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
border-color: rgb(255 255 255 / 0.1);
|
||||
color: rgb(255 255 255 / 0.92);
|
||||
box-shadow: 0 1px 0 rgb(255 255 255 / 0.04) inset, 0 8px 24px rgb(0 0 0 / 0.25);
|
||||
box-shadow: var(--glass-shadow), inset 0 1px 0 rgb(255 255 255 / 0.07);
|
||||
}
|
||||
.public-dark .field {
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
border-color: rgb(255 255 255 / 0.1);
|
||||
border-color: rgb(255 255 255 / 0.12);
|
||||
color: rgb(255 255 255 / 0.95);
|
||||
transition: border-color 0.18s, background 0.18s, box-shadow 0.18s;
|
||||
}
|
||||
.public-dark .field::placeholder { color: rgb(255 255 255 / 0.3); }
|
||||
/* Single focus treatment shared by every <.field> across the admin shell —
|
||||
matches the per-component overrides in ConfigView/LoginModal so all
|
||||
inputs glow with the same violet ring on focus. */
|
||||
a violet glow ring, matching the light theme's focus affordance. */
|
||||
.public-dark .field:focus,
|
||||
.public-dark .field:focus-visible {
|
||||
border-color: rgb(167 139 250 / 0.65);
|
||||
border-color: rgb(139 92 246 / 0.7);
|
||||
background: rgb(255 255 255 / 0.06);
|
||||
box-shadow: 0 0 0 3px rgb(167 139 250 / 0.15);
|
||||
box-shadow: 0 0 0 3px rgb(139 92 246 / 0.25);
|
||||
outline: none;
|
||||
}
|
||||
.public-dark .btn-soft {
|
||||
background: rgb(255 255 255 / 0.06);
|
||||
background: rgb(255 255 255 / 0.05);
|
||||
border-color: rgb(255 255 255 / 0.12);
|
||||
color: rgb(255 255 255 / 0.85);
|
||||
}
|
||||
.public-dark .btn-soft:hover { background: rgb(255 255 255 / 0.12); color: white; }
|
||||
.public-dark .btn-ghost { color: rgb(255 255 255 / 0.65); }
|
||||
.public-dark .btn-ghost:hover { background: rgb(255 255 255 / 0.06); color: white; }
|
||||
.public-dark .btn-soft:hover { background: rgb(139 92 246 / 0.25); color: white; }
|
||||
.public-dark .btn-ghost { color: rgb(255 255 255 / 0.65); border-color: transparent; }
|
||||
.public-dark .btn-ghost:hover { background: rgb(139 92 246 / 0.18); color: white; }
|
||||
.public-dark .btn-primary {
|
||||
background: white; color: rgb(2 6 23); box-shadow: 0 1px 0 rgb(0 0 0 / 0.1);
|
||||
background: linear-gradient(120deg, #7c5cfc, #5f6cf8); color: #fff;
|
||||
box-shadow: 0 6px 18px var(--accent-glow);
|
||||
}
|
||||
.public-dark .btn-primary:hover { background: rgb(255 255 255 / 0.92); }
|
||||
.public-dark .pill { background: rgb(255 255 255 / 0.06); color: rgb(255 255 255 / 0.85); }
|
||||
.public-dark .btn-primary:hover { filter: brightness(1.08); }
|
||||
.public-dark .pill { background: rgb(255 255 255 / 0.06); color: rgb(255 255 255 / 0.85); border-color: rgb(255 255 255 / 0.12); }
|
||||
|
||||
/* Remap slate text tokens */
|
||||
.public-dark .text-slate-900,
|
||||
@@ -485,7 +622,7 @@ html.dark .anime-card:hover {
|
||||
/* Tables in dark */
|
||||
.public-dark .th { color: rgb(255 255 255 / 0.4); }
|
||||
.public-dark .row { border-color: rgb(255 255 255 / 0.06); }
|
||||
.public-dark .row:hover { background: rgb(255 255 255 / 0.03); }
|
||||
.public-dark .row:hover { background: rgb(139 92 246 / 0.07); }
|
||||
|
||||
/* Inline code blocks (pill highlight) — only for inline <code>, NOT code inside
|
||||
a <pre> block (docs examples), which would get an unwanted full-width tint. */
|
||||
@@ -511,34 +648,81 @@ html.dark .anime-card:hover {
|
||||
.public-dark .hover\:bg-slate-100:hover { background: rgb(255 255 255 / 0.06); }
|
||||
|
||||
@layer components {
|
||||
/* surfaces */
|
||||
.card { @apply bg-white rounded-xl border border-slate-200 shadow-sm; }
|
||||
/* surfaces — frosted card with a hairline glass edge + soft shadow.
|
||||
The inset top line is the light's "sheen" on the glass — barely
|
||||
visible on white, reads as a subtle rim-light in dark mode. */
|
||||
.card {
|
||||
@apply rounded-2xl;
|
||||
background: var(--surface);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--glass-shadow), inset 0 1px 0 rgb(255 255 255 / 0.55);
|
||||
}
|
||||
|
||||
/* buttons — each variant is self-contained (Tailwind v4 can't @apply a custom class) */
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center justify-center gap-1.5 rounded-lg text-sm font-medium
|
||||
transition-colors disabled:opacity-50 disabled:cursor-not-allowed
|
||||
bg-slate-900 text-white hover:bg-slate-700 px-3.5 py-2;
|
||||
@apply inline-flex items-center justify-center gap-1.5 rounded-lg text-sm font-bold
|
||||
disabled:opacity-50 disabled:cursor-not-allowed px-3.5 py-2;
|
||||
color: #fff;
|
||||
background: linear-gradient(120deg, #7c5cfc, #5f6cf8 60%, #22d3ee 140%);
|
||||
border: 1px solid rgb(255 255 255 / 0.16);
|
||||
box-shadow: 0 6px 18px var(--accent-glow);
|
||||
transition: transform 0.13s ease, box-shadow 0.13s ease, filter 0.13s ease;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 10px 26px var(--accent-glow); filter: brightness(1.07); }
|
||||
.btn-primary:active:not(:disabled) { transform: scale(0.97) translateY(1px); box-shadow: 0 3px 10px var(--accent-glow); }
|
||||
.btn-soft {
|
||||
@apply inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-medium
|
||||
transition-colors disabled:opacity-50 disabled:cursor-not-allowed
|
||||
bg-slate-100 text-slate-700 hover:bg-slate-200 px-3 py-1.5;
|
||||
@apply inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-semibold
|
||||
disabled:opacity-50 disabled:cursor-not-allowed px-3 py-1.5;
|
||||
color: var(--fg-2);
|
||||
background: var(--surface);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--ink-soft);
|
||||
transition: transform 0.13s ease, box-shadow 0.13s ease, background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.btn-soft:hover:not(:disabled) {
|
||||
color: var(--fg);
|
||||
background: var(--hover);
|
||||
border-color: rgb(124 92 252 / 0.45);
|
||||
box-shadow: 0 4px 14px rgb(124 92 252 / 0.16);
|
||||
}
|
||||
html.dark .btn-soft:hover:not(:disabled) { border-color: rgb(139 92 246 / 0.5); box-shadow: 0 4px 14px rgb(139 92 246 / 0.22); }
|
||||
.btn-soft:active:not(:disabled) { transform: scale(0.97) translateY(1px); box-shadow: none; }
|
||||
.btn-ghost {
|
||||
@apply inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-medium
|
||||
transition-colors disabled:opacity-50 disabled:cursor-not-allowed
|
||||
text-slate-600 hover:bg-slate-100 hover:text-slate-900 px-3 py-1.5;
|
||||
disabled:opacity-50 disabled:cursor-not-allowed px-3 py-1.5;
|
||||
color: var(--fg-2);
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
color: var(--fg);
|
||||
background: var(--hover);
|
||||
border-color: rgb(124 92 252 / 0.3);
|
||||
}
|
||||
|
||||
/* text link / quiet action */
|
||||
.link { @apply text-slate-500 hover:text-slate-900 transition-colors cursor-pointer; }
|
||||
.link { @apply transition-colors cursor-pointer; color: var(--fg-3); }
|
||||
.link:hover { color: var(--m-orange); }
|
||||
|
||||
/* form controls */
|
||||
/* form controls — glass field, violet glow ring on focus */
|
||||
.field {
|
||||
@apply w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-800
|
||||
outline-none transition-colors placeholder:text-slate-400
|
||||
focus:border-slate-400 focus:ring-2;
|
||||
@apply w-full rounded-lg px-3 py-2 text-sm
|
||||
outline-none placeholder:text-slate-400;
|
||||
color: var(--fg);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--ink-soft);
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.field:focus, .field:focus-visible {
|
||||
border-color: rgb(124 92 252 / 0.65);
|
||||
box-shadow: 0 0 0 3px rgb(124 92 252 / 0.18);
|
||||
}
|
||||
html.dark .field:focus, html.dark .field:focus-visible {
|
||||
border-color: rgb(139 92 246 / 0.7);
|
||||
box-shadow: 0 0 0 3px rgb(139 92 246 / 0.25);
|
||||
}
|
||||
|
||||
/* Native <select> using .field: drop the OS arrow for a custom chevron that
|
||||
@@ -565,13 +749,21 @@ html.dark .anime-card:hover {
|
||||
}
|
||||
|
||||
/* status pill */
|
||||
.pill { @apply inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium; }
|
||||
.pill {
|
||||
@apply inline-flex items-center rounded-md px-2 py-0.5 text-xs font-semibold;
|
||||
border: 1px solid var(--ink-soft);
|
||||
}
|
||||
|
||||
/* table primitives */
|
||||
.th { @apply text-left px-4 py-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400; }
|
||||
.th { @apply text-left px-4 py-3 text-[11px] font-bold uppercase tracking-wider; color: var(--fg-faint); }
|
||||
.td { @apply px-4 py-3.5 align-middle; }
|
||||
.row { @apply border-t border-slate-100 transition-colors hover:bg-slate-50; }
|
||||
.row { @apply border-t transition-colors; border-color: var(--hairline); }
|
||||
.row:hover { background: rgb(124 92 252 / 0.06); }
|
||||
|
||||
/* avatar chip for identity columns */
|
||||
.avatar { @apply w-8 h-8 rounded-full grid place-items-center text-xs font-semibold shrink-0 ring-1 ring-black/5; }
|
||||
.avatar {
|
||||
@apply w-8 h-8 rounded-full grid place-items-center text-xs font-bold shrink-0;
|
||||
border: 1px solid var(--ink);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,16 +7,21 @@ let idSeq = 0
|
||||
|
||||
/** 世界→屏幕 */
|
||||
export function worldToScreen(x, y, vp) {
|
||||
return { x: x * vp.scale + vp.x, y: y * vp.scale + vp.y }
|
||||
const s = vp.scale * BASE_SCALE
|
||||
return { x: x * s + vp.x, y: y * s + vp.y }
|
||||
}
|
||||
|
||||
/** 屏幕→世界 */
|
||||
export function screenToWorld(sx, sy, vp) {
|
||||
return { x: (sx - vp.x) / vp.scale, y: (sy - vp.y) / vp.scale }
|
||||
const s = vp.scale * BASE_SCALE
|
||||
return { x: (sx - vp.x) / s, y: (sy - vp.y) / s }
|
||||
}
|
||||
|
||||
export const MIN_SCALE = 0.05
|
||||
export const MAX_SCALE = 4
|
||||
export const DEFAULT_SCALE = 1 // 新画布/重置视图的逻辑缩放(BASE_SCALE 已整体缩小)
|
||||
// 视觉基准:显示 100% 时实际渲染 0.6×,全部屏幕换算统一走它
|
||||
export const BASE_SCALE = 0.6
|
||||
|
||||
/** 以屏幕点 m 为锚缩放到 newScale,返回新 viewport(平移量按锚点保持不动) */
|
||||
export function zoomAt(vp, mx, my, newScale) {
|
||||
@@ -40,11 +45,12 @@ export function pointInRect(px, py, r) {
|
||||
|
||||
/** 视口剔除:返回与可见世界矩形(含 margin)相交的节点。纯几何,千级节点也是微秒。 */
|
||||
export function nodesInViewport(nodes, vp, stageW, stageH, margin = 240) {
|
||||
const s = vp.scale * BASE_SCALE
|
||||
const vis = {
|
||||
x: -vp.x / vp.scale - margin,
|
||||
y: -vp.y / vp.scale - margin,
|
||||
w: stageW / vp.scale + margin * 2,
|
||||
h: stageH / vp.scale + margin * 2,
|
||||
x: -vp.x / s - margin,
|
||||
y: -vp.y / s - margin,
|
||||
w: stageW / s + margin * 2,
|
||||
h: stageH / s + margin * 2,
|
||||
}
|
||||
return nodes.filter((n) => rectsIntersect(nodeRect(n), vis))
|
||||
}
|
||||
@@ -78,7 +84,7 @@ export function newId(prefix) {
|
||||
*/
|
||||
export function normalizeDoc(raw) {
|
||||
const doc = {
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
viewport: { x: 0, y: 0, scale: DEFAULT_SCALE },
|
||||
nodes: [],
|
||||
edges: [],
|
||||
}
|
||||
@@ -97,7 +103,7 @@ export function normalizeDoc(raw) {
|
||||
if (!n || typeof n.id !== 'string' || ids.has(n.id)) continue
|
||||
if (!Number.isFinite(n.x) || !Number.isFinite(n.y)) continue
|
||||
if (!Number.isFinite(n.w) || n.w <= 0 || !Number.isFinite(n.h) || n.h <= 0) continue
|
||||
const type = n.type === 'note' || n.type === 'gen' ? n.type : 'image'
|
||||
const type = ['note', 'gen', 'video'].includes(n.type) ? n.type : 'image'
|
||||
const node = {
|
||||
id: n.id,
|
||||
type,
|
||||
@@ -108,9 +114,9 @@ export function normalizeDoc(raw) {
|
||||
z: Number.isFinite(n.z) ? n.z : 0,
|
||||
}
|
||||
if (type === 'note') node.text = typeof n.text === 'string' ? n.text.slice(0, 4000) : ''
|
||||
if (type === 'image') {
|
||||
if (type === 'image' || type === 'video') {
|
||||
node.src = typeof n.src === 'string' ? n.src.slice(0, 500) : ''
|
||||
if (!node.src) continue // 图片节点必须有 src
|
||||
if (!node.src) continue // 媒体节点必须有 src
|
||||
if (typeof n.prompt === 'string') node.prompt = n.prompt.slice(0, 2000)
|
||||
if (typeof n.model === 'string') node.model = n.model.slice(0, 120)
|
||||
}
|
||||
@@ -123,11 +129,22 @@ export function normalizeDoc(raw) {
|
||||
}
|
||||
}
|
||||
if (Array.isArray(raw.edges)) {
|
||||
const pairs = new Set() // 同一对节点只保留一条线(旧文档可能有重复)
|
||||
for (const e of raw.edges) {
|
||||
if (!e || typeof e.id !== 'string') continue
|
||||
if (typeof e.from !== 'string' || typeof e.to !== 'string') continue
|
||||
if (!ids.has(e.from) || !ids.has(e.to) || e.from === e.to) continue
|
||||
doc.edges.push({ id: e.id, from: e.from, to: e.to })
|
||||
const pair = e.from < e.to ? `${e.from}|${e.to}` : `${e.to}|${e.from}`
|
||||
if (pairs.has(pair)) continue
|
||||
pairs.add(pair)
|
||||
const edge = { id: e.id, from: e.from, to: e.to }
|
||||
if (['top', 'right', 'bottom', 'left'].includes(e.fromSide)) edge.fromSide = e.fromSide
|
||||
if (['top', 'right', 'bottom', 'left'].includes(e.toSide)) edge.toSide = e.toSide
|
||||
if (Number.isFinite(e.bendX) && Number.isFinite(e.bendY)) {
|
||||
edge.bendX = Math.max(-10000, Math.min(10000, e.bendX))
|
||||
edge.bendY = Math.max(-10000, Math.min(10000, e.bendY))
|
||||
}
|
||||
doc.edges.push(edge)
|
||||
}
|
||||
}
|
||||
return doc
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
// Smart parsing of pasted credentials (Adobe cookies / ChatGPT JWTs),
|
||||
// ported verbatim from admin.html so import behaviour is unchanged.
|
||||
|
||||
export function looksLikeJwt(s) {
|
||||
s = (s || '').replace(/^Bearer\s+/i, '').trim()
|
||||
const parts = s.split('.')
|
||||
if (parts.length !== 3) return false
|
||||
return parts.every((p) => /^[A-Za-z0-9_-]+$/.test(p) && p.length > 4)
|
||||
}
|
||||
|
||||
function decodeJwtPayload(s) {
|
||||
try {
|
||||
let p = (s || '').replace(/^Bearer\s+/i, '').trim().split('.')[1]
|
||||
if (!p) return null
|
||||
p = p.replace(/-/g, '+').replace(/_/g, '/')
|
||||
p += '='.repeat((4 - (p.length % 4)) % 4)
|
||||
return JSON.parse(atob(p))
|
||||
} catch (_) { return null }
|
||||
}
|
||||
|
||||
// Runway JWTs carry a top-level numeric `id` plus an `sso` claim and, crucially,
|
||||
// no OpenAI (https://api.openai.com/*) claims — that's what distinguishes them
|
||||
// from a ChatGPT JWT, which is otherwise also an opaque three-part token.
|
||||
export function looksLikeRunwayJwt(s) {
|
||||
const claims = decodeJwtPayload(s)
|
||||
if (!claims || typeof claims !== 'object') return false
|
||||
if (Object.keys(claims).some((k) => k.startsWith('https://api.openai.com/'))) return false
|
||||
return 'sso' in claims && claims.id != null
|
||||
}
|
||||
|
||||
// Grok website "sso" JWTs carry ONLY a session_id claim (no openai claims, no
|
||||
// runway id/sso) — that's what tells them apart from a ChatGPT/Runway JWT.
|
||||
export function looksLikeGrokJwt(s) {
|
||||
const claims = decodeJwtPayload(s)
|
||||
if (!claims || typeof claims !== 'object') return false
|
||||
if (Object.keys(claims).some((k) => k.startsWith('https://api.openai.com/'))) return false
|
||||
if ('sso' in claims || claims.id != null) return false
|
||||
return 'session_id' in claims
|
||||
}
|
||||
|
||||
// Leonardo cookies carry the better-auth session cookie — that's what tells them
|
||||
// apart from an Adobe cookie (both are otherwise opaque cookie strings).
|
||||
export function looksLikeLeonardoCookie(s) {
|
||||
return /better-auth\.session_token/.test(s || '') || /better-auth\.session_data/.test(s || '')
|
||||
}
|
||||
|
||||
// Krea cookies carry the Supabase auth cookie.
|
||||
export function looksLikeKreaCookie(s) {
|
||||
return /sb-superb-auth-token/.test(s || '')
|
||||
}
|
||||
|
||||
// Creative Fabrica session cookies carry the cfauth_* family (cfauth_uid /
|
||||
// cfauth_sig / cfauth_utp) plus the WordPress SSO cookie — those markers tell
|
||||
// them apart from an Adobe/Krea/Leonardo cookie (all otherwise opaque strings).
|
||||
export function looksLikeCreativeFabricaCookie(s) {
|
||||
return /cfauth_/.test(s || '') || /cfAmpAnonymousId/.test(s || '') || /wordpress_logged_in_/.test(s || '')
|
||||
}
|
||||
|
||||
// An Imagine.art credential is a JSON object { token, refreshToken } (both JWTs).
|
||||
function isImagineObj(o) {
|
||||
return !!o && typeof o === 'object' &&
|
||||
typeof o.token === 'string' && looksLikeJwt(o.token) &&
|
||||
typeof o.refreshToken === 'string' && looksLikeJwt(o.refreshToken)
|
||||
}
|
||||
|
||||
// String form (a pasted JSON object on a line).
|
||||
export function looksLikeImagineToken(s) {
|
||||
try { return isImagineObj(JSON.parse(s)) } catch (_) { return false }
|
||||
}
|
||||
|
||||
// Classify an opaque credential string by its distinctive shape. Imagine is
|
||||
// JSON-shaped, so it must be checked before the cookie heuristics.
|
||||
function cookieType(v) {
|
||||
if (looksLikeImagineToken(v)) return 'imagine'
|
||||
if (looksLikeKreaCookie(v)) return 'krea'
|
||||
if (looksLikeLeonardoCookie(v)) return 'leonardo'
|
||||
if (looksLikeCreativeFabricaCookie(v)) return 'creativefabrica'
|
||||
return 'adobe'
|
||||
}
|
||||
|
||||
function cookieFromAny(item) {
|
||||
if (typeof item === 'string') return item.trim()
|
||||
if (item && typeof item === 'object') {
|
||||
if (typeof item.cookie === 'string') return item.cookie.trim()
|
||||
if (typeof item.value === 'string' && !('name' in item)) return item.value.trim()
|
||||
if (Array.isArray(item.cookies)) {
|
||||
return item.cookies.filter((c) => c && c.name).map((c) => `${c.name}=${c.value}`).join('; ')
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** Returns a list of { type: 'adobe' | 'openai' | 'runway' | 'leonardo', value }. */
|
||||
export function parseImportInput(text) {
|
||||
text = (text || '').trim()
|
||||
if (!text) return []
|
||||
// Try JSON first.
|
||||
try {
|
||||
const j = JSON.parse(text)
|
||||
if (Array.isArray(j) && j.length > 0) {
|
||||
// Chrome cookie export: array of {name,value} → one cookie account.
|
||||
if (j.every((it) => it && typeof it === 'object' && 'name' in it && 'value' in it)) {
|
||||
const joined = j.filter((c) => c && c.name).map((c) => `${c.name}=${c.value}`).join('; ')
|
||||
return joined ? [{ type: cookieType(joined), value: joined }] : []
|
||||
}
|
||||
// Otherwise treat as multiple accounts. An Imagine credential is itself a
|
||||
// JSON object {token,refreshToken} — keep it as its JSON string value.
|
||||
return j.map((it) => {
|
||||
if (isImagineObj(it)) return { type: 'imagine', value: JSON.stringify(it) }
|
||||
const v = cookieFromAny(it)
|
||||
return { type: cookieType(v), value: v }
|
||||
}).filter((x) => x.value)
|
||||
}
|
||||
if (j && typeof j === 'object') {
|
||||
if (isImagineObj(j)) return [{ type: 'imagine', value: JSON.stringify(j) }]
|
||||
const v = cookieFromAny(j)
|
||||
return v ? [{ type: cookieType(v), value: v }] : []
|
||||
}
|
||||
} catch (_) { /* not JSON */ }
|
||||
// Not JSON → split per line, identify each. A JWT is either a Runway token
|
||||
// (top-level id+sso, no openai claims) or a ChatGPT token; anything else is
|
||||
// treated as an Adobe cookie string.
|
||||
const lines = text.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)
|
||||
return lines.map((line) => {
|
||||
// Accept a bare JWT or one with a leading `sso=` (grok cookie value form).
|
||||
const stripped = line.replace(/^Bearer\s+/i, '').replace(/^sso=/, '')
|
||||
if (looksLikeJwt(stripped)) {
|
||||
const value = stripped
|
||||
return looksLikeRunwayJwt(value)
|
||||
? { type: 'runway', value }
|
||||
: looksLikeGrokJwt(value)
|
||||
? { type: 'grok', value }
|
||||
: { type: 'openai', value }
|
||||
}
|
||||
return { type: cookieType(line), value: line }
|
||||
})
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
// 视频模型的参考资产分类上限(图片 / 视频 / 音频)。
|
||||
// 单一真源是后端 /video-presets 的 max_images / max_videos / max_audios(服务端
|
||||
// leonardoVideoSpecs 校验的就是这套数),预设没声明音视频上限的 seedance 系回落
|
||||
// 到 3 / 3,与画图台 (PlaygroundView) 的判定保持一致。
|
||||
const SEEDANCE_FALLBACK = { videos: 3, audios: 3 }
|
||||
|
||||
// 单一真源是后端 /video-presets 的 max_videos / max_audios(渠道导入时从上游
|
||||
// 模型规格带入);预设没声明的回落到模型行自己的 max_reference_videos/audios。
|
||||
function declared(v) {
|
||||
return v === undefined || v === null ? null : Number(v)
|
||||
}
|
||||
@@ -12,13 +9,10 @@ function declared(v) {
|
||||
// 参考资产时返回 null。model 兼容 /managed-models(type) 与 /models(kind) 两种字段。
|
||||
export function mediaCaps(model, preset) {
|
||||
if ((model?.type || model?.kind) !== 'video') return null
|
||||
// creativefabrica 上游只收图片参考(VIDEO_FRAME_TYPE_REFERENCE),不收视频/音频;
|
||||
// 它虽然叫 seedance,但要排除,否则会错误地显示「视频 3 音频 3」。
|
||||
const isSeedance = /^seedance/.test(model?.id || '') && (model?.provider || '') !== 'creativefabrica'
|
||||
const videos = declared(preset?.max_videos) ?? (isSeedance ? SEEDANCE_FALLBACK.videos : 0)
|
||||
const audios = declared(preset?.max_audios) ?? (isSeedance ? SEEDANCE_FALLBACK.audios : 0)
|
||||
const videos = declared(preset?.max_videos) ?? declared(model?.max_reference_videos) ?? 0
|
||||
const audios = declared(preset?.max_audios) ?? declared(model?.max_reference_audios) ?? 0
|
||||
if (!videos && !audios) return null
|
||||
return { images: declared(preset?.max_images) ?? 0, videos, audios }
|
||||
return { images: declared(preset?.max_images) ?? declared(model?.max_reference_images) ?? 0, videos, audios }
|
||||
}
|
||||
|
||||
// presetMap 把 /video-presets 的列表转成 key → preset,便于按模型 id 取预设。
|
||||
|
||||
@@ -1,100 +1,108 @@
|
||||
<script setup>
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { site } from '../site'
|
||||
import Mascot from '../components/Mascot.vue'
|
||||
|
||||
const contact = computed(() => site.contact || {})
|
||||
|
||||
const powers = [
|
||||
{ icon: '🎨', title: '文字生图', desc: '一句话出图,画幅与分辨率随模型可选。', tint: 'from-fuchsia-400/20 to-pink-400/10' },
|
||||
{ icon: '🖼️', title: '参考图生图', desc: '上传参考换风格、改构图、做变体。', tint: 'from-violet-400/20 to-indigo-400/10' },
|
||||
{ icon: '🎬', title: '文字生视频', desc: '一段描述生成短片,时长分辨率可选。', tint: 'from-sky-400/20 to-cyan-400/10' },
|
||||
{ icon: '✨', title: '图生视频', desc: '给张参考图,让静态画面动起来。', tint: 'from-amber-400/20 to-orange-400/10' },
|
||||
{ icon: 't2i', title: '文字生图', desc: '一句话出图,画幅与分辨率随模型可选。', tint: 'linear-gradient(135deg,#34d399,#2dd4bf)' },
|
||||
{ icon: 'i2i', title: '参考图生图', desc: '上传参考换风格、改构图、做变体。', tint: 'linear-gradient(135deg,#e879f9,#f472b6)' },
|
||||
{ icon: 't2v', title: '文字生视频', desc: '一段描述生成短片,时长分辨率可选。', tint: 'linear-gradient(135deg,#38bdf8,#22d3ee)' },
|
||||
{ icon: 'i2v', title: '图生视频', desc: '给张参考图,让静态画面动起来。', tint: 'linear-gradient(135deg,#7c5cfc,#a855f7)' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-16 pb-8">
|
||||
<!-- ===== hero ===== -->
|
||||
<section class="relative overflow-hidden rounded-[2rem] anime-card px-6 py-10 md:px-12 md:py-14">
|
||||
<div class="anime-stars"></div>
|
||||
<section class="relative overflow-hidden rounded-[1.4rem] anime-card px-6 py-10 md:px-12 md:py-14">
|
||||
<div class="memphis-dots absolute inset-0 opacity-40 pointer-events-none"></div>
|
||||
<div class="relative grid md:grid-cols-[1.1fr_0.9fr] gap-8 items-center">
|
||||
<div>
|
||||
<span class="sticker">✦ 关于我们</span>
|
||||
<h1 class="mt-4 text-4xl md:text-5xl font-extrabold tracking-tight text-[color:var(--fg)]">
|
||||
嗨,我是 <span class="anime-grad-text">薇薇</span>
|
||||
<h1 class="mt-4 text-4xl md:text-5xl font-black tracking-tight text-[color:var(--fg)]">
|
||||
把创作交给 <span class="anime-grad-text">{{ site.title }}</span>
|
||||
</h1>
|
||||
<p class="text-[color:var(--fg-2)] mt-5 text-base md:text-lg leading-relaxed max-w-xl">
|
||||
{{ site.title }} 是一个聚合 GPT、Gemini、Firefly、Flux 等顶级模型的 AI 生图与生视频平台。
|
||||
把脑海里的画面写成一句话,交给我替你变成图像与视频。
|
||||
把脑海里的画面写成一句话,剩下的交给我们变成图像与视频。
|
||||
</p>
|
||||
<router-link to="/user" class="bubble-btn mt-7">开始创作 →</router-link>
|
||||
</div>
|
||||
<div class="grid place-items-center">
|
||||
<Mascot :size="240" />
|
||||
<!-- floating glass tile cluster standing in for a mascot illustration -->
|
||||
<div class="ab-cluster" aria-hidden="true">
|
||||
<span class="ab-tile ab-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 5.8a2 2 0 0 0 1.3 1.3L21 12l-5.8 1.9a2 2 0 0 0-1.3 1.3L12 21l-1.9-5.8a2 2 0 0 0-1.3-1.3L3 12l5.8-1.9a2 2 0 0 0 1.3-1.3z"/></svg></span>
|
||||
<span class="ab-tile ab-2"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 16 5-5 3 3 4-4 6 6"/><circle cx="9" cy="9" r="1.4"/></svg></span>
|
||||
<span class="ab-tile ab-3"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h13v12H3z"/><path d="M16 10l5-3v10l-5-3"/></svg></span>
|
||||
<span class="ab-tile ab-4"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2 3 14h7l-1 8 10-12h-7z"/></svg></span>
|
||||
<span class="ab-star">✦</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== powers ===== -->
|
||||
<section>
|
||||
<div class="anime-eyebrow text-fuchsia-500 dark:text-fuchsia-300">能做什么</div>
|
||||
<h2 class="mt-3 text-2xl md:text-3xl font-extrabold tracking-tight text-[color:var(--fg)]">四种方式,把想象变成画面</h2>
|
||||
<div class="anime-eyebrow text-[var(--m-orange)]">能做什么</div>
|
||||
<h2 class="mt-3 text-2xl md:text-3xl font-black tracking-tight text-[color:var(--fg)]">四种方式,把想象变成画面</h2>
|
||||
<div class="mt-7 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div v-for="p in powers" :key="p.title"
|
||||
class="anime-card p-5 bg-gradient-to-br" :class="p.tint">
|
||||
<div class="text-3xl">{{ p.icon }}</div>
|
||||
<div class="mt-3 text-base font-bold text-[color:var(--fg)]">{{ p.title }}</div>
|
||||
<div class="mt-1.5 text-[13px] leading-relaxed text-[color:var(--fg-3)]">{{ p.desc }}</div>
|
||||
<div v-for="p in powers" :key="p.title" class="power-card p-5">
|
||||
<div class="power-ico" :style="{ background: p.tint }">
|
||||
<svg v-if="p.icon === 't2i'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5h16v14H4z"/><path d="M4 15l4.5-4.5 3 3L15 10l5 5"/><circle cx="9" cy="9" r="1.3"/></svg>
|
||||
<svg v-else-if="p.icon === 'i2i'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7h11v11H3z"/><path d="M10 4h11v11"/></svg>
|
||||
<svg v-else-if="p.icon === 't2v'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h13v12H3z"/><path d="M16 10l5-3v10l-5-3"/></svg>
|
||||
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5h16v14H4z"/><path d="M9 9l6 3-6 3z"/></svg>
|
||||
</div>
|
||||
<div class="mt-3 text-base font-extrabold text-[color:var(--fg)]">{{ p.title }}</div>
|
||||
<div class="mt-1.5 text-[13px] font-medium leading-relaxed text-[color:var(--fg-3)]">{{ p.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== contact ===== -->
|
||||
<section>
|
||||
<div class="anime-eyebrow text-sky-500 dark:text-sky-300">联系我们</div>
|
||||
<h2 class="mt-3 text-2xl md:text-3xl font-extrabold tracking-tight text-[color:var(--fg)]">有问题?来找我们玩~</h2>
|
||||
<div class="anime-eyebrow text-sky-600">联系我们</div>
|
||||
<h2 class="mt-3 text-2xl md:text-3xl font-black tracking-tight text-[color:var(--fg)]">有问题?来找我们玩~</h2>
|
||||
<p class="text-[color:var(--fg-3)] mt-2 mb-6">合作、反馈,或想加入交流群,随时招呼。</p>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-3xl">
|
||||
<a v-if="contact.shop" :href="contact.shop" target="_blank" rel="noopener"
|
||||
class="group anime-card flex items-center justify-between gap-3 p-5">
|
||||
<div>
|
||||
<div class="sticker">🛍️ 商店</div>
|
||||
<div class="text-base font-semibold text-[color:var(--fg)] mt-2 group-hover:text-fuchsia-400 transition-colors">前往商店</div>
|
||||
<div class="sticker">商店</div>
|
||||
<div class="text-base font-semibold text-[color:var(--fg)] mt-2 group-hover:text-[var(--m-orange)] transition-colors">前往商店</div>
|
||||
</div>
|
||||
<span class="text-[color:var(--fg-faint)] group-hover:translate-x-1 transition-transform">→</span>
|
||||
</a>
|
||||
|
||||
<a v-if="contact.email" :href="`mailto:${contact.email}`"
|
||||
class="group anime-card p-5">
|
||||
<div class="sticker">✉️ 邮箱</div>
|
||||
<div class="text-base font-semibold text-[color:var(--fg)] mt-2 break-all group-hover:text-sky-400 transition-colors">{{ contact.email }}</div>
|
||||
<div class="sticker">邮箱</div>
|
||||
<div class="text-base font-semibold text-[color:var(--fg)] mt-2 break-all group-hover:text-sky-600 transition-colors">{{ contact.email }}</div>
|
||||
</a>
|
||||
|
||||
<a v-if="contact.qq && contact.qq_link" :href="contact.qq_link" target="_blank" rel="noopener"
|
||||
class="group anime-card flex items-center justify-between gap-3 p-5">
|
||||
<div>
|
||||
<div class="sticker">💬 QQ</div>
|
||||
<div class="text-base font-semibold text-[color:var(--fg)] mt-2 tabular-nums break-all group-hover:text-sky-400 transition-colors">{{ contact.qq }}</div>
|
||||
<div class="sticker">QQ</div>
|
||||
<div class="text-base font-semibold text-[color:var(--fg)] mt-2 tabular-nums break-all group-hover:text-sky-600 transition-colors">{{ contact.qq }}</div>
|
||||
</div>
|
||||
<span class="text-[color:var(--fg-faint)] group-hover:translate-x-1 transition-transform">→</span>
|
||||
</a>
|
||||
<div v-else-if="contact.qq" class="anime-card p-5">
|
||||
<div class="sticker">💬 QQ</div>
|
||||
<div class="sticker">QQ</div>
|
||||
<div class="text-base font-semibold text-[color:var(--fg)] mt-2 tabular-nums break-all">{{ contact.qq }}</div>
|
||||
</div>
|
||||
|
||||
<a v-if="contact.qq_group && contact.qq_group_link" :href="contact.qq_group_link" target="_blank" rel="noopener"
|
||||
class="group anime-card flex items-center justify-between gap-3 p-5">
|
||||
<div>
|
||||
<div class="sticker">🎀 QQ 群</div>
|
||||
<div class="text-base font-semibold text-[color:var(--fg)] mt-2 tabular-nums break-all group-hover:text-emerald-400 transition-colors">{{ contact.qq_group }}</div>
|
||||
<div class="sticker">QQ 群</div>
|
||||
<div class="text-base font-semibold text-[color:var(--fg)] mt-2 tabular-nums break-all group-hover:text-emerald-600 transition-colors">{{ contact.qq_group }}</div>
|
||||
</div>
|
||||
<span class="text-[color:var(--fg-faint)] group-hover:translate-x-1 transition-transform">→</span>
|
||||
</a>
|
||||
<div v-else-if="contact.qq_group" class="anime-card p-5">
|
||||
<div class="sticker">🎀 QQ 群</div>
|
||||
<div class="sticker">QQ 群</div>
|
||||
<div class="text-base font-semibold text-[color:var(--fg)] mt-2 tabular-nums break-all">{{ contact.qq_group }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,3 +112,59 @@ const powers = [
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Floating glass tile cluster — a loose collage of rotated frosted squares. */
|
||||
.ab-cluster {
|
||||
position: relative;
|
||||
min-height: 240px;
|
||||
}
|
||||
.ab-tile {
|
||||
position: absolute;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 5.5rem;
|
||||
height: 5.5rem;
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 1.2rem;
|
||||
background: var(--surface);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow: var(--glass-shadow-lg);
|
||||
color: var(--m-orange);
|
||||
}
|
||||
.ab-tile svg { width: 2.3rem; height: 2.3rem; }
|
||||
.ab-1 { left: 12%; top: 6%; transform: rotate(-9deg); animation: memphis-float 6s ease-in-out infinite; }
|
||||
.ab-2 { right: 14%; top: 0; color: var(--m-orange-2); transform: rotate(7deg); animation: memphis-float 7s ease-in-out infinite reverse; }
|
||||
.ab-3 { left: 26%; bottom: 4%; color: var(--m-pink); transform: rotate(5deg); animation: memphis-bounce 4.5s ease-in-out infinite; }
|
||||
.ab-4 { right: 8%; bottom: 14%; color: var(--m-mint); transform: rotate(-6deg); animation: memphis-float 5.5s ease-in-out infinite; }
|
||||
.ab-star {
|
||||
position: absolute;
|
||||
left: 50%; top: 44%;
|
||||
font-size: 2.6rem;
|
||||
color: var(--m-orange);
|
||||
animation: memphis-wiggle 3.5s ease-in-out infinite;
|
||||
}
|
||||
.power-card {
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 1.1rem;
|
||||
background: var(--surface);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow: var(--glass-shadow);
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
.power-card:hover { transform: translateY(-3px); border-color: rgb(124 92 252 / 0.45); box-shadow: var(--glass-shadow-lg); }
|
||||
.power-ico {
|
||||
width: 2.6rem;
|
||||
height: 2.6rem;
|
||||
border-radius: 0.75rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
box-shadow: 0 6px 16px rgb(124 92 252 / 0.28);
|
||||
}
|
||||
.power-ico svg { width: 1.3rem; height: 1.3rem; }
|
||||
@media (max-width: 768px) { .ab-cluster { display: none; } }
|
||||
@media (prefers-reduced-motion: reduce) { .ab-tile, .ab-star { animation: none; } }
|
||||
</style>
|
||||
|
||||
@@ -1,759 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { api, jsonBody } from '../api'
|
||||
import { fmtTs, fmtIso, fmtDate, fmtClock } from '../utils/format'
|
||||
import ImportModal from '../components/ImportModal.vue'
|
||||
import UpstreamModal from '../components/UpstreamModal.vue'
|
||||
import AccountEditModal from '../components/AccountEditModal.vue'
|
||||
import AccountTestModal from '../components/AccountTestModal.vue'
|
||||
import Icon from '../components/Icon.vue'
|
||||
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
const quotaStatus = ref('')
|
||||
const showImport = ref(false)
|
||||
const showUpstream = ref(false)
|
||||
const editingUpstream = ref(null)
|
||||
function editUpstream(a) { editingUpstream.value = a; showUpstream.value = true }
|
||||
const editingAccount = ref(null)
|
||||
function editAccount(a) { editingAccount.value = a }
|
||||
const testingAccount = ref(null)
|
||||
function testAccount(a) { testingAccount.value = a }
|
||||
// 预加载模型列表,让「生图测试」弹窗即开即用(不显示加载中)。
|
||||
const allModels = ref([])
|
||||
async function loadModelList() {
|
||||
const r = await api('/managed-models')
|
||||
allModels.value = r.data?.data || []
|
||||
}
|
||||
// Reflect the saved values in the table without a full reload.
|
||||
function applyEdit(payload) {
|
||||
const row = editingAccount.value
|
||||
if (!row) return
|
||||
if (payload.weight != null) row.weight = payload.weight
|
||||
if (payload.concurrency != null) row.concurrency = payload.concurrency
|
||||
}
|
||||
|
||||
const typeFilter = ref('') // '' | 'openai' | 'adobe' | 'runway' | 'leonardo'
|
||||
const statusFilter = ref('') // '' | 'active' | 'quota' | 'disabled'
|
||||
const planFilter = ref('') // '' | 'vip' | 'free' | 'sub' | 'master'
|
||||
const search = ref('')
|
||||
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
// Typing a search term must jump back to page 1 and re-query the server —
|
||||
// search is cross-page now (server-side).
|
||||
let searchTimer = null
|
||||
watch(search, () => {
|
||||
clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => { resetAndLoad() }, 300)
|
||||
})
|
||||
|
||||
const EMPTY_TYPE = { n: 0, ok: 0, dead: 0, quota: 0 }
|
||||
// 每个类型的 成功/失败/限额 三个数 — 由后端对全量账号统计(与筛选/分页无关)。
|
||||
const stats = ref({
|
||||
total: 0, dead_total: 0,
|
||||
openai: { ...EMPTY_TYPE }, adobe: { ...EMPTY_TYPE }, runway: { ...EMPTY_TYPE },
|
||||
leonardo: { ...EMPTY_TYPE }, krea: { ...EMPTY_TYPE }, imagine: { ...EMPTY_TYPE },
|
||||
grok: { ...EMPTY_TYPE }, creativefabrica: { ...EMPTY_TYPE },
|
||||
})
|
||||
|
||||
// 异常账号 = 已失效(401)被锁定的号(红色锁定行)。用于「一键删除异常账号」。
|
||||
const deadCount = computed(() => stats.value.dead_total || 0)
|
||||
|
||||
function typePill(t) {
|
||||
return {
|
||||
adobe: 'bg-rose-500/10 text-rose-300 ring-rose-400/30',
|
||||
openai: 'bg-emerald-500/10 text-emerald-300 ring-emerald-400/30',
|
||||
runway: 'bg-violet-500/10 text-violet-300 ring-violet-400/30',
|
||||
leonardo: 'bg-amber-500/10 text-amber-300 ring-amber-400/30',
|
||||
krea: 'bg-sky-500/10 text-sky-300 ring-sky-400/30',
|
||||
imagine: 'bg-teal-500/10 text-teal-300 ring-teal-400/30',
|
||||
creativefabrica: 'bg-fuchsia-500/10 text-fuchsia-300 ring-fuchsia-400/30',
|
||||
}[t] || 'bg-white/[0.06] text-white/70 ring-white/15'
|
||||
}
|
||||
// 与后端 accountConcurrency 保持一致:adobe 普号固定 1 并发,会员号取配置值
|
||||
// (未配置时默认 5);custom 上游取配置值 (默认 1);grok 固定 10,其余 1。
|
||||
function acctConcurrency(a) {
|
||||
if (a.type === 'adobe') return a.plan === 'free' ? 1 : (Number(a.concurrency) || 5)
|
||||
if (a.type === 'custom') return Number(a.concurrency) || 1
|
||||
return a.type === 'grok' ? 10 : 1
|
||||
}
|
||||
|
||||
const STATUS_LABEL = { active: '正常', quota: '额度耗尽', disabled: '已禁用', pending: '检测中' }
|
||||
|
||||
// Server-side pagination: rows IS the current page, already filtered/sorted
|
||||
// by the backend. total = server-side filtered count.
|
||||
const filtered = computed(() => rows.value)
|
||||
const pagedItems = computed(() => rows.value)
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||
function goPage(n) {
|
||||
const target = Math.max(1, Math.min(totalPages.value, n))
|
||||
if (target !== page.value) page.value = target
|
||||
}
|
||||
function setFilter(fn) { fn(); resetAndLoad() }
|
||||
function resetAndLoad() {
|
||||
if (page.value !== 1) page.value = 1 // the page watcher triggers the load
|
||||
else loadAccounts()
|
||||
}
|
||||
const pageNumbers = computed(() => {
|
||||
const n = totalPages.value
|
||||
const cur = page.value
|
||||
if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1)
|
||||
const want = new Set([1, n, cur - 1, cur, cur + 1])
|
||||
if (cur <= 3) { want.add(2); want.add(3); want.add(4) }
|
||||
if (cur >= n - 2) { want.add(n - 1); want.add(n - 2); want.add(n - 3) }
|
||||
const list = [...want].filter((x) => x >= 1 && x <= n).sort((a, b) => a - b)
|
||||
const out = []
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (i > 0 && list[i] - list[i - 1] > 1) out.push(null)
|
||||
out.push(list[i])
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
let pendingTimer = null
|
||||
|
||||
function buildQs() {
|
||||
const qs = new URLSearchParams({
|
||||
limit: String(pageSize.value),
|
||||
offset: String((page.value - 1) * pageSize.value),
|
||||
})
|
||||
if (typeFilter.value) qs.set('type', typeFilter.value)
|
||||
if (statusFilter.value) qs.set('status', statusFilter.value)
|
||||
if (planFilter.value && typeFilter.value === 'adobe') qs.set('plan', planFilter.value)
|
||||
if (search.value.trim()) qs.set('q', search.value.trim())
|
||||
return qs.toString()
|
||||
}
|
||||
|
||||
async function fetchAccounts() {
|
||||
const r = await api('/accounts?' + buildQs())
|
||||
rows.value = r.data?.data || []
|
||||
total.value = Number(r.data?.total ?? rows.value.length)
|
||||
if (r.data?.stats) stats.value = r.data.stats
|
||||
}
|
||||
|
||||
async function loadAccounts() {
|
||||
loading.value = true
|
||||
quotaStatus.value = ''
|
||||
await fetchAccounts()
|
||||
loading.value = false
|
||||
if (rows.value.length) reconcile()
|
||||
schedulePendingPoll()
|
||||
}
|
||||
|
||||
// While any imported account is still being checked server-side, re-fetch the
|
||||
// list so it flips pending → active/dead on its own (no manual refresh).
|
||||
function schedulePendingPoll() {
|
||||
if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null }
|
||||
if (!rows.value.some((r) => r.pending)) return
|
||||
pendingTimer = setTimeout(async () => {
|
||||
await fetchAccounts()
|
||||
schedulePendingPoll()
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
// Background reconciliation: openai → live quota; adobe → reset_after;
|
||||
// adobe without email → fetch email. Only ACTIVE accounts — pending ones are
|
||||
// handled by the import worker, dead/disabled ones need no re-check.
|
||||
//
|
||||
// Scope: ONLY the accounts visible on the current page. Probing all 100+ rows on
|
||||
// every open floods the backend; the user only ever sees ~20 at a time, so we
|
||||
// re-check just those and re-run when the page (or filters) change. New imports
|
||||
// are hydrated server-side by the import worker and surfaced via the pending
|
||||
// poll reading the store — they don't need a frontend probe.
|
||||
let reconcileToken = 0
|
||||
async function reconcile() {
|
||||
const myToken = ++reconcileToken // supersede any in-flight run (fast page flips)
|
||||
const visible = pagedItems.value
|
||||
// NEW accounts (still pending the import worker's server-side check) are never
|
||||
// probed here — the pending poll just reads the store until the worker writes
|
||||
// their quota/email. OLD accounts (active) get a real live /quota probe for
|
||||
// up-to-date remaining + refresh time.
|
||||
const quotaRows = visible.filter((r) => !r.pending && r.status === 'active' && (r.type === 'openai' || r.type === 'adobe' || r.type === 'runway' || r.type === 'leonardo' || r.type === 'krea' || r.type === 'imagine' || r.type === 'grok'))
|
||||
const adobeNeedEmail = visible.filter((r) => !r.pending && r.type === 'adobe' && !r.email)
|
||||
const total = quotaRows.length + adobeNeedEmail.length
|
||||
if (total === 0) { quotaStatus.value = ''; return }
|
||||
|
||||
let done = 0, updates = 0
|
||||
quotaStatus.value = `后台校对… 0/${total}`
|
||||
const bump = () => {
|
||||
done++
|
||||
if (myToken !== reconcileToken) return // a newer page-flip superseded us
|
||||
quotaStatus.value = `后台校对… ${done}/${total}${updates ? ` · 更新 ${updates}` : ''}`
|
||||
}
|
||||
|
||||
// Build thunks (NOT immediately-invoked) so the pool controls how many run at
|
||||
// once. Each /accounts/.../quota probe is a *synchronous* backend call to
|
||||
// OpenAI/Adobe. We only probe the visible page (≤ pageSize rows), so the
|
||||
// limit below is effectively bounded by that — no full-list flood.
|
||||
const jobs = []
|
||||
for (const row of quotaRows) {
|
||||
jobs.push(async () => {
|
||||
const result = await fetchOneQuota(row.pool, row.id)
|
||||
if (result && result.auth_failed) {
|
||||
// backend auto-disabled this dead (401) token — reflect it immediately
|
||||
row.status = result.status || 'disabled'
|
||||
row.dead = true
|
||||
row.remaining = null
|
||||
row._unknown = true
|
||||
updates++
|
||||
} else if (result && result.unchanged === false) {
|
||||
if (row.type === 'adobe') {
|
||||
row.reset_after = result.reset_after
|
||||
applyQuota(row, result)
|
||||
} else applyQuota(row, result)
|
||||
updates++
|
||||
}
|
||||
bump()
|
||||
})
|
||||
}
|
||||
for (const row of adobeNeedEmail) {
|
||||
jobs.push(async () => {
|
||||
const result = await fetchOneEmail(row.pool, row.id)
|
||||
if (result && result.email && (!row.email || row.email === '—')) {
|
||||
row.email = result.email
|
||||
updates++
|
||||
}
|
||||
bump()
|
||||
})
|
||||
}
|
||||
await runWithLimit(jobs, Infinity) // no JS-side cap — fire all visible-page probes at once (browser still limits ~6 conns/origin)
|
||||
// clear the indicator when done — but only if we're still the current run
|
||||
// (a page flip mid-reconcile starts a fresh one that owns the indicator).
|
||||
if (myToken === reconcileToken) quotaStatus.value = ''
|
||||
}
|
||||
|
||||
// Flipping pages re-queries the server for the new page; loadAccounts() then
|
||||
// reconciles just the freshly visible rows. Filter buttons go through
|
||||
// setFilter → resetAndLoad, so everything funnels into loadAccounts.
|
||||
watch(page, () => { loadAccounts() })
|
||||
|
||||
// Bounded-concurrency runner: keeps at most `limit` thunks in flight at once.
|
||||
async function runWithLimit(thunks, limit) {
|
||||
let next = 0
|
||||
const workers = Array.from({ length: Math.min(limit, thunks.length) }, async () => {
|
||||
while (next < thunks.length) {
|
||||
const idx = next++
|
||||
await thunks[idx]()
|
||||
}
|
||||
})
|
||||
await Promise.all(workers)
|
||||
}
|
||||
|
||||
async function fetchOneQuota(pool, id) {
|
||||
try { return (await api(`/accounts/${pool}/${id}/quota`)).data || {} }
|
||||
catch (e) { return { error: String(e) } }
|
||||
}
|
||||
async function fetchOneEmail(pool, id) {
|
||||
try { return (await api(`/accounts/${pool}/${id}/email`)).data || {} }
|
||||
catch (e) { return { error: String(e) } }
|
||||
}
|
||||
|
||||
function applyQuota(row, result) {
|
||||
// A transient probe error (e.g. connection reset when OpenAI is unreachable
|
||||
// without a proxy) must NOT blank the cached number — keep the last-known
|
||||
// value so a network blip doesn't turn the whole column into "—".
|
||||
if (result.error) { row._quotaError = result.error; return }
|
||||
row._quotaError = null
|
||||
if (result.unknown && result.remaining === null) { row.remaining = null; row._unknown = true; return }
|
||||
row._unknown = false
|
||||
row.remaining = result.remaining
|
||||
if (result.image_remaining !== undefined) row.image_remaining = result.image_remaining
|
||||
if (result.video_remaining !== undefined) row.video_remaining = result.video_remaining
|
||||
row.reset_after = result.reset_after
|
||||
// 会员身份随额度一起回写:改过会员/掉了会员的号,查一次额度徽章就跟着更新。
|
||||
if (result.plan !== undefined) row.plan = result.plan
|
||||
if (result.sub_account !== undefined) row.sub_account = result.sub_account
|
||||
}
|
||||
|
||||
async function toggleAccountStatus(pool, id, current) {
|
||||
const row = rows.value.find((r) => r.pool === pool && r.id === id)
|
||||
const next = current === 'active' ? 'disabled' : 'active'
|
||||
// Optimistic: flip the switch instantly so the UI never waits on the network.
|
||||
// The PATCH itself is a cheap in-memory update server-side; the old 5s lag came
|
||||
// from the follow-up loadAccounts() → reconcile() probing every account's quota.
|
||||
if (row) row.status = next
|
||||
try {
|
||||
const r = await api(`/tokens/${pool}/${id}`, jsonBody('PATCH', { status: next }))
|
||||
if (!r.ok && row) row.status = current // revert on server rejection
|
||||
} catch (e) {
|
||||
if (row) row.status = current // revert on network error
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAccount(pool, id) {
|
||||
if (!confirm(`确认删除 ${pool} / ${id}?`)) return
|
||||
await api(`/tokens/${pool}/${id}`, { method: 'DELETE' })
|
||||
loadAccounts()
|
||||
}
|
||||
|
||||
// 一键删除全部异常(已失效/红色锁定)账号。先向服务端要全量 dead 列表(跨页),再逐个删除。
|
||||
async function deleteDeadAccounts() {
|
||||
if (!deadCount.value) return
|
||||
if (!confirm(`确认删除全部 ${deadCount.value} 个异常(已失效)账号?此操作不可撤销。`)) return
|
||||
const r = await api('/accounts?dead=1&limit=0')
|
||||
const dead = r.data?.data || []
|
||||
if (!dead.length) return
|
||||
await Promise.all(dead.map((a) => api(`/tokens/${a.pool}/${a.id}`, { method: 'DELETE' })))
|
||||
loadAccounts()
|
||||
}
|
||||
|
||||
// ===== 多选删除 =====
|
||||
const selected = ref(new Set())
|
||||
function toggleSelect(id) {
|
||||
const s = new Set(selected.value)
|
||||
s.has(id) ? s.delete(id) : s.add(id)
|
||||
selected.value = s
|
||||
}
|
||||
// Header checkbox selects/deselects the CURRENT PAGE only.
|
||||
const allSelected = computed(() =>
|
||||
pagedItems.value.length > 0 && pagedItems.value.every((a) => selected.value.has(a.id)))
|
||||
function toggleSelectAll() {
|
||||
const s = new Set(selected.value)
|
||||
if (allSelected.value) pagedItems.value.forEach((a) => s.delete(a.id))
|
||||
else pagedItems.value.forEach((a) => s.add(a.id))
|
||||
selected.value = s
|
||||
}
|
||||
async function deleteSelected() {
|
||||
const ids = [...selected.value]
|
||||
if (!ids.length) return
|
||||
if (!confirm(`确认删除选中的 ${ids.length} 个账号?此操作不可撤销。`)) return
|
||||
const r = await api('/tokens/delete-bulk', jsonBody('POST', { ids }))
|
||||
if (r.ok) {
|
||||
selected.value = new Set()
|
||||
loadAccounts()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { loadAccounts(); loadModelList() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<!-- KPI strip — 每个类型显示 成功/失败/限额 三个数(绿/红/琥珀) -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 xl:grid-cols-7 gap-3">
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-white/45">账号总数</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.total }}</div>
|
||||
<div class="text-[10px] text-white/35 mt-0.5">成功/失败/限额</div>
|
||||
</div>
|
||||
<div v-for="t in [['openai','OpenAI','text-emerald-300/80'],['adobe','Adobe','text-rose-300/80'],['runway','Runway','text-violet-300/80'],['leonardo','Leonardo','text-amber-300/80'],['krea','Krea','text-sky-300/80'],['imagine','Imagine','text-teal-300/80'],['grok','Grok','text-slate-300/80'],['creativefabrica','Creative Fabrica','text-fuchsia-300/80']]"
|
||||
:key="t[0]" class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider" :class="t[2]">{{ t[1] }}</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums">
|
||||
<span class="text-emerald-300">{{ stats[t[0]].ok }}</span><span class="text-white/30">/</span><span class="text-rose-300">{{ stats[t[0]].dead }}</span><span class="text-white/30">/</span><span class="text-amber-300">{{ stats[t[0]].quota }}</span>
|
||||
</div>
|
||||
<div class="text-[10px] text-white/35 mt-0.5">共 {{ stats[t[0]].n }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="card p-3 flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="setFilter(() => typeFilter = '')" class="fp" :class="typeFilter === '' && 'fp-on'">全部类型</button>
|
||||
<button @click="setFilter(() => typeFilter = 'openai')" class="fp" :class="typeFilter === 'openai' && 'fp-emerald'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>OpenAI
|
||||
</button>
|
||||
<button @click="setFilter(() => typeFilter = 'adobe')" class="fp" :class="typeFilter === 'adobe' && 'fp-rose'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>Adobe
|
||||
</button>
|
||||
<button @click="setFilter(() => typeFilter = 'runway')" class="fp" :class="typeFilter === 'runway' && 'fp-violet'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-violet-400"></span>Runway
|
||||
</button>
|
||||
<button @click="setFilter(() => typeFilter = 'leonardo')" class="fp" :class="typeFilter === 'leonardo' && 'fp-amber'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>Leonardo
|
||||
</button>
|
||||
<button @click="setFilter(() => typeFilter = 'krea')" class="fp" :class="typeFilter === 'krea' && 'fp-sky'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-sky-400"></span>Krea
|
||||
</button>
|
||||
<button @click="setFilter(() => typeFilter = 'imagine')" class="fp" :class="typeFilter === 'imagine' && 'fp-teal'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-teal-400"></span>Imagine
|
||||
</button>
|
||||
<button @click="setFilter(() => typeFilter = 'grok')" class="fp" :class="typeFilter === 'grok' && 'fp-on'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-slate-400"></span>Grok
|
||||
</button>
|
||||
<button @click="setFilter(() => typeFilter = 'creativefabrica')" class="fp" :class="typeFilter === 'creativefabrica' && 'fp-on'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-fuchsia-400"></span>Creative Fabrica
|
||||
</button>
|
||||
</div>
|
||||
<div class="w-px h-5 bg-white/10"></div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="setFilter(() => statusFilter = '')" class="fp" :class="statusFilter === '' && 'fp-on'">所有状态</button>
|
||||
<button @click="setFilter(() => statusFilter = 'active')" class="fp" :class="statusFilter === 'active' && 'fp-emerald'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>正常
|
||||
</button>
|
||||
<button @click="setFilter(() => statusFilter = 'quota')" class="fp" :class="statusFilter === 'quota' && 'fp-amber'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>额度耗尽
|
||||
</button>
|
||||
<button @click="setFilter(() => statusFilter = 'disabled')" class="fp" :class="statusFilter === 'disabled' && 'fp-rose'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>已禁用
|
||||
</button>
|
||||
</div>
|
||||
<template v-if="typeFilter === 'adobe'">
|
||||
<div class="w-px h-5 bg-white/10"></div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="setFilter(() => planFilter = '')" class="fp" :class="planFilter === '' && 'fp-on'">全部账号</button>
|
||||
<button @click="setFilter(() => planFilter = 'free')" class="fp" :class="planFilter === 'free' && 'fp-slate'">普号</button>
|
||||
<button @click="setFilter(() => planFilter = 'sub')" class="fp" :class="planFilter === 'sub' && 'fp-sky'">子号</button>
|
||||
<button @click="setFilter(() => planFilter = 'master')" class="fp" :class="planFilter === 'master' && 'fp-purple'">会员号</button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 邮箱 / ID / 类型…" />
|
||||
</div>
|
||||
<button v-if="selected.size" @click="deleteSelected" class="btn-soft danger" title="删除选中的账号">
|
||||
<Icon name="trash" class="w-3.5 h-3.5" /> 删除选中 ({{ selected.size }})
|
||||
</button>
|
||||
<button v-if="deadCount" @click="deleteDeadAccounts" class="btn-soft danger" title="删除全部已失效(401)账号">
|
||||
<Icon name="trash" class="w-3.5 h-3.5" /> 删除异常账号 ({{ deadCount }})
|
||||
</button>
|
||||
<button @click="loadAccounts" class="btn-soft">
|
||||
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
|
||||
</button>
|
||||
<button @click="showImport = true" class="btn-primary">
|
||||
<Icon name="plus" class="w-3.5 h-3.5" /> 导入账号
|
||||
</button>
|
||||
<button @click="editingUpstream = null; showUpstream = true" class="btn-soft">
|
||||
<Icon name="plus" class="w-3.5 h-3.5" /> 添加上游
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card overflow-x-auto">
|
||||
<div v-if="loading && !rows.length" class="text-center text-sm text-white/40 py-20">加载中…</div>
|
||||
<div v-else-if="!filtered.length" class="flex flex-col items-center gap-3 text-white/40 py-20">
|
||||
<span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center">
|
||||
<Icon name="accounts" class="w-6 h-6" />
|
||||
</span>
|
||||
<span class="text-sm">{{ stats.total ? '没有匹配的账号' : '还没有账号' }}</span>
|
||||
<button v-if="!stats.total" @click="showImport = true" class="btn-soft mt-1">导入第一个</button>
|
||||
</div>
|
||||
|
||||
<table v-else class="w-full text-sm table-fixed min-w-[1080px]">
|
||||
<colgroup>
|
||||
<col class="w-9" /> <!-- select -->
|
||||
<col /> <!-- identity (flex) -->
|
||||
<col class="w-20" /> <!-- type -->
|
||||
<col class="w-24" /> <!-- remaining -->
|
||||
<col class="w-16" /> <!-- weight -->
|
||||
<col class="w-16" /> <!-- concurrency -->
|
||||
<col class="w-32" /> <!-- reset -->
|
||||
<col class="w-28" /> <!-- created -->
|
||||
<col class="w-28" /> <!-- last used -->
|
||||
<col class="w-40" /> <!-- inflight/success/fail -->
|
||||
<col class="w-16" /> <!-- status switch -->
|
||||
<col class="w-32" /> <!-- actions -->
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]">
|
||||
<th class="text-center px-3 py-3 font-medium">
|
||||
<input type="checkbox" :checked="allSelected" @change="toggleSelectAll"
|
||||
class="chk" title="全选" />
|
||||
</th>
|
||||
<th class="text-left px-5 py-3 font-medium">账户</th>
|
||||
<th class="text-left px-3 py-3 font-medium">类型</th>
|
||||
<th class="text-right px-3 py-3 font-medium">额度</th>
|
||||
<th class="text-center px-3 py-3 font-medium">权重</th>
|
||||
<th class="text-center px-3 py-3 font-medium">并发</th>
|
||||
<th class="text-left px-3 py-3 font-medium">恢复时间</th>
|
||||
<th class="text-left px-3 py-3 font-medium">创建时间</th>
|
||||
<th class="text-left px-3 py-3 font-medium">最后使用</th>
|
||||
<th class="text-center px-3 py-3 font-medium">在途 / 成功 / 失败</th>
|
||||
<th class="text-left px-3 py-3 font-medium">状态</th>
|
||||
<th class="text-right px-3 py-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="a in pagedItems" :key="a.pool + '/' + a.id"
|
||||
class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors"
|
||||
:class="a.dead && 'dead-row'">
|
||||
<!-- select -->
|
||||
<td class="px-3 py-3.5 align-middle text-center">
|
||||
<input type="checkbox" :checked="selected.has(a.id)" @change="toggleSelect(a.id)" @click.stop
|
||||
class="chk" />
|
||||
</td>
|
||||
<!-- identity -->
|
||||
<td class="px-5 py-3.5 align-middle">
|
||||
<!-- email + per-kind quota markers on one line. Both-limited shows as
|
||||
额度耗尽 in the status column, so here we only surface the single
|
||||
case. -->
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-sm text-white/90 truncate" :title="a.email || '-'">{{ a.email || '-' }}</span>
|
||||
<!-- 身份互斥:子号必然是会员号,所以 子号 > 母号 > 普号 只取一个。
|
||||
plan 未知(还没查过额度)时不猜,先不显示。 -->
|
||||
<span v-if="a.sub_account" class="acct-tag acct-tag-sub"
|
||||
:title="'子号 · 会员 (剩余 ' + (a.remaining ?? '—') + ' 积分)'">子号</span>
|
||||
<span v-else-if="a.plan && a.plan !== 'free'" class="acct-tag acct-tag-master"
|
||||
:title="'会员号 (' + a.plan + ')'">会员号</span>
|
||||
<span v-else-if="a.plan === 'free'" class="acct-tag acct-tag-free"
|
||||
title="普号 (无会员)">普号</span>
|
||||
<span v-if="a.image_limited && a.status !== 'quota' && a.plan !== 'free'" class="acct-tag acct-tag-limit"
|
||||
title="图片额度耗尽,仅视频可用">图片限额</span>
|
||||
<span v-if="a.video_limited && a.status !== 'quota' && a.plan !== 'free'" class="acct-tag acct-tag-limit"
|
||||
title="视频额度耗尽,仅图片可用">视频限额</span>
|
||||
</div>
|
||||
</td>
|
||||
<!-- type -->
|
||||
<td class="px-3 py-3.5 align-middle">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1 whitespace-nowrap"
|
||||
:class="typePill(a.type)">{{ a.type }}</span>
|
||||
</td>
|
||||
<!-- remaining -->
|
||||
<td class="px-3 py-3.5 align-middle text-right text-sm tabular-nums whitespace-nowrap">
|
||||
<!-- quota column: 数字 / — (never "未知"/"失败"/"检测中") -->
|
||||
<!-- remaining === -1 is the provider "unlimited" sentinel → show — not a scary red -1 -->
|
||||
<!-- grok 额度是本地写死的两份计数:图 / 视频,用完即判死,没有恢复时间 -->
|
||||
<span v-if="a.type === 'grok' && a.image_remaining != null"
|
||||
class="font-mono font-semibold" title="图片剩余 / 视频剩余">
|
||||
<span :class="a.image_remaining > 0 ? 'text-emerald-300' : 'text-rose-300'">{{ a.image_remaining }}</span>
|
||||
<span class="text-white/20">/</span>
|
||||
<span :class="a.video_remaining > 0 ? 'text-emerald-300' : 'text-rose-300'">{{ a.video_remaining }}</span>
|
||||
</span>
|
||||
<span v-else-if="(a.type === 'openai' || a.type === 'adobe' || a.type === 'runway' || a.type === 'leonardo' || a.type === 'krea' || a.type === 'imagine' || a.type === 'creativefabrica') && a.remaining != null && a.remaining !== -1"
|
||||
class="font-mono font-semibold"
|
||||
:class="a.remaining > 0 ? 'text-emerald-300' : 'text-rose-300'">{{ a.remaining }}</span>
|
||||
<span v-else class="text-white/25" :title="a._quotaError || ''">—</span>
|
||||
</td>
|
||||
<!-- weight (edit via modal) -->
|
||||
<td class="px-3 py-3.5 align-middle text-center whitespace-nowrap text-xs tabular-nums text-white/70">
|
||||
{{ a.weight ?? 0 }}
|
||||
</td>
|
||||
<!-- concurrency (custom = configured value; others = system fixed) -->
|
||||
<td class="px-3 py-3.5 align-middle text-center whitespace-nowrap text-xs tabular-nums">
|
||||
<span v-if="a.type === 'custom'" class="text-white/70">{{ acctConcurrency(a) }}</span>
|
||||
<span v-else class="text-white/25" title="系统固定">{{ acctConcurrency(a) }}</span>
|
||||
</td>
|
||||
<!-- reset_after -->
|
||||
<td class="px-3 py-3.5 align-middle text-xs whitespace-nowrap">
|
||||
<div v-if="a.reset_after" class="leading-tight" :title="fmtIso(a.reset_after)">
|
||||
<div class="text-white/65 tabular-nums">{{ fmtDate(a.reset_after) }}</div>
|
||||
<div class="text-white/35 tabular-nums">{{ fmtClock(a.reset_after) }}</div>
|
||||
</div>
|
||||
<span v-else class="text-white/25">—</span>
|
||||
</td>
|
||||
<!-- created_at -->
|
||||
<td class="px-3 py-3.5 align-middle text-xs whitespace-nowrap">
|
||||
<div class="leading-tight" :title="fmtTs(a.created_at)">
|
||||
<div class="text-white/65 tabular-nums">{{ fmtDate(a.created_at) }}</div>
|
||||
<div class="text-white/35 tabular-nums">{{ fmtClock(a.created_at) }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<!-- last_used_at -->
|
||||
<td class="px-3 py-3.5 align-middle text-xs whitespace-nowrap">
|
||||
<div v-if="a.last_used_at" class="leading-tight" :title="fmtTs(a.last_used_at)">
|
||||
<div class="text-white/65 tabular-nums">{{ fmtDate(a.last_used_at) }}</div>
|
||||
<div class="text-white/35 tabular-nums">{{ fmtClock(a.last_used_at) }}</div>
|
||||
</div>
|
||||
<span v-else class="text-white/25">从未</span>
|
||||
</td>
|
||||
<!-- inflight / success / fail -->
|
||||
<td class="px-3 py-3.5 align-middle">
|
||||
<div class="flex items-center justify-center gap-1.5 text-xs tabular-nums">
|
||||
<span class="px-1.5 py-0.5 rounded"
|
||||
:class="a.in_flight ? 'bg-indigo-500/15 text-indigo-300 font-semibold' : 'text-white/25'"
|
||||
title="在途">{{ a.in_flight || 0 }}</span>
|
||||
<span class="text-white/20">/</span>
|
||||
<span class="px-1.5 py-0.5 rounded text-emerald-300 font-medium" title="成功">{{ a.success_total || 0 }}</span>
|
||||
<span class="text-white/20">/</span>
|
||||
<span class="px-1.5 py-0.5 rounded"
|
||||
:class="a.fail_total ? 'bg-rose-500/15 text-rose-300 font-medium' : 'text-white/25'"
|
||||
title="失败">{{ a.fail_total || 0 }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<!-- status (switch) -->
|
||||
<td class="px-3 py-3.5 align-middle">
|
||||
<button class="sw"
|
||||
:class="{ 'sw-on': a.status === 'active', 'sw-dead': a.dead, 'sw-pending': a.status === 'pending', 'sw-quota': a.status === 'quota', 'sw-locked': a.dead || a.status === 'pending' || a.status === 'quota' }"
|
||||
:disabled="a.dead || a.status === 'pending' || a.status === 'quota'"
|
||||
:aria-pressed="a.status === 'active'"
|
||||
:title="a.status === 'pending' ? '正在检测额度…(暂不调度)' : (a.dead ? '号已失效(401) · 已锁定(删除后重新导入有效令牌)' : (a.status === 'quota' ? '额度耗尽 · 已锁定,到恢复时间自动解开' : (a.status === 'active' ? '点击禁用' : '点击启用')))"
|
||||
@click="!(a.dead || a.status === 'pending' || a.status === 'quota') && toggleAccountStatus(a.pool, a.id, a.status)">
|
||||
<span class="sw-thumb"></span>
|
||||
</button>
|
||||
</td>
|
||||
<!-- actions -->
|
||||
<td class="px-3 py-3.5 align-middle whitespace-nowrap">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button @click="testAccount(a)" class="act" title="生图测试">
|
||||
<Icon name="spark" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button v-if="a.type !== 'custom'" @click="editAccount(a)" class="act" title="编辑">
|
||||
<Icon name="config" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button v-if="a.type === 'custom'" @click="editUpstream(a)" class="act" title="编辑上游">
|
||||
<Icon name="config" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button @click="deleteAccount(a.pool, a.id)" class="act danger" title="删除">
|
||||
<Icon name="trash" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- pagination -->
|
||||
<div v-if="!loading && totalPages > 1"
|
||||
class="flex items-center justify-between gap-3 border-t border-white/[0.06] px-5 py-3 text-xs text-white/55">
|
||||
<div>
|
||||
<span class="tabular-nums text-white/85">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(total, page * pageSize) }}</span>
|
||||
<span class="ml-1">/ {{ total }} 条</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<template v-for="(n, i) in pageNumbers" :key="i">
|
||||
<span v-if="n === null" class="px-1 text-white/35">…</span>
|
||||
<button v-else @click="goPage(n)" class="pg" :class="page === n && 'pg-on'">{{ n }}</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ImportModal v-if="showImport" @close="showImport = false" @imported="loadAccounts" />
|
||||
<UpstreamModal v-if="showUpstream" :account="editingUpstream" @close="showUpstream = false; editingUpstream = null" @imported="loadAccounts" />
|
||||
<AccountEditModal v-if="editingAccount" :account="editingAccount" @saved="applyEdit" @close="editingAccount = null" />
|
||||
<AccountTestModal v-if="testingAccount" :account="testingAccount" :all-models="allModels" @close="testingAccount = null" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* --- filter pills (mirrors LogsView/UsersView/ModelsView) */
|
||||
.fp {
|
||||
display: inline-flex; align-items: center; gap: 0.35rem;
|
||||
padding: 0.35rem 0.7rem; font-size: 0.72rem;
|
||||
border-radius: 0.55rem;
|
||||
color: rgb(255 255 255 / 0.65);
|
||||
background: rgb(255 255 255 / 0.05);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.06);
|
||||
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.fp:hover { background: rgb(255 255 255 / 0.09); color: white; }
|
||||
.fp-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); box-shadow: none; }
|
||||
.fp-emerald {
|
||||
background: rgb(16 185 129 / 0.22);
|
||||
color: rgb(110 231 183);
|
||||
box-shadow: inset 0 0 0 1px rgb(110 231 183 / 0.45);
|
||||
}
|
||||
.fp-rose {
|
||||
background: rgb(244 63 94 / 0.22);
|
||||
color: rgb(253 164 175);
|
||||
box-shadow: inset 0 0 0 1px rgb(253 164 175 / 0.45);
|
||||
}
|
||||
.fp-amber {
|
||||
background: rgb(245 158 11 / 0.22);
|
||||
color: rgb(253 224 71);
|
||||
box-shadow: inset 0 0 0 1px rgb(253 224 71 / 0.4);
|
||||
}
|
||||
.fp-violet {
|
||||
background: rgb(139 92 246 / 0.22);
|
||||
color: rgb(196 181 253);
|
||||
box-shadow: inset 0 0 0 1px rgb(196 181 253 / 0.45);
|
||||
}
|
||||
.fp-sky {
|
||||
background: rgb(56 189 248 / 0.22);
|
||||
color: rgb(125 211 252);
|
||||
box-shadow: inset 0 0 0 1px rgb(125 211 252 / 0.45);
|
||||
}
|
||||
.fp-teal {
|
||||
background: rgb(20 184 166 / 0.22);
|
||||
color: rgb(94 234 212);
|
||||
box-shadow: inset 0 0 0 1px rgb(94 234 212 / 0.45);
|
||||
}
|
||||
.fp-purple {
|
||||
background: rgb(168 85 247 / 0.22);
|
||||
color: rgb(216 180 254);
|
||||
box-shadow: inset 0 0 0 1px rgb(216 180 254 / 0.45);
|
||||
}
|
||||
.fp-slate {
|
||||
background: rgb(148 163 184 / 0.22);
|
||||
color: rgb(203 213 225);
|
||||
box-shadow: inset 0 0 0 1px rgb(203 213 225 / 0.4);
|
||||
}
|
||||
|
||||
/* --- icon-only action buttons */
|
||||
.act {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 1.9rem; height: 1.9rem;
|
||||
border-radius: 0.5rem;
|
||||
color: rgb(255 255 255 / 0.7);
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.act:hover { background: rgb(255 255 255 / 0.1); color: white; }
|
||||
.act.danger {
|
||||
color: rgb(253 164 175);
|
||||
background: rgb(244 63 94 / 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3);
|
||||
}
|
||||
.act.danger:hover { color: white; background: rgb(244 63 94 / 0.25); }
|
||||
|
||||
/* toolbar 「删除异常账号」按钮 — rose 变体 */
|
||||
.btn-soft.danger {
|
||||
color: rgb(253 164 175);
|
||||
background: rgb(244 63 94 / 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3);
|
||||
}
|
||||
.btn-soft.danger:hover { color: white; background: rgb(244 63 94 / 0.25); }
|
||||
|
||||
/* iOS-style switch (mirrors UsersView/ModelsView) */
|
||||
.sw {
|
||||
position: relative;
|
||||
width: 2.25rem; height: 1.3rem;
|
||||
border-radius: 9999px;
|
||||
background: rgb(255 255 255 / 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
transition: background 0.18s ease;
|
||||
}
|
||||
.sw-thumb {
|
||||
position: absolute;
|
||||
top: 2px; left: 2px;
|
||||
width: calc(1.3rem - 4px); height: calc(1.3rem - 4px);
|
||||
border-radius: 9999px;
|
||||
background: white;
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 0.3);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.sw-on {
|
||||
background: rgb(16 185 129 / 0.7);
|
||||
box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.5);
|
||||
}
|
||||
.sw-on .sw-thumb { transform: translateX(calc(2.25rem - 1.3rem)); }
|
||||
/* dead account (401) — red, thumb stays left */
|
||||
.sw-dead {
|
||||
background: rgb(244 63 94 / 0.8);
|
||||
box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.6);
|
||||
}
|
||||
/* pending (import quota probe in flight) — neutral indigo, thumb stays left */
|
||||
.sw-pending {
|
||||
background: rgb(99 102 241 / 0.45);
|
||||
box-shadow: inset 0 0 0 1px rgb(99 102 241 / 0.4);
|
||||
}
|
||||
/* quota exhausted — amber (NOT red/dead), thumb stays left, locked until reset */
|
||||
.sw-quota {
|
||||
background: rgb(245 158 11 / 0.5);
|
||||
box-shadow: inset 0 0 0 1px rgb(245 158 11 / 0.45);
|
||||
}
|
||||
/* dead / pending toggle is locked — can't be flipped */
|
||||
.sw-locked { cursor: not-allowed; }
|
||||
/* tint the whole row so a dead account is obvious at a glance */
|
||||
.dead-row { background: rgb(244 63 94 / 0.07); }
|
||||
.dead-row:hover { background: rgb(244 63 94 / 0.12); }
|
||||
|
||||
/* --- numbered pagination buttons */
|
||||
.pg {
|
||||
min-width: 1.75rem;
|
||||
padding: 0.3rem 0.55rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
border-radius: 0.45rem;
|
||||
color: rgb(255 255 255 / 0.7);
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.pg:hover:not(.pg-on) { background: rgb(255 255 255 / 0.1); color: white; }
|
||||
.pg-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); box-shadow: none; }
|
||||
</style>
|
||||
@@ -116,7 +116,7 @@ function goPage(n) {
|
||||
<td class="px-3 py-3.5 align-middle text-xs text-white/55 whitespace-nowrap">{{ fmt(o.created_at) }}</td>
|
||||
<td class="px-3 py-3.5 align-middle text-xs text-white/55 whitespace-nowrap">{{ fmt(o.paid_at) }}</td>
|
||||
<td class="px-3 py-3.5 align-middle text-right tabular-nums text-white/85">{{ o.source && o.source !== 'epay' ? '—' : '¥' + o.amount }}</td>
|
||||
<td class="px-3 py-3.5 align-middle text-right tabular-nums" :class="o.points < 0 ? 'text-rose-300' : 'text-violet-300'">{{ o.points }}</td>
|
||||
<td class="px-3 py-3.5 align-middle text-right tabular-nums" :class="o.points < 0 ? 'text-rose-300' : 'text-orange-300'">{{ o.points }}</td>
|
||||
<td class="px-3 py-3.5 align-middle">
|
||||
<span class="chip" :class="chipClass(o.status)">{{ STATUS[o.status] }}<span class="opacity-50 ml-1">· {{ METHOD[o.pay_type] || o.pay_type }}</span></span>
|
||||
</td>
|
||||
@@ -148,7 +148,7 @@ function goPage(n) {
|
||||
.log-table { border-collapse: separate; border-spacing: 0; }
|
||||
.log-row td { border-bottom: 1px solid rgb(255 255 255 / 0.04); transition: background-color 0.15s ease, box-shadow 0.15s ease; }
|
||||
.log-row:hover td { background: rgb(255 255 255 / 0.025); }
|
||||
.log-row:hover td:first-child { box-shadow: inset 2px 0 0 rgb(167 139 250 / 0.55); }
|
||||
.log-row:hover td:first-child { box-shadow: inset 2px 0 0 rgb(253 186 116 / 0.55); }
|
||||
.log-row:last-child td { border-bottom: none; }
|
||||
.pg { min-width: 1.75rem; padding: 0.3rem 0.55rem; font-size: 0.72rem; font-weight: 500; text-align: center; border-radius: 0.45rem; color: rgb(255 255 255 / 0.7); background: rgb(255 255 255 / 0.04); box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08); transition: background 0.15s, color 0.15s; }
|
||||
.pg:hover:not(.pg-on) { background: rgb(255 255 255 / 0.1); color: white; }
|
||||
|
||||
@@ -210,7 +210,7 @@ onMounted(load)
|
||||
color: white;
|
||||
background: rgb(244 63 94 / 0.25);
|
||||
}
|
||||
.chk { accent-color: rgb(217 70 239); width: 0.9rem; height: 0.9rem; cursor: pointer; }
|
||||
.chk { accent-color: rgb(244 63 94); width: 0.9rem; height: 0.9rem; cursor: pointer; }
|
||||
.pg { min-width: 1.75rem; padding: 0.3rem 0.55rem; font-size: 0.72rem; font-weight: 500; text-align: center; border-radius: 0.45rem; color: rgb(255 255 255 / 0.7); background: rgb(255 255 255 / 0.04); box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08); transition: background 0.15s, color 0.15s; }
|
||||
.pg:hover:not(.pg-on) { background: rgb(255 255 255 / 0.1); color: white; }
|
||||
.pg-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); box-shadow: none; }
|
||||
|
||||
+840
-241
File diff suppressed because it is too large
Load Diff
@@ -155,8 +155,8 @@ const pageNumbers = computed(() => {
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-white/60">{{ stats.redeemed }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-fuchsia-300/80">未使用面额</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-fuchsia-300">{{ Number(stats.active_amount || 0).toLocaleString('en-US') }}</div>
|
||||
<div class="text-[11px] uppercase tracking-wider text-rose-300/80">未使用面额</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-rose-300">{{ Number(stats.active_amount || 0).toLocaleString('en-US') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -178,12 +178,12 @@ const pageNumbers = computed(() => {
|
||||
<label class="block text-xs text-white/55 mb-1.5">类型</label>
|
||||
<div class="flex items-center gap-1">
|
||||
<button type="button" @click="form.type = 'normal'" class="fp" :class="form.type === 'normal' && 'fp-on'">普通</button>
|
||||
<button type="button" @click="form.type = 'marketing'" class="fp" :class="form.type === 'marketing' && 'fp-fuchsia'">营销</button>
|
||||
<button type="button" @click="form.type = 'marketing'" class="fp" :class="form.type === 'marketing' && 'fp-rose'">营销</button>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="generate" class="btn-primary"><Icon name="plus" class="w-3.5 h-3.5" /> 生成</button>
|
||||
</div>
|
||||
<p v-if="form.type === 'marketing'" class="text-[11px] text-fuchsia-300/80 mt-2">营销兑换码:同一批次每个用户只能兑换一次。</p>
|
||||
<p v-if="form.type === 'marketing'" class="text-[11px] text-rose-300/80 mt-2">营销兑换码:同一批次每个用户只能兑换一次。</p>
|
||||
|
||||
<!-- last batch -->
|
||||
<div v-if="lastBatch.length" class="mt-4 rounded-xl bg-white/[0.04] ring-1 ring-white/10 p-4">
|
||||
@@ -202,8 +202,8 @@ const pageNumbers = computed(() => {
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="setFilter(() => typeFilter = '')" class="fp" :class="typeFilter === '' && 'fp-on'">全部类型</button>
|
||||
<button @click="setFilter(() => typeFilter = 'normal')" class="fp" :class="typeFilter === 'normal' && 'fp-on'">普通</button>
|
||||
<button @click="setFilter(() => typeFilter = 'marketing')" class="fp" :class="typeFilter === 'marketing' && 'fp-fuchsia'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-fuchsia-400"></span>营销
|
||||
<button @click="setFilter(() => typeFilter = 'marketing')" class="fp" :class="typeFilter === 'marketing' && 'fp-rose'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>营销
|
||||
</button>
|
||||
</div>
|
||||
<div class="w-px h-5 bg-white/10"></div>
|
||||
@@ -266,7 +266,7 @@ const pageNumbers = computed(() => {
|
||||
<td class="px-5 py-3.5 align-middle font-mono text-xs text-white/90 truncate" :title="c.code">
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span class="truncate">{{ c.code }}</span>
|
||||
<span v-if="c.type === 'marketing'" class="shrink-0 inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-sans font-medium bg-fuchsia-500/15 text-fuchsia-300 ring-1 ring-fuchsia-400/25">营销</span>
|
||||
<span v-if="c.type === 'marketing'" class="shrink-0 inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-sans font-medium bg-rose-500/15 text-rose-300 ring-1 ring-rose-400/25">营销</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle text-right tabular-nums text-white/85 whitespace-nowrap">
|
||||
@@ -365,10 +365,10 @@ const pageNumbers = computed(() => {
|
||||
color: rgb(110 231 183);
|
||||
box-shadow: inset 0 0 0 1px rgb(110 231 183 / 0.45);
|
||||
}
|
||||
.fp-fuchsia {
|
||||
background: rgb(217 70 239 / 0.22);
|
||||
color: rgb(245 208 254);
|
||||
box-shadow: inset 0 0 0 1px rgb(245 208 254 / 0.45);
|
||||
.fp-rose {
|
||||
background: rgb(244 63 94 / 0.22);
|
||||
color: rgb(254 205 211);
|
||||
box-shadow: inset 0 0 0 1px rgb(254 205 211 / 0.45);
|
||||
}
|
||||
|
||||
.pg {
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api, jsonBody } from '../api'
|
||||
import { fmtTs, fmtDate } from '../utils/format'
|
||||
import Icon from '../components/Icon.vue'
|
||||
|
||||
const KIND_LABEL = { image: '图像', video: '视频', text: '文本' }
|
||||
|
||||
const channels = ref([])
|
||||
const loading = ref(false)
|
||||
const err = ref('')
|
||||
|
||||
// create/edit form
|
||||
const form = ref(null)
|
||||
const saving = ref(false)
|
||||
const formErr = ref('')
|
||||
|
||||
// fetch-models → pick → import
|
||||
const fetchTarget = ref(null) // channel snapshot being imported into
|
||||
const fetching = ref(false)
|
||||
const fetchErr = ref('')
|
||||
const specs = ref([])
|
||||
const picked = ref(new Set())
|
||||
const importing = ref(false)
|
||||
const managedIds = ref(new Set())
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
err.value = ''
|
||||
const r = await api('/channels')
|
||||
if (r.ok) channels.value = r.data?.data || []
|
||||
else err.value = r.data?.detail || '渠道加载失败'
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function loadManaged() {
|
||||
const r = await api('/managed-models')
|
||||
managedIds.value = new Set((r.data?.data || []).map((m) => m.id))
|
||||
}
|
||||
|
||||
function modelSummary(c) {
|
||||
const names = c.models || []
|
||||
if (!names.length) return ''
|
||||
return names.length <= 2 ? names.join('、') : `${names[0]}、${names[1]} 等`
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
formErr.value = ''
|
||||
form.value = { id: '', name: '', base_url: '', api_key: '', concurrency: 5, weight: 1, enabled: true }
|
||||
}
|
||||
function openEdit(c) {
|
||||
formErr.value = ''
|
||||
form.value = { id: c.id, name: c.name, base_url: c.base_url, api_key: '', concurrency: c.concurrency || 0, weight: c.weight || 1, enabled: !!c.enabled }
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const f = form.value
|
||||
if (!f) return
|
||||
if (!/^https?:\/\/\S+$/.test((f.base_url || '').trim())) { formErr.value = '上游地址需以 http(s):// 开头'; return }
|
||||
if (!f.id && !f.api_key) { formErr.value = '请填写 API Key'; return }
|
||||
if (saving.value) return
|
||||
saving.value = true
|
||||
formErr.value = ''
|
||||
const r = await api('/channels', jsonBody('POST', {
|
||||
id: f.id, name: f.name, base_url: f.base_url, api_key: f.api_key,
|
||||
enabled: !!f.enabled, concurrency: f.concurrency || 0, weight: f.weight || 1,
|
||||
}))
|
||||
saving.value = false
|
||||
if (!r.ok) { formErr.value = r.data?.detail || '保存失败'; return }
|
||||
form.value = null
|
||||
load()
|
||||
}
|
||||
|
||||
async function toggle(c) {
|
||||
const prev = !!c.enabled
|
||||
c.enabled = !prev
|
||||
const r = await api('/channels', jsonBody('POST', {
|
||||
id: c.id, name: c.name, base_url: c.base_url, api_key: '',
|
||||
enabled: c.enabled, concurrency: c.concurrency || 0, weight: c.weight || 1,
|
||||
}))
|
||||
if (!r.ok) { c.enabled = prev; err.value = r.data?.detail || '状态更新失败' }
|
||||
}
|
||||
|
||||
async function del(c) {
|
||||
if (!confirm(`确认删除渠道「${c.name || c.base_url}」?其绑定的模型若没有其他渠道接入,将一并删除。`)) return
|
||||
const r = await api('/channels/delete', jsonBody('POST', { id: c.id }))
|
||||
if (!r.ok) { err.value = r.data?.detail || '删除失败'; return }
|
||||
channels.value = channels.value.filter((x) => x.id !== c.id)
|
||||
}
|
||||
|
||||
// 表单内拉模型:编辑态走库内渠道(密钥不用重填);新渠道用表单里的地址+key 预览。
|
||||
function fetchFromForm() {
|
||||
const f = form.value
|
||||
if (!f) return
|
||||
if (!/^https?:\/\/\S+$/.test((f.base_url || '').trim())) { formErr.value = '上游地址需以 http(s):// 开头'; return }
|
||||
if (!f.id && !f.api_key) { formErr.value = '请填写 API Key'; return }
|
||||
openFetch({ id: f.id || '', name: f.name || f.base_url, base_url: f.base_url, api_key: f.api_key, concurrency: f.concurrency, weight: f.weight, enabled: !!f.enabled })
|
||||
}
|
||||
|
||||
async function openFetch(c) {
|
||||
fetchTarget.value = c
|
||||
specs.value = []
|
||||
picked.value = new Set()
|
||||
fetchErr.value = ''
|
||||
fetching.value = true
|
||||
const r = await api('/channels/fetch-models', jsonBody('POST', {
|
||||
id: c.id || '', base_url: c.base_url || '', api_key: c.api_key || '',
|
||||
}))
|
||||
fetching.value = false
|
||||
if (!r.ok) { fetchErr.value = r.data?.detail || '拉取失败'; return }
|
||||
specs.value = (r.data?.data || []).map((s) => ({ ...s, alias: s.alias || '', imported: managedIds.value.has(s.id) }))
|
||||
picked.value = new Set(specs.value.filter((s) => !s.imported).map((s) => s.id))
|
||||
if (!picked.value.size) picked.value = new Set(specs.value.map((s) => s.id))
|
||||
}
|
||||
|
||||
function togglePick(id) {
|
||||
const next = new Set(picked.value)
|
||||
next.has(id) ? next.delete(id) : next.add(id)
|
||||
picked.value = next
|
||||
}
|
||||
|
||||
function specText(s) {
|
||||
const parts = []
|
||||
if (s.qualities?.length) parts.push(s.qualities.join('/'))
|
||||
if (s.duration) {
|
||||
if (s.duration.mode === 'fixed') parts.push(`固定 ${s.duration.seconds}s`)
|
||||
else if (s.duration.mode === 'range') parts.push(`${s.duration.min}-${s.duration.max}s`)
|
||||
else if (s.duration.mode === 'options') parts.push((s.duration.options || []).map((d) => `${d}s`).join('/'))
|
||||
}
|
||||
const refs = []
|
||||
if (s.refImages > 0) refs.push(`图片${s.refImages}`)
|
||||
if (s.refVideos > 0) refs.push(`视频${s.refVideos}${s.refVideoSeconds > 0 ? `≤${s.refVideoSeconds}s` : ''}`)
|
||||
if (s.refAudios > 0) refs.push(`音频${s.refAudios}${s.refAudioSeconds > 0 ? `≤${s.refAudioSeconds}s` : ''}`)
|
||||
if (refs.length) parts.push(`参考 ${refs.join('+')}`)
|
||||
return parts.join(' · ') || '-'
|
||||
}
|
||||
|
||||
async function doImport() {
|
||||
if (importing.value || !picked.value.size) return
|
||||
const models = specs.value
|
||||
.filter((s) => picked.value.has(s.id))
|
||||
.map((s) => {
|
||||
const { imported, ...rest } = s
|
||||
return rest
|
||||
})
|
||||
const target = fetchTarget.value
|
||||
importing.value = true
|
||||
try {
|
||||
if (!target.id) {
|
||||
// 表单预览路径:渠道尚未入库,先按快照保存再绑定;失败中止不建孤儿。
|
||||
const sr = await api('/channels', jsonBody('POST', {
|
||||
id: '', name: target.name || target.base_url, base_url: target.base_url, api_key: target.api_key,
|
||||
enabled: target.enabled !== false, concurrency: target.concurrency || 0, weight: target.weight || 1,
|
||||
}))
|
||||
if (!sr.ok) { fetchErr.value = sr.data?.detail || '渠道保存失败'; return }
|
||||
target.id = sr.data?.id
|
||||
form.value = null
|
||||
}
|
||||
const r = await api('/channels/import-models', jsonBody('POST', { id: target.id, models }))
|
||||
if (!r.ok) { fetchErr.value = r.data?.detail || '导入失败'; return }
|
||||
fetchTarget.value = null
|
||||
load()
|
||||
loadManaged()
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { load(); loadManaged() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<div class="card p-4 flex items-center justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<div class="text-sm font-medium">渠道</div>
|
||||
<p class="text-xs text-white/45 mt-0.5">同构 /v1 协议上游实例 · 模型经「拉取上游模型」导入 · 余额实时探测</p>
|
||||
</div>
|
||||
<button @click="openAdd" class="btn-primary">
|
||||
<Icon name="plus" class="w-4 h-4" /> 添加渠道
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="err" class="text-xs text-rose-300">{{ err }}</p>
|
||||
|
||||
<div class="card overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>渠道</th>
|
||||
<th>上游地址</th>
|
||||
<th>模型</th>
|
||||
<th>并发</th>
|
||||
<th>配比</th>
|
||||
<th class="text-right">成功调用</th>
|
||||
<th class="text-right">累计消耗</th>
|
||||
<th class="text-right">上游余额</th>
|
||||
<th>状态</th>
|
||||
<th>最近使用</th>
|
||||
<th class="text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="c in channels" :key="c.id">
|
||||
<td>
|
||||
<div class="font-medium text-white/85">{{ c.name }}</div>
|
||||
<div class="text-[11px] text-white/35 font-mono">{{ c.id }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="max-w-[240px] truncate font-mono text-xs text-white/55" :title="c.base_url">{{ c.base_url }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="tabular-nums text-white/80">{{ (c.models || []).length }}</div>
|
||||
<div class="text-[11px] text-white/35 max-w-[180px] truncate" :title="(c.models || []).join('、')">{{ modelSummary(c) }}</div>
|
||||
</td>
|
||||
<td class="tabular-nums">{{ c.concurrency || '默认' }}</td>
|
||||
<td class="tabular-nums">{{ c.weight || 1 }}</td>
|
||||
<td class="text-right tabular-nums">{{ (c.success_total || 0).toLocaleString() }}</td>
|
||||
<td class="text-right tabular-nums">{{ (c.used_credits || 0).toLocaleString() }}</td>
|
||||
<td class="text-right tabular-nums">{{ c.upstream_balance == null ? '-' : c.upstream_balance.toLocaleString() }}</td>
|
||||
<td>
|
||||
<button @click="toggle(c)" class="inline-flex items-center gap-1.5 text-xs"
|
||||
:class="c.enabled ? 'text-emerald-300' : 'text-white/40'">
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="c.enabled ? 'bg-emerald-400' : 'bg-white/25'"></span>
|
||||
{{ c.enabled ? '启用' : '停用' }}
|
||||
</button>
|
||||
</td>
|
||||
<td class="text-xs text-white/45 whitespace-nowrap">{{ c.last_used_at ? fmtTs(c.last_used_at) : '-' }}</td>
|
||||
<td class="text-right whitespace-nowrap">
|
||||
<button @click="openFetch(c)" class="op" title="拉取上游模型"><Icon name="spark" class="w-3.5 h-3.5" /></button>
|
||||
<button @click="openEdit(c)" class="op" title="编辑"><Icon name="config" class="w-3.5 h-3.5" /></button>
|
||||
<button @click="del(c)" class="op danger" title="删除"><Icon name="trash" class="w-3.5 h-3.5" /></button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="loading"><td colspan="11" class="py-10 text-center text-white/40 text-sm">正在加载渠道…</td></tr>
|
||||
<tr v-else-if="!channels.length"><td colspan="11" class="py-10 text-center text-white/40 text-sm">没有渠道,点右上角「添加渠道」接入上游实例</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加 / 编辑渠道 -->
|
||||
<div v-if="form" class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4" @click.self="form = null">
|
||||
<div class="card !shadow-2xl my-12 w-full max-w-lg">
|
||||
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold">{{ form.id ? '编辑渠道' : '添加渠道' }}</h2>
|
||||
<button @click="form = null" class="text-white/40 hover:text-white transition-colors"><Icon name="close" class="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<div>
|
||||
<label class="lbl">渠道名称</label>
|
||||
<input v-model.trim="form.name" type="text" maxlength="64" class="field" placeholder="例如:主站" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">上游地址 <span class="text-rose-300">*</span></label>
|
||||
<input v-model.trim="form.base_url" type="text" maxlength="300" class="field" placeholder="https://example.com" />
|
||||
<p class="mt-1 text-[11px] text-white/35">同构 /v1 协议实例的站点地址,不带路径</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">API Key <span v-if="!form.id" class="text-rose-300">*</span></label>
|
||||
<input v-model.trim="form.api_key" type="text" class="field" :placeholder="form.id ? '留空保持不变' : 'vk_…'" autocomplete="off" />
|
||||
<p class="mt-1 text-[11px] text-white/35">上游站点的用户 API Key(vk_ 前缀),密文存储,不回显</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="lbl">并发数</label>
|
||||
<input v-model.number="form.concurrency" type="number" min="0" max="100" class="field" />
|
||||
<p class="mt-1 text-[11px] text-white/35">0 = 默认 5</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">配比</label>
|
||||
<input v-model.number="form.weight" type="number" min="1" max="100" class="field" />
|
||||
<p class="mt-1 text-[11px] text-white/35">同模型多渠道按配比分单</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="lbl !mb-0">状态</label>
|
||||
<button type="button" @click="form.enabled = !form.enabled" class="inline-flex items-center gap-1.5 text-xs"
|
||||
:class="form.enabled ? 'text-emerald-300' : 'text-white/40'">
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="form.enabled ? 'bg-emerald-400' : 'bg-white/25'"></span>
|
||||
{{ form.enabled ? '已启用' : '已停用' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="button" @click="fetchFromForm" :disabled="fetching" class="btn-ghost text-xs">
|
||||
{{ fetching ? '拉取中…' : '获取模型列表' }}
|
||||
</button>
|
||||
<span class="text-[11px] text-white/35">{{ form.id ? '用已保存的地址与密钥拉取' : '用上方的地址与 API Key 拉取;导入时会先自动保存渠道' }}</span>
|
||||
</div>
|
||||
<p v-if="formErr" class="text-xs text-rose-300">{{ formErr }}</p>
|
||||
</div>
|
||||
<div class="px-5 py-4 border-t border-white/[0.06] flex justify-end gap-2">
|
||||
<button @click="form = null" class="btn-ghost">取消</button>
|
||||
<button @click="save" :disabled="saving" class="btn-primary">{{ saving ? '保存中…' : '保存' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 拉取上游模型 → 勾选导入 -->
|
||||
<div v-if="fetchTarget" class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4" @click.self="fetchTarget = null">
|
||||
<div class="card !shadow-2xl my-12 w-full max-w-2xl">
|
||||
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold">上游模型 · {{ fetchTarget.name || fetchTarget.base_url }}</h2>
|
||||
<button @click="fetchTarget = null" class="text-white/40 hover:text-white transition-colors"><Icon name="close" class="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div v-if="fetching" class="py-10 text-center text-white/40 text-sm">正在从上游拉取模型列表…</div>
|
||||
<div v-else-if="fetchErr" class="py-10 text-center text-rose-300 text-sm">{{ fetchErr }}</div>
|
||||
<template v-else>
|
||||
<p class="text-xs text-white/45 mb-3">勾选要导入的模型:能力参数随模型带入,导入后到「模型管理」配价上架。别名即本站模型标识(留空=与上游同名);已导入的模型自动标记(重复导入会刷新参数)。</p>
|
||||
<div v-if="!specs.length" class="py-10 text-center text-white/40 text-sm">上游没有可售模型(模型需在上游已配价)</div>
|
||||
<div v-else class="space-y-1.5 max-h-[380px] overflow-y-auto">
|
||||
<div v-for="s in specs" :key="s.id" @click="togglePick(s.id)"
|
||||
class="flex items-start gap-2.5 p-2.5 rounded-lg ring-1 ring-white/10 hover:bg-white/[0.04] cursor-pointer transition-colors"
|
||||
:class="!picked.has(s.id) && 'opacity-50'">
|
||||
<input type="checkbox" :checked="picked.has(s.id)" @click.stop @change="togglePick(s.id)" class="mt-1 accent-sky-400" />
|
||||
<div class="flex-1 min-w-0 space-y-1.5">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<code class="font-mono text-xs text-white/85">{{ s.id }}</code>
|
||||
<span class="px-1.5 py-0.5 rounded text-[10px] font-semibold"
|
||||
:class="s.kind === 'video' ? 'bg-rose-400/15 text-rose-300' : 'bg-sky-400/15 text-sky-300'">
|
||||
{{ KIND_LABEL[s.kind] || s.kind }}
|
||||
</span>
|
||||
<span class="text-[11px] text-white/40">{{ specText(s) }}</span>
|
||||
<span v-if="s.imported" class="ml-auto px-1.5 py-0.5 rounded bg-emerald-400/15 text-emerald-300 text-[10px] font-semibold">已导入</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2" @click.stop>
|
||||
<span class="text-[11px] text-white/35 shrink-0">别名</span>
|
||||
<input v-model.trim="s.alias" type="text" maxlength="64" :disabled="!picked.has(s.id)"
|
||||
class="flex-1 min-w-0 px-2.5 py-1 rounded-lg bg-white/[0.04] ring-1 ring-white/10 text-xs text-white/80 disabled:opacity-40"
|
||||
placeholder="留空 = 与上游同名" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="px-5 py-4 border-t border-white/[0.06] flex justify-end gap-2">
|
||||
<button @click="fetchTarget = null" class="btn-ghost">关闭</button>
|
||||
<button @click="doImport" :disabled="importing || !picked.size" class="btn-primary">
|
||||
{{ importing ? '导入中…' : (fetchTarget?.id ? `导入选中(${picked.size})` : `保存渠道并导入(${picked.size})`) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tbl { width: 100%; border-collapse: collapse; min-width: 1000px; }
|
||||
.tbl thead th { padding: 10px 14px; text-align: left; font-size: 11px; font-weight: 600; color: rgb(255 255 255 / 0.45); border-bottom: 1px solid rgb(255 255 255 / 0.06); white-space: nowrap; }
|
||||
.tbl tbody td { padding: 10px 14px; border-bottom: 1px solid rgb(255 255 255 / 0.04); font-size: 12.5px; color: rgb(255 255 255 / 0.7); vertical-align: middle; }
|
||||
.tbl tbody tr:last-child td { border-bottom: none; }
|
||||
.tbl tbody tr:hover { background: rgb(255 255 255 / 0.02); }
|
||||
|
||||
.lbl { display: block; font-size: 0.72rem; font-weight: 500; color: rgb(255 255 255 / 0.55); margin-bottom: 0.4rem; }
|
||||
|
||||
.op { display: inline-grid; place-items: center; min-width: 28px; height: 26px; padding: 0 7px; margin-left: 4px; border-radius: 7px; background: rgb(255 255 255 / 0.05); color: rgb(255 255 255 / 0.6); }
|
||||
.op:hover { background: rgb(255 255 255 / 0.1); color: white; }
|
||||
.op.danger:hover { background: rgb(244 63 94 / 0.15); color: rgb(251 113 133); }
|
||||
</style>
|
||||
@@ -81,8 +81,8 @@ onMounted(load)
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle text-right tabular-nums text-white/70">{{ g.user_count }}</td>
|
||||
<td class="px-3 py-3.5 align-middle">
|
||||
<span v-if="g.is_default" class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium bg-fuchsia-500/10 text-fuchsia-300 ring-1 ring-fuchsia-400/30">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-fuchsia-400"></span> 默认
|
||||
<span v-if="g.is_default" class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium bg-rose-500/10 text-rose-300 ring-1 ring-rose-400/30">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span> 默认
|
||||
</span>
|
||||
<button v-else @click="setDefault(g)" class="btn-soft text-xs">设为默认</button>
|
||||
</td>
|
||||
|
||||
@@ -310,7 +310,7 @@ onMounted(() => { loadSite(); loadReg(); loadSmtp(); loadCredits(); loadAnnounce
|
||||
<div @click="pickLogo" @drop="onLogoDrop" @dragover.prevent="logoDragOver = true" @dragleave="logoDragOver = false"
|
||||
title="点击或拖拽图片替换"
|
||||
class="w-16 h-16 rounded-xl grid place-items-center overflow-hidden shrink-0 cursor-pointer transition-all"
|
||||
:class="logoDragOver ? 'ring-2 ring-indigo-400 bg-indigo-50/40' : ''">
|
||||
:class="logoDragOver ? 'ring-2 ring-sky-400 bg-sky-50/40' : ''">
|
||||
<img v-if="logoPreview" :src="logoPreview" class="w-full h-full object-cover" />
|
||||
<Logo v-else :size="64" class="w-full h-full" />
|
||||
</div>
|
||||
@@ -656,31 +656,29 @@ html.dark .hint b { color: rgb(253 164 175); } /* rose — visible warning, no
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 0.2);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.sw:checked { background: #4f46e5; } /* indigo-600 — matches btn-primary */
|
||||
.sw:checked { background: #4f46e5; } /* sky-600 — matches btn-primary */
|
||||
.sw:checked::after { transform: translateX(calc(2.25rem - 1.3rem)); }
|
||||
.sw:focus-visible { box-shadow: 0 0 0 3px rgb(99 102 241 / 0.25); }
|
||||
.sw:focus-visible { box-shadow: 0 0 0 3px rgb(236 91 19 / 0.3); }
|
||||
.sw:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
/* All three input variants now share the same dark-glass surface as the rest
|
||||
of the admin shell. Background + border use white-alpha so they read
|
||||
against the card; placeholders are tuned for legibility, not noise. */
|
||||
/* All three input variants now share the same surface as the rest of the
|
||||
admin shell. Background + border use ink-alpha so they read against the
|
||||
card; placeholders are tuned for legibility, not noise. */
|
||||
.num, .txt, .field {
|
||||
background: rgb(15 23 42 / 0.03);
|
||||
border: 1px solid var(--hairline);
|
||||
border: 2px solid var(--ink-soft);
|
||||
color: var(--fg);
|
||||
border-radius: 0.55rem;
|
||||
outline: none;
|
||||
transition: border-color 0.18s, background 0.18s, box-shadow 0.18s;
|
||||
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
html.dark .num, html.dark .txt, html.dark .field {
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
border-color: rgb(255 255 255 / 0.1);
|
||||
color: white;
|
||||
}
|
||||
.num::placeholder, .txt::placeholder, .field::placeholder { color: var(--fg-faint); }
|
||||
.num:focus, .txt:focus, .field:focus {
|
||||
border-color: rgb(167 139 250 / 0.65);
|
||||
background: rgb(255 255 255 / 0.06);
|
||||
box-shadow: 0 0 0 3px rgb(167 139 250 / 0.15);
|
||||
border-color: rgb(124 92 252 / 0.65);
|
||||
box-shadow: 0 0 0 3px rgb(124 92 252 / 0.18);
|
||||
}
|
||||
.num:disabled, .txt:disabled, .field:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.num { width: 6rem; padding: 0.4rem 0.55rem; font-size: 0.8rem; text-align: right; -moz-appearance: textfield; appearance: textfield; }
|
||||
@@ -688,23 +686,23 @@ html.dark .num, html.dark .txt, html.dark .field {
|
||||
.txt { width: 16rem; max-width: 60%; padding: 0.4rem 0.65rem; font-size: 0.8rem; }
|
||||
.field { width: 100%; padding: 0.55rem 0.75rem; font-size: 0.85rem; }
|
||||
|
||||
/* Section save buttons — solid violet to match brand. The global .btn-primary
|
||||
under .public-dark goes to white; we override here so the save action stays
|
||||
visually distinct as the primary action on a form. */
|
||||
/* Section save buttons — violet→cyan gradient with a glow. The global
|
||||
.btn-primary under .public-dark already uses this palette; we re-assert it
|
||||
here so the save action stays visually distinct. */
|
||||
.btn-primary {
|
||||
padding: 0.55rem 1.15rem; border-radius: 0.6rem;
|
||||
font-size: 0.8rem; font-weight: 600; color: white;
|
||||
background: linear-gradient(135deg, #a855f7 0%, #7c3aed 50%, #ec4899 100%);
|
||||
box-shadow: 0 8px 20px -8px rgb(168 85 247 / 0.55);
|
||||
transition: filter 0.15s, transform 0.12s, box-shadow 0.18s, opacity 0.15s;
|
||||
font-size: 0.8rem; font-weight: 800; color: white;
|
||||
background: linear-gradient(120deg, #7c5cfc, #5f6cf8);
|
||||
border: 1px solid rgb(255 255 255 / 0.16);
|
||||
box-shadow: 0 6px 18px var(--accent-glow);
|
||||
transition: filter 0.15s, transform 0.12s, box-shadow 0.15s, opacity 0.15s;
|
||||
}
|
||||
/* Re-assert the gradient on hover for ALL states: the global
|
||||
`.public-dark .btn-primary:hover` in style.css repaints the background white
|
||||
with no `:not(:disabled)` guard, so even disabled save buttons (注册与登录 /
|
||||
邮件服务 default to disabled) flashed white on hover. Keep this rule
|
||||
/* Keep the gradient on hover for ALL states — the global
|
||||
`.public-dark .btn-primary:hover` in style.css repaints the background,
|
||||
so even disabled save buttons would flash on hover. Keep this rule
|
||||
unconditional; only the brightness/lift below is gated on :not(:disabled). */
|
||||
.btn-primary:hover { background: linear-gradient(135deg, #a855f7 0%, #7c3aed 50%, #ec4899 100%); }
|
||||
.btn-primary:hover:not(:disabled) { filter: brightness(1.08); box-shadow: 0 10px 24px -8px rgb(168 85 247 / 0.7); }
|
||||
.btn-primary:active:not(:disabled) { transform: translateY(1px); }
|
||||
.btn-primary:hover { filter: brightness(1.08); }
|
||||
.btn-primary:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 10px 26px var(--accent-glow); }
|
||||
.btn-primary:active:not(:disabled) { transform: translateY(1px); box-shadow: none; }
|
||||
.btn-primary:disabled { opacity: 0.45; cursor: not-allowed; box-shadow: none; }
|
||||
</style>
|
||||
|
||||
+112
-257
@@ -1,7 +1,6 @@
|
||||
<script setup>
|
||||
// API 对接文档 — OpenAI-compatible. Lists live models and shows ready-to-run
|
||||
// curl / Python(openai SDK) examples for image + video, wired to this
|
||||
// deployment's base URL and the caller's model ids.
|
||||
// API 对接文档 — Vivid 协议(/v1/generate NDJSON 长连接)。列出在售模型
|
||||
// 与计费档位,示例代码直接可跑。
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { auth } from '../auth'
|
||||
import { api } from '../api'
|
||||
@@ -10,7 +9,7 @@ import { points } from '../credits'
|
||||
import { mediaCaps, presetMap } from '../videoCaps'
|
||||
|
||||
const base = computed(() => location.origin) // /v1 is same-origin (dev: Vite proxy)
|
||||
const keyHint = computed(() => auth.user?.api_keys?.[0]?.key_preview || 'YOUR_API_KEY')
|
||||
const keyHint = computed(() => auth.user?.api_keys?.[0]?.key_preview || 'vk_xxxx')
|
||||
|
||||
const models = ref([])
|
||||
const presets = ref({}) // /video-presets: key → 预设(参考资产分类上限的真源)
|
||||
@@ -31,8 +30,10 @@ const videoModels = computed(() => models.value.filter((m) => m.type === 'video'
|
||||
function pubName(m) {
|
||||
return m?.alias || m?.id || ''
|
||||
}
|
||||
const sampleImage = computed(() => pubName(imageModels.value[0]) || 'firefly-image-4')
|
||||
const sampleVideo = computed(() => pubName(videoModels.value[0]) || 'firefly-kling3')
|
||||
const sampleImage = computed(() => pubName(imageModels.value[0]) || 'gpt-image-2')
|
||||
const sampleVideo = computed(() => pubName(videoModels.value[0]) || 'video-model')
|
||||
const sampleQuality = computed(() => (imageModels.value[0]?.resolutions || [])[0] || '1K')
|
||||
const sampleVQuality = computed(() => (videoModels.value[0]?.resolutions || [])[0] || '720p')
|
||||
const sampleSeconds = computed(() => String(videoModels.value[0]?.durations?.[0] || '8s').replace(/s$/, ''))
|
||||
|
||||
function modeEmpty(m) {
|
||||
@@ -61,183 +62,118 @@ function priceEmpty(m) {
|
||||
if (perSecondPrice(m) != null) return false
|
||||
return !(m.durations || []).some((d) => durPrice(m, d) != null)
|
||||
}
|
||||
// 视频时长展示:固定/离散档列出来;区间档(duration_min/max)显示 min–max s。
|
||||
function durText(m) {
|
||||
const ds = m.durations || []
|
||||
if (ds.length) return ds.join('/')
|
||||
if ((m.duration_max || 0) > 0) return `${m.duration_min}–${m.duration_max}s`
|
||||
return ''
|
||||
}
|
||||
|
||||
// ---- request parameter tables ----
|
||||
const imageParams = [
|
||||
['model', 'string', '必填', '模型名(别名优先),见上表(图像)'],
|
||||
['prompt', 'string', '必填', '文字描述'],
|
||||
['size', 'string', '可选', '宽x高,如 "1024x1024"。同时决定「比例」+「分辨率档」(按长边)。具体怎么填见下方对照表;留空 = 1:1 · 2K'],
|
||||
]
|
||||
const editParams = [
|
||||
['image', 'file', '必填', '输入图;多张参考图重复 image[] 字段(multipart 文件上传)'],
|
||||
['prompt', 'string', '必填', '编辑/参考描述'],
|
||||
['model', 'string', '必填', '模型名(别名优先,需支持图生图)'],
|
||||
['size', 'string', '可选', '同图像:决定比例 + 分辨率档(见下方对照表)'],
|
||||
]
|
||||
const videoParams = [
|
||||
['model', 'string', '必填', '模型名(别名优先),见上表(视频)'],
|
||||
['prompt', 'string', '必填', '文字描述'],
|
||||
['seconds', 'string|int', '必填', '时长秒数,如 "5" "8"(取决于模型支持)'],
|
||||
['size', 'string', '可选', '如 "1280x720" / "720x1280" → 决定比例与分辨率'],
|
||||
['input_reference', 'file', '可选', '参考图/首尾帧(multipart 文件上传;多张时重复 input_reference[] 字段)。runway 图生视频必填 1 张,张数上限见上方模型表'],
|
||||
['reference_mode', 'string', '可选', '参考图用途:"frame"=首尾帧(最多 2 张,第 1 张为首帧、第 2 张为尾帧,只传 1 张即仅首帧),"asset"=普通参考图。留空用模型默认;仅支持单一模式的模型不可传'],
|
||||
const generateParams = [
|
||||
['model', 'string', '必填', '模型名(别名优先),见上表 /v1/models 的 id'],
|
||||
['prompt', 'string', '必填', '文本描述'],
|
||||
['quality', 'string', '可选', '清晰度档位,取该模型 qualities 之一(图像如 1K/2K/4K,视频如 720p/1080p);不传 = 该模型最低档'],
|
||||
['ratio', 'string', '可选', '宽高比,如 "16:9";不传 = 类型默认'],
|
||||
['duration', 'int64', '可选', '视频时长(秒);range/options 模型必填,图片忽略'],
|
||||
['refs', 'array<string>', '可选', '参考素材 key 数组:先 POST /v1/uploads 拿预签名直传,PUT 上传后把返回的 key 放这里。30 分钟内有效,张数上限见模型表'],
|
||||
['jobId', 'string', '可选', '重连模式:流意外断开后只带 jobId 原样重发,不创建新任务、不重复扣费;已完成直接回终态帧'],
|
||||
]
|
||||
|
||||
// ---- size → 比例 × 分辨率档 对照表(用 size 该传的值)----
|
||||
// size 的长边映射档位:<1800→1K · 1800–3499→2K · ≥3500→4K;宽高比映射比例。
|
||||
const sizeTable = [
|
||||
{ ratio: '1:1 · 方', k1: '1024x1024', k2: '2048x2048', k4: '4096x4096' },
|
||||
{ ratio: '5:4 · 横', k1: '1280x1024', k2: '2560x2048', k4: '3840x3072' },
|
||||
{ ratio: '4:3 · 横', k1: '1024x768', k2: '2048x1536', k4: '4096x3072' },
|
||||
{ ratio: '3:2 · 横', k1: '1200x800', k2: '2400x1600', k4: '3600x2400' },
|
||||
{ ratio: '16:9 · 横', k1: '1280x720', k2: '2048x1152', k4: '4096x2304' },
|
||||
{ ratio: '2:1 · 横', k1: '1440x720', k2: '2880x1440', k4: '4096x2048' },
|
||||
{ ratio: '21:9 · 超宽', k1: '1680x720', k2: '2520x1080', k4: '5040x2160' },
|
||||
{ ratio: '3:1 · 超宽', k1: '1536x512', k2: '2304x768', k4: '3840x1280' },
|
||||
{ ratio: '4:1 · 超宽', k1: '1728x432', k2: '2880x720', k4: '4096x1024' },
|
||||
{ ratio: '8:1 · 超宽', k1: '1728x216', k2: '2880x360', k4: '4096x512' },
|
||||
{ ratio: '4:5 · 竖', k1: '1024x1280', k2: '2048x2560', k4: '3072x3840' },
|
||||
{ ratio: '3:4 · 竖', k1: '768x1024', k2: '1536x2048', k4: '3072x4096' },
|
||||
{ ratio: '2:3 · 竖', k1: '800x1200', k2: '1600x2400', k4: '2400x3600' },
|
||||
{ ratio: '9:16 · 竖', k1: '720x1280', k2: '1152x2048', k4: '2304x4096' },
|
||||
{ ratio: '1:3 · 竖', k1: '512x1536', k2: '768x2304', k4: '1280x3840' },
|
||||
{ ratio: '1:4 · 竖', k1: '432x1728', k2: '720x2880', k4: '1024x4096' },
|
||||
{ ratio: '1:8 · 竖', k1: '216x1728', k2: '360x2880', k4: '512x4096' },
|
||||
]
|
||||
|
||||
// ---- 视频 size → 比例 × 分辨率(720p / 1080p)----
|
||||
// 视频按「短边」判档:短边 <1080 → 720p,≥1080 → 1080p;宽高比映射比例。
|
||||
const videoSizeTable = [
|
||||
{ ratio: '16:9 · 横', p720: '1280x720', p1080: '1920x1080' },
|
||||
{ ratio: '9:16 · 竖', p720: '720x1280', p1080: '1080x1920' },
|
||||
{ ratio: '1:1 · 方', p720: '720x720', p1080: '1080x1080' },
|
||||
{ ratio: '4:3 · 横', p720: '960x720', p1080: '1440x1080' },
|
||||
{ ratio: '3:4 · 竖', p720: '720x960', p1080: '1080x1440' },
|
||||
{ ratio: '3:2 · 横', p720: '1080x720', p1080: '1620x1080' },
|
||||
{ ratio: '2:3 · 竖', p720: '720x1080', p1080: '1080x1620' },
|
||||
const uploadParams = [
|
||||
['kind', 'string', '必填', '素材大类:image / video / audio(与文件实际类型一致)'],
|
||||
['mime', 'string', '必填', '文件 MIME(如 image/png),须与 kind 自洽;PUT 上传的 Content-Type 必须与它一字不差(已签进 URL)'],
|
||||
]
|
||||
|
||||
// ---- examples (built in script so refs resolve correctly) ----
|
||||
const examples = computed(() => [
|
||||
{
|
||||
title: '文生图 · curl',
|
||||
title: '文生图 · curl (NDJSON 流)',
|
||||
code:
|
||||
`curl ${base.value}/v1/images/generations \\
|
||||
`curl -N ${base.value}/v1/generate \\
|
||||
-H "Authorization: Bearer ${keyHint.value}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"model": "${sampleImage.value}",
|
||||
"prompt": "a corgi running in a golden wheat field, cinematic",
|
||||
"size": "2048x2048"
|
||||
}'`,
|
||||
"quality": "${sampleQuality.value}",
|
||||
"ratio": "1:1"
|
||||
}'
|
||||
# 响应是 NDJSON:首行 {"jobId":"...","status":"running"},空行为心跳(忽略),
|
||||
# 末行终态 {"status":"succeeded","result":"<预签名URL>","creditsCharged":10}`,
|
||||
},
|
||||
{
|
||||
title: '文生图 · Python (openai SDK)',
|
||||
title: '生图 · Python (流式读终态)',
|
||||
code:
|
||||
`import urllib.request
|
||||
from openai import OpenAI
|
||||
`import json, requests
|
||||
|
||||
client = OpenAI(api_key="${keyHint.value}", base_url="${base.value}/v1")
|
||||
r = requests.post("${base.value}/v1/generate", stream=True, timeout=600,
|
||||
headers={"Authorization": "Bearer ${keyHint.value}"},
|
||||
json={"model": "${sampleImage.value}", "prompt": "a cute cat", "quality": "${sampleQuality.value}"})
|
||||
|
||||
resp = client.images.generate(
|
||||
model="${sampleImage.value}",
|
||||
prompt="a corgi running in a golden wheat field, cinematic",
|
||||
size="2048x2048", # 2K · 1:1,见下方对照表
|
||||
)
|
||||
# 结果是图片 URL(上游原始直链,会过期 → 尽快下载/转存)
|
||||
urllib.request.urlretrieve(resp.data[0].url, "out.png")`,
|
||||
job_id, final = None, None
|
||||
for line in r.iter_lines():
|
||||
if not line: # 空行 = 心跳,忽略
|
||||
continue
|
||||
frame = json.loads(line)
|
||||
job_id = frame.get("jobId") or job_id
|
||||
if frame.get("status") in ("succeeded", "failed"):
|
||||
final = frame
|
||||
break
|
||||
|
||||
# 断流重连:只带 jobId 重发即继续等同一任务(不重复扣费)
|
||||
# requests.post(..., json={"jobId": job_id})
|
||||
print(final)`,
|
||||
},
|
||||
{
|
||||
title: '图生图 / 参考图 · curl (multipart)',
|
||||
title: '参考素材上传 · curl (预签名直传)',
|
||||
code:
|
||||
`curl ${base.value}/v1/images/edits \\
|
||||
`# 1) 申请上传会话 → 拿 {key, url}
|
||||
resp=$(curl -s ${base.value}/v1/uploads \\
|
||||
-H "Authorization: Bearer ${keyHint.value}" \\
|
||||
-F model="${sampleImage.value}" \\
|
||||
-F prompt="把这张图改成赛博朋克风格" \\
|
||||
-F size="2048x2048" \\
|
||||
-F image=@input.png
|
||||
# 多张参考图:重复 -F image=@a.png -F image=@b.png`,
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"kind":"image","mime":"image/png"}')
|
||||
|
||||
# 2) PUT 直传(Content-Type 必须与申请的 mime 完全一致)
|
||||
curl -X PUT -H "Content-Type: image/png" --data-binary @input.png "<url>"
|
||||
|
||||
# 3) key 放进 /v1/generate 的 refs
|
||||
curl -N ${base.value}/v1/generate \\
|
||||
-H "Authorization: Bearer ${keyHint.value}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"model":"${sampleImage.value}","prompt":"改成赛博朋克风格","refs":["<key>"]}'`,
|
||||
},
|
||||
{
|
||||
title: '图生图 · Python (openai SDK)',
|
||||
title: '视频 · curl',
|
||||
code:
|
||||
`import urllib.request
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(api_key="${keyHint.value}", base_url="${base.value}/v1")
|
||||
|
||||
resp = client.images.edit(
|
||||
model="${sampleImage.value}",
|
||||
image=open("input.png", "rb"), # 多张:image=[open("a.png","rb"), open("b.png","rb")]
|
||||
prompt="把这张图改成赛博朋克风格",
|
||||
)
|
||||
# 结果是图片 URL(上游原始直链,会过期 → 尽快下载/转存)
|
||||
urllib.request.urlretrieve(resp.data[0].url, "out.png")`,
|
||||
},
|
||||
{
|
||||
title: '视频 · curl(创建 → 轮询 → 下载)',
|
||||
code:
|
||||
`# 1) 创建任务 → 立即返回 {"id": "...", "status": "queued"}
|
||||
curl ${base.value}/v1/videos \\
|
||||
`curl -N ${base.value}/v1/generate \\
|
||||
-H "Authorization: Bearer ${keyHint.value}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"model": "${sampleVideo.value}",
|
||||
"prompt": "a paper boat sailing down a rainy street, cinematic",
|
||||
"seconds": "${sampleSeconds.value}",
|
||||
"size": "1280x720"
|
||||
"quality": "${sampleVQuality.value}",
|
||||
"ratio": "16:9",
|
||||
"duration": ${sampleSeconds.value}
|
||||
}'
|
||||
|
||||
# 2) 轮询状态,直到 status=completed
|
||||
curl ${base.value}/v1/videos/<VIDEO_ID> \\
|
||||
-H "Authorization: Bearer ${keyHint.value}"
|
||||
|
||||
# 3) 下载 mp4(完成后)
|
||||
curl ${base.value}/v1/videos/<VIDEO_ID>/content \\
|
||||
-H "Authorization: Bearer ${keyHint.value}" -o out.mp4`,
|
||||
},
|
||||
{
|
||||
title: '视频 · Python (requests, 轮询)',
|
||||
code:
|
||||
`import time, requests
|
||||
|
||||
base = "${base.value}/v1"
|
||||
h = {"Authorization": "Bearer ${keyHint.value}"}
|
||||
|
||||
# 1) 创建
|
||||
job = requests.post(f"{base}/videos", headers=h, json={
|
||||
"model": "${sampleVideo.value}",
|
||||
"prompt": "a paper boat sailing down a rainy street",
|
||||
"seconds": "${sampleSeconds.value}",
|
||||
"size": "1280x720",
|
||||
}).json()
|
||||
vid = job["id"]
|
||||
|
||||
# 2) 轮询
|
||||
while True:
|
||||
s = requests.get(f"{base}/videos/{vid}", headers=h).json()
|
||||
if s["status"] in ("completed", "failed"):
|
||||
break
|
||||
time.sleep(5)
|
||||
|
||||
# 3) 下载
|
||||
if s["status"] == "completed":
|
||||
mp4 = requests.get(f"{base}/videos/{vid}/content", headers=h).content
|
||||
open("out.mp4", "wb").write(mp4)`,
|
||||
# 与图像同一端点:quality 传分辨率档,duration 传秒数`,
|
||||
},
|
||||
{
|
||||
title: '列出模型 · curl',
|
||||
code:
|
||||
`curl ${base.value}/v1/models \\
|
||||
-H "Authorization: Bearer ${keyHint.value}"`,
|
||||
-H "Authorization: Bearer ${keyHint.value}"
|
||||
|
||||
# => [{"id":"...","kind":"image","qualities":["1K","2K"],"refImages":4}, ...]`,
|
||||
},
|
||||
{
|
||||
title: '查询余额 · curl',
|
||||
code:
|
||||
`curl ${base.value}/v1/user/balance \\
|
||||
`curl ${base.value}/v1/balance \\
|
||||
-H "Authorization: Bearer ${keyHint.value}"
|
||||
|
||||
# => {"object":"user.balance","balance":12000,"used":680,"total":12680}`,
|
||||
# => {"balance":980}`,
|
||||
},
|
||||
])
|
||||
|
||||
@@ -255,7 +191,7 @@ async function copy(text) {
|
||||
<header>
|
||||
<span class="sticker">✦ 开发者</span>
|
||||
<h1 class="mt-3 text-4xl md:text-5xl font-bold tracking-tight">接口文档</h1>
|
||||
<p class="text-white/45 mt-2">完全兼容 OpenAI 接口规范 — 改个 <code class="text-white/70">base_url</code> 和 <code class="text-white/70">api_key</code> 即可直接调用。图像 / 视频 / 图生图全支持。</p>
|
||||
<p class="text-white/45 mt-2">Vivid 协议 — 创建任务后连接保持到任务完成(NDJSON 流),一次性返回终态结果,无需轮询。图像 / 视频 / 参考素材全支持。</p>
|
||||
</header>
|
||||
|
||||
<!-- quickstart -->
|
||||
@@ -264,28 +200,25 @@ async function copy(text) {
|
||||
<h2 class="text-sm font-semibold text-white/80">基础信息</h2>
|
||||
<dl class="mt-4 space-y-3 text-sm">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<dt class="text-white/45">Base URL</dt><dd class="font-mono text-white/90">{{ base }}/v1</dd>
|
||||
<dt class="text-white/45">Base URL</dt><dd class="font-mono text-white/90">{{ base }}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<dt class="text-white/45">鉴权</dt><dd class="font-mono text-white/90">Authorization: Bearer <key></dd>
|
||||
<dt class="text-white/45">鉴权</dt><dd class="font-mono text-white/90">Authorization: Bearer vk_…</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<dt class="text-white/45">你的 Key</dt><dd class="font-mono text-white/70">{{ keyHint }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="text-[11px] text-white/40 mt-4">还没有 Key?去 <router-link to="/settings" class="text-violet-300 underline">设置 → API Key</router-link> 生成。</p>
|
||||
<p class="text-[11px] text-white/40 mt-4">还没有 Key?去 <router-link to="/settings" class="text-orange-300 underline">设置 → API Key</router-link> 生成。</p>
|
||||
</div>
|
||||
|
||||
<div class="card p-6">
|
||||
<h2 class="text-sm font-semibold text-white/80">端点</h2>
|
||||
<ul class="mt-4 space-y-2.5 text-sm font-mono">
|
||||
<li class="flex items-center gap-2"><span class="badge-get">GET</span><span class="text-white/80">/v1/models</span></li>
|
||||
<li class="flex items-center gap-2"><span class="badge-get">GET</span><span class="text-white/80">/v1/user/balance</span><span class="text-white/35 font-sans text-xs">查余额</span></li>
|
||||
<li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/images/generations</span><span class="text-white/35 font-sans text-xs">文生图</span></li>
|
||||
<li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/images/edits</span><span class="text-white/35 font-sans text-xs">图生图(multipart)</span></li>
|
||||
<li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/videos</span><span class="text-white/35 font-sans text-xs">建视频任务</span></li>
|
||||
<li class="flex items-center gap-2"><span class="badge-get">GET</span><span class="text-white/80">/v1/videos/{id}</span><span class="text-white/35 font-sans text-xs">查状态</span></li>
|
||||
<li class="flex items-center gap-2"><span class="badge-get">GET</span><span class="text-white/80">/v1/videos/{id}/content</span><span class="text-white/35 font-sans text-xs">下载 mp4</span></li>
|
||||
<li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/generate</span><span class="text-white/35 font-sans text-xs">创建任务(NDJSON 流)</span></li>
|
||||
<li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/uploads</span><span class="text-white/35 font-sans text-xs">参考素材上传会话</span></li>
|
||||
<li class="flex items-center gap-2"><span class="badge-get">GET</span><span class="text-white/80">/v1/models</span><span class="text-white/35 font-sans text-xs">可售模型 + 能力规格</span></li>
|
||||
<li class="flex items-center gap-2"><span class="badge-get">GET</span><span class="text-white/80">/v1/balance</span><span class="text-white/35 font-sans text-xs">查余额</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
@@ -323,13 +256,11 @@ async function copy(text) {
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex flex-wrap items-center gap-1 text-[11px]">
|
||||
<template v-if="m.type === 'image'">
|
||||
<span v-for="r in (m.resolutions || [])" :key="r" class="cap cap-k">{{ r }}</span>
|
||||
<template v-for="r in (m.resolutions || [])" :key="r">
|
||||
<span class="cap cap-k">{{ r }}</span>
|
||||
</template>
|
||||
<template v-if="m.type === 'video'">
|
||||
<span v-for="r in (m.resolutions || [])" :key="r" class="cap cap-k">{{ r }}</span>
|
||||
<span v-if="(m.durations || []).length >= 2" class="cap cap-dur">{{ m.durations[0] }}–{{ m.durations[m.durations.length - 1] }}</span>
|
||||
<span v-else-if="(m.durations || []).length === 1" class="cap cap-dur">{{ m.durations[0] }}</span>
|
||||
<span v-if="durText(m)" class="cap cap-dur">{{ durText(m) }}</span>
|
||||
</template>
|
||||
<span v-if="m.reference_mode === 'frame' && m.max_reference_images > 0" class="cap cap-frame">首尾帧 {{ Math.min(m.max_reference_images, 2) }}</span>
|
||||
<span v-if="m.reference_mode === 'frame' && m.max_reference_images > 2" class="cap cap-ref">参考图 {{ refImages(m) }}</span>
|
||||
@@ -353,14 +284,14 @@ async function copy(text) {
|
||||
<!-- parameters -->
|
||||
<section class="grid lg:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold mb-3">文生图参数 <span class="text-xs font-normal text-white/40">/v1/images/generations</span></h2>
|
||||
<h2 class="text-lg font-semibold mb-3">生成参数 <span class="text-xs font-normal text-white/40">POST /v1/generate</span></h2>
|
||||
<div class="card overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead><tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
|
||||
<th class="px-4 py-2.5 font-medium">参数</th><th class="px-4 py-2.5 font-medium">类型</th><th class="px-4 py-2.5 font-medium">必填</th><th class="px-4 py-2.5 font-medium">说明</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="p in imageParams" :key="p[0]" class="border-b border-white/[0.04] last:border-0">
|
||||
<tr v-for="p in generateParams" :key="p[0]" class="border-b border-white/[0.04] last:border-0">
|
||||
<td class="px-4 py-2.5 font-mono text-white/85">{{ p[0] }}</td>
|
||||
<td class="px-4 py-2.5 text-white/50 font-mono text-xs">{{ p[1] }}</td>
|
||||
<td class="px-4 py-2.5 text-white/55">{{ p[2] }}</td>
|
||||
@@ -372,14 +303,14 @@ async function copy(text) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold mb-3">图生图参数 <span class="text-xs font-normal text-white/40">/v1/images/edits · multipart</span></h2>
|
||||
<h2 class="text-lg font-semibold mb-3">上传参数 <span class="text-xs font-normal text-white/40">POST /v1/uploads</span></h2>
|
||||
<div class="card overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead><tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
|
||||
<th class="px-4 py-2.5 font-medium">参数</th><th class="px-4 py-2.5 font-medium">类型</th><th class="px-4 py-2.5 font-medium">必填</th><th class="px-4 py-2.5 font-medium">说明</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="p in editParams" :key="p[0]" class="border-b border-white/[0.04] last:border-0">
|
||||
<tr v-for="p in uploadParams" :key="p[0]" class="border-b border-white/[0.04] last:border-0">
|
||||
<td class="px-4 py-2.5 font-mono text-white/85">{{ p[0] }}</td>
|
||||
<td class="px-4 py-2.5 text-white/50 font-mono text-xs">{{ p[1] }}</td>
|
||||
<td class="px-4 py-2.5 text-white/55">{{ p[2] }}</td>
|
||||
@@ -388,85 +319,13 @@ async function copy(text) {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="text-xs text-white/40 mt-3">
|
||||
用法:POST 拿 <code class="text-white/70">url</code> → PUT 直传 → 返回的 <code class="text-white/70">key</code> 放进
|
||||
<code class="text-white/70">/v1/generate</code> 的 <code class="text-white/70">refs</code>。
|
||||
预签名 URL 仅用于 PUT(不可 GET 读回),30 分钟内有效。
|
||||
支持:图 png/jpeg/webp,视频 mp4/webm/mov,音频 mp3/wav/m4a/ogg。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-2">
|
||||
<h2 class="text-lg font-semibold mb-3">视频参数 <span class="text-xs font-normal text-white/40">/v1/videos · 异步</span></h2>
|
||||
<div class="card overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead><tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
|
||||
<th class="px-4 py-2.5 font-medium">参数</th><th class="px-4 py-2.5 font-medium">类型</th><th class="px-4 py-2.5 font-medium">必填</th><th class="px-4 py-2.5 font-medium">说明</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="p in videoParams" :key="p[0]" class="border-b border-white/[0.04] last:border-0">
|
||||
<td class="px-4 py-2.5 font-mono text-white/85">{{ p[0] }}</td>
|
||||
<td class="px-4 py-2.5 text-white/50 font-mono text-xs">{{ p[1] }}</td>
|
||||
<td class="px-4 py-2.5 text-white/55">{{ p[2] }}</td>
|
||||
<td class="px-4 py-2.5 text-white/60 text-xs">{{ p[3] }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- size 对照表(课时表)—— 解决"传错分辨率" -->
|
||||
<section>
|
||||
<h2 class="text-lg font-semibold mb-1">图像分辨率对照表 · <code class="text-white/70 text-sm">size</code> 该传什么</h2>
|
||||
<p class="text-xs text-white/45 mb-3">
|
||||
左边选比例,上面选分辨率档,交叉格里就是 <code class="text-white/70">size</code> 要传的值(直接复制)。
|
||||
没有 <code class="text-white/70">quality</code> 参数,图像分辨率只看 <code class="text-white/70">size</code> 的<strong class="text-white/70">长边</strong>。
|
||||
档位必须是该模型支持的(见上方「可用模型」的分辨率列),不支持会自动回退到该模型最低档。
|
||||
</p>
|
||||
<div class="card overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead><tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
|
||||
<th class="px-4 py-2.5 font-medium">比例</th>
|
||||
<th class="px-4 py-2.5 font-medium">1K</th>
|
||||
<th class="px-4 py-2.5 font-medium">2K</th>
|
||||
<th class="px-4 py-2.5 font-medium">4K</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="row in sizeTable" :key="row.ratio" class="border-b border-white/[0.04] last:border-0">
|
||||
<td class="px-4 py-2.5 text-white/75">{{ row.ratio }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-white/85">{{ row.k1 }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-white/85">{{ row.k2 }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-white/85">{{ row.k4 }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="text-xs text-white/40 mt-2">
|
||||
例:想要 <strong class="text-white/70">2K 的 16:9 横图</strong> → <code class="text-white/70">"size": "2048x1152"</code>。
|
||||
留空 size = 默认 <strong class="text-white/70">1:1 · 2K</strong>。
|
||||
</p>
|
||||
|
||||
<!-- 视频分辨率(720p / 1080p,按短边判) -->
|
||||
<h2 class="text-lg font-semibold mb-1 mt-8">视频分辨率对照表 · <code class="text-white/70 text-sm">size</code> 该传什么</h2>
|
||||
<p class="text-xs text-white/45 mb-3">
|
||||
视频用 <code class="text-white/70">720p</code> / <code class="text-white/70">1080p</code> 两档,只看 <code class="text-white/70">size</code> 的<strong class="text-white/70">短边</strong>(短边 ≥1080 = 1080p,否则 720p)。
|
||||
档位必须是该视频模型支持的(如 grok-video 仅 720p),不支持会被拒。
|
||||
</p>
|
||||
<div class="card overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead><tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
|
||||
<th class="px-4 py-2.5 font-medium">比例</th>
|
||||
<th class="px-4 py-2.5 font-medium">720p</th>
|
||||
<th class="px-4 py-2.5 font-medium">1080p</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="row in videoSizeTable" :key="row.ratio" class="border-b border-white/[0.04] last:border-0">
|
||||
<td class="px-4 py-2.5 text-white/75">{{ row.ratio }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-white/85">{{ row.p720 }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-white/85">{{ row.p1080 }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="text-xs text-white/40 mt-2">
|
||||
例:想要 <strong class="text-white/70">720p 的 16:9 横版视频</strong> → <code class="text-white/70">"size": "1280x720"</code>;
|
||||
竖版 9:16 → <code class="text-white/70">"720x1280"</code>。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- examples -->
|
||||
@@ -488,23 +347,19 @@ async function copy(text) {
|
||||
<section>
|
||||
<h2 class="text-lg font-semibold mb-3">响应 & 计费</h2>
|
||||
<div class="card p-6 space-y-3 text-sm text-white/70">
|
||||
<p><strong class="text-white/90">图像</strong>(generations / edits)返回 OpenAI 图片格式:<code class="text-white/85 font-mono">{{ '{ "created": ..., "data": [{ "url": "..." }] }' }}</code> —— <code class="text-white/85 font-mono">data[0].url</code> 是产物 URL,服务端不留存(<strong class="text-white/90">不返回 base64</strong>)。多数模型返回上游<strong class="text-white/90">原始直链</strong>;少数上游需鉴权的(如 gpt-image)会返回一个本站转发链 <code class="text-white/85 font-mono">/v1/images/{id}/content</code>,由服务端带账号凭据取回。<strong class="text-white/90">两种链接都会过期</strong>,请<strong class="text-white/90">尽快下载或转存到你自己的存储</strong>。</p>
|
||||
<p><strong class="text-white/90">视频</strong>(异步,Sora 风格三步):</p>
|
||||
<ol class="list-decimal list-inside space-y-1 text-white/65 pl-1">
|
||||
<li><code class="text-white/85 font-mono">POST /v1/videos</code> 立即返回任务对象 <code class="text-white/85 font-mono">{{ '{ "id": "...", "object": "video", "status": "queued", ... }' }}</code></li>
|
||||
<li>轮询 <code class="text-white/85 font-mono">GET /v1/videos/{id}</code>,<code class="text-white/70">status</code> 从 <code class="text-white/70">queued → in_progress → completed</code>(或 <code class="text-white/70">failed</code>)</li>
|
||||
<li>完成后 <code class="text-white/85 font-mono">GET /v1/videos/{id}/content</code> 返回 <strong class="text-white/90">mp4 原始二进制</strong>(非 base64、非 URL)</li>
|
||||
</ol>
|
||||
<p><strong class="text-white/90">计费(预扣)</strong>:生成<strong class="text-white/90">前</strong>按上表价格从你的 Key 账号预扣积分;图像或视频上游失败会自动退回 —— 失败不扣费。</p>
|
||||
<p><strong class="text-white/90">参数映射</strong>:<code class="text-white/70">size</code>(宽x高)同时决定<strong class="text-white/90">比例 + 分辨率档</strong>(长边:<1800→1K · 1800–3499→2K · ≥3500→4K),<code class="text-white/70">seconds</code>→视频时长。<strong class="text-white/90">没有 quality 参数</strong>,分辨率只看 size。档位须是该模型支持的(不支持会回退到该模型最低档);参数须落在定价表内否则 400,余额不足 402。</p>
|
||||
<p><strong class="text-white/90">NDJSON 流</strong>(<code class="text-white/85 font-mono">application/x-ndjson</code>):连接建立即回执首行
|
||||
<code class="text-white/85 font-mono">{{ '{"jobId":"…","status":"running"}' }}</code>;
|
||||
等待期间每 25 秒一个空行心跳(请忽略);末行为终态
|
||||
<code class="text-white/85 font-mono">succeeded</code> / <code class="text-white/85 font-mono">failed</code> —— 读最后一行 JSON 即结果。</p>
|
||||
<p><strong class="text-white/90">断流重连</strong>:流意外断开时,只带 <code class="text-white/85 font-mono">jobId</code> 重发同一请求即可重连续等 —— 不会重复创建任务、不重复扣费;已完成直接回终态帧。</p>
|
||||
<p><strong class="text-white/90">产物 URL</strong>:<code class="text-white/85 font-mono">result</code> 是只读预签名 URL(30 分钟),过期即焚,请及时下载转存。任务类型即文件类型。</p>
|
||||
<p><strong class="text-white/90">计费(预扣)</strong>:生成<strong class="text-white/90">前</strong>按上表价格从你的 Key 账号预扣积分;终态 <code class="text-white/85 font-mono">failed</code> 时 <code class="text-white/85 font-mono">refunded:true</code>,预扣积分自动退回 —— 失败不扣费。</p>
|
||||
<div class="pt-2 grid sm:grid-cols-2 gap-2 text-xs">
|
||||
<div class="flex items-center gap-2"><span class="badge-err">401</span> Key 无效 / 上游需重新授权</div>
|
||||
<div class="flex items-center gap-2"><span class="badge-err">404</span> 未知 model / 视频任务不存在</div>
|
||||
<div class="flex items-center gap-2"><span class="badge-err">400</span> 参数缺失 / 不支持或未定价</div>
|
||||
<div class="flex items-center gap-2"><span class="badge-err">402</span> 积分不足</div>
|
||||
<div class="flex items-center gap-2"><span class="badge-err">409</span> 视频尚未完成(content 未就绪)</div>
|
||||
<div class="flex items-center gap-2"><span class="badge-err">429</span> 账号并发已满,请重试</div>
|
||||
<div class="flex items-center gap-2"><span class="badge-err">503</span> 上游繁忙,请重试</div>
|
||||
<div class="flex items-center gap-2"><span class="badge-err">4001</span> 参数/业务拒绝:模型不可用、quality 不在白名单、refs 超限等</div>
|
||||
<div class="flex items-center gap-2"><span class="badge-err">4002</span> 积分不足,请先充值</div>
|
||||
<div class="flex items-center gap-2"><span class="badge-err">4011</span> API Key 无效或未授权</div>
|
||||
<div class="flex items-center gap-2"><span class="badge-err">4293</span> 并发任务数已达上限 / 上游繁忙,请重试</div>
|
||||
<div class="flex items-center gap-2"><span class="badge-err">5001</span> 服务异常,请稍后重试</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -528,15 +383,15 @@ html.dark .badge-err { background: rgb(244 63 94 / 0.15); color: rgb(253 164 175
|
||||
|
||||
.cap { display: inline-flex; align-items: center; padding: 0.18rem 0.55rem; font-size: 0.7rem; border-radius: 9999px; white-space: nowrap; }
|
||||
.cap-k { background: rgb(16 185 129 / 0.12); color: rgb(4 120 87); box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.3); }
|
||||
.cap-dur { background: rgb(99 102 241 / 0.12); color: rgb(55 48 163); box-shadow: inset 0 0 0 1px rgb(99 102 241 / 0.3); }
|
||||
.cap-frame { background: rgb(236 72 153 / 0.12); color: rgb(159 18 57); box-shadow: inset 0 0 0 1px rgb(236 72 153 / 0.3); }
|
||||
.cap-dur { background: rgb(14 165 233 / 0.12); color: rgb(55 48 163); box-shadow: inset 0 0 0 1px rgb(14 165 233 / 0.3); }
|
||||
.cap-frame { background: rgb(251 113 133 / 0.12); color: rgb(159 18 57); box-shadow: inset 0 0 0 1px rgb(251 113 133 / 0.3); }
|
||||
.cap-ref { background: rgb(16 185 129 / 0.12); color: rgb(4 120 87); box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.3); }
|
||||
.cap-media { background: rgb(245 158 11 / 0.12); color: rgb(146 64 14); box-shadow: inset 0 0 0 1px rgb(245 158 11 / 0.3); }
|
||||
.cap-price { background: rgb(14 165 233 / 0.12); color: rgb(3 105 161); box-shadow: inset 0 0 0 1px rgb(14 165 233 / 0.3); font-variant-numeric: tabular-nums; }
|
||||
.cap-ratio { background: rgb(100 116 139 / 0.1); color: rgb(51 65 85); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; box-shadow: inset 0 0 0 1px rgb(100 116 139 / 0.25); }
|
||||
html.dark .cap-k { background: rgb(16 185 129 / 0.18); color: rgb(110 231 183); box-shadow: inset 0 0 0 1px rgb(52 211 153 / 0.4); }
|
||||
html.dark .cap-dur { background: rgb(99 102 241 / 0.18); color: rgb(165 180 252); box-shadow: inset 0 0 0 1px rgb(129 140 248 / 0.4); }
|
||||
html.dark .cap-frame { background: rgb(236 72 153 / 0.18); color: rgb(244 114 182); box-shadow: inset 0 0 0 1px rgb(244 114 182 / 0.45); }
|
||||
html.dark .cap-dur { background: rgb(14 165 233 / 0.18); color: rgb(147 197 253); box-shadow: inset 0 0 0 1px rgb(129 140 248 / 0.4); }
|
||||
html.dark .cap-frame { background: rgb(251 113 133 / 0.18); color: rgb(244 114 182); box-shadow: inset 0 0 0 1px rgb(244 114 182 / 0.45); }
|
||||
html.dark .cap-ref { background: rgb(16 185 129 / 0.18); color: rgb(110 231 183); box-shadow: inset 0 0 0 1px rgb(52 211 153 / 0.4); }
|
||||
html.dark .cap-media { background: rgb(245 158 11 / 0.18); color: rgb(252 211 77); box-shadow: inset 0 0 0 1px rgb(252 211 77 / 0.45); }
|
||||
html.dark .cap-price { background: rgb(14 165 233 / 0.18); color: rgb(125 211 252); box-shadow: inset 0 0 0 1px rgb(56 189 248 / 0.45); }
|
||||
|
||||
+254
-106
@@ -4,8 +4,6 @@ import { useRouter } from 'vue-router'
|
||||
import { api, generatedUrl } from '../api'
|
||||
import { site } from '../site'
|
||||
import { isAuthed, openLogin } from '../auth'
|
||||
import Mascot from '../components/Mascot.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// Navigate to a signed-in page, or pop the login modal (remembering the
|
||||
@@ -123,32 +121,32 @@ const caps = computed(() => {
|
||||
{
|
||||
key: 't2i', title: '文字生图', model: imageModels.value[0]?.id,
|
||||
desc: '一句话出图,画幅与分辨率按所选模型的可用范围给出。',
|
||||
tint: 'rgb(52 211 153 / 0.14)', color: 'rgb(52 211 153)',
|
||||
tint: 'linear-gradient(135deg,#34d399,#2dd4bf)', color: '#fff',
|
||||
metas: [resRange.value, `${imageModels.value.length} 个模型`].filter(Boolean),
|
||||
},
|
||||
{
|
||||
key: 'i2i', title: '参考图生图', model: i2iModels[0]?.id,
|
||||
desc: '上传参考图换风格、改构图、做变体,支持多张参考。',
|
||||
tint: 'rgb(217 70 239 / 0.14)', color: 'rgb(232 121 249)',
|
||||
tint: 'linear-gradient(135deg,#e879f9,#f472b6)', color: '#fff',
|
||||
metas: ['多图参考', `${i2iModels.length} 个模型`],
|
||||
},
|
||||
{
|
||||
key: 't2v', title: '文字生视频', model: videoModels.value[0]?.id,
|
||||
desc: '一段描述生成短片,时长与分辨率按模型能力可选。',
|
||||
tint: 'rgb(14 165 233 / 0.14)', color: 'rgb(56 189 248)',
|
||||
tint: 'linear-gradient(135deg,#38bdf8,#22d3ee)', color: '#fff',
|
||||
metas: [durationRange.value, videoRes.value, `${videoModels.value.length} 个模型`].filter(Boolean),
|
||||
},
|
||||
{
|
||||
key: 'i2v', title: '图生视频', model: i2vModels[0]?.id,
|
||||
desc: '给参考图或首帧,让静态画面动起来。',
|
||||
tint: 'rgb(245 158 11 / 0.14)', color: 'rgb(251 191 36)',
|
||||
tint: 'linear-gradient(135deg,#7c5cfc,#a855f7)', color: '#fff',
|
||||
metas: ['参考图', `${i2vModels.length} 个模型`],
|
||||
},
|
||||
].filter((c) => c.model)
|
||||
})
|
||||
|
||||
// ---- model cards: the admin-set alias wins, so no upstream naming leaks ----
|
||||
const MODEL_TINT = ['#a78bfa', '#f0abfc', '#fb7185', '#fbbf24', '#38bdf8', '#34d399', '#94a3b8']
|
||||
const MODEL_TINT = ['#7c5cfc', '#22d3ee', '#e879f9', '#34d399', '#a78bfa', '#38bdf8', '#94a3b8']
|
||||
const modelCards = computed(() => managed.value.map((m, i) => {
|
||||
const bits = []
|
||||
if (m.resolutions?.length) bits.push(m.resolutions.join(' / '))
|
||||
@@ -199,38 +197,49 @@ function useExample(ex) {
|
||||
|
||||
<template>
|
||||
<div class="px-4 md:px-8">
|
||||
<!-- ============ HERO (centered, mascot-forward) ============ -->
|
||||
<section class="hero-band relative overflow-hidden pt-16 md:pt-24 pb-10">
|
||||
<!-- ============ HERO (centered, memphis deco) ============ -->
|
||||
<section class="hero-band relative overflow-hidden pt-14 md:pt-20 pb-12">
|
||||
<div class="anime-stars"></div>
|
||||
<div class="relative flex flex-col items-center text-center">
|
||||
<!-- mascot centerpiece with soft aura -->
|
||||
<div class="relative mb-6">
|
||||
<span class="mascot-aura"></span>
|
||||
<Mascot :size="184" class="relative" />
|
||||
</div>
|
||||
|
||||
<!-- floating aurora shapes (desktop only) -->
|
||||
<div aria-hidden="true" class="pointer-events-none absolute inset-0 hidden lg:block">
|
||||
<span class="deco deco-a">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 5.8a2 2 0 0 0 1.3 1.3L21 12l-5.8 1.9a2 2 0 0 0-1.3 1.3L12 21l-1.9-5.8a2 2 0 0 0-1.3-1.3L3 12l5.8-1.9a2 2 0 0 0 1.3-1.3z"/></svg>
|
||||
</span>
|
||||
<span class="deco deco-b">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2 3 14h7l-1 8 10-12h-7z"/></svg>
|
||||
</span>
|
||||
<span class="deco deco-c">4K</span>
|
||||
<span class="deco deco-d">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h13v12H3z"/><path d="M16 10l5-3v10l-5-3"/></svg>
|
||||
</span>
|
||||
<span class="deco deco-star">✦</span>
|
||||
<span class="deco deco-star deco-star-2">✦</span>
|
||||
</div>
|
||||
|
||||
<div class="relative flex flex-col items-center text-center">
|
||||
<span class="sticker">
|
||||
<span class="inline-block w-1.5 h-1.5 rounded-full bg-emerald-400 shadow-[0_0_8px_rgb(52_211_153)]"></span>
|
||||
<span class="inline-block w-1.5 h-1.5 rounded-full bg-emerald-500 border border-[#111]"></span>
|
||||
{{ modelCount }} 个模型在线 · 图像与视频一站生成
|
||||
</span>
|
||||
|
||||
<h1 class="mt-5 font-extrabold tracking-tight leading-[1.05] text-[clamp(2.6rem,6vw,4.6rem)] text-[color:var(--fg)]">
|
||||
<h1 class="mt-5 font-black tracking-tight leading-[1.05] text-[clamp(2.6rem,6vw,4.6rem)] text-[color:var(--fg)]">
|
||||
把想象 <span class="anime-grad-text">说给 AI 听</span>
|
||||
</h1>
|
||||
|
||||
<p class="mt-5 text-[15px] md:text-base text-[color:var(--fg-2)] max-w-xl leading-relaxed">
|
||||
<p class="mt-5 text-[15px] md:text-base font-medium text-[color:var(--fg-2)] max-w-xl leading-relaxed">
|
||||
写一句话,剩下的交给我们:自动选模型、自动排队,图像与视频都在同一个工作台里出片。
|
||||
</p>
|
||||
|
||||
<!-- prompt composer — the shortest path into the workbench -->
|
||||
<form class="composer mt-8 !max-w-2xl w-full" @submit.prevent="submitPrompt">
|
||||
<form class="composer mt-9 !max-w-2xl w-full" @submit.prevent="submitPrompt">
|
||||
<input v-model="prompt" class="composer-input" type="text"
|
||||
placeholder="描述你想要的画面,比如:雨夜的东京小巷,霓虹倒映在湿滑路面…" />
|
||||
<button type="submit" class="composer-go">✦ 生成</button>
|
||||
</form>
|
||||
|
||||
<!-- quick starters come from the curated bento set -->
|
||||
<div v-if="bento.length" class="mt-4 flex flex-wrap justify-center gap-2 max-w-2xl">
|
||||
<div v-if="bento.length" class="mt-5 flex flex-wrap justify-center gap-2 max-w-2xl">
|
||||
<button v-for="ex in bento.slice(0, 5)" :key="ex.id" type="button" class="chip"
|
||||
@click="useExample(ex)">{{ ex.title }}</button>
|
||||
</div>
|
||||
@@ -238,7 +247,7 @@ function useExample(ex) {
|
||||
</section>
|
||||
|
||||
<!-- ============ GALLERY MARQUEE (hero + bento imagery) ============ -->
|
||||
<section v-if="gallery.length" class="mt-2 -mx-4 md:-mx-8 overflow-hidden marquee-mask">
|
||||
<section v-if="gallery.length" class="mt-6 -mx-4 md:-mx-8 overflow-hidden marquee-mask border-y border-y-[color:var(--ink-soft)] bg-[var(--surface)] py-4">
|
||||
<div class="marquee flex gap-4 px-4 md:px-8 w-max">
|
||||
<button v-for="(card, i) in [...gallery, ...gallery]" :key="card.id + '-' + i" type="button"
|
||||
class="gcard" :style="cardBg(card)" :aria-label="card.title" @click="useExample(card)">
|
||||
@@ -254,23 +263,28 @@ function useExample(ex) {
|
||||
<!-- ============ KPI STRIP ============ -->
|
||||
<section class="mt-12 grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div v-for="k in [
|
||||
{ v: modelCount, l: '在线模型', e: '🧩' },
|
||||
{ v: caps.length, l: '创作方式', e: '🎨' },
|
||||
{ v: resRange, l: '输出分辨率', e: '🖼️' },
|
||||
{ v: avgLabel, l: '平均出片 · 近 24h', e: '⚡' },
|
||||
{ v: modelCount, l: '在线模型', i: 'models', tint: 'linear-gradient(135deg,#7c5cfc,#a855f7)' },
|
||||
{ v: caps.length, l: '创作方式', i: 'wand', tint: 'linear-gradient(135deg,#e879f9,#f472b6)' },
|
||||
{ v: resRange, l: '输出分辨率', i: 'image', tint: 'linear-gradient(135deg,#38bdf8,#22d3ee)' },
|
||||
{ v: avgLabel, l: '平均出片 · 近 24h', i: 'bolt', tint: 'linear-gradient(135deg,#34d399,#2dd4bf)' },
|
||||
]" :key="k.l"
|
||||
class="anime-card px-5 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-lg">{{ k.e }}</span>
|
||||
<span class="text-[26px] font-bold tabular-nums text-[color:var(--fg)]">{{ k.v }}</span>
|
||||
class="kpi-card px-5 py-4">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span class="kpi-ico" :style="{ background: k.tint }">
|
||||
<svg v-if="k.i === 'models'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>
|
||||
<svg v-else-if="k.i === 'wand'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 4 5 5L7 22l-5-5z"/><path d="m14 7 3 3"/><path d="M9 6V4M7 8H5M9 10v2M11 8h2"/></svg>
|
||||
<svg v-else-if="k.i === 'image'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 16 5-5 3 3 4-4 6 6"/><circle cx="9" cy="9" r="1.4"/></svg>
|
||||
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2 3 14h7l-1 8 10-12h-7z"/></svg>
|
||||
</span>
|
||||
<span class="text-[26px] font-black tabular-nums text-[color:var(--fg)]">{{ k.v }}</span>
|
||||
</div>
|
||||
<div class="mt-1.5 text-[10px] uppercase tracking-[0.18em] text-[color:var(--fg-3)]">{{ k.l }}</div>
|
||||
<div class="mt-1.5 text-[10px] font-bold uppercase tracking-[0.18em] text-[color:var(--fg-3)]">{{ k.l }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ CAPABILITIES ============ -->
|
||||
<section class="mt-24">
|
||||
<div class="anime-eyebrow text-violet-500 dark:text-violet-300/80">能力</div>
|
||||
<div class="anime-eyebrow text-[var(--m-orange)]">能力</div>
|
||||
<h2 class="mt-3 text-[clamp(1.65rem,3vw,2.3rem)] font-extrabold tracking-tight text-[color:var(--fg)]">
|
||||
{{ caps.length }} 种方式开始创作
|
||||
</h2>
|
||||
@@ -299,7 +313,7 @@ function useExample(ex) {
|
||||
|
||||
<!-- ============ INSPIRATION (kind=bento) ============ -->
|
||||
<section v-if="bento.length" id="bento" class="mt-24">
|
||||
<div class="anime-eyebrow text-fuchsia-500 dark:text-fuchsia-300/80">灵感</div>
|
||||
<div class="anime-eyebrow text-rose-500">灵感</div>
|
||||
<h2 class="mt-3 text-[clamp(1.65rem,3vw,2.3rem)] font-extrabold tracking-tight text-[color:var(--fg)]">点一下,套用同款提示词</h2>
|
||||
<p class="mt-2.5 text-[13.5px] text-[color:var(--fg-3)] max-w-lg leading-relaxed">
|
||||
任选一张,自动进入画图工作台并预填提示词。
|
||||
@@ -320,7 +334,7 @@ function useExample(ex) {
|
||||
|
||||
<!-- ============ WORKS (kind=work) ============ -->
|
||||
<section v-if="works.length" class="mt-24">
|
||||
<div class="anime-eyebrow text-sky-500 dark:text-sky-300/80">作品</div>
|
||||
<div class="anime-eyebrow text-sky-600">作品</div>
|
||||
<h2 class="mt-3 text-[clamp(1.65rem,3vw,2.3rem)] font-extrabold tracking-tight text-[color:var(--fg)]">大家都在生成什么</h2>
|
||||
<p class="mt-2.5 text-[13.5px] text-[color:var(--fg-3)] max-w-lg leading-relaxed">
|
||||
精选公开作品,点开即用同一条提示词继续创作。
|
||||
@@ -340,7 +354,7 @@ function useExample(ex) {
|
||||
|
||||
<!-- ============ MODELS ============ -->
|
||||
<section v-if="modelCards.length" class="mt-24">
|
||||
<div class="anime-eyebrow text-emerald-500 dark:text-emerald-300/80">模型</div>
|
||||
<div class="anime-eyebrow text-emerald-600">模型</div>
|
||||
<h2 class="mt-3 text-[clamp(1.65rem,3vw,2.3rem)] font-extrabold tracking-tight text-[color:var(--fg)]">在线模型</h2>
|
||||
<p class="mt-2.5 text-[13.5px] text-[color:var(--fg-3)] max-w-lg leading-relaxed">
|
||||
每个模型的分辨率、时长与参考图能力都写在卡片上,选之前就知道能做什么。
|
||||
@@ -359,7 +373,7 @@ function useExample(ex) {
|
||||
</section>
|
||||
|
||||
<!-- ============ CTA ============ -->
|
||||
<section class="mt-24 cta-band relative overflow-hidden rounded-[2rem] ring-1 ring-[color:var(--hairline)] p-10 md:p-16">
|
||||
<section class="mt-24 cta-band relative overflow-hidden rounded-[1.4rem] p-10 md:p-16">
|
||||
<div class="relative max-w-xl">
|
||||
<h3 class="text-[clamp(1.7rem,3.4vw,2.8rem)] font-extrabold tracking-tight leading-[1.2] text-[color:var(--fg)]">
|
||||
与其继续想,<br />不如现在写一句。
|
||||
@@ -368,15 +382,15 @@ function useExample(ex) {
|
||||
注册即可开始,图像与视频都能试;不满意就换个模型,额度按次计费。
|
||||
</p>
|
||||
<button type="button" @click="go('/user')"
|
||||
class="mt-7 group inline-flex items-center gap-3 rounded-full bg-[var(--btn-solid-bg)] text-[color:var(--btn-solid-fg)] hover:bg-[var(--btn-solid-bg-h)] pl-6 pr-3 py-3 text-sm font-semibold transition-all">
|
||||
class="mt-7 group inline-flex items-center gap-3 rounded-xl text-white pl-6 pr-3 py-3 text-sm font-extrabold transition-all cta-go">
|
||||
免费开始
|
||||
<span class="w-8 h-8 rounded-full bg-[var(--btn-solid-fg)] text-[color:var(--btn-solid-bg)] grid place-items-center group-hover:translate-x-1 transition-transform">→</span>
|
||||
<span class="w-8 h-8 rounded-lg bg-white/20 border border-white/30 grid place-items-center font-black group-hover:translate-x-1 transition-transform">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- thin footer line -->
|
||||
<footer class="mt-16 pt-10 border-t border-[color:var(--hairline)] flex flex-wrap items-center justify-between gap-4 text-xs text-[color:var(--fg-3)]">
|
||||
<footer class="mt-16 pt-10 border-t border-t-[color:var(--ink-soft)] flex flex-wrap items-center justify-between gap-4 text-xs font-semibold text-[color:var(--fg-3)]">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-semibold text-[color:var(--fg-2)]">{{ site.title }}</span>
|
||||
<span>·</span>
|
||||
@@ -398,13 +412,18 @@ function useExample(ex) {
|
||||
gap: 0.5rem;
|
||||
max-width: 36rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 1.25rem;
|
||||
border-radius: 1.1rem;
|
||||
background: var(--surface);
|
||||
box-shadow: inset 0 0 0 1px var(--hairline), 0 20px 50px -22px rgb(139 92 246 / 0.3);
|
||||
transition: box-shadow 0.25s ease;
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--glass-shadow-lg);
|
||||
transition: box-shadow 0.2s ease, transform 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
.composer:focus-within {
|
||||
box-shadow: inset 0 0 0 1px rgb(139 92 246 / 0.55), 0 20px 60px -20px rgb(139 92 246 / 0.45);
|
||||
transform: translateY(-2px);
|
||||
border-color: rgb(124 92 252 / 0.5);
|
||||
box-shadow: 0 18px 50px var(--accent-glow), var(--glass-shadow);
|
||||
}
|
||||
.composer-input {
|
||||
flex: 1;
|
||||
@@ -414,45 +433,56 @@ function useExample(ex) {
|
||||
outline: none;
|
||||
color: var(--fg);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
padding: 0.7rem 0.85rem;
|
||||
}
|
||||
.composer-input::placeholder { color: var(--fg-faint); }
|
||||
.composer-go {
|
||||
flex: none;
|
||||
border: none;
|
||||
border: 1px solid rgb(255 255 255 / 0.16);
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
padding: 0.7rem 1.25rem;
|
||||
border-radius: 0.9rem;
|
||||
background: linear-gradient(135deg, #e879f9, #8b5cf6);
|
||||
transition: transform 0.15s ease, filter 0.15s ease;
|
||||
font-weight: 800;
|
||||
padding: 0.65rem 1.2rem;
|
||||
border-radius: 0.75rem;
|
||||
background: linear-gradient(120deg, #7c5cfc, #5f6cf8 55%, #22d3ee 140%);
|
||||
box-shadow: 0 6px 18px var(--accent-glow);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease, filter 0.15s ease;
|
||||
}
|
||||
.composer-go:hover { transform: translateY(-1px); filter: brightness(1.1); }
|
||||
.composer-go:hover { transform: translateY(-1px); box-shadow: 0 10px 26px var(--accent-glow); filter: brightness(1.07); }
|
||||
.composer-go:active { transform: scale(0.96) translateY(1px); box-shadow: 0 3px 10px var(--accent-glow); }
|
||||
|
||||
.chip {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--fg-2);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
padding: 0.42rem 0.8rem;
|
||||
border: 1px solid var(--ink-soft);
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
box-shadow: inset 0 0 0 1px var(--hairline);
|
||||
transition: color 0.15s ease, box-shadow 0.15s ease;
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
backdrop-filter: blur(10px);
|
||||
transition: color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.chip:hover {
|
||||
color: var(--fg);
|
||||
background: var(--hover);
|
||||
border-color: rgb(124 92 252 / 0.45);
|
||||
box-shadow: 0 4px 14px rgb(124 92 252 / 0.18);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.chip:hover { color: var(--fg); box-shadow: inset 0 0 0 1px rgb(139 92 246 / 0.5); }
|
||||
|
||||
/* ---------- hero cards (kind=hero) ---------- */
|
||||
.fcard {
|
||||
position: absolute;
|
||||
border: none;
|
||||
border: 1px solid var(--glass-border);
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 1.4rem;
|
||||
border-radius: 1.1rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 30px 70px -24px rgb(0 0 0 / 0.55), inset 0 0 0 1px rgb(255 255 255 / 0.14);
|
||||
box-shadow: var(--glass-shadow-lg);
|
||||
transition: transform 0.45s cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||
animation: fcardIn 0.7s cubic-bezier(0.2, 0.7, 0.2, 1) backwards;
|
||||
}
|
||||
@@ -497,34 +527,56 @@ function useExample(ex) {
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border: 1px solid var(--glass-border);
|
||||
padding: 1.35rem;
|
||||
border-radius: 1.25rem;
|
||||
border-radius: 1.1rem;
|
||||
background: var(--surface);
|
||||
box-shadow: inset 0 0 0 1px var(--hairline);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow: var(--glass-shadow);
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
.cap-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -3rem;
|
||||
right: -3rem;
|
||||
width: 9rem;
|
||||
height: 9rem;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgb(124 92 252 / 0.22), transparent 65%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
.cap-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: inset 0 0 0 1px rgb(139 92 246 / 0.45), 0 20px 40px -24px rgb(139 92 246 / 0.45);
|
||||
border-color: rgb(124 92 252 / 0.45);
|
||||
box-shadow: var(--glass-shadow-lg), 0 10px 30px rgb(124 92 252 / 0.14);
|
||||
}
|
||||
.cap-arrow { position: absolute; top: 1.1rem; right: 1.1rem; color: var(--fg-3); transition: color 0.15s ease; }
|
||||
.cap-card:hover .cap-arrow { color: var(--fg); }
|
||||
.cap-card:hover::before { opacity: 1; }
|
||||
.cap-arrow { position: absolute; top: 1.1rem; right: 1.1rem; color: var(--fg-3); transition: color 0.15s ease, transform 0.15s ease; }
|
||||
.cap-card:hover .cap-arrow { color: var(--m-orange); transform: translate(2px, -2px); }
|
||||
.cap-icon {
|
||||
position: relative;
|
||||
width: 2.6rem;
|
||||
height: 2.6rem;
|
||||
border-radius: 0.85rem;
|
||||
border-radius: 0.8rem;
|
||||
border: 1px solid var(--ink-soft);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-bottom: 0.9rem;
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.cap-icon svg { width: 1.25rem; height: 1.25rem; }
|
||||
.cap-meta {
|
||||
font-style: normal;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--fg-2);
|
||||
padding: 0.18rem 0.5rem;
|
||||
border-radius: 0.4rem;
|
||||
border: 1px solid var(--ink-soft);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
@@ -532,14 +584,14 @@ function useExample(ex) {
|
||||
.tile {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border: 1px solid var(--glass-border);
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 1.25rem;
|
||||
box-shadow: inset 0 0 0 1px var(--hairline);
|
||||
transition: transform 0.35s ease;
|
||||
border-radius: 1.1rem;
|
||||
box-shadow: var(--glass-shadow);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
.tile:hover { transform: translateY(-3px); }
|
||||
.tile:hover { transform: translateY(-3px); border-color: rgb(34 211 238 / 0.5); box-shadow: var(--glass-shadow-lg), 0 10px 30px rgb(34 211 238 / 0.14); }
|
||||
.tile-veil {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -566,12 +618,14 @@ function useExample(ex) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border: 1px solid var(--glass-border);
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 1.1rem;
|
||||
box-shadow: inset 0 0 0 1px var(--hairline);
|
||||
box-shadow: var(--glass-shadow);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
.work:hover { transform: translateY(-3px); border-color: rgb(232 121 249 / 0.5); box-shadow: var(--glass-shadow-lg), 0 10px 30px rgb(232 121 249 / 0.14); }
|
||||
.work-img { display: block; width: 100%; aspect-ratio: 4 / 5; transition: transform 0.5s ease; }
|
||||
.work:hover .work-img { transform: scale(1.04); }
|
||||
.work-overlay {
|
||||
@@ -606,11 +660,13 @@ function useExample(ex) {
|
||||
margin-top: 0.7rem;
|
||||
font-style: normal;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: rgb(11 13 19);
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
background: linear-gradient(120deg, #7c5cfc, #22d3ee);
|
||||
border: 1px solid rgb(255 255 255 / 0.25);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
box-shadow: 0 4px 14px rgb(124 92 252 / 0.4);
|
||||
}
|
||||
|
||||
/* ---------- model row ---------- */
|
||||
@@ -622,12 +678,15 @@ function useExample(ex) {
|
||||
width: 14.5rem;
|
||||
overflow: hidden;
|
||||
padding: 1.25rem;
|
||||
border-radius: 1.25rem;
|
||||
border-radius: 1.1rem;
|
||||
border: 1px solid var(--glass-border);
|
||||
background: var(--surface);
|
||||
box-shadow: inset 0 0 0 1px var(--hairline);
|
||||
transition: box-shadow 0.2s ease;
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow: var(--glass-shadow);
|
||||
transition: box-shadow 0.18s ease, transform 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
.model-card:hover { box-shadow: inset 0 0 0 1px rgb(139 92 246 / 0.42); }
|
||||
.model-card:hover { transform: translateY(-3px); border-color: rgb(124 92 252 / 0.45); box-shadow: var(--glass-shadow-lg); }
|
||||
.model-glow {
|
||||
position: absolute;
|
||||
top: -2.5rem;
|
||||
@@ -636,33 +695,122 @@ function useExample(ex) {
|
||||
height: 8rem;
|
||||
border-radius: 50%;
|
||||
filter: blur(40px);
|
||||
opacity: 0.3;
|
||||
opacity: 0.35;
|
||||
}
|
||||
.model-tag { font-size: 10.5px; padding: 0.18rem 0.55rem; border-radius: 0.4rem; }
|
||||
.model-tag.is-image { background: rgb(52 211 153 / 0.14); color: rgb(52 211 153); }
|
||||
.model-tag.is-video { background: rgb(125 211 252 / 0.14); color: rgb(56 189 248); }
|
||||
.model-tag {
|
||||
font-size: 10.5px;
|
||||
font-weight: 800;
|
||||
padding: 0.18rem 0.55rem;
|
||||
border-radius: 0.4rem;
|
||||
border: 1px solid var(--ink-soft);
|
||||
}
|
||||
.model-tag.is-image { background: rgb(52 211 153 / 0.16); color: rgb(5 150 105); border-color: rgb(52 211 153 / 0.4); }
|
||||
.model-tag.is-video { background: rgb(34 211 238 / 0.16); color: rgb(8 145 178); border-color: rgb(34 211 238 / 0.4); }
|
||||
html.dark .model-tag.is-image { color: rgb(110 231 183); }
|
||||
html.dark .model-tag.is-video { color: rgb(103 232 249); }
|
||||
|
||||
/* ---------- hero band + mascot aura ---------- */
|
||||
/* ---------- hero band + aurora deco ---------- */
|
||||
.hero-band {
|
||||
border-radius: 2rem;
|
||||
}
|
||||
.mascot-aura {
|
||||
/* Floating glass chips scattered around the headline — each is a small
|
||||
frosted tile with a soft shadow and its own idle animation. */
|
||||
.deco {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 260px;
|
||||
height: 260px;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgb(217 70 239 / 0.35), rgb(56 189 248 / 0.12) 55%, transparent 72%);
|
||||
filter: blur(14px);
|
||||
animation: auraPulse 5s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--glass-shadow);
|
||||
font-weight: 900;
|
||||
color: var(--fg);
|
||||
background: var(--surface);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
@keyframes auraPulse {
|
||||
0%, 100% { opacity: 0.8; transform: translate(-50%, -50%) scale(1); }
|
||||
50% { opacity: 1; transform: translate(-50%, -50%) scale(1.08); }
|
||||
.deco svg { width: 45%; height: 45%; color: var(--m-orange); }
|
||||
.deco-a {
|
||||
left: 7%; top: 22%;
|
||||
width: 3.6rem; height: 3.6rem;
|
||||
border-radius: 1rem;
|
||||
transform: rotate(-8deg);
|
||||
animation: memphis-float 6s ease-in-out infinite;
|
||||
}
|
||||
.deco-b {
|
||||
right: 8%; top: 16%;
|
||||
width: 3rem; height: 3rem;
|
||||
border-radius: 999px;
|
||||
transform: rotate(6deg);
|
||||
animation: memphis-wiggle 3.5s ease-in-out infinite;
|
||||
}
|
||||
.deco-b svg { color: var(--m-orange-2); }
|
||||
.deco-c {
|
||||
left: 13%; bottom: 16%;
|
||||
width: 3.2rem; height: 2.4rem;
|
||||
border-radius: 0.7rem;
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--m-orange);
|
||||
transform: rotate(9deg);
|
||||
animation: memphis-float 7s ease-in-out infinite reverse;
|
||||
}
|
||||
.deco-d {
|
||||
right: 13%; bottom: 20%;
|
||||
width: 3.4rem; height: 2.6rem;
|
||||
border-radius: 0.8rem;
|
||||
transform: rotate(-7deg);
|
||||
animation: memphis-bounce 4s ease-in-out infinite;
|
||||
}
|
||||
.deco-d svg { color: #34d399; }
|
||||
.deco-star {
|
||||
left: 22%; top: 10%;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
-webkit-backdrop-filter: none;
|
||||
backdrop-filter: none;
|
||||
font-size: 1.7rem;
|
||||
color: var(--m-orange);
|
||||
animation: memphis-wiggle 4s ease-in-out infinite;
|
||||
}
|
||||
.deco-star-2 {
|
||||
left: auto; right: 22%; top: auto; bottom: 10%;
|
||||
font-size: 1.3rem;
|
||||
color: var(--m-pink);
|
||||
animation: memphis-wiggle 5s ease-in-out infinite reverse;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .deco { animation: none; } }
|
||||
|
||||
/* KPI tiles — frosted cards with a gradient icon chip. */
|
||||
.kpi-card {
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 1.1rem;
|
||||
background: var(--surface);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow: var(--glass-shadow);
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
.kpi-card:hover { transform: translateY(-3px); border-color: rgb(124 92 252 / 0.4); box-shadow: var(--glass-shadow-lg); }
|
||||
.kpi-ico {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.6rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
flex: none;
|
||||
box-shadow: 0 4px 12px rgb(124 92 252 / 0.3);
|
||||
}
|
||||
.kpi-ico svg { width: 1.05rem; height: 1.05rem; }
|
||||
|
||||
/* ---------- CTA button ---------- */
|
||||
.cta-go {
|
||||
background: linear-gradient(120deg, #7c5cfc, #5f6cf8 55%, #22d3ee 140%);
|
||||
border: 1px solid rgb(255 255 255 / 0.2);
|
||||
box-shadow: 0 10px 30px var(--accent-glow);
|
||||
}
|
||||
.cta-go:hover { transform: translateY(-2px); box-shadow: 0 14px 38px var(--accent-glow); filter: brightness(1.07); }
|
||||
.cta-go:active { transform: translateY(1px) scale(0.98); box-shadow: 0 4px 14px var(--accent-glow); }
|
||||
|
||||
/* ---------- gallery marquee ---------- */
|
||||
.marquee-mask {
|
||||
@@ -683,21 +831,21 @@ function useExample(ex) {
|
||||
width: 15rem;
|
||||
height: 10rem;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border: 1px solid var(--glass-border);
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 1.4rem;
|
||||
border-radius: 1rem;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
box-shadow: inset 0 0 0 1px var(--hairline);
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
box-shadow: var(--glass-shadow);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
.gcard:hover {
|
||||
transform: translateY(-4px) scale(1.02);
|
||||
box-shadow: inset 0 0 0 1px rgb(217 70 239 / 0.5), 0 22px 44px -26px rgb(168 85 247 / 0.6);
|
||||
transform: translateY(-3px);
|
||||
border-color: rgb(124 92 252 / 0.5);
|
||||
box-shadow: var(--glass-shadow-lg), 0 10px 30px rgb(124 92 252 / 0.16);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.marquee { animation: none; }
|
||||
.mascot-aura { animation: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -215,12 +215,12 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.total }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-indigo-300/80">图像</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-indigo-300">{{ stats.image }}</div>
|
||||
<div class="text-[11px] uppercase tracking-wider text-sky-300/80">图像</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-sky-300">{{ stats.image }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-fuchsia-300/80">视频</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-fuchsia-300">{{ stats.video }}</div>
|
||||
<div class="text-[11px] uppercase tracking-wider text-rose-300/80">视频</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-rose-300">{{ stats.video }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-amber-300/80">存储</div>
|
||||
@@ -293,7 +293,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
|
||||
<Icon name="check" class="w-3 h-3" />
|
||||
</button>
|
||||
<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ring-1"
|
||||
:class="f.kind === 'video' ? 'bg-fuchsia-500/20 text-fuchsia-200 ring-fuchsia-400/30' : 'bg-indigo-500/20 text-indigo-200 ring-indigo-400/30'">
|
||||
:class="f.kind === 'video' ? 'bg-rose-500/20 text-rose-200 ring-rose-400/30' : 'bg-sky-500/20 text-sky-200 ring-sky-400/30'">
|
||||
{{ f.kind === 'video' ? '视频' : '图像' }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -327,8 +327,8 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
|
||||
<span class="shrink-0 flex items-center gap-1">
|
||||
<span v-if="f.resolution" class="text-emerald-300/90">{{ f.resolution }}</span>
|
||||
<span v-if="f.ratio" class="text-white/40">{{ f.ratio }}</span>
|
||||
<span v-if="f.kind === 'video' && f.duration" class="text-fuchsia-300/80">{{ f.duration }}</span>
|
||||
<span v-if="f.deai" class="text-[#7c3aed]">去AI</span>
|
||||
<span v-if="f.kind === 'video' && f.duration" class="text-rose-300/80">{{ f.duration }}</span>
|
||||
<span v-if="f.deai" class="text-[#a78bfa]">去AI</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-[10px] text-white/35 mt-0.5 tabular-nums">{{ fmtSize(f.size) }} · {{ fmtTs(f.mtime) }}</div>
|
||||
@@ -438,6 +438,6 @@ html.dark .pg-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); }
|
||||
.pick svg { opacity: 0; transition: opacity 0.15s; }
|
||||
.pick:hover { background: rgb(0 0 0 / 0.65); }
|
||||
.pick:hover svg { opacity: 0.6; }
|
||||
.pick-on { background: rgb(217 70 239); box-shadow: inset 0 0 0 1.5px rgb(255 255 255 / 0.9); }
|
||||
.pick-on { background: rgb(244 63 94); box-shadow: inset 0 0 0 1.5px rgb(255 255 255 / 0.9); }
|
||||
.pick-on svg { opacity: 1; }
|
||||
</style>
|
||||
|
||||
@@ -7,7 +7,6 @@ import { auth, refreshMe } from '../auth'
|
||||
import { api } from '../api'
|
||||
import { fmtIso } from '../utils/format'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import Mascot from '../components/Mascot.vue'
|
||||
|
||||
// Reward per completed invite — comes from the backend (credits.invite_reward),
|
||||
// falling back to 3 until the response lands.
|
||||
@@ -81,39 +80,41 @@ function toast(m) {
|
||||
<div class="theme-text space-y-10">
|
||||
<!-- header -->
|
||||
<header>
|
||||
<span class="sticker">🎀 奖励</span>
|
||||
<span class="sticker">✦ 奖励</span>
|
||||
<h1 class="mt-3 text-4xl md:text-5xl font-bold tracking-tight">邀请好友</h1>
|
||||
<p class="text-white/45 mt-2">好友用你的链接注册,并完成首次生图后,你得 {{ INVITE_REWARD }} 积分。</p>
|
||||
</header>
|
||||
|
||||
<section class="relative rounded-3xl ring-1 ring-white/[0.08] p-7 md:p-8 overflow-hidden"
|
||||
style="background: radial-gradient(at 70% 30%,rgba(251,191,36,0.16) 0%, transparent 55%),linear-gradient(180deg,rgba(255,255,255,0.04),rgba(255,255,255,0.02))">
|
||||
<div class="inline-grid w-10 h-10 rounded-xl bg-amber-500/15 ring-1 ring-amber-400/30 grid place-items-center text-amber-300">
|
||||
<Icon name="accounts" class="w-4 h-4" />
|
||||
<section class="relative rounded-2xl p-7 md:p-8 overflow-hidden ink-card">
|
||||
<div class="memphis-dots absolute inset-0 opacity-30 pointer-events-none"></div>
|
||||
<div class="relative inline-grid w-11 h-11 rounded-xl text-white place-items-center"
|
||||
style="background: linear-gradient(135deg, #7c5cfc, #22d3ee); box-shadow: 0 8px 20px var(--accent-glow)">
|
||||
<Icon name="accounts" class="w-5 h-5" />
|
||||
</div>
|
||||
|
||||
<!-- summary -->
|
||||
<div class="mt-6 grid grid-cols-2 gap-3">
|
||||
<div class="rounded-xl bg-white/[0.04] ring-1 ring-white/[0.06] px-4 py-3">
|
||||
<div class="text-2xl font-bold tabular-nums">{{ inviteCount }}</div>
|
||||
<div class="text-[10px] text-white/40 mt-1 uppercase tracking-widest">已邀请</div>
|
||||
<div class="relative mt-6 grid grid-cols-2 gap-3">
|
||||
<div class="rounded-xl px-4 py-3 border border-[color:var(--glass-border)] bg-[var(--surface)] shadow-[var(--glass-shadow)]">
|
||||
<div class="text-2xl font-black tabular-nums text-[color:var(--fg)]">{{ inviteCount }}</div>
|
||||
<div class="text-[10px] text-[color:var(--fg-3)] mt-1 uppercase tracking-widest font-bold">已邀请</div>
|
||||
</div>
|
||||
<div class="rounded-xl bg-white/[0.04] ring-1 ring-white/[0.06] px-4 py-3">
|
||||
<div class="text-2xl font-bold tabular-nums">{{ inviteEarned.toLocaleString('en-US') }}</div>
|
||||
<div class="text-[10px] text-white/40 mt-1 uppercase tracking-widest">累计积分</div>
|
||||
<div class="rounded-xl px-4 py-3 border border-[color:var(--glass-border)] bg-[var(--surface)] shadow-[var(--glass-shadow)]">
|
||||
<div class="text-2xl font-black tabular-nums text-[color:var(--fg)]">{{ inviteEarned.toLocaleString('en-US') }}</div>
|
||||
<div class="text-[10px] text-[color:var(--fg-3)] mt-1 uppercase tracking-widest font-bold">累计积分</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- code -->
|
||||
<div class="mt-6">
|
||||
<label class="block text-xs text-white/50 mb-2">邀请码</label>
|
||||
<div class="relative mt-6">
|
||||
<label class="block text-xs font-bold text-white/50 mb-2">邀请码</label>
|
||||
<div class="flex gap-2">
|
||||
<button @click="copyCode"
|
||||
class="flex-1 rounded-xl bg-white/[0.05] ring-1 ring-white/10 hover:ring-white/30 hover:bg-white/[0.08] px-4 py-3 text-sm font-mono text-left transition-all">
|
||||
class="flex-1 rounded-xl border border-[color:var(--ink-soft)] bg-[var(--surface)] px-4 py-3 text-sm font-mono text-left shadow-[var(--glass-shadow)] hover:-translate-y-0.5 hover:border-[rgb(124_92_252/0.45)] transition-all">
|
||||
{{ inviteCode || '—' }}
|
||||
</button>
|
||||
<button @click="copyInvite"
|
||||
class="rounded-xl bg-white text-black hover:bg-white/90 px-5 py-3 text-sm font-semibold transition-colors">
|
||||
class="rounded-xl border border-white/20 text-white px-5 py-3 text-sm font-extrabold hover:-translate-y-0.5 active:translate-y-0.5 transition-all"
|
||||
style="background: linear-gradient(120deg, #7c5cfc, #22d3ee); box-shadow: 0 8px 20px var(--accent-glow)">
|
||||
复制链接
|
||||
</button>
|
||||
</div>
|
||||
@@ -134,7 +135,7 @@ function toast(m) {
|
||||
<div v-if="loading && !records.length" class="text-center text-sm text-white/40 py-16">加载中…</div>
|
||||
<div v-else-if="!records.length" class="relative overflow-hidden flex flex-col items-center gap-3 text-white/40 py-14">
|
||||
<div class="anime-stars"></div>
|
||||
<Mascot :size="110" />
|
||||
<span class="empty-badge">✦</span>
|
||||
<span class="text-sm">还没有人通过你的链接注册~快去分享吧</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -36,8 +36,8 @@ onMounted(load)
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-amber-300">{{ stats.pending }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-fuchsia-300/80">已发奖励</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-fuchsia-300">{{ Number(stats.reward_paid || 0).toLocaleString('en-US') }}</div>
|
||||
<div class="text-[11px] uppercase tracking-wider text-rose-300/80">已发奖励</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-rose-300">{{ Number(stats.reward_paid || 0).toLocaleString('en-US') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ const statusDot = (s) => ({
|
||||
// Source: backend stamps "v1" (API key), "user" (画图台), "admin" (后台测试模型).
|
||||
const sourceLabel = (s) => ({ v1: 'API', user: '画图台', admin: '测试' }[s] || '画图台')
|
||||
const sourcePill = (s) => ({
|
||||
v1: 'bg-violet-500/15 text-violet-300 ring-violet-400/30',
|
||||
v1: 'bg-orange-500/15 text-orange-300 ring-orange-400/30',
|
||||
admin: 'bg-amber-500/15 text-amber-300 ring-amber-400/30',
|
||||
user: 'bg-sky-500/15 text-sky-300 ring-sky-400/30',
|
||||
}[s] || 'bg-sky-500/15 text-sky-300 ring-sky-400/30')
|
||||
@@ -241,7 +241,7 @@ const sourcePill = (s) => ({
|
||||
<th class="text-center px-4 py-3 font-medium">预览</th>
|
||||
<th class="text-left px-4 py-3 font-medium">时间</th>
|
||||
<th class="text-left px-3 py-3 font-medium">状态</th>
|
||||
<th class="text-left px-3 py-3 font-medium">用户 / 账号</th>
|
||||
<th class="text-left px-3 py-3 font-medium">用户 / 渠道</th>
|
||||
<th class="text-left px-3 py-3 font-medium">模型</th>
|
||||
<th class="text-left px-3 py-3 font-medium">提示词 / 错误</th>
|
||||
<th class="text-left px-3 py-3 font-medium">参数</th>
|
||||
@@ -254,7 +254,7 @@ const sourcePill = (s) => ({
|
||||
<td class="px-4 py-3.5 align-middle text-center">
|
||||
<button v-if="e.status === 'success' && e.file && e.source !== 'v1'"
|
||||
@click="openPreview(e)"
|
||||
class="block w-12 h-12 mx-auto rounded-lg overflow-hidden ring-1 ring-white/10 hover:ring-fuchsia-400/60 transition-all">
|
||||
class="block w-12 h-12 mx-auto rounded-lg overflow-hidden ring-1 ring-white/10 hover:ring-rose-400/60 transition-all">
|
||||
<img v-if="e.kind !== 'video' || !thumbFail[e.id]" :src="thumbUrl(e.file)" loading="lazy"
|
||||
class="w-full h-full object-cover"
|
||||
@error="e.kind === 'video' && (thumbFail[e.id] = true)" />
|
||||
@@ -291,7 +291,7 @@ const sourcePill = (s) => ({
|
||||
<div class="font-mono text-xs text-white/90 break-all" :title="e.model">{{ e.model }}</div>
|
||||
<div class="mt-1 flex items-center gap-1.5 min-w-0">
|
||||
<span class="text-[10px] uppercase tracking-wider font-medium truncate min-w-0"
|
||||
:class="e.kind === 'video' ? 'text-fuchsia-300/80' : 'text-indigo-300/80'">
|
||||
:class="e.kind === 'video' ? 'text-rose-300/80' : 'text-sky-300/80'">
|
||||
{{ e.kind === 'video' ? '视频' : '图像' }}
|
||||
<span v-if="e.provider" class="text-white/30 ml-1">· {{ e.provider }}</span>
|
||||
</span>
|
||||
@@ -327,7 +327,7 @@ const sourcePill = (s) => ({
|
||||
</template>
|
||||
<template v-if="e.deai">
|
||||
<span class="text-white/25 mx-1.5">·</span>
|
||||
<span class="text-[#7c3aed]">去AI特征</span>
|
||||
<span class="text-[#a78bfa]">去AI特征</span>
|
||||
</template>
|
||||
</td>
|
||||
<td class="px-3 py-3.5 text-right text-xs tabular-nums align-middle whitespace-nowrap">
|
||||
@@ -437,7 +437,7 @@ const sourcePill = (s) => ({
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
|
||||
/* --- log table: subtle row separators + a barely-there hover tint that
|
||||
extends a soft violet accent on the left of the row (read as a
|
||||
extends a soft orange accent on the left of the row (read as a
|
||||
focus indicator without being noisy). --- */
|
||||
.log-table { border-collapse: separate; border-spacing: 0; }
|
||||
.log-row td {
|
||||
@@ -446,7 +446,7 @@ const sourcePill = (s) => ({
|
||||
}
|
||||
.log-row:hover td { background: rgb(255 255 255 / 0.025); }
|
||||
.log-row:hover td:first-child {
|
||||
box-shadow: inset 2px 0 0 rgb(167 139 250 / 0.55);
|
||||
box-shadow: inset 2px 0 0 rgb(253 186 116 / 0.55);
|
||||
}
|
||||
.log-row:last-child td { border-bottom: none; }
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { api } from '../api'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import ModelFormModal from '../components/ModelFormModal.vue'
|
||||
import CustomModelModal from '../components/CustomModelModal.vue'
|
||||
import TestModal from '../components/TestModal.vue'
|
||||
import { points } from '../credits'
|
||||
import { mediaCaps, presetMap } from '../videoCaps'
|
||||
@@ -11,7 +10,6 @@ import { mediaCaps, presetMap } from '../videoCaps'
|
||||
const models = ref([])
|
||||
const loading = ref(false)
|
||||
const showForm = ref(false)
|
||||
const showCustom = ref(false)
|
||||
const editing = ref(null) // null = add, object = edit
|
||||
const testing = ref(null) // model being tested, or null
|
||||
|
||||
@@ -41,7 +39,6 @@ async function loadModels() {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function openAdd() { editing.value = null; showForm.value = true }
|
||||
function openEdit(m) { editing.value = { ...m }; showForm.value = true }
|
||||
function onSaved() { showForm.value = false; loadModels() }
|
||||
|
||||
@@ -79,7 +76,7 @@ const filtered = computed(() => {
|
||||
if (kindFilter.value && m.type !== kindFilter.value) return false
|
||||
if (statusFilter.value === 'enabled' && m.enabled === false) return false
|
||||
if (statusFilter.value === 'disabled' && m.enabled !== false) return false
|
||||
if (q && !(m.id.toLowerCase().includes(q) || (m.alias || '').toLowerCase().includes(q) || (m.provider || '').toLowerCase().includes(q))) return false
|
||||
if (q && !(m.id.toLowerCase().includes(q) || (m.alias || '').toLowerCase().includes(q) || (m.name || '').toLowerCase().includes(q))) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
@@ -96,12 +93,12 @@ onMounted(loadModels)
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.total }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-indigo-300/80">图像</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-indigo-300">{{ stats.image }}</div>
|
||||
<div class="text-[11px] uppercase tracking-wider text-sky-300/80">图像</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-sky-300">{{ stats.image }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-fuchsia-300/80">视频</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-fuchsia-300">{{ stats.video }}</div>
|
||||
<div class="text-[11px] uppercase tracking-wider text-rose-300/80">视频</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-rose-300">{{ stats.video }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-emerald-300/80">启用</div>
|
||||
@@ -132,12 +129,9 @@ onMounted(loadModels)
|
||||
<button @click="loadModels" class="btn-soft">
|
||||
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
|
||||
</button>
|
||||
<button @click="openAdd" class="btn-primary">
|
||||
<Icon name="plus" class="w-3.5 h-3.5" /> 新增模型
|
||||
</button>
|
||||
<button @click="showCustom = true" class="btn-soft">
|
||||
<Icon name="plus" class="w-3.5 h-3.5" /> 自定义模型
|
||||
</button>
|
||||
<router-link to="/admin/channels" class="btn-primary inline-flex items-center gap-1">
|
||||
<Icon name="plug" class="w-3.5 h-3.5" /> 渠道导入模型
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
@@ -145,7 +139,7 @@ onMounted(loadModels)
|
||||
<div v-if="loading && !models.length" class="text-center text-sm text-white/40 py-20">加载中…</div>
|
||||
<div v-else-if="!filtered.length" class="flex flex-col items-center gap-3 text-white/40 py-20">
|
||||
<span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center"><Icon name="models" class="w-6 h-6" /></span>
|
||||
<span class="text-sm">{{ models.length ? '没有匹配的模型' : '还没有模型,点右上角「新增模型」' }}</span>
|
||||
<span class="text-sm">{{ models.length ? '没有匹配的模型' : '还没有模型,先到「渠道管理」拉取上游模型导入' }}</span>
|
||||
</div>
|
||||
|
||||
<table v-else class="w-full text-sm table-fixed">
|
||||
@@ -180,15 +174,15 @@ onMounted(loadModels)
|
||||
<div class="font-mono text-xs text-white/90 truncate" :title="m.id">{{ m.id }}</div>
|
||||
<span v-if="m.alias" class="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] bg-sky-500/10 text-sky-300 ring-1 ring-sky-400/20 shrink-0">{{ m.alias }}</span>
|
||||
</div>
|
||||
<div class="mt-1 text-[10px] text-white/45 capitalize truncate">{{ m.provider || '—' }}</div>
|
||||
<div class="mt-1 text-[10px] text-white/45 truncate">{{ m.provider === 'channel' ? '渠道导入' : (m.provider || '—') }}</div>
|
||||
</td>
|
||||
|
||||
<!-- Type chip with the same shape as Logs/Provider 健康 -->
|
||||
<td class="px-3 py-3.5 align-middle">
|
||||
<span class="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1"
|
||||
:class="m.type === 'video'
|
||||
? 'bg-fuchsia-500/10 text-fuchsia-300 ring-fuchsia-400/30'
|
||||
: 'bg-indigo-500/10 text-indigo-300 ring-indigo-400/30'">
|
||||
? 'bg-rose-500/10 text-rose-300 ring-rose-400/30'
|
||||
: 'bg-sky-500/10 text-sky-300 ring-sky-400/30'">
|
||||
{{ TYPE_LABEL[m.type] || m.type }}
|
||||
</span>
|
||||
</td>
|
||||
@@ -303,7 +297,6 @@ onMounted(loadModels)
|
||||
</div>
|
||||
|
||||
<ModelFormModal v-if="showForm" :model="editing" @close="showForm = false" @saved="onSaved" />
|
||||
<CustomModelModal v-if="showCustom" @close="showCustom = false" @saved="() => { showCustom = false; loadModels() }" />
|
||||
<TestModal v-if="testing" :model="testing" @close="testing = null" />
|
||||
</section>
|
||||
</template>
|
||||
@@ -354,12 +347,12 @@ onMounted(loadModels)
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cap-dur {
|
||||
background: rgb(99 102 241 / 0.12);
|
||||
color: rgb(165 180 252);
|
||||
background: rgb(14 165 233 / 0.12);
|
||||
color: rgb(147 197 253);
|
||||
box-shadow: inset 0 0 0 1px rgb(129 140 248 / 0.3);
|
||||
}
|
||||
.cap-frame {
|
||||
background: rgb(236 72 153 / 0.12);
|
||||
background: rgb(251 113 133 / 0.12);
|
||||
color: rgb(244 114 182);
|
||||
box-shadow: inset 0 0 0 1px rgb(244 114 182 / 0.35);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user