Compare commits

...

10 Commits

Author SHA1 Message Date
yyhhyyyyyy
ad224ecf5b
fix: prevent duplicate channel action toasts (#5015)
* fix: prevent duplicate channel action toasts

* fix: localize api error fallbacks
2026-05-26 10:20:54 +08:00
t0ng7u
a64f26d1d2 🎨 feat(web/default): add Anthropic theme preset and configurable serif typography
Introduce a switchable Anthropic-inspired color preset and a new Font customization axis so users can adopt the editorial serif look across the entire UI, including sidebar navigation, tabs, form controls, buttons, and table headers.

Theme preset

Add anthropic to the theme preset registry with warm cream canvas, slate foreground, and clay/coral accent tokens for light and dark modes
Define explicit surface colors for the Anthropic preset instead of relying on the semantic surface bridge
Exclude anthropic from the primary-color surface bridge so bespoke warm neutrals are not overridden by accent-tinted mixes
Typography system

Add @fontsource-variable/lora and a global --font-serif token with CJK serif fallbacks (Noto Serif SC, Source Han Serif, Songti SC, etc.)
Introduce a --font-body token and drive <body> font-family from it
Add a Font axis (default | sans | serif) parallel to radius/scale
Resolve font: 'default' against preset defaults (anthropic → serif)
Persist font preference via cookie and apply data-theme-font on <body>
Apply serif OpenType features (kern, liga, calt, tnum) and heading display tuning when serif is active
Remove per-component sans opt-outs so serif inherits through sidebar, tabs, inputs, buttons, badges, and table headers via natural CSS cascade
Keep monospace contexts unchanged via Tailwind preflight and .font-mono
UI and i18n

Add Font selector to the theme config drawer (Auto / Sans / Serif)
Add "Font" and "Select body font" translations for en, zh, fr, ja, ru, vi
Misc

Tighten group and status badge sizing for better balance with serif text
2026-05-26 04:31:13 +08:00
t0ng7u
3360882642 ♻️ refactor(channels): rebuild channel editor UX with modular sections and Base UI multi-select
Restructure the default-theme channel create/edit experience to match classic
frontend behavior, improve form UX, and align with the project's Base UI design
system.

Channel editor architecture:
- Split the monolithic channel mutate drawer into focused section components
  (basic, API access, auth, models, advanced) with shared drawer layout
  primitives
- Extract submission, toast handling, and react-query cache invalidation into
  `useChannelMutateForm`
- Add a dedicated loading skeleton for channel detail fetch during edit mode
- Remove the top-level configuration summary block

Form validation and data handling:
- Strengthen `channel-form` Zod schema with JSON, model mapping, status code
  mapping, Codex credential, and Vertex AI key refinements
- Move type-specific conditional validation into `superRefine`
- Normalize base URL formatting and tighten model mapping value validation

Model mapping editor:
- Add Visual/JSON tabbed editing with inline JSON and duplicate-key feedback
- Improve accessibility for icon-only actions and add model suggestion datalists

MultiSelect component:
- Replace the custom cmdk-based implementation with Base UI Combobox chips
- Align focus, border, ring, disabled, and invalid states with standard Input
  styling via `ComboboxChips`
- Preserve existing API for all current callers (`options`, `selected`,
  `onChange`, `allowCreate`, `createLabel`)
- Support inline custom value creation and comma/newline batch input
- Limit visible chips with a compact "+N more" overflow summary via
  `maxVisibleChips` (8 in the channel editor)
- Anchor the dropdown to the full chips container via `useComboboxAnchor` so
  the popup matches input width and long model names are no longer truncated

Models & groups UX:
- Integrate manual custom model entry directly into the model MultiSelect
- Remove the separate manual model input/button block
- Keep selected-model count badge and existing model-mapping guardrail behavior

i18n:
- Add and sync translation keys for section descriptions, validation messages,
  model mapping UI, and MultiSelect labels across en, zh, fr, ja, ru, and vi
- Fix missing translations for "Name, provider type, and availability.",
  "Endpoint, provider-specific settings, and credentials.", and "Published
  models, groups, and model remapping rules."
- Remove obsolete keys tied to the deprecated summary and manual model entry UI
2026-05-26 01:55:27 +08:00
t0ng7u
b37b6d80b3 Merge remote-tracking branch 'origin/main' 2026-05-26 01:22:56 +08:00
t0ng7u
3d850d38b6 ♻️ refactor(channels): rebuild channel create/edit drawer with modular sections and improved form UX
Restructure the default-theme channel create/edit experience to align with
classic frontend behavior, modern form UX patterns, and the project's Base UI
design system.

Channel editor architecture:
- Split the monolithic channel mutate drawer into focused section components
  (basic, API access, auth, models, advanced) with shared drawer layout
  primitives
- Extract submission, toast handling, and react-query cache invalidation into
  `useChannelMutateForm`
- Add a dedicated loading skeleton for channel detail fetch during edit mode
- Remove the top-level configuration summary block per UX feedback

Form validation and data handling:
- Strengthen `channel-form` Zod schema with JSON, model mapping, status code
  mapping, Codex credential, and Vertex AI key refinements
- Move type-specific conditional validation into `superRefine`
- Normalize base URL formatting and tighten model mapping value validation

Model mapping editor:
- Add Visual/JSON tabbed editing with inline JSON and duplicate-key feedback
- Improve accessibility for icon-only actions and add model suggestion datalists

MultiSelect component:
- Replace the custom cmdk-based implementation with Base UI Combobox chips
- Align focus, border, ring, disabled, and invalid states with standard Input
  styling via `ComboboxChips`
- Preserve existing API (`options`, `selected`, `onChange`, `allowCreate`,
  `createLabel`) for all current callers
- Support inline custom value creation, comma/newline batch input, searchable
  options, portal-based dropdown positioning, and chip removal

Models & groups UX:
- Integrate manual custom model entry directly into the model MultiSelect
- Remove the separate manual model input/button block
- Keep selected-model count and existing model-mapping guardrail behavior

i18n:
- Add and sync translation keys for new editor sections, validation messages,
  model mapping UI, and MultiSelect empty/create labels across en, zh, fr, ja,
  ru, and vi
- Remove obsolete keys tied to the deprecated summary and manual model entry UI

Affected areas:
- `web/default/src/features/channels/components/drawers/`
- `web/default/src/features/channels/hooks/use-channel-mutate-form.ts`
- `web/default/src/features/channels/lib/channel-form.ts`
- `web/default/src/features/channels/lib/model-mapping-validation.ts`
- `web/default/src/features/channels/components/model-mapping-editor.tsx`
- `web/default/src/components/multi-select.tsx`
- `web/default/src/i18n/locales/*.json`
2026-05-26 01:22:49 +08:00
yyhhyyyyyy
349d5429ca
fix: handle paginated API key search response (#5014)
* fix: handle paginated API key search response

* fix: add accessible label to API key filter
2026-05-25 23:15:59 +08:00
花月喵梦
465c5edab9
fix:gemini to claude tool_use err (#5041) 2026-05-25 23:14:01 +08:00
learner-i
ff06067a18
fix: 移除 fcIdx -1 偏移,修复并发工具调用撞键问题 (#5095)
当 Claude 直接以多个 tool_use 块起始(无文本前导,index=0)时,
-1 偏移导致 index=0 和 index=1 同时映射到 fcIdx=0:
- index=0 的工具 args 先流完,发出一次合法调用 ✓
- index=1 的 args 追加到同一 map 槽位,污染后为非法 JSON,该工具丢失 ✗
- index=2 以后的工具各自独占唯一 fcIdx,正常发出 ✓

结果:每轮并发调用中第 2 个工具必然丢失,
模型收不到对应的工具结果后重试剩余工具,
产生雪球效应(10个→9个→8个...逐轮收缩)。

修复:直接使用 Claude 的 block index 作为 fcIdx,不做偏移。
fcIdx 仅作为本地 map 的 key,只需保证唯一性,无需从 0 开始。
2026-05-25 23:13:06 +08:00
CaIon
51ca897cf4
refactor(home): redesign hero section to dual-column layout with compliant copywriting
Redesigns the hero section into a balanced horizontal dual-column layout:
- Left Column: Features title, clean legal-compliant descriptions, CTA buttons with BookOpen Docs link, and enlarged supported apps buttons (Cherry Studio and CC Switch with lobe icons)
- Right Column: Smoothly integrates the terminal API demo with top horizontal alignment
- i18n: Configures compliance translations for en, zh, fr, ja, ru, and vi locales
2026-05-25 23:11:05 +08:00
Seefs
1288028181
fix: truncate oversized upstream error logs (#5083) 2026-05-25 23:10:30 +08:00
48 changed files with 4652 additions and 2709 deletions

View File

@ -3,6 +3,7 @@ package common
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/url"
"regexp"
"strconv"
@ -20,6 +21,16 @@ var (
maskApiKeyPattern = regexp.MustCompile(`(['"]?)api_key:([^\s'"]+)(['"]?)`)
)
const LocalLogContentLimit = 2048
// LocalLogPreview limits log-only content unless debug logging is enabled.
func LocalLogPreview(content string) string {
if DebugEnabled || len(content) <= LocalLogContentLimit {
return content
}
return fmt.Sprintf("%s... [truncated, original_length=%d, limit=%d]", content[:LocalLogContentLimit], len(content), LocalLogContentLimit)
}
func GetStringIfEmpty(str string, defaultValue string) string {
if str == "" {
return defaultValue

View File

@ -88,7 +88,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
defer func() {
if newAPIError != nil {
logger.LogError(c, fmt.Sprintf("relay error: %s", newAPIError.Error()))
logger.LogError(c, fmt.Sprintf("relay error: %s", common.LocalLogPreview(newAPIError.Error())))
newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId))
switch relayFormat {
case types.RelayFormatOpenAIRealtime:
@ -354,7 +354,7 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
}
func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError) {
logger.LogError(c, fmt.Sprintf("channel error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, err.Error()))
logger.LogError(c, fmt.Sprintf("channel error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, common.LocalLogPreview(err.Error())))
// 不要使用context获取渠道信息异步处理时可能会出现渠道信息不一致的情况
// do not use context to get channel info, there may be inconsistent channel info when processing asynchronously
if service.ShouldDisableChannel(err) && channelError.AutoBan {

View File

@ -17,24 +17,24 @@ import (
)
type Log struct {
Id int `json:"id" gorm:"index:idx_created_at_id,priority:1;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"`
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:''"`
TokenName string `json:"token_name" gorm:"index;default:''"`
ModelName string `json:"model_name" gorm:"index;index:index_username_model_name,priority:1;default:''"`
Quota int `json:"quota" gorm:"default:0"`
PromptTokens int `json:"prompt_tokens" gorm:"default:0"`
CompletionTokens int `json:"completion_tokens" gorm:"default:0"`
UseTime int `json:"use_time" gorm:"default:0"`
IsStream bool `json:"is_stream"`
ChannelId int `json:"channel" gorm:"index"`
ChannelName string `json:"channel_name" gorm:"->"`
TokenId int `json:"token_id" gorm:"default:0;index"`
Group string `json:"group" gorm:"index"`
Ip string `json:"ip" gorm:"index;default:''"`
Id int `json:"id" gorm:"index:idx_created_at_id,priority:1;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"`
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:''"`
TokenName string `json:"token_name" gorm:"index;default:''"`
ModelName string `json:"model_name" gorm:"index;index:index_username_model_name,priority:1;default:''"`
Quota int `json:"quota" gorm:"default:0"`
PromptTokens int `json:"prompt_tokens" gorm:"default:0"`
CompletionTokens int `json:"completion_tokens" gorm:"default:0"`
UseTime int `json:"use_time" gorm:"default:0"`
IsStream bool `json:"is_stream"`
ChannelId int `json:"channel" gorm:"index"`
ChannelName string `json:"channel_name" gorm:"->"`
TokenId int `json:"token_id" gorm:"default:0;index"`
Group string `json:"group" gorm:"index"`
Ip string `json:"ip" gorm:"index;default:''"`
RequestId string `json:"request_id,omitempty" gorm:"type:varchar(64);index:idx_logs_request_id;default:''"`
UpstreamRequestId string `json:"upstream_request_id,omitempty" gorm:"type:varchar(128);index:idx_logs_upstream_request_id;default:''"`
Other string `json:"other"`
@ -145,7 +145,7 @@ func RecordTopupLog(userId int, content string, callerIp string, paymentMethod s
func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string, tokenName string, content string, tokenId int, useTimeSeconds int,
isStream bool, group string, other map[string]interface{}) {
logger.LogInfo(c, fmt.Sprintf("record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s", userId, channelId, modelName, tokenName, content))
logger.LogInfo(c, fmt.Sprintf("record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s", userId, channelId, modelName, tokenName, common.LocalLogPreview(content)))
username := c.GetString("username")
requestId := c.GetString(common.RequestIdKey)
upstreamRequestId := c.GetString(common.UpstreamRequestIdKey)

View File

@ -442,10 +442,7 @@ func StreamResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.ChatCo
tools := make([]dto.ToolCallResponse, 0)
fcIdx := 0
if claudeResponse.Index != nil {
fcIdx = *claudeResponse.Index - 1
if fcIdx < 0 {
fcIdx = 0
}
fcIdx = *claudeResponse.Index
}
var choice dto.ChatCompletionsStreamResponseChoice
if claudeResponse.Type == "message_start" {

View File

@ -1410,6 +1410,14 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
response.Id = id
response.Created = createAt
response.Model = info.UpstreamModelName
if response.IsToolCall() {
finishReason = constant.FinishReasonToolCalls
if info.RelayFormat == types.RelayFormatClaude {
for choiceIdx := range response.Choices {
response.Choices[choiceIdx].FinishReason = nil
}
}
}
for choiceIdx := range response.Choices {
choiceKey := response.Choices[choiceIdx].Index
for toolIdx := range response.Choices[choiceIdx].Delta.ToolCalls {
@ -1470,7 +1478,9 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
logger.LogError(c, err.Error())
}
if isStop {
_ = handleStream(c, info, helper.GenerateStopResponse(id, createAt, info.UpstreamModelName, finishReason))
if info.RelayFormat != types.RelayFormatClaude {
_ = handleStream(c, info, helper.GenerateStopResponse(id, createAt, info.UpstreamModelName, finishReason))
}
}
return true
})
@ -1480,6 +1490,10 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
}
response := helper.GenerateFinalUsageResponse(id, createAt, info.UpstreamModelName, *usage)
if info.RelayFormat == types.RelayFormatClaude && info.ClaudeConvertInfo != nil && !info.ClaudeConvertInfo.Done {
response = helper.GenerateStopResponse(id, createAt, info.UpstreamModelName, finishReason)
response.Usage = usage
}
handleErr := handleFinalStream(c, info, response)
if handleErr != nil {
common.SysLog("send final response failed: " + handleErr.Error())

View File

@ -17,7 +17,7 @@ func formatNotifyType(channelId int, status int) string {
// disable & notify
func DisableChannel(channelError types.ChannelError, reason string) {
common.SysLog(fmt.Sprintf("通道「%s」#%d发生错误准备禁用原因%s", channelError.ChannelName, channelError.ChannelId, reason))
common.SysLog(fmt.Sprintf("通道「%s」#%d发生错误准备禁用原因%s", channelError.ChannelName, channelError.ChannelId, common.LocalLogPreview(reason)))
// 检查是否启用自动禁用功能
if !channelError.AutoBan {

View File

@ -92,11 +92,13 @@ func RelayErrorHandler(ctx context.Context, resp *http.Response, showBodyWhenFai
}
CloseResponseBodyGracefully(resp)
var errResponse dto.GeneralErrorResponse
responseBodyText := string(responseBody)
responseBodyPreview := common.LocalLogPreview(responseBodyText)
buildErrWithBody := func(message string) error {
if message == "" {
return fmt.Errorf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody))
return fmt.Errorf("bad response status code %d, body: %s", resp.StatusCode, responseBodyText)
}
return fmt.Errorf("bad response status code %d, message: %s, body: %s", resp.StatusCode, message, string(responseBody))
return fmt.Errorf("bad response status code %d, message: %s, body: %s", resp.StatusCode, message, responseBodyText)
}
err = common.Unmarshal(responseBody, &errResponse)
@ -104,7 +106,7 @@ func RelayErrorHandler(ctx context.Context, resp *http.Response, showBodyWhenFai
if showBodyWhenFail {
newApiErr.Err = buildErrWithBody("")
} else {
logger.LogError(ctx, fmt.Sprintf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody)))
logger.LogError(ctx, fmt.Sprintf("bad response status code %d, body: %s", resp.StatusCode, responseBodyPreview))
newApiErr.Err = fmt.Errorf("bad response status code %d", resp.StatusCode)
}
return

View File

@ -1,9 +1,17 @@
package service
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
@ -55,3 +63,99 @@ func TestResetStatusCode(t *testing.T) {
})
}
}
func TestRelayErrorHandlerTruncatesInvalidJSONBodyInLog(t *testing.T) {
withDebugEnabled(t, false)
body := strings.Repeat("b", common.LocalLogContentLimit+256)
var logBuffer bytes.Buffer
common.LogWriterMu.Lock()
oldWriter := gin.DefaultErrorWriter
gin.DefaultErrorWriter = &logBuffer
common.LogWriterMu.Unlock()
t.Cleanup(func() {
common.LogWriterMu.Lock()
gin.DefaultErrorWriter = oldWriter
common.LogWriterMu.Unlock()
})
resp := &http.Response{
StatusCode: http.StatusInternalServerError,
Body: io.NopCloser(strings.NewReader(body)),
}
newAPIError := RelayErrorHandler(context.Background(), resp, false)
require.NotNil(t, newAPIError)
require.Equal(t, "bad response status code 500", newAPIError.Error())
require.Contains(t, logBuffer.String(), "[truncated")
require.Contains(t, logBuffer.String(), fmt.Sprintf("original_length=%d", len(body)))
require.NotContains(t, logBuffer.String(), strings.Repeat("b", common.LocalLogContentLimit+1))
}
func TestRelayErrorHandlerKeepsStructuredErrorMessage(t *testing.T) {
message := strings.Repeat("c", common.LocalLogContentLimit+256)
body := `{"message":"` + message + `"}`
resp := &http.Response{
StatusCode: http.StatusInternalServerError,
Body: io.NopCloser(strings.NewReader(body)),
}
newAPIError := RelayErrorHandler(context.Background(), resp, false)
require.NotNil(t, newAPIError)
require.Equal(t, message, newAPIError.Error())
}
func TestRelayErrorHandlerKeepsOpenAIErrorMessage(t *testing.T) {
message := strings.Repeat("d", common.LocalLogContentLimit+256)
body := `{"error":{"message":"` + message + `","type":"server_error","code":"server_error"}}`
resp := &http.Response{
StatusCode: http.StatusInternalServerError,
Body: io.NopCloser(strings.NewReader(body)),
}
newAPIError := RelayErrorHandler(context.Background(), resp, false)
require.NotNil(t, newAPIError)
require.Equal(t, message, newAPIError.Error())
}
func TestRelayErrorHandlerKeepsInvalidJSONBodyInDebugLog(t *testing.T) {
withDebugEnabled(t, true)
body := strings.Repeat("e", common.LocalLogContentLimit+256)
var logBuffer bytes.Buffer
common.LogWriterMu.Lock()
oldWriter := gin.DefaultErrorWriter
gin.DefaultErrorWriter = &logBuffer
common.LogWriterMu.Unlock()
t.Cleanup(func() {
common.LogWriterMu.Lock()
gin.DefaultErrorWriter = oldWriter
common.LogWriterMu.Unlock()
})
resp := &http.Response{
StatusCode: http.StatusInternalServerError,
Body: io.NopCloser(strings.NewReader(body)),
}
newAPIError := RelayErrorHandler(context.Background(), resp, false)
require.NotNil(t, newAPIError)
require.NotContains(t, logBuffer.String(), "[truncated")
require.Contains(t, logBuffer.String(), body)
}
func withDebugEnabled(t *testing.T, enabled bool) {
t.Helper()
oldDebug := common.DebugEnabled
common.DebugEnabled = enabled
t.Cleanup(func() {
common.DebugEnabled = oldDebug
})
}

View File

@ -5,6 +5,7 @@
"name": "newapi-web",
"dependencies": {
"@base-ui/react": "^1.5.0",
"@fontsource-variable/lora": "^5.2.8",
"@fontsource-variable/public-sans": "^5.2.7",
"@hookform/resolvers": "^5.4.0",
"@hugeicons/core-free-icons": "^4.1.4",
@ -86,7 +87,7 @@
},
},
"overrides": {
"brace-expansion": "5.0.6",
"brace-expansion": "2.1.1",
"dompurify": "3.4.5",
"fast-uri": "3.1.2",
"hono": "4.12.22",
@ -267,6 +268,8 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
"@fontsource-variable/lora": ["@fontsource-variable/lora@5.2.8", "", {}, "sha512-cxjTJ9BbOWIzusewR4UMBLVePvTSWV6dtNaNsCkF/oKoyA68fJGWfaYCILOOP1BObE4dmjfZ3xo6m9hdHhtYhg=="],
"@fontsource-variable/public-sans": ["@fontsource-variable/public-sans@5.2.7", "", {}, "sha512-4mvade2J3slKkvwRkS+p8T3szet/0vhWoSnuUJTVU81Uo2pRpSZY/Y8bSLRqpSwzIPxjVmRJ53oq6JKP/l/PSg=="],
"@giscus/react": ["@giscus/react@3.1.0", "", { "dependencies": { "giscus": "^1.6.0" }, "peerDependencies": { "react": "^16 || ^17 || ^18 || ^19", "react-dom": "^16 || ^17 || ^18 || ^19" } }, "sha512-0TCO2TvL43+oOdyVVGHDItwxD1UMKP2ZYpT6gXmhFOqfAJtZxTzJ9hkn34iAF/b6YzyJ4Um89QIt9z/ajmAEeg=="],
@ -981,13 +984,13 @@
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.17", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA=="],
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
"brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],

View File

@ -19,6 +19,7 @@
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@fontsource-variable/lora": "^5.2.8",
"@fontsource-variable/public-sans": "^5.2.7",
"@hookform/resolvers": "^5.4.0",
"@hugeicons/core-free-icons": "^4.1.4",
@ -98,7 +99,7 @@
"typescript-eslint": "^8.59.4"
},
"overrides": {
"brace-expansion": "5.0.6",
"brace-expansion": "2.1.1",
"dompurify": "3.4.5",
"fast-uri": "3.1.2",
"hono": "4.12.22",

View File

@ -34,6 +34,7 @@ import { IconThemeSystem } from '@/assets/custom/icon-theme-system'
import {
type ContentLayout,
THEME_PRESETS,
type ThemeFont,
type ThemePreset,
type ThemeRadius,
type ThemeScale,
@ -104,6 +105,7 @@ export function ConfigDrawer() {
<div className={sideDrawerFormClassName()}>
<ThemeConfig />
<PresetConfig />
<FontConfig />
<RadiusConfig />
<ScaleConfig />
<SidebarConfig />
@ -302,6 +304,90 @@ function PresetConfig() {
)
}
/**
* Font options shown in the theme drawer.
*
* Each option renders a live "Aa" preview in the font it represents.
* `Auto` deliberately leaves `fontFamily` undefined so the preview inherits
* the currently active body font that way the user sees what `Auto` will
* actually look like for the active preset (Anthropic serif glyphs,
* everything else sans glyphs) without us having to duplicate the
* preset-default mapping in the UI.
*/
const FONT_OPTIONS: {
value: ThemeFont
label: string
// CSS font-family applied to the "Aa" preview. `undefined` = inherit
// from the current theme (used by the `default` option).
preview?: string
}[] = [
{ value: 'default', label: 'Auto', preview: undefined },
{ value: 'sans', label: 'Sans', preview: 'var(--font-sans)' },
{ value: 'serif', label: 'Serif', preview: 'var(--font-serif)' },
]
function FontConfig() {
const { t } = useTranslation()
const { defaults, customization, setFont } = useThemeCustomization()
return (
<div>
<SectionTitle
title={t('Font')}
showReset={customization.font !== defaults.font}
onReset={() => setFont(defaults.font)}
/>
<Radio
value={customization.font}
onValueChange={(v) => setFont(v as ThemeFont)}
className='grid w-full grid-cols-3 gap-4'
aria-label={t('Select body font')}
>
{FONT_OPTIONS.map((option) => (
<Item
key={option.value}
value={option.value}
className='group flex flex-col items-stretch outline-none'
aria-label={
option.value === 'default' ? t('System default') : option.label
}
>
<div
className={cn(
'ring-border relative h-12 rounded-md ring-[1px] transition',
'group-data-checked:ring-primary group-data-checked:shadow-md',
'group-focus-visible:ring-2',
'group-hover:ring-primary/60'
)}
>
<CircleCheck
className={cn(
'fill-primary absolute top-0 right-0 z-10 size-5 translate-x-1/2 -translate-y-1/2 stroke-white',
'group-data-unchecked:hidden'
)}
aria-hidden='true'
/>
<span
aria-hidden='true'
className='text-foreground absolute inset-0 flex items-center justify-center text-lg leading-none font-medium'
style={
option.preview
? { fontFamily: option.preview }
: // `font: inherit` defers to the active theme so the
// "Auto" tile previews what the resolved font will be.
{ font: 'inherit', fontSize: '1.125rem' }
}
>
Aa
</span>
</div>
<div className='mt-1.5 text-center text-xs'>{option.label}</div>
</Item>
))}
</Radio>
</div>
)
}
const RADIUS_OPTIONS: {
value: ThemeRadius
label: string

View File

@ -94,7 +94,7 @@ export function GroupBadge(props: GroupBadgeProps) {
{badge}
<span
className={cn(
'inline-flex h-6 items-center rounded-full px-2 font-mono text-sm leading-none font-medium tabular-nums',
'inline-flex h-5 items-center rounded-full px-2 font-mono text-xs leading-none font-medium tabular-nums',
getGroupRatioClassName(ratio)
)}
>

View File

@ -8,21 +8,32 @@ 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
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/>.
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import * as React from 'react'
import { Command as CommandPrimitive } from 'cmdk'
import { X } from 'lucide-react'
import { Add01Icon } from '@hugeicons/core-free-icons'
import { HugeiconsIcon } from '@hugeicons/react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Command, CommandGroup, CommandItem } from '@/components/ui/command'
import { cn } from '@/lib/utils'
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxCollection,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxValue,
useComboboxAnchor,
} from '@/components/ui/combobox'
export type Option = {
label: string
@ -35,116 +46,261 @@ interface MultiSelectProps {
onChange: (values: string[]) => void
placeholder?: string
className?: string
allowCreate?: boolean
/**
* Label shown for the "create" item in the dropdown.
* Supports the `{{value}}` placeholder which is replaced with the typed input.
* Falls back to `Add "{{value}}"` when omitted.
*/
createLabel?: string
/** Empty state text. Defaults to "No matching items". */
emptyText?: string
/** Optional `id` to wire labels/aria-describedby to the input. */
id?: string
/** Disable the entire control. */
disabled?: boolean
/**
* Limits rendered chips while keeping all values selected.
* Hidden values remain searchable/removable from the dropdown.
*/
maxVisibleChips?: number
}
export function MultiSelect({
options,
selected,
onChange,
placeholder,
className,
}: MultiSelectProps) {
const { t } = useTranslation()
const resolvedPlaceholder = placeholder ?? t('Select items...')
const inputRef = React.useRef<HTMLInputElement>(null)
const [open, setOpen] = React.useState(false)
const [inputValue, setInputValue] = React.useState('')
const COMMA_REGEX = /[,\n]/
const handleUnselect = (value: string) => {
onChange(selected.filter((s) => s !== value))
function splitDraft(value: string): { completed: string[]; draft: string } {
if (!COMMA_REGEX.test(value)) {
return { completed: [], draft: value }
}
const normalized = value.replaceAll('', ',').replaceAll('\n', ',')
const parts = normalized.split(',')
const draft = parts.at(-1) ?? ''
const completed = parts
.slice(0, -1)
.map((part) => part.trim())
.filter(Boolean)
return { completed, draft }
}
/**
* MultiSelect tags/chips style multi-select built on Base UI Combobox.
*
* Behaviour:
* - Search filters built-in options (Base UI handles fuzzy filtering).
* - When `allowCreate` is true, custom values can be added inline:
* - Type and press Enter / "," to add a single value.
* - Paste a comma- (or newline-) separated list to add many at once.
* - A "Add \"<value>\"" item appears at the top of the dropdown when the
* typed text doesn't match any option.
* - Backspace on an empty input removes the last selected chip (Base UI default).
* - `maxVisibleChips` can cap large selections and show a compact "+N more"
* summary so forms do not grow vertically without bound.
*
* Focus/border styling is inherited from `ComboboxChips`, which uses the same
* tokens as `Input` so it stays visually consistent with other form fields.
*/
export function MultiSelect(props: MultiSelectProps) {
const { t } = useTranslation()
const placeholder = props.placeholder ?? t('Select items...')
// Anchor the popup to the chips container so its width tracks the entire
// input row, not just the leftover space at the end of wrapped chips.
const chipsAnchorRef = useComboboxAnchor()
const [inputValue, setInputValue] = React.useState('')
const [open, setOpen] = React.useState(false)
const selectedSet = React.useMemo(
() => new Set(props.selected),
[props.selected]
)
// Lookup of value -> display label so chips and items can show friendly names
// even when the underlying option list changes (e.g. custom-added values).
const labelMap = React.useMemo(() => {
const map = new Map<string, string>()
for (const option of props.options) {
map.set(option.value, option.label)
}
return map
}, [props.options])
const trimmedInput = inputValue.trim()
const inputMatchesExisting =
trimmedInput.length > 0 &&
(selectedSet.has(trimmedInput) ||
props.options.some(
(option) =>
option.value.toLowerCase() === trimmedInput.toLowerCase() ||
option.label.toLowerCase() === trimmedInput.toLowerCase()
))
const canCreate =
props.allowCreate === true &&
trimmedInput.length > 0 &&
!inputMatchesExisting
// We expose all known option values + every currently selected value to Base
// UI's items list. This way Base UI filters them by the search query and the
// user can still see the chip labels mapped correctly.
const items = React.useMemo(() => {
const set = new Set<string>(props.options.map((option) => option.value))
for (const value of props.selected) {
set.add(value)
}
if (canCreate) {
set.add(trimmedInput)
}
return Array.from(set)
}, [props.options, props.selected, canCreate, trimmedInput])
const addValues = React.useCallback(
(values: string[]) => {
const next: string[] = []
const seen = new Set<string>(props.selected)
for (const raw of values) {
const value = raw.trim()
if (!value) continue
if (seen.has(value)) continue
seen.add(value)
next.push(value)
}
if (next.length === 0) return
props.onChange([...props.selected, ...next])
},
[props]
)
const handleInputValueChange = (value: string) => {
if (!props.allowCreate) {
setInputValue(value)
return
}
const parsed = splitDraft(value)
if (parsed.completed.length > 0) {
addValues(parsed.completed)
setInputValue(parsed.draft)
return
}
setInputValue(value)
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const input = inputRef.current
if (input) {
if (e.key === 'Delete' || e.key === 'Backspace') {
if (input.value === '' && selected.length > 0) {
onChange(selected.slice(0, -1))
}
}
if (e.key === 'Escape') {
input.blur()
const handleValueChange = (next: string[]) => {
props.onChange(next)
// When an item is picked (multiple mode), Base UI keeps the input but most
// UX patterns clear it. Clearing once a value is added makes batch picking
// feel snappier and matches popular chip-style multiselects.
if (next.length > props.selected.length) {
setInputValue('')
}
}
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
// Enter without a highlighted option commits the typed value.
if (event.key === 'Enter' && props.allowCreate && canCreate) {
// Only fire when Base UI has no highlighted item to select. We rely on
// the highlighted item's data attribute on the popup. If the popup is
// closed or empty, manually commit the typed value.
const popup = document.querySelector<HTMLElement>(
'[data-slot="combobox-content"][data-open]'
)
const hasHighlight = popup?.querySelector('[data-highlighted]') != null
if (!hasHighlight) {
event.preventDefault()
addValues([trimmedInput])
setInputValue('')
}
}
}
const selectables = options.filter(
(option) => !selected.includes(option.value)
)
return (
<Command
onKeyDown={handleKeyDown}
className={`overflow-visible bg-transparent ${className || ''}`}
<Combobox
multiple
items={items}
value={props.selected}
onValueChange={handleValueChange}
inputValue={inputValue}
onInputValueChange={handleInputValueChange}
open={open}
onOpenChange={setOpen}
disabled={props.disabled}
>
<div className='group border-input ring-offset-background focus-within:ring-ring rounded-md border px-3 py-2 text-sm focus-within:ring-2 focus-within:ring-offset-2'>
<div className='flex flex-wrap gap-1'>
{selected.map((value) => {
const option = options.find((o) => o.value === value)
<ComboboxChips
ref={chipsAnchorRef}
className={cn('w-full', props.className)}
>
<ComboboxValue>
{(values: string[]) => {
const visibleValues =
typeof props.maxVisibleChips === 'number'
? values.slice(0, props.maxVisibleChips)
: values
const hiddenCount = values.length - visibleValues.length
return (
<Badge key={value} variant='secondary'>
{option?.label || value}
<Button
variant='ghost'
size='icon-sm'
aria-label='Remove'
className='ml-1 size-auto p-0'
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleUnselect(value)
}
}}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onClick={() => handleUnselect(value)}
>
<X
className='text-muted-foreground hover:text-foreground h-3 w-3'
aria-hidden='true'
/>
</Button>
</Badge>
<>
{visibleValues.map((value) => (
<ComboboxChip key={value}>
<span className='max-w-[16rem] truncate'>
{labelMap.get(value) ?? value}
</span>
</ComboboxChip>
))}
{hiddenCount > 0 && (
<span className='bg-muted text-muted-foreground flex h-[calc(--spacing(5.25))] w-fit items-center justify-center rounded-sm px-1.5 text-xs font-medium whitespace-nowrap'>
{t('+{{count}} more', { count: hiddenCount })}
</span>
)}
</>
)
})}
<CommandPrimitive.Input
ref={inputRef}
value={inputValue}
onValueChange={setInputValue}
onBlur={() => setOpen(false)}
onFocus={() => setOpen(true)}
placeholder={selected.length === 0 ? resolvedPlaceholder : ''}
className='placeholder:text-muted-foreground flex-1 bg-transparent outline-none'
/>
</div>
</div>
<div className='relative'>
{open && selectables.length > 0 ? (
<div className='bg-popover text-popover-foreground animate-in absolute top-0 z-10 w-full rounded-md border shadow-md outline-none'>
<CommandGroup className='h-full max-h-60 overflow-auto'>
{selectables.map((option) => {
return (
<CommandItem
key={option.value}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onSelect={() => {
setInputValue('')
onChange([...selected, option.value])
}}
className='cursor-pointer'
>
{option.label}
</CommandItem>
)
})}
</CommandGroup>
</div>
) : null}
</div>
</Command>
}}
</ComboboxValue>
<ComboboxChipsInput
id={props.id}
placeholder={props.selected.length === 0 ? placeholder : undefined}
onKeyDown={handleKeyDown}
aria-label={placeholder}
/>
</ComboboxChips>
<ComboboxContent anchor={chipsAnchorRef}>
<ComboboxList>
<ComboboxCollection>
{(item: string) => {
const isCreate = canCreate && item === trimmedInput
const label = labelMap.get(item) ?? item
return (
<ComboboxItem
key={item}
value={item}
className={isCreate ? 'text-foreground' : undefined}
>
{isCreate ? (
<>
<HugeiconsIcon
icon={Add01Icon}
strokeWidth={2}
className='text-muted-foreground'
aria-hidden='true'
/>
<span className='truncate'>
{props.createLabel
? t(props.createLabel, { value: item })
: t('Add "{{value}}"', { value: item })}
</span>
</>
) : (
<span className='truncate'>{label}</span>
)}
</ComboboxItem>
)
}}
</ComboboxCollection>
</ComboboxList>
<ComboboxEmpty>
{props.emptyText ?? t('No matching items')}
</ComboboxEmpty>
</ComboboxContent>
</Combobox>
)
}

View File

@ -74,8 +74,8 @@ export const textColorMap = {
export type StatusVariant = keyof typeof dotColorMap
const sizeMap = {
sm: 'h-6 gap-1 px-2 text-sm leading-none',
md: 'h-6 gap-1 px-2 text-sm leading-none',
sm: 'h-5 gap-1 px-2 text-xs leading-none',
md: 'h-6 gap-1 px-2 text-xs leading-none',
lg: 'h-7 gap-1.5 px-2.5 text-sm leading-none',
} as const
@ -168,7 +168,7 @@ export function StatusBadge({
title={copyable ? `Click to copy: ${copyText || label || ''}` : undefined}
{...props}
>
{Icon && <Icon className='size-3.5 shrink-0' />}
{Icon && <Icon className='size-3 shrink-0' />}
{content}
</span>
)

View File

@ -29,11 +29,14 @@ import {
CONTENT_LAYOUT_VALUES,
type ContentLayout,
DEFAULT_THEME_CUSTOMIZATION,
resolveThemeFont,
THEME_COOKIE_KEYS,
THEME_FONT_VALUES,
THEME_PRESET_VALUES,
THEME_RADIUS_VALUES,
THEME_SCALE_VALUES,
type ThemeCustomization,
type ThemeFont,
type ThemePreset,
type ThemeRadius,
type ThemeScale,
@ -65,6 +68,7 @@ type ThemeCustomizationContextType = {
defaults: ThemeCustomization
customization: ThemeCustomization
setPreset: (preset: ThemePreset) => void
setFont: (font: ThemeFont) => void
setRadius: (radius: ThemeRadius) => void
setScale: (scale: ThemeScale) => void
setContentLayout: (contentLayout: ContentLayout) => void
@ -79,6 +83,7 @@ const FALLBACK_CONTEXT: ThemeCustomizationContextType = {
defaults: DEFAULT_THEME_CUSTOMIZATION,
customization: DEFAULT_THEME_CUSTOMIZATION,
setPreset: () => {},
setFont: () => {},
setRadius: () => {},
setScale: () => {},
setContentLayout: () => {},
@ -98,6 +103,13 @@ export function ThemeCustomizationProvider(props: {
DEFAULT_THEME_CUSTOMIZATION.preset
)
)
const [font, _setFont] = useState<ThemeFont>(() =>
readCookie<ThemeFont>(
THEME_COOKIE_KEYS.font,
THEME_FONT_VALUES,
DEFAULT_THEME_CUSTOMIZATION.font
)
)
const [radius, _setRadius] = useState<ThemeRadius>(() =>
readCookie<ThemeRadius>(
THEME_COOKIE_KEYS.radius,
@ -129,6 +141,16 @@ export function ThemeCustomizationProvider(props: {
)
}, [preset])
// Font is the one axis where we resolve before writing the attribute:
// the persisted preference may be `default`, but CSS works in terms of
// the concrete `sans`/`serif` choice that should drive the cascade.
// Resolving here (instead of in CSS via `:not()` selectors) keeps the
// stylesheet to one simple `[data-theme-font='serif']` selector and lets
// future presets opt into typography via `PRESET_DEFAULT_FONT` alone.
useEffect(() => {
applyAttribute('data-theme-font', resolveThemeFont(font, preset))
}, [font, preset])
useEffect(() => {
applyAttribute(
'data-theme-radius',
@ -156,6 +178,15 @@ export function ThemeCustomizationProvider(props: {
}
}, [])
const setFont = useCallback((value: ThemeFont) => {
_setFont(value)
if (value === DEFAULT_THEME_CUSTOMIZATION.font) {
removeCookie(THEME_COOKIE_KEYS.font)
} else {
setCookie(THEME_COOKIE_KEYS.font, value, COOKIE_MAX_AGE)
}
}, [])
const setRadius = useCallback((value: ThemeRadius) => {
_setRadius(value)
if (value === DEFAULT_THEME_CUSTOMIZATION.radius) {
@ -185,16 +216,18 @@ export function ThemeCustomizationProvider(props: {
const resetCustomization = useCallback(() => {
setPreset(DEFAULT_THEME_CUSTOMIZATION.preset)
setFont(DEFAULT_THEME_CUSTOMIZATION.font)
setRadius(DEFAULT_THEME_CUSTOMIZATION.radius)
setScale(DEFAULT_THEME_CUSTOMIZATION.scale)
setContentLayout(DEFAULT_THEME_CUSTOMIZATION.contentLayout)
}, [setPreset, setRadius, setScale, setContentLayout])
}, [setPreset, setFont, setRadius, setScale, setContentLayout])
const value = useMemo<ThemeCustomizationContextType>(
() => ({
defaults: DEFAULT_THEME_CUSTOMIZATION,
customization: { preset, radius, scale, contentLayout },
customization: { preset, font, radius, scale, contentLayout },
setPreset,
setFont,
setRadius,
setScale,
setContentLayout,
@ -202,10 +235,12 @@ export function ThemeCustomizationProvider(props: {
}),
[
preset,
font,
radius,
scale,
contentLayout,
setPreset,
setFont,
setRadius,
setScale,
setContentLayout,

View File

@ -16,8 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { AxiosRequestConfig } from 'axios'
import { api } from '@/lib/api'
import { api, type ApiRequestConfig } from '@/lib/api'
import { getGroups as getUserGroups } from '@/features/users/api'
import type {
AddChannelRequest,
@ -39,11 +38,13 @@ import type {
TagOperationParams,
} from './types'
// Extended API config types
interface ExtendedApiConfig extends AxiosRequestConfig {
skipBusinessError?: boolean
disableDuplicate?: boolean
}
const channelActionConfig = (
config: ApiRequestConfig = {}
): ApiRequestConfig => ({
...config,
skipBusinessError: true,
skipErrorHandler: true,
})
export type CodexOAuthStartResponse = {
success: boolean
@ -125,7 +126,7 @@ export async function getChannel(id: number): Promise<GetChannelResponse> {
export async function createChannel(
data: AddChannelRequest
): Promise<{ success: boolean; message?: string }> {
const res = await api.post('/api/channel', data)
const res = await api.post('/api/channel', data, channelActionConfig())
return res.data
}
@ -136,7 +137,11 @@ export async function updateChannel(
id: number,
data: Partial<Channel>
): Promise<{ success: boolean; message?: string; data?: Channel }> {
const res = await api.put('/api/channel/', { id, ...data })
const res = await api.put(
'/api/channel/',
{ id, ...data },
channelActionConfig()
)
return res.data
}
@ -146,7 +151,7 @@ export async function updateChannel(
export async function deleteChannel(
id: number
): Promise<{ success: boolean; message?: string }> {
const res = await api.delete(`/api/channel/${id}`)
const res = await api.delete(`/api/channel/${id}`, channelActionConfig())
return res.data
}
@ -156,7 +161,7 @@ export async function deleteChannel(
export async function batchDeleteChannels(
data: BatchDeleteParams
): Promise<{ success: boolean; message?: string; data?: number }> {
const res = await api.post('/api/channel/batch', data)
const res = await api.post('/api/channel/batch', data, channelActionConfig())
return res.data
}
@ -166,7 +171,11 @@ export async function batchDeleteChannels(
export async function batchSetChannelTag(
data: BatchSetTagParams
): Promise<{ success: boolean; message?: string; data?: number }> {
const res = await api.post('/api/channel/batch/tag', data)
const res = await api.post(
'/api/channel/batch/tag',
data,
channelActionConfig()
)
return res.data
}
@ -181,7 +190,10 @@ export async function testChannel(
id: number,
params?: { model?: string; endpoint_type?: string; stream?: boolean }
): Promise<ChannelTestResponse> {
const res = await api.get(`/api/channel/test/${id}`, { params })
const res = await api.get(
`/api/channel/test/${id}`,
channelActionConfig({ params })
)
return res.data
}
@ -191,7 +203,10 @@ export async function testChannel(
export async function updateChannelBalance(
id: number
): Promise<ChannelBalanceResponse> {
const res = await api.get(`/api/channel/update_balance/${id}`)
const res = await api.get(
`/api/channel/update_balance/${id}`,
channelActionConfig()
)
return res.data
}
@ -201,7 +216,10 @@ export async function updateChannelBalance(
export async function fetchUpstreamModels(
id: number
): Promise<FetchModelsResponse> {
const res = await api.get(`/api/channel/fetch_models/${id}`)
const res = await api.get(
`/api/channel/fetch_models/${id}`,
channelActionConfig()
)
return res.data
}
@ -212,7 +230,11 @@ export async function copyChannel(
id: number,
params: CopyChannelParams = {}
): Promise<CopyChannelResponse> {
const res = await api.post(`/api/channel/copy/${id}`, null, { params })
const res = await api.post(
`/api/channel/copy/${id}`,
null,
channelActionConfig({ params })
)
return res.data
}
@ -224,7 +246,11 @@ export async function fixChannelAbilities(): Promise<{
message?: string
data?: { success: number; fails: number }
}> {
const res = await api.post('/api/channel/fix')
const res = await api.post(
'/api/channel/fix',
undefined,
channelActionConfig()
)
return res.data
}
@ -236,7 +262,7 @@ export async function deleteDisabledChannels(): Promise<{
message?: string
data?: number
}> {
const res = await api.delete('/api/channel/disabled')
const res = await api.delete('/api/channel/disabled', channelActionConfig())
return res.data
}
@ -248,7 +274,11 @@ export async function getChannelKey(
code?: string
): Promise<{ success: boolean; message?: string; data?: { key: string } }> {
const payload = code ? { code } : undefined
const res = await api.post(`/api/channel/${id}/key`, payload)
const res = await api.post(
`/api/channel/${id}/key`,
payload,
channelActionConfig()
)
return res.data
}
@ -257,19 +287,21 @@ export async function getChannelKey(
// ============================================================================
export async function startCodexOAuth(): Promise<CodexOAuthStartResponse> {
const config: ExtendedApiConfig = { skipBusinessError: true }
const res = await api.post('/api/channel/codex/oauth/start', {}, config)
const res = await api.post(
'/api/channel/codex/oauth/start',
{},
channelActionConfig()
)
return res.data
}
export async function completeCodexOAuth(
input: string
): Promise<CodexOAuthCompleteResponse> {
const config: ExtendedApiConfig = { skipBusinessError: true }
const res = await api.post(
'/api/channel/codex/oauth/complete',
{ input },
config
channelActionConfig()
)
return res.data
}
@ -277,11 +309,10 @@ export async function completeCodexOAuth(
export async function refreshCodexCredential(
channelId: number
): Promise<CodexCredentialRefreshResponse> {
const config: ExtendedApiConfig = { skipBusinessError: true }
const res = await api.post(
`/api/channel/${channelId}/codex/refresh`,
{},
config
channelActionConfig()
)
return res.data
}
@ -289,11 +320,10 @@ export async function refreshCodexCredential(
export async function getCodexUsage(
channelId: number
): Promise<CodexUsageResponse> {
const config: ExtendedApiConfig = {
skipBusinessError: true,
disableDuplicate: true,
}
const res = await api.get(`/api/channel/${channelId}/codex/usage`, config)
const res = await api.get(
`/api/channel/${channelId}/codex/usage`,
channelActionConfig({ disableDuplicate: true })
)
return res.data
}
@ -307,7 +337,11 @@ export async function getCodexUsage(
export async function manageMultiKeys(
params: MultiKeyManageParams
): Promise<MultiKeyStatusResponse | { success: boolean; message?: string }> {
const res = await api.post('/api/channel/multi_key/manage', params)
const res = await api.post(
'/api/channel/multi_key/manage',
params,
channelActionConfig()
)
return res.data
}
@ -417,7 +451,11 @@ export async function deleteDisabledMultiKeys(
export async function enableTagChannels(
tag: string
): Promise<{ success: boolean; message?: string }> {
const res = await api.post('/api/channel/tag/enabled', { tag })
const res = await api.post(
'/api/channel/tag/enabled',
{ tag },
channelActionConfig()
)
return res.data
}
@ -427,7 +465,11 @@ export async function enableTagChannels(
export async function disableTagChannels(
tag: string
): Promise<{ success: boolean; message?: string }> {
const res = await api.post('/api/channel/tag/disabled', { tag })
const res = await api.post(
'/api/channel/tag/disabled',
{ tag },
channelActionConfig()
)
return res.data
}
@ -437,7 +479,7 @@ export async function disableTagChannels(
export async function editTagChannels(
params: TagOperationParams
): Promise<{ success: boolean; message?: string }> {
const res = await api.put('/api/channel/tag', params)
const res = await api.put('/api/channel/tag', params, channelActionConfig())
return res.data
}
@ -463,7 +505,11 @@ export async function fetchModels(data: {
type: number
key: string
}): Promise<FetchModelsResponse> {
const res = await api.post('/api/channel/fetch_models', data)
const res = await api.post(
'/api/channel/fetch_models',
data,
channelActionConfig()
)
return res.data
}
@ -474,7 +520,10 @@ export async function deleteOllamaModel(params: {
channel_id: number
model_name: string
}): Promise<{ success: boolean; message?: string }> {
const res = await api.delete('/api/channel/ollama/delete', { data: params })
const res = await api.delete(
'/api/channel/ollama/delete',
channelActionConfig({ data: params })
)
return res.data
}
@ -485,7 +534,7 @@ export async function testAllChannels(): Promise<{
success: boolean
message?: string
}> {
const res = await api.get('/api/channel/test')
const res = await api.get('/api/channel/test', channelActionConfig())
return res.data
}
@ -496,7 +545,10 @@ export async function updateAllChannelsBalance(): Promise<{
success: boolean
message?: string
}> {
const res = await api.get('/api/channel/update_balance')
const res = await api.get(
'/api/channel/update_balance',
channelActionConfig()
)
return res.data
}

View File

@ -135,6 +135,8 @@ export function MultiKeyManageDialog({
setEnabledCount(response.data.enabled_count || 0)
setManualDisabledCount(response.data.manual_disabled_count || 0)
setAutoDisabledCount(response.data.auto_disabled_count || 0)
} else {
toast.error(response.message || t('Failed to load key status'))
}
} catch (error: unknown) {
toast.error(

View File

@ -211,14 +211,22 @@ export function OllamaModelsDialog({
? Array.from(new Set(selected))
: Array.from(new Set([...existingModels, ...selected]))
const res = await updateChannel(currentRow.id, { models: next.join(',') })
if (res.success) {
toast.success(
mode === 'replace'
? t('Models updated successfully')
: t('Models appended successfully')
try {
const res = await updateChannel(currentRow.id, { models: next.join(',') })
if (res.success) {
toast.success(
mode === 'replace'
? t('Models updated successfully')
: t('Models appended successfully')
)
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
} else {
toast.error(res.message || t('Failed to update models'))
}
} catch (err: unknown) {
toast.error(
err instanceof Error ? err.message : t('Failed to update models')
)
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
}
}

View File

@ -0,0 +1,78 @@
/*
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 { ReactNode } from 'react'
import { ChevronDown, Settings } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
type ChannelAdvancedSectionProps = {
children: ReactNode
open: boolean
onOpenChange: (open: boolean) => void
}
export function ChannelAdvancedSection(props: ChannelAdvancedSectionProps) {
const { t } = useTranslation()
return (
<Collapsible open={props.open} onOpenChange={props.onOpenChange}>
<CollapsibleTrigger
render={
<button
type='button'
className='hover:bg-muted/40 border-border/60 flex w-full items-center justify-between rounded-lg border px-3 py-3 text-left transition-colors'
aria-expanded={props.open}
/>
}
>
<div className='flex items-start gap-3'>
<span className='bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-md'>
<Settings className='h-4 w-4' aria-hidden='true' />
</span>
<div className='flex flex-col gap-0.5'>
<div className='text-[13px] font-semibold'>
{t('Advanced Settings')}
</div>
<div className='text-muted-foreground text-xs'>
{t(
'Request overrides, routing behavior, and upstream model automation'
)}
</div>
</div>
</div>
<ChevronDown
className={cn(
'text-muted-foreground h-4 w-4 shrink-0 transition-transform',
props.open && 'rotate-180'
)}
aria-hidden='true'
/>
</CollapsibleTrigger>
<CollapsibleContent className='mt-5 flex flex-col gap-5'>
{props.children}
</CollapsibleContent>
</Collapsible>
)
}

View File

@ -0,0 +1,46 @@
/*
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 { ReactNode } from 'react'
import { Link2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import {
SideDrawerSection,
SideDrawerSectionHeader,
} from '@/components/drawer-layout'
type ChannelApiAccessSectionProps = {
children: ReactNode
}
export function ChannelApiAccessSection(props: ChannelApiAccessSectionProps) {
const { t } = useTranslation()
return (
<SideDrawerSection>
<SideDrawerSectionHeader
title={t('API Access')}
description={t(
'Endpoint, provider-specific settings, and credentials.'
)}
icon={<Link2 className='h-4 w-4' aria-hidden='true' />}
/>
{props.children}
</SideDrawerSection>
)
}

View File

@ -0,0 +1,44 @@
/*
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 { ReactNode } from 'react'
import { KeyRound } from 'lucide-react'
import { useTranslation } from 'react-i18next'
type ChannelAuthSectionProps = {
children: ReactNode
}
export function ChannelAuthSection(props: ChannelAuthSectionProps) {
const { t } = useTranslation()
return (
<div className='border-border/60 flex flex-col gap-4 border-t pt-4'>
<div className='flex items-center gap-2'>
<KeyRound
className='text-muted-foreground h-3.5 w-3.5'
aria-hidden='true'
/>
<h4 className='text-muted-foreground text-xs font-medium tracking-wide uppercase'>
{t('Authentication')}
</h4>
</div>
{props.children}
</div>
)
}

View File

@ -0,0 +1,44 @@
/*
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 { ReactNode } from 'react'
import { Server } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import {
SideDrawerSection,
SideDrawerSectionHeader,
} from '@/components/drawer-layout'
type ChannelBasicSectionProps = {
children: ReactNode
}
export function ChannelBasicSection(props: ChannelBasicSectionProps) {
const { t } = useTranslation()
return (
<SideDrawerSection>
<SideDrawerSectionHeader
title={t('Basic Information')}
description={t('Name, provider type, and availability.')}
icon={<Server className='h-4 w-4' aria-hidden='true' />}
/>
{props.children}
</SideDrawerSection>
)
}

View File

@ -0,0 +1,44 @@
/*
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 { Skeleton } from '@/components/ui/skeleton'
export function ChannelEditorLoadingState() {
const { t } = useTranslation()
return (
<div
className='border-border/60 flex flex-col gap-4 rounded-lg border p-4'
aria-live='polite'
>
<div>
<p className='text-sm font-medium'>{t('Loading channel details')}</p>
<p className='text-muted-foreground mt-1 text-xs'>
{t('Please wait before editing to avoid overwriting saved values.')}
</p>
</div>
<div className='grid gap-4 sm:grid-cols-2'>
<Skeleton className='h-10 w-full' />
<Skeleton className='h-10 w-full' />
</div>
<Skeleton className='h-24 w-full' />
<Skeleton className='h-32 w-full' />
</div>
)
}

View File

@ -0,0 +1,44 @@
/*
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 { ReactNode } from 'react'
import { Boxes } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import {
SideDrawerSection,
SideDrawerSectionHeader,
} from '@/components/drawer-layout'
type ChannelModelsSectionProps = {
children: ReactNode
}
export function ChannelModelsSection(props: ChannelModelsSectionProps) {
const { t } = useTranslation()
return (
<SideDrawerSection>
<SideDrawerSectionHeader
title={t('Models & Groups')}
description={t('Published models, groups, and model remapping rules.')}
icon={<Boxes className='h-4 w-4' aria-hidden='true' />}
/>
{props.children}
</SideDrawerSection>
)
}

View File

@ -0,0 +1,24 @@
/*
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
*/
export * from './channel-advanced-section'
export * from './channel-api-access-section'
export * from './channel-auth-section'
export * from './channel-basic-section'
export * from './channel-editor-loading-state'
export * from './channel-models-section'

View File

@ -16,18 +16,22 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState, useEffect, useRef } from 'react'
import { Code, Table, Plus, Trash2 } from 'lucide-react'
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import { Code, Plus, Table, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
type ModelMappingEditorProps = {
value: string
onChange: (value: string) => void
disabled?: boolean
sourceModelOptions?: string[]
targetModelOptions?: string[]
}
type MappingRow = {
@ -36,30 +40,59 @@ type MappingRow = {
to: string
}
export function ModelMappingEditor({
value,
onChange,
disabled = false,
}: ModelMappingEditorProps) {
const DUPLICATE_MAPPING_SENTINEL = '{ "duplicate_source_models": '
function getDuplicateSources(rows: MappingRow[]): string[] {
const seen = new Set<string>()
const duplicates = new Set<string>()
for (const row of rows) {
const source = row.from.trim()
if (!source) continue
if (seen.has(source)) {
duplicates.add(source)
} else {
seen.add(source)
}
}
return Array.from(duplicates)
}
export function ModelMappingEditor(props: ModelMappingEditorProps) {
const { t } = useTranslation()
const sourceListId = useId()
const targetListId = useId()
const [mode, setMode] = useState<'visual' | 'json'>('visual')
const [rows, setRows] = useState<MappingRow[]>([])
const [jsonValue, setJsonValue] = useState(value)
const [jsonValue, setJsonValue] = useState(props.value)
const [jsonError, setJsonError] = useState<string | null>(null)
const nextRowIdRef = useRef(0)
const duplicateSources = useMemo(() => getDuplicateSources(rows), [rows])
const createRowId = () => {
nextRowIdRef.current += 1
return `mapping-${nextRowIdRef.current}`
}
const parseJsonToRows = (json: string) => {
const parseJsonToRows = (json: string): boolean => {
try {
if (!json.trim()) {
setRows([])
return
setJsonError(null)
return true
}
const parsed = JSON.parse(json)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
setJsonError(t('Model mapping must be a valid JSON object'))
return false
}
const entries = Object.entries(parsed)
const invalidValue = entries.find(([, to]) => typeof to !== 'string')
if (invalidValue) {
setJsonError(t('Model mapping values must be strings'))
return false
}
setRows((previousRows) => {
const remainingRows = [...previousRows]
return entries.map(([from, to], index) => {
@ -85,17 +118,20 @@ export function ModelMappingEditor({
}
})
})
setJsonError(null)
return true
} catch (_error) {
// Invalid JSON, keep current rows
setJsonError(t('Model mapping must be valid JSON format'))
return false
}
}
// Parse JSON to rows when value changes externally
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setJsonValue(value)
parseJsonToRows(value)
}, [value])
setJsonValue(props.value)
parseJsonToRows(props.value)
}, [props.value])
const convertRowsToJson = (updatedRows: MappingRow[]): string => {
if (updatedRows.length === 0) {
@ -110,22 +146,33 @@ export function ModelMappingEditor({
return JSON.stringify(obj, null, 2)
}
const syncRows = (updatedRows: MappingRow[]) => {
setRows(updatedRows)
const duplicates = getDuplicateSources(updatedRows)
if (duplicates.length > 0) {
setJsonError(t('Duplicate source model mappings are not allowed'))
setJsonValue(DUPLICATE_MAPPING_SENTINEL)
props.onChange(DUPLICATE_MAPPING_SENTINEL)
return
}
const json = convertRowsToJson(updatedRows)
setJsonError(null)
setJsonValue(json)
props.onChange(json)
}
const handleAddRow = () => {
const newRow: MappingRow = {
id: createRowId(),
from: '',
to: '',
}
const updatedRows = [...rows, newRow]
setRows(updatedRows)
syncRows([...rows, newRow])
}
const handleDeleteRow = (id: string) => {
const updatedRows = rows.filter((row) => row.id !== id)
setRows(updatedRows)
const json = convertRowsToJson(updatedRows)
setJsonValue(json)
onChange(json)
syncRows(rows.filter((row) => row.id !== id))
}
const handleRowChange = (
@ -136,15 +183,12 @@ export function ModelMappingEditor({
const updatedRows = rows.map((row) =>
row.id === id ? { ...row, [field]: newValue } : row
)
setRows(updatedRows)
const json = convertRowsToJson(updatedRows)
setJsonValue(json)
onChange(json)
syncRows(updatedRows)
}
const handleJsonChange = (newJson: string) => {
setJsonValue(newJson)
onChange(newJson)
props.onChange(newJson)
parseJsonToRows(newJson)
}
@ -155,62 +199,69 @@ export function ModelMappingEditor({
2
)
setJsonValue(template)
onChange(template)
props.onChange(template)
parseJsonToRows(template)
}
const toggleMode = () => {
if (mode === 'visual') {
// Switching to JSON mode: sync rows to JSON
const json = convertRowsToJson(rows)
setJsonValue(json)
onChange(json)
const handleModeChange = (nextMode: string) => {
if (nextMode !== 'visual' && nextMode !== 'json') return
if (nextMode === 'json') {
const duplicates = getDuplicateSources(rows)
if (duplicates.length === 0) {
const json = convertRowsToJson(rows)
setJsonValue(json)
props.onChange(json)
}
setMode('json')
} else {
// Switching to visual mode: sync JSON to rows
parseJsonToRows(jsonValue)
setMode('visual')
return
}
parseJsonToRows(jsonValue)
setMode('visual')
}
return (
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<div className='flex gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={toggleMode}
disabled={disabled}
>
{mode === 'visual' ? (
<>
<Code className='mr-2 h-4 w-4' />
{t('JSON Mode')}
</>
) : (
<>
<Table className='mr-2 h-4 w-4' />
{t('Visual Mode')}
</>
)}
</Button>
<Tabs value={mode} onValueChange={handleModeChange} className='space-y-2'>
<div className='flex items-center justify-between gap-3'>
<TabsList>
<TabsTrigger value='visual'>
<Table className='h-4 w-4' aria-hidden='true' />
{t('Visual')}
</TabsTrigger>
<TabsTrigger value='json'>
<Code className='h-4 w-4' aria-hidden='true' />
{t('JSON')}
</TabsTrigger>
</TabsList>
<Button
type='button'
variant='link'
size='sm'
className='h-auto p-0'
onClick={handleFillTemplate}
disabled={disabled}
disabled={props.disabled}
>
{t('Fill Template')}
</Button>
</div>
</div>
{mode === 'visual' ? (
<div className='space-y-2'>
{jsonError && (
<Alert variant='destructive'>
<AlertDescription>{jsonError}</AlertDescription>
</Alert>
)}
{duplicateSources.length > 0 && (
<Alert>
<AlertDescription>
{t('Duplicate source model(s): {{models}}', {
models: duplicateSources.join(', '),
})}
</AlertDescription>
</Alert>
)}
<TabsContent value='visual' className='space-y-2'>
{rows.length > 0 ? (
<div className='space-y-2'>
<div className='grid grid-cols-[1fr_1fr_auto] gap-2 text-sm font-medium'>
@ -229,7 +280,8 @@ export function ModelMappingEditor({
handleRowChange(row.id, 'from', e.target.value)
}
placeholder='gpt-3.5-turbo'
disabled={disabled}
disabled={props.disabled}
list={sourceListId}
/>
<Input
value={row.to}
@ -237,17 +289,19 @@ export function ModelMappingEditor({
handleRowChange(row.id, 'to', e.target.value)
}
placeholder='gpt-3.5-turbo-0125'
disabled={disabled}
disabled={props.disabled}
list={targetListId}
/>
<Button
type='button'
variant='ghost'
size='icon'
onClick={() => handleDeleteRow(row.id)}
disabled={disabled}
disabled={props.disabled}
className='h-10 w-10'
aria-label={t('Delete mapping')}
>
<Trash2 className='h-4 w-4' />
<Trash2 className='h-4 w-4' aria-hidden='true' />
</Button>
</div>
))}
@ -264,22 +318,42 @@ export function ModelMappingEditor({
variant='outline'
size='sm'
onClick={handleAddRow}
disabled={disabled}
disabled={props.disabled}
className='w-full'
>
<Plus className='mr-2 h-4 w-4' />
{t('Add Mapping')}
</Button>
</div>
) : (
<Textarea
value={jsonValue}
onChange={(e) => handleJsonChange(e.target.value)}
placeholder={t('{"original-model": "replacement-model"}')}
disabled={disabled}
rows={8}
className={cn('font-mono text-sm')}
/>
</TabsContent>
<TabsContent value='json'>
<Textarea
value={jsonValue}
onChange={(e) => handleJsonChange(e.target.value)}
placeholder={t('{"original-model": "replacement-model"}')}
disabled={props.disabled}
rows={8}
className={cn(
'font-mono text-sm',
jsonError && 'border-destructive'
)}
aria-invalid={Boolean(jsonError)}
/>
</TabsContent>
</Tabs>
{props.sourceModelOptions && props.sourceModelOptions.length > 0 && (
<datalist id={sourceListId}>
{props.sourceModelOptions.map((model) => (
<option key={model} value={model} />
))}
</datalist>
)}
{props.targetModelOptions && props.targetModelOptions.length > 0 && (
<datalist id={targetListId}>
{props.targetModelOptions.map((model) => (
<option key={model} value={model} />
))}
</datalist>
)}
</div>
)

View File

@ -0,0 +1,106 @@
/*
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 { useMutation } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { createChannel, updateChannel } from '../api'
import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
import {
transformFormDataToCreatePayload,
transformFormDataToUpdatePayload,
type ChannelFormValues,
} from '../lib'
import type { Channel } from '../types'
type UseChannelMutateFormParams = {
currentRow?: Channel | null
isEditing: boolean
isMultiKeyChannel: boolean
onSuccess: () => void
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function getErrorMessage(error: unknown): string | undefined {
if (error instanceof Error && typeof error.message === 'string') {
return error.message
}
if (!isRecord(error)) return undefined
const response = error.response
if (isRecord(response)) {
const data = response.data
if (isRecord(data)) {
const message = data.message
if (typeof message === 'string') return message
}
}
const message = error.message
if (typeof message === 'string') return message
return undefined
}
export function useChannelMutateForm(props: UseChannelMutateFormParams) {
const { t } = useTranslation()
return useMutation({
mutationFn: async (data: ChannelFormValues): Promise<string> => {
if (props.isEditing && props.currentRow) {
const payload = transformFormDataToUpdatePayload(
data,
props.currentRow.id
)
const payloadWithKeyMode =
props.isMultiKeyChannel && data.key_mode
? {
...payload,
key_mode: data.key_mode,
}
: payload
const response = await updateChannel(
props.currentRow.id,
payloadWithKeyMode
)
if (!response.success) {
throw new Error(response.message || t(ERROR_MESSAGES.UPDATE_FAILED))
}
return SUCCESS_MESSAGES.UPDATED
}
const payload = transformFormDataToCreatePayload(data)
const response = await createChannel(payload)
if (!response.success) {
throw new Error(response.message || t(ERROR_MESSAGES.CREATE_FAILED))
}
return SUCCESS_MESSAGES.CREATED
},
onSuccess: (messageKey) => {
toast.success(t(messageKey))
props.onSuccess()
},
onError: (error: unknown) => {
toast.error(getErrorMessage(error) || t(ERROR_MESSAGES.CREATE_FAILED))
},
})
}

View File

@ -19,9 +19,14 @@ For commercial licensing, please contact support@quantumnous.com
import { useRef, useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { api } from '@/lib/api'
import { api, type ApiRequestConfig } from '@/lib/api'
import { normalizeModelList } from '../lib/upstream-update-utils'
const upstreamUpdateRequestConfig = {
skipBusinessError: true,
skipErrorHandler: true,
} satisfies ApiRequestConfig
function getManualIgnoredModelCount(settings: unknown): number {
let parsed: Record<string, unknown> | null = null
if (settings && typeof settings === 'object')
@ -117,7 +122,7 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
ignore_models: ignoreModels,
remove_models: normalizeModelList(selectedRemove),
},
{ skipErrorHandler: true } as Record<string, unknown>
upstreamUpdateRequestConfig
)
const { success, message, data } = res.data || {}
if (!success) {
@ -162,7 +167,7 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
const res = await api.post(
'/api/channel/upstream_updates/apply_all',
{},
{ skipErrorHandler: true } as Record<string, unknown>
upstreamUpdateRequestConfig
)
const { success, message, data } = res.data || {}
if (!success) {
@ -206,7 +211,7 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
const res = await api.post(
'/api/channel/upstream_updates/detect',
{ id: ch.id },
{ skipErrorHandler: true } as Record<string, unknown>
upstreamUpdateRequestConfig
)
const { success, message, data } = res.data || {}
if (!success) {
@ -244,7 +249,7 @@ export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
const res = await api.post(
'/api/channel/upstream_updates/detect_all',
{},
{ skipErrorHandler: true } as Record<string, unknown>
upstreamUpdateRequestConfig
)
const { success, message, data } = res.data || {}
if (!success) {

View File

@ -70,6 +70,8 @@ export async function handleEnableChannel(
toast.success(i18next.t(SUCCESS_MESSAGES.ENABLED))
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
} else {
toast.error(response.message || i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
} catch (_error) {
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
@ -92,6 +94,8 @@ export async function handleDisableChannel(
toast.success(i18next.t(SUCCESS_MESSAGES.DISABLED))
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
} else {
toast.error(response.message || i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
} catch (_error) {
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
@ -128,6 +132,8 @@ export async function handleDeleteChannel(
toast.success(i18next.t(SUCCESS_MESSAGES.DELETED))
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
} else {
toast.error(response.message || i18next.t(ERROR_MESSAGES.DELETE_FAILED))
}
} catch (_error) {
toast.error(i18next.t(ERROR_MESSAGES.DELETE_FAILED))
@ -338,6 +344,8 @@ export async function handleBatchDelete(
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.(response.data || ids.length)
} else {
toast.error(response.message || i18next.t(ERROR_MESSAGES.DELETE_FAILED))
}
} catch (_error) {
toast.error(i18next.t(ERROR_MESSAGES.DELETE_FAILED))
@ -364,8 +372,10 @@ export async function handleBatchEnable(
)
const results = await Promise.allSettled(promises)
const successCount = results.filter((r) => r.status === 'fulfilled').length
const failCount = results.filter((r) => r.status === 'rejected').length
const successCount = results.filter(
(r) => r.status === 'fulfilled' && r.value.success
).length
const failCount = results.length - successCount
if (successCount > 0) {
toast.success(
@ -405,8 +415,10 @@ export async function handleBatchDisable(
)
const results = await Promise.allSettled(promises)
const successCount = results.filter((r) => r.status === 'fulfilled').length
const failCount = results.filter((r) => r.status === 'rejected').length
const successCount = results.filter(
(r) => r.status === 'fulfilled' && r.value.success
).length
const failCount = results.length - successCount
if (successCount > 0) {
toast.success(
@ -448,6 +460,8 @@ export async function handleBatchSetTag(
toast.success(i18next.t(SUCCESS_MESSAGES.TAG_SET))
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
} else {
toast.error(response.message || i18next.t('Failed to set tag'))
}
} catch (_error) {
toast.error(i18next.t('Failed to set tag'))
@ -474,6 +488,10 @@ export async function handleEnableTagChannels(
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
} else {
toast.error(
response.message || i18next.t('Failed to enable tag channels')
)
}
} catch (_error) {
toast.error(i18next.t('Failed to enable tag channels'))
@ -496,6 +514,10 @@ export async function handleDisableTagChannels(
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
} else {
toast.error(
response.message || i18next.t('Failed to disable tag channels')
)
}
} catch (_error) {
toast.error(i18next.t('Failed to disable tag channels'))
@ -523,6 +545,10 @@ export async function handleDeleteAllDisabled(
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.(response.data || 0)
} else {
toast.error(
response.message || i18next.t('Failed to delete disabled channels')
)
}
} catch (_error) {
toast.error(i18next.t('Failed to delete disabled channels'))
@ -547,6 +573,8 @@ export async function handleFixAbilities(
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.(response.data)
} else {
toast.error(response.message || i18next.t('Failed to fix abilities'))
}
} catch (_error) {
toast.error(i18next.t('Failed to fix abilities'))

View File

@ -17,68 +17,249 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { z } from 'zod'
import { CHANNEL_STATUS, MODEL_FETCHABLE_TYPES } from '../constants'
import {
CHANNEL_STATUS,
ERROR_MESSAGES,
MODEL_FETCHABLE_TYPES,
} from '../constants'
import type { Channel } from '../types'
// ============================================================================
// Form Validation Schema
// ============================================================================
export const channelFormSchema = z.object({
name: z.string().min(1, 'Channel name is required'),
type: z.number().min(0, 'Channel type is required'),
base_url: z.string().optional(),
key: z.string(),
openai_organization: z.string().optional(),
models: z.string().min(1, 'At least one model is required'),
group: z.array(z.string()).min(1, 'At least one group is required'),
model_mapping: z.string().optional(),
priority: z.number().optional(),
weight: z.number().optional(),
test_model: z.string().optional(),
auto_ban: z.number().optional(),
status: z.number(),
status_code_mapping: z.string().optional(),
tag: z.string().optional(),
remark: z
.string()
.max(255, 'Remark must be less than 255 characters')
.optional(),
setting: z.string().optional(),
param_override: z.string().optional(),
header_override: z.string().optional(),
settings: z.string().optional(),
other: z.string().optional(),
// Multi-key options (not sent to backend directly)
multi_key_mode: z.enum(['single', 'batch', 'multi_to_single']).optional(),
multi_key_type: z.enum(['random', 'polling']).optional(),
batch_add_set_key_prefix_2_name: z.boolean().optional(),
key_mode: z.enum(['append', 'replace']).optional(), // For editing multi-key channels
// Channel extra settings (stored in setting JSON, not sent directly)
force_format: z.boolean().optional(),
thinking_to_content: z.boolean().optional(),
proxy: z.string().optional(),
pass_through_body_enabled: z.boolean().optional(),
system_prompt: z.string().optional(),
system_prompt_override: z.boolean().optional(),
// Type-specific settings (stored in settings JSON)
is_enterprise_account: z.boolean().optional(), // OpenRouter specific
vertex_key_type: z.enum(['json', 'api_key']).optional(), // Vertex AI specific
aws_key_type: z.enum(['ak_sk', 'api_key']).optional(), // AWS specific
azure_responses_version: z.string().optional(), // Azure specific
// Field passthrough controls (stored in settings JSON)
allow_service_tier: z.boolean().optional(), // OpenAI/Anthropic
disable_store: z.boolean().optional(), // OpenAI only
allow_safety_identifier: z.boolean().optional(), // OpenAI only
allow_include_obfuscation: z.boolean().optional(), // OpenAI: include usage obfuscation
allow_inference_geo: z.boolean().optional(), // OpenAI/Anthropic: inference geography
allow_speed: z.boolean().optional(), // Anthropic: speed mode control
claude_beta_query: z.boolean().optional(), // Anthropic: beta query passthrough
// Upstream model update settings (stored in settings JSON)
upstream_model_update_check_enabled: z.boolean().optional(),
upstream_model_update_auto_sync_enabled: z.boolean().optional(),
upstream_model_update_ignored_models: z.string().optional(),
})
function parseOptionalJson(value: string | undefined): unknown {
if (!value?.trim()) return undefined
return JSON.parse(value)
}
function isJsonObjectValue(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isOptionalJsonObject(value: string | undefined): boolean {
try {
const parsed = parseOptionalJson(value)
return parsed === undefined || isJsonObjectValue(parsed)
} catch {
return false
}
}
function isOptionalModelMapping(value: string | undefined): boolean {
try {
const parsed = parseOptionalJson(value)
if (parsed === undefined) return true
if (!isJsonObjectValue(parsed)) return false
return Object.values(parsed).every((item) => typeof item === 'string')
} catch {
return false
}
}
function isOptionalStatusCodeMapping(value: string | undefined): boolean {
try {
const parsed = parseOptionalJson(value)
if (parsed === undefined) return true
if (!isJsonObjectValue(parsed)) return false
return Object.entries(parsed).every(([from, to]) => {
const fromCode = Number(from)
const toCode = Number(to)
return (
Number.isInteger(fromCode) &&
Number.isInteger(toCode) &&
fromCode >= 100 &&
fromCode <= 599 &&
toCode >= 100 &&
toCode <= 599
)
})
} catch {
return false
}
}
function isCodexCredential(value: string | undefined): boolean {
try {
const parsed = parseOptionalJson(value)
if (parsed === undefined) return true
return (
isJsonObjectValue(parsed) &&
typeof parsed.access_token === 'string' &&
parsed.access_token.trim().length > 0 &&
typeof parsed.account_id === 'string' &&
parsed.account_id.trim().length > 0
)
} catch {
return false
}
}
function isVertexJsonKey(value: string | undefined): boolean {
try {
const parsed = parseOptionalJson(value)
if (parsed === undefined) return true
if (Array.isArray(parsed)) {
return parsed.every((item) => isJsonObjectValue(item))
}
return isJsonObjectValue(parsed)
} catch {
return false
}
}
function addRequiredIssue(
ctx: z.RefinementCtx,
path: string,
message: string
): void {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [path],
message,
})
}
export const channelFormSchema = z
.object({
name: z.string().min(1, ERROR_MESSAGES.REQUIRED_NAME),
type: z.number().min(0, ERROR_MESSAGES.REQUIRED_TYPE),
base_url: z.string().optional(),
key: z.string(),
openai_organization: z.string().optional(),
models: z.string().min(1, ERROR_MESSAGES.REQUIRED_MODELS),
group: z.array(z.string()).min(1, ERROR_MESSAGES.REQUIRED_GROUP),
model_mapping: z
.string()
.optional()
.refine(
isOptionalModelMapping,
'Model mapping must be a JSON object with string values'
),
priority: z.number().optional(),
weight: z.number().optional(),
test_model: z.string().optional(),
auto_ban: z.number().optional(),
status: z.number(),
status_code_mapping: z
.string()
.optional()
.refine(
isOptionalStatusCodeMapping,
'Status code mapping must use valid HTTP status codes'
),
tag: z.string().optional(),
remark: z
.string()
.max(255, 'Remark must be less than 255 characters')
.optional(),
setting: z
.string()
.optional()
.refine(isOptionalJsonObject, ERROR_MESSAGES.INVALID_JSON),
param_override: z
.string()
.optional()
.refine(isOptionalJsonObject, ERROR_MESSAGES.INVALID_JSON),
header_override: z
.string()
.optional()
.refine(isOptionalJsonObject, ERROR_MESSAGES.INVALID_JSON),
settings: z
.string()
.optional()
.refine(isOptionalJsonObject, ERROR_MESSAGES.INVALID_JSON),
other: z.string().optional(),
// Multi-key options (not sent to backend directly)
multi_key_mode: z.enum(['single', 'batch', 'multi_to_single']).optional(),
multi_key_type: z.enum(['random', 'polling']).optional(),
batch_add_set_key_prefix_2_name: z.boolean().optional(),
key_mode: z.enum(['append', 'replace']).optional(), // For editing multi-key channels
// Channel extra settings (stored in setting JSON, not sent directly)
force_format: z.boolean().optional(),
thinking_to_content: z.boolean().optional(),
proxy: z.string().optional(),
pass_through_body_enabled: z.boolean().optional(),
system_prompt: z.string().optional(),
system_prompt_override: z.boolean().optional(),
// Type-specific settings (stored in settings JSON)
is_enterprise_account: z.boolean().optional(), // OpenRouter specific
vertex_key_type: z.enum(['json', 'api_key']).optional(), // Vertex AI specific
aws_key_type: z.enum(['ak_sk', 'api_key']).optional(), // AWS specific
azure_responses_version: z.string().optional(), // Azure specific
// Field passthrough controls (stored in settings JSON)
allow_service_tier: z.boolean().optional(), // OpenAI/Anthropic
disable_store: z.boolean().optional(), // OpenAI only
allow_safety_identifier: z.boolean().optional(), // OpenAI only
allow_include_obfuscation: z.boolean().optional(), // OpenAI: include usage obfuscation
allow_inference_geo: z.boolean().optional(), // OpenAI/Anthropic: inference geography
allow_speed: z.boolean().optional(), // Anthropic: speed mode control
claude_beta_query: z.boolean().optional(), // Anthropic: beta query passthrough
// Upstream model update settings (stored in settings JSON)
upstream_model_update_check_enabled: z.boolean().optional(),
upstream_model_update_auto_sync_enabled: z.boolean().optional(),
upstream_model_update_ignored_models: z.string().optional(),
})
.superRefine((data, ctx) => {
if ([3, 8, 36, 45].includes(data.type) && !data.base_url?.trim()) {
addRequiredIssue(
ctx,
'base_url',
'Base URL is required for this channel type'
)
}
if ([3, 18, 21, 39, 41, 49].includes(data.type) && !data.other?.trim()) {
addRequiredIssue(
ctx,
'other',
'This channel type requires additional configuration'
)
}
if (data.type === 57) {
if (data.multi_key_mode && data.multi_key_mode !== 'single') {
addRequiredIssue(
ctx,
'multi_key_mode',
'Codex channels do not support batch creation'
)
}
if (data.key?.trim() && !isCodexCredential(data.key)) {
addRequiredIssue(
ctx,
'key',
'Codex credential must be a JSON object with access_token and account_id'
)
}
}
if (
data.type === 41 &&
data.vertex_key_type === 'json' &&
data.key?.trim() &&
!isVertexJsonKey(data.key)
) {
addRequiredIssue(
ctx,
'key',
'Vertex AI service account key must be valid JSON'
)
}
if (
data.type === 41 &&
data.vertex_key_type === 'api_key' &&
data.multi_key_mode &&
data.multi_key_mode !== 'single'
) {
addRequiredIssue(
ctx,
'multi_key_mode',
'Vertex AI API Key mode does not support batch creation'
)
}
})
export type ChannelFormValues = z.infer<typeof channelFormSchema>
@ -389,6 +570,12 @@ function buildSettingsJSON(formData: ChannelFormValues): string {
return JSON.stringify(settingsObj)
}
function normalizeBaseUrl(value: string | undefined): string {
return String(value || '')
.trim()
.replace(/\/+$/, '')
}
/**
* Transform form data to API payload for creating channel
*/
@ -403,7 +590,7 @@ export function transformFormDataToCreatePayload(formData: ChannelFormValues): {
const channel: Partial<Channel> = {
name: formData.name,
type: formData.type,
base_url: formData.base_url || null,
base_url: normalizeBaseUrl(formData.base_url) || null,
key: formData.key,
openai_organization: formData.openai_organization || null,
models: formData.models,
@ -452,7 +639,7 @@ export function transformFormDataToUpdatePayload(
id: channelId,
name: formData.name,
type: formData.type,
base_url: formData.base_url || null,
base_url: normalizeBaseUrl(formData.base_url) || null,
openai_organization: formData.openai_organization || null,
models: formData.models,
group: formatGroups(formData.group),
@ -485,7 +672,7 @@ export function transformFormDataToUpdatePayload(
})
// Send explicit empty strings for nullable fields so GORM updates can clear them.
payload.base_url = formData.base_url || ''
payload.base_url = normalizeBaseUrl(formData.base_url) || ''
payload.openai_organization = formData.openai_organization || ''
payload.test_model = formData.test_model || ''
payload.tag = formData.tag || ''

View File

@ -179,6 +179,12 @@ export function validateModelMappingJson(modelMapping: string): {
error: 'Model mapping must be a valid JSON object',
}
}
if (Object.values(parsed).some((value) => typeof value !== 'string')) {
return {
valid: false,
error: 'Model mapping values must be strings',
}
}
return { valid: true }
} catch {
return {

View File

@ -163,7 +163,11 @@ const API_DEMOS: ApiDemoConfig[] = [
const CYCLE_INTERVAL = 4500
const TRANSITION_MS = 220
export function HeroTerminalDemo() {
interface HeroTerminalDemoProps {
className?: string
}
export function HeroTerminalDemo(props: HeroTerminalDemoProps) {
const [activeIndex, setActiveIndex] = useState(0)
const [transitioning, setTransitioning] = useState(false)
const intervalRef = useRef<ReturnType<typeof setInterval>>(undefined)
@ -202,7 +206,7 @@ export function HeroTerminalDemo() {
const accent = ACCENT_CLASSES[demo.accent]
return (
<div className='mx-auto mt-16 w-full max-w-2xl'>
<div className={cn('mx-auto w-full max-w-2xl', props.className)}>
<div
className={cn(
'overflow-hidden rounded-2xl border backdrop-blur-sm',

View File

@ -17,8 +17,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Link } from '@tanstack/react-router'
import { ArrowRight } from 'lucide-react'
import { CherryStudio } from '@lobehub/icons'
import { ArrowRight, BookOpen } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useStatus } from '@/hooks/use-status'
import { Button } from '@/components/ui/button'
import { HeroTerminalDemo } from '../hero-terminal-demo'
@ -27,11 +29,56 @@ interface HeroProps {
isAuthenticated?: boolean
}
// Stylized three-dots indicator representing "More"
const MoreIcon = () => (
<svg
className='text-muted-foreground/60 group-hover:text-foreground size-6 shrink-0 transition-colors'
viewBox='0 0 24 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<circle cx='6' cy='12' r='2' fill='currentColor' />
<circle cx='12' cy='12' r='2' fill='currentColor' />
<circle cx='18' cy='12' r='2' fill='currentColor' />
</svg>
)
export function Hero(props: HeroProps) {
const { t } = useTranslation()
const { status } = useStatus()
const docsUrl =
(status?.docs_link as string | undefined) || 'https://docs.newapi.pro'
const renderDocsButton = () => {
const isExternal = docsUrl.startsWith('http')
if (isExternal) {
return (
<Button
variant='outline'
className='group border-border/50 hover:border-border hover:bg-muted/50 inline-flex h-11 items-center gap-1.5 rounded-lg px-5 text-sm font-medium'
render={
<a href={docsUrl} target='_blank' rel='noopener noreferrer' />
}
>
<BookOpen className='text-muted-foreground/80 group-hover:text-foreground size-4 transition-colors duration-200' />
<span>{t('Docs')}</span>
</Button>
)
}
return (
<Button
variant='outline'
className='group border-border/50 hover:border-border hover:bg-muted/50 inline-flex h-11 items-center gap-1.5 rounded-lg px-5 text-sm font-medium'
render={<Link to={docsUrl} />}
>
<BookOpen className='text-muted-foreground/80 group-hover:text-foreground size-4 transition-colors duration-200' />
<span>{t('Docs')}</span>
</Button>
)
}
return (
<section className='relative z-10 flex flex-col items-center overflow-hidden px-6 pt-28 pb-16 md:pt-36 md:pb-24'>
<section className='relative z-10 overflow-hidden px-6 pt-24 pb-16 md:pt-32 md:pb-24 lg:pt-36 lg:pb-28'>
{/* Radial gradient background */}
<div
aria-hidden
@ -50,63 +97,146 @@ export function Hero(props: HeroProps) {
className='absolute inset-0 -z-10 bg-[linear-gradient(to_right,var(--border)_1px,transparent_1px),linear-gradient(to_bottom,var(--border)_1px,transparent_1px)] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_30%,black_20%,transparent_100%)] bg-[size:4rem_4rem] opacity-[0.08]'
/>
<div className='flex max-w-3xl flex-col items-center text-center'>
<h1
className='landing-animate-fade-up text-[clamp(2rem,5.5vw,3.5rem)] leading-[1.15] font-bold tracking-tight'
style={{ animationDelay: '0ms' }}
>
{t('Unified API Gateway for')}
<br />
<span className='bg-gradient-to-r from-blue-400 via-violet-400 to-purple-500 bg-clip-text text-transparent'>
{t('All Your AI Models')}
</span>
</h1>
<p
className='landing-animate-fade-up text-muted-foreground/80 mt-5 max-w-lg text-base leading-relaxed opacity-0 md:text-lg'
style={{ animationDelay: '80ms' }}
>
{t(
'Power AI applications, manage digital assets, connect the Future'
)}
</p>
<div
className='landing-animate-fade-up mt-8 flex items-center gap-3 opacity-0'
style={{ animationDelay: '160ms' }}
>
{props.isAuthenticated ? (
<Button
className='group rounded-lg'
render={<Link to='/dashboard' />}
>
{t('Go to Dashboard')}
<ArrowRight className='ml-1 size-3.5 transition-transform duration-200 group-hover:translate-x-0.5' />
</Button>
) : (
<>
<Button
className='group rounded-lg'
render={<Link to='/sign-up' />}
>
{t('Get Started')}
<ArrowRight className='ml-1 size-3.5 transition-transform duration-200 group-hover:translate-x-0.5' />
</Button>
<Button
variant='outline'
className='border-border/50 hover:border-border hover:bg-muted/50 rounded-lg'
render={<Link to='/pricing' />}
>
{t('View Pricing')}
</Button>
</>
)}
</div>
</div>
<div className='mx-auto grid max-w-6xl grid-cols-1 items-start gap-12 lg:grid-cols-12 lg:gap-8'>
{/* Left Column: Title, description, action buttons and application support */}
<div className='flex flex-col items-start text-left lg:col-span-6'>
{/* Top Pill Badge */}
<div
className='landing-animate-fade-up mb-5 inline-flex items-center gap-1.5 rounded-full border border-blue-500/20 bg-blue-500/5 px-3 py-1.5 text-[11px] font-medium text-blue-600 opacity-0 shadow-xs dark:border-blue-400/20 dark:bg-blue-400/5 dark:text-blue-400'
style={{ animationDelay: '0ms' }}
>
<span className='relative flex size-1.5'>
<span className='absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75' />
<span className='relative inline-flex size-1.5 rounded-full bg-blue-500 dark:bg-blue-400' />
</span>
<span>{t('AI Application Infrastructure Foundation')}</span>
</div>
<div
className='landing-animate-fade-up w-full opacity-0'
style={{ animationDelay: '300ms' }}
>
<HeroTerminalDemo />
<h1
className='landing-animate-fade-up text-[clamp(2.25rem,4.5vw,3.25rem)] leading-[1.15] font-bold tracking-tight'
style={{ animationDelay: '60ms' }}
>
{t('Unified API Gateway for')}
<br />
<span className='bg-gradient-to-r from-blue-400 via-violet-400 to-purple-500 bg-clip-text text-transparent'>
{t('Vast Range of AI Models')}
</span>
</h1>
<p
className='landing-animate-fade-up text-muted-foreground/80 mt-5 max-w-xl text-base leading-relaxed opacity-0 md:text-[15px]'
style={{ animationDelay: '120ms' }}
>
{t(
'Access a vast selection of models via a standard, unified API protocol. Power AI applications, manage digital assets, and connect the Future.'
)}
</p>
<div
className='landing-animate-fade-up mt-8 flex flex-wrap items-center gap-3 opacity-0'
style={{ animationDelay: '180ms' }}
>
{props.isAuthenticated ? (
<>
<Button
className='group h-11 rounded-lg px-5 text-sm font-medium'
render={<Link to='/dashboard' />}
>
{t('Go to Dashboard')}
<ArrowRight className='ml-1.5 size-4 transition-transform duration-200 group-hover:translate-x-0.5' />
</Button>
{renderDocsButton()}
</>
) : (
<>
<Button
className='group h-11 rounded-lg px-5 text-sm font-medium'
render={<Link to='/sign-up' />}
>
{t('Get Started')}
<ArrowRight className='ml-1.5 size-4 transition-transform duration-200 group-hover:translate-x-0.5' />
</Button>
<Button
variant='outline'
className='border-border/50 hover:border-border hover:bg-muted/50 h-11 rounded-lg px-5 text-sm font-medium'
render={<Link to='/pricing' />}
>
{t('View Pricing')}
</Button>
{renderDocsButton()}
</>
)}
</div>
{/* Supported Apps (参考图二样式,进行卡片化和信息扩充设计,增加视觉高度) */}
<div
className='landing-animate-fade-up mt-10 w-full max-w-xl opacity-0'
style={{ animationDelay: '240ms' }}
>
<div className='mb-4 flex flex-col gap-1'>
<span className='text-muted-foreground/50 text-[10px] font-bold tracking-[0.15em] uppercase'>
{t('Supported Applications')}
</span>
<p className='text-muted-foreground/60 text-xs leading-relaxed'>
{t(
'Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.'
)}
</p>
</div>
<div className='flex flex-wrap items-center gap-3'>
{/* Cherry Studio */}
<a
href='https://cherry-ai.com'
target='_blank'
rel='noopener noreferrer'
className='group border-border/40 bg-muted/15 text-foreground/80 hover:border-border hover:bg-muted/30 hover:text-foreground flex items-center gap-3 rounded-full border px-5 py-2.5 text-sm font-medium shadow-[0_1px_2.5px_rgba(0,0,0,0.01)] backdrop-blur-xs transition-all duration-300 hover:scale-[1.02]'
>
<CherryStudio.Color size={24} className='shrink-0' />
<span>Cherry Studio</span>
</a>
{/* CC Switch */}
<a
href='https://ccswitch.io'
target='_blank'
rel='noopener noreferrer'
className='group border-border/40 bg-muted/15 text-foreground/80 hover:border-border hover:bg-muted/30 hover:text-foreground flex items-center gap-3 rounded-full border px-5 py-2.5 text-sm font-medium shadow-[0_1px_2.5px_rgba(0,0,0,0.01)] backdrop-blur-xs transition-all duration-300 hover:scale-[1.02]'
>
<img
src='https://ccswitch.io/favicon.png'
alt='CC Switch'
className='size-6 shrink-0 rounded-md object-contain'
onError={(e) => {
// Fallback to a styled text avatar if the remote favicon fails to load in sandbox or local environments
e.currentTarget.style.display = 'none'
const fallback = e.currentTarget.nextSibling as HTMLElement
if (fallback) fallback.style.display = 'flex'
}}
/>
<span
style={{ display: 'none' }}
className='size-6 shrink-0 items-center justify-center rounded-md bg-blue-500/10 text-[10px] font-bold text-blue-600 dark:bg-blue-400/10 dark:text-blue-400'
>
CC
</span>
<span>CC Switch</span>
</a>
{/* "更多" */}
<div className='group border-border/40 bg-muted/15 text-foreground/55 hover:border-border hover:bg-muted/30 hover:text-foreground flex cursor-default items-center gap-2.5 rounded-full border px-5 py-2.5 text-sm font-medium shadow-[0_1px_2.5px_rgba(0,0,0,0.01)] backdrop-blur-xs transition-all duration-300 hover:scale-[1.02]'>
<MoreIcon />
<span>{t('More Apps')}</span>
</div>
</div>
</div>
</div>
{/* Right Column: Hero Terminal API Demo */}
<div
className='landing-animate-fade-up flex w-full justify-center opacity-0 lg:col-span-6'
style={{ animationDelay: '320ms' }}
>
<HeroTerminalDemo className='mt-8 lg:mt-0' />
</div>
</div>
</section>
)

View File

@ -42,7 +42,7 @@ export async function getApiKeys(
// Search API keys by keyword or token (with pagination)
export async function searchApiKeys(
params: SearchApiKeysParams
): Promise<{ success: boolean; message?: string; data?: ApiKey[] }> {
): Promise<GetApiKeysResponse> {
const { keyword = '', token = '', p, size } = params
const queryParams = new URLSearchParams()
if (keyword) queryParams.set('keyword', keyword)

View File

@ -30,6 +30,7 @@ import {
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table'
import { useDebounce } from '@/hooks'
import { Database } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@ -43,6 +44,7 @@ import {
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import {
DISABLED_ROW_DESKTOP,
@ -207,9 +209,35 @@ export function ApiKeysTable() {
navigate: route.useNavigate(),
pagination: { defaultPage: 1, defaultPageSize: 20 },
globalFilter: { enabled: true, key: 'filter' },
columnFilters: [{ columnId: 'status', searchKey: 'status', type: 'array' }],
columnFilters: [
{ columnId: 'status', searchKey: 'status', type: 'array' },
{ columnId: '_tokenSearch', searchKey: 'token', type: 'string' },
],
})
const tokenFilterFromUrl =
(columnFilters.find((f) => f.id === '_tokenSearch')?.value as string) || ''
const [tokenFilterInput, setTokenFilterInput] = useState(tokenFilterFromUrl)
const debouncedTokenFilter = useDebounce(tokenFilterInput, 500)
useEffect(() => {
setTokenFilterInput(tokenFilterFromUrl)
}, [tokenFilterFromUrl])
useEffect(() => {
if (debouncedTokenFilter !== tokenFilterFromUrl) {
onColumnFiltersChange((prev) => {
const filtered = prev.filter((f) => f.id !== '_tokenSearch')
return debouncedTokenFilter
? [...filtered, { id: '_tokenSearch', value: debouncedTokenFilter }]
: filtered
})
}
}, [debouncedTokenFilter, tokenFilterFromUrl, onColumnFiltersChange])
const tokenFilter = tokenFilterFromUrl
const shouldSearch = Boolean(globalFilter?.trim() || tokenFilter.trim())
// Fetch data with React Query
// eslint-disable-next-line @tanstack/query/exhaustive-deps
const { data, isLoading, isFetching } = useQuery({
@ -218,32 +246,31 @@ export function ApiKeysTable() {
pagination.pageIndex + 1,
pagination.pageSize,
globalFilter,
tokenFilter,
refreshTrigger,
],
queryFn: async () => {
// If there's a global filter, use search
const hasFilter = globalFilter?.trim()
if (hasFilter) {
const result = await searchApiKeys({ keyword: globalFilter })
if (!result.success) {
toast.error(result.message || t(ERROR_MESSAGES.SEARCH_FAILED))
return { items: [], total: 0 }
}
return {
items: result.data || [],
total: result.data?.length || 0,
}
}
// Otherwise use pagination
const result = await getApiKeys({
p: pagination.pageIndex + 1,
size: pagination.pageSize,
})
const result = shouldSearch
? await searchApiKeys({
keyword: globalFilter,
token: tokenFilter,
p: pagination.pageIndex + 1,
size: pagination.pageSize,
})
: await getApiKeys({
p: pagination.pageIndex + 1,
size: pagination.pageSize,
})
if (!result.success) {
toast.error(result.message || t(ERROR_MESSAGES.LOAD_FAILED))
toast.error(
result.message ||
t(
shouldSearch
? ERROR_MESSAGES.SEARCH_FAILED
: ERROR_MESSAGES.LOAD_FAILED
)
)
return { items: [], total: 0 }
}
@ -272,13 +299,7 @@ export function ApiKeysTable() {
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnVisibilityChange: setColumnVisibility,
globalFilterFn: (row, _columnId, filterValue) => {
const name = String(row.getValue('name')).toLowerCase()
const key = String(row.original.key).toLowerCase()
const searchValue = String(filterValue).toLowerCase()
return name.includes(searchValue) || key.includes(searchValue)
},
globalFilterFn: () => true,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
@ -288,10 +309,8 @@ export function ApiKeysTable() {
onPaginationChange,
onGlobalFilterChange,
onColumnFiltersChange,
manualPagination: !globalFilter,
pageCount: globalFilter
? Math.ceil((data?.total || 0) / pagination.pageSize)
: Math.ceil((data?.total || 0) / pagination.pageSize),
manualPagination: true,
pageCount: Math.ceil((data?.total || 0) / pagination.pageSize),
})
const pageCount = table.getPageCount()
@ -311,7 +330,16 @@ export function ApiKeysTable() {
)}
skeletonKeyPrefix='api-keys-skeleton'
toolbarProps={{
searchPlaceholder: t('Filter by name or key...'),
searchPlaceholder: t('Filter by name...'),
additionalSearch: (
<Input
placeholder={t('Filter by API key...')}
aria-label={t('Filter by API key...')}
value={tokenFilterInput}
onChange={(e) => setTokenFilterInput(e.target.value)}
className='w-full sm:w-50 lg:w-60'
/>
),
filters: [
{
columnId: 'status',

View File

@ -37,7 +37,6 @@
"{{count}} IP(s)": "{{count}} IP(s)",
"{{count}} log entries removed.": "{{count}} log entries removed.",
"{{count}} minutes ago": "{{count}} minutes ago",
"{{count}} model(s)": "{{count}} model(s)",
"{{count}} models": "{{count}} models",
"{{count}} months ago": "{{count}} months ago",
"{{count}} override": "{{count}} override",
@ -107,6 +106,7 @@
"Accept Unpriced Models": "Accept Unpriced Models",
"Accepts a JSON array of model identifiers that support the Imagine API.": "Accepts a JSON array of model identifiers that support the Imagine API.",
"Accepts comma-separated status codes and inclusive ranges.": "Accepts comma-separated status codes and inclusive ranges.",
"Access a vast selection of models via a standard, unified API protocol. Power AI applications, manage digital assets, and connect the Future.": "Access a vast selection of models via a standard, unified API protocol. Power AI applications, manage digital assets, and connect the Future.",
"Access Denied Message": "Access Denied Message",
"Access Forbidden": "Access Forbidden",
"Access Policy (JSON)": "Access Policy (JSON)",
@ -135,6 +135,7 @@
"Actual Model": "Actual Model",
"Actual Model:": "Actual Model:",
"Add": "Add",
"Add \"{{value}}\"": "Add \"{{value}}\"",
"Add {{title}}": "Add {{title}}",
"Add a group identifier to the auto assignment list.": "Add a group identifier to the auto assignment list.",
"Add a new API key by providing necessary info.": "Add a new API key by providing necessary info.",
@ -152,7 +153,7 @@
"Add condition": "Add condition",
"Add Condition": "Add Condition",
"Add credits": "Add credits",
"Add custom model(s), comma-separated": "Add custom model(s), comma-separated",
"Add custom model \"{{value}}\"": "Add custom model \"{{value}}\"",
"Add discount tier": "Add discount tier",
"Add each model or tag you want to include.": "Add each model or tag you want to include.",
"Add FAQ": "Add FAQ",
@ -164,6 +165,7 @@
"Add group rules": "Add group rules",
"Add Mapping": "Add Mapping",
"Add method": "Add method",
"Add missing models": "Add missing models",
"Add Mode": "Add Mode",
"Add model": "Add model",
"Add Model": "Add Model",
@ -192,7 +194,6 @@
"Add User": "Add User",
"Add user group": "Add user group",
"Add your API keys, set up channels and configure access permissions": "Add your API keys, set up channels and configure access permissions",
"Added {{count}} custom model(s)": "Added {{count}} custom model(s)",
"Added {{count}} model(s)": "Added {{count}} model(s)",
"Added successfully": "Added successfully",
"Additional Conditions": "Additional Conditions",
@ -201,8 +202,8 @@
"Additional Limit": "Additional Limit",
"Additional Limits": "Additional Limits",
"Additional metered capability": "Additional metered capability",
"Adjust Quota": "Adjust Quota",
"Adjust filters, then search to refresh the logs.": "Adjust filters, then search to refresh the logs.",
"Adjust Quota": "Adjust Quota",
"Adjust response formatting, prompt behavior, proxy, and upstream automation.": "Adjust response formatting, prompt behavior, proxy, and upstream automation.",
"Adjust the appearance and layout to suit your preferences.": "Adjust the appearance and layout to suit your preferences.",
"Admin": "Admin",
@ -236,6 +237,7 @@
"aggregates 50+ AI providers behind one unified API. Manage access, track costs, and scale effortlessly.": "aggregates 50+ AI providers behind one unified API. Manage access, track costs, and scale effortlessly.",
"Aggregation bucket": "Aggregation bucket",
"AGPL v3.0 License": "AGPL v3.0 License",
"AI Application Infrastructure Foundation": "AI Application Infrastructure Foundation",
"AI model testing environment": "AI model testing environment",
"AI models": "AI models",
"AI models supported": "AI models supported",
@ -263,7 +265,6 @@
"All Types": "All Types",
"All upstream data is trusted": "All upstream data is trusted",
"All Vendors": "All Vendors",
"All Your AI Models": "All Your AI Models",
"All-time": "All-time",
"Allocated Memory": "Allocated Memory",
"Allow accountFilter parameter": "Allow accountFilter parameter",
@ -424,6 +425,11 @@
"Audio Tokens": "Audio Tokens",
"Auth configured": "Auth configured",
"Auth Style": "Auth Style",
"auth.resetPasswordConfirm.backToLogin": "Return to login",
"auth.resetPasswordConfirm.confirm": "Confirm reset password",
"auth.resetPasswordConfirm.description": "Confirm the reset request to generate a new password.",
"auth.resetPasswordConfirm.retry": "Retry ({{seconds}}s)",
"auth.resetPasswordConfirm.success": "Your password has been reset successfully",
"Authentication": "Authentication",
"Authenticator code": "Authenticator code",
"Authorization Endpoint": "Authorization Endpoint",
@ -502,6 +508,7 @@
"Base Price": "Base Price",
"Base rate limit windows for this account.": "Base rate limit windows for this account.",
"Base URL": "Base URL",
"Base URL is required for this channel type": "Base URL is required for this channel type",
"Base URL of your Uptime Kuma instance": "Base URL of your Uptime Kuma instance",
"Basic Authentication": "Basic Authentication",
"Basic Configuration": "Basic Configuration",
@ -671,6 +678,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 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",
"Check-in Rewards": "Check-in Rewards",
@ -759,8 +767,10 @@
"Codex": "Codex",
"Codex Account & Usage": "Codex Account & Usage",
"Codex Authorization": "Codex Authorization",
"Codex channels do not support batch creation": "Codex channels do not support batch creation",
"Codex channels use an OAuth JSON credential as the key.": "Codex channels use an OAuth JSON credential as the key.",
"Codex CLI Header Passthrough": "Codex CLI Header Passthrough",
"Codex credential must be a JSON object with access_token and account_id": "Codex credential must be a JSON object with access_token and account_id",
"Cohere": "Cohere",
"Collapse": "Collapse",
"Collapse All": "Collapse All",
@ -867,8 +877,6 @@
"Confirm New Password": "Confirm New Password",
"Confirm password": "Confirm password",
"Confirm Payment": "Confirm Payment",
"auth.resetPasswordConfirm.confirm": "Confirm reset password",
"auth.resetPasswordConfirm.description": "Confirm the reset request to generate a new password.",
"Confirm Selection": "Confirm Selection",
"Confirm settings and finish setup": "Confirm settings and finish setup",
"confirm that I bear legal responsibility arising from deployment": "confirm that I bear legal responsibility arising from deployment",
@ -999,6 +1007,7 @@
"Create, revoke, and audit API tokens.": "Create, revoke, and audit API tokens.",
"Created": "Created",
"Created At": "Created At",
"Creates a Pancake product in the saved store using this plans title and price. Requires Waffo Pancake to be fully configured in Payment settings first.": "Creates a Pancake product in the saved store using this plans title and price. Requires Waffo Pancake to be fully configured in Payment settings first.",
"Creating...": "Creating...",
"Creation failed": "Creation failed",
"Credential generated": "Credential generated",
@ -1122,6 +1131,7 @@
"Delete invalid redemption codes": "Delete invalid redemption codes",
"Delete Invalid Redemption Codes?": "Delete Invalid Redemption Codes?",
"Delete logs": "Delete logs",
"Delete mapping": "Delete mapping",
"Delete Model": "Delete Model",
"Delete Models?": "Delete Models?",
"Delete Provider": "Delete Provider",
@ -1249,6 +1259,8 @@
"Drawing task records": "Drawing task records",
"Duplicate": "Duplicate",
"Duplicate group names: {{names}}": "Duplicate group names: {{names}}",
"Duplicate source model mappings are not allowed": "Duplicate source model mappings are not allowed",
"Duplicate source model(s): {{models}}": "Duplicate source model(s): {{models}}",
"Duration": "Duration",
"Duration (hours)": "Duration (hours)",
"Duration Settings": "Duration Settings",
@ -1401,6 +1413,7 @@
"Endpoint config": "Endpoint config",
"Endpoint Configuration": "Endpoint Configuration",
"Endpoint Type": "Endpoint Type",
"Endpoint, provider-specific settings, and credentials.": "Endpoint, provider-specific settings, and credentials.",
"Endpoint:": "Endpoint:",
"Endpoints": "Endpoints",
"English": "English",
@ -1704,14 +1717,15 @@
"Filled {{count}} model(s)": "Filled {{count}} model(s)",
"Filled {{count}} related model(s)": "Filled {{count}} related model(s)",
"Filter": "Filter",
"Filter by API key...": "Filter by API key...",
"Filter by channel ID": "Filter by channel ID",
"Filter by group": "Filter by group",
"Filter by Midjourney task ID": "Filter by Midjourney task ID",
"Filter by model name...": "Filter by model name...",
"Filter by model...": "Filter by model...",
"Filter by name or ID...": "Filter by name or ID...",
"Filter by name or key...": "Filter by name or key...",
"Filter by name, ID, or key...": "Filter by name, ID, or key...",
"Filter by name...": "Filter by name...",
"Filter by price field": "Filter by price field",
"Filter by ratio type": "Filter by ratio type",
"Filter by request ID": "Filter by request ID",
@ -1742,6 +1756,7 @@
"FluentRead extension not detected. Please ensure it is installed and active.": "FluentRead extension not detected. Please ensure it is installed and active.",
"Flush interval (minutes)": "Flush interval (minutes)",
"Follow the guided steps to prepare your workspace before the first login.": "Follow the guided steps to prepare your workspace before the first login.",
"Font": "Font",
"Footer": "Footer",
"Footer text displayed at the bottom of pages": "Footer text displayed at the bottom of pages",
"footer.columns.about.links.aboutProject": "About Project",
@ -2177,6 +2192,7 @@
"Load template...": "Load template...",
"Loader": "Loader",
"Loading": "Loading",
"Loading channel details": "Loading channel details",
"Loading configuration": "Loading configuration",
"Loading content settings...": "Loading content settings...",
"Loading current models...": "Loading current models...",
@ -2293,7 +2309,6 @@
"Minimum:": "Minimum:",
"Minor blips in the last 30 days": "Minor blips in the last 30 days",
"Mint a fresh pair below — or pick an existing one further down. Click Save when ready.": "Mint a fresh pair below — or pick an existing one further down. Click Save when ready.",
"Creates a Pancake product in the saved store using this plans title and price. Requires Waffo Pancake to be fully configured in Payment settings first.": "Creates a Pancake product in the saved store using this plans title and price. Requires Waffo Pancake to be fully configured in Payment settings first.",
"Minute": "Minute",
"minutes": "minutes",
"Missing code": "Missing code",
@ -2324,7 +2339,9 @@
"Model Mapping": "Model Mapping",
"Model Mapping (JSON)": "Model Mapping (JSON)",
"Model Mapping must be a JSON object like": "Model Mapping must be a JSON object like",
"Model mapping must be a JSON object with string values": "Model mapping must be a JSON object with string values",
"Model mapping must be valid JSON": "Model mapping must be valid JSON",
"Model mapping values must be strings": "Model mapping values must be strings",
"Model name": "Model name",
"Model Name": "Model Name",
"Model Name *": "Model Name *",
@ -2333,8 +2350,8 @@
"Model not found": "Model not found",
"Model performance metrics": "Model performance metrics",
"Model Price": "Model Price",
"Model Price Not Configured": "Model Price Not Configured",
"Model price is not configured. Please complete model pricing in settings.": "Model price is not configured. Please complete model pricing in settings.",
"Model Price Not Configured": "Model Price Not Configured",
"Model prices": "Model prices",
"Model prices reset successfully": "Model prices reset successfully",
"Model Pricing": "Model Pricing",
@ -2385,6 +2402,7 @@
"months": "months",
"Moonshot": "Moonshot",
"More": "More",
"More Apps": "More Apps",
"more mapping": "more mapping",
"More templates...": "More templates...",
"More than 999 days left": "More than 999 days left",
@ -2437,6 +2455,7 @@
"Name Suffix": "Name Suffix",
"Name the channel and choose the upstream provider.": "Name the channel and choose the upstream provider.",
"Name the channel, choose the provider, configure API access, and set credentials.": "Name the channel, choose the provider, configure API access, and set credentials.",
"Name, provider type, and availability.": "Name, provider type, and availability.",
"name@example.com": "name@example.com",
"Native format": "Native format",
"Need a redemption code?": "Need a redemption code?",
@ -2446,6 +2465,7 @@
"Network proxy for this channel (supports socks5 protocol)": "Network proxy for this channel (supports socks5 protocol)",
"Never": "Never",
"Never expires": "Never expires",
"Never used an API Gateway?": "Never used an API Gateway?",
"NEW": "NEW",
"New API": "New API",
"New API &lt;noreply@example.com&gt;": "New API &lt;noreply@example.com&gt;",
@ -2532,6 +2552,7 @@
"No Logs Found": "No Logs Found",
"No mappings configured. Click \"Add Row\" to get started.": "No mappings configured. Click \"Add Row\" to get started.",
"No matches found": "No matches found",
"No matching items": "No matching items",
"No matching results": "No matching results",
"No matching rules": "No matching rules",
"No messages yet": "No messages yet",
@ -2950,6 +2971,7 @@
"Please upload key file(s)": "Please upload key file(s)",
"Please wait a moment before trying again.": "Please wait a moment before trying again.",
"Please wait a moment, human check is initializing...": "Please wait a moment, human check is initializing...",
"Please wait before editing to avoid overwriting saved values.": "Please wait before editing to avoid overwriting saved values.",
"Policy JSON": "Policy JSON",
"Polling": "Polling",
"Polling mode requires Redis and memory cache, otherwise performance will be significantly degraded": "Polling mode requires Redis and memory cache, otherwise performance will be significantly degraded",
@ -2983,10 +3005,12 @@
"Prepend to Start": "Prepend to Start",
"Prepend value to array / string / object start": "Prepend value to array / string / object start",
"Preserve the original field when applying this rule": "Preserve the original field when applying this rule",
"Preset groups": "Preset groups",
"Preset recharge amounts (JSON array)": "Preset recharge amounts (JSON array)",
"Preset recharge amounts displayed to users": "Preset recharge amounts displayed to users",
"Preset Template": "Preset Template",
"Preset templates": "Preset templates",
"preset.anthropic": "Anthropic",
"preset.default": "Default",
"preset.forest-whisper": "Forest Whisper",
"preset.lake-view": "Lake View",
@ -3082,6 +3106,7 @@
"Public rankings page based on live usage data.": "Public rankings page based on live usage data.",
"Publish Date": "Publish Date",
"Published": "Published",
"Published models, groups, and model remapping rules.": "Published models, groups, and model remapping rules.",
"Published:": "Published:",
"Pull": "Pull",
"Pull model": "Pull model",
@ -3098,6 +3123,7 @@
"Querying...": "Querying...",
"Question": "Question",
"Queued": "Queued",
"Quick actions": "Quick actions",
"Quick insert common payment methods": "Quick insert common payment methods",
"Quick Range": "Quick Range",
"Quick Setup from Preset": "Quick Setup from Preset",
@ -3219,6 +3245,7 @@
"Remaining:": "Remaining:",
"Remark": "Remark",
"Remove": "Remove",
"Remove {{value}}": "Remove {{value}}",
"Remove ${{amount}}": "Remove ${{amount}}",
"Remove all log entries created before the selected timestamp.": "Remove all log entries created before the selected timestamp.",
"Remove attachment": "Remove attachment",
@ -3226,6 +3253,7 @@
"Remove Duplicates": "Remove Duplicates",
"Remove filter": "Remove filter",
"Remove functionResponse.id field": "Remove functionResponse.id field",
"Remove mapped targets": "Remove mapped targets",
"Remove Models": "Remove Models",
"Remove Passkey": "Remove Passkey",
"Remove Passkey?": "Remove Passkey?",
@ -3329,7 +3357,6 @@
"Retain last N files": "Retain last N files",
"Retention days": "Retention days",
"Retry": "Retry",
"auth.resetPasswordConfirm.retry": "Retry ({{seconds}}s)",
"Retry Chain": "Retry Chain",
"Retry Suggestion": "Retry Suggestion",
"Retry Times": "Retry Times",
@ -3339,7 +3366,6 @@
"Return Error": "Return Error",
"Return per-token log probabilities": "Return per-token log probabilities",
"Return to dashboard": "Return to dashboard",
"auth.resetPasswordConfirm.backToLogin": "Return to login",
"Return vector embeddings for inputs": "Return vector embeddings for inputs",
"Reveal API key": "Reveal API key",
"Reveal key": "Reveal key",
@ -3491,11 +3517,11 @@
"Select all (filtered)": "Select all (filtered)",
"Select all models": "Select all models",
"Select All Visible": "Select All Visible",
"Select model {{model}}": "Select model {{model}}",
"Select an operation mode and enter the amount": "Select an operation mode and enter the amount",
"Select announcement type": "Select announcement type",
"Select at least one field to overwrite.": "Select at least one field to overwrite.",
"Select at least one target model": "Select at least one target model",
"Select body font": "Select body font",
"Select border radius": "Select border radius",
"Select channel type": "Select channel type",
"Select color preset": "Select color preset",
@ -3518,6 +3544,7 @@
"Select layout style": "Select layout style",
"Select locations": "Select locations",
"Select Model": "Select Model",
"Select model {{model}}": "Select model {{model}}",
"Select models (empty for allow all)": "Select models (empty for allow all)",
"Select models and apply to channel models list.": "Select models and apply to channel models list.",
"Select models or add custom ones": "Select models or add custom ones",
@ -3547,6 +3574,7 @@
"Select vendor": "Select vendor",
"Selectable groups": "Selectable groups",
"selected": "selected",
"Selected {{count}}": "Selected {{count}}",
"selected channel(s). Leave empty to remove tag.": "selected channel(s). Leave empty to remove tag.",
"Selected conflicts were overwritten successfully.": "Selected conflicts were overwritten successfully.",
"Selected when creating a token and used as the default billing group for API calls.": "Selected when creating a token and used as the default billing group for API calls.",
@ -3690,6 +3718,7 @@
"Status & Sync": "Status & Sync",
"Status Code": "Status Code",
"Status Code Mapping": "Status Code Mapping",
"Status code mapping must use valid HTTP status codes": "Status code mapping must use valid HTTP status codes",
"Status Page Slug": "Status Page Slug",
"Status short": "Status",
"Status:": "Status:",
@ -3753,12 +3782,14 @@
"Sunset Glow": "Sunset Glow",
"Super Admin": "Super Admin",
"Support for high concurrency with automatic load balancing": "Support for high concurrency with automatic load balancing",
"Supported Applications": "Supported Applications",
"Supported Imagine Models": "Supported Imagine Models",
"Supported modalities": "Supported modalities",
"Supported parameters": "Supported parameters",
"Supported variables": "Supported variables",
"Supports `-thinking`, `-thinking-": "Supports `-thinking`, `-thinking-",
"Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.": "Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.",
"Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.": "Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.",
"Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.": "Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.",
"Sustained tokens per second": "Sustained tokens per second",
"Swap Face": "Swap Face",
@ -3897,6 +3928,7 @@
"This action will permanently remove 2FA protection from your account.": "This action will permanently remove 2FA protection from your account.",
"This channel is not an Ollama channel.": "This channel is not an Ollama channel.",
"This channel type does not support fetching models": "This channel type does not support fetching models",
"This channel type requires additional configuration": "This channel type requires additional configuration",
"This confirmation unlocks payment, redemption code, subscription plan, and invitation reward features. Please read the statements carefully.": "This confirmation unlocks payment, redemption code, subscription plan, and invitation reward features. Please read the statements carefully.",
"This controls model request rate limiting. Web/API route throttling is configured by environment variables and may still return 429.": "This controls model request rate limiting. Web/API route throttling is configured by environment variables and may still return 429.",
"This data may be unreliable, use with caution": "This data may be unreliable, use with caution",
@ -4215,6 +4247,7 @@
"Use external tools to extend capabilities": "Use external tools to extend capabilities",
"Use our unified OpenAI-compatible endpoint in your applications": "Use our unified OpenAI-compatible endpoint in your applications",
"Use Passkey to sign in without entering your password.": "Use Passkey to sign in without entering your password.",
"Use presets or upstream discovery to populate the model list faster.": "Use presets or upstream discovery to populate the model list faster.",
"Use secure connection when sending emails": "Use secure connection when sending emails",
"Use sidebar shortcut": "Use sidebar shortcut",
"Use the full-width table to scan prices, then select a row to edit it here.": "Use the full-width table to scan prices, then select a row to edit it here.",
@ -4280,6 +4313,7 @@
"Vary": "Vary",
"Vary (Strong)": "Vary (Strong)",
"Vary (Subtle)": "Vary (Subtle)",
"Vast Range of AI Models": "Vast Range of AI Models",
"Vendor": "Vendor",
"Vendor deleted successfully": "Vendor deleted successfully",
"Vendor Name *": "Vendor Name *",
@ -4302,8 +4336,10 @@
"Verifying credentials and pulling stores from your Pancake account...": "Verifying credentials and pulling stores from your Pancake account...",
"Version Overrides": "Version Overrides",
"Vertex AI": "Vertex AI",
"Vertex AI API Key mode does not support batch creation": "Vertex AI API Key mode does not support batch creation",
"Vertex AI does not support functionResponse.id. Enable this to remove the field automatically.": "Vertex AI does not support functionResponse.id. Enable this to remove the field automatically.",
"Vertex AI Key Format": "Vertex AI Key Format",
"Vertex AI service account key must be valid JSON": "Vertex AI service account key must be valid JSON",
"Video": "Video",
"Video length in seconds": "Video length in seconds",
"Video Remix": "Video Remix",
@ -4466,7 +4502,6 @@
"Your GitHub OAuth Client ID": "Your GitHub OAuth Client ID",
"Your GitHub OAuth Client Secret": "Your GitHub OAuth Client Secret",
"Your new backup codes are ready": "Your new backup codes are ready",
"auth.resetPasswordConfirm.success": "Your password has been reset successfully",
"Your Referral Link": "Your Referral Link",
"Your setup guide is collapsed so usage stays in focus.": "Your setup guide is collapsed so usage stays in focus.",
"Your system access token for API authentication. Keep it secure and don't share it with others.": "Your system access token for API authentication. Keep it secure and don't share it with others.",

View File

@ -37,7 +37,6 @@
"{{count}} IP(s)": "{{count}} IP",
"{{count}} log entries removed.": "{{count}} entrées de journal supprimées.",
"{{count}} minutes ago": "il y a {{count}} minutes",
"{{count}} model(s)": "{{count}} modèle(s)",
"{{count}} models": "{{count}} modèles",
"{{count}} months ago": "il y a {{count}} mois",
"{{count}} override": "{{count}} remplacement",
@ -107,6 +106,7 @@
"Accept Unpriced Models": "Accepter les modèles non tarifés",
"Accepts a JSON array of model identifiers that support the Imagine API.": "Accepte un tableau JSON d'identifiants de modèles qui prennent en charge l'API Imagine.",
"Accepts comma-separated status codes and inclusive ranges.": "Accepte les codes de statut séparés par des virgules et les plages inclusives.",
"Access a vast selection of models via a standard, unified API protocol. Power AI applications, manage digital assets, and connect the Future.": "Accédez à une vaste sélection de modèles via un protocole API standard et unifié. Propulsez les applications d'IA, gérez les actifs numériques et connectez le futur.",
"Access Denied Message": "Message d'accès refusé",
"Access Forbidden": "Accès interdit",
"Access Policy (JSON)": "Politique d'accès (JSON)",
@ -135,6 +135,7 @@
"Actual Model": "Modèle réel",
"Actual Model:": "Modèle réel :",
"Add": "Ajouter",
"Add \"{{value}}\"": "Ajouter \"{{value}}\"",
"Add {{title}}": "Ajouter {{title}}",
"Add a group identifier to the auto assignment list.": "Ajouter un identifiant de groupe à la liste d'affectation automatique.",
"Add a new API key by providing necessary info.": "Ajoutez une nouvelle clé API en fournissant les informations nécessaires.",
@ -152,7 +153,7 @@
"Add condition": "Ajouter une condition",
"Add Condition": "Ajouter une condition",
"Add credits": "Ajouter des crédits",
"Add custom model(s), comma-separated": "Ajouter un ou plusieurs modèles personnalisés, séparés par des virgules",
"Add custom model \"{{value}}\"": "Ajouter le modèle personnalisé « {{value}} »",
"Add discount tier": "Ajouter un niveau de réduction",
"Add each model or tag you want to include.": "Ajoutez chaque modèle ou étiquette que vous souhaitez inclure.",
"Add FAQ": "Ajouter une FAQ",
@ -164,6 +165,7 @@
"Add group rules": "Ajouter des règles de groupe",
"Add Mapping": "Ajouter un mappage",
"Add method": "Ajouter une méthode",
"Add missing models": "Ajouter les modèles manquants",
"Add Mode": "Ajouter un mode",
"Add model": "Ajouter un modèle",
"Add Model": "Ajouter un modèle",
@ -192,7 +194,6 @@
"Add User": "Ajouter un utilisateur",
"Add user group": "Ajouter un groupe d'utilisateurs",
"Add your API keys, set up channels and configure access permissions": "Ajoutez vos clés API, configurez les canaux et les permissions d'accès",
"Added {{count}} custom model(s)": "{{count}} modèle(s) personnalisé(s) ajouté(s)",
"Added {{count}} model(s)": "{{count}} modèle(s) ajouté(s)",
"Added successfully": "Ajouté avec succès",
"Additional Conditions": "Conditions supplémentaires",
@ -201,8 +202,8 @@
"Additional Limit": "Limite supplémentaire",
"Additional Limits": "Limites supplémentaires",
"Additional metered capability": "Fonctionnalité supplémentaire facturée à lusage",
"Adjust Quota": "Ajuster le quota",
"Adjust filters, then search to refresh the logs.": "Ajustez les filtres, puis lancez la recherche pour actualiser les journaux.",
"Adjust Quota": "Ajuster le quota",
"Adjust response formatting, prompt behavior, proxy, and upstream automation.": "Ajustez le formatage des réponses, le comportement des prompts, le proxy et lautomatisation amont.",
"Adjust the appearance and layout to suit your preferences.": "Ajustez l'apparence et la mise en page selon vos préférences.",
"Admin": "Administrateur",
@ -236,6 +237,7 @@
"aggregates 50+ AI providers behind one unified API. Manage access, track costs, and scale effortlessly.": "agrège plus de 50 fournisseurs IA derrière une API unifiée. Gérez l'accès, suivez les coûts et évoluez sans effort.",
"Aggregation bucket": "Fenêtre dagrégation",
"AGPL v3.0 License": "Licence AGPL v3.0",
"AI Application Infrastructure Foundation": "Socle d'infrastructure pour applications d'IA",
"AI model testing environment": "Environnement de test de modèle IA",
"AI models": "Modèles d'IA",
"AI models supported": "Modèles d'IA pris en charge",
@ -263,7 +265,6 @@
"All Types": "Tous les types",
"All upstream data is trusted": "Toutes les données en amont sont fiables",
"All Vendors": "Tous les fournisseurs",
"All Your AI Models": "Tous vos modèles IA",
"All-time": "Tous temps",
"Allocated Memory": "Mémoire allouée",
"Allow accountFilter parameter": "Autoriser le paramètre accountFilter",
@ -424,6 +425,11 @@
"Audio Tokens": "Jetons audio",
"Auth configured": "Authentification configurée",
"Auth Style": "Style d'authentification",
"auth.resetPasswordConfirm.backToLogin": "Retour à la connexion",
"auth.resetPasswordConfirm.confirm": "Confirmer la réinitialisation du mot de passe",
"auth.resetPasswordConfirm.description": "Confirmez la demande de réinitialisation pour générer un nouveau mot de passe.",
"auth.resetPasswordConfirm.retry": "Réessayer ({{seconds}}s)",
"auth.resetPasswordConfirm.success": "Votre mot de passe a été réinitialisé avec succès",
"Authentication": "Authentification",
"Authenticator code": "Code d'authentification",
"Authorization Endpoint": "Point de terminaison d'autorisation",
@ -502,6 +508,7 @@
"Base Price": "Prix de base",
"Base rate limit windows for this account.": "Fenêtres de limitation de débit de base pour ce compte.",
"Base URL": "URL de base",
"Base URL is required for this channel type": "L'URL de base est requise pour ce type de canal",
"Base URL of your Uptime Kuma instance": "URL de base de votre instance Uptime Kuma",
"Basic Authentication": "Authentification de base",
"Basic Configuration": "Configuration de base",
@ -671,6 +678,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 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",
"Check-in Rewards": "Récompenses de connexion quotidienne",
@ -759,8 +767,10 @@
"Codex": "Codex",
"Codex Account & Usage": "Compte et utilisation Codex",
"Codex Authorization": "Autorisation Codex",
"Codex channels do not support batch creation": "Les canaux Codex ne prennent pas en charge la création par lot",
"Codex channels use an OAuth JSON credential as the key.": "Les canaux Codex utilisent un identifiant OAuth JSON comme clé.",
"Codex CLI Header Passthrough": "Passthrough en-tête Codex CLI",
"Codex credential must be a JSON object with access_token and account_id": "L'identifiant Codex doit être un objet JSON avec access_token et account_id",
"Cohere": "Cohere",
"Collapse": "Réduire",
"Collapse All": "Tout réduire",
@ -867,8 +877,6 @@
"Confirm New Password": "Confirmer le nouveau mot de passe",
"Confirm password": "Confirmer le mot de passe",
"Confirm Payment": "Confirmer le paiement",
"auth.resetPasswordConfirm.confirm": "Confirmer la réinitialisation du mot de passe",
"auth.resetPasswordConfirm.description": "Confirmez la demande de réinitialisation pour générer un nouveau mot de passe.",
"Confirm Selection": "Confirmer la sélection",
"Confirm settings and finish setup": "Confirmez les paramètres et terminez la configuration",
"confirm that I bear legal responsibility arising from deployment": "confirme assumer la responsabilité juridique découlant du déploiement",
@ -999,6 +1007,7 @@
"Create, revoke, and audit API tokens.": "Créer, révoquer et auditer les jetons API.",
"Created": "Créé",
"Created At": "Créé le",
"Creates a Pancake product in the saved store using this plans title and price. Requires Waffo Pancake to be fully configured in Payment settings first.": "Crée un produit Pancake dans la boutique enregistrée avec le titre et le prix de ce forfait. Waffo Pancake doit dabord être entièrement configuré dans les paramètres de paiement.",
"Creating...": "Création...",
"Creation failed": "Échec de la création",
"Credential generated": "Identifiant généré",
@ -1122,6 +1131,7 @@
"Delete invalid redemption codes": "Supprimer les codes de rachat invalides",
"Delete Invalid Redemption Codes?": "Supprimer les codes de rachat invalides ?",
"Delete logs": "Supprimer les journaux",
"Delete mapping": "Supprimer le mappage",
"Delete Model": "Supprimer le modèle",
"Delete Models?": "Supprimer les modèles ?",
"Delete Provider": "Supprimer le fournisseur",
@ -1249,6 +1259,8 @@
"Drawing task records": "Historique des tâches de dessin",
"Duplicate": "Dupliquer",
"Duplicate group names: {{names}}": "Noms de groupe en double : {{names}}",
"Duplicate source model mappings are not allowed": "Les mappages de modèles source en double ne sont pas autorisés",
"Duplicate source model(s): {{models}}": "Modèle(s) source en double : {{models}}",
"Duration": "Durée",
"Duration (hours)": "Durée (heures)",
"Duration Settings": "Paramètres de durée",
@ -1401,6 +1413,7 @@
"Endpoint config": "Configuration de l'endpoint",
"Endpoint Configuration": "Configuration du point de terminaison",
"Endpoint Type": "Type de point de terminaison",
"Endpoint, provider-specific settings, and credentials.": "Point de terminaison, paramètres propres au fournisseur et identifiants.",
"Endpoint:": "Point de terminaison :",
"Endpoints": "Points de terminaison",
"English": "Anglais",
@ -1704,14 +1717,15 @@
"Filled {{count}} model(s)": "{{count}} modèle(s) rempli(s)",
"Filled {{count}} related model(s)": "{{count}} modèle(s) associé(s) rempli(s)",
"Filter": "Filtre",
"Filter by API key...": "Filtrer par clé API...",
"Filter by channel ID": "Filtrer par ID de canal",
"Filter by group": "Filtrer par groupe",
"Filter by Midjourney task ID": "Filtrer par ID de tâche Midjourney",
"Filter by model name...": "Filtrer par nom du modèle...",
"Filter by model...": "Filtrer par modèle...",
"Filter by name or ID...": "Filtrer par nom ou ID...",
"Filter by name or key...": "Filtrer par nom ou clé...",
"Filter by name, ID, or key...": "Filtrer par nom, ID ou clé...",
"Filter by name...": "Filtrer par nom...",
"Filter by price field": "Filtrer par champ de prix",
"Filter by ratio type": "Filtrer par type de ratio",
"Filter by request ID": "Filtrer par ID de requête",
@ -1742,6 +1756,7 @@
"FluentRead extension not detected. Please ensure it is installed and active.": "Extension FluentRead non détectée. Veuillez vous assurer qu'elle est installée et activée.",
"Flush interval (minutes)": "Intervalle décriture (minutes)",
"Follow the guided steps to prepare your workspace before the first login.": "Suivez les étapes guidées pour préparer votre espace de travail avant la première connexion.",
"Font": "Police",
"Footer": "Pied de page",
"Footer text displayed at the bottom of pages": "Texte de pied de page affiché en bas des pages",
"footer.columns.about.links.aboutProject": "À propos du projet",
@ -1935,7 +1950,7 @@
"How client credentials are sent to the token endpoint": "Comment les informations d'identification client sont envoyées au point de terminaison de jeton",
"How frequently the system tests all channels": "Fréquence à laquelle le système teste tous les canaux",
"How It Works": "Comment ça marche",
"How model mapping works": "Comment fonctionne le mappage de modèle",
"How model mapping works": "Fonctionnement du mappage des modèles",
"How much to charge for each US dollar of balance (Epay)": "Montant à facturer pour chaque dollar US de solde (Epay)",
"How this model name should match requests": "Comment ce nom de modèle doit correspondre aux requêtes",
"How to deliver the resulting image": "Comment délivrer l'image résultante",
@ -2177,6 +2192,7 @@
"Load template...": "Charger le modèle...",
"Loader": "Chargeur",
"Loading": "Chargement",
"Loading channel details": "Chargement des détails du canal",
"Loading configuration": "Chargement de la configuration",
"Loading content settings...": "Chargement des paramètres de contenu...",
"Loading current models...": "Chargement des modèles actuels...",
@ -2293,7 +2309,6 @@
"Minimum:": "Minimum :",
"Minor blips in the last 30 days": "Légères perturbations sur les 30 derniers jours",
"Mint a fresh pair below — or pick an existing one further down. Click Save when ready.": "Créez une nouvelle paire ci-dessous, ou choisissez une paire existante plus bas. Cliquez sur Enregistrer lorsque vous êtes prêt.",
"Creates a Pancake product in the saved store using this plans title and price. Requires Waffo Pancake to be fully configured in Payment settings first.": "Crée un produit Pancake dans la boutique enregistrée avec le titre et le prix de ce forfait. Waffo Pancake doit dabord être entièrement configuré dans les paramètres de paiement.",
"Minute": "Minute",
"minutes": "minutes",
"Missing code": "Code manquant",
@ -2324,7 +2339,9 @@
"Model Mapping": "Mappage de modèle",
"Model Mapping (JSON)": "Mappage de modèle (JSON)",
"Model Mapping must be a JSON object like": "Le mappage de modèle doit être un objet JSON tel que",
"Model mapping must be a JSON object with string values": "Le mappage de modèles doit être un objet JSON avec des valeurs de chaîne",
"Model mapping must be valid JSON": "La cartographie des modèles doit être un JSON valide",
"Model mapping values must be strings": "Les valeurs du mappage de modèles doivent être des chaînes",
"Model name": "Nom du modèle",
"Model Name": "Nom du modèle",
"Model Name *": "Nom du modèle *",
@ -2333,8 +2350,8 @@
"Model not found": "Modèle introuvable",
"Model performance metrics": "Indicateurs de performance des modèles",
"Model Price": "Prix du modèle",
"Model Price Not Configured": "Prix du modèle non configuré",
"Model price is not configured. Please complete model pricing in settings.": "Le prix du modèle n'est pas configuré. Veuillez compléter la tarification du modèle dans les paramètres.",
"Model Price Not Configured": "Prix du modèle non configuré",
"Model prices": "Prix des modèles",
"Model prices reset successfully": "Prix des modèles réinitialisés avec succès",
"Model Pricing": "Tarification des modèles",
@ -2385,6 +2402,7 @@
"months": "mois",
"Moonshot": "Moonshot",
"More": "Plus",
"More Apps": "Plus",
"more mapping": "plus de mappage",
"More templates...": "Autres modèles…",
"More than 999 days left": "Plus de 999 jours restants",
@ -2437,6 +2455,7 @@
"Name Suffix": "Suffixe du nom",
"Name the channel and choose the upstream provider.": "Nommez le canal et choisissez le fournisseur amont.",
"Name the channel, choose the provider, configure API access, and set credentials.": "Nommez le canal, choisissez le fournisseur, configurez laccès API et définissez les identifiants.",
"Name, provider type, and availability.": "Nom, type de fournisseur et disponibilité.",
"name@example.com": "name@example.com",
"Native format": "Format natif",
"Need a redemption code?": "Besoin d'un code d'échange ?",
@ -2446,6 +2465,7 @@
"Network proxy for this channel (supports socks5 protocol)": "Proxy réseau pour ce canal (supporte le protocole socks5)",
"Never": "Jamais",
"Never expires": "N'expire jamais",
"Never used an API Gateway?": "Vous n'avez jamais utilisé de passerelle API ?",
"NEW": "NOUVEAU",
"New API": "New API",
"New API &lt;noreply@example.com&gt;": "New API &lt;noreply@example.com&gt;",
@ -2532,6 +2552,7 @@
"No Logs Found": "Aucun journal trouvé",
"No mappings configured. Click \"Add Row\" to get started.": "Aucun mappage configuré. Cliquez sur « Ajouter une ligne » pour commencer.",
"No matches found": "Aucune correspondance trouvée",
"No matching items": "Aucun élément correspondant",
"No matching results": "Aucun résultat correspondant",
"No matching rules": "Aucune règle correspondante",
"No messages yet": "Pas encore de messages",
@ -2950,6 +2971,7 @@
"Please upload key file(s)": "Veuillez télécharger le (s) fichier(s) clé (s)",
"Please wait a moment before trying again.": "Veuillez patienter un instant avant de réessayer.",
"Please wait a moment, human check is initializing...": "Veuillez patienter un instant, la vérification humaine s'initialise...",
"Please wait before editing to avoid overwriting saved values.": "Veuillez patienter avant de modifier afin d'éviter d'écraser les valeurs enregistrées.",
"Policy JSON": "JSON de stratégie",
"Polling": "Sondage",
"Polling mode requires Redis and memory cache, otherwise performance will be significantly degraded": "Le mode d'interrogation nécessite Redis et un cache mémoire, sinon les performances seront considérablement dégradées",
@ -2983,10 +3005,12 @@
"Prepend to Start": "Ajouter au début",
"Prepend value to array / string / object start": "Ajouter la valeur au début du tableau / chaîne / objet",
"Preserve the original field when applying this rule": "Conserver le champ original lors de lapplication de cette règle",
"Preset groups": "Groupes prédéfinis",
"Preset recharge amounts (JSON array)": "Montants de recharge prédéfinis (tableau JSON)",
"Preset recharge amounts displayed to users": "Montants de recharge prédéfinis affichés aux utilisateurs",
"Preset Template": "Modèle prédéfini",
"Preset templates": "Modèles prédéfinis",
"preset.anthropic": "Anthropic",
"preset.default": "Par défaut",
"preset.forest-whisper": "Murmure de la forêt",
"preset.lake-view": "Vue sur le lac",
@ -3082,6 +3106,7 @@
"Public rankings page based on live usage data.": "Page publique des classements basée sur les données d'utilisation réelles.",
"Publish Date": "Date de publication",
"Published": "Publié",
"Published models, groups, and model remapping rules.": "Modèles publiés, groupes et règles de remappage des modèles.",
"Published:": "Publié :",
"Pull": "Télécharger",
"Pull model": "Télécharger le modèle",
@ -3098,6 +3123,7 @@
"Querying...": "Recherche en cours...",
"Question": "Question",
"Queued": "En file",
"Quick actions": "Actions rapides",
"Quick insert common payment methods": "Insérer rapidement les méthodes de paiement courantes",
"Quick Range": "Plage rapide",
"Quick Setup from Preset": "Configuration rapide à partir d'un préréglage",
@ -3219,6 +3245,7 @@
"Remaining:": "Restant :",
"Remark": "Remarque",
"Remove": "Supprimer",
"Remove {{value}}": "Retirer {{value}}",
"Remove ${{amount}}": "Supprimer ${{amount}}",
"Remove all log entries created before the selected timestamp.": "Supprimer toutes les entrées de journal créées avant l'horodatage sélectionné.",
"Remove attachment": "Supprimer la pièce jointe",
@ -3226,6 +3253,7 @@
"Remove Duplicates": "Supprimer les doublons",
"Remove filter": "Supprimer le filtre",
"Remove functionResponse.id field": "Supprimer le champ functionResponse.id",
"Remove mapped targets": "Retirer les cibles mappées",
"Remove Models": "Supprimer des modèles",
"Remove Passkey": "Supprimer le Passkey",
"Remove Passkey?": "Supprimer la clé d'accès ?",
@ -3329,7 +3357,6 @@
"Retain last N files": "Conserver les N derniers fichiers",
"Retention days": "Jours de rétention",
"Retry": "Réessayer",
"auth.resetPasswordConfirm.retry": "Réessayer ({{seconds}}s)",
"Retry Chain": "Chaîne de tentatives",
"Retry Suggestion": "Suggestion de relance",
"Retry Times": "Nombre de tentatives",
@ -3339,7 +3366,6 @@
"Return Error": "Retourner l'erreur",
"Return per-token log probabilities": "Retourner les log-probabilités par jeton",
"Return to dashboard": "Retour au tableau de bord",
"auth.resetPasswordConfirm.backToLogin": "Retour à la connexion",
"Return vector embeddings for inputs": "Renvoyer des embeddings vectoriels pour les entrées",
"Reveal API key": "Afficher la clé API",
"Reveal key": "Révéler la clé",
@ -3491,11 +3517,11 @@
"Select all (filtered)": "Tout sélectionner (filtré)",
"Select all models": "Sélectionner tous les modèles",
"Select All Visible": "Sélectionner tout ce qui est visible",
"Select model {{model}}": "Sélectionner le modèle {{model}}",
"Select an operation mode and enter the amount": "Sélectionnez un mode d'opération et entrez le montant",
"Select announcement type": "Sélectionner le type d'annonce",
"Select at least one field to overwrite.": "Sélectionnez au moins un champ à écraser.",
"Select at least one target model": "Sélectionnez au moins un modèle cible",
"Select body font": "Sélectionner la police du corps de texte",
"Select border radius": "Sélectionner le rayon de bordure",
"Select channel type": "Sélectionner le type de canal",
"Select color preset": "Sélectionner un préréglage de couleur",
@ -3518,6 +3544,7 @@
"Select layout style": "Sélectionner le style de mise en page",
"Select locations": "Sélectionner des emplacements",
"Select Model": "Sélectionner le modèle",
"Select model {{model}}": "Sélectionner le modèle {{model}}",
"Select models (empty for allow all)": "Sélectionner les modèles (vide pour autoriser tout)",
"Select models and apply to channel models list.": "Sélectionnez les modèles et appliquez-les à la liste des modèles de canaux.",
"Select models or add custom ones": "Sélectionner des modèles ou en ajouter des personnalisés",
@ -3547,6 +3574,7 @@
"Select vendor": "Sélectionner le fournisseur",
"Selectable groups": "Groupes sélectionnables",
"selected": "sélectionné",
"Selected {{count}}": "{{count}} sélectionné(s)",
"selected channel(s). Leave empty to remove tag.": "canal(aux) sélectionné(s). Laisser vide pour supprimer l'étiquette.",
"Selected conflicts were overwritten successfully.": "Les conflits sélectionnés ont été écrasés avec succès.",
"Selected when creating a token and used as the default billing group for API calls.": "Sélectionné lors de la création dun jeton et utilisé comme groupe de facturation par défaut pour les appels API.",
@ -3690,6 +3718,7 @@
"Status & Sync": "Statut et synchronisation",
"Status Code": "Code de statut",
"Status Code Mapping": "Mappage des codes d'état",
"Status code mapping must use valid HTTP status codes": "Le mappage des codes d'état doit utiliser des codes d'état HTTP valides",
"Status Page Slug": "Slug de la page d'état",
"Status short": "État",
"Status:": "Statut :",
@ -3753,12 +3782,14 @@
"Sunset Glow": "Lueur du couchant",
"Super Admin": "Super Administrateur",
"Support for high concurrency with automatic load balancing": "Prise en charge de la haute concurrence avec équilibrage de charge automatique",
"Supported Applications": "Applications prises en charge",
"Supported Imagine Models": "Modèles Imagine pris en charge",
"Supported modalities": "Modalités prises en charge",
"Supported parameters": "Paramètres pris en charge",
"Supported variables": "Variables supportées",
"Supports `-thinking`, `-thinking-": "Prend en charge `-thinking`, `-thinking-",
"Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.": "Prend en charge le balisage HTML ou l'intégration d'iframe. Entrez le code HTML directement, ou fournissez une URL complète pour l'intégrer automatiquement en tant qu'iframe.",
"Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.": "Prend en charge la configuration en un clic et s'adapte parfaitement à la configuration multi-protocole NewAPI.",
"Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.": "Prend en charge PNG, JPG, SVG ou WebP. Taille recommandée : 128×128 ou moins.",
"Sustained tokens per second": "Jetons par seconde soutenus",
"Swap Face": "Échanger le visage",
@ -3897,6 +3928,7 @@
"This action will permanently remove 2FA protection from your account.": "Cette action supprimera définitivement la protection 2FA de votre compte.",
"This channel is not an Ollama channel.": "Ce canal n'est pas un canal Ollama.",
"This channel type does not support fetching models": "Ce type de canal ne prend pas en charge la récupération de modèles",
"This channel type requires additional configuration": "Ce type de canal nécessite une configuration supplémentaire",
"This confirmation unlocks payment, redemption code, subscription plan, and invitation reward features. Please read the statements carefully.": "Cette confirmation déverrouille les fonctionnalités de paiement, de codes de兑换, de forfaits dabonnement et de récompenses dinvitation. Veuillez lire attentivement les déclarations.",
"This controls model request rate limiting. Web/API route throttling is configured by environment variables and may still return 429.": "Ce réglage contrôle la limitation des requêtes de modèles. La limitation des routes Web/API se configure via les variables d'environnement et peut encore renvoyer 429.",
"This data may be unreliable, use with caution": "Ces données peuvent être peu fiables, utilisez-les avec prudence",
@ -4215,6 +4247,7 @@
"Use external tools to extend capabilities": "Utiliser des outils externes pour étendre les capacités",
"Use our unified OpenAI-compatible endpoint in your applications": "Utilisez notre point de terminaison unifié compatible OpenAI dans vos applications",
"Use Passkey to sign in without entering your password.": "Utilisez une clé d'accès (Passkey) pour vous connecter sans saisir votre mot de passe.",
"Use presets or upstream discovery to populate the model list faster.": "Utilisez des préréglages ou la découverte en amont pour remplir plus vite la liste des modèles.",
"Use secure connection when sending emails": "Utiliser une connexion sécurisée lors de l'envoi d'e-mails",
"Use sidebar shortcut": "Utiliser le raccourci de la barre latérale",
"Use the full-width table to scan prices, then select a row to edit it here.": "Parcourez les prix dans le tableau, puis sélectionnez une ligne pour la modifier ici.",
@ -4280,6 +4313,7 @@
"Vary": "Varier",
"Vary (Strong)": "Varier (fort)",
"Vary (Subtle)": "Varier (subtil)",
"Vast Range of AI Models": "Vaste gamme de modèles d'IA",
"Vendor": "Fournisseur",
"Vendor deleted successfully": "Fournisseur supprimé avec succès",
"Vendor Name *": "Nom du fournisseur *",
@ -4302,8 +4336,10 @@
"Verifying credentials and pulling stores from your Pancake account...": "Vérification des identifiants et récupération des boutiques depuis votre compte Pancake...",
"Version Overrides": "Remplacements de version",
"Vertex AI": "Vertex AI",
"Vertex AI API Key mode does not support batch creation": "Le mode clé API Vertex AI ne prend pas en charge la création par lot",
"Vertex AI does not support functionResponse.id. Enable this to remove the field automatically.": "Vertex AI ne prend pas en charge functionResponse.id. Activez ceci pour supprimer automatiquement ce champ.",
"Vertex AI Key Format": "Format de clé Vertex AI",
"Vertex AI service account key must be valid JSON": "La clé de compte de service Vertex AI doit être un JSON valide",
"Video": "Vidéo",
"Video length in seconds": "Durée de la vidéo en secondes",
"Video Remix": "Remix vidéo",
@ -4466,7 +4502,6 @@
"Your GitHub OAuth Client ID": "Votre ID Client OAuth GitHub",
"Your GitHub OAuth Client Secret": "Votre Secret Client OAuth GitHub",
"Your new backup codes are ready": "Vos nouveaux codes de secours sont prêts",
"auth.resetPasswordConfirm.success": "Votre mot de passe a été réinitialisé avec succès",
"Your Referral Link": "Votre lien de parrainage",
"Your setup guide is collapsed so usage stays in focus.": "Le guide de configuration est réduit afin de garder l'utilisation au premier plan.",
"Your system access token for API authentication. Keep it secure and don't share it with others.": "Votre jeton d'accès système pour l'authentification API. Gardez-le en sécurité et ne le partagez pas avec d'autres.",

View File

@ -37,7 +37,6 @@
"{{count}} IP(s)": "{{count}} IP",
"{{count}} log entries removed.": "{{count}} 件のログエントリを削除しました。",
"{{count}} minutes ago": "{{count}} 分前",
"{{count}} model(s)": "{{count}} モデル",
"{{count}} models": "{{count}} モデル",
"{{count}} months ago": "{{count}} ヶ月前",
"{{count}} override": "{{count}} 個のオーバーライド",
@ -107,6 +106,7 @@
"Accept Unpriced Models": "価格設定されていないモデルを許可",
"Accepts a JSON array of model identifiers that support the Imagine API.": "Imagine APIをサポートするモデル識別子のJSON配列を受け入れます。",
"Accepts comma-separated status codes and inclusive ranges.": "カンマ区切りのステータスコードと包含範囲を受け入れます。",
"Access a vast selection of models via a standard, unified API protocol. Power AI applications, manage digital assets, and connect the Future.": "標準的で統一されたAPIプロトコルを介して、膨大なモデルにアクセス。AIアプリケーションを強化し、デジタル資産を管理し、未来へと繋げます。",
"Access Denied Message": "アクセス拒否メッセージ",
"Access Forbidden": "アクセス禁止",
"Access Policy (JSON)": "アクセスポリシー (JSON)",
@ -135,6 +135,7 @@
"Actual Model": "実際のモデル",
"Actual Model:": "実際のモデル:",
"Add": "追加",
"Add \"{{value}}\"": "\"{{value}}\" を追加",
"Add {{title}}": "{{title}}を追加",
"Add a group identifier to the auto assignment list.": "自動割り当てリストにグループ識別子を追加します。",
"Add a new API key by providing necessary info.": "必要な情報を提供して新しいAPIキーを追加。",
@ -152,7 +153,7 @@
"Add condition": "条件を追加",
"Add Condition": "条件を追加",
"Add credits": "クレジットを追加",
"Add custom model(s), comma-separated": "カスタムモデルを追加 (コンマ区切り)",
"Add custom model \"{{value}}\"": "カスタムモデル「{{value}}」を追加",
"Add discount tier": "割引ティアを追加",
"Add each model or tag you want to include.": "含めたい各モデルまたはタグを追加。",
"Add FAQ": "FAQ追加",
@ -164,6 +165,7 @@
"Add group rules": "グループルールを追加",
"Add Mapping": "マッピングを追加",
"Add method": "メソッドを追加",
"Add missing models": "不足しているモデルを追加",
"Add Mode": "モードを追加",
"Add model": "モデル追加",
"Add Model": "モデルを追加",
@ -192,7 +194,6 @@
"Add User": "ユーザーを追加",
"Add user group": "ユーザーグループを追加",
"Add your API keys, set up channels and configure access permissions": "APIキーを追加し、チャネルを設定してアクセス権限を構成します",
"Added {{count}} custom model(s)": "{{count}} 個のカスタムモデルを追加しました",
"Added {{count}} model(s)": "{{count}} 個のモデルを追加しました",
"Added successfully": "追加に成功しました",
"Additional Conditions": "追加条件",
@ -201,8 +202,8 @@
"Additional Limit": "追加上限",
"Additional Limits": "追加上限",
"Additional metered capability": "追加の従量制機能",
"Adjust Quota": "クォータを調整",
"Adjust filters, then search to refresh the logs.": "フィルターを調整してから検索し、ログを更新します。",
"Adjust Quota": "クォータを調整",
"Adjust response formatting, prompt behavior, proxy, and upstream automation.": "レスポンス形式、プロンプト動作、プロキシ、上流自動化を調整します。",
"Adjust the appearance and layout to suit your preferences.": "好みに合わせて外観とレイアウトを調整します。",
"Admin": "管理者",
@ -236,6 +237,7 @@
"aggregates 50+ AI providers behind one unified API. Manage access, track costs, and scale effortlessly.": "50以上のAIプロバイダーを統一APIで集約。アクセス管理、コスト追跡、スケーリングを簡単に。",
"Aggregation bucket": "集計バケット",
"AGPL v3.0 License": "AGPL v3.0ライセンス",
"AI Application Infrastructure Foundation": "AI アプリケーションのインフラ基盤",
"AI model testing environment": "AIモデルテスト環境",
"AI models": "AIモデル",
"AI models supported": "AIモデル対応",
@ -263,7 +265,6 @@
"All Types": "すべてのタイプ",
"All upstream data is trusted": "すべてのアップストリームデータは信頼されています",
"All Vendors": "すべてのベンダー",
"All Your AI Models": "すべてのAIモデル",
"All-time": "全期間",
"Allocated Memory": "割り当て済みメモリ",
"Allow accountFilter parameter": "accountFilter パラメータを許可",
@ -424,6 +425,11 @@
"Audio Tokens": "音声トークン",
"Auth configured": "認証設定済み",
"Auth Style": "認証スタイル",
"auth.resetPasswordConfirm.backToLogin": "ログインに戻る",
"auth.resetPasswordConfirm.confirm": "パスワードリセットを確認",
"auth.resetPasswordConfirm.description": "新しいパスワードを生成するには、リセット要求を確認してください。",
"auth.resetPasswordConfirm.retry": "再試行 ({{seconds}}秒)",
"auth.resetPasswordConfirm.success": "パスワードが正常にリセットされました",
"Authentication": "認証",
"Authenticator code": "認証コード",
"Authorization Endpoint": "認可エンドポイント",
@ -502,6 +508,7 @@
"Base Price": "基本価格",
"Base rate limit windows for this account.": "このアカウント向けの基本レート制限ウィンドウ。",
"Base URL": "ベースURL",
"Base URL is required for this channel type": "このチャンネルタイプには Base URL が必要です",
"Base URL of your Uptime Kuma instance": "Uptime KumaインスタンスのベースURL",
"Basic Authentication": "基本認証",
"Basic Configuration": "基本設定",
@ -671,6 +678,7 @@
"Check for updates": "更新を確認",
"Check in daily to receive random quota rewards": "毎日チェックインして、ランダムなノルマ報酬を受け取りましょう",
"Check in now": "今すぐチェックイン",
"Check out the Quick Start": "クイックスタートをご確認ください",
"Check resolved IPs against IP filters even when accessing by domain": "ドメインによるアクセスであっても、解決されたIPをIPフィルターと照合してチェックします",
"Check-in failed": "チェックインできませんでした",
"Check-in Rewards": "チェックイン報酬",
@ -759,8 +767,10 @@
"Codex": "Codex",
"Codex Account & Usage": "Codex アカウントと使用量",
"Codex Authorization": "Codex認証",
"Codex channels do not support batch creation": "Codex チャンネルは一括作成をサポートしていません",
"Codex channels use an OAuth JSON credential as the key.": "CodexチャンネルはOAuth JSON認証情報をキーとして使用します。",
"Codex CLI Header Passthrough": "Codex CLI ヘッダーパススルー",
"Codex credential must be a JSON object with access_token and account_id": "Codex 認証情報は access_token と account_id を含む JSON オブジェクトである必要があります",
"Cohere": "Cohere",
"Collapse": "折りたたむ",
"Collapse All": "すべて折りたたむ",
@ -867,8 +877,6 @@
"Confirm New Password": "新しいパスワードの確認",
"Confirm password": "パスワードの確認",
"Confirm Payment": "支払いの確認",
"auth.resetPasswordConfirm.confirm": "パスワードリセットを確認",
"auth.resetPasswordConfirm.description": "新しいパスワードを生成するには、リセット要求を確認してください。",
"Confirm Selection": "選択の確認",
"Confirm settings and finish setup": "設定を確認してセットアップを完了",
"confirm that I bear legal responsibility arising from deployment": "デプロイに起因する法的責任を負うことを確認します",
@ -999,6 +1007,7 @@
"Create, revoke, and audit API tokens.": "APIトークンを作成、取り消し、監査。",
"Created": "作成済み",
"Created At": "作成日時",
"Creates a Pancake product in the saved store using this plans title and price. Requires Waffo Pancake to be fully configured in Payment settings first.": "保存済みストアに、このプランのタイトルと価格を使って Pancake 商品を作成します。事前に支払い設定で Waffo Pancake を完全に設定する必要があります。",
"Creating...": "作成中...",
"Creation failed": "作成に失敗しました",
"Credential generated": "認証情報を生成しました",
@ -1122,6 +1131,7 @@
"Delete invalid redemption codes": "無効な引き換えコードを削除",
"Delete Invalid Redemption Codes?": "無効な引き換えコードを削除しますか?",
"Delete logs": "ログを削除",
"Delete mapping": "マッピングを削除",
"Delete Model": "モデルを削除",
"Delete Models?": "モデルを削除しますか?",
"Delete Provider": "プロバイダーを削除",
@ -1249,6 +1259,8 @@
"Drawing task records": "描画タスク記録",
"Duplicate": "複製",
"Duplicate group names: {{names}}": "重複するグループ名: {{names}}",
"Duplicate source model mappings are not allowed": "重複したソースモデルのマッピングは許可されていません",
"Duplicate source model(s): {{models}}": "重複したソースモデル: {{models}}",
"Duration": "所要時間",
"Duration (hours)": "期間(時間)",
"Duration Settings": "有効期間設定",
@ -1401,6 +1413,7 @@
"Endpoint config": "エンドポイント設定",
"Endpoint Configuration": "エンドポイント設定",
"Endpoint Type": "エンドポイントタイプ",
"Endpoint, provider-specific settings, and credentials.": "エンドポイント、プロバイダー固有の設定、認証情報。",
"Endpoint:": "エンドポイント:",
"Endpoints": "エンドポイント",
"English": "英語",
@ -1704,14 +1717,15 @@
"Filled {{count}} model(s)": "{{count}} 個のモデルを補完しました",
"Filled {{count}} related model(s)": "{{count}} 個の関連モデルを補完しました",
"Filter": "フィルター",
"Filter by API key...": "APIキーでフィルター...",
"Filter by channel ID": "チャンネルIDでフィルター",
"Filter by group": "グループでフィルター",
"Filter by Midjourney task ID": "MidjourneyタスクIDでフィルター",
"Filter by model name...": "モデル名でフィルター...",
"Filter by model...": "モデルでフィルタリング...",
"Filter by name or ID...": "名前またはIDでフィルター...",
"Filter by name or key...": "名前またはキーでフィルター...",
"Filter by name, ID, or key...": "名前、ID、またはキーでフィルター...",
"Filter by name...": "名前でフィルター...",
"Filter by price field": "価格フィールドでフィルター",
"Filter by ratio type": "倍率タイプで絞り込み",
"Filter by request ID": "リクエストIDで絞り込み",
@ -1742,6 +1756,7 @@
"FluentRead extension not detected. Please ensure it is installed and active.": "FluentRead 拡張機能が検出されませんでした。インストールされていて有効になっていることを確認してください。",
"Flush interval (minutes)": "書き込み間隔(分)",
"Follow the guided steps to prepare your workspace before the first login.": "初回ログイン前に、ガイド付きの手順に従ってワークスペースを準備してください。",
"Font": "フォント",
"Footer": "フッター",
"Footer text displayed at the bottom of pages": "ページ下部に表示されるフッターテキスト",
"footer.columns.about.links.aboutProject": "プロジェクトについて",
@ -2177,6 +2192,7 @@
"Load template...": "テンプレートをロード...",
"Loader": "ローダー",
"Loading": "読み込み中",
"Loading channel details": "チャンネル詳細を読み込み中",
"Loading configuration": "設定を読み込んでいます",
"Loading content settings...": "コンテンツ設定をロード中...",
"Loading current models...": "現在のモデルをロード中...",
@ -2293,7 +2309,6 @@
"Minimum:": "最小:",
"Minor blips in the last 30 days": "直近 30 日で軽微な障害あり",
"Mint a fresh pair below — or pick an existing one further down. Click Save when ready.": "下で新しいペアを作成するか、さらに下で既存のものを選択してください。準備ができたら保存をクリックします。",
"Creates a Pancake product in the saved store using this plans title and price. Requires Waffo Pancake to be fully configured in Payment settings first.": "保存済みストアに、このプランのタイトルと価格を使って Pancake 商品を作成します。事前に支払い設定で Waffo Pancake を完全に設定する必要があります。",
"Minute": "分",
"minutes": "分",
"Missing code": "コードが不足しています",
@ -2324,7 +2339,9 @@
"Model Mapping": "モデルマッピング",
"Model Mapping (JSON)": "モデルマッピング (JSON)",
"Model Mapping must be a JSON object like": "モデルマッピングは次のようなJSONオブジェクトである必要があります",
"Model mapping must be a JSON object with string values": "モデルマッピングは文字列値を持つ JSON オブジェクトである必要があります",
"Model mapping must be valid JSON": "モデルマッピングは有効な JSON である必要があります",
"Model mapping values must be strings": "モデルマッピングの値は文字列である必要があります",
"Model name": "モデル名",
"Model Name": "モデル名",
"Model Name *": "モデル名 *",
@ -2333,8 +2350,8 @@
"Model not found": "モデルが見つかりません",
"Model performance metrics": "モデル性能メトリクス",
"Model Price": "モデル価格",
"Model Price Not Configured": "モデル価格が未設定",
"Model price is not configured. Please complete model pricing in settings.": "モデル価格が未設定です。設定でモデル料金を補完してください。",
"Model Price Not Configured": "モデル価格が未設定",
"Model prices": "モデル価格",
"Model prices reset successfully": "モデル価格が正常にリセットされました",
"Model Pricing": "モデル料金",
@ -2385,6 +2402,7 @@
"months": "ヶ月",
"Moonshot": "Moonshot",
"More": "もっと見る",
"More Apps": "さらに",
"more mapping": "さらにマッピング",
"More templates...": "ほかのテンプレート…",
"More than 999 days left": "999日以上",
@ -2437,6 +2455,7 @@
"Name Suffix": "名前サフィックス",
"Name the channel and choose the upstream provider.": "チャンネル名を設定し、上流プロバイダーを選択します。",
"Name the channel, choose the provider, configure API access, and set credentials.": "チャンネル名を設定し、プロバイダーを選択し、API アクセスと認証情報を設定します。",
"Name, provider type, and availability.": "名前、プロバイダー種別、利用可否。",
"name@example.com": "name@example.com",
"Native format": "ネイティブ形式",
"Need a redemption code?": "引き換えコードが必要ですか?",
@ -2446,6 +2465,7 @@
"Network proxy for this channel (supports socks5 protocol)": "このチャネルのネットワークプロキシ (socks5プロトコルをサポート)",
"Never": "しない",
"Never expires": "無期限",
"Never used an API Gateway?": "APIゲートウェイを一度も使用したことがありませんか",
"NEW": "NEW",
"New API": "新しいAPI",
"New API &lt;noreply@example.com&gt;": "新しいAPI __ PH_0 __",
@ -2532,6 +2552,7 @@
"No Logs Found": "ログが見つかりません",
"No mappings configured. Click \"Add Row\" to get started.": "マッピングが設定されていません。「行を追加」をクリックして開始してください。",
"No matches found": "一致するものが見つかりません",
"No matching items": "一致する項目がありません",
"No matching results": "一致する結果がありません",
"No matching rules": "一致するルールがありません",
"No messages yet": "まだメッセージがありません",
@ -2950,6 +2971,7 @@
"Please upload key file(s)": "キーファイルをアップロードしてください",
"Please wait a moment before trying again.": "しばらく待ってからもう一度お試しください。",
"Please wait a moment, human check is initializing...": "しばらくお待ちください、人間チェックを初期化中です...",
"Please wait before editing to avoid overwriting saved values.": "保存済みの値を上書きしないよう、編集前に読み込み完了をお待ちください。",
"Policy JSON": "ポリシーJSON",
"Polling": "ポーリング",
"Polling mode requires Redis and memory cache, otherwise performance will be significantly degraded": "ポーリングモードにはRedisとメモリキャッシュが必要です。そうでない場合、パフォーマンスが大幅に低下します",
@ -2983,10 +3005,12 @@
"Prepend to Start": "先頭に追加",
"Prepend value to array / string / object start": "配列/文字列/オブジェクトの先頭に値を追加",
"Preserve the original field when applying this rule": "このルール適用時に元のフィールドを保持します",
"Preset groups": "プリセットグループ",
"Preset recharge amounts (JSON array)": "プリセットチャージ金額 (JSON配列)",
"Preset recharge amounts displayed to users": "ユーザーに表示されるプリセットチャージ金額",
"Preset Template": "プリセットテンプレート",
"Preset templates": "プリセットのテンプレート",
"preset.anthropic": "Anthropic",
"preset.default": "デフォルト",
"preset.forest-whisper": "フォレストウィスパー",
"preset.lake-view": "レイクビュー",
@ -3082,6 +3106,7 @@
"Public rankings page based on live usage data.": "実際の利用データに基づく公開ランキングページ。",
"Publish Date": "公開日",
"Published": "公開済み",
"Published models, groups, and model remapping rules.": "公開モデル、グループ、モデルの再マッピングルール。",
"Published:": "公開済み:",
"Pull": "プル",
"Pull model": "モデルをプル",
@ -3098,6 +3123,7 @@
"Querying...": "クエリ中...",
"Question": "質問",
"Queued": "キュー中",
"Quick actions": "クイック操作",
"Quick insert common payment methods": "一般的な支払い方法をすばやく挿入",
"Quick Range": "クイック範囲",
"Quick Setup from Preset": "プリセットからクイックセットアップ",
@ -3219,6 +3245,7 @@
"Remaining:": "残り:",
"Remark": "備考",
"Remove": "削除",
"Remove {{value}}": "{{value}} を削除",
"Remove ${{amount}}": "${{amount}}を削除",
"Remove all log entries created before the selected timestamp.": "選択したタイムスタンプより前に作成されたすべてのログエントリを削除します。",
"Remove attachment": "添付ファイルを削除",
@ -3226,6 +3253,7 @@
"Remove Duplicates": "重複を削除",
"Remove filter": "フィルターを削除",
"Remove functionResponse.id field": "functionResponse.id フィールドを削除",
"Remove mapped targets": "マッピング先を削除",
"Remove Models": "モデルを削除",
"Remove Passkey": "Passkey連携解除",
"Remove Passkey?": "Passkeyを削除しますか",
@ -3329,7 +3357,6 @@
"Retain last N files": "最新N個のファイルを保持",
"Retention days": "保持日数",
"Retry": "再試行",
"auth.resetPasswordConfirm.retry": "再試行 ({{seconds}}秒)",
"Retry Chain": "リトライチェーン",
"Retry Suggestion": "リトライ提案",
"Retry Times": "再試行回数",
@ -3339,7 +3366,6 @@
"Return Error": "エラーを返す",
"Return per-token log probabilities": "トークンごとの対数確率を返します",
"Return to dashboard": "ダッシュボードに戻る",
"auth.resetPasswordConfirm.backToLogin": "ログインに戻る",
"Return vector embeddings for inputs": "入力に対してベクトル埋め込みを返却",
"Reveal API key": "APIキーを表示",
"Reveal key": "キーを表示",
@ -3491,11 +3517,11 @@
"Select all (filtered)": "フィルタ結果をすべて選択(S)",
"Select all models": "すべてのモデルを選択",
"Select All Visible": "表示中のすべてを選択",
"Select model {{model}}": "モデル {{model}} を選択",
"Select an operation mode and enter the amount": "操作モードを選択し、金額を入力してください",
"Select announcement type": "アナウンスメントタイプを選択",
"Select at least one field to overwrite.": "上書きするフィールドを少なくとも 1 つ選択してください。",
"Select at least one target model": "少なくとも1つの対象モデルを選択してください",
"Select body font": "本文フォントを選択",
"Select border radius": "角丸を選択",
"Select channel type": "チャネルタイプを選択",
"Select color preset": "カラープリセットを選択",
@ -3518,6 +3544,7 @@
"Select layout style": "レイアウトスタイルを選択",
"Select locations": "ロケーションを選択",
"Select Model": "モデルを選択",
"Select model {{model}}": "モデル {{model}} を選択",
"Select models (empty for allow all)": "モデルを選択 (すべて許可する場合は空)",
"Select models and apply to channel models list.": "モデルを選択し、チャンネルモデルリストに適用します。",
"Select models or add custom ones": "モデルを選択するか、カスタムモデルを追加",
@ -3547,6 +3574,7 @@
"Select vendor": "ベンダーを選択",
"Selectable groups": "選択可能なグループ",
"selected": "選択済み",
"Selected {{count}}": "{{count}} 件選択済み",
"selected channel(s). Leave empty to remove tag.": "選択されたチャンネル。タグを削除するには空のままにしてください。",
"Selected conflicts were overwritten successfully.": "選択した競合が正常に上書きされました。",
"Selected when creating a token and used as the default billing group for API calls.": "トークン作成時に選択され、API 呼び出しのデフォルト課金グループとして使われます。",
@ -3690,6 +3718,7 @@
"Status & Sync": "ステータスと同期",
"Status Code": "ステータスコード",
"Status Code Mapping": "ステータスコードマッピング",
"Status code mapping must use valid HTTP status codes": "ステータスコードマッピングには有効な HTTP ステータスコードを使用する必要があります",
"Status Page Slug": "ステータスページスラッグ",
"Status short": "状態",
"Status:": "ステータス:",
@ -3753,12 +3782,14 @@
"Sunset Glow": "サンセットグロウ",
"Super Admin": "スーパー管理者",
"Support for high concurrency with automatic load balancing": "自動ロードバランシングによる高並行性のサポート",
"Supported Applications": "サポートされているアプリケーション",
"Supported Imagine Models": "対応Imagineモデル",
"Supported modalities": "サポートされるモダリティ",
"Supported parameters": "対応パラメータ",
"Supported variables": "サポートされる変数",
"Supports `-thinking`, `-thinking-": "「-thinking」、「-thinking-」をサポートします",
"Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.": "HTMLマークアップまたはiframe埋め込みをサポートします。HTMLコードを直接入力するか、完全なURLを提供してiframeとして自動的に埋め込みます。",
"Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.": "ワンクリック設定をサポートし、NewAPIマルチプロトコル設定に完全に適応します。",
"Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.": "PNG、JPG、SVG、WebPに対応。推奨サイズ: 128×128以下。",
"Sustained tokens per second": "持続的な毎秒トークン数",
"Swap Face": "顔入れ替え",
@ -3897,6 +3928,7 @@
"This action will permanently remove 2FA protection from your account.": "この操作により、アカウントから2FA保護が完全に削除されます。",
"This channel is not an Ollama channel.": "このチャンネルはOllamaチャンネルではありません。",
"This channel type does not support fetching models": "このチャンネルタイプはモデルの取得をサポートしていません",
"This channel type requires additional configuration": "このチャンネルタイプには追加設定が必要です",
"This confirmation unlocks payment, redemption code, subscription plan, and invitation reward features. Please read the statements carefully.": "この確認により、支払い、引換コード、サブスクリプションプラン、招待報酬の機能が解除されます。各項目をよく読んでください。",
"This controls model request rate limiting. Web/API route throttling is configured by environment variables and may still return 429.": "これはモデルリクエストのレート制限を制御します。Web/API ルートのスロットリングは環境変数で設定され、引き続き 429 を返す場合があります。",
"This data may be unreliable, use with caution": "このデータは信頼できない可能性があります。注意して使用してください",
@ -4215,6 +4247,7 @@
"Use external tools to extend capabilities": "外部ツールを利用して機能を拡張",
"Use our unified OpenAI-compatible endpoint in your applications": "アプリケーションでOpenAI互換の統一エンドポイントを使用",
"Use Passkey to sign in without entering your password.": "パスワードを入力せずにサインインするには、パスキーを使用してください。",
"Use presets or upstream discovery to populate the model list faster.": "プリセットまたは上流検出を使ってモデルリストをすばやく入力します。",
"Use secure connection when sending emails": "メール送信時に安全な接続を使用する",
"Use sidebar shortcut": "サイドバーのショートカットを使用",
"Use the full-width table to scan prices, then select a row to edit it here.": "表で価格を確認し、行を選択してここで編集します。",
@ -4280,6 +4313,7 @@
"Vary": "バリエーション",
"Vary (Strong)": "バリエーション(強)",
"Vary (Subtle)": "バリエーション(微)",
"Vast Range of AI Models": "膨大なAIモデル",
"Vendor": "ベンダー",
"Vendor deleted successfully": "ベンダーが正常に削除されました",
"Vendor Name *": "ベンダー名 *",
@ -4302,8 +4336,10 @@
"Verifying credentials and pulling stores from your Pancake account...": "認証情報を検証し、Pancake アカウントからストアを取得しています...",
"Version Overrides": "バージョンオーバーライド",
"Vertex AI": "Vertex AI",
"Vertex AI API Key mode does not support batch creation": "Vertex AI API Key モードは一括作成をサポートしていません",
"Vertex AI does not support functionResponse.id. Enable this to remove the field automatically.": "Vertex AI は functionResponse.id フィールドをサポートしません。有効にすると自動的に削除します。",
"Vertex AI Key Format": "Vertex AIキー形式",
"Vertex AI service account key must be valid JSON": "Vertex AI サービスアカウントキーは有効な JSON である必要があります",
"Video": "動画",
"Video length in seconds": "動画の長さ(秒)",
"Video Remix": "動画 Remix",
@ -4466,7 +4502,6 @@
"Your GitHub OAuth Client ID": "あなたのGitHub OAuthクライアントID",
"Your GitHub OAuth Client Secret": "あなたのGitHub OAuthクライアントシークレット",
"Your new backup codes are ready": "新しいバックアップコードの準備ができました",
"auth.resetPasswordConfirm.success": "パスワードが正常にリセットされました",
"Your Referral Link": "あなたの紹介リンク",
"Your setup guide is collapsed so usage stays in focus.": "利用状況に集中できるよう、セットアップガイドを折りたたみました。",
"Your system access token for API authentication. Keep it secure and don't share it with others.": "API認証用のシステムアクセストークンです。安全に保管し、他者と共有しないでください。",

View File

@ -37,7 +37,6 @@
"{{count}} IP(s)": "{{count}} IP",
"{{count}} log entries removed.": "Удалено {{count}} записей журнала.",
"{{count}} minutes ago": "{{count}} минут назад",
"{{count}} model(s)": "{{count}} модел(ей)",
"{{count}} models": "моделей: {{count}}",
"{{count}} months ago": "{{count}} месяцев назад",
"{{count}} override": "{{count}} переопределений",
@ -107,6 +106,7 @@
"Accept Unpriced Models": "Принимать модели без цены",
"Accepts a JSON array of model identifiers that support the Imagine API.": "Принимает JSON-массив идентификаторов моделей, поддерживающих Imagine API.",
"Accepts comma-separated status codes and inclusive ranges.": "Принимает коды статуса, разделенные запятыми, и включающие диапазоны.",
"Access a vast selection of models via a standard, unified API protocol. Power AI applications, manage digital assets, and connect the Future.": "Получите доступ к огромному выбору моделей через стандартный, единый протокол API. Развивайте приложения ИИ, управляйте цифровыми активами и соединяйте будущее.",
"Access Denied Message": "Сообщение об отказе в доступе",
"Access Forbidden": "Доступ запрещен",
"Access Policy (JSON)": "Политика доступа (JSON)",
@ -135,6 +135,7 @@
"Actual Model": "Фактическая модель",
"Actual Model:": "Фактическая модель:",
"Add": "Добавить",
"Add \"{{value}}\"": "Добавить \"{{value}}\"",
"Add {{title}}": "Добавить {{title}}",
"Add a group identifier to the auto assignment list.": "Добавить идентификатор группы в список автоматического назначения.",
"Add a new API key by providing necessary info.": "Добавьте новый API-ключ, предоставив необходимую информацию.",
@ -152,7 +153,7 @@
"Add condition": "Добавить условие",
"Add Condition": "Добавить условие",
"Add credits": "Добавить средства",
"Add custom model(s), comma-separated": "Добавить пользовательскую модель(и), через запятую",
"Add custom model \"{{value}}\"": "Добавить пользовательскую модель «{{value}}»",
"Add discount tier": "Добавить уровень скидки",
"Add each model or tag you want to include.": "Добавьте каждую модель или тег, который хотите включить.",
"Add FAQ": "Добавить вопрос-ответ",
@ -164,6 +165,7 @@
"Add group rules": "Добавить правила группы",
"Add Mapping": "Добавить сопоставление",
"Add method": "Добавить метод",
"Add missing models": "Добавить отсутствующие модели",
"Add Mode": "Добавить режим",
"Add model": "Добавить модель",
"Add Model": "Добавить модель",
@ -192,7 +194,6 @@
"Add User": "Добавить пользователя",
"Add user group": "Добавить группу пользователей",
"Add your API keys, set up channels and configure access permissions": "Добавьте ваши API-ключи, настройте каналы и права доступа",
"Added {{count}} custom model(s)": "Добавлено {{count}} пользовательских моделей",
"Added {{count}} model(s)": "Добавлено {{count}} моделей",
"Added successfully": "Успешно добавлено",
"Additional Conditions": "Дополнительные условия",
@ -201,8 +202,8 @@
"Additional Limit": "Доп. лимит",
"Additional Limits": "Дополнительные лимиты",
"Additional metered capability": "Дополнительная зарезервированная ёмкость (metered)",
"Adjust Quota": "Изменить квоту",
"Adjust filters, then search to refresh the logs.": "Настройте фильтры, затем выполните поиск, чтобы обновить журналы.",
"Adjust Quota": "Изменить квоту",
"Adjust response formatting, prompt behavior, proxy, and upstream automation.": "Настройте форматирование ответов, поведение промпта, прокси и автоматизацию upstream.",
"Adjust the appearance and layout to suit your preferences.": "Настройте внешний вид и макет в соответствии с вашими предпочтениями.",
"Admin": "Администратор",
@ -236,6 +237,7 @@
"aggregates 50+ AI providers behind one unified API. Manage access, track costs, and scale effortlessly.": "объединяет 50+ ИИ-провайдеров за единым API. Управляйте доступом, отслеживайте затраты и масштабируйтесь без усилий.",
"Aggregation bucket": "Интервал агрегации",
"AGPL v3.0 License": "Лицензия AGPL v3.0",
"AI Application Infrastructure Foundation": "Инфраструктурная основа для ИИ-приложений",
"AI model testing environment": "Среда тестирования ИИ моделей",
"AI models": "Модели ИИ",
"AI models supported": "Поддерживаемые модели ИИ",
@ -263,7 +265,6 @@
"All Types": "Все типы",
"All upstream data is trusted": "Все вышестоящие данные являются доверенными",
"All Vendors": "Все поставщики",
"All Your AI Models": "Все ваши ИИ-модели",
"All-time": "За всё время",
"Allocated Memory": "Выделенная память",
"Allow accountFilter parameter": "Разрешить параметр accountFilter",
@ -424,6 +425,11 @@
"Audio Tokens": "Аудио токены",
"Auth configured": "Аутентификация настроена",
"Auth Style": "Стиль аутентификации",
"auth.resetPasswordConfirm.backToLogin": "Вернуться ко входу",
"auth.resetPasswordConfirm.confirm": "Подтвердить сброс пароля",
"auth.resetPasswordConfirm.description": "Подтвердите запрос на сброс, чтобы создать новый пароль.",
"auth.resetPasswordConfirm.retry": "Повторить ({{seconds}}с)",
"auth.resetPasswordConfirm.success": "Ваш пароль успешно сброшен",
"Authentication": "Аутентификация",
"Authenticator code": "Код аутентификатора",
"Authorization Endpoint": "Конечная точка авторизации",
@ -502,6 +508,7 @@
"Base Price": "Базовая цена",
"Base rate limit windows for this account.": "Окна базовых лимитов для этого аккаунта.",
"Base URL": "Адрес API",
"Base URL is required for this channel type": "Для этого типа канала требуется Base URL",
"Base URL of your Uptime Kuma instance": "Базовый URL вашего экземпляра Uptime Kuma",
"Basic Authentication": "Базовая аутентификация",
"Basic Configuration": "Базовая конфигурация",
@ -671,6 +678,7 @@
"Check for updates": "Проверить обновления",
"Check in daily to receive random quota rewards": "Регистрируйтесь ежедневно, чтобы получать случайные вознаграждения по квоте",
"Check in now": "Войдите сейчас",
"Check out the Quick Start": "Ознакомьтесь с быстрым стартом",
"Check resolved IPs against IP filters even when accessing by domain": "Проверять разрешенные IP-адреса по IP-фильтрам даже при доступе по домену",
"Check-in failed": "Регистрация не удалась.",
"Check-in Rewards": "Награды за отметку",
@ -759,8 +767,10 @@
"Codex": "Codex",
"Codex Account & Usage": "Аккаунт и использование Codex",
"Codex Authorization": "Авторизация Codex",
"Codex channels do not support batch creation": "Каналы Codex не поддерживают пакетное создание",
"Codex channels use an OAuth JSON credential as the key.": "Каналы Codex используют учётные данные OAuth в формате JSON в качестве ключа.",
"Codex CLI Header Passthrough": "Проброс заголовков Codex CLI",
"Codex credential must be a JSON object with access_token and account_id": "Учетные данные Codex должны быть JSON-объектом с access_token и account_id",
"Cohere": "Cohere",
"Collapse": "Свернуть",
"Collapse All": "Свернуть все",
@ -867,8 +877,6 @@
"Confirm New Password": "Подтвердить новый пароль",
"Confirm password": "Подтвердить пароль",
"Confirm Payment": "Подтвердить оплату",
"auth.resetPasswordConfirm.confirm": "Подтвердить сброс пароля",
"auth.resetPasswordConfirm.description": "Подтвердите запрос на сброс, чтобы создать новый пароль.",
"Confirm Selection": "Подтвердить выбор",
"Confirm settings and finish setup": "Подтвердите настройки и завершите установку",
"confirm that I bear legal responsibility arising from deployment": "подтверждаю, что несу юридическую ответственность, возникающую из развертывания",
@ -999,6 +1007,7 @@
"Create, revoke, and audit API tokens.": "Создать, отозвать и аудитировать токены API.",
"Created": "Создано",
"Created At": "Дата создания",
"Creates a Pancake product in the saved store using this plans title and price. Requires Waffo Pancake to be fully configured in Payment settings first.": "Создает продукт Pancake в сохраненном магазине с названием и ценой этого плана. Сначала необходимо полностью настроить Waffo Pancake в настройках платежей.",
"Creating...": "Создание...",
"Creation failed": "Создание не удалось",
"Credential generated": "Учётные данные созданы",
@ -1122,6 +1131,7 @@
"Delete invalid redemption codes": "Удалить недействительные коды активации",
"Delete Invalid Redemption Codes?": "Удалить недействительные коды активации?",
"Delete logs": "Удалить логи",
"Delete mapping": "Удалить сопоставление",
"Delete Model": "Удалить модель",
"Delete Models?": "Удалить модели?",
"Delete Provider": "Удалить провайдер",
@ -1249,6 +1259,8 @@
"Drawing task records": "Записи задач рисования",
"Duplicate": "Дублировать",
"Duplicate group names: {{names}}": "Повторяющиеся имена групп: {{names}}",
"Duplicate source model mappings are not allowed": "Повторяющиеся сопоставления исходных моделей не допускаются",
"Duplicate source model(s): {{models}}": "Повторяющиеся исходные модели: {{models}}",
"Duration": "Длительность",
"Duration (hours)": "Длительность (часы)",
"Duration Settings": "Настройки срока действия",
@ -1401,6 +1413,7 @@
"Endpoint config": "Конфигурация конечной точки",
"Endpoint Configuration": "Конфигурация конечной точки",
"Endpoint Type": "Тип конечной точки",
"Endpoint, provider-specific settings, and credentials.": "Эндпоинт, настройки провайдера и учетные данные.",
"Endpoint:": "Конечная точка:",
"Endpoints": "Конечные точки",
"English": "Английский",
@ -1704,14 +1717,15 @@
"Filled {{count}} model(s)": "Заполнено {{count}} моделей",
"Filled {{count}} related model(s)": "Заполнено {{count}} связанных моделей",
"Filter": "Фильтр",
"Filter by API key...": "Фильтр по API-ключу...",
"Filter by channel ID": "Фильтр по ID канала",
"Filter by group": "Фильтр по группе",
"Filter by Midjourney task ID": "Фильтр по ID задачи Midjourney",
"Filter by model name...": "Фильтр по имени модели...",
"Filter by model...": "Фильтровать по модели...",
"Filter by name or ID...": "Фильтр по имени или ID...",
"Filter by name or key...": "Фильтровать по имени или ключу...",
"Filter by name, ID, or key...": "Фильтровать по имени, ID или ключу...",
"Filter by name...": "Фильтр по имени...",
"Filter by price field": "Фильтр по полю цены",
"Filter by ratio type": "Фильтровать по типу коэффициента",
"Filter by request ID": "Фильтр по ID запроса",
@ -1742,6 +1756,7 @@
"FluentRead extension not detected. Please ensure it is installed and active.": "Расширение FluentRead не обнаружено. Убедитесь, что оно установлено и активно.",
"Flush interval (minutes)": "Интервал записи (минуты)",
"Follow the guided steps to prepare your workspace before the first login.": "Следуйте пошаговым инструкциям, чтобы подготовить рабочее пространство перед первым входом.",
"Font": "Шрифт",
"Footer": "Подвал",
"Footer text displayed at the bottom of pages": "Текст нижнего колонтитула, отображаемый внизу страниц",
"footer.columns.about.links.aboutProject": "О проекте",
@ -2177,6 +2192,7 @@
"Load template...": "Загрузить шаблон...",
"Loader": "Загрузчик",
"Loading": "Загрузка",
"Loading channel details": "Загрузка сведений о канале",
"Loading configuration": "Загрузка конфигурации",
"Loading content settings...": "Загрузка настроек контента...",
"Loading current models...": "Загрузка текущих моделей...",
@ -2293,7 +2309,6 @@
"Minimum:": "Минимум:",
"Minor blips in the last 30 days": "Небольшие сбои за последние 30 дней",
"Mint a fresh pair below — or pick an existing one further down. Click Save when ready.": "Создайте новую пару ниже или выберите существующую дальше. Когда будете готовы, нажмите Сохранить.",
"Creates a Pancake product in the saved store using this plans title and price. Requires Waffo Pancake to be fully configured in Payment settings first.": "Создает продукт Pancake в сохраненном магазине с названием и ценой этого плана. Сначала необходимо полностью настроить Waffo Pancake в настройках платежей.",
"Minute": "Минута",
"minutes": "минут",
"Missing code": "Код отсутствует",
@ -2324,7 +2339,9 @@
"Model Mapping": "Сопоставление моделей",
"Model Mapping (JSON)": "Сопоставление моделей (JSON)",
"Model Mapping must be a JSON object like": "Сопоставление моделей должно быть JSON-объектом, например",
"Model mapping must be a JSON object with string values": "Сопоставление моделей должно быть JSON-объектом со строковыми значениями",
"Model mapping must be valid JSON": "Сопоставление моделей должно быть допустимым JSON",
"Model mapping values must be strings": "Значения сопоставления моделей должны быть строками",
"Model name": "Имя модели",
"Model Name": "Название модели",
"Model Name *": "Имя модели *",
@ -2333,8 +2350,8 @@
"Model not found": "Модель не найдена",
"Model performance metrics": "Метрики производительности моделей",
"Model Price": "Цена модели",
"Model Price Not Configured": "Цена модели не настроена",
"Model price is not configured. Please complete model pricing in settings.": "Цена модели не настроена. Заполните тарификацию модели в настройках.",
"Model Price Not Configured": "Цена модели не настроена",
"Model prices": "Цены моделей",
"Model prices reset successfully": "Цены моделей успешно сброшены",
"Model Pricing": "Тарификация моделей",
@ -2385,6 +2402,7 @@
"months": "месяцев",
"Moonshot": "Moonshot",
"More": "Ещё",
"More Apps": "Еще",
"more mapping": "больше сопоставлений",
"More templates...": "Другие шаблоны…",
"More than 999 days left": "Более 999 дней",
@ -2437,6 +2455,7 @@
"Name Suffix": "Суффикс имени",
"Name the channel and choose the upstream provider.": "Задайте имя канала и выберите upstream-провайдера.",
"Name the channel, choose the provider, configure API access, and set credentials.": "Задайте имя канала, выберите провайдера, настройте доступ к API и учетные данные.",
"Name, provider type, and availability.": "Название, тип провайдера и доступность.",
"name@example.com": "name@example.com",
"Native format": "Собственный формат",
"Need a redemption code?": "Нужен код активации?",
@ -2446,6 +2465,7 @@
"Network proxy for this channel (supports socks5 protocol)": "Сетевой прокси для этого канала (поддерживает протокол socks5)",
"Never": "Никогда",
"Never expires": "Никогда не истекает",
"Never used an API Gateway?": "Никогда не пользовались API-шлюзом?",
"NEW": "НОВОЕ",
"New API": "Новый API",
"New API &lt;noreply@example.com&gt;": "Новый API &lt;noreply@example.com&gt;",
@ -2532,6 +2552,7 @@
"No Logs Found": "Логи не найдены",
"No mappings configured. Click \"Add Row\" to get started.": "Нет настроенных сопоставлений. Нажмите \"Добавить строку\", чтобы начать.",
"No matches found": "Совпадений не найдено",
"No matching items": "Нет подходящих элементов",
"No matching results": "Нет совпадений",
"No matching rules": "Нет совпадающих правил",
"No messages yet": "Сообщений пока нет",
@ -2950,6 +2971,7 @@
"Please upload key file(s)": "Загрузите ключевой файл(ы)",
"Please wait a moment before trying again.": "Пожалуйста, подождите немного и попробуйте снова.",
"Please wait a moment, human check is initializing...": "Пожалуйста, подождите немного, инициализация проверки человеком...",
"Please wait before editing to avoid overwriting saved values.": "Дождитесь загрузки перед редактированием, чтобы не перезаписать сохраненные значения.",
"Policy JSON": "JSON политики",
"Polling": "Опрос",
"Polling mode requires Redis and memory cache, otherwise performance will be significantly degraded": "Режим опроса требует Redis и кэш памяти, в противном случае производительность будет значительно снижена",
@ -2983,10 +3005,12 @@
"Prepend to Start": "Добавить в начало",
"Prepend value to array / string / object start": "Добавить значение в начало массива / строки / объекта",
"Preserve the original field when applying this rule": "Сохранять исходное поле при применении этого правила",
"Preset groups": "Предустановленные группы",
"Preset recharge amounts (JSON array)": "Предустановленные суммы пополнения (массив JSON)",
"Preset recharge amounts displayed to users": "Предустановленные суммы пополнения, отображаемые пользователям",
"Preset Template": "Предустановленный шаблон",
"Preset templates": "Предустановленные шаблоны",
"preset.anthropic": "Anthropic",
"preset.default": "По умолчанию",
"preset.forest-whisper": "Лесной шёпот",
"preset.lake-view": "Озёрный вид",
@ -3082,6 +3106,7 @@
"Public rankings page based on live usage data.": "Публичная страница рейтингов на основе реальных данных использования.",
"Publish Date": "Дата публикации",
"Published": "Опубликовано",
"Published models, groups, and model remapping rules.": "Опубликованные модели, группы и правила переназначения моделей.",
"Published:": "Опубликовано:",
"Pull": "Загрузить",
"Pull model": "Загрузить модель",
@ -3098,6 +3123,7 @@
"Querying...": "Выполняется запрос...",
"Question": "Вопрос",
"Queued": "В очереди",
"Quick actions": "Быстрые действия",
"Quick insert common payment methods": "Быстрая вставка распространенных способов оплаты",
"Quick Range": "Быстрый диапазон",
"Quick Setup from Preset": "Быстрая настройка из предустановки",
@ -3219,6 +3245,7 @@
"Remaining:": "Осталось:",
"Remark": "Примечания",
"Remove": "Удалить",
"Remove {{value}}": "Удалить {{value}}",
"Remove ${{amount}}": "Удалить ${{amount}}",
"Remove all log entries created before the selected timestamp.": "Удалить все записи журнала, созданные до выбранной отметки времени.",
"Remove attachment": "Удалить вложение",
@ -3226,6 +3253,7 @@
"Remove Duplicates": "Удалить дубликаты",
"Remove filter": "Удалить фильтр",
"Remove functionResponse.id field": "Удалить поле functionResponse.id",
"Remove mapped targets": "Удалить сопоставленные цели",
"Remove Models": "Удалить модели",
"Remove Passkey": "Отвязать Passkey",
"Remove Passkey?": "Удалить ключ доступа?",
@ -3329,7 +3357,6 @@
"Retain last N files": "Хранить последние N файлов",
"Retention days": "Дней хранения",
"Retry": "Повторить попытку",
"auth.resetPasswordConfirm.retry": "Повторить ({{seconds}}с)",
"Retry Chain": "Цепочка повторов",
"Retry Suggestion": "Рекомендация по повтору",
"Retry Times": "Количество повторных попыток",
@ -3339,7 +3366,6 @@
"Return Error": "Вернуть ошибку",
"Return per-token log probabilities": "Возвращать логарифмические вероятности по токенам",
"Return to dashboard": "Вернуться на панель управления",
"auth.resetPasswordConfirm.backToLogin": "Вернуться ко входу",
"Return vector embeddings for inputs": "Возвращать векторные эмбеддинги для входных данных",
"Reveal API key": "Показать API ключ",
"Reveal key": "Показать ключ",
@ -3491,11 +3517,11 @@
"Select all (filtered)": "& Выбрать все отфильтрованные",
"Select all models": "Выбрать все модели",
"Select All Visible": "Выбрать все видимые",
"Select model {{model}}": "Выбрать модель {{model}}",
"Select an operation mode and enter the amount": "Выберите режим операции и введите сумму",
"Select announcement type": "Выбрать тип объявления",
"Select at least one field to overwrite.": "Выберите хотя бы одно поле для перезаписи.",
"Select at least one target model": "Выберите хотя бы одну целевую модель",
"Select body font": "Выберите шрифт текста",
"Select border radius": "Выберите радиус скругления",
"Select channel type": "Выбрать тип канала",
"Select color preset": "Выберите цветовую предустановку",
@ -3518,6 +3544,7 @@
"Select layout style": "Выбрать стиль макета",
"Select locations": "Выбрать локации",
"Select Model": "Выбрать модель",
"Select model {{model}}": "Выбрать модель {{model}}",
"Select models (empty for allow all)": "Выбрать модели (пусто для разрешения всех)",
"Select models and apply to channel models list.": "Выберите модели и примените к списку моделей каналов.",
"Select models or add custom ones": "Выбрать модели или добавить пользовательские",
@ -3547,6 +3574,7 @@
"Select vendor": "Выбрать поставщика",
"Selectable groups": "Выбираемые группы",
"selected": "выбрано",
"Selected {{count}}": "Выбрано: {{count}}",
"selected channel(s). Leave empty to remove tag.": "выбранный канал(ы). Оставьте пустым, чтобы удалить тег.",
"Selected conflicts were overwritten successfully.": "Выбранные конфликты успешно перезаписаны.",
"Selected when creating a token and used as the default billing group for API calls.": "Выбирается при создании токена и используется как группа тарификации по умолчанию для вызовов API.",
@ -3690,6 +3718,7 @@
"Status & Sync": "Статус и синхронизация",
"Status Code": "Код статуса",
"Status Code Mapping": "Сопоставление кодов состояния",
"Status code mapping must use valid HTTP status codes": "Сопоставление кодов состояния должно использовать допустимые HTTP-коды",
"Status Page Slug": "Slug страницы статуса",
"Status short": "Стат.",
"Status:": "Статус:",
@ -3753,12 +3782,14 @@
"Sunset Glow": "Закатное сияние",
"Super Admin": "Суперадмин",
"Support for high concurrency with automatic load balancing": "Поддержка высокой конкурентности с автоматической балансировкой нагрузки",
"Supported Applications": "Поддерживаемые приложения",
"Supported Imagine Models": "Поддерживаемые модели Imagine",
"Supported modalities": "Поддерживаемые модальности",
"Supported parameters": "Поддерживаемые параметры",
"Supported variables": "Поддерживаемые переменные",
"Supports `-thinking`, `-thinking-": "Поддерживает `-thinking`, `-thinking-",
"Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.": "Поддерживает HTML-разметку или встраивание iframe. Введите HTML-код напрямую или укажите полный URL для автоматического встраивания в виде iframe.",
"Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.": "Поддерживает настройку в один клик и идеально адаптируется к многопротокольной конфигурации NewAPI.",
"Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.": "Поддерживаются PNG, JPG, SVG или WebP. Рекомендуемый размер: 128×128 или меньше.",
"Sustained tokens per second": "Устойчивая скорость токенов в секунду",
"Swap Face": "Замена лица",
@ -3897,6 +3928,7 @@
"This action will permanently remove 2FA protection from your account.": "Это действие безвозвратно удалит защиту 2FA из вашей учетной записи.",
"This channel is not an Ollama channel.": "Этот канал не является каналом Ollama.",
"This channel type does not support fetching models": "Этот тип канала не поддерживает получение моделей",
"This channel type requires additional configuration": "Для этого типа канала требуется дополнительная конфигурация",
"This confirmation unlocks payment, redemption code, subscription plan, and invitation reward features. Please read the statements carefully.": "Это подтверждение разблокирует функции платежей, кодов пополнения, планов подписки и наград за приглашения. Внимательно прочитайте заявления.",
"This controls model request rate limiting. Web/API route throttling is configured by environment variables and may still return 429.": "Этот параметр управляет ограничением частоты запросов к моделям. Ограничение маршрутов Web/API настраивается переменными окружения и всё ещё может возвращать 429.",
"This data may be unreliable, use with caution": "Эти данные могут быть ненадежными, используйте с осторожностью",
@ -4215,6 +4247,7 @@
"Use external tools to extend capabilities": "Использовать внешние инструменты для расширения возможностей",
"Use our unified OpenAI-compatible endpoint in your applications": "Используйте наш единый OpenAI-совместимый эндпоинт в ваших приложениях",
"Use Passkey to sign in without entering your password.": "Используйте ключ доступа для входа без ввода пароля.",
"Use presets or upstream discovery to populate the model list faster.": "Используйте пресеты или обнаружение upstream, чтобы быстрее заполнить список моделей.",
"Use secure connection when sending emails": "Использовать безопасное соединение при отправке электронных писем",
"Use sidebar shortcut": "Использовать ярлык боковой панели",
"Use the full-width table to scan prices, then select a row to edit it here.": "Просмотрите цены в таблице, затем выберите строку для редактирования здесь.",
@ -4280,6 +4313,7 @@
"Vary": "Вариация",
"Vary (Strong)": "Вариация (сильная)",
"Vary (Subtle)": "Вариация (лёгкая)",
"Vast Range of AI Models": "Огромный выбор моделей ИИ",
"Vendor": "Поставщик",
"Vendor deleted successfully": "Поставщик успешно удалён",
"Vendor Name *": "Название поставщика *",
@ -4302,8 +4336,10 @@
"Verifying credentials and pulling stores from your Pancake account...": "Проверяем учетные данные и загружаем магазины из вашего аккаунта Pancake...",
"Version Overrides": "Переопределения версий",
"Vertex AI": "Vertex AI",
"Vertex AI API Key mode does not support batch creation": "Режим API Key Vertex AI не поддерживает пакетное создание",
"Vertex AI does not support functionResponse.id. Enable this to remove the field automatically.": "Vertex AI не поддерживает functionResponse.id. Включите, чтобы автоматически удалить это поле.",
"Vertex AI Key Format": "Формат ключа Vertex AI",
"Vertex AI service account key must be valid JSON": "Ключ сервисного аккаунта Vertex AI должен быть допустимым JSON",
"Video": "Видео",
"Video length in seconds": "Длительность видео в секундах",
"Video Remix": "Ремикс видео",
@ -4334,7 +4370,7 @@
"Visit Settings → General and adjust quota options...": "Перейдите в Настройки → Общие и настройте параметры квоты...",
"Visitors must authenticate before accessing the pricing directory.": "Посетители должны пройти аутентификацию перед доступом к каталогу цен.",
"Visitors must authenticate before accessing the rankings page.": "Посетители должны пройти аутентификацию перед доступом к странице рейтингов.",
"Visual": "Визуальный",
"Visual": "Визуально",
"Visual edit": "Визуальное редактирование",
"Visual editor": "Визуальный редактор",
"Visual Editor": "Визуальный редактор",
@ -4466,7 +4502,6 @@
"Your GitHub OAuth Client ID": "Ваш ID клиента GitHub OAuth",
"Your GitHub OAuth Client Secret": "Ваш секрет клиента GitHub OAuth",
"Your new backup codes are ready": "Ваши новые резервные коды готовы",
"auth.resetPasswordConfirm.success": "Ваш пароль успешно сброшен",
"Your Referral Link": "Ваша реферальная ссылка",
"Your setup guide is collapsed so usage stays in focus.": "Руководство свернуто, чтобы основные показатели оставались в фокусе.",
"Your system access token for API authentication. Keep it secure and don't share it with others.": "Ваш системный токен доступа для аутентификации API. Храните его в безопасности и не делитесь им с другими.",

View File

@ -37,7 +37,6 @@
"{{count}} IP(s)": "{{count}} IP",
"{{count}} log entries removed.": "Đã xóa {{count}} mục nhật ký.",
"{{count}} minutes ago": "{{count}} phút trước",
"{{count}} model(s)": "{{count}} mô hình",
"{{count}} models": "{{count}} mô hình",
"{{count}} months ago": "{{count}} tháng trước",
"{{count}} override": "{{count}} ghi đè",
@ -107,6 +106,7 @@
"Accept Unpriced Models": "Chấp nhận các Mô hình chưa định giá",
"Accepts a JSON array of model identifiers that support the Imagine API.": "Chấp nhận một mảng JSON gồm các mã định danh mô hình hỗ trợ API Imagine.",
"Accepts comma-separated status codes and inclusive ranges.": "Chấp nhận mã trạng thái phân cách bằng dấu phẩy và phạm vi bao gồm.",
"Access a vast selection of models via a standard, unified API protocol. Power AI applications, manage digital assets, and connect the Future.": "Truy cập số lượng lớn các mô hình thông qua giao thức API chuẩn hóa và thống nhất. Thúc đẩy các ứng dụng AI, quản lý tài sản kỹ thuật số và kết nối tương lai.",
"Access Denied Message": "Thông báo từ chối truy cập",
"Access Forbidden": "Truy cập bị cấm",
"Access Policy (JSON)": "Chính sách truy cập (JSON)",
@ -135,6 +135,7 @@
"Actual Model": "Mô hình thực tế",
"Actual Model:": "Mô hình thực tế:",
"Add": "Add",
"Add \"{{value}}\"": "Thêm \"{{value}}\"",
"Add {{title}}": "Thêm {{title}}",
"Add a group identifier to the auto assignment list.": "Thêm một mã định danh nhóm vào danh sách phân công tự động.",
"Add a new API key by providing necessary info.": "Thêm khóa API mới bằng cách cung cấp thông tin cần thiết.",
@ -152,7 +153,7 @@
"Add condition": "Thêm điều kiện",
"Add Condition": "Thêm điều kiện",
"Add credits": "Thêm tín dụng",
"Add custom model(s), comma-separated": "Thêm mô hình tùy chỉnh, phân tách bằng dấu phẩy",
"Add custom model \"{{value}}\"": "Thêm mô hình tùy chỉnh \"{{value}}\"",
"Add discount tier": "Thêm bậc giảm giá",
"Add each model or tag you want to include.": "Thêm mỗi mô hình hoặc thẻ bạn muốn đưa vào.",
"Add FAQ": "Thêm FAQ",
@ -164,6 +165,7 @@
"Add group rules": "Thêm quy tắc nhóm",
"Add Mapping": "Thêm ánh xạ",
"Add method": "Thêm phương thức",
"Add missing models": "Thêm mô hình còn thiếu",
"Add Mode": "Thêm Chế độ",
"Add model": "Thêm mô hình",
"Add Model": "Thêm Mô hình",
@ -192,7 +194,6 @@
"Add User": "Thêm người dùng",
"Add user group": "Thêm nhóm người dùng",
"Add your API keys, set up channels and configure access permissions": "Thêm khóa API, thiết lập kênh và cấu hình quyền truy cập",
"Added {{count}} custom model(s)": "Đã thêm {{count}} mô hình tùy chỉnh",
"Added {{count}} model(s)": "Đã thêm {{count}} mô hình",
"Added successfully": "Thêm thành công",
"Additional Conditions": "Điều kiện bổ sung",
@ -201,8 +202,8 @@
"Additional Limit": "Hạn mức bổ sung",
"Additional Limits": "Các hạn mức bổ sung",
"Additional metered capability": "Tính năng tính phí theo mức dùng bổ sung",
"Adjust Quota": "Điều chỉnh hạn mức",
"Adjust filters, then search to refresh the logs.": "Điều chỉnh bộ lọc, sau đó tìm kiếm để làm mới nhật ký.",
"Adjust Quota": "Điều chỉnh hạn mức",
"Adjust response formatting, prompt behavior, proxy, and upstream automation.": "Điều chỉnh định dạng phản hồi, hành vi prompt, proxy và tự động hóa upstream.",
"Adjust the appearance and layout to suit your preferences.": "Điều chỉnh giao diện và bố cục để phù hợp với sở thích của bạn.",
"Admin": "Quản trị viên",
@ -236,6 +237,7 @@
"aggregates 50+ AI providers behind one unified API. Manage access, track costs, and scale effortlessly.": "tổng hợp hơn 50 nhà cung cấp AI sau một API thống nhất. Quản lý truy cập, theo dõi chi phí và mở rộng dễ dàng.",
"Aggregation bucket": "Khoảng tổng hợp",
"AGPL v3.0 License": "Giấy phép AGPL v3.0",
"AI Application Infrastructure Foundation": "Nền tảng hạ tầng ứng dụng AI",
"AI model testing environment": "Môi trường thử nghiệm mô hình AI",
"AI models": "mô hình AI",
"AI models supported": "Các mô hình AI được hỗ trợ",
@ -263,7 +265,6 @@
"All Types": "All types",
"All upstream data is trusted": "Tất cả dữ liệu thượng nguồn đều được tin cậy",
"All Vendors": "Tất cả Nhà cung cấp",
"All Your AI Models": "Tất cả mô hình AI của bạn",
"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",
@ -424,6 +425,11 @@
"Audio Tokens": "Token âm thanh",
"Auth configured": "Đã cấu hình xác thực",
"Auth Style": "Kiểu xác thực",
"auth.resetPasswordConfirm.backToLogin": "Quay lại đăng nhập",
"auth.resetPasswordConfirm.confirm": "Xác nhận đặt lại mật khẩu",
"auth.resetPasswordConfirm.description": "Xác nhận yêu cầu đặt lại để tạo mật khẩu mới.",
"auth.resetPasswordConfirm.retry": "Thử lại ({{seconds}} giây)",
"auth.resetPasswordConfirm.success": "Mật khẩu của bạn đã được đặt lại thành công",
"Authentication": "Xác thực",
"Authenticator code": "Mã xác thực",
"Authorization Endpoint": "Điểm cuối ủy quyền",
@ -502,6 +508,7 @@
"Base Price": "Giá cơ bản",
"Base rate limit windows for this account.": "Cửa sổ giới hạn tốc độ cơ bản cho tài khoản này.",
"Base URL": "URL cơ sở",
"Base URL is required for this channel type": "Loại kênh này yêu cầu Base URL",
"Base URL of your Uptime Kuma instance": "URL cơ sở của phiên bản Uptime Kuma của bạn",
"Basic Authentication": "Xác thực cơ bản",
"Basic Configuration": "Cấu hình cơ bản",
@ -671,6 +678,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 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",
"Check-in Rewards": "Phần thưởng điểm danh",
@ -759,8 +767,10 @@
"Codex": "Codex",
"Codex Account & Usage": "Tài khoản và sử dụng Codex",
"Codex Authorization": "Ủy quyền Codex",
"Codex channels do not support batch creation": "Kênh Codex không hỗ trợ tạo hàng loạt",
"Codex channels use an OAuth JSON credential as the key.": "Kênh Codex dùng thông tin xác thực OAuth JSON làm khóa.",
"Codex CLI Header Passthrough": "Chuyển tiếp header Codex CLI",
"Codex credential must be a JSON object with access_token and account_id": "Thông tin xác thực Codex phải là đối tượng JSON có access_token và account_id",
"Cohere": "Cohere",
"Collapse": "Thu gọn",
"Collapse All": "Thu gọn tất cả",
@ -867,8 +877,6 @@
"Confirm New Password": "Xác nhận mật khẩu mới",
"Confirm password": "Xác nhận mật khẩu",
"Confirm Payment": "Xác nhận Thanh toán",
"auth.resetPasswordConfirm.confirm": "Xác nhận đặt lại mật khẩu",
"auth.resetPasswordConfirm.description": "Xác nhận yêu cầu đặt lại để tạo mật khẩu mới.",
"Confirm Selection": "Xác nhận lựa chọn",
"Confirm settings and finish setup": "Xác nhận cài đặt và hoàn tất thiết lập",
"confirm that I bear legal responsibility arising from deployment": "xác nhận rằng tôi chịu trách nhiệm pháp lý phát sinh từ việc triển khai",
@ -999,6 +1007,7 @@
"Create, revoke, and audit API tokens.": "Tạo, thu hồi và kiểm toán token API.",
"Created": "Đã tạo",
"Created At": "Ngày tạo",
"Creates a Pancake product in the saved store using this plans title and price. Requires Waffo Pancake to be fully configured in Payment settings first.": "Tạo một sản phẩm Pancake trong cửa hàng đã lưu bằng tiêu đề và giá của gói này. Trước tiên cần cấu hình đầy đủ Waffo Pancake trong cài đặt Thanh toán.",
"Creating...": "Đang tạo...",
"Creation failed": "Tạo thất bại",
"Credential generated": "Đã tạo thông tin xác thực",
@ -1122,6 +1131,7 @@
"Delete invalid redemption codes": "Xóa mã đổi thưởng không hợp lệ",
"Delete Invalid Redemption Codes?": "Xóa Mã đổi thưởng không hợp lệ?",
"Delete logs": "Xóa nhật ký",
"Delete mapping": "Xóa ánh xạ",
"Delete Model": "Xóa Mô hình",
"Delete Models?": "Xóa mô hình?",
"Delete Provider": "Xóa nhà cung cấp",
@ -1249,6 +1259,8 @@
"Drawing task records": "Lịch sử tác vụ vẽ",
"Duplicate": "Nhân bản",
"Duplicate group names: {{names}}": "Tên nhóm bị trùng: {{names}}",
"Duplicate source model mappings are not allowed": "Không cho phép ánh xạ mô hình nguồn trùng lặp",
"Duplicate source model(s): {{models}}": "Mô hình nguồn trùng lặp: {{models}}",
"Duration": "Thời lượng",
"Duration (hours)": "Thời lượng (giờ)",
"Duration Settings": "Cài đặt thời lượng",
@ -1401,6 +1413,7 @@
"Endpoint config": "Cấu hình điểm cuối",
"Endpoint Configuration": "Cấu hình điểm cuối",
"Endpoint Type": "Loại điểm cuối",
"Endpoint, provider-specific settings, and credentials.": "Endpoint, cài đặt riêng của nhà cung cấp và thông tin xác thực.",
"Endpoint:": "Điểm cuối:",
"Endpoints": "Điểm cuối",
"English": "Tiếng Anh",
@ -1704,14 +1717,15 @@
"Filled {{count}} model(s)": "Đã điền {{count}} mô hình",
"Filled {{count}} related model(s)": "Đã điền {{count}} mô hình liên quan",
"Filter": "Lọc",
"Filter by API key...": "Lọc theo khóa API...",
"Filter by channel ID": "Lọc theo ID kênh",
"Filter by group": "Lọc theo nhóm",
"Filter by Midjourney task ID": "Lọc theo ID nhiệm vụ Midjourney",
"Filter by model name...": "Lọc theo tên mô hình...",
"Filter by model...": "Lọc theo mẫu...",
"Filter by name or ID...": "Lọc theo tên hoặc ID...",
"Filter by name or key...": "Lọc theo tên hoặc khóa...",
"Filter by name, ID, or key...": "Lọc theo tên, ID hoặc khóa...",
"Filter by name...": "Lọc theo tên...",
"Filter by price field": "Lọc theo trường giá",
"Filter by ratio type": "Lọc theo loại tỷ lệ",
"Filter by request ID": "Lọc theo ID yêu cầu",
@ -1742,6 +1756,7 @@
"FluentRead extension not detected. Please ensure it is installed and active.": "Không phát hiện tiện ích mở rộng FluentRead. Vui lòng đảm bảo nó đã được cài đặt và kích hoạt.",
"Flush interval (minutes)": "Khoảng ghi xuống DB (phút)",
"Follow the guided steps to prepare your workspace before the first login.": "Thực hiện theo các bước hướng dẫn để chuẩn bị không gian làm việc của bạn trước lần đăng nhập đầu tiên.",
"Font": "Phông chữ",
"Footer": "Chân trang",
"Footer text displayed at the bottom of pages": "Văn bản chân trang hiển thị ở cuối các trang",
"footer.columns.about.links.aboutProject": "Về Dự án",
@ -1935,7 +1950,7 @@
"How client credentials are sent to the token endpoint": "Cách thông tin xác thực client được gửi đến endpoint token",
"How frequently the system tests all channels": "Tần suất hệ thống kiểm tra tất cả các kênh là bao nhiêu?",
"How It Works": "Cách hoạt động",
"How model mapping works": "Cách ánh xạ mô hình hoạt động",
"How model mapping works": "Cách hoạt động của ánh xạ mô hình",
"How much to charge for each US dollar of balance (Epay)": "Tính phí bao nhiêu cho mỗi đô la Mỹ số dư (Epay)",
"How this model name should match requests": "Tên mô hình này nên khớp với các yêu cầu như thế nào",
"How to deliver the resulting image": "Cách trả về ảnh kết quả",
@ -2177,6 +2192,7 @@
"Load template...": "Tải mẫu...",
"Loader": "Trình tải",
"Loading": "Đang tải",
"Loading channel details": "Đang tải chi tiết kênh",
"Loading configuration": "Đang tải cấu hình",
"Loading content settings...": "Đang tải cài đặt nội dung...",
"Loading current models...": "Đang tải các mô hình hiện tại...",
@ -2293,7 +2309,6 @@
"Minimum:": "Tối thiểu:",
"Minor blips in the last 30 days": "Vài gián đoạn nhỏ trong 30 ngày qua",
"Mint a fresh pair below — or pick an existing one further down. Click Save when ready.": "Tạo một cặp mới bên dưới, hoặc chọn một cặp hiện có ở phía dưới. Nhấn Lưu khi đã sẵn sàng.",
"Creates a Pancake product in the saved store using this plans title and price. Requires Waffo Pancake to be fully configured in Payment settings first.": "Tạo một sản phẩm Pancake trong cửa hàng đã lưu bằng tiêu đề và giá của gói này. Trước tiên cần cấu hình đầy đủ Waffo Pancake trong cài đặt Thanh toán.",
"Minute": "Phút",
"minutes": "phút",
"Missing code": "Thiếu mã",
@ -2324,7 +2339,9 @@
"Model Mapping": "Ánh xạ mô hình",
"Model Mapping (JSON)": "Ánh xạ mô hình (JSON)",
"Model Mapping must be a JSON object like": "Ánh xạ Mô hình phải là một đối tượng JSON như",
"Model mapping must be a JSON object with string values": "Ánh xạ mô hình phải là đối tượng JSON với giá trị chuỗi",
"Model mapping must be valid JSON": "Ánh xạ mô hình phải là JSON hợp lệ",
"Model mapping values must be strings": "Giá trị ánh xạ mô hình phải là chuỗi",
"Model name": "Tên mẫu",
"Model Name": "Tên mẫu",
"Model Name *": "Tên mẫu *",
@ -2333,8 +2350,8 @@
"Model not found": "Không tìm thấy mô hình",
"Model performance metrics": "Chỉ số hiệu năng mô hình",
"Model Price": "Giá mô hình",
"Model Price Not Configured": "Giá mô hình chưa được cấu hình",
"Model price is not configured. Please complete model pricing in settings.": "Giá mô hình chưa được cấu hình. Vui lòng hoàn tất định giá mô hình trong cài đặt.",
"Model Price Not Configured": "Giá mô hình chưa được cấu hình",
"Model prices": "Giá mô hình",
"Model prices reset successfully": "Đã đặt lại giá mô hình thành công",
"Model Pricing": "Định giá mô hình",
@ -2385,6 +2402,7 @@
"months": "tháng",
"Moonshot": "Dự án táo bạo",
"More": "Thêm",
"More Apps": "Thêm",
"more mapping": "thêm lập bản đồ",
"More templates...": "Thêm mẫu...",
"More than 999 days left": "Hơn 999 ngày",
@ -2437,6 +2455,7 @@
"Name Suffix": "Hậu tố tên",
"Name the channel and choose the upstream provider.": "Đặt tên kênh và chọn nhà cung cấp upstream.",
"Name the channel, choose the provider, configure API access, and set credentials.": "Đặt tên kênh, chọn nhà cung cấp, cấu hình truy cập API và thiết lập thông tin xác thực.",
"Name, provider type, and availability.": "Tên, loại nhà cung cấp và trạng thái khả dụng.",
"name@example.com": "name@example.com",
"Native format": "Định dạng gốc",
"Need a redemption code?": "Cần mã đổi thưởng?",
@ -2446,6 +2465,7 @@
"Network proxy for this channel (supports socks5 protocol)": "Proxy mạng cho kênh này (hỗ trợ giao thức socks5)",
"Never": "Không bao giờ",
"Never expires": "Không hết hạn",
"Never used an API Gateway?": "Chưa bao giờ sử dụng API Gateway?",
"NEW": "MỚI",
"New API": "API mới",
"New API &lt;noreply@example.com&gt;": "API mới &lt;noreply@example.com&gt;",
@ -2532,6 +2552,7 @@
"No Logs Found": "Không tìm thấy nhật ký",
"No mappings configured. Click \"Add Row\" to get started.": "Chưa có ánh xạ nào được cấu hình. Nhấp vào \"Thêm hàng\" để bắt đầu.",
"No matches found": "Không tìm thấy kết quả nào",
"No matching items": "Không có mục phù hợp",
"No matching results": "Không có kết quả phù hợp",
"No matching rules": "Không có quy tắc phù hợp",
"No messages yet": "Chưa có tin nhắn",
@ -2950,6 +2971,7 @@
"Please upload key file(s)": "Vui lòng tải lên (các) tệp khóa",
"Please wait a moment before trying again.": "Vui lòng chờ một lát rồi thử lại.",
"Please wait a moment, human check is initializing...": "Vui lòng đợi một chút, kiểm tra con người đang khởi tạo...",
"Please wait before editing to avoid overwriting saved values.": "Vui lòng chờ trước khi chỉnh sửa để tránh ghi đè các giá trị đã lưu.",
"Policy JSON": "JSON chính sách",
"Polling": "Thăm dò",
"Polling mode requires Redis and memory cache, otherwise performance will be significantly degraded": "Chế độ thăm dò yêu cầu Redis và bộ nhớ đệm, nếu không hiệu suất sẽ bị suy giảm đáng kể.",
@ -2983,10 +3005,12 @@
"Prepend to Start": "Thêm vào đầu",
"Prepend value to array / string / object start": "Thêm giá trị vào đầu mảng / chuỗi / đối tượng",
"Preserve the original field when applying this rule": "Giữ trường gốc khi áp dụng quy tắc này",
"Preset groups": "Nhóm đặt sẵn",
"Preset recharge amounts (JSON array)": "Số tiền nạp đặt trước (mảng JSON)",
"Preset recharge amounts displayed to users": "Các mức nạp tiền đặt trước hiển thị cho người dùng",
"Preset Template": "Mẫu cài sẵn",
"Preset templates": "Mẫu sẵn",
"preset.anthropic": "Anthropic",
"preset.default": "Mặc định",
"preset.forest-whisper": "Thì thầm rừng",
"preset.lake-view": "Hồ nước",
@ -3082,6 +3106,7 @@
"Public rankings page based on live usage data.": "Trang bảng xếp hạng công khai dựa trên dữ liệu sử dụng thực.",
"Publish Date": "Ngày xuất bản",
"Published": "Đã xuất bản",
"Published models, groups, and model remapping rules.": "Các mô hình đã xuất bản, nhóm và quy tắc ánh xạ lại mô hình.",
"Published:": "Đã xuất bản:",
"Pull": "Tải",
"Pull model": "Tải mô hình",
@ -3098,6 +3123,7 @@
"Querying...": "Đang truy vấn...",
"Question": "Câu hỏi",
"Queued": "Trong hàng đợi",
"Quick actions": "Thao tác nhanh",
"Quick insert common payment methods": "Chèn nhanh các phương thức thanh toán phổ biến",
"Quick Range": "Phạm vi nhanh",
"Quick Setup from Preset": "Thiết lập nhanh từ cấu hình sẵn",
@ -3219,6 +3245,7 @@
"Remaining:": "Còn lại:",
"Remark": "Nhận xét",
"Remove": "Xóa",
"Remove {{value}}": "Xóa {{value}}",
"Remove ${{amount}}": "Xóa ${{amount}}",
"Remove all log entries created before the selected timestamp.": "Xóa tất cả các mục nhật ký được tạo trước mốc thời gian đã chọn.",
"Remove attachment": "Xóa tệp đính kèm",
@ -3226,6 +3253,7 @@
"Remove Duplicates": "Xóa trùng lặp",
"Remove filter": "Xóa bộ lọc",
"Remove functionResponse.id field": "Loại bỏ trường functionResponse.id",
"Remove mapped targets": "Xóa đích đã ánh xạ",
"Remove Models": "Xóa mô hình",
"Remove Passkey": "Xóa Khóa truy cập",
"Remove Passkey?": "Xóa khóa truy cập?",
@ -3329,7 +3357,6 @@
"Retain last N files": "Giữ lại N tệp gần nhất",
"Retention days": "Số ngày lưu giữ",
"Retry": "Thử lại",
"auth.resetPasswordConfirm.retry": "Thử lại ({{seconds}} giây)",
"Retry Chain": "Chuỗi thử lại",
"Retry Suggestion": "Gợi ý thử lại",
"Retry Times": "Số lần thử lại",
@ -3339,7 +3366,6 @@
"Return Error": "Trả về lỗi",
"Return per-token log probabilities": "Trả về log probabilities cho từng token",
"Return to dashboard": "Quay lại bảng điều khiển",
"auth.resetPasswordConfirm.backToLogin": "Quay lại đăng nhập",
"Return vector embeddings for inputs": "Trả về vector embedding cho đầu vào",
"Reveal API key": "Hiển thị khóa API",
"Reveal key": "Display key",
@ -3491,11 +3517,11 @@
"Select all (filtered)": "Chọn tất cả (đã lọc)",
"Select all models": "Chọn tất cả mô hình",
"Select All Visible": "Chọn tất cả hiển thị",
"Select model {{model}}": "Chọn mô hình {{model}}",
"Select an operation mode and enter the amount": "Chọn chế độ thao tác và nhập số tiền",
"Select announcement type": "Select notification type",
"Select at least one field to overwrite.": "Chọn ít nhất một trường để ghi đè.",
"Select at least one target model": "Chọn ít nhất một mô hình đích",
"Select body font": "Chọn phông chữ nội dung",
"Select border radius": "Chọn độ bo góc",
"Select channel type": "Chọn loại kênh",
"Select color preset": "Chọn cài đặt màu sẵn",
@ -3518,6 +3544,7 @@
"Select layout style": "Chọn kiểu bố cục",
"Select locations": "Chọn vị trí",
"Select Model": "Chọn mẫu",
"Select model {{model}}": "Chọn mô hình {{model}}",
"Select models (empty for allow all)": "Chọn model (để trống nếu muốn cho",
"Select models and apply to channel models list.": "Chọn mô hình và áp dụng cho danh sách mô hình kênh.",
"Select models or add custom ones": "Chọn các mô hình hoặc thêm các mô hình tùy chỉnh",
@ -3547,6 +3574,7 @@
"Select vendor": "Chọn nhà cung cấp",
"Selectable groups": "Nhóm có thể chọn",
"selected": "đã chọn",
"Selected {{count}}": "Đã chọn {{count}}",
"selected channel(s). Leave empty to remove tag.": "Kênh đã chọn. Để trống để xóa thẻ.",
"Selected conflicts were overwritten successfully.": "Các xung đột được chọn đã được ghi đè thành công.",
"Selected when creating a token and used as the default billing group for API calls.": "Được chọn khi tạo token và dùng làm nhóm tính phí mặc định cho các lệnh gọi API.",
@ -3690,6 +3718,7 @@
"Status & Sync": "Trạng thái & Đồng bộ",
"Status Code": "Mã trạng thái",
"Status Code Mapping": "Ánh xạ mã trạng thái",
"Status code mapping must use valid HTTP status codes": "Ánh xạ mã trạng thái phải dùng mã trạng thái HTTP hợp lệ",
"Status Page Slug": "Đường dẫn phụ trang trạng thái",
"Status short": "TT",
"Status:": "Trạng thái:",
@ -3753,12 +3782,14 @@
"Sunset Glow": "Hoàng hôn",
"Super Admin": "Siêu Quản trị viên",
"Support for high concurrency with automatic load balancing": "Hỗ trợ đồng thời cao với cân bằng tải tự động",
"Supported Applications": "Ứng dụng được hỗ trợ",
"Supported Imagine Models": "Mô hình Imagine được hỗ trợ",
"Supported modalities": "Phương thức hỗ trợ",
"Supported parameters": "Tham số hỗ trợ",
"Supported variables": "Biến được hỗ trợ",
"Supports `-thinking`, `-thinking-": "Hỗ trợ `-thinking`, `-thinking-",
"Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.": "Hỗ trợ đánh dấu HTML hoặc nhúng iframe. Nhập mã HTML trực tiếp, hoặc cung cấp một URL đầy đủ để tự động nhúng nó dưới dạng một iframe.",
"Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.": "Hỗ trợ cấu hình bằng một cú nhấp chuột và thích ứng hoàn hảo với cấu hình đa giao thức NewAPI.",
"Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.": "Hỗ trợ PNG, JPG, SVG hoặc WebP. Kích thước khuyến nghị: 128×128 hoặc nhỏ hơn.",
"Sustained tokens per second": "Token mỗi giây duy trì",
"Swap Face": "Đổi mặt",
@ -3897,6 +3928,7 @@
"This action will permanently remove 2FA protection from your account.": "Hành động này sẽ vĩnh viễn gỡ bỏ tính năng bảo vệ",
"This channel is not an Ollama channel.": "Kênh này không phải là kênh Ollama.",
"This channel type does not support fetching models": "Loại kênh này không hỗ trợ lấy mô hình",
"This channel type requires additional configuration": "Loại kênh này yêu cầu cấu hình bổ sung",
"This confirmation unlocks payment, redemption code, subscription plan, and invitation reward features. Please read the statements carefully.": "Xác nhận này mở khóa các tính năng thanh toán, mã đổi thưởng, gói đăng ký và phần thưởng mời. Vui lòng đọc kỹ các tuyên bố.",
"This controls model request rate limiting. Web/API route throttling is configured by environment variables and may still return 429.": "Thiết lập này kiểm soát giới hạn tốc độ yêu cầu mô hình. Giới hạn tuyến Web/API được cấu hình bằng biến môi trường và vẫn có thể trả về 429.",
"This data may be unreliable, use with caution": "Dữ liệu này có thể không đáng tin cậy, sử dụng thận trọng",
@ -4215,6 +4247,7 @@
"Use external tools to extend capabilities": "Sử dụng công cụ ngoài để mở rộng khả năng",
"Use our unified OpenAI-compatible endpoint in your applications": "Sử dụng endpoint thống nhất tương thích OpenAI trong ứng dụng của bạn",
"Use Passkey to sign in without entering your password.": "Sử dụng Khóa truy cập để đăng nhập mà không cần nhập mật khẩu của bạn.",
"Use presets or upstream discovery to populate the model list faster.": "Dùng mẫu đặt sẵn hoặc phát hiện từ upstream để điền danh sách mô hình nhanh hơn.",
"Use secure connection when sending emails": "Sử dụng kết nối an toàn khi gửi email",
"Use sidebar shortcut": "Sử dụng phím tắt thanh bên",
"Use the full-width table to scan prices, then select a row to edit it here.": "Duyệt giá trong bảng, rồi chọn một hàng để chỉnh sửa tại đây.",
@ -4280,6 +4313,7 @@
"Vary": "Biến thể",
"Vary (Strong)": "Biến thể (mạnh)",
"Vary (Subtle)": "Biến thể (nhẹ)",
"Vast Range of AI Models": "Số lượng lớn mô hình AI",
"Vendor": "Supplier",
"Vendor deleted successfully": "Đã xóa nhà cung cấp thành công",
"Vendor Name *": "Tên nhà cung cấp *",
@ -4302,8 +4336,10 @@
"Verifying credentials and pulling stores from your Pancake account...": "Đang xác minh thông tin xác thực và lấy cửa hàng từ tài khoản Pancake của bạn...",
"Version Overrides": "Ghi đè phiên bản",
"Vertex AI": "Vertex AI",
"Vertex AI API Key mode does not support batch creation": "Chế độ API Key của Vertex AI không hỗ trợ tạo hàng loạt",
"Vertex AI does not support functionResponse.id. Enable this to remove the field automatically.": "Vertex AI không hỗ trợ functionResponse.id. Bật để tự động loại bỏ trường này.",
"Vertex AI Key Format": "Định dạng khóa Vertex AI",
"Vertex AI service account key must be valid JSON": "Khóa tài khoản dịch vụ Vertex AI phải là JSON hợp lệ",
"Video": "Video",
"Video length in seconds": "Độ dài video (giây)",
"Video Remix": "Remix video",
@ -4466,7 +4502,6 @@
"Your GitHub OAuth Client ID": "Client ID OAuth GitHub của bạn",
"Your GitHub OAuth Client Secret": "Bí mật ứng dụng OAuth của GitHub của bạn",
"Your new backup codes are ready": "Mã dự phòng mới của bạn đã sẵn sàng",
"auth.resetPasswordConfirm.success": "Mật khẩu của bạn đã được đặt lại thành công",
"Your Referral Link": "Liên kết giới thiệu của bạn",
"Your setup guide is collapsed so usage stays in focus.": "Hướng dẫn thiết lập đã thu gọn để giữ phần sử dụng ở vị trí nổi bật.",
"Your system access token for API authentication. Keep it secure and don't share it with others.": "Mã truy cập hệ thống của bạn để xác thực API. Hãy giữ nó an toàn và đừng chia sẻ nó với người khác.",

View File

@ -37,7 +37,6 @@
"{{count}} IP(s)": "{{count}} 个 IP",
"{{count}} log entries removed.": "已删除 {{count}} 条日志。",
"{{count}} minutes ago": "{{count}} 分钟前",
"{{count}} model(s)": "{{count}} 个模型",
"{{count}} models": "{{count}} 个模型",
"{{count}} months ago": "{{count}} 个月前",
"{{count}} override": "{{count}} 个覆盖",
@ -107,6 +106,7 @@
"Accept Unpriced Models": "接受未定价模型",
"Accepts a JSON array of model identifiers that support the Imagine API.": "接受支持 Imagine API 的模型标识符的 JSON 数组。",
"Accepts comma-separated status codes and inclusive ranges.": "接受逗号分隔的状态码和包含性范围。",
"Access a vast selection of models via a standard, unified API protocol. Power AI applications, manage digital assets, and connect the Future.": "通过统一、标准的接口协议接入海量模型。承载 AI 应用,高效管理数字资产,连接未来。",
"Access Denied Message": "访问被拒绝消息",
"Access Forbidden": "禁止访问",
"Access Policy (JSON)": "访问策略 (JSON)",
@ -135,6 +135,7 @@
"Actual Model": "实际模型",
"Actual Model:": "实际模型:",
"Add": "添加",
"Add \"{{value}}\"": "添加 \"{{value}}\"",
"Add {{title}}": "添加{{title}}",
"Add a group identifier to the auto assignment list.": "将分组标识符添加到自动分配列表。",
"Add a new API key by providing necessary info.": "通过提供必要信息添加新的 API 密钥。",
@ -152,7 +153,7 @@
"Add condition": "新增条件",
"Add Condition": "添加条件",
"Add credits": "添加额度",
"Add custom model(s), comma-separated": "添加自定义模型(多个以逗号分隔)",
"Add custom model \"{{value}}\"": "添加自定义模型“{{value}}”",
"Add discount tier": "添加折扣等级",
"Add each model or tag you want to include.": "添加您想要包含的每个模型或标签。",
"Add FAQ": "添加问答",
@ -164,6 +165,7 @@
"Add group rules": "添加分组规则",
"Add Mapping": "添加映射",
"Add method": "添加方法",
"Add missing models": "添加缺失模型",
"Add Mode": "添加模式",
"Add model": "添加模型",
"Add Model": "添加模型",
@ -192,7 +194,6 @@
"Add User": "添加用户",
"Add user group": "添加用户分组",
"Add your API keys, set up channels and configure access permissions": "添加 API 密钥,设置渠道并配置访问权限",
"Added {{count}} custom model(s)": "已添加 {{count}} 个自定义模型",
"Added {{count}} model(s)": "已添加 {{count}} 个模型",
"Added successfully": "新增成功",
"Additional Conditions": "附加条件",
@ -201,8 +202,8 @@
"Additional Limit": "附加额度",
"Additional Limits": "附加额度",
"Additional metered capability": "附加计费能力",
"Adjust Quota": "调整额度",
"Adjust filters, then search to refresh the logs.": "调整筛选条件,然后搜索以刷新日志。",
"Adjust Quota": "调整额度",
"Adjust response formatting, prompt behavior, proxy, and upstream automation.": "调整响应格式、提示词行为、代理和上游自动化。",
"Adjust the appearance and layout to suit your preferences.": "调整外观和布局以适应您的偏好。",
"Admin": "管理员",
@ -236,6 +237,7 @@
"aggregates 50+ AI providers behind one unified API. Manage access, track costs, and scale effortlessly.": "聚合 50+ AI 提供商于统一 API 之后。轻松管理访问、追踪成本、弹性扩展。",
"Aggregation bucket": "聚合时间桶",
"AGPL v3.0 License": "AGPL v3.0 协议",
"AI Application Infrastructure Foundation": "人工智能应用基座",
"AI model testing environment": "AI模型测试环境",
"AI models": "AI 模型",
"AI models supported": "支持的 AI 模型",
@ -263,7 +265,6 @@
"All Types": "所有类型",
"All upstream data is trusted": "所有上游数据均受信任",
"All Vendors": "所有供应商",
"All Your AI Models": "所有 AI 模型",
"All-time": "全部时间",
"Allocated Memory": "已分配内存",
"Allow accountFilter parameter": "允许 accountFilter 参数",
@ -424,6 +425,11 @@
"Audio Tokens": "语音 Token",
"Auth configured": "认证已配置",
"Auth Style": "认证方式",
"auth.resetPasswordConfirm.backToLogin": "返回登录",
"auth.resetPasswordConfirm.confirm": "确认重置密码",
"auth.resetPasswordConfirm.description": "确认重置请求以生成新密码。",
"auth.resetPasswordConfirm.retry": "重试 ({{seconds}}s)",
"auth.resetPasswordConfirm.success": "您的密码已成功重置",
"Authentication": "身份验证",
"Authenticator code": "身份验证器代码",
"Authorization Endpoint": "授权端点",
@ -502,6 +508,7 @@
"Base Price": "基础价格",
"Base rate limit windows for this account.": "当前账号的基础额度窗口。",
"Base URL": "API 地址",
"Base URL is required for this channel type": "此渠道类型需要填写 Base URL",
"Base URL of your Uptime Kuma instance": "您的 Uptime Kuma 实例的基础 URL",
"Basic Authentication": "基本身份验证",
"Basic Configuration": "基本配置",
@ -671,6 +678,7 @@
"Check for updates": "检查更新",
"Check in daily to receive random quota rewards": "每日签到可获得随机额度奖励",
"Check in now": "立即签到",
"Check out the Quick Start": "请查看 新手入门",
"Check resolved IPs against IP filters even when accessing by domain": "即使通过域名访问,也对照 IP 过滤器检查解析的 IP",
"Check-in failed": "签到失败",
"Check-in Rewards": "签到奖励",
@ -759,8 +767,10 @@
"Codex": "Codex",
"Codex Account & Usage": "Codex 账户和用量",
"Codex Authorization": "Codex 授权",
"Codex channels do not support batch creation": "Codex 渠道不支持批量创建",
"Codex channels use an OAuth JSON credential as the key.": "Codex 频道使用 OAuth JSON 凭据作为密钥。",
"Codex CLI Header Passthrough": "Codex CLI 请求头透传",
"Codex credential must be a JSON object with access_token and account_id": "Codex 凭据必须是包含 access_token 和 account_id 的 JSON 对象",
"Cohere": "Cohere",
"Collapse": "收起",
"Collapse All": "全部收起",
@ -867,8 +877,6 @@
"Confirm New Password": "确认新密码",
"Confirm password": "确认密码",
"Confirm Payment": "确认付款",
"auth.resetPasswordConfirm.confirm": "确认重置密码",
"auth.resetPasswordConfirm.description": "确认重置请求以生成新密码。",
"Confirm Selection": "确认选择",
"Confirm settings and finish setup": "确认设置并完成安装",
"confirm that I bear legal responsibility arising from deployment": "确认我承担因部署产生的法律责任",
@ -999,6 +1007,7 @@
"Create, revoke, and audit API tokens.": "创建、撤销和审计 API 令牌。",
"Created": "创建时间",
"Created At": "创建时间",
"Creates a Pancake product in the saved store using this plans title and price. Requires Waffo Pancake to be fully configured in Payment settings first.": "使用此套餐的标题和价格,在已保存的店铺中创建 Pancake 产品。需要先在支付设置中完整配置 Waffo Pancake。",
"Creating...": "创建中...",
"Creation failed": "创建失败",
"Credential generated": "凭据已生成",
@ -1122,6 +1131,7 @@
"Delete invalid redemption codes": "删除无效兑换码",
"Delete Invalid Redemption Codes?": "删除无效的兑换码?",
"Delete logs": "删除日志",
"Delete mapping": "删除映射",
"Delete Model": "删除模型",
"Delete Models?": "删除模型?",
"Delete Provider": "删除提供商",
@ -1249,6 +1259,8 @@
"Drawing task records": "绘图任务记录",
"Duplicate": "重复",
"Duplicate group names: {{names}}": "存在重复的分组名称:{{names}}",
"Duplicate source model mappings are not allowed": "不允许重复的源模型映射",
"Duplicate source model(s): {{models}}": "重复的源模型:{{models}}",
"Duration": "耗时",
"Duration (hours)": "时长 (小时)",
"Duration Settings": "有效期设置",
@ -1401,6 +1413,7 @@
"Endpoint config": "端点配置",
"Endpoint Configuration": "端点配置",
"Endpoint Type": "端点类型",
"Endpoint, provider-specific settings, and credentials.": "接口地址、供应商专属设置和凭据。",
"Endpoint:": "端点:",
"Endpoints": "端点",
"English": "英文",
@ -1704,14 +1717,15 @@
"Filled {{count}} model(s)": "已填充 {{count}} 个模型",
"Filled {{count}} related model(s)": "已填充 {{count}} 个关联模型",
"Filter": "筛选",
"Filter by API key...": "按 API 密钥筛选...",
"Filter by channel ID": "按通道 ID 筛选",
"Filter by group": "按分组筛选",
"Filter by Midjourney task ID": "按 Midjourney 任务 ID 筛选",
"Filter by model name...": "按模型名称筛选...",
"Filter by model...": "按模型筛选...",
"Filter by name or ID...": "按名称或 ID 筛选...",
"Filter by name or key...": "按名称或密钥筛选...",
"Filter by name, ID, or key...": "按名称、ID 或密钥筛选...",
"Filter by name...": "按名称筛选...",
"Filter by price field": "按价格字段筛选",
"Filter by ratio type": "按倍率类型筛选",
"Filter by request ID": "按请求 ID 筛选",
@ -1742,6 +1756,7 @@
"FluentRead extension not detected. Please ensure it is installed and active.": "未检测到 FluentRead 扩展。请确保已安装并激活。",
"Flush interval (minutes)": "刷库间隔(分钟)",
"Follow the guided steps to prepare your workspace before the first login.": "请按照引导步骤在首次登录前准备您的工作区。",
"Font": "字体",
"Footer": "页脚",
"Footer text displayed at the bottom of pages": "显示在页面底部的页脚文本",
"footer.columns.about.links.aboutProject": "关于项目",
@ -1935,7 +1950,7 @@
"How client credentials are sent to the token endpoint": "客户端凭据如何发送至令牌端点",
"How frequently the system tests all channels": "系统测试所有渠道的频率",
"How It Works": "工作流程",
"How model mapping works": "模型映射的工作原理",
"How model mapping works": "模型映射如何工作",
"How much to charge for each US dollar of balance (Epay)": "每美元余额Epay的收费金额",
"How this model name should match requests": "此模型名称应如何匹配请求",
"How to deliver the resulting image": "图像结果的返回方式",
@ -2177,6 +2192,7 @@
"Load template...": "加载模板...",
"Loader": "加载器",
"Loading": "加载中",
"Loading channel details": "正在加载渠道详情",
"Loading configuration": "正在加载配置",
"Loading content settings...": "正在加载内容设置...",
"Loading current models...": "正在加载当前模型...",
@ -2293,7 +2309,6 @@
"Minimum:": "最低:",
"Minor blips in the last 30 days": "近 30 天内有轻微抖动",
"Mint a fresh pair below — or pick an existing one further down. Click Save when ready.": "在下方创建新的配对,或继续向下选择已有配对。准备好后点击保存。",
"Creates a Pancake product in the saved store using this plans title and price. Requires Waffo Pancake to be fully configured in Payment settings first.": "使用此套餐的标题和价格,在已保存的店铺中创建 Pancake 产品。需要先在支付设置中完整配置 Waffo Pancake。",
"Minute": "分钟",
"minutes": "分钟",
"Missing code": "缺少代码",
@ -2324,7 +2339,9 @@
"Model Mapping": "模型映射",
"Model Mapping (JSON)": "模型映射 (JSON)",
"Model Mapping must be a JSON object like": "模型映射必须是如下所示的 JSON 对象",
"Model mapping must be a JSON object with string values": "模型映射必须是值为字符串的 JSON 对象",
"Model mapping must be valid JSON": "模型映射必须是有效的 JSON",
"Model mapping values must be strings": "模型映射的值必须是字符串",
"Model name": "模型名称",
"Model Name": "模型名称",
"Model Name *": "模型名称 *",
@ -2333,8 +2350,8 @@
"Model not found": "模型未找到",
"Model performance metrics": "模型性能指标",
"Model Price": "模型价格",
"Model Price Not Configured": "模型价格未配置",
"Model price is not configured. Please complete model pricing in settings.": "模型价格未配置,请前往设置补充模型价格。",
"Model Price Not Configured": "模型价格未配置",
"Model prices": "模型价格",
"Model prices reset successfully": "模型价格重置成功",
"Model Pricing": "模型定价",
@ -2385,6 +2402,7 @@
"months": "个月",
"Moonshot": "Moonshot",
"More": "更多",
"More Apps": "更多",
"more mapping": "更多映射",
"More templates...": "更多模板...",
"More than 999 days left": "剩余超过 999 天",
@ -2437,6 +2455,7 @@
"Name Suffix": "名称后缀",
"Name the channel and choose the upstream provider.": "命名渠道并选择上游供应商。",
"Name the channel, choose the provider, configure API access, and set credentials.": "命名渠道、选择供应商、配置 API 访问并设置凭据。",
"Name, provider type, and availability.": "名称、供应商类型和可用状态。",
"name@example.com": "name@example.com",
"Native format": "原生格式",
"Need a redemption code?": "需要兑换码?",
@ -2446,6 +2465,7 @@
"Network proxy for this channel (supports socks5 protocol)": "此渠道的网络代理(支持 socks5 协议)",
"Never": "永不",
"Never expires": "永不过期",
"Never used an API Gateway?": "从未使用过 API 网关/中转 API",
"NEW": "新",
"New API": "New API",
"New API &lt;noreply@example.com&gt;": "New API &lt;noreply@example.com&gt;",
@ -2532,6 +2552,7 @@
"No Logs Found": "未找到日志",
"No mappings configured. Click \"Add Row\" to get started.": "未配置映射。点击 \"添加行\" 开始。",
"No matches found": "未找到匹配项",
"No matching items": "没有匹配项",
"No matching results": "无匹配结果",
"No matching rules": "没有匹配的规则",
"No messages yet": "暂无消息",
@ -2950,6 +2971,7 @@
"Please upload key file(s)": "请上传密钥文件",
"Please wait a moment before trying again.": "请稍候再试。",
"Please wait a moment, human check is initializing...": "请稍等,人机验证正在初始化...",
"Please wait before editing to avoid overwriting saved values.": "请等待加载完成后再编辑,以免覆盖已保存的值。",
"Policy JSON": "策略 JSON",
"Polling": "轮询",
"Polling mode requires Redis and memory cache, otherwise performance will be significantly degraded": "轮询模式需要 Redis 和内存缓存,否则性能将显著下降",
@ -2983,10 +3005,12 @@
"Prepend to Start": "追加到开头",
"Prepend value to array / string / object start": "把值追加到数组/字符串/对象开头",
"Preserve the original field when applying this rule": "应用此规则时保留原始字段",
"Preset groups": "预设分组",
"Preset recharge amounts (JSON array)": "预设充值金额JSON 数组)",
"Preset recharge amounts displayed to users": "向用户显示的预设充值金额",
"Preset Template": "预设模板",
"Preset templates": "预设模板",
"preset.anthropic": "Anthropic",
"preset.default": "默认",
"preset.forest-whisper": "森林低语",
"preset.lake-view": "湖光",
@ -3082,6 +3106,7 @@
"Public rankings page based on live usage data.": "基于真实用量数据的公开排行榜页面。",
"Publish Date": "发布日期",
"Published": "已发布",
"Published models, groups, and model remapping rules.": "已发布的模型、分组和模型重映射规则。",
"Published:": "已发布:",
"Pull": "拉取",
"Pull model": "拉取模型",
@ -3098,6 +3123,7 @@
"Querying...": "正在查询...",
"Question": "问题",
"Queued": "排队中",
"Quick actions": "快捷操作",
"Quick insert common payment methods": "快速插入常用支付方式",
"Quick Range": "快速范围",
"Quick Setup from Preset": "从预设快速设置",
@ -3219,6 +3245,7 @@
"Remaining:": "剩余:",
"Remark": "备注",
"Remove": "移除",
"Remove {{value}}": "移除 {{value}}",
"Remove ${{amount}}": "移除 ${{amount}}",
"Remove all log entries created before the selected timestamp.": "移除所选时间戳之前创建的所有日志条目。",
"Remove attachment": "移除附件",
@ -3226,6 +3253,7 @@
"Remove Duplicates": "移除重复项",
"Remove filter": "移除筛选",
"Remove functionResponse.id field": "移除 functionResponse.id 字段",
"Remove mapped targets": "移除映射目标",
"Remove Models": "删除模型",
"Remove Passkey": "解绑 Passkey",
"Remove Passkey?": "移除通行密钥?",
@ -3329,7 +3357,6 @@
"Retain last N files": "保留最近 N 个文件",
"Retention days": "保留天数",
"Retry": "重试",
"auth.resetPasswordConfirm.retry": "重试 ({{seconds}}s)",
"Retry Chain": "重试链路",
"Retry Suggestion": "重试建议",
"Retry Times": "重试次数",
@ -3339,7 +3366,6 @@
"Return Error": "返回错误",
"Return per-token log probabilities": "返回每个 token 的对数概率",
"Return to dashboard": "返回仪表盘",
"auth.resetPasswordConfirm.backToLogin": "返回登录",
"Return vector embeddings for inputs": "为输入返回向量嵌入",
"Reveal API key": "显示 API 密钥",
"Reveal key": "显示密钥",
@ -3491,11 +3517,11 @@
"Select all (filtered)": "全选(筛选结果)",
"Select all models": "选择所有模型",
"Select All Visible": "全选当前",
"Select model {{model}}": "选择模型 {{model}}",
"Select an operation mode and enter the amount": "选择操作模式并输入金额",
"Select announcement type": "选择公告类型",
"Select at least one field to overwrite.": "请选择至少一个要覆盖的字段。",
"Select at least one target model": "请至少选择一个目标模型",
"Select body font": "选择正文字体",
"Select border radius": "选择圆角大小",
"Select channel type": "选择渠道类型",
"Select color preset": "选择颜色预设",
@ -3518,6 +3544,7 @@
"Select layout style": "选择布局样式",
"Select locations": "选择位置",
"Select Model": "选择模型",
"Select model {{model}}": "选择模型 {{model}}",
"Select models (empty for allow all)": "选择模型(留空表示允许所有)",
"Select models and apply to channel models list.": "选择模型并应用到渠道模型列表。",
"Select models or add custom ones": "选择模型或添加自定义模型",
@ -3547,6 +3574,7 @@
"Select vendor": "选择供应商",
"Selectable groups": "可选分组",
"selected": "已选择",
"Selected {{count}}": "已选 {{count}} 个",
"selected channel(s). Leave empty to remove tag.": "选定的渠道。留空以移除标签。",
"Selected conflicts were overwritten successfully.": "选中的冲突已成功覆盖。",
"Selected when creating a token and used as the default billing group for API calls.": "创建令牌时选择,用作 API 调用的默认计费分组。",
@ -3690,6 +3718,7 @@
"Status & Sync": "状态与同步",
"Status Code": "状态码",
"Status Code Mapping": "状态码映射",
"Status code mapping must use valid HTTP status codes": "状态码映射必须使用有效的 HTTP 状态码",
"Status Page Slug": "状态页面 Slug",
"Status short": "状态",
"Status:": "状态:",
@ -3753,12 +3782,14 @@
"Sunset Glow": "日落霞光",
"Super Admin": "超级管理员",
"Support for high concurrency with automatic load balancing": "支持高并发和自动负载均衡",
"Supported Applications": "常用应用支持",
"Supported Imagine Models": "支持的 Imagine 模型",
"Supported modalities": "支持的模态",
"Supported parameters": "支持的参数",
"Supported variables": "支持变量",
"Supports `-thinking`, `-thinking-": "支持 `-thinking`、`-thinking-`",
"Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.": "支持 HTML 标记或 iframe 嵌入。直接输入 HTML 代码,或提供完整的 URL 以将其自动嵌入为 iframe。",
"Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.": "支持一键配置并完美适配 NewAPI 多协议配置",
"Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.": "支持 PNG、JPG、SVG 或 WebP建议尺寸不超过 128×128。",
"Sustained tokens per second": "持续每秒 Token 数",
"Swap Face": "换脸",
@ -3897,6 +3928,7 @@
"This action will permanently remove 2FA protection from your account.": "此操作将永久移除您账户的 2FA 保护。",
"This channel is not an Ollama channel.": "该渠道不是 Ollama 渠道。",
"This channel type does not support fetching models": "此通道类型不支持获取模型",
"This channel type requires additional configuration": "此渠道类型需要填写额外配置",
"This confirmation unlocks payment, redemption code, subscription plan, and invitation reward features. Please read the statements carefully.": "此确认会解锁支付、兑换码、订阅套餐和邀请奖励功能。请仔细阅读相关声明。",
"This controls model request rate limiting. Web/API route throttling is configured by environment variables and may still return 429.": "此处仅控制模型请求速率限制。Web/API 路由限流由环境变量配置,仍可能返回 429。",
"This data may be unreliable, use with caution": "此数据可能不可靠,请谨慎使用",
@ -4215,6 +4247,7 @@
"Use external tools to extend capabilities": "通过外部工具扩展能力",
"Use our unified OpenAI-compatible endpoint in your applications": "在应用中使用我们兼容 OpenAI 的统一接口",
"Use Passkey to sign in without entering your password.": "使用通行密钥登录,无需输入密码。",
"Use presets or upstream discovery to populate the model list faster.": "使用预设或上游发现来更快填充模型列表。",
"Use secure connection when sending emails": "发送电子邮件时使用安全连接",
"Use sidebar shortcut": "使用侧边栏快捷方式",
"Use the full-width table to scan prices, then select a row to edit it here.": "先在表格中快速浏览价格,然后选择一行在这里编辑。",
@ -4280,6 +4313,7 @@
"Vary": "变换",
"Vary (Strong)": "强变换",
"Vary (Subtle)": "弱变换",
"Vast Range of AI Models": "海量 AI 模型",
"Vendor": "供应商",
"Vendor deleted successfully": "供应商删除成功",
"Vendor Name *": "供应商名称 *",
@ -4302,8 +4336,10 @@
"Verifying credentials and pulling stores from your Pancake account...": "正在验证凭证并从你的 Pancake 账户拉取店铺...",
"Version Overrides": "版本覆盖",
"Vertex AI": "Vertex AI",
"Vertex AI API Key mode does not support batch creation": "Vertex AI API Key 模式不支持批量创建",
"Vertex AI does not support functionResponse.id. Enable this to remove the field automatically.": "Vertex AI 不支持 functionResponse.id 字段,开启后将自动移除该字段",
"Vertex AI Key Format": "Vertex AI 密钥格式",
"Vertex AI service account key must be valid JSON": "Vertex AI 服务账号密钥必须是有效 JSON",
"Video": "视频",
"Video length in seconds": "视频时长(秒)",
"Video Remix": "视频 Remix",
@ -4334,7 +4370,7 @@
"Visit Settings → General and adjust quota options...": "访问设置 → 通用并调整配额选项...",
"Visitors must authenticate before accessing the pricing directory.": "访客必须先进行身份验证才能访问定价目录。",
"Visitors must authenticate before accessing the rankings page.": "访客必须先进行身份验证才能访问排行榜页面。",
"Visual": "可视",
"Visual": "可视",
"Visual edit": "可视化编辑",
"Visual editor": "可视化编辑器",
"Visual Editor": "可视编辑器",
@ -4466,7 +4502,6 @@
"Your GitHub OAuth Client ID": "您的 GitHub OAuth 客户端 ID",
"Your GitHub OAuth Client Secret": "您的 GitHub OAuth 客户端密钥",
"Your new backup codes are ready": "您的新备份代码已准备就绪",
"auth.resetPasswordConfirm.success": "您的密码已成功重置",
"Your Referral Link": "您的推荐链接",
"Your setup guide is collapsed so usage stays in focus.": "设置引导已收起,让用量信息保持在焦点位置。",
"Your system access token for API authentication. Keep it secure and don't share it with others.": "您的系统访问令牌,用于 API 认证。请妥善保管,不要与他人分享。",

View File

@ -16,11 +16,21 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import axios from 'axios'
import i18next from 'i18next'
import axios, { type AxiosRequestConfig } from 'axios'
import { t } from 'i18next'
import { toast } from 'sonner'
import { useAuthStore } from '@/stores/auth-store'
declare module 'axios' {
export interface AxiosRequestConfig {
skipBusinessError?: boolean
skipErrorHandler?: boolean
disableDuplicate?: boolean
}
}
export type ApiRequestConfig = AxiosRequestConfig
// ============================================================================
// Axios Instance Configuration
// ============================================================================
@ -46,14 +56,11 @@ export const api = axios.create({
const inFlightGet = new Map<string, Promise<unknown>>()
const originalGet = api.get.bind(api)
api.get = ((url: string, config = {}) => {
const disableDuplicate = (config as unknown as Record<string, unknown>)
?.disableDuplicate
api.get = ((url: string, config: ApiRequestConfig = {}) => {
const disableDuplicate = config.disableDuplicate
if (disableDuplicate) return originalGet(url, config)
const params = (config as unknown as Record<string, unknown>)?.params
? JSON.stringify((config as unknown as Record<string, unknown>).params)
: '{}'
const params = config.params ? JSON.stringify(config.params) : '{}'
const key = `${url}?${params}`
// Return existing in-flight request if available
@ -72,8 +79,7 @@ api.get = ((url: string, config = {}) => {
// Handle business logic errors and HTTP errors globally
api.interceptors.response.use(
(response) => {
const skipBusiness = (response.config as unknown as Record<string, unknown>)
?.skipBusinessError
const skipBusiness = response.config.skipBusinessError
// Unified business response format: { success, message, data }
if (
@ -84,7 +90,7 @@ api.interceptors.response.use(
) {
if (!response.data.success) {
// Show error toast for business failures
const msg = response.data.message || 'Request failed'
const msg = response.data.message || t('Request failed')
toast.error(msg)
}
}
@ -92,23 +98,23 @@ api.interceptors.response.use(
},
(error) => {
const skip = error?.config?.skipErrorHandler
if (!skip) {
const status = error?.response?.status
const status = error?.response?.status
if (status === 401) {
// Unauthorized: clear auth state and show toast
toast.error(i18next.t('Session expired!'))
try {
useAuthStore.getState().auth.reset()
} catch {
/* empty */
}
} else {
// Other errors: show error message from response or default
const msg =
error?.response?.data?.message || error?.message || 'Request error'
toast.error(msg)
if (status === 401) {
try {
useAuthStore.getState().auth.reset()
} catch {
/* empty */
}
if (!skip) {
toast.error(t('Session expired!'))
}
} else if (!skip) {
// Other errors: show error message from response or default
const msg =
error?.response?.data?.message || error?.message || t('Request failed')
toast.error(msg)
}
return Promise.reject(error)
}
@ -175,7 +181,7 @@ export async function getSelf() {
const res = await api.get('/api/user/self', {
// Avoid global 401 toast during guards/preloads
skipErrorHandler: true,
} as Record<string, unknown>)
})
return res.data
}

View File

@ -29,6 +29,14 @@ export const THEME_PRESETS = [
name: 'Default',
swatches: ['oklch(0.13 0 0)', 'oklch(0.95 0 0)'],
},
{
// Inspired by Anthropic's official brand language: warm cream canvas
// (#faf9f5) paired with clay/coral (#d97757) as the single accent.
// Swatches preview the canvas → accent gradient that defines the system.
value: 'anthropic',
name: 'Anthropic',
swatches: ['oklch(0.984 0.005 95)', 'oklch(0.685 0.142 38)'],
},
{
value: 'underground',
name: 'Underground',
@ -71,8 +79,32 @@ export type ThemeRadius = 'default' | 'none' | 'sm' | 'md' | 'lg' | 'xl'
export type ThemeScale = 'default' | 'sm' | 'lg'
export type ContentLayout = 'full' | 'centered'
/**
* Font axis for the theme.
*
* - `default` resolve at runtime from the active preset
* (see `PRESET_DEFAULT_FONT`). The shipped `default` and `anthropic`
* presets resolve to serif; other named color presets fall back to
* sans unless they list a different choice. Mirrors how
* `radius: 'default'` defers to a per-preset hint.
* - `sans` humanist sans (Public Sans), the project's UI fallback.
* - `serif` editorial serif (Lora + CJK fallbacks), the project's
* "soul" typography. Inherits across the whole UI; monospace contexts
* keep their own family via Tailwind preflight and `.font-mono`.
*/
export type ThemeFont = 'default' | 'sans' | 'serif'
/**
* The resolved (non-`default`) font value applied to the DOM. The provider
* always sets `data-theme-font` to one of these concrete values so CSS only
* needs simple attribute selectors (no `:not()` gymnastics, no per-preset
* font branches).
*/
export type ResolvedThemeFont = Exclude<ThemeFont, 'default'>
export type ThemeCustomization = {
preset: ThemePreset
font: ThemeFont
radius: ThemeRadius
scale: ThemeScale
contentLayout: ContentLayout
@ -80,6 +112,7 @@ export type ThemeCustomization = {
export const DEFAULT_THEME_CUSTOMIZATION: ThemeCustomization = {
preset: 'default',
font: 'default',
radius: 'default',
scale: 'default',
contentLayout: 'full',
@ -89,6 +122,12 @@ export const THEME_PRESET_VALUES = new Set(
THEME_PRESETS.map((p) => p.value)
) as ReadonlySet<ThemePreset>
export const THEME_FONT_VALUES: ReadonlySet<ThemeFont> = new Set([
'default',
'sans',
'serif',
])
export const THEME_RADIUS_VALUES: ReadonlySet<ThemeRadius> = new Set([
'default',
'none',
@ -111,7 +150,42 @@ export const CONTENT_LAYOUT_VALUES: ReadonlySet<ContentLayout> = new Set([
export const THEME_COOKIE_KEYS = {
preset: 'theme_preset',
font: 'theme_font',
radius: 'theme_radius',
scale: 'theme_scale',
contentLayout: 'theme_content_layout',
} as const
/**
* Preset default font mapping. Used by the provider to resolve the user's
* `font: 'default'` preference against the active preset.
*
* Co-located with the preset registry so a preset's signature typography
* is declared in one place. Presets not listed here fall back to the
* `resolveThemeFont` default of `sans`. The shipped `default` preset
* opts into serif so the editorial Lora voice is the out-of-the-box
* experience; vivid color presets stay on the humanist sans so their
* accents read clearly without competing with the body type.
*/
export const PRESET_DEFAULT_FONT: Partial<
Record<ThemePreset, ResolvedThemeFont>
> = {
default: 'serif',
anthropic: 'serif',
}
/**
* Resolve a user font preference + active preset into the concrete font that
* should drive the DOM. Pure function so it's safe to call inside both the
* effect that applies the attribute and the UI preview that hints at what
* `default` will render as.
*/
export function resolveThemeFont(
font: ThemeFont,
preset: ThemePreset
): ResolvedThemeFont {
if (font === 'default') {
return PRESET_DEFAULT_FONT[preset] ?? 'sans'
}
return font
}

View File

@ -29,6 +29,7 @@ const apiKeySearchSchema = z.object({
.optional()
.catch([]),
filter: z.string().optional().catch(''),
token: z.string().optional().catch(''),
})
export const Route = createFileRoute('/_authenticated/keys/')({

View File

@ -20,6 +20,12 @@ For commercial licensing, please contact support@quantumnous.com
@import 'tw-animate-css';
@import 'shadcn/tailwind.css';
@import '@fontsource-variable/public-sans';
/* Editorial serif (Lora) backing the `serif` font axis and the Anthropic
* preset's default typography. See `--font-serif` in theme.css for the
* full Latin + CJK fallback stack and `theme-presets.css` for the cascade
* that activates it. Loaded globally so font-switching is instantaneous
* with no FOUT once the variable is fetched. */
@import '@fontsource-variable/lora';
@import './theme.css';
@import './theme-presets.css';
@ -44,7 +50,11 @@ For commercial licensing, please contact support@quantumnous.com
@apply overflow-x-hidden font-sans;
}
body {
@apply bg-background text-foreground has-[div[data-variant='inset']]:bg-sidebar min-h-svh w-full font-sans;
@apply bg-background text-foreground has-[div[data-variant='inset']]:bg-sidebar min-h-svh w-full;
/* Font is driven by the theme's font axis via `--font-body`
* (defined in theme.css, swapped by `[data-theme-font='...']` blocks
* in theme-presets.css). Defaults to the project's humanist sans. */
font-family: var(--font-body);
}
/* Keep sticky headers stable while primitives lock body scrolling. */

View File

@ -294,8 +294,20 @@ For commercial licensing, please contact support@quantumnous.com
/* ── Semantic surface bridge ──────────────────────────────────────────── */
/* Color presets should tint the surfaces most components actually use, not
* only primary buttons. These derived tokens keep the app theme-aware without
* duplicating per-component dark-mode overrides. */
[data-theme-preset]:not([data-theme-preset='default']) {
* duplicating per-component dark-mode overrides.
*
* NOTE: `:not()` contributes its argument's specificity, so this selector
* resolves to (0,2,0). Presets that define bespoke surfaces below need to
* either match that specificity or opt out here the latter is cleaner.
*
* Opt-outs:
* - `default`: keeps neutral surfaces from :root.
* - `anthropic`: warm cream surfaces are a brand choice, NOT a primary-mix
* derivation (the Anthropic system deliberately uses warm neutrals for
* cards/borders rather than tinting them with the clay accent). */
[data-theme-preset]:not([data-theme-preset='default']):not(
[data-theme-preset='anthropic']
) {
--card: color-mix(in oklch, var(--primary) 3%, var(--background));
--popover: color-mix(in oklch, var(--primary) 5%, var(--background));
--muted: color-mix(in oklch, var(--primary) 7%, var(--background));
@ -317,7 +329,10 @@ For commercial licensing, please contact support@quantumnous.com
--info: var(--chart-1);
--neutral: var(--muted-foreground);
}
.dark [data-theme-preset]:not([data-theme-preset='default']) {
.dark
[data-theme-preset]:not([data-theme-preset='default']):not(
[data-theme-preset='anthropic']
) {
--card: color-mix(in oklch, var(--primary) 8%, var(--background));
--popover: color-mix(in oklch, var(--primary) 12%, var(--background));
--muted: color-mix(in oklch, var(--primary) 12%, var(--background));
@ -334,6 +349,213 @@ For commercial licensing, please contact support@quantumnous.com
--sidebar-border: color-mix(in oklch, var(--primary) 22%, var(--background));
}
/* ── Anthropic ────────────────────────────────────────────────────────── */
/*
* Inspired by Anthropic's official brand language: warm cream canvas
* (#faf9f5) on warm slate ink (#141413), with clay/coral (#d97757) as the
* single primary accent. The dormant accent palette (olive, sky, fig,
* cactus) is wired into chart and semantic tokens.
*
* Defining counter-positioning: a tinted (non-white) canvas with warm
* neutral cards and borders NOT primary-tinted surfaces. This is the
* brand's deliberate counter-positioning against every cool-gray AI tool.
*
* Anthropic is opted out of the semantic surface bridge above so these
* bespoke warm-neutral surface tokens win the cascade. Without the opt-out,
* the bridge selector (specificity 0,2,0 because of `:not()`) would override
* this block (specificity 0,1,0) and tint every surface with the clay
* accent producing the peach/pink look that doesn't match Anthropic.
*
* OKLCH hue 95 = warm yellow-cream (matches #faf9f5 family);
* OKLCH hue 60 = warm slate (matches #141413 family);
* OKLCH hue 38 = clay/coral (matches #d97757).
*/
[data-theme-preset='anthropic'] {
/* Canvas + ink — the defining pair. */
--background: oklch(0.984 0.004 95); /* ≈ #faf9f5 cream */
--foreground: oklch(0.205 0.005 60); /* ≈ #141413 ink */
/* Warm-neutral surfaces (NOT primary-tinted). Stepped opacity matches
* the Anthropic surface ladder: canvas secondary card strong. */
--card: oklch(0.945 0.008 92); /* ≈ #efe9de */
--card-foreground: oklch(0.205 0.005 60);
--popover: oklch(0.97 0.006 92); /* slight cream lift */
--popover-foreground: oklch(0.205 0.005 60);
/* Clay/coral — Anthropic's signature accent, used scarcely on CTAs. */
--primary: oklch(0.685 0.142 38); /* ≈ #d97757 */
--primary-foreground: oklch(0.99 0.005 95);
--secondary: oklch(0.925 0.008 92);
--secondary-foreground: oklch(0.255 0.005 60);
--muted: oklch(0.94 0.007 92);
--muted-foreground: oklch(0.51 0.006 75); /* ≈ #5e5d59 warm gray */
--accent: oklch(0.92 0.009 92);
--accent-foreground: oklch(0.205 0.005 60);
--destructive: oklch(0.55 0.18 27);
--destructive-foreground: oklch(0.985 0 0);
--success: oklch(0.59 0.082 130); /* olive #788c5d */
--success-foreground: oklch(0.985 0 0);
--warning: oklch(0.78 0.13 70); /* kraft amber */
--warning-foreground: oklch(0.205 0.005 60);
--info: oklch(0.67 0.075 248); /* sky #6a9bcc */
--info-foreground: oklch(0.985 0 0);
--neutral: oklch(0.51 0.006 75);
--neutral-foreground: oklch(0.205 0.005 60);
/* Hairline borders — warm gray, not coral. */
--border: oklch(0.895 0.008 92); /* ≈ #e8e6dc */
--input: oklch(0.895 0.008 92);
--ring: oklch(0.685 0.142 38);
/* Chart palette uses Anthropic's dormant accent swatches. */
--chart-1: oklch(0.685 0.142 38); /* clay */
--chart-2: oklch(0.59 0.082 130); /* olive */
--chart-3: oklch(0.67 0.075 248); /* sky */
--chart-4: oklch(0.7 0.115 0); /* fig */
--chart-5: oklch(0.83 0.027 175); /* cactus */
--sidebar: oklch(0.955 0.008 92);
--sidebar-foreground: oklch(0.255 0.005 60);
--sidebar-primary: oklch(0.685 0.142 38);
--sidebar-primary-foreground: oklch(0.99 0.005 95);
--sidebar-accent: oklch(0.915 0.009 92);
--sidebar-accent-foreground: oklch(0.205 0.005 60);
--sidebar-border: oklch(0.895 0.008 92);
--sidebar-ring: oklch(0.685 0.142 38);
--skeleton-base: oklch(0.93 0.008 92);
--skeleton-highlight: oklch(0.96 0.006 92);
--radius: 0.625rem;
/* Default typography for the Anthropic preset is the editorial serif.
* Users can override this with the Font axis (`data-theme-font='sans'`).
* The `--font-serif` token itself is declared once in theme.css. */
--font-body: var(--font-serif);
}
.dark [data-theme-preset='anthropic'] {
/* Warm near-black product surfaces, not pure black keeps the editorial
* personality even when inverted. Coral lifts slightly for legibility. */
--background: oklch(0.205 0.004 60); /* ≈ #181715 */
--foreground: oklch(0.965 0.005 92); /* ≈ #faf9f5 */
--card: oklch(0.245 0.004 60);
--card-foreground: oklch(0.965 0.005 92);
--popover: oklch(0.265 0.004 60);
--popover-foreground: oklch(0.965 0.005 92);
--primary: oklch(0.72 0.135 40);
--primary-foreground: oklch(0.18 0.005 60);
--secondary: oklch(0.295 0.004 60);
--secondary-foreground: oklch(0.945 0.005 92);
--muted: oklch(0.275 0.004 60);
--muted-foreground: oklch(0.76 0.006 75);
--accent: oklch(0.32 0.006 60);
--accent-foreground: oklch(0.985 0.005 92);
--destructive: oklch(0.7 0.19 22);
--destructive-foreground: oklch(0.985 0 0);
--success: oklch(0.7 0.105 135);
--success-foreground: oklch(0.18 0.005 60);
--warning: oklch(0.78 0.13 70);
--warning-foreground: oklch(0.18 0.005 60);
--info: oklch(0.72 0.085 248);
--info-foreground: oklch(0.18 0.005 60);
--neutral: oklch(0.76 0.006 75);
--neutral-foreground: oklch(0.18 0.005 60);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 16%);
--ring: oklch(0.72 0.135 40);
--chart-1: oklch(0.72 0.135 40);
--chart-2: oklch(0.7 0.105 135);
--chart-3: oklch(0.72 0.085 248);
--chart-4: oklch(0.78 0.13 0);
--chart-5: oklch(0.83 0.027 175);
--sidebar: oklch(0.175 0.004 60);
--sidebar-foreground: oklch(0.95 0.005 92);
--sidebar-primary: oklch(0.72 0.135 40);
--sidebar-primary-foreground: oklch(0.18 0.005 60);
--sidebar-accent: oklch(0.31 0.006 60);
--sidebar-accent-foreground: oklch(0.985 0.005 92);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.72 0.135 40);
--skeleton-base: oklch(0.295 0.004 60);
--skeleton-highlight: oklch(0.4 0.004 60);
}
/* Font axis
* Mirrors how `data-theme-radius` overrides a preset's default radius:
* presets may set `--font-body` (Anthropic serif), and the user's
* explicit Font choice wins because these blocks sit AFTER preset blocks.
*
* The provider resolves `font: 'default'` `'sans' | 'serif'` against the
* active preset before writing the attribute, so the DOM always carries a
* concrete value and CSS only needs the simple `[data-theme-font='serif']`
* selector below (no `:not()`, no per-preset branches). */
[data-theme-font='sans'] {
--font-body: var(--font-sans);
}
[data-theme-font='serif'] {
--font-body: var(--font-serif);
}
/* Serif typography refinements
* When the body runs in serif, three things happen:
* 1. Editorial OpenType features (kern/liga + tabular numerals so numeric
* columns stay grid-aligned even with proportional serif glyphs).
* 2. Every UI surface inherits the editorial voice buttons, inputs,
* tabs, sidebar, table headers, badges, pagination, popovers all
* through Tailwind preflight's `button, input, ... { font: inherit }`
* rule plus natural HTML inheritance. We intentionally do NOT carry
* a per-slot opt-out list: it adds maintenance cost and only blunts
* the Anthropic editorial intent. Monospace contexts are excluded
* automatically because:
* - `<code>`, `<kbd>`, `<pre>`, `<samp>` are typed monospace by
* Tailwind preflight (specificity wins over body inheritance).
* - `.font-mono` and `.tabular-nums` utilities declare their own
* font-family / font-variant-numeric on the element itself.
* 3. Headings adopt the Anthropic display setting (medium weight, slight
* negative tracking).
*
* All keyed off `[data-theme-font='serif']` so they apply to any preset
* paired with the serif font not just Anthropic. */
[data-theme-font='serif'] {
font-feature-settings:
'kern' 1,
'liga' 1,
'calt' 1,
'tnum' 1;
}
/* Heading tuning applies to <h*> tags and the shadcn title slots so
* card/sheet/dialog titles read with the same authoritative voice as
* page headers. */
[data-theme-font='serif'] :is(h1, h2, h3, h4, h5, h6),
[data-theme-font='serif'] [data-slot='sheet-title'],
[data-theme-font='serif'] [data-slot='dialog-title'],
[data-theme-font='serif'] [data-slot='alert-dialog-title'],
[data-theme-font='serif'] [data-slot='drawer-title'],
[data-theme-font='serif'] [data-slot='card-title'] {
font-weight: 500;
letter-spacing: -0.012em;
}
/* Larger displays earn tighter tracking, matching Copernicus/Tiempos
* editorial display setting (~ -0.02em at 30px+). */
[data-theme-font='serif'] h1,
[data-theme-font='serif'] .text-3xl,
[data-theme-font='serif'] .text-4xl,
[data-theme-font='serif'] .text-5xl {
letter-spacing: -0.02em;
}
/* ── Border radius ────────────────────────────────────────────────────── */
[data-theme-radius='none'] {
--radius: 0rem;

View File

@ -20,6 +20,19 @@ For commercial licensing, please contact support@quantumnous.com
@theme inline {
--font-sans: 'Public Sans', sans-serif;
/* Editorial serif token the body face used by the `serif` font axis
* and by presets that opt in (currently Anthropic). Lora carries only
* Latin glyphs, so the stack walks through CJK serifs (Noto / Source Han
* / Songti / SimSun) before the generic `serif` fallback. Without these
* explicit CJK fonts the browser's generic-serif pick on Windows is
* non-deterministic at small sizes and often appears sans-like. */
--font-serif:
'Lora Variable', 'Lora', 'Source Serif Pro', 'Source Serif 4',
'Noto Serif SC', 'Noto Serif TC', 'Noto Serif JP', 'Noto Serif KR',
'Source Han Serif SC', 'Source Han Serif TC', 'Source Han Serif',
'Songti SC', 'STSong', 'STSongti-SC-Regular', 'PingFang SC', 'SimSun',
'NSimSun', '宋体', 'FangSong', '仿宋', 'KaiTi', '楷体', Georgia,
'Times New Roman', Cambria, 'Liberation Serif', serif;
--font-inter:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
@ -82,6 +95,11 @@ For commercial licensing, please contact support@quantumnous.com
--app-header-height: 3rem;
/* Static build-channel fallback consumed when JS hasn't booted yet. */
--app-rev: '2k6e8r7p';
/* Currently active body font. The font axis swaps this between
* `--font-sans` and `--font-serif` via `[data-theme-font='...']` blocks
* in theme-presets.css. Default mirrors `--font-sans` so behavior is
* identical when no font preference is set. */
--font-body: var(--font-sans);
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);