Compare commits

...

31 Commits

Author SHA1 Message Date
QuentinHsu 4dd68bad52 perf(model-pricing): move pricing tabs into page title
- place the model pricing tab switcher beside the page title instead of spanning the content area.
- keep the switcher width tied to its labels while preserving spacing around title status content.
2026-06-06 15:26:53 +08:00
QuentinHsu 0f043ae404 feat(json-editor): add reusable JSON code editor
- introduce a shared themed JSON editor with line numbers, formatting, status feedback, and keyboard editing helpers.
- use the shared editor in model pricing JSON mode so pricing maps get consistent editor behavior.
- localize structured JSON validation messages so parse errors avoid browser-specific English text.
2026-06-06 15:14:26 +08:00
QuentinHsu 75c05bb4b8 perf(model-pricing): improve JSON pricing editor layout
- render pricing JSON fields from shared configuration to reduce duplicated form markup.
- use fixed-height JSON textareas so long model maps scroll internally instead of stretching the page.
- arrange JSON editors in responsive columns to make wider settings pages easier to scan.
2026-06-06 14:36:21 +08:00
QuentinHsu 81d3dc08e5 perf(model-pricing): reduce duplicate model name display 2026-06-06 14:15:44 +08:00
QuentinHsu 5681c92b3f perf(model-pricing): refine visual editor actions
- keep the global reset action in the top toolbar while moving visual-mode saves into the model editor footer.
- pin the actions header with the rest of the model table headers so horizontal scrolling keeps context visible.
- add action icons to make save and reset controls easier to scan.
2026-06-05 01:04:47 +08:00
QuentinHsu 6e5a359110 refactor(model-pricing): split visual pricing editor modules
- extract pricing form primitives, snapshot helpers, and table column setup to keep the editor components smaller.
- remove draft comparison UI now that switching models discards unsaved edits.
- refine the model list with a fixed actions column and tighter mode and price summary display.
2026-06-05 00:06:41 +08:00
QuentinHsu 77d3157592 fix(model-pricing): commit visual pricing drafts on save
- Commit the open visual editor draft before saving model pricing settings
- Show unsaved draft differences against persisted model pricing values
- Move model pricing actions into the editor toolbar and refine the visual editor layout
2026-06-04 17:22:50 +08:00
QuentinHsu 39e05118ff fix(model-pricing): align pricing mode editor spacing
- add consistent tab and field spacing so each pricing mode keeps the same visual rhythm.
- wrap per-request and tiered sections in shared field groups to match the per-token form structure.
- keep fixed-price descriptions and validation messages aligned with the updated field layout.
2026-06-03 18:27:40 +08:00
QuentinHsu 9e59ffc3d8 fix(model-pricing): align pricing mode editor spacing
- add consistent tab and field spacing so each pricing mode keeps the same visual rhythm.
- wrap per-request and tiered sections in shared field groups to match the per-token form structure.
- keep fixed-price descriptions and validation messages aligned with the updated field layout.
2026-06-03 18:27:07 +08:00
QuentinHsu abad0d3cc0 fix(model-pricing): detect visual pricing draft changes on save
- expose a draft commit handle from the model pricing editor panel before saving.
- commit the open visual editor into the parent form before page-level save runs.
- support both desktop side editor and mobile sheet save paths.
2026-06-03 14:49:08 +08:00
同語 7aaa533265 fix(channels): reveal advanced validation errors #5239
Merge pull request #5239 from QuantumNous/fix/channel-advanced-errors
2026-06-02 14:30:20 +08:00
t0ng7u 7791b78429 chore(fd): delete the test file 2026-06-02 14:28:35 +08:00
QuentinHsu cb5c0453f5 fix(channels): avoid expanding advanced settings for model mapping
- remove model mapping from advanced settings error detection so visible model configuration errors do not expand the advanced panel.
- update the edit-time advanced settings auto-expand check to only depend on fields actually rendered in the advanced section.
- add regression coverage to verify model_mapping errors are not classified as advanced settings errors.
2026-06-02 12:31:32 +08:00
QuentinHsu 4d20e053cb fix(channels): reveal advanced validation errors
- add channel form error detection for JSON validation errors inside advanced settings.
- expand advanced settings on invalid channel drawer submit and prompt users to fix highlighted fields.
- add regression tests for advanced error detection, non-advanced exclusions, and schema error classification.
2026-06-02 12:09:47 +08:00
同語 0ff9c35e62 feat(web): support classic Rsbuild dev and build
Merge pull request #5232 from QuantumNous/feat/classic-rsbuild-dev-workflow
2026-06-02 11:33:33 +08:00
QuentinHsu 0bbcaa8999 fix(classic): inject Semi React 19 adapter 2026-06-02 00:50:29 +08:00
QuentinHsu 1e9ff8a0de feat(web): support classic Rsbuild dev and build
- migrate the classic frontend from Vite to Rsbuild with JSX, Semi UI, proxy, and production build config.
- update make dev-web to run both default and classic frontends for local theme switching.
- fix classic public page height, footer, CORS proxy, error handling, and constant export warnings.
- update Dockerfile and release workflow to install from the web workspace root with the shared lockfile.
2026-06-02 00:32:16 +08:00
同語 9a2e60dff2 chore(web): centralize shared frontend dependency versions #5227
Merge pull request #5227 from QuantumNous/chore/web-shared-dependency-catalog
2026-06-01 19:19:13 +08:00
QuentinHsu b596de739d chore(web): centralize shared frontend dependency versions
- add a web workspace catalog to manage dependency versions shared by default and classic frontends.
- switch shared dependencies including @lobehub/icons to catalog references and align @lobehub/icons on 5.10.0.
- replace separate frontend Bun lockfiles with a unified web/bun.lock to reduce duplicate maintenance.
2026-06-01 19:12:39 +08:00
同語 45d54c1613 fix(pricing): sync custom model icons #5224
Merge pull request #5224 from QuantumNous/fix/pricing-model-icons
2026-06-01 18:17:58 +08:00
QuentinHsu 086044650d fix(pricing): sync custom model icons
- add the icon field to the pricing model type to consume model-level icons returned by the backend.
- prefer model icons in cards, table model cells, and detail headers while falling back to vendor icons.
2026-06-01 17:58:02 +08:00
GGXH 0c7aceb831 feat: add claude opus 4.8 support (#5177) 2026-05-31 13:50:52 +08:00
dependabot[bot] b2e25b7df2 chore(deps): bump axios from 1.15.2 to 1.16.0 in /web/classic (#5185)
Bumps [axios](https://github.com/axios/axios) from 1.15.2 to 1.16.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.15.2...v1.16.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.16.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-31 13:49:50 +08:00
skynono 230a3592f8 perf: order admin logs by created_at to use composite index (#5116) 2026-05-30 20:00:02 +08:00
YuPeng Wu afb470e405 fix(model): correct idx_created_at_id index column order to (created_at, id) (#5191)
The idx_created_at_id composite index on the logs table was defined as
(id, created_at) because the GORM `priority` values on Id and CreatedAt were
swapped. Since `id` is the auto-increment primary key, a secondary composite
index leading with `id` is redundant with the PK and cannot accelerate
`created_at` range scans (a range column must sit at the index prefix).

This defeats the common log-listing queries
(`WHERE created_at BETWEEN ? AND ? ORDER BY id DESC LIMIT n` in
GetAllLogs/GetUserLogs) that the index name implies it should serve — the
optimizer falls back to scanning the primary key, degrading to near full-table
scans on large logs tables.

Swap the priorities so the column order becomes (created_at, id), matching the
index name and its intended purpose. idx_user_id_id and idx_created_at_type are
unaffected.

Note: GORM AutoMigrate does not change the column order of an already-existing
index with the same name, so existing deployments must rebuild the index
manually (see PR description for per-database DDL).

Co-authored-by: wuyupeng <wuyupeng@floatmiracle.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 19:54:02 +08:00
CaIon 1588027084 feat: add subscription balance redemption toggle (#3071) 2026-05-29 12:54:00 +08:00
zengwei 38bf2d8daa feat(keys/cc-switch-dialog): 修复自定义cc-switch名称失焦后重置问题 (#5170) 2026-05-29 12:18:52 +08:00
同語 e8c836d705 fix(web): improve form validation error focus #5163
Merge pull request #5163 from QuantumNous/fix/form-validation-focus
2026-05-28 23:34:02 +08:00
QuentinHsu e79cee1e9e perf(form): focus first validation error on submit
- scope validation queries with a form root id so feedback stays inside the submitted form.
- scroll to the earliest invalid control or message and move focus without fighting scroll position.
- avoid handling the same failed submit twice to keep retries from jumping unexpectedly.
2026-05-28 15:10:17 +08:00
QuentinHsu 63ead2bf7f chore(repo): ignore playwright mcp artifacts 2026-05-28 15:02:00 +08:00
CaIon 5b86ce0d70 fix: optimize batch update process 2026-05-27 13:23:05 +08:00
74 changed files with 4394 additions and 4928 deletions
+18 -12
View File
@@ -33,16 +33,18 @@ jobs:
env:
CI: ""
run: |
cd web/default
bun install
cd web
bun install --frozen-lockfile
cd default
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
cd ../..
- name: Build Frontend (classic)
env:
CI: ""
run: |
cd web/classic
bun install
cd web
bun install --frozen-lockfile
cd classic
VITE_REACT_APP_VERSION=$VERSION bun run build
cd ../..
- name: Set up Go
@@ -91,16 +93,18 @@ jobs:
CI: ""
NODE_OPTIONS: "--max-old-space-size=4096"
run: |
cd web/default
bun install
cd web
bun install --frozen-lockfile
cd default
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
cd ../..
- name: Build Frontend (classic)
env:
CI: ""
run: |
cd web/classic
bun install
cd web
bun install --frozen-lockfile
cd classic
VITE_REACT_APP_VERSION=$VERSION bun run build
cd ../..
- name: Set up Go
@@ -146,16 +150,18 @@ jobs:
env:
CI: ""
run: |
cd web/default
bun install
cd web
bun install --frozen-lockfile
cd default
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
cd ../..
- name: Build Frontend (classic)
env:
CI: ""
run: |
cd web/classic
bun install
cd web
bun install --frozen-lockfile
cd classic
VITE_REACT_APP_VERSION=$VERSION bun run build
cd ../..
- name: Set up Go
+1
View File
@@ -35,3 +35,4 @@ data/
.test
token_estimator_test.go
skills-lock.json
.playwright-mcp
+18 -16
View File
@@ -1,22 +1,24 @@
FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder
WORKDIR /build
COPY web/default/package.json .
COPY web/default/bun.lock .
RUN bun install
COPY ./web/default .
COPY ./VERSION .
RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
WORKDIR /build/web
COPY web/package.json web/bun.lock ./
COPY web/default/package.json ./default/package.json
COPY web/classic/package.json ./classic/package.json
RUN bun install --frozen-lockfile
COPY ./web/default ./default
COPY ./VERSION /build/VERSION
RUN cd default && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build
FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder-classic
WORKDIR /build
COPY web/classic/package.json .
COPY web/classic/bun.lock .
RUN bun install
COPY ./web/classic .
COPY ./VERSION .
RUN VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
WORKDIR /build/web
COPY web/package.json web/bun.lock ./
COPY web/default/package.json ./default/package.json
COPY web/classic/package.json ./classic/package.json
RUN bun install --frozen-lockfile
COPY ./web/classic ./classic
COPY ./VERSION /build/VERSION
RUN cd classic && VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build
FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder2
ENV GO111MODULE=on CGO_ENABLED=0
@@ -32,8 +34,8 @@ ADD go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=builder /build/dist ./web/default/dist
COPY --from=builder-classic /build/dist ./web/classic/dist
COPY --from=builder /build/web/default/dist ./web/default/dist
COPY --from=builder-classic /build/web/classic/dist ./web/classic/dist
RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a
+8
View File
@@ -41,6 +41,7 @@ func GetSubscriptionPlans(c *gin.Context) {
}
result := make([]SubscriptionPlanDTO, 0, len(plans))
for _, p := range plans {
p.NormalizeDefaults()
result = append(result, SubscriptionPlanDTO{
Plan: p,
})
@@ -125,6 +126,7 @@ func AdminListSubscriptionPlans(c *gin.Context) {
}
result := make([]SubscriptionPlanDTO, 0, len(plans))
for _, p := range plans {
p.NormalizeDefaults()
result = append(result, SubscriptionPlanDTO{
Plan: p,
})
@@ -163,6 +165,9 @@ func AdminCreateSubscriptionPlan(c *gin.Context) {
req.Plan.Currency = "USD"
}
req.Plan.Currency = "USD"
if req.Plan.AllowBalancePay == nil {
req.Plan.AllowBalancePay = common.GetPointer(true)
}
if req.Plan.DurationUnit == "" {
req.Plan.DurationUnit = model.SubscriptionDurationMonth
}
@@ -279,6 +284,9 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
"quota_reset_custom_seconds": req.Plan.QuotaResetCustomSeconds,
"updated_at": common.GetTimestamp(),
}
if req.Plan.AllowBalancePay != nil {
updateMap["allow_balance_pay"] = *req.Plan.AllowBalancePay
}
if err := tx.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Updates(updateMap).Error; err != nil {
return err
}
+32 -5
View File
@@ -1,6 +1,8 @@
FRONTEND_DIR = ./web/default
FRONTEND_CLASSIC_DIR = ./web/classic
BACKEND_DIR = .
DEV_FRONTEND_DEFAULT_PORT ?= 5173
DEV_FRONTEND_CLASSIC_PORT ?= 5174
DEV_COMPOSE_FILE = docker-compose.dev.yml
DEV_POSTGRES_SERVICE = postgres
DEV_BACKEND_SERVICE = new-api
@@ -14,11 +16,13 @@ all: build-all-frontends start-backend
build-frontend:
@echo "Building default frontend..."
@cd $(FRONTEND_DIR) && bun install && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build
@cd ./web && bun install --frozen-lockfile
@cd $(FRONTEND_DIR) && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build
build-frontend-classic:
@echo "Building classic frontend..."
@cd $(FRONTEND_CLASSIC_DIR) && bun install && VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build
@cd ./web && bun install --frozen-lockfile
@cd $(FRONTEND_CLASSIC_DIR) && VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build
build-all-frontends: build-frontend build-frontend-classic
@@ -35,12 +39,35 @@ dev-api-rebuild:
@docker compose -f $(DEV_COMPOSE_FILE) up -d --build $(DEV_BACKEND_SERVICE)
dev-web:
@echo "Starting frontend dev server..."
@cd $(FRONTEND_DIR) && bun install && bun run dev
@echo "Starting both frontend dev servers..."
@echo "Default frontend: http://localhost:$(DEV_FRONTEND_DEFAULT_PORT)"
@echo "Classic frontend: http://localhost:$(DEV_FRONTEND_CLASSIC_PORT)"
@cd ./web && bun install
@(cd $(FRONTEND_DIR) && bun run dev -- --host 0.0.0.0 --port $(DEV_FRONTEND_DEFAULT_PORT)) & \
default_pid=$$!; \
(cd $(FRONTEND_CLASSIC_DIR) && bun run dev -- --host 0.0.0.0 --port $(DEV_FRONTEND_CLASSIC_PORT)) & \
classic_pid=$$!; \
trap 'kill $$default_pid $$classic_pid 2>/dev/null; wait $$default_pid $$classic_pid 2>/dev/null; exit 130' INT TERM; \
while kill -0 $$default_pid 2>/dev/null && kill -0 $$classic_pid 2>/dev/null; do \
sleep 1; \
done; \
if ! kill -0 $$default_pid 2>/dev/null; then \
wait $$default_pid; \
status=$$?; \
kill $$classic_pid 2>/dev/null; \
wait $$classic_pid 2>/dev/null; \
exit $$status; \
fi; \
wait $$classic_pid; \
status=$$?; \
kill $$default_pid 2>/dev/null; \
wait $$default_pid 2>/dev/null; \
exit $$status
dev-web-classic:
@echo "Starting classic frontend dev server..."
@cd $(FRONTEND_CLASSIC_DIR) && bun install && bun run dev
@cd ./web && bun install
@cd $(FRONTEND_CLASSIC_DIR) && bun run dev -- --host 0.0.0.0 --port $(DEV_FRONTEND_CLASSIC_PORT)
dev: dev-api dev-web
+3 -3
View File
@@ -32,9 +32,9 @@ func applyExplicitLogTextFilter(tx *gorm.DB, column string, value string) (*gorm
}
type Log struct {
Id int `json:"id" gorm:"index:idx_created_at_id,priority:1;index:idx_user_id_id,priority:2"`
Id int `json:"id" gorm:"index:idx_created_at_id,priority:2;index:idx_user_id_id,priority:2"`
UserId int `json:"user_id" gorm:"index;index:idx_user_id_id,priority:1"`
CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_id,priority:2;index:idx_created_at_type"`
CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_id,priority:1;index:idx_created_at_type"`
Type int `json:"type" gorm:"index:idx_created_at_type"`
Content string `json:"content"`
Username string `json:"username" gorm:"index;index:index_username_model_name,priority:2;default:''"`
@@ -354,7 +354,7 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName
if err != nil {
return nil, 0, err
}
err = tx.Order("logs.id desc").Limit(num).Offset(startIdx).Find(&logs).Error
err = tx.Order("logs.created_at desc, logs.id desc").Limit(num).Offset(startIdx).Find(&logs).Error
if err != nil {
return nil, 0, err
}
+2
View File
@@ -397,6 +397,7 @@ func ensureSubscriptionPlanTableSQLite() error {
` + "`custom_seconds`" + ` bigint NOT NULL DEFAULT 0,
` + "`enabled`" + ` numeric DEFAULT 1,
` + "`sort_order`" + ` integer DEFAULT 0,
` + "`allow_balance_pay`" + ` numeric DEFAULT 1,
` + "`stripe_price_id`" + ` varchar(128) DEFAULT '',
` + "`creem_product_id`" + ` varchar(128) DEFAULT '',
` + "`waffo_pancake_product_id`" + ` varchar(128) DEFAULT '',
@@ -431,6 +432,7 @@ PRIMARY KEY (` + "`id`" + `)
{Name: "custom_seconds", DDL: "`custom_seconds` bigint NOT NULL DEFAULT 0"},
{Name: "enabled", DDL: "`enabled` numeric DEFAULT 1"},
{Name: "sort_order", DDL: "`sort_order` integer DEFAULT 0"},
{Name: "allow_balance_pay", DDL: "`allow_balance_pay` numeric DEFAULT 1"},
{Name: "stripe_price_id", DDL: "`stripe_price_id` varchar(128) DEFAULT ''"},
{Name: "creem_product_id", DDL: "`creem_product_id` varchar(128) DEFAULT ''"},
{Name: "waffo_pancake_product_id", DDL: "`waffo_pancake_product_id` varchar(128) DEFAULT ''"},
+13
View File
@@ -160,6 +160,8 @@ type SubscriptionPlan struct {
Enabled bool `json:"enabled" gorm:"default:true"`
SortOrder int `json:"sort_order" gorm:"type:int;default:0"`
AllowBalancePay *bool `json:"allow_balance_pay" gorm:"default:true"`
StripePriceId string `json:"stripe_price_id" gorm:"type:varchar(128);default:''"`
CreemProductId string `json:"creem_product_id" gorm:"type:varchar(128);default:''"`
WaffoPancakeProductId string `json:"waffo_pancake_product_id" gorm:"type:varchar(128);default:''"`
@@ -193,6 +195,12 @@ func (p *SubscriptionPlan) BeforeUpdate(tx *gorm.DB) error {
return nil
}
func (p *SubscriptionPlan) NormalizeDefaults() {
if p.AllowBalancePay == nil {
p.AllowBalancePay = common.GetPointer(true)
}
}
// Subscription order (payment -> webhook -> create UserSubscription)
type SubscriptionOrder struct {
Id int `json:"id"`
@@ -360,6 +368,7 @@ func getSubscriptionPlanByIdTx(tx *gorm.DB, id int) (*SubscriptionPlan, error) {
key := subscriptionPlanCacheKey(id)
if key != "" {
if cached, found, err := getSubscriptionPlanCache().Get(key); err == nil && found {
cached.NormalizeDefaults()
return &cached, nil
}
}
@@ -371,6 +380,7 @@ func getSubscriptionPlanByIdTx(tx *gorm.DB, id int) (*SubscriptionPlan, error) {
if err := query.Where("id = ?", id).First(&plan).Error; err != nil {
return nil, err
}
plan.NormalizeDefaults()
_ = getSubscriptionPlanCache().SetWithTTL(key, plan, subscriptionPlanCacheTTL())
return &plan, nil
}
@@ -701,6 +711,9 @@ func PurchaseSubscriptionWithBalance(userId int, planId int) error {
if plan.PriceAmount < 0 {
return errors.New("套餐价格不能为负数")
}
if plan.AllowBalancePay != nil && !*plan.AllowBalancePay {
return errors.New("该套餐不允许使用余额兑换")
}
requiredQuota, err := calcSubscriptionBalanceQuota(plan.PriceAmount)
if err != nil {
+17
View File
@@ -984,6 +984,23 @@ func updateUserUsedQuotaAndRequestCount(id int, quota int, count int) {
//}
}
func updateUserQuotaUsedQuotaAndRequestCount(id int, quota int, usedQuota int, requestCount int) {
if quota == 0 && usedQuota == 0 && requestCount == 0 {
return
}
err := DB.Model(&User{}).Where("id = ?", id).Updates(
map[string]interface{}{
"quota": gorm.Expr("quota + ?", quota),
"used_quota": gorm.Expr("used_quota + ?", usedQuota),
"request_count": gorm.Expr("request_count + ?", requestCount),
},
).Error
if err != nil {
common.SysLog("failed to batch update user quota, used quota and request count: " + err.Error())
}
}
func updateUserUsedQuota(id int, quota int) {
err := DB.Model(&User{}).Where("id = ?", id).Updates(
map[string]interface{}{
+26 -11
View File
@@ -67,33 +67,48 @@ func batchUpdate() {
}
common.SysLog("batch update started")
stores := make([]map[int]int, BatchUpdateTypeCount)
for i := 0; i < BatchUpdateTypeCount; i++ {
batchUpdateLocks[i].Lock()
store := batchUpdateStores[i]
stores[i] = batchUpdateStores[i]
batchUpdateStores[i] = make(map[int]int)
batchUpdateLocks[i].Unlock()
// TODO: maybe we can combine updates with same key?
}
for i, store := range stores {
if i == BatchUpdateTypeUserQuota || i == BatchUpdateTypeUsedQuota || i == BatchUpdateTypeRequestCount {
continue
}
for key, value := range store {
switch i {
case BatchUpdateTypeUserQuota:
err := increaseUserQuota(key, value)
if err != nil {
common.SysLog("failed to batch update user quota: " + err.Error())
}
case BatchUpdateTypeTokenQuota:
err := increaseTokenQuota(key, value)
if err != nil {
common.SysLog("failed to batch update token quota: " + err.Error())
}
case BatchUpdateTypeUsedQuota:
updateUserUsedQuota(key, value)
case BatchUpdateTypeRequestCount:
updateUserRequestCount(key, value)
case BatchUpdateTypeChannelUsedQuota:
updateChannelUsedQuota(key, value)
}
}
}
userQuotaStore := stores[BatchUpdateTypeUserQuota]
usedQuotaStore := stores[BatchUpdateTypeUsedQuota]
requestCountStore := stores[BatchUpdateTypeRequestCount]
userIDs := make(map[int]struct{}, len(userQuotaStore)+len(usedQuotaStore)+len(requestCountStore))
for key := range userQuotaStore {
userIDs[key] = struct{}{}
}
for key := range usedQuotaStore {
userIDs[key] = struct{}{}
}
for key := range requestCountStore {
userIDs[key] = struct{}{}
}
for key := range userIDs {
updateUserQuotaUsedQuotaAndRequestCount(key, userQuotaStore[key], usedQuotaStore[key], requestCountStore[key])
}
common.SysLog("batch update finished")
}
+6
View File
@@ -19,6 +19,7 @@ var awsModelIDMap = map[string]string{
"claude-opus-4-5-20251101": "anthropic.claude-opus-4-5-20251101-v1:0",
"claude-opus-4-6": "anthropic.claude-opus-4-6-v1",
"claude-opus-4-7": "anthropic.claude-opus-4-7",
"claude-opus-4-8": "anthropic.claude-opus-4-8",
// Nova models
"nova-micro-v1:0": "amazon.nova-micro-v1:0",
"nova-lite-v1:0": "amazon.nova-lite-v1:0",
@@ -97,6 +98,11 @@ var awsModelCanCrossRegionMap = map[string]map[string]bool{
"ap": true,
"eu": true,
},
"anthropic.claude-opus-4-8": {
"us": true,
"ap": true,
"eu": true,
},
"anthropic.claude-haiku-4-5-20251001-v1:0": {
"us": true,
"ap": true,
+7
View File
@@ -33,6 +33,13 @@ var ModelList = []string{
"claude-opus-4-7-medium",
"claude-opus-4-7-low",
"claude-opus-4-7-thinking",
"claude-opus-4-8",
"claude-opus-4-8-max",
"claude-opus-4-8-xhigh",
"claude-opus-4-8-high",
"claude-opus-4-8-medium",
"claude-opus-4-8-low",
"claude-opus-4-8-thinking",
}
var ChannelName = "claude"
+9 -5
View File
@@ -154,14 +154,17 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
}
if baseModel, effortLevel, ok := reasoning.TrimEffortSuffix(textRequest.Model); ok && effortLevel != "" &&
(strings.HasPrefix(textRequest.Model, "claude-opus-4-6") || strings.HasPrefix(textRequest.Model, "claude-opus-4-7")) {
(strings.HasPrefix(textRequest.Model, "claude-opus-4-6") ||
strings.HasPrefix(textRequest.Model, "claude-opus-4-7") ||
strings.HasPrefix(textRequest.Model, "claude-opus-4-8")) {
claudeRequest.Model = baseModel
claudeRequest.Thinking = &dto.Thinking{
Type: "adaptive",
}
claudeRequest.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel))
if strings.HasPrefix(baseModel, "claude-opus-4-7") {
// Opus 4.7 rejects non-default temperature/top_p/top_k with 400
if strings.HasPrefix(baseModel, "claude-opus-4-7") ||
strings.HasPrefix(baseModel, "claude-opus-4-8") {
// Opus 4.7/4.8 reject non-default temperature/top_p/top_k with 400
// and defaults display to "omitted"; restore the 4.6 visible summary.
claudeRequest.Thinking.Display = "summarized"
claudeRequest.Temperature = nil
@@ -175,8 +178,9 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
strings.HasSuffix(textRequest.Model, "-thinking") {
trimmedModel := strings.TrimSuffix(textRequest.Model, "-thinking")
if strings.HasPrefix(trimmedModel, "claude-opus-4-7") {
// Opus 4.7 rejects thinking.type="enabled"; use adaptive at high effort.
if strings.HasPrefix(trimmedModel, "claude-opus-4-7") ||
strings.HasPrefix(trimmedModel, "claude-opus-4-8") {
// Opus 4.7/4.8 reject thinking.type="enabled"; use adaptive at high effort.
claudeRequest.Thinking = &dto.Thinking{Type: "adaptive", Display: "summarized"}
claudeRequest.OutputConfig = json.RawMessage(`{"effort":"high"}`)
claudeRequest.Temperature = nil
+56
View File
@@ -9,6 +9,10 @@ import (
"github.com/stretchr/testify/require"
)
func commonPointer[T any](value T) *T {
return &value
}
func TestFormatClaudeResponseInfo_MessageStart(t *testing.T) {
claudeInfo := &ClaudeResponseInfo{
Usage: &dto.Usage{},
@@ -310,6 +314,58 @@ func TestRequestOpenAI2ClaudeMessage_IgnoresUnsupportedFileContent(t *testing.T)
require.Equal(t, "see attachment", *content[0].Text)
}
func TestRequestOpenAI2ClaudeMessage_ClaudeOpus48HighUsesAdaptiveThinking(t *testing.T) {
request := dto.GeneralOpenAIRequest{
Model: "claude-opus-4-8-high",
Temperature: commonPointer(0.7),
TopP: commonPointer(0.9),
TopK: commonPointer(40),
Messages: []dto.Message{
{
Role: "user",
Content: "hello",
},
},
}
claudeRequest, err := RequestOpenAI2ClaudeMessage(nil, request)
require.NoError(t, err)
require.Equal(t, "claude-opus-4-8", claudeRequest.Model)
require.NotNil(t, claudeRequest.Thinking)
require.Equal(t, "adaptive", claudeRequest.Thinking.Type)
require.Equal(t, "summarized", claudeRequest.Thinking.Display)
require.JSONEq(t, `{"effort":"high"}`, string(claudeRequest.OutputConfig))
require.Nil(t, claudeRequest.Temperature)
require.Nil(t, claudeRequest.TopP)
require.Nil(t, claudeRequest.TopK)
}
func TestRequestOpenAI2ClaudeMessage_ClaudeOpus48ThinkingUsesAdaptiveHighEffort(t *testing.T) {
request := dto.GeneralOpenAIRequest{
Model: "claude-opus-4-8-thinking",
Temperature: commonPointer(0.7),
TopP: commonPointer(0.9),
TopK: commonPointer(40),
Messages: []dto.Message{
{
Role: "user",
Content: "hello",
},
},
}
claudeRequest, err := RequestOpenAI2ClaudeMessage(nil, request)
require.NoError(t, err)
require.Equal(t, "claude-opus-4-8", claudeRequest.Model)
require.NotNil(t, claudeRequest.Thinking)
require.Equal(t, "adaptive", claudeRequest.Thinking.Type)
require.Equal(t, "summarized", claudeRequest.Thinking.Display)
require.JSONEq(t, `{"effort":"high"}`, string(claudeRequest.OutputConfig))
require.Nil(t, claudeRequest.Temperature)
require.Nil(t, claudeRequest.TopP)
require.Nil(t, claudeRequest.TopK)
}
func TestRequestOpenAI2ClaudeMessage_SupportsPDFFileContent(t *testing.T) {
request := dto.GeneralOpenAIRequest{
Model: "claude-3-5-sonnet",
+1
View File
@@ -45,6 +45,7 @@ var claudeModelMap = map[string]string{
"claude-opus-4-5-20251101": "claude-opus-4-5@20251101",
"claude-opus-4-6": "claude-opus-4-6",
"claude-opus-4-7": "claude-opus-4-7",
"claude-opus-4-8": "claude-opus-4-8",
}
const anthropicVersion = "vertex-2023-10-16"
+9 -5
View File
@@ -53,14 +53,17 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
}
if baseModel, effortLevel, ok := reasoning.TrimEffortSuffix(request.Model); ok && effortLevel != "" &&
(strings.HasPrefix(request.Model, "claude-opus-4-6") || strings.HasPrefix(request.Model, "claude-opus-4-7")) {
(strings.HasPrefix(request.Model, "claude-opus-4-6") ||
strings.HasPrefix(request.Model, "claude-opus-4-7") ||
strings.HasPrefix(request.Model, "claude-opus-4-8")) {
request.Model = baseModel
request.Thinking = &dto.Thinking{
Type: "adaptive",
}
request.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel))
if strings.HasPrefix(request.Model, "claude-opus-4-7") {
// Opus 4.7 rejects non-default temperature/top_p/top_k with 400
if strings.HasPrefix(request.Model, "claude-opus-4-7") ||
strings.HasPrefix(request.Model, "claude-opus-4-8") {
// Opus 4.7/4.8 reject non-default temperature/top_p/top_k with 400
// and defaults display to "omitted"; restore the 4.6 visible summary.
request.Thinking.Display = "summarized"
request.Temperature = nil
@@ -74,8 +77,9 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
strings.HasSuffix(request.Model, "-thinking") {
if request.Thinking == nil {
baseModel := strings.TrimSuffix(request.Model, "-thinking")
if strings.HasPrefix(baseModel, "claude-opus-4-7") {
// Opus 4.7 rejects thinking.type="enabled"; use adaptive at high effort.
if strings.HasPrefix(baseModel, "claude-opus-4-7") ||
strings.HasPrefix(baseModel, "claude-opus-4-8") {
// Opus 4.7/4.8 reject thinking.type="enabled"; use adaptive at high effort.
request.Thinking = &dto.Thinking{Type: "adaptive", Display: "summarized"}
request.OutputConfig = json.RawMessage(`{"effort":"high"}`)
request.Temperature = nil
+14
View File
@@ -71,6 +71,13 @@ var defaultCacheRatio = map[string]float64{
"claude-opus-4-7-high": 0.1,
"claude-opus-4-7-medium": 0.1,
"claude-opus-4-7-low": 0.1,
"claude-opus-4-8": 0.1,
"claude-opus-4-8-thinking": 0.1,
"claude-opus-4-8-max": 0.1,
"claude-opus-4-8-xhigh": 0.1,
"claude-opus-4-8-high": 0.1,
"claude-opus-4-8-medium": 0.1,
"claude-opus-4-8-low": 0.1,
}
var defaultCreateCacheRatio = map[string]float64{
@@ -106,6 +113,13 @@ var defaultCreateCacheRatio = map[string]float64{
"claude-opus-4-7-high": 1.25,
"claude-opus-4-7-medium": 1.25,
"claude-opus-4-7-low": 1.25,
"claude-opus-4-8": 1.25,
"claude-opus-4-8-thinking": 1.25,
"claude-opus-4-8-max": 1.25,
"claude-opus-4-8-xhigh": 1.25,
"claude-opus-4-8-high": 1.25,
"claude-opus-4-8-medium": 1.25,
"claude-opus-4-8-low": 1.25,
}
//var defaultCreateCacheRatio = map[string]float64{}
+6
View File
@@ -152,6 +152,12 @@ var defaultModelRatio = map[string]float64{
"claude-opus-4-7-high": 2.5,
"claude-opus-4-7-medium": 2.5,
"claude-opus-4-7-low": 2.5,
"claude-opus-4-8": 2.5,
"claude-opus-4-8-max": 2.5,
"claude-opus-4-8-xhigh": 2.5,
"claude-opus-4-8-high": 2.5,
"claude-opus-4-8-medium": 2.5,
"claude-opus-4-8-low": 2.5,
"claude-3-opus-20240229": 7.5, // $15 / 1M tokens
"claude-opus-4-20250514": 7.5,
"claude-opus-4-1-20250805": 7.5,
+1136 -371
View File
File diff suppressed because it is too large Load Diff
-2379
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -24,6 +24,5 @@
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.jsx"></script>
</body>
</html>
+21 -20
View File
@@ -4,30 +4,32 @@
"private": true,
"type": "module",
"dependencies": {
"@douyinfe/semi-illustrations": "^2.69.1",
"@douyinfe/semi-icons": "^2.63.1",
"@douyinfe/semi-ui": "^2.69.1",
"@lobehub/icons": "^2.0.0",
"@lobehub/icons": "catalog:",
"@visactor/react-vchart": "~1.8.8",
"@visactor/vchart": "~1.8.8",
"@visactor/vchart-semi-theme": "~1.8.8",
"axios": "1.15.2",
"clsx": "^2.1.1",
"dayjs": "^1.11.11",
"axios": "catalog:",
"clsx": "catalog:",
"dayjs": "catalog:",
"history": "^5.3.0",
"highlight.js": "^11.11.1",
"i18next": "^23.16.8",
"i18next-browser-languagedetector": "^7.2.0",
"katex": "^0.16.22",
"lucide-react": "^0.511.0",
"marked": "^4.1.1",
"mermaid": "^11.6.0",
"qrcode.react": "^4.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"qrcode.react": "catalog:",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-dropzone": "^14.2.3",
"react-fireworks": "^1.0.4",
"react-i18next": "^13.0.0",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-icons": "catalog:",
"react-markdown": "catalog:",
"react-router-dom": "^6.3.0",
"react-telegram-login": "^1.1.2",
"react-toastify": "^9.0.8",
@@ -35,20 +37,20 @@
"rehype-highlight": "^7.0.2",
"rehype-katex": "^7.0.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"remark-gfm": "catalog:",
"remark-math": "^6.0.0",
"sse.js": "^2.6.0",
"sse.js": "catalog:",
"unist-util-visit": "^5.0.0",
"use-debounce": "^10.0.4"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"dev": "rsbuild dev",
"build": "rsbuild build",
"lint": "prettier . --check",
"lint:fix": "prettier . --write",
"eslint": "bunx eslint \"**/*.{js,jsx}\" --cache",
"eslint:fix": "bunx eslint \"**/*.{js,jsx}\" --fix --cache",
"preview": "vite preview",
"preview": "rsbuild preview",
"i18n:extract": "bunx i18next-cli extract",
"i18n:status": "bunx i18next-cli status",
"i18n:sync": "bunx i18next-cli sync",
@@ -73,20 +75,19 @@
]
},
"devDependencies": {
"@douyinfe/vite-plugin-semi": "^2.74.0-alpha.6",
"@rsbuild/core": "^2.0.7",
"@rsbuild/plugin-react": "^2.0.0",
"@so1ve/prettier-config": "^3.1.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.21",
"code-inspector-plugin": "^1.3.3",
"eslint": "8.57.0",
"eslint-plugin-header": "^3.1.1",
"eslint-plugin-react-hooks": "^5.2.0",
"i18next-cli": "^1.10.3",
"postcss": "^8.5.3",
"prettier": "^3.0.0",
"prop-types": "^15.8.1",
"prettier": "catalog:",
"tailwindcss": "^3",
"typescript": "4.4.2",
"vite": "^5.2.0"
"typescript": "4.4.2"
},
"prettier": {
"singleQuote": true,
+106
View File
@@ -0,0 +1,106 @@
import path from 'path'
import { createRequire } from 'module'
import { fileURLToPath } from 'url'
import { defineConfig, loadEnv } from '@rsbuild/core'
import { pluginReact } from '@rsbuild/plugin-react'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const require = createRequire(import.meta.url)
const semiUiDir = path.resolve(
path.dirname(require.resolve('@douyinfe/semi-ui')),
'../..',
)
export default defineConfig(({ envMode }) => {
const env = loadEnv({ mode: envMode, prefixes: ['VITE_'] })
const clientServerUrl =
process.env.VITE_REACT_APP_SERVER_URL ||
env.rawPublicVars.VITE_REACT_APP_SERVER_URL ||
''
const proxyServerUrl =
clientServerUrl ||
'http://localhost:3000'
const isProd = envMode === 'production'
const devProxy = Object.fromEntries(
(['/api', '/mj', '/pg'] as const).map((key) => [
key,
{ target: proxyServerUrl, changeOrigin: true },
]),
) as Record<string, { target: string; changeOrigin: boolean }>
return {
plugins: [pluginReact()],
source: {
entry: {
index: './src/index.jsx',
},
define: {
'import.meta.env.VITE_REACT_APP_SERVER_URL': JSON.stringify(
clientServerUrl,
),
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@douyinfe/semi-ui/dist/css/semi.css': path.resolve(
semiUiDir,
'dist/css/semi.css',
),
},
},
html: {
template: './index.html',
},
server: {
host: '0.0.0.0',
strictPort: true,
proxy: devProxy,
},
output: {
minify: isProd,
target: 'web',
distPath: {
root: 'dist',
},
},
performance: {
removeConsole: isProd ? ['log'] : false,
buildCache: {
cacheDigest: [process.env.VITE_REACT_APP_VERSION],
},
},
tools: {
rspack: {
module: {
rules: [
{
test: /src[\\/].*\.js$/,
type: 'javascript/auto',
use: [
{
loader: 'builtin:swc-loader',
options: {
jsc: {
parser: {
syntax: 'ecmascript',
jsx: true,
},
transform: {
react: {
runtime: 'automatic',
development: !isProd,
refresh: !isProd,
},
},
},
},
},
],
},
],
},
},
},
}
})
+1 -1
View File
@@ -947,7 +947,7 @@ const LoginForm = () => {
};
return (
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
<div className='classic-page-fill relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
{/* 背景模糊晕染球 */}
<div
className='blur-ball blur-ball-indigo'
+1 -1
View File
@@ -104,7 +104,7 @@ const PasswordResetConfirm = () => {
}
return (
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
<div className='classic-page-fill relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
{/* 背景模糊晕染球 */}
<div
className='blur-ball blur-ball-indigo'
+1 -1
View File
@@ -104,7 +104,7 @@ const PasswordResetForm = () => {
}
return (
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
<div className='classic-page-fill relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
{/* 背景模糊晕染球 */}
<div
className='blur-ball blur-ball-indigo'
+1 -1
View File
@@ -770,7 +770,7 @@ const RegisterForm = () => {
};
return (
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
<div className='classic-page-fill relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
{/* 背景模糊晕染球 */}
<div
className='blur-ball blur-ball-indigo'
@@ -141,7 +141,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
// 显示加载状态
if (loading) {
return (
<div className='flex justify-center items-center min-h-screen'>
<div className='classic-page-fill flex justify-center items-center'>
<Spin size='large' />
</div>
);
@@ -150,7 +150,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
// 如果没有内容,显示空状态
if (!content || content.trim() === '') {
return (
<div className='flex justify-center items-center min-h-screen bg-gray-50'>
<div className='classic-page-fill flex justify-center items-center bg-gray-50'>
<Empty
title={t('管理员未设置' + title + '内容')}
image={
@@ -168,7 +168,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
// 如果是 URL,显示链接卡片
if (isUrl(content)) {
return (
<div className='flex justify-center items-center min-h-screen bg-gray-50 p-4'>
<div className='classic-page-fill flex justify-center items-center bg-gray-50 p-4'>
<Card className='max-w-md w-full'>
<div className='text-center'>
<Title heading={4} className='mb-4'>
@@ -196,7 +196,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
// 如果是 HTML 内容,直接渲染
if (isHtmlContent(content)) {
return (
<div className='min-h-screen bg-gray-50'>
<div className='classic-page-fill bg-gray-50'>
<div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'>
<div className='bg-white rounded-lg shadow-sm p-8'>
<Title heading={2} className='text-center mb-8'>
@@ -214,7 +214,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
// 其他内容统一使用 Markdown 渲染器
return (
<div className='min-h-screen bg-gray-50'>
<div className='classic-page-fill bg-gray-50'>
<div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'>
<div className='bg-white rounded-lg shadow-sm p-8'>
<Title heading={2} className='text-center mb-8'>
+10 -5
View File
@@ -71,6 +71,7 @@ const PageLayout = () => {
const isConsoleRoute = location.pathname.startsWith('/console');
const showSider = isConsoleRoute && (!isMobile || drawerOpen);
const isFixedLayout = isConsoleRoute || location.pathname === '/pricing';
useEffect(() => {
if (isMobile && drawerOpen && collapsed) {
@@ -146,11 +147,11 @@ const PageLayout = () => {
return (
<Layout
className='app-layout'
className={`app-layout${isFixedLayout ? ' app-layout-fixed' : ''}`}
style={{
display: 'flex',
flexDirection: 'column',
overflow: isMobile ? 'visible' : 'hidden',
overflow: isFixedLayout && !isMobile ? 'hidden' : 'visible',
}}
>
<Header
@@ -171,9 +172,10 @@ const PageLayout = () => {
</Header>
<Layout
style={{
overflow: isMobile ? 'visible' : 'auto',
overflow: isFixedLayout && !isMobile ? 'auto' : 'visible',
display: 'flex',
flexDirection: 'column',
flex: '1 1 auto',
}}
>
{showSider && (
@@ -206,15 +208,18 @@ const PageLayout = () => {
flex: '1 1 auto',
display: 'flex',
flexDirection: 'column',
minHeight: 0,
}}
>
<Content
className={isFixedLayout ? undefined : 'public-page-content'}
style={{
flex: '1 0 auto',
overflowY: isMobile ? 'visible' : 'hidden',
flex: isFixedLayout ? '1 0 auto' : '1 1 auto',
overflowY: isFixedLayout && !isMobile ? 'hidden' : 'visible',
WebkitOverflowScrolling: 'touch',
padding: shouldInnerPadding ? (isMobile ? '5px' : '24px') : '0',
position: 'relative',
minHeight: 0,
}}
>
<ErrorBoundary>
+43 -9
View File
@@ -17,12 +17,46 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
export * from './channel.constants';
export * from './user.constants';
export * from './toast.constants';
export * from './common.constant';
export * from './dashboard.constants';
export * from './playground.constants';
export * from './redemption.constants';
export * from './channel-affinity-template.constants';
export * from './billing.constants';
export {
CHANNEL_OPTIONS,
MODEL_FETCHABLE_CHANNEL_TYPES,
MODEL_TABLE_PAGE_SIZE,
} from './channel.constants';
export { userConstants } from './user.constants';
export { toastConstants } from './toast.constants';
export {
ITEMS_PER_PAGE,
DEFAULT_ENDPOINT,
TABLE_COMPACT_MODES_KEY,
API_ENDPOINTS,
TASK_ACTION_GENERATE,
TASK_ACTION_TEXT_GENERATE,
TASK_ACTION_FIRST_TAIL_GENERATE,
TASK_ACTION_REFERENCE_GENERATE,
TASK_ACTION_REMIX_GENERATE,
} from './common.constant';
export {
REDEMPTION_STATUS,
REDEMPTION_STATUS_MAP,
REDEMPTION_ACTIONS,
} from './redemption.constants';
export {
CODEX_CLI_HEADER_PASSTHROUGH_HEADERS,
CLAUDE_CLI_HEADER_PASSTHROUGH_HEADERS,
CODEX_CLI_HEADER_PASSTHROUGH_TEMPLATE,
CLAUDE_CLI_HEADER_PASSTHROUGH_TEMPLATE,
CHANNEL_AFFINITY_RULE_TEMPLATES,
cloneChannelAffinityTemplate,
} from './channel-affinity-template.constants';
export {
BILLING_VARS,
BILLING_VAR_KEYS,
BILLING_PRICING_VARS,
BILLING_EXTRA_VARS,
BILLING_VAR_KEY_TO_FIELD,
BILLING_VAR_FIELD_TO_LABEL,
BILLING_VAR_FIELD_TO_SHORT_LABEL,
BILLING_CACHE_VAR_MAP,
BILLING_VAR_REGEX,
BILLING_CONDITION_VARS,
} from './billing.constants';
+2 -2
View File
@@ -94,7 +94,6 @@ import {
SiGitlab,
SiGoogle,
SiKeycloak,
SiLinkedin,
SiNextcloud,
SiNotion,
SiOkta,
@@ -106,6 +105,7 @@ import {
SiWechat,
SiX,
} from 'react-icons/si';
import { FaLinkedin } from 'react-icons/fa';
// 获取侧边栏Lucide图标组件
export function getLucideIcon(key, selected = false) {
@@ -509,7 +509,7 @@ const oauthProviderIconMap = {
google: SiGoogle,
discord: SiDiscord,
facebook: SiFacebook,
linkedin: SiLinkedin,
linkedin: FaLinkedin,
x: SiX,
twitter: SiX,
slack: SiSlack,
+1 -1
View File
@@ -123,7 +123,7 @@ export function showError(error) {
console.error(error);
if (error.message) {
if (error.name === 'AxiosError') {
switch (error.response.status) {
switch (error.response?.status) {
case 401:
//
localStorage.removeItem('user');
+28 -6
View File
@@ -31,18 +31,40 @@ body {
background-color: var(--semi-color-bg-0);
}
/* 桌面端禁止 body 纵向滚动 - 防止 VChart tooltip 触发页面滚动条 */
@media (min-width: 768px) {
body {
overflow-y: hidden;
}
.app-layout {
min-height: 100vh;
min-height: 100dvh;
}
.app-layout {
.app-layout-fixed {
height: 100vh;
height: 100dvh;
}
.public-page-content {
display: flex;
flex-direction: column;
}
.classic-page-fill {
flex: 1 0 auto;
min-height: 100%;
}
.classic-home-page,
.classic-home-default {
display: flex;
flex-direction: column;
}
.classic-home-default {
flex: 1 0 auto;
}
.classic-home-hero {
flex: 1 0 auto;
}
.app-sider {
height: calc(100vh - 64px);
height: calc(100dvh - 64px);
+2
View File
@@ -1,3 +1,5 @@
import '@douyinfe/semi-ui/react19-adapter';
/*
Copyright (C) 2025 QuantumNous
+8 -3
View File
@@ -133,9 +133,9 @@ const About = () => {
);
return (
<div className='mt-[60px] px-2'>
<div className='classic-page-fill flex flex-col pt-[60px] px-2'>
{aboutLoaded && about === '' ? (
<div className='flex justify-center items-center h-screen p-8'>
<div className='flex flex-1 justify-center items-center p-8'>
<Empty
image={
<IllustrationConstruction style={{ width: 150, height: 150 }} />
@@ -156,7 +156,12 @@ const About = () => {
{about.startsWith('https://') ? (
<iframe
src={about}
style={{ width: '100%', height: '100vh', border: 'none' }}
style={{
width: '100%',
flex: '1 1 auto',
minHeight: 0,
border: 'none',
}}
/>
) : (
<div
+1 -1
View File
@@ -28,7 +28,7 @@ import { useTranslation } from 'react-i18next';
const Forbidden = () => {
const { t } = useTranslation();
return (
<div className='flex justify-center items-center h-screen p-8'>
<div className='classic-page-fill flex justify-center items-center p-8'>
<Empty
image={<IllustrationNoAccess style={{ width: 250, height: 250 }} />}
darkModeImage={
+6 -6
View File
@@ -149,20 +149,20 @@ const Home = () => {
}, [endpointItems.length]);
return (
<div className='w-full overflow-x-hidden'>
<div className='classic-page-fill classic-home-page w-full overflow-x-hidden'>
<NoticeModal
visible={noticeVisible}
onClose={() => setNoticeVisible(false)}
isMobile={isMobile}
/>
{homePageContentLoaded && homePageContent === '' ? (
<div className='w-full overflow-x-hidden'>
<div className='classic-home-default w-full overflow-x-hidden'>
{/* Banner 部分 */}
<div className='w-full border-b border-semi-color-border min-h-[500px] md:min-h-[600px] lg:min-h-[700px] relative overflow-x-hidden'>
<div className='classic-home-hero w-full border-b border-semi-color-border relative overflow-x-hidden'>
{/* 背景模糊晕染球 */}
<div className='blur-ball blur-ball-indigo' />
<div className='blur-ball blur-ball-teal' />
<div className='flex items-center justify-center h-full px-4 py-20 md:py-24 lg:py-32 mt-10'>
<div className='flex items-center justify-center px-4 pt-24 pb-8'>
{/* 居中内容区 */}
<div className='flex flex-col items-center justify-center text-center max-w-4xl mx-auto'>
<div className='flex flex-col items-center justify-center mb-6 md:mb-8'>
@@ -335,11 +335,11 @@ const Home = () => {
</div>
</div>
) : (
<div className='overflow-x-hidden w-full'>
<div className='classic-page-fill overflow-x-hidden w-full'>
{homePageContent.startsWith('https://') ? (
<iframe
src={homePageContent}
className='w-full h-screen border-none'
className='w-full h-full border-none'
/>
) : (
<div
+1 -1
View File
@@ -28,7 +28,7 @@ import { useTranslation } from 'react-i18next';
const NotFound = () => {
const { t } = useTranslation();
return (
<div className='flex justify-center items-center h-screen p-8'>
<div className='classic-page-fill flex justify-center items-center p-8'>
<Empty
image={<IllustrationNotFound style={{ width: 250, height: 250 }} />}
darkModeImage={
-107
View File
@@ -1,107 +0,0 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import react from '@vitejs/plugin-react';
import { defineConfig, transformWithEsbuild } from 'vite';
import pkg from '@douyinfe/vite-plugin-semi';
import path from 'path';
import { codeInspectorPlugin } from 'code-inspector-plugin';
const { vitePluginSemi } = pkg;
// https://vitejs.dev/config/
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
plugins: [
codeInspectorPlugin({
bundler: 'vite',
}),
{
name: 'treat-js-files-as-jsx',
async transform(code, id) {
if (!/src\/.*\.js$/.test(id)) {
return null;
}
// Use the exposed transform from vite, instead of directly
// transforming with esbuild
return transformWithEsbuild(code, id, {
loader: 'jsx',
jsx: 'automatic',
});
},
},
react(),
vitePluginSemi({
cssLayer: true,
}),
],
optimizeDeps: {
force: true,
esbuildOptions: {
loader: {
'.js': 'jsx',
'.json': 'json',
},
},
},
build: {
rollupOptions: {
output: {
manualChunks: {
'react-core': ['react', 'react-dom', 'react-router-dom'],
'semi-ui': ['@douyinfe/semi-icons', '@douyinfe/semi-ui'],
tools: ['axios', 'history', 'marked'],
'react-components': [
'react-dropzone',
'react-fireworks',
'react-telegram-login',
'react-toastify',
'react-turnstile',
],
i18n: [
'i18next',
'react-i18next',
'i18next-browser-languagedetector',
],
},
},
},
},
server: {
host: '0.0.0.0',
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
'/mj': {
target: 'http://localhost:3000',
changeOrigin: true,
},
'/pg': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
});
+10 -10
View File
@@ -24,7 +24,7 @@
"@hookform/resolvers": "^5.4.0",
"@hugeicons/core-free-icons": "^4.1.4",
"@hugeicons/react": "^1.1.6",
"@lobehub/icons": "^5.8.0",
"@lobehub/icons": "catalog:",
"@tailwindcss/postcss": "^4.3.0",
"@tanstack/react-query": "^5.100.14",
"@tanstack/react-router": "^1.170.8",
@@ -34,12 +34,12 @@
"@visactor/vchart": "^2.0.22",
"ai": "^6.0.191",
"auto-skeleton-react": "^1.0.5",
"axios": "^1.16.1",
"axios": "catalog:",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"clsx": "catalog:",
"cmdk": "^1.1.1",
"date-fns": "^4.3.0",
"dayjs": "^1.11.20",
"dayjs": "catalog:",
"i18next": "^26.2.0",
"i18next-browser-languagedetector": "^8.2.1",
"input-otp": "^1.4.2",
@@ -47,22 +47,22 @@
"motion": "^12.40.0",
"nanoid": "^5.1.11",
"next-themes": "^0.4.6",
"qrcode.react": "^4.2.0",
"qrcode.react": "catalog:",
"react": "^19.2.6",
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.6",
"react-hook-form": "^7.76.1",
"react-i18next": "^17.0.8",
"react-icons": "^5.6.0",
"react-markdown": "^10.1.0",
"react-icons": "catalog:",
"react-markdown": "catalog:",
"react-resizable-panels": "^4.11.2",
"react-top-loading-bar": "^3.0.2",
"recharts": "3.8.1",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"remark-gfm": "catalog:",
"shiki": "^4.1.0",
"sonner": "^2.0.7",
"sse.js": "^2.8.0",
"sse.js": "catalog:",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.0",
@@ -92,7 +92,7 @@
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"knip": "^6.14.2",
"prettier": "^3.8.3",
"prettier": "catalog:",
"prettier-plugin-tailwindcss": "^0.8.0",
"shadcn": "^4.8.0",
"typescript": "~6.0.3",
+1
View File
@@ -65,6 +65,7 @@ export default defineConfig(({ envMode }) => {
},
server: {
host: '0.0.0.0',
strictPort: true,
proxy: devProxy,
},
output: {
+284
View File
@@ -0,0 +1,284 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import {
useMemo,
useRef,
useState,
type ComponentProps,
type KeyboardEvent,
} from 'react'
import { AlertCircle, Braces, CheckCircle2, Code2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
export type JsonCodeEditorProps = Omit<ComponentProps<'div'>, 'onChange'> & {
value: string
onChange: (value: string) => void
disabled?: boolean
heightClassName?: string
}
export function JsonCodeEditor({
value,
onChange,
disabled,
heightClassName = 'h-56 min-h-56 max-h-56',
className,
id,
'aria-describedby': ariaDescribedBy,
'aria-invalid': ariaInvalid,
...rootProps
}: JsonCodeEditorProps) {
const { t } = useTranslation()
const textareaRef = useRef<HTMLTextAreaElement>(null)
const [scrollTop, setScrollTop] = useState(0)
const lineNumbers = useMemo(() => {
const count = Math.max(1, value.split('\n').length)
return Array.from({ length: count }, (_, index) => index + 1)
}, [value])
const jsonStatus = useMemo(() => {
const trimmed = value.trim()
if (!trimmed) return { valid: true, message: t('JSON') }
try {
JSON.parse(trimmed)
return { valid: true, message: t('JSON') }
} catch {
return { valid: false, message: t('Invalid JSON') }
}
}, [value, t])
const formatJson = () => {
const trimmed = value.trim()
if (!trimmed) return
try {
onChange(JSON.stringify(JSON.parse(trimmed), null, 2))
} catch {
// Keep invalid drafts untouched; validation feedback remains visible.
}
}
const updateValueWithSelection = (
nextValue: string,
selectionStart: number,
selectionEnd = selectionStart
) => {
onChange(nextValue)
window.requestAnimationFrame(() => {
textareaRef.current?.setSelectionRange(selectionStart, selectionEnd)
})
}
const getLineIndent = (text: string, cursor: number) => {
const lineStart = text.lastIndexOf('\n', cursor - 1) + 1
return text.slice(lineStart, cursor).match(/^\s*/)?.[0] ?? ''
}
const handleEditorKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
const target = event.currentTarget
const start = target.selectionStart
const end = target.selectionEnd
const selected = value.slice(start, end)
const before = value.slice(0, start)
const after = value.slice(end)
if (event.key === 'Tab') {
event.preventDefault()
if (start !== end && selected.includes('\n')) {
const selectionLineStart = value.lastIndexOf('\n', start - 1) + 1
const selectedBlock = value.slice(selectionLineStart, end)
const lines = selectedBlock.split('\n')
const nextBlock = event.shiftKey
? lines
.map((line) =>
line.startsWith(' ')
? line.slice(2)
: line.startsWith('\t')
? line.slice(1)
: line
)
.join('\n')
: lines.map((line) => ` ${line}`).join('\n')
const nextValue =
value.slice(0, selectionLineStart) + nextBlock + value.slice(end)
updateValueWithSelection(
nextValue,
selectionLineStart,
selectionLineStart + nextBlock.length
)
return
}
if (event.shiftKey) {
const lineStart = value.lastIndexOf('\n', start - 1) + 1
const removable = value.slice(lineStart, lineStart + 2)
if (removable === ' ') {
updateValueWithSelection(
value.slice(0, lineStart) + value.slice(lineStart + 2),
Math.max(lineStart, start - 2),
Math.max(lineStart, end - 2)
)
}
return
}
updateValueWithSelection(`${before} ${after}`, start + 2)
return
}
if (event.key === 'Enter') {
event.preventDefault()
const indent = getLineIndent(value, start)
const previousChar = before.trimEnd().at(-1)
const nextChar = after.trimStart().at(0)
const shouldNest = previousChar === '{' || previousChar === '['
const shouldClose =
(previousChar === '{' && nextChar === '}') ||
(previousChar === '[' && nextChar === ']')
if (shouldNest && shouldClose) {
const innerIndent = `${indent} `
const insert = `\n${innerIndent}\n${indent}`
updateValueWithSelection(
`${before}${insert}${after}`,
start + 1 + innerIndent.length
)
return
}
const nextIndent = shouldNest ? `${indent} ` : indent
const insert = `\n${nextIndent}`
updateValueWithSelection(
`${before}${insert}${after}`,
start + insert.length
)
return
}
const pairs: Record<string, string> = {
'"': '"',
'{': '}',
'[': ']',
}
const closingChars = new Set(Object.values(pairs))
if (closingChars.has(event.key) && value[start] === event.key) {
event.preventDefault()
textareaRef.current?.setSelectionRange(start + 1, start + 1)
return
}
if (pairs[event.key]) {
event.preventDefault()
const close = pairs[event.key]
const wrapped = `${event.key}${selected}${close}`
updateValueWithSelection(
`${before}${wrapped}${after}`,
start + 1,
start + 1 + selected.length
)
return
}
if (event.key === 'Backspace' && start === end && start > 0) {
const previousChar = value[start - 1]
const nextChar = value[start]
if (pairs[previousChar] === nextChar) {
event.preventDefault()
updateValueWithSelection(
value.slice(0, start - 1) + value.slice(start + 1),
start - 1
)
}
}
}
return (
<div
className={cn(
'border-input bg-background focus-within:border-ring focus-within:ring-ring/50 overflow-hidden rounded-lg border transition-colors focus-within:ring-3',
className
)}
{...rootProps}
>
<div className='bg-muted/30 flex h-8 items-center justify-between border-b px-2'>
<div className='text-muted-foreground flex min-w-0 items-center gap-1.5 text-xs font-medium'>
<Braces className='h-3.5 w-3.5' />
<span>{t('JSON')}</span>
</div>
<div className='flex items-center gap-2'>
<span
className={cn(
'flex items-center gap-1 text-xs',
jsonStatus.valid ? 'text-emerald-600' : 'text-destructive'
)}
>
{jsonStatus.valid ? (
<CheckCircle2 className='h-3.5 w-3.5' />
) : (
<AlertCircle className='h-3.5 w-3.5' />
)}
{jsonStatus.message}
</span>
<Button
type='button'
variant='ghost'
size='sm'
className='h-6 px-2 text-xs'
onClick={formatJson}
disabled={disabled || !jsonStatus.valid || !value.trim()}
>
<Code2 className='mr-1 h-3.5 w-3.5' />
{t('Format JSON')}
</Button>
</div>
</div>
<div className={cn('relative flex overflow-hidden', heightClassName)}>
<div className='bg-muted/20 text-muted-foreground/70 relative w-10 shrink-0 overflow-hidden border-r font-mono text-xs leading-5 select-none'>
<div
className='px-2 py-2 text-right'
style={{ transform: `translateY(-${scrollTop}px)` }}
>
{lineNumbers.map((lineNumber) => (
<div key={lineNumber}>{lineNumber}</div>
))}
</div>
</div>
<Textarea
ref={textareaRef}
id={id}
aria-describedby={ariaDescribedBy}
aria-invalid={ariaInvalid}
value={value}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
onKeyDown={handleEditorKeyDown}
onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}
className={cn(
'[field-sizing:fixed] resize-none overflow-auto rounded-none border-0 bg-transparent px-3 py-2 font-mono text-xs leading-5 shadow-none ring-0 outline-none focus-visible:ring-0',
heightClassName
)}
spellCheck={false}
/>
</div>
</div>
)
}
+99 -1
View File
@@ -31,7 +31,99 @@ import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { Label } from '@/components/ui/label'
const Form = FormProvider
type FormRootContextValue = {
id: string
}
const FormRootContext = React.createContext<FormRootContextValue | null>(null)
function getFormScopedSelector(formId: string, selector: string): string {
return `[data-form-root="${formId}"]${selector}`
}
function hasFormErrors(errors: unknown): boolean {
return (
typeof errors === 'object' &&
errors !== null &&
Object.keys(errors).length > 0
)
}
function getFirstFormErrorTarget(
invalidControl: HTMLElement | null,
errorMessage: HTMLElement | null
): HTMLElement | null {
if (!invalidControl) return errorMessage
if (!errorMessage) return invalidControl
const position = invalidControl.compareDocumentPosition(errorMessage)
return position & Node.DOCUMENT_POSITION_PRECEDING
? errorMessage
: invalidControl
}
function FormValidationFocus() {
const formContext = React.useContext(FormRootContext)
const { control } = useFormContext()
const { errors, submitCount } = useFormState({ control })
const handledSubmitCountRef = React.useRef(0)
React.useEffect(() => {
if (!formContext || submitCount === 0 || !hasFormErrors(errors)) return
if (handledSubmitCountRef.current === submitCount) return
handledSubmitCountRef.current = submitCount
const animationFrameId = window.requestAnimationFrame(() => {
const invalidControl = document.querySelector<HTMLElement>(
getFormScopedSelector(formContext.id, '[aria-invalid="true"]')
)
const errorMessage = document.querySelector<HTMLElement>(
getFormScopedSelector(formContext.id, '[data-slot="form-message"]')
)
const target = getFirstFormErrorTarget(invalidControl, errorMessage)
if (!target) return
const formItem = target.closest<HTMLElement>(
getFormScopedSelector(formContext.id, '[data-slot="form-item"]')
)
const scrollTarget = formItem ?? target
const focusTarget =
target === invalidControl
? invalidControl
: (formItem?.querySelector<HTMLElement>(
'[aria-invalid="true"], input, textarea, select, button, [tabindex]:not([tabindex="-1"])'
) ?? null)
scrollTarget.scrollIntoView({ block: 'center', behavior: 'smooth' })
focusTarget?.focus({ preventScroll: true })
})
return () => window.cancelAnimationFrame(animationFrameId)
}, [errors, formContext, submitCount])
return null
}
function Form<TFieldValues extends FieldValues = FieldValues>({
children,
...props
}: React.ComponentProps<typeof FormProvider<TFieldValues>>) {
const reactId = React.useId()
const id = React.useMemo(
() => `form-${reactId.replaceAll(/[^a-zA-Z0-9_-]/g, '_')}`,
[reactId]
)
return (
<FormRootContext.Provider value={{ id }}>
<FormProvider {...props}>
<FormValidationFocus />
{children}
</FormProvider>
</FormRootContext.Provider>
)
}
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
@@ -90,11 +182,13 @@ const FormItemContext = React.createContext<FormItemContextValue>(
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
const id = React.useId()
const formContext = React.useContext(FormRootContext)
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot='form-item'
data-form-root={formContext?.id}
className={cn('grid gap-2', className)}
{...props}
/>
@@ -124,11 +218,13 @@ function FormControl({
...props
}: { children: React.ReactElement } & Record<string, unknown>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
const formContext = React.useContext(FormRootContext)
return useRender({
render: children,
props: {
'data-slot': 'form-control',
'data-form-root': formContext?.id,
id: formItemId,
'aria-describedby': !error
? `${formDescriptionId}`
@@ -154,6 +250,7 @@ function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
const { error, formMessageId } = useFormField()
const formContext = React.useContext(FormRootContext)
const { t } = useTranslation()
const body = error ? String(error?.message ?? '') : props.children
@@ -166,6 +263,7 @@ function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot='form-message'
data-form-root={formContext?.id}
id={formMessageId}
className={cn('text-destructive text-sm', className)}
{...props}
@@ -24,7 +24,7 @@ import {
useCallback,
useRef,
} from 'react'
import { useForm } from 'react-hook-form'
import { type SubmitErrorHandler, useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
@@ -140,6 +140,7 @@ import {
hasModelConfigChanged,
findMissingModelsInMapping,
validateModelMappingJson,
hasAdvancedSettingsErrors,
} from '../../lib'
import {
collectInvalidStatusCodeEntries,
@@ -204,7 +205,6 @@ function readAdvancedSettingsPreference(): boolean {
function hasAdvancedSettingsValues(values: ChannelFormValues): boolean {
return Boolean(
values.model_mapping?.trim() ||
values.param_override?.trim() ||
values.header_override?.trim() ||
values.status_code_mapping?.trim() ||
@@ -1008,6 +1008,26 @@ export function ChannelMutateDrawer({
]
)
const handleAdvancedSettingsOpenChange = useCallback((nextOpen: boolean) => {
setAdvancedSettingsOpen(nextOpen)
if (typeof window !== 'undefined') {
window.localStorage.setItem(
ADVANCED_SETTINGS_EXPANDED_KEY,
String(nextOpen)
)
}
}, [])
const onInvalid: SubmitErrorHandler<ChannelFormValues> = useCallback(
(errors) => {
if (hasAdvancedSettingsErrors(errors)) {
handleAdvancedSettingsOpenChange(true)
}
toast.error(t('Please fix the highlighted fields before saving'))
},
[handleAdvancedSettingsOpenChange, t]
)
// Handle drawer close
const handleOpenChange = useCallback(
(v: boolean) => {
@@ -1020,16 +1040,6 @@ export function ChannelMutateDrawer({
[onOpenChange, form]
)
const handleAdvancedSettingsOpenChange = useCallback((nextOpen: boolean) => {
setAdvancedSettingsOpen(nextOpen)
if (typeof window !== 'undefined') {
window.localStorage.setItem(
ADVANCED_SETTINGS_EXPANDED_KEY,
String(nextOpen)
)
}
}, [])
return (
<>
<Sheet open={open} onOpenChange={handleOpenChange}>
@@ -1060,7 +1070,7 @@ export function ChannelMutateDrawer({
<Form {...form}>
<form
id='channel-form'
onSubmit={form.handleSubmit(onSubmit)}
onSubmit={form.handleSubmit(onSubmit, onInvalid)}
className={sideDrawerFormClassName('gap-5')}
>
{isChannelDetailLoading ? (
@@ -0,0 +1,66 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { FieldPath } from 'react-hook-form'
import type { ChannelFormValues } from './channel-form'
type ChannelFormErrorMap = Partial<
Record<FieldPath<ChannelFormValues>, unknown>
>
const ADVANCED_SETTINGS_FIELDS = new Set<FieldPath<ChannelFormValues>>([
'priority',
'weight',
'test_model',
'auto_ban',
'tag',
'remark',
'param_override',
'header_override',
'status_code_mapping',
'force_format',
'thinking_to_content',
'pass_through_body_enabled',
'proxy',
'system_prompt',
'system_prompt_override',
'allow_service_tier',
'disable_store',
'allow_safety_identifier',
'allow_include_obfuscation',
'allow_inference_geo',
'allow_speed',
'claude_beta_query',
'upstream_model_update_check_enabled',
'upstream_model_update_auto_sync_enabled',
'upstream_model_update_ignored_models',
])
export function isAdvancedSettingsField(
fieldName: string
): fieldName is FieldPath<ChannelFormValues> {
return ADVANCED_SETTINGS_FIELDS.has(fieldName as FieldPath<ChannelFormValues>)
}
export function hasAdvancedSettingsErrors(
errors: ChannelFormErrorMap
): boolean {
return Object.keys(errors).some((fieldName) =>
isAdvancedSettingsField(fieldName)
)
}
+1
View File
@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
// Re-export all library functions
export * from './channel-actions'
export * from './channel-form-errors'
export * from './channel-form'
export * from './channel-type-config'
export * from './channel-utils'
@@ -189,6 +189,7 @@ export function CCSwitchDialog(props: Props) {
onValueChange={setName}
placeholder={currentConfig.defaultName}
emptyText=''
allowCustomValue={true}
/>
</div>
+4 -3
View File
@@ -56,8 +56,9 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
const tags = parseTags(props.model.tags)
const groups = props.model.enable_groups || []
const endpoints = props.model.supported_endpoint_types || []
const vendorIcon = props.model.vendor_icon
? getLobeIcon(props.model.vendor_icon, 28)
const modelIconKey = props.model.icon || props.model.vendor_icon
const modelIcon = modelIconKey
? getLobeIcon(modelIconKey, 28)
: null
const initial = props.model.model_name?.charAt(0).toUpperCase() || '?'
const isDynamicPricing =
@@ -97,7 +98,7 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
<div className='flex items-start justify-between gap-2.5 sm:gap-3'>
<div className='flex min-w-0 items-start gap-2.5 sm:gap-3'>
<div className='bg-muted/40 flex size-9 shrink-0 items-center justify-center rounded-lg sm:size-10 sm:rounded-xl'>
{vendorIcon || (
{modelIcon || (
<span className='text-muted-foreground text-sm font-bold'>
{initial}
</span>
@@ -268,8 +268,9 @@ function OverviewSummaryGrid(props: { model: PricingModel }) {
function ModelHeader(props: { model: PricingModel }) {
const { t } = useTranslation()
const model = props.model
const vendorIcon = model.vendor_icon
? getLobeIcon(model.vendor_icon, 20)
const modelIconKey = model.icon || model.vendor_icon
const modelIcon = modelIconKey
? getLobeIcon(modelIconKey, 20)
: null
const description = model.description || model.vendor_description || null
const tags = parseTags(model.tags)
@@ -281,7 +282,7 @@ function ModelHeader(props: { model: PricingModel }) {
return (
<header className='pb-4'>
<div className='flex items-center gap-2.5'>
{vendorIcon}
{modelIcon}
<h1 className='font-mono text-xl font-bold tracking-tight sm:text-2xl'>
{model.model_name}
</h1>
@@ -106,13 +106,14 @@ export function usePricingColumns(
),
cell: ({ row }) => {
const model = row.original
const vendorIcon = model.vendor_icon
? getLobeIcon(model.vendor_icon, 14)
const modelIconKey = model.icon || model.vendor_icon
const modelIcon = modelIconKey
? getLobeIcon(modelIconKey, 14)
: null
return (
<div className='flex min-w-[200px] items-center gap-2'>
{vendorIcon}
{modelIcon}
<span className='truncate font-mono text-sm font-medium'>
{model.model_name}
</span>
+1
View File
@@ -31,6 +31,7 @@ export type PricingModel = {
id: number
model_name: string
description?: string
icon?: string
vendor_id?: number
vendor_name?: string
vendor_icon?: string
@@ -111,6 +111,7 @@ export function SubscriptionPurchaseDialog(props: Props) {
Math.ceil(Number(plan.price_amount || 0) * quotaPerUnit)
)
const userQuota = Math.max(0, Number(props.userQuota || 0))
const allowBalancePay = plan.allow_balance_pay !== false
const insufficientBalance = userQuota < balanceCost
const limitReached =
(props.purchaseLimit || 0) > 0 &&
@@ -232,6 +233,10 @@ export function SubscriptionPurchaseDialog(props: Props) {
}
const handlePayBalance = async () => {
if (!allowBalancePay) {
toast.error(t('This plan does not allow balance redemption'))
return
}
setPaying(true)
try {
const res = await paySubscriptionBalance({ plan_id: plan.id })
@@ -332,15 +337,27 @@ export function SubscriptionPurchaseDialog(props: Props) {
<span className='text-muted-foreground'>{t('Available')}</span>
<span>{formatQuota(userQuota)}</span>
</div>
{insufficientBalance && (
{!allowBalancePay ? (
<Alert variant='destructive'>
<AlertDescription>{t('Insufficient balance')}</AlertDescription>
<AlertDescription>
{t('This plan does not allow balance redemption')}
</AlertDescription>
</Alert>
) : (
insufficientBalance && (
<Alert variant='destructive'>
<AlertDescription>
{t('Insufficient balance')}
</AlertDescription>
</Alert>
)
)}
<Button
variant='outline'
onClick={handlePayBalance}
disabled={paying || limitReached || insufficientBalance}
disabled={
paying || limitReached || !allowBalancePay || insufficientBalance
}
>
{t('Pay with Balance')}
</Button>
@@ -461,6 +461,24 @@ export function SubscriptionsMutateDrawer({
</FormItem>
)}
/>
<FormField
control={form.control}
name='allow_balance_pay'
render={({ field }) => (
<FormItem className={sideDrawerSwitchItemClassName()}>
<FormLabel className='!mt-0'>
{t('Allow balance redemption')}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</SideDrawerSection>
@@ -39,6 +39,7 @@ export function getPlanFormSchema(t: TFunction) {
quota_reset_custom_seconds: z.coerce.number().min(0).optional(),
enabled: z.boolean(),
sort_order: z.coerce.number(),
allow_balance_pay: z.boolean(),
max_purchase_per_user: z.coerce.number().min(0),
total_amount: z.coerce.number().min(0),
upgrade_group: z.string().optional(),
@@ -61,6 +62,7 @@ export const PLAN_FORM_DEFAULTS: PlanFormValues = {
quota_reset_custom_seconds: 0,
enabled: true,
sort_order: 0,
allow_balance_pay: true,
max_purchase_per_user: 0,
total_amount: 0,
upgrade_group: '',
@@ -81,6 +83,7 @@ export function planToFormValues(plan: SubscriptionPlan): PlanFormValues {
quota_reset_custom_seconds: Number(plan.quota_reset_custom_seconds || 0),
enabled: plan.enabled !== false,
sort_order: Number(plan.sort_order || 0),
allow_balance_pay: plan.allow_balance_pay !== false,
max_purchase_per_user: Number(plan.max_purchase_per_user || 0),
total_amount: quotaUnitsToDollars(Number(plan.total_amount || 0)),
upgrade_group: plan.upgrade_group || '',
+1
View File
@@ -35,6 +35,7 @@ export const subscriptionPlanSchema = z.object({
quota_reset_custom_seconds: z.number().optional(),
enabled: z.boolean(),
sort_order: z.number(),
allow_balance_pay: z.boolean().optional().default(true),
max_purchase_per_user: z.number(),
total_amount: z.number(),
upgrade_group: z.string().optional(),
@@ -70,7 +70,7 @@ function SettingsPageFrame(props: SettingsPageFrameProps) {
<span className='truncate'>{props.title}</span>
<span
ref={setTitleStatusContainer}
className='inline-flex shrink-0'
className='inline-flex min-w-0 shrink-0 items-center'
/>
</span>
</SectionPageLayout.Title>
@@ -0,0 +1,296 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import * as z from 'zod'
import { combineBillingExpr } from '@/features/pricing/lib/billing-expr'
import { formatPricingNumber } from './pricing-format'
export const createModelPricingSchema = (t: (key: string) => string) =>
z.object({
name: z.string().min(1, t('Model name is required')),
price: z.string().optional(),
ratio: z.string().optional(),
cacheRatio: z.string().optional(),
createCacheRatio: z.string().optional(),
completionRatio: z.string().optional(),
imageRatio: z.string().optional(),
audioRatio: z.string().optional(),
audioCompletionRatio: z.string().optional(),
})
export type ModelPricingFormValues = z.infer<
ReturnType<typeof createModelPricingSchema>
>
export type PricingMode = 'per-token' | 'per-request' | 'tiered_expr'
export type LaneKey =
| 'completion'
| 'cache'
| 'createCache'
| 'image'
| 'audioInput'
| 'audioOutput'
export type ModelRatioData = {
name: string
price?: string
ratio?: string
cacheRatio?: string
createCacheRatio?: string
completionRatio?: string
imageRatio?: string
audioRatio?: string
audioCompletionRatio?: string
billingMode?: PricingMode
billingExpr?: string
requestRuleExpr?: string
}
export type PreviewRow = {
key: string
label: string
value: string
multiline?: boolean
}
export const numericDraftRegex = /^(\d+(\.\d*)?|\.\d*)?$/
export const EMPTY_LANE_PRICES: Record<LaneKey, string> = {
completion: '',
cache: '',
createCache: '',
image: '',
audioInput: '',
audioOutput: '',
}
export const EMPTY_LANE_ENABLED: Record<LaneKey, boolean> = {
completion: false,
cache: false,
createCache: false,
image: false,
audioInput: false,
audioOutput: false,
}
export const ratioFieldByLane: Record<LaneKey, keyof ModelPricingFormValues> = {
completion: 'completionRatio',
cache: 'cacheRatio',
createCache: 'createCacheRatio',
image: 'imageRatio',
audioInput: 'audioRatio',
audioOutput: 'audioCompletionRatio',
}
export const laneConfigs: Array<{
key: LaneKey
titleKey: string
descriptionKey: string
placeholder: string
}> = [
{
key: 'completion',
titleKey: 'Completion price',
descriptionKey: 'Output token price for generated tokens.',
placeholder: '15',
},
{
key: 'cache',
titleKey: 'Cache read price',
descriptionKey: 'Token price for cache reads.',
placeholder: '0.3',
},
{
key: 'createCache',
titleKey: 'Cache write price',
descriptionKey: 'Token price for creating cache entries.',
placeholder: '3.75',
},
{
key: 'image',
titleKey: 'Image input price',
descriptionKey: 'Token price for image input.',
placeholder: '2.5',
},
{
key: 'audioInput',
titleKey: 'Audio input price',
descriptionKey: 'Token price for audio input.',
placeholder: '3.81',
},
{
key: 'audioOutput',
titleKey: 'Audio output price',
descriptionKey: 'Token price for audio output.',
placeholder: '15.11',
},
]
export function hasValue(value: unknown): boolean {
return (
value !== '' && value !== null && value !== undefined && value !== false
)
}
export function toNumberOrNull(value: unknown): number | null {
if (!hasValue(value) && value !== 0) return null
const num = Number(value)
return Number.isFinite(num) ? num : null
}
function ratioToBasePrice(ratio: unknown): string {
const num = toNumberOrNull(ratio)
if (num === null) return ''
return formatPricingNumber(num * 2)
}
function deriveLanePrice(
ratio: unknown,
denominator: unknown,
fallback = ''
): string {
const ratioNumber = toNumberOrNull(ratio)
const denominatorNumber = toNumberOrNull(denominator)
if (ratioNumber === null || denominatorNumber === null) return fallback
return formatPricingNumber(ratioNumber * denominatorNumber)
}
export function createInitialLaneState(data?: ModelRatioData | null) {
if (!data) {
return {
promptPrice: '',
prices: { ...EMPTY_LANE_PRICES },
enabled: { ...EMPTY_LANE_ENABLED },
}
}
const promptPrice = ratioToBasePrice(data.ratio)
const audioInputPrice = deriveLanePrice(data.audioRatio, promptPrice)
const prices: Record<LaneKey, string> = {
completion: deriveLanePrice(data.completionRatio, promptPrice),
cache: deriveLanePrice(data.cacheRatio, promptPrice),
createCache: deriveLanePrice(data.createCacheRatio, promptPrice),
image: deriveLanePrice(data.imageRatio, promptPrice),
audioInput: audioInputPrice,
audioOutput: deriveLanePrice(data.audioCompletionRatio, audioInputPrice),
}
return {
promptPrice,
prices,
enabled: {
completion: hasValue(data.completionRatio),
cache: hasValue(data.cacheRatio),
createCache: hasValue(data.createCacheRatio),
image: hasValue(data.imageRatio),
audioInput: hasValue(data.audioRatio),
audioOutput: hasValue(data.audioCompletionRatio),
},
}
}
export function buildPreviewRows(
values: ModelPricingFormValues,
mode: PricingMode,
billingExpr: string,
requestRuleExpr: string,
promptPrice: string,
lanePrices: Record<LaneKey, string>,
laneEnabled: Record<LaneKey, boolean>,
t: (key: string) => string
): PreviewRow[] {
if (mode === 'tiered_expr') {
const effectiveExpr = combineBillingExpr(billingExpr, requestRuleExpr)
return [
{ key: 'mode', label: 'BillingMode', value: 'tiered_expr' },
{
key: 'expr',
label: t('Expression'),
value: effectiveExpr || t('Empty'),
multiline: true,
},
]
}
if (mode === 'per-request') {
return [
{
key: 'price',
label: 'ModelPrice',
value: values.price || t('Empty'),
},
]
}
return [
{
key: 'inputPrice',
label: t('Input price'),
value: promptPrice ? `$${promptPrice}` : t('Empty'),
},
{
key: 'completion',
label: t('Completion price'),
value:
laneEnabled.completion && lanePrices.completion
? `$${lanePrices.completion}`
: t('Empty'),
},
{
key: 'cache',
label: t('Cache read price'),
value:
laneEnabled.cache && lanePrices.cache
? `$${lanePrices.cache}`
: t('Empty'),
},
{
key: 'createCache',
label: t('Cache write price'),
value:
laneEnabled.createCache && lanePrices.createCache
? `$${lanePrices.createCache}`
: t('Empty'),
},
{
key: 'image',
label: t('Image input price'),
value:
laneEnabled.image && lanePrices.image
? `$${lanePrices.image}`
: t('Empty'),
},
{
key: 'audio',
label: t('Audio input price'),
value:
laneEnabled.audioInput && lanePrices.audioInput
? `$${lanePrices.audioInput}`
: t('Empty'),
},
{
key: 'audioCompletion',
label: t('Audio output price'),
value:
laneEnabled.audioOutput && lanePrices.audioOutput
? `$${lanePrices.audioOutput}`
: t('Empty'),
},
]
}
@@ -0,0 +1,91 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from '@/components/ui/input-group'
import {
SettingsControlGroup,
SettingsSwitchField,
} from '../components/settings-form-layout'
export function PriceInput(props: {
value: string
placeholder?: string
disabled?: boolean
onChange: (value: string) => void
}) {
return (
<InputGroup>
<InputGroupAddon>$</InputGroupAddon>
<InputGroupInput
inputMode='decimal'
value={props.value}
placeholder={props.placeholder}
disabled={props.disabled}
onChange={(event) => props.onChange(event.target.value)}
/>
<InputGroupAddon align='inline-end'>$/1M</InputGroupAddon>
</InputGroup>
)
}
export function PriceLane(props: {
title: string
description: string
placeholder: string
value: string
enabled: boolean
disabled?: boolean
onEnabledChange: (checked: boolean) => void
onChange: (value: string) => void
}) {
const { t } = useTranslation()
const effectiveDisabled = props.disabled || !props.enabled
return (
<SettingsControlGroup
className={cn('space-y-3', effectiveDisabled && 'opacity-75')}
data-disabled={effectiveDisabled || undefined}
>
<SettingsSwitchField
checked={props.enabled}
disabled={props.disabled}
onCheckedChange={props.onEnabledChange}
label={props.title}
description={props.description}
aria-label={props.title}
/>
<PriceInput
value={props.value}
placeholder={props.placeholder}
disabled={effectiveDisabled}
onChange={props.onChange}
/>
<p className='text-muted-foreground text-xs'>
{props.enabled
? t('USD price per 1M tokens.')
: t('Disabled lanes are omitted on save.')}
</p>
</SettingsControlGroup>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,296 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { splitBillingExprAndRequestRules } from '@/features/pricing/lib/billing-expr'
import { safeJsonParse } from '../utils/json-parser'
import { formatPricingNumber } from './pricing-format'
export type ModelPricingSnapshotInput = {
modelPrice: string
modelRatio: string
cacheRatio: string
createCacheRatio: string
completionRatio: string
imageRatio: string
audioRatio: string
audioCompletionRatio: string
billingMode: string
billingExpr: string
}
export type ModelPricingSnapshot = {
name: string
price?: string
ratio?: string
cacheRatio?: string
createCacheRatio?: string
completionRatio?: string
imageRatio?: string
audioRatio?: string
audioCompletionRatio?: string
billingMode?: string
billingExpr?: string
requestRuleExpr?: string
hasConflict: boolean
}
export type ModelRow = ModelPricingSnapshot & {
saved?: ModelPricingSnapshot
draft?: ModelPricingSnapshot
isDraftChanged: boolean
isDraftDeleted: boolean
isDraftNew: boolean
}
export const hasPricingValue = (value?: string) =>
value !== undefined && value !== ''
const toNumberOrNull = (value?: string) => {
if (!hasPricingValue(value)) return null
const num = Number(value)
return Number.isFinite(num) ? num : null
}
const ratioToPrice = (ratio?: string, denominator?: string) => {
const ratioNumber = toNumberOrNull(ratio)
const denominatorNumber = denominator ? toNumberOrNull(denominator) : 2
if (ratioNumber === null || denominatorNumber === null) return ''
return formatPricingNumber(ratioNumber * denominatorNumber)
}
export const getModeLabel = (mode?: string) => {
if (mode === 'per-request') return 'Per-request'
if (mode === 'tiered_expr') return 'Expression'
return 'Per-token'
}
export const getModeVariant = (
mode?: string
): 'warning' | 'info' | 'success' => {
if (mode === 'per-request') return 'warning'
if (mode === 'tiered_expr') return 'info'
return 'success'
}
const getExpressionSummary = (
row: ModelPricingSnapshot,
t: (key: string) => string
) => {
const tierCount = (row.billingExpr?.match(/tier\(/g) || []).length
if (tierCount > 0) {
return `${t('Tiered pricing')} · ${tierCount} ${t('tiers')}`
}
return t('Expression pricing')
}
export const getPriceSummary = (
row: ModelPricingSnapshot,
t: (key: string) => string
) => {
if (row.billingMode === 'tiered_expr') {
return getExpressionSummary(row, t)
}
if (row.billingMode === 'per-request') {
return row.price ? `$${row.price} / ${t('request')}` : t('Unset price')
}
const inputPrice = ratioToPrice(row.ratio)
if (!inputPrice) return t('Unset price')
const extraCount = [
row.completionRatio,
row.cacheRatio,
row.createCacheRatio,
row.imageRatio,
row.audioRatio,
row.audioCompletionRatio,
].filter(hasPricingValue).length
return extraCount > 0
? `${t('Input')} $${inputPrice} · ${extraCount} ${t('extras')}`
: `${t('Input')} $${inputPrice}`
}
export const getPriceDetail = (
row: ModelPricingSnapshot,
t: (key: string) => string
) => {
if (row.billingMode === 'tiered_expr') {
return row.requestRuleExpr
? t('Includes request rules')
: t('Expression based')
}
if (row.billingMode === 'per-request') {
return t('Fixed request price')
}
const inputPrice = ratioToPrice(row.ratio)
if (!inputPrice) return t('No base input price')
const details = [
row.completionRatio &&
`${t('Output')} $${ratioToPrice(row.completionRatio, inputPrice)}`,
row.cacheRatio &&
`${t('Cache')} $${ratioToPrice(row.cacheRatio, inputPrice)}`,
row.createCacheRatio &&
`${t('Cache write')} $${ratioToPrice(row.createCacheRatio, inputPrice)}`,
]
.filter(Boolean)
.slice(0, 2)
return details.length > 0 ? details.join(' · ') : t('Base input price only')
}
export const buildModelSnapshots = ({
modelPrice,
modelRatio,
cacheRatio,
createCacheRatio,
completionRatio,
imageRatio,
audioRatio,
audioCompletionRatio,
billingMode,
billingExpr,
}: ModelPricingSnapshotInput): ModelPricingSnapshot[] => {
const priceMap = safeJsonParse<Record<string, number>>(modelPrice, {
fallback: {},
context: 'model prices',
})
const ratioMap = safeJsonParse<Record<string, number>>(modelRatio, {
fallback: {},
context: 'model ratios',
})
const cacheMap = safeJsonParse<Record<string, number>>(cacheRatio, {
fallback: {},
context: 'cache ratios',
})
const createCacheMap = safeJsonParse<Record<string, number>>(
createCacheRatio,
{ fallback: {}, context: 'create cache ratios' }
)
const completionMap = safeJsonParse<Record<string, number>>(completionRatio, {
fallback: {},
context: 'completion ratios',
})
const imageMap = safeJsonParse<Record<string, number>>(imageRatio, {
fallback: {},
context: 'image ratios',
})
const audioMap = safeJsonParse<Record<string, number>>(audioRatio, {
fallback: {},
context: 'audio ratios',
})
const audioCompletionMap = safeJsonParse<Record<string, number>>(
audioCompletionRatio,
{ fallback: {}, context: 'audio completion ratios' }
)
const billingModeMap = safeJsonParse<Record<string, string>>(billingMode, {
fallback: {},
context: 'billing mode',
})
const billingExprMap = safeJsonParse<Record<string, string>>(billingExpr, {
fallback: {},
context: 'billing expression',
})
const modelNames = new Set([
...Object.keys(priceMap),
...Object.keys(ratioMap),
...Object.keys(cacheMap),
...Object.keys(createCacheMap),
...Object.keys(completionMap),
...Object.keys(imageMap),
...Object.keys(audioMap),
...Object.keys(audioCompletionMap),
...Object.keys(billingModeMap),
...Object.keys(billingExprMap),
])
return Array.from(modelNames).map((name) => {
const price = priceMap[name]?.toString() || ''
const ratio = ratioMap[name]?.toString() || ''
const cache = cacheMap[name]?.toString() || ''
const createCache = createCacheMap[name]?.toString() || ''
const completion = completionMap[name]?.toString() || ''
const image = imageMap[name]?.toString() || ''
const audio = audioMap[name]?.toString() || ''
const audioCompletion = audioCompletionMap[name]?.toString() || ''
const modeForModel = billingModeMap[name]
if (modeForModel === 'tiered_expr') {
const fullExpr = billingExprMap[name] || ''
const { billingExpr: pureExpr, requestRuleExpr } =
splitBillingExprAndRequestRules(fullExpr)
return {
name,
billingMode: 'tiered_expr',
billingExpr: pureExpr,
requestRuleExpr,
price,
ratio,
cacheRatio: cache,
createCacheRatio: createCache,
completionRatio: completion,
imageRatio: image,
audioRatio: audio,
audioCompletionRatio: audioCompletion,
hasConflict: false,
}
}
return {
name,
price,
ratio,
cacheRatio: cache,
createCacheRatio: createCache,
completionRatio: completion,
imageRatio: image,
audioRatio: audio,
audioCompletionRatio: audioCompletion,
billingMode: price !== '' ? 'per-request' : 'per-token',
hasConflict:
price !== '' &&
(ratio !== '' ||
completion !== '' ||
cache !== '' ||
createCache !== '' ||
image !== '' ||
audio !== '' ||
audioCompletion !== ''),
}
})
}
export const getSnapshotSignature = (snapshot?: ModelPricingSnapshot) => {
if (!snapshot) return ''
return JSON.stringify({
price: snapshot.price || '',
ratio: snapshot.ratio || '',
cacheRatio: snapshot.cacheRatio || '',
createCacheRatio: snapshot.createCacheRatio || '',
completionRatio: snapshot.completionRatio || '',
imageRatio: snapshot.imageRatio || '',
audioRatio: snapshot.audioRatio || '',
audioCompletionRatio: snapshot.audioCompletionRatio || '',
billingMode: snapshot.billingMode || 'per-token',
billingExpr: snapshot.billingExpr || '',
requestRuleExpr: snapshot.requestRuleExpr || '',
})
}
@@ -16,9 +16,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { memo, useCallback, useState } from 'react'
import { memo, useCallback, useRef, useState } from 'react'
import { type UseFormReturn } from 'react-hook-form'
import { Code2, Eye } from 'lucide-react'
import { Code2, Eye, RotateCcw, Save } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
@@ -31,14 +31,16 @@ import {
FormMessage,
} from '@/components/ui/form'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { JsonCodeEditor } from '@/components/json-code-editor'
import {
SettingsForm,
SettingsSwitchContent,
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageActionsPortal } from '../components/settings-page-context'
import { ModelRatioVisualEditor } from './model-ratio-visual-editor'
import {
ModelRatioVisualEditor,
type ModelRatioVisualEditorHandle,
} from './model-ratio-visual-editor'
type ModelFormValues = {
ModelPrice: string
@@ -56,14 +58,106 @@ type ModelFormValues = {
type ModelRatioFormProps = {
form: UseFormReturn<ModelFormValues>
savedValues: ModelFormValues
onSave: (values: ModelFormValues) => Promise<void>
onReset: () => void
isSaving: boolean
isResetting: boolean
}
type ModelJsonFieldName =
| 'ModelPrice'
| 'ModelRatio'
| 'CacheRatio'
| 'CreateCacheRatio'
| 'CompletionRatio'
| 'ImageRatio'
| 'AudioRatio'
| 'AudioCompletionRatio'
const modelJsonFields: Array<{
name: ModelJsonFieldName
labelKey: string
descriptionKey: string
}> = [
{
name: 'ModelPrice',
labelKey: 'Model fixed pricing',
descriptionKey:
'JSON map of model → USD cost per request. Takes precedence over ratio based billing.',
},
{
name: 'ModelRatio',
labelKey: 'Model ratio',
descriptionKey: 'JSON map of model → multiplier applied to quota billing.',
},
{
name: 'CacheRatio',
labelKey: 'Prompt cache ratio',
descriptionKey: 'Optional ratio used when upstream cache hits occur.',
},
{
name: 'CreateCacheRatio',
labelKey: 'Create cache ratio',
descriptionKey:
'Ratio applied when creating cache entries for supported models.',
},
{
name: 'CompletionRatio',
labelKey: 'Completion ratio',
descriptionKey:
'Applies to custom completion endpoints. JSON map of model → ratio.',
},
{
name: 'ImageRatio',
labelKey: 'Image ratio',
descriptionKey: 'Configure per-model ratio for image inputs or outputs.',
},
{
name: 'AudioRatio',
labelKey: 'Audio ratio',
descriptionKey:
'Ratio applied to audio inputs where supported by the upstream model.',
},
{
name: 'AudioCompletionRatio',
labelKey: 'Audio completion ratio',
descriptionKey: 'Ratio applied to audio completions for streaming models.',
},
]
function ModelJsonTextareaField(props: {
form: UseFormReturn<ModelFormValues>
name: ModelJsonFieldName
label: string
description: string
}) {
return (
<FormField
control={props.form.control}
name={props.name}
render={({ field }) => (
<FormItem className='flex min-w-0 flex-col gap-2'>
<FormLabel>{props.label}</FormLabel>
<FormControl>
<JsonCodeEditor
value={field.value}
onChange={(value) => field.onChange(value)}
/>
</FormControl>
<FormDescription className='text-xs leading-5'>
{props.description}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)
}
export const ModelRatioForm = memo(function ModelRatioForm({
form,
savedValues,
onSave,
onReset,
isSaving,
@@ -71,6 +165,7 @@ export const ModelRatioForm = memo(function ModelRatioForm({
}: ModelRatioFormProps) {
const { t } = useTranslation()
const [editMode, setEditMode] = useState<'visual' | 'json'>('visual')
const visualEditorRef = useRef<ModelRatioVisualEditorHandle>(null)
const handleFieldChange = useCallback(
(field: keyof ModelFormValues, value: string) => {
@@ -86,9 +181,39 @@ export const ModelRatioForm = memo(function ModelRatioForm({
setEditMode((prev) => (prev === 'visual' ? 'json' : 'visual'))
}, [])
const handleSave = useCallback(async () => {
if (editMode === 'visual') {
const committed = await visualEditorRef.current?.commitOpenEditor()
if (committed === false) return
}
await form.handleSubmit(onSave)()
}, [editMode, form, onSave])
return (
<div className='space-y-6'>
<div className='flex justify-end'>
<div className='flex flex-wrap justify-end gap-2'>
<Button
type='button'
variant='destructive'
size='sm'
onClick={onReset}
disabled={isResetting}
>
<RotateCcw data-icon='inline-start' />
{t('Reset prices')}
</Button>
{editMode === 'json' && (
<Button
type='button'
size='sm'
onClick={handleSave}
disabled={isSaving}
>
<Save data-icon='inline-start' />
{isSaving ? t('Saving...') : t('Save model prices')}
</Button>
)}
<Button variant='outline' size='sm' onClick={toggleEditMode}>
{editMode === 'visual' ? (
<>
@@ -105,28 +230,20 @@ export const ModelRatioForm = memo(function ModelRatioForm({
</div>
<Form {...form}>
<SettingsPageActionsPortal>
<Button
type='button'
variant='destructive'
size='sm'
onClick={onReset}
disabled={isResetting}
>
{t('Reset prices')}
</Button>
<Button
type='button'
size='sm'
onClick={form.handleSubmit(onSave)}
disabled={isSaving}
>
{isSaving ? t('Saving...') : t('Save model prices')}
</Button>
</SettingsPageActionsPortal>
{editMode === 'visual' ? (
<div className='space-y-6'>
<ModelRatioVisualEditor
ref={visualEditorRef}
savedModelPrice={savedValues.ModelPrice}
savedModelRatio={savedValues.ModelRatio}
savedCacheRatio={savedValues.CacheRatio}
savedCreateCacheRatio={savedValues.CreateCacheRatio}
savedCompletionRatio={savedValues.CompletionRatio}
savedImageRatio={savedValues.ImageRatio}
savedAudioRatio={savedValues.AudioRatio}
savedAudioCompletionRatio={savedValues.AudioCompletionRatio}
savedBillingMode={savedValues.BillingMode}
savedBillingExpr={savedValues.BillingExpr}
modelPrice={form.watch('ModelPrice')}
modelRatio={form.watch('ModelRatio')}
cacheRatio={form.watch('CacheRatio')}
@@ -137,6 +254,8 @@ export const ModelRatioForm = memo(function ModelRatioForm({
audioCompletionRatio={form.watch('AudioCompletionRatio')}
billingMode={form.watch('BillingMode')}
billingExpr={form.watch('BillingExpr')}
onSave={handleSave}
isSaving={isSaving}
onChange={(field, value) => {
const fieldMap: Record<string, keyof ModelFormValues> = {
'billing_setting.billing_mode': 'BillingMode',
@@ -173,155 +292,17 @@ export const ModelRatioForm = memo(function ModelRatioForm({
</div>
) : (
<SettingsForm onSubmit={form.handleSubmit(onSave)}>
<FormField
control={form.control}
name='ModelPrice'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Model fixed pricing')}</FormLabel>
<FormControl>
<Textarea rows={8} {...field} />
</FormControl>
<FormDescription>
{t(
'JSON map of model → USD cost per request. Takes precedence over ratio based billing.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='ModelRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Model ratio')}</FormLabel>
<FormControl>
<Textarea rows={8} {...field} />
</FormControl>
<FormDescription>
{t(
'JSON map of model → multiplier applied to quota billing.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='CacheRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Prompt cache ratio')}</FormLabel>
<FormControl>
<Textarea rows={8} {...field} />
</FormControl>
<FormDescription>
{t('Optional ratio used when upstream cache hits occur.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='CreateCacheRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Create cache ratio')}</FormLabel>
<FormControl>
<Textarea rows={8} {...field} />
</FormControl>
<FormDescription>
{t(
'Ratio applied when creating cache entries for supported models.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='CompletionRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Completion ratio')}</FormLabel>
<FormControl>
<Textarea rows={8} {...field} />
</FormControl>
<FormDescription>
{t(
'Applies to custom completion endpoints. JSON map of model → ratio.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='ImageRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Image ratio')}</FormLabel>
<FormControl>
<Textarea rows={6} {...field} />
</FormControl>
<FormDescription>
{t(
'Configure per-model ratio for image inputs or outputs.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='AudioRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Audio ratio')}</FormLabel>
<FormControl>
<Textarea rows={6} {...field} />
</FormControl>
<FormDescription>
{t(
'Ratio applied to audio inputs where supported by the upstream model.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='AudioCompletionRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Audio completion ratio')}</FormLabel>
<FormControl>
<Textarea rows={6} {...field} />
</FormControl>
<FormDescription>
{t(
'Ratio applied to audio completions for streaming models.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className='grid min-w-0 gap-x-5 gap-y-8 lg:grid-cols-2 2xl:grid-cols-3'>
{modelJsonFields.map((config) => (
<ModelJsonTextareaField
key={config.name}
form={form}
name={config.name}
label={t(config.labelKey)}
description={t(config.descriptionKey)}
/>
))}
</div>
<FormField
control={form.control}
@@ -0,0 +1,161 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { type ColumnDef } from '@tanstack/react-table'
import { Pencil, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { DataTableColumnHeader } from '@/components/data-table'
import { StatusBadge } from '@/components/status-badge'
import {
getModeLabel,
getModeVariant,
getPriceDetail,
getPriceSummary,
type ModelRow,
} from './model-pricing-snapshots'
const filterBySelectedValues = (
rowValue: unknown,
filterValue: unknown
): boolean => {
if (!Array.isArray(filterValue) || filterValue.length === 0) return true
return filterValue.includes(String(rowValue))
}
type BuildModelRatioColumnsOptions = {
onDelete: (name: string) => void
onEdit: (model: ModelRow) => void
t: (key: string) => string
}
export function buildModelRatioColumns({
onDelete,
onEdit,
t,
}: BuildModelRatioColumnsOptions): ColumnDef<ModelRow>[] {
return [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label={t('Select all')}
className='translate-y-[2px]'
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label={t('Select row')}
className='translate-y-[2px]'
/>
),
enableSorting: false,
enableHiding: false,
meta: { label: t('Select') },
},
{
accessorKey: 'name',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Model name')} />
),
cell: ({ row }) => (
<div className='flex items-center gap-2 font-medium'>
{row.getValue('name')}
{row.original.billingMode === 'tiered_expr' && (
<StatusBadge label={t('Tiered')} variant='info' copyable={false} />
)}
{row.original.hasConflict && (
<StatusBadge
label={t('Conflict')}
variant='danger'
copyable={false}
/>
)}
</div>
),
enableHiding: false,
},
{
accessorKey: 'billingMode',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Mode')} />
),
cell: ({ row }) => (
<StatusBadge
label={t(getModeLabel(row.original.billingMode))}
variant={getModeVariant(row.original.billingMode)}
copyable={false}
showDot={false}
className='px-0'
/>
),
filterFn: (row, id, value) =>
filterBySelectedValues(row.getValue(id), value),
meta: { label: t('Mode') },
},
{
id: 'priceSummary',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Price summary')} />
),
cell: ({ row }) => (
<div className='flex min-w-[180px] flex-col gap-1'>
<span className='font-medium'>
{getPriceSummary(row.original, t)}
</span>
<span className='text-muted-foreground max-w-[320px] truncate text-xs'>
{getPriceDetail(row.original, t)}
</span>
</div>
),
sortingFn: (rowA, rowB) =>
getPriceSummary(rowA.original, t).localeCompare(
getPriceSummary(rowB.original, t)
),
meta: { label: t('Price summary') },
},
{
id: 'actions',
header: () => <div className='text-right'>{t('Actions')}</div>,
cell: ({ row }) => (
<div className='flex justify-end gap-2'>
<Button
variant='ghost'
size='sm'
onClick={() => onEdit(row.original)}
>
<Pencil />
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => onDelete(row.original.name)}
>
<Trash2 />
</Button>
</div>
),
enableHiding: false,
},
]
}
File diff suppressed because it is too large Load Diff
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
@@ -26,6 +26,7 @@ import { toast } from 'sonner'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { resetModelRatios } from '../api'
import { SettingsPageTitleStatusPortal } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
import { GroupRatioForm } from './group-ratio-form'
@@ -34,169 +35,99 @@ import { ToolPriceSettings } from './tool-price-settings'
import { UpstreamRatioSync } from './upstream-ratio-sync'
import {
formatJsonForTextarea,
type JsonValidationError,
normalizeJsonString,
validateJsonString,
} from './utils'
const modelSchema = z.object({
ModelPrice: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
ModelRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
CacheRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
CreateCacheRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
CompletionRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
ImageRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
AudioRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
AudioCompletionRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
ExposeRatioEnabled: z.boolean(),
BillingMode: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
BillingExpr: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
})
type Translate = (key: string, options?: Record<string, unknown>) => string
const groupSchema = z.object({
GroupRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
function formatJsonValidationError(
t: Translate,
error?: JsonValidationError,
fallback = 'Invalid JSON'
) {
if (!error) return t(fallback)
if (error.type === 'required') return t('Value is required')
if (error.type === 'structure') {
return t(
fallback === 'Invalid JSON' ? 'JSON structure is invalid' : fallback
)
}
const parts = [
error.line && error.column
? t('JSON is invalid at line {{line}}, column {{column}}.', {
line: error.line,
column: error.column,
})
: error.position !== undefined
? t('JSON is invalid at position {{position}}.', {
position: error.position,
})
: t('JSON is invalid. Please check the syntax.'),
]
if (error.missingCommaLine) {
parts.push(
t('Check line {{line}} for a missing comma.', {
line: error.missingCommaLine,
})
)
}
return parts.join(' ')
}
function createJsonStringField(
t: Translate,
options?: Parameters<typeof validateJsonString>[1]
) {
return z.string().superRefine((value, ctx) => {
const result = validateJsonString(value, options)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
message: formatJsonValidationError(t, result.error, result.message),
})
}
}),
TopupGroupRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
UserUsableGroups: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
GroupGroupRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
AutoGroups: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value, {
})
}
const createModelSchema = (t: Translate) =>
z.object({
ModelPrice: createJsonStringField(t),
ModelRatio: createJsonStringField(t),
CacheRatio: createJsonStringField(t),
CreateCacheRatio: createJsonStringField(t),
CompletionRatio: createJsonStringField(t),
ImageRatio: createJsonStringField(t),
AudioRatio: createJsonStringField(t),
AudioCompletionRatio: createJsonStringField(t),
ExposeRatioEnabled: z.boolean(),
BillingMode: createJsonStringField(t),
BillingExpr: createJsonStringField(t),
})
const createGroupSchema = (t: Translate) =>
z.object({
GroupRatio: createJsonStringField(t),
TopupGroupRatio: createJsonStringField(t),
UserUsableGroups: createJsonStringField(t),
GroupGroupRatio: createJsonStringField(t),
AutoGroups: createJsonStringField(t, {
predicate: (parsed) =>
Array.isArray(parsed) &&
parsed.every((item) => typeof item === 'string'),
predicateMessage: 'Expected a JSON array of group identifiers',
})
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON array',
})
}
}),
DefaultUseAutoGroup: z.boolean(),
GroupSpecialUsableGroup: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
})
}),
DefaultUseAutoGroup: z.boolean(),
GroupSpecialUsableGroup: createJsonStringField(t),
})
type ModelFormValues = z.infer<typeof modelSchema>
type GroupFormValues = z.infer<typeof groupSchema>
type ModelFormValues = z.infer<ReturnType<typeof createModelSchema>>
type GroupFormValues = z.infer<ReturnType<typeof createGroupSchema>>
type RatioTabId = 'models' | 'groups' | 'tool-prices' | 'upstream-sync'
type RatioSettingsCardProps = {
@@ -250,6 +181,9 @@ export function RatioSettingsCard({
BillingMode: normalizeJsonString(modelDefaults.BillingMode),
BillingExpr: normalizeJsonString(modelDefaults.BillingExpr),
})
const [savedModelValues, setSavedModelValues] = useState(
modelNormalizedDefaults.current
)
const groupNormalizedDefaults = useRef({
GroupRatio: normalizeJsonString(groupDefaults.GroupRatio),
@@ -262,6 +196,8 @@ export function RatioSettingsCard({
groupDefaults.GroupSpecialUsableGroup
),
})
const modelSchema = useMemo(() => createModelSchema(t), [t])
const groupSchema = useMemo(() => createGroupSchema(t), [t])
const modelForm = useForm<ModelFormValues>({
resolver: zodResolver(modelSchema),
@@ -315,6 +251,7 @@ export function RatioSettingsCard({
BillingMode: normalizeJsonString(modelDefaults.BillingMode),
BillingExpr: normalizeJsonString(modelDefaults.BillingExpr),
}
setSavedModelValues(modelNormalizedDefaults.current)
modelForm.reset({
...modelDefaults,
@@ -395,6 +332,9 @@ export function RatioSettingsCard({
const apiKey = apiKeyMap[key as string] || (key as string)
await updateOption.mutateAsync({ key: apiKey, value: normalized[key] })
}
modelNormalizedDefaults.current = normalized
setSavedModelValues(normalized)
},
[t, updateOption]
)
@@ -462,6 +402,7 @@ export function RatioSettingsCard({
return (
<ModelRatioForm
form={modelForm}
savedValues={savedModelValues}
onSave={saveModelRatios}
onReset={handleResetRatios}
isSaving={updateOption.isPending}
@@ -499,25 +440,35 @@ export function RatioSettingsCard({
)
}
const renderTabSwitcher = () => (
<TabsList className={`grid w-fit max-w-full ${tabsGridClass}`}>
{visibleTabs.map((tab) => (
<TabsTrigger key={tab} value={tab}>
{t(tabLabels[tab])}
</TabsTrigger>
))}
</TabsList>
)
return (
<SettingsSection title={t(titleKey)}>
<>
{visibleTabs.length === 1 ? (
renderTabContent(defaultTab)
<SettingsSection title={t(titleKey)}>
{renderTabContent(defaultTab)}
</SettingsSection>
) : (
<Tabs defaultValue={defaultTab} className='space-y-6'>
<TabsList className={`grid w-full ${tabsGridClass}`}>
{visibleTabs.map((tab) => (
<TabsTrigger key={tab} value={tab}>
{t(tabLabels[tab])}
</TabsTrigger>
))}
</TabsList>
<SettingsPageTitleStatusPortal>
{renderTabSwitcher()}
</SettingsPageTitleStatusPortal>
{visibleTabs.map((tab) => (
<TabsContent key={tab} value={tab}>
{renderTabContent(tab)}
</TabsContent>
))}
<SettingsSection title={t(titleKey)}>
{visibleTabs.map((tab) => (
<TabsContent key={tab} value={tab}>
{renderTabContent(tab)}
</TabsContent>
))}
</SettingsSection>
</Tabs>
)}
@@ -533,6 +484,6 @@ export function RatioSettingsCard({
handleConfirm={handleConfirmReset}
confirmText={t('Reset')}
/>
</SettingsSection>
</>
)
}
@@ -40,6 +40,7 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Field, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
@@ -1309,9 +1310,7 @@ function PresetSection({ applyPreset }: PresetSectionProps) {
return (
<div className='space-y-2'>
<div className='flex items-center gap-2'>
<span className='text-muted-foreground text-xs'>
{t('Preset templates')}
</span>
<span className='text-sm font-medium'>{t('Preset templates')}</span>
{hasMore && (
<Button
variant='ghost'
@@ -1770,35 +1769,37 @@ export const TieredPricingEditor = memo(function TieredPricingEditor({
}, [])
return (
<div className='space-y-4'>
<div className='flex items-center justify-between gap-2'>
<Label className='text-xs'>{t('Editor mode')}</Label>
<Select
items={[
{ value: 'visual', label: t('Visual editor') },
{ value: 'raw', label: t('Expression editor') },
]}
value={editorMode}
onValueChange={(value) => handleModeChange(value as EditorMode)}
>
<SelectTrigger className='w-44' size='sm'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value='visual'>{t('Visual editor')}</SelectItem>
<SelectItem value='raw'>{t('Expression editor')}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<div className='space-y-5'>
<div className='grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-end'>
<Field className='gap-2'>
<FieldLabel>{t('Editor mode')}</FieldLabel>
<Select
items={[
{ value: 'visual', label: t('Visual editor') },
{ value: 'raw', label: t('Expression editor') },
]}
value={editorMode}
onValueChange={(value) => handleModeChange(value as EditorMode)}
>
<SelectTrigger className='w-full sm:w-56' size='sm'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value='visual'>{t('Visual editor')}</SelectItem>
<SelectItem value='raw'>{t('Expression editor')}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Field>
{editorMode === 'raw' && (
<div className='sm:pb-0.5'>
<LlmPromptHelper modelName={modelName} />
</div>
)}
</div>
<div className='flex flex-wrap items-start gap-x-4 gap-y-1'>
<div className='flex-1'>
<PresetSection applyPreset={applyPreset} />
</div>
{editorMode === 'raw' && <LlmPromptHelper modelName={modelName} />}
</div>
<PresetSection applyPreset={applyPreset} />
<div className='bg-muted/30 space-y-3 rounded-md border p-3'>
{editorMode === 'visual' ? (
+47 -4
View File
@@ -49,6 +49,14 @@ type JsonValidationOptions = {
predicateMessage?: string
}
export type JsonValidationError = {
type: 'required' | 'structure' | 'syntax'
line?: number
column?: number
position?: number
missingCommaLine?: number
}
function extractErrorPosition(
error: unknown,
jsonString: string
@@ -81,8 +89,15 @@ function extractErrorPosition(
return {}
}
function formatErrorMessage(error: unknown, jsonString: string): string {
if (!(error instanceof Error)) return 'Invalid JSON'
function buildSyntaxError(
error: unknown,
jsonString: string
): JsonValidationError {
if (!(error instanceof Error)) {
return {
type: 'syntax',
} satisfies JsonValidationError
}
const position = extractErrorPosition(error, jsonString)
const message = error.message
@@ -93,10 +108,29 @@ function formatErrorMessage(error: unknown, jsonString: string): string {
message.includes('Expected property name') ||
message.includes('Unexpected string')
const missingCommaLine =
isMissingCommaError && position.line && position.line > 1
? position.line - 1
: undefined
return {
type: 'syntax',
...position,
missingCommaLine,
} satisfies JsonValidationError
}
function formatErrorMessage(error: unknown, jsonString: string): string {
if (!(error instanceof Error)) return 'Invalid JSON'
const position = extractErrorPosition(error, jsonString)
const message = error.message
const syntaxError = buildSyntaxError(error, jsonString)
if (position.line && position.column) {
let hint = ''
if (isMissingCommaError && position.line > 1) {
hint = ` (check line ${position.line - 1} for missing comma)`
if (syntaxError.missingCommaLine) {
hint = ` (check line ${syntaxError.missingCommaLine} for missing comma)`
}
return `Error at line ${position.line}, column ${position.column}: ${message}${hint}`
}
@@ -119,6 +153,11 @@ export function validateJsonString(
return {
valid: allowEmpty,
message: allowEmpty ? undefined : 'Value is required',
error: allowEmpty
? undefined
: ({
type: 'required',
} satisfies JsonValidationError),
}
}
@@ -128,6 +167,9 @@ export function validateJsonString(
return {
valid: false,
message: predicateMessage || 'JSON structure is invalid',
error: {
type: 'structure',
} satisfies JsonValidationError,
}
}
@@ -136,6 +178,7 @@ export function validateJsonString(
return {
valid: false,
message: formatErrorMessage(error, trimmed),
error: buildSyntaxError(error, trimmed),
}
}
}
+11 -2
View File
@@ -268,6 +268,7 @@
"All-time": "All-time",
"Allocated Memory": "Allocated Memory",
"Allow accountFilter parameter": "Allow accountFilter parameter",
"Allow balance redemption": "Allow balance redemption",
"Allow Claude beta query passthrough": "Allow Claude beta query passthrough",
"Allow clients to query configured ratios via `/api/ratio`.": "Allow clients to query configured ratios via `/api/ratio`.",
"Allow HTTP image requests": "Allow HTTP image requests",
@@ -678,6 +679,7 @@
"Check for updates": "Check for updates",
"Check in daily to receive random quota rewards": "Check in daily to receive random quota rewards",
"Check in now": "Check in now",
"Check line {{line}} for a missing comma.": "Check line {{line}} for a missing comma.",
"Check out the Quick Start": "Check out the Quick Start",
"Check resolved IPs against IP filters even when accessing by domain": "Check resolved IPs against IP filters even when accessing by domain",
"Check-in failed": "Check-in failed",
@@ -1526,6 +1528,7 @@
"Expand": "Expand",
"Expand All": "Expand All",
"Expected a JSON array.": "Expected a JSON array.",
"Expected a JSON array of group identifiers": "Expected a JSON array of group identifiers",
"Experiment with prompts and models in real time.": "Experiment with prompts and models in real time.",
"Expiration Time": "Expiration Time",
"expired": "expired",
@@ -2092,6 +2095,9 @@
"JSON Editor": "JSON Editor",
"JSON format error": "JSON format error",
"JSON format supports service account JSON files": "JSON format supports service account JSON files",
"JSON is invalid at line {{line}}, column {{column}}.": "JSON is invalid at line {{line}}, column {{column}}.",
"JSON is invalid at position {{position}}.": "JSON is invalid at position {{position}}.",
"JSON is invalid. Please check the syntax.": "JSON is invalid. Please check the syntax.",
"JSON map of group → description exposed when users create API keys.": "JSON map of group → description exposed when users create API keys.",
"JSON map of group → ratio applied when the user selects the group explicitly.": "JSON map of group → ratio applied when the user selects the group explicitly.",
"JSON map of model → multiplier applied to quota billing.": "JSON map of model → multiplier applied to quota billing.",
@@ -2100,6 +2106,7 @@
"JSON Mode": "JSON Mode",
"JSON must be an object": "JSON must be an object",
"JSON object:": "JSON object:",
"JSON structure is invalid": "JSON structure is invalid",
"JSON Text": "JSON Text",
"JSON-based access control rules. Leave empty to allow all users.": "JSON-based access control rules. Leave empty to allow all users.",
"Just now": "Just now",
@@ -2853,8 +2860,8 @@
"Path Regex (one per line)": "Path Regex (one per line)",
"Path:": "Path:",
"Pay": "Pay",
"Pay-as-you-go with real-time usage monitoring": "Pay-as-you-go with real-time usage monitoring",
"Pay with Balance": "Pay with Balance",
"Pay-as-you-go with real-time usage monitoring": "Pay-as-you-go with real-time usage monitoring",
"Payment": "Payment",
"Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.": "Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.",
"Payment Channel": "Payment Channel",
@@ -3767,10 +3774,10 @@
"Subscription First": "Subscription First",
"Subscription Management": "Subscription Management",
"Subscription Only": "Subscription Only",
"Subscription purchased successfully": "Subscription purchased successfully",
"Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.": "Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.",
"Subscription Plans": "Subscription Plans",
"Subscription plans do NOT use the bound Product — each plan has its own dedicated Pancake product, set in the Subscriptions admin (or auto-minted via the \"+ Create\" button there).": "Subscription plans do NOT use the bound Product — each plan has its own dedicated Pancake product, set in the Subscriptions admin (or auto-minted via the \"+ Create\" button there).",
"Subscription purchased successfully": "Subscription purchased successfully",
"Subtract": "Subtract",
"Success": "Success",
"Success rate": "Success rate",
@@ -3951,6 +3958,7 @@
"This model is not available in any group, or no group pricing information is configured.": "This model is not available in any group, or no group pricing information is configured.",
"This month": "This month",
"This page has not been created yet.": "This page has not been created yet.",
"This plan does not allow balance redemption": "This plan does not allow balance redemption",
"This project must be used in compliance with the": "This project must be used in compliance with the",
"This record was written by a pre-upgrade instance and lacks audit info. Upgrade the instance to record server IP, callback IP, payment method and system version.": "This record was written by a pre-upgrade instance and lacks audit info. Upgrade the instance to record server IP, callback IP, payment method and system version.",
"This site currently has {{count}} models enabled": "This site currently has {{count}} models enabled",
@@ -4311,6 +4319,7 @@
"Validity Period": "Validity Period",
"Value": "Value",
"Value (supports JSON or plain text)": "Value (supports JSON or plain text)",
"Value is required": "Value is required",
"Value must be at least 0": "Value must be at least 0",
"Value Regex": "Value Regex",
"variable": "variable",
+11 -2
View File
@@ -268,6 +268,7 @@
"All-time": "Tous temps",
"Allocated Memory": "Mémoire allouée",
"Allow accountFilter parameter": "Autoriser le paramètre accountFilter",
"Allow balance redemption": "Autoriser le paiement avec le solde",
"Allow Claude beta query passthrough": "Autoriser le passage des requêtes bêta Claude",
"Allow clients to query configured ratios via `/api/ratio`.": "Autoriser les clients à interroger les ratios configurés via `/api/ratio`.",
"Allow HTTP image requests": "Autoriser les requêtes d'images HTTP",
@@ -678,6 +679,7 @@
"Check for updates": "Vérifier les mises à jour",
"Check in daily to receive random quota rewards": "Connectez-vous quotidiennement pour recevoir des récompenses de quota aléatoires",
"Check in now": "Se connecter maintenant",
"Check line {{line}} for a missing comma.": "Vérifiez la ligne {{line}} pour une virgule manquante.",
"Check out the Quick Start": "Consultez le démarrage rapide",
"Check resolved IPs against IP filters even when accessing by domain": "Vérifier les adresses IP résolues par rapport aux filtres IP même lors de l'accès par domaine",
"Check-in failed": "Échec de la connexion",
@@ -1526,6 +1528,7 @@
"Expand": "Développer",
"Expand All": "Tout développer",
"Expected a JSON array.": "Un tableau JSON est attendu.",
"Expected a JSON array of group identifiers": "Un tableau JSON d'identifiants de groupe est attendu",
"Experiment with prompts and models in real time.": "Expérimentez avec des prompts et des modèles en temps réel.",
"Expiration Time": "Heure d'expiration",
"expired": "expiré",
@@ -2092,6 +2095,9 @@
"JSON Editor": "Édition JSON",
"JSON format error": "Erreur de format JSON",
"JSON format supports service account JSON files": "Le format JSON prend en charge les fichiers JSON de compte de service",
"JSON is invalid at line {{line}}, column {{column}}.": "Le JSON est invalide à la ligne {{line}}, colonne {{column}}.",
"JSON is invalid at position {{position}}.": "Le JSON est invalide à la position {{position}}.",
"JSON is invalid. Please check the syntax.": "Le JSON est invalide. Veuillez vérifier la syntaxe.",
"JSON map of group → description exposed when users create API keys.": "Carte JSON de groupe → description exposée lorsque les utilisateurs créent des clés API.",
"JSON map of group → ratio applied when the user selects the group explicitly.": "Carte JSON de groupe → ratio appliqué lorsque l'utilisateur sélectionne explicitement le groupe.",
"JSON map of model → multiplier applied to quota billing.": "Carte JSON de modèle → multiplicateur appliqué à la facturation par quota.",
@@ -2100,6 +2106,7 @@
"JSON Mode": "Mode JSON",
"JSON must be an object": "Le JSON doit être un objet",
"JSON object:": "Objet JSON :",
"JSON structure is invalid": "La structure JSON est invalide",
"JSON Text": "Texte JSON",
"JSON-based access control rules. Leave empty to allow all users.": "Règles de contrôle d'accès basées sur JSON. Laisser vide pour autoriser tous les utilisateurs.",
"Just now": "À l'instant",
@@ -2853,8 +2860,8 @@
"Path Regex (one per line)": "Regex du chemin (un par ligne)",
"Path:": "Chemin :",
"Pay": "Pay",
"Pay-as-you-go with real-time usage monitoring": "Paiement à l'usage avec suivi de la consommation en temps réel",
"Pay with Balance": "Payer avec le solde",
"Pay-as-you-go with real-time usage monitoring": "Paiement à l'usage avec suivi de la consommation en temps réel",
"Payment": "Paiement",
"Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.": "Mode agrégateur de paiement — embarquez avec votre propre société enregistrée (entité offshore). Conçu pour les entreprises.",
"Payment Channel": "Canal de paiement",
@@ -3767,10 +3774,10 @@
"Subscription First": "Abonnement en priorité",
"Subscription Management": "Gestion des abonnements",
"Subscription Only": "Abonnement uniquement",
"Subscription purchased successfully": "Abonnement acheté avec succès",
"Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.": "La création et la modification des forfaits dabonnement sont verrouillées jusqu’à ce que ladministrateur confirme les conditions de conformité dans les paramètres de la passerelle de paiement.",
"Subscription Plans": "Plans d'abonnement",
"Subscription plans do NOT use the bound Product — each plan has its own dedicated Pancake product, set in the Subscriptions admin (or auto-minted via the \"+ Create\" button there).": "Les forfaits dabonnement nutilisent PAS le produit associé : chaque forfait dispose de son propre produit Pancake dédié, défini dans ladministration des abonnements (ou créé automatiquement via le bouton « + Créer »).",
"Subscription purchased successfully": "Abonnement acheté avec succès",
"Subtract": "Soustraire",
"Success": "Succès",
"Success rate": "Taux de réussite",
@@ -3951,6 +3958,7 @@
"This model is not available in any group, or no group pricing information is configured.": "Ce modèle n'est disponible dans aucun groupe, ou aucune information de tarification de groupe n'est configurée.",
"This month": "Ce mois-ci",
"This page has not been created yet.": "Cette page n'a pas encore été créée.",
"This plan does not allow balance redemption": "Ce forfait ne permet pas le paiement avec le solde",
"This project must be used in compliance with the": "Ce projet doit être utilisé conformément aux",
"This record was written by a pre-upgrade instance and lacks audit info. Upgrade the instance to record server IP, callback IP, payment method and system version.": "Cet enregistrement provient dune instance avant la mise à niveau et ninclut pas daudits. Mettez à jour linstance pour enregistrer lIP du serveur, lIP de callback, le moyen de paiement et la version du système.",
"This site currently has {{count}} models enabled": "Ce site compte actuellement {{count}} modèles activés",
@@ -4311,6 +4319,7 @@
"Validity Period": "Période de validité",
"Value": "Valeur",
"Value (supports JSON or plain text)": "Valeur (JSON ou texte brut)",
"Value is required": "La valeur est obligatoire",
"Value must be at least 0": "La valeur doit être au moins 0",
"Value Regex": "Regex de valeur",
"variable": "variable",
+11 -2
View File
@@ -268,6 +268,7 @@
"All-time": "全期間",
"Allocated Memory": "割り当て済みメモリ",
"Allow accountFilter parameter": "accountFilter パラメータを許可",
"Allow balance redemption": "残高での交換を許可",
"Allow Claude beta query passthrough": "Claude ベータクエリのパススルーを許可",
"Allow clients to query configured ratios via `/api/ratio`.": "クライアントが `/api/ratio` 経由で設定された比率を照会できるようにします。",
"Allow HTTP image requests": "HTTP画像リクエストを許可",
@@ -678,6 +679,7 @@
"Check for updates": "更新を確認",
"Check in daily to receive random quota rewards": "毎日チェックインして、ランダムなノルマ報酬を受け取りましょう",
"Check in now": "今すぐチェックイン",
"Check line {{line}} for a missing comma.": "{{line}} 行目にカンマの抜けがないか確認してください。",
"Check out the Quick Start": "クイックスタートをご確認ください",
"Check resolved IPs against IP filters even when accessing by domain": "ドメインによるアクセスであっても、解決されたIPをIPフィルターと照合してチェックします",
"Check-in failed": "チェックインできませんでした",
@@ -1526,6 +1528,7 @@
"Expand": "展開",
"Expand All": "すべて展開",
"Expected a JSON array.": "JSON 配列が必要です。",
"Expected a JSON array of group identifiers": "グループ識別子の JSON 配列が必要です",
"Experiment with prompts and models in real time.": "プロンプトとモデルをリアルタイムで実験する。",
"Expiration Time": "有効期限",
"expired": "期限切れ",
@@ -2092,6 +2095,9 @@
"JSON Editor": "JSON編集",
"JSON format error": "JSONフォーマットエラー",
"JSON format supports service account JSON files": "JSON形式はサービスアカウントJSONファイルをサポートします",
"JSON is invalid at line {{line}}, column {{column}}.": "JSON は {{line}} 行目、{{column}} 列目で無効です。",
"JSON is invalid at position {{position}}.": "JSON は位置 {{position}} で無効です。",
"JSON is invalid. Please check the syntax.": "JSON が無効です。構文を確認してください。",
"JSON map of group → description exposed when users create API keys.": "ユーザーがAPIキーを作成する際に公開される、グループ → 説明のJSONマップ。",
"JSON map of group → ratio applied when the user selects the group explicitly.": "ユーザーがグループを明示的に選択したときに適用される、グループ → 比率のJSONマップ。",
"JSON map of model → multiplier applied to quota billing.": "モデル → クォータ請求に適用される乗数のJSONマップ。",
@@ -2100,6 +2106,7 @@
"JSON Mode": "JSONモード",
"JSON must be an object": "JSON はオブジェクトである必要があります",
"JSON object:": "JSONオブジェクト:",
"JSON structure is invalid": "JSON 構造が無効です",
"JSON Text": "JSONテキスト",
"JSON-based access control rules. Leave empty to allow all users.": "JSONベースのアクセス制御ルール。すべてのユーザーを許可する場合は空のままにしてください。",
"Just now": "たった今",
@@ -2853,8 +2860,8 @@
"Path Regex (one per line)": "パス正規表現(1行に1つ)",
"Path:": "パス:",
"Pay": "Pay",
"Pay-as-you-go with real-time usage monitoring": "リアルタイム使用量監視付き従量課金制",
"Pay with Balance": "残高で支払う",
"Pay-as-you-go with real-time usage monitoring": "リアルタイム使用量監視付き従量課金制",
"Payment": "支払い",
"Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.": "決済アグリゲーターモード — 自社の登録済み法人(オフショア法人)でオンボーディングします。エンタープライズ向けです。",
"Payment Channel": "決済チャネル",
@@ -3767,10 +3774,10 @@
"Subscription First": "サブスクリプション優先",
"Subscription Management": "サブスクリプション管理",
"Subscription Only": "サブスクリプションのみ",
"Subscription purchased successfully": "サブスクリプションを購入しました",
"Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.": "管理者が支払いゲートウェイ設定でコンプライアンス条件を確認するまで、サブスクリプションプランの作成と変更はロックされます。",
"Subscription Plans": "サブスクリプションプラン",
"Subscription plans do NOT use the bound Product — each plan has its own dedicated Pancake product, set in the Subscriptions admin (or auto-minted via the \"+ Create\" button there).": "サブスクリプションプランは紐付け済み商品を使用しません。各プランには専用の Pancake 商品があり、サブスクリプション管理画面で設定します(または「+ 作成」ボタンで自動作成します)。",
"Subscription purchased successfully": "サブスクリプションを購入しました",
"Subtract": "減算",
"Success": "成功",
"Success rate": "成功率",
@@ -3951,6 +3958,7 @@
"This model is not available in any group, or no group pricing information is configured.": "このモデルはどのグループでも利用できないか、グループの料金情報が設定されていません。",
"This month": "今月",
"This page has not been created yet.": "このページはまだ作成されていません。",
"This plan does not allow balance redemption": "このプランでは残高での交換は許可されていません",
"This project must be used in compliance with the": "このプロジェクトは、以下を遵守して使用する必要があります",
"This record was written by a pre-upgrade instance and lacks audit info. Upgrade the instance to record server IP, callback IP, payment method and system version.": "古いバージョンのインスタンスがこの記録を書き込み、監査情報がありません。最新に更新し、サーバーIP・コールバックIP・支払方法・OSバージョンの記録を有効にしてください。",
"This site currently has {{count}} models enabled": "このサイトでは現在 {{count}} 個のモデルが有効です",
@@ -4311,6 +4319,7 @@
"Validity Period": "有効期間",
"Value": "値",
"Value (supports JSON or plain text)": "値(JSONまたはプレーンテキスト対応)",
"Value is required": "値は必須です",
"Value must be at least 0": "値は 0 以上である必要があります",
"Value Regex": "Value 正規表現",
"variable": "変数",
+11 -2
View File
@@ -268,6 +268,7 @@
"All-time": "За всё время",
"Allocated Memory": "Выделенная память",
"Allow accountFilter parameter": "Разрешить параметр accountFilter",
"Allow balance redemption": "Разрешить оплату балансом",
"Allow Claude beta query passthrough": "Разрешить проброс бета-запросов Claude",
"Allow clients to query configured ratios via `/api/ratio`.": "Разрешить клиентам запрашивать настроенные соотношения через `/api/ratio`.",
"Allow HTTP image requests": "Разрешить HTTP-запросы изображений",
@@ -678,6 +679,7 @@
"Check for updates": "Проверить обновления",
"Check in daily to receive random quota rewards": "Регистрируйтесь ежедневно, чтобы получать случайные вознаграждения по квоте",
"Check in now": "Войдите сейчас",
"Check line {{line}} for a missing comma.": "Проверьте строку {{line}} на пропущенную запятую.",
"Check out the Quick Start": "Ознакомьтесь с быстрым стартом",
"Check resolved IPs against IP filters even when accessing by domain": "Проверять разрешенные IP-адреса по IP-фильтрам даже при доступе по домену",
"Check-in failed": "Регистрация не удалась.",
@@ -1526,6 +1528,7 @@
"Expand": "Развернуть",
"Expand All": "Развернуть все",
"Expected a JSON array.": "Ожидается JSON-массив.",
"Expected a JSON array of group identifiers": "Ожидается JSON-массив идентификаторов групп",
"Experiment with prompts and models in real time.": "Экспериментируйте с промптами и моделями в реальном времени.",
"Expiration Time": "Время истечения срока действия",
"expired": "истек",
@@ -2092,6 +2095,9 @@
"JSON Editor": "Редактирование JSON",
"JSON format error": "Ошибка формата JSON",
"JSON format supports service account JSON files": "Формат JSON поддерживает JSON-файлы сервисного аккаунта",
"JSON is invalid at line {{line}}, column {{column}}.": "JSON недействителен в строке {{line}}, столбце {{column}}.",
"JSON is invalid at position {{position}}.": "JSON недействителен в позиции {{position}}.",
"JSON is invalid. Please check the syntax.": "JSON недействителен. Проверьте синтаксис.",
"JSON map of group → description exposed when users create API keys.": "JSON-карта группы → описание, отображаемое при создании пользователями ключей API.",
"JSON map of group → ratio applied when the user selects the group explicitly.": "JSON-карта группы → соотношение, применяемое, когда пользователь явно выбирает группу.",
"JSON map of model → multiplier applied to quota billing.": "JSON-карта модели → множитель, применяемый к тарификации по квоте.",
@@ -2100,6 +2106,7 @@
"JSON Mode": "Режим JSON",
"JSON must be an object": "JSON должен быть объектом",
"JSON object:": "Объект JSON:",
"JSON structure is invalid": "Структура JSON недействительна",
"JSON Text": "JSON текст",
"JSON-based access control rules. Leave empty to allow all users.": "Правила контроля доступа на основе JSON. Оставьте пустым, чтобы разрешить всем пользователям.",
"Just now": "Только что",
@@ -2853,8 +2860,8 @@
"Path Regex (one per line)": "Регулярное выражение пути (по одному на строку)",
"Path:": "Путь:",
"Pay": "Pay",
"Pay-as-you-go with real-time usage monitoring": "Оплата по мере использования с мониторингом в реальном времени",
"Pay with Balance": "Оплатить балансом",
"Pay-as-you-go with real-time usage monitoring": "Оплата по мере использования с мониторингом в реальном времени",
"Payment": "Платеж",
"Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.": "Режим платежного агрегатора — подключение через вашу зарегистрированную компанию (офшорное юрлицо). Создано для Enterprise.",
"Payment Channel": "Платёжный канал",
@@ -3767,10 +3774,10 @@
"Subscription First": "Подписка в приоритете",
"Subscription Management": "Управление подписками",
"Subscription Only": "Только подписка",
"Subscription purchased successfully": "Подписка успешно приобретена",
"Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.": "Создание и изменение планов подписки заблокированы, пока администратор не подтвердит условия соответствия в настройках платежного шлюза.",
"Subscription Plans": "Планы подписки",
"Subscription plans do NOT use the bound Product — each plan has its own dedicated Pancake product, set in the Subscriptions admin (or auto-minted via the \"+ Create\" button there).": "Планы подписки НЕ используют привязанный продукт — у каждого плана есть собственный продукт Pancake, задаваемый в администрировании подписок (или автоматически создаваемый кнопкой «+ Создать»).",
"Subscription purchased successfully": "Подписка успешно приобретена",
"Subtract": "Вычесть",
"Success": "Успешно",
"Success rate": "Доля успешных запросов",
@@ -3951,6 +3958,7 @@
"This model is not available in any group, or no group pricing information is configured.": "Эта модель недоступна ни в одной группе, или информация о ценах для групп не настроена.",
"This month": "В этом месяце",
"This page has not been created yet.": "Эта страница еще не создана.",
"This plan does not allow balance redemption": "Этот план не разрешает оплату балансом",
"This project must be used in compliance with the": "Этот проект должен использоваться в соответствии с",
"This record was written by a pre-upgrade instance and lacks audit info. Upgrade the instance to record server IP, callback IP, payment method and system version.": "Запись создана экземпляром до обновления и не содержит сведений аудита. Обновите экземпляр, чтобы фиксировать IP сервера, IP callback, способ оплаты и версию ОС.",
"This site currently has {{count}} models enabled": "На этом сайте сейчас включено моделей: {{count}}",
@@ -4311,6 +4319,7 @@
"Validity Period": "Срок действия",
"Value": "Значение",
"Value (supports JSON or plain text)": "Значение (JSON или текст)",
"Value is required": "Значение обязательно",
"Value must be at least 0": "Значение должно быть не менее 0",
"Value Regex": "Регулярное выражение значения",
"variable": "переменная",
+11 -2
View File
@@ -268,6 +268,7 @@
"All-time": "Mọi thời điểm",
"Allocated Memory": "Bộ nhớ đã cấp phát",
"Allow accountFilter parameter": "Cho phép tham số accountFilter",
"Allow balance redemption": "Cho phép thanh toán bằng số dư",
"Allow Claude beta query passthrough": "Cho phép chuyển tiếp truy vấn beta Claude",
"Allow clients to query configured ratios via `/api/ratio`.": "Cho phép khách hàng truy vấn các tỷ lệ đã cấu hình thông qua `/api/ratio`.",
"Allow HTTP image requests": "Cho phép yêu cầu hình ảnh HTTP",
@@ -678,6 +679,7 @@
"Check for updates": "Kiểm tra cập nhật",
"Check in daily to receive random quota rewards": "Nhận phòng hàng ngày để nhận phần thưởng theo hạn ngạch ngẫu nhiên",
"Check in now": "Điểm danh ngay",
"Check line {{line}} for a missing comma.": "Kiểm tra dòng {{line}} xem có thiếu dấu phẩy không.",
"Check out the Quick Start": "Xem hướng dẫn bắt đầu nhanh",
"Check resolved IPs against IP filters even when accessing by domain": "Kiểm tra các IP đã phân giải đối chiếu với các bộ lọc IP ngay cả khi truy cập bằng tên miền",
"Check-in failed": "Điểm danh thất bại",
@@ -1526,6 +1528,7 @@
"Expand": "Mở rộng",
"Expand All": "Mở rộng tất cả",
"Expected a JSON array.": "Cần là một mảng JSON.",
"Expected a JSON array of group identifiers": "Cần là một mảng JSON gồm các định danh nhóm",
"Experiment with prompts and models in real time.": "Thử nghiệm với prompt và mô hình theo thời gian thực.",
"Expiration Time": "Thời gian hết hạn",
"expired": "Đã hết hạn",
@@ -2092,6 +2095,9 @@
"JSON Editor": "Trình chỉnh sửa JSON",
"JSON format error": "Lỗi định dạng JSON",
"JSON format supports service account JSON files": "Định dạng JSON hỗ trợ các tệp JSON tài khoản dịch vụ",
"JSON is invalid at line {{line}}, column {{column}}.": "JSON không hợp lệ tại dòng {{line}}, cột {{column}}.",
"JSON is invalid at position {{position}}.": "JSON không hợp lệ tại vị trí {{position}}.",
"JSON is invalid. Please check the syntax.": "JSON không hợp lệ. Vui lòng kiểm tra cú pháp.",
"JSON map of group → description exposed when users create API keys.": "Ánh xạ JSON của nhóm → mô tả được hiển thị khi người dùng tạo khóa API.",
"JSON map of group → ratio applied when the user selects the group explicitly.": "Bản đồ JSON của nhóm → tỷ lệ được áp dụng khi người dùng chọn nhóm đó một cách rõ ràng.",
"JSON map of model → multiplier applied to quota billing.": "Bản đồ JSON của mô hình → hệ số nhân áp dụng cho thanh toán hạn mức.",
@@ -2100,6 +2106,7 @@
"JSON Mode": "Chế độ JSON",
"JSON must be an object": "JSON phải là object",
"JSON object:": "Đối tượng JSON:",
"JSON structure is invalid": "Cấu trúc JSON không hợp lệ",
"JSON Text": "Văn bản JSON",
"JSON-based access control rules. Leave empty to allow all users.": "Quy tắc kiểm soát truy cập dựa trên JSON. Để trống để cho phép tất cả người dùng.",
"Just now": "Vừa nãy",
@@ -2853,8 +2860,8 @@
"Path Regex (one per line)": "Regex đường dẫn (mỗi dòng một mục)",
"Path:": "Đường dẫn:",
"Pay": "Pay",
"Pay-as-you-go with real-time usage monitoring": "Thanh toán theo mức sử dụng với theo dõi mức sử dụng theo thời gian thực",
"Pay with Balance": "Thanh toán bằng số dư",
"Pay-as-you-go with real-time usage monitoring": "Thanh toán theo mức sử dụng với theo dõi mức sử dụng theo thời gian thực",
"Payment": "Thanh toán",
"Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.": "Chế độ tổng hợp thanh toán — đăng ký bằng công ty đã đăng ký của bạn (pháp nhân offshore). Dành cho doanh nghiệp.",
"Payment Channel": "Kênh thanh toán",
@@ -3767,10 +3774,10 @@
"Subscription First": "Ưu tiên đăng ký",
"Subscription Management": "Quản lý đăng ký",
"Subscription Only": "Chỉ đăng ký",
"Subscription purchased successfully": "Đã mua gói đăng ký thành công",
"Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.": "Việc tạo và thay đổi gói đăng ký bị khóa cho đến khi quản trị viên xác nhận điều khoản tuân thủ trong cài đặt Cổng thanh toán.",
"Subscription Plans": "Gói đăng ký",
"Subscription plans do NOT use the bound Product — each plan has its own dedicated Pancake product, set in the Subscriptions admin (or auto-minted via the \"+ Create\" button there).": "Gói đăng ký KHÔNG dùng Sản phẩm đã liên kết — mỗi gói có một sản phẩm Pancake riêng, được đặt trong quản trị Đăng ký (hoặc tự động tạo bằng nút \"+ Create\" tại đó).",
"Subscription purchased successfully": "Đã mua gói đăng ký thành công",
"Subtract": "Trừ",
"Success": "Thành công",
"Success rate": "Tỷ lệ thành công",
@@ -3951,6 +3958,7 @@
"This model is not available in any group, or no group pricing information is configured.": "Mô hình này không khả dụng trong bất kỳ nhóm nào, hoặc thông tin giá nhóm chưa được cấu hình.",
"This month": "Tháng này",
"This page has not been created yet.": "Trang này chưa được tạo.",
"This plan does not allow balance redemption": "Gói này không cho phép thanh toán bằng số dư",
"This project must be used in compliance with the": "Dự án này phải được sử dụng tuân thủ theo",
"This record was written by a pre-upgrade instance and lacks audit info. Upgrade the instance to record server IP, callback IP, payment method and system version.": "Bản ghi này do bản cũ tạo và thiếu thông tin audit. Nâng cấp bản cài để lưu IP máy chủ, IP callback, hình thức thanh toán và phiên bản hệ thống.",
"This site currently has {{count}} models enabled": "Trang này hiện đã bật {{count}} mô hình",
@@ -4311,6 +4319,7 @@
"Validity Period": "Thời hạn hiệu lực",
"Value": "Giá trị",
"Value (supports JSON or plain text)": "Giá trị (hỗ trợ JSON hoặc văn bản thuần)",
"Value is required": "Giá trị là bắt buộc",
"Value must be at least 0": "Giá trị phải ít nhất là 0",
"Value Regex": "Regex giá trị",
"variable": "biến",
+11 -2
View File
@@ -268,6 +268,7 @@
"All-time": "全部时间",
"Allocated Memory": "已分配内存",
"Allow accountFilter parameter": "允许 accountFilter 参数",
"Allow balance redemption": "允许余额兑换",
"Allow Claude beta query passthrough": "允许 Claude beta 查询透传",
"Allow clients to query configured ratios via `/api/ratio`.": "允许客户端通过 `/api/ratio` 查询配置的比例。",
"Allow HTTP image requests": "允许 HTTP 图像请求",
@@ -678,6 +679,7 @@
"Check for updates": "检查更新",
"Check in daily to receive random quota rewards": "每日签到可获得随机额度奖励",
"Check in now": "立即签到",
"Check line {{line}} for a missing comma.": "请检查第 {{line}} 行是否缺少逗号。",
"Check out the Quick Start": "请查看 新手入门",
"Check resolved IPs against IP filters even when accessing by domain": "即使通过域名访问,也对照 IP 过滤器检查解析的 IP",
"Check-in failed": "签到失败",
@@ -1526,6 +1528,7 @@
"Expand": "展开",
"Expand All": "全部展开",
"Expected a JSON array.": "应为 JSON 数组。",
"Expected a JSON array of group identifiers": "应为分组标识符的 JSON 数组",
"Experiment with prompts and models in real time.": "实时实验提示词和模型。",
"Expiration Time": "过期时间",
"expired": "已过期",
@@ -2092,6 +2095,9 @@
"JSON Editor": "JSON 编辑",
"JSON format error": "JSON 格式错误",
"JSON format supports service account JSON files": "JSON 格式支持服务账户 JSON 文件",
"JSON is invalid at line {{line}}, column {{column}}.": "JSON 在第 {{line}} 行、第 {{column}} 列无效。",
"JSON is invalid at position {{position}}.": "JSON 在位置 {{position}} 无效。",
"JSON is invalid. Please check the syntax.": "JSON 无效,请检查语法。",
"JSON map of group → description exposed when users create API keys.": "分组 → 描述的 JSON 映射,在用户创建 API 密钥时公开。",
"JSON map of group → ratio applied when the user selects the group explicitly.": "分组 → 比率的 JSON 映射,当用户明确选择该分组时应用此比率。",
"JSON map of model → multiplier applied to quota billing.": "模型 → 应用于配额计费的乘数的 JSON 映射。",
@@ -2100,6 +2106,7 @@
"JSON Mode": "JSON 模式",
"JSON must be an object": "JSON 必须是对象",
"JSON object:": "JSON 对象:",
"JSON structure is invalid": "JSON 结构无效",
"JSON Text": "JSON 文本",
"JSON-based access control rules. Leave empty to allow all users.": "基于 JSON 的访问控制规则。留空以允许所有用户。",
"Just now": "刚刚",
@@ -2853,8 +2860,8 @@
"Path Regex (one per line)": "路径正则(每行一个)",
"Path:": "路径:",
"Pay": "支付",
"Pay-as-you-go with real-time usage monitoring": "按量付费,实时监控使用情况",
"Pay with Balance": "使用余额支付",
"Pay-as-you-go with real-time usage monitoring": "按量付费,实时监控使用情况",
"Payment": "支付",
"Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.": "支付聚合模式:使用你自己的注册公司(离岸实体)入驻。面向企业场景构建。",
"Payment Channel": "支付渠道",
@@ -3767,10 +3774,10 @@
"Subscription First": "优先订阅",
"Subscription Management": "订阅管理",
"Subscription Only": "仅用订阅",
"Subscription purchased successfully": "订阅购买成功",
"Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.": "管理员在支付网关设置中确认合规条款之前,订阅套餐的创建和修改会被锁定。",
"Subscription Plans": "订阅套餐",
"Subscription plans do NOT use the bound Product — each plan has its own dedicated Pancake product, set in the Subscriptions admin (or auto-minted via the \"+ Create\" button there).": "订阅套餐不会使用已绑定的产品。每个套餐都有独立的 Pancake 产品,可在订阅管理中设置,或通过其中的“+ 创建”按钮自动生成。",
"Subscription purchased successfully": "订阅购买成功",
"Subtract": "减少",
"Success": "成功",
"Success rate": "成功率",
@@ -3951,6 +3958,7 @@
"This model is not available in any group, or no group pricing information is configured.": "此模型在任何分组中均不可用,或未配置分组定价信息。",
"This month": "本月获得",
"This page has not been created yet.": "此页面尚未创建。",
"This plan does not allow balance redemption": "该套餐不允许使用余额兑换",
"This project must be used in compliance with the": "此项目的使用必须遵守",
"This record was written by a pre-upgrade instance and lacks audit info. Upgrade the instance to record server IP, callback IP, payment method and system version.": "该记录由旧版本实例写入,缺少审计信息,建议将实例升级至最新版本以便记录服务器 IP、回调 IP、支付方式与系统版本等审计字段。",
"This site currently has {{count}} models enabled": "本站当前已启用模型,总计 {{count}} 个",
@@ -4311,6 +4319,7 @@
"Validity Period": "有效期",
"Value": "值",
"Value (supports JSON or plain text)": "值(支持 JSON 或普通文本)",
"Value is required": "值为必填项",
"Value must be at least 0": "值必须至少为 0",
"Value Regex": "Value 正则",
"variable": "变量",
+1
View File
@@ -88,6 +88,7 @@ export const STATIC_I18N_KEYS = [
'Failed to delete API key',
'Failed to delete API keys',
'Failed to update API key status',
'Expected a JSON array of group identifiers',
'Successfully created {{count}} API Key(s)',
'Successfully deleted {{count}} API key(s)',
'Enter API key for this channel',
+20
View File
@@ -0,0 +1,20 @@
{
"name": "new-api-web-workspace",
"private": true,
"workspaces": [
"default",
"classic"
],
"catalog": {
"@lobehub/icons": "^5.10.0",
"axios": "^1.16.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.20",
"prettier": "^3.8.3",
"qrcode.react": "^4.2.0",
"react-icons": "^5.6.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"sse.js": "^2.8.0"
}
}