From 5c47d89b7b91fe7aed39eaa558e768a79de2622e Mon Sep 17 00:00:00 2001 From: wangdong Date: Wed, 8 Jul 2026 16:36:33 +0800 Subject: [PATCH 01/14] ci: add GHCR workflow for custom Docker image builds Enable multi-arch image publishing to ghcr.io on version tags and manual dispatch. Co-authored-by: Cursor --- .github/workflows/docker-ghcr-custom.yml | 138 +++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 .github/workflows/docker-ghcr-custom.yml diff --git a/.github/workflows/docker-ghcr-custom.yml b/.github/workflows/docker-ghcr-custom.yml new file mode 100644 index 000000000000..319eeff95477 --- /dev/null +++ b/.github/workflows/docker-ghcr-custom.yml @@ -0,0 +1,138 @@ +name: Publish custom image to GHCR + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Image tag (e.g. v1.0.0-custom)' + required: true + type: string + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build_single_arch: + name: Build & push (${{ matrix.arch }}) + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-latest + - arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + outputs: + tag: ${{ steps.version.outputs.tag }} + + permissions: + contents: read + packages: write + + steps: + - name: Check out + uses: actions/checkout@v4 + with: + fetch-depth: ${{ github.event_name == 'workflow_dispatch' && 0 || 1 }} + ref: ${{ github.event.inputs.tag || github.ref }} + + - name: Resolve tag & write VERSION + id: version + run: | + if [ -n "${{ github.event.inputs.tag }}" ]; then + TAG="${{ github.event.inputs.tag }}" + else + TAG=${GITHUB_REF#refs/tags/} + fi + echo "TAG=${TAG}" >> "$GITHUB_ENV" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "${TAG}" > VERSION + echo "Building tag: ${TAG} for ${{ matrix.arch }}" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Build & push + id: build + uses: docker/build-push-action@v6 + with: + context: . + platforms: ${{ matrix.platform }} + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.TAG }}-${{ matrix.arch }} + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest-${{ matrix.arch }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Image summary + run: | + echo "### Docker Image (${{ matrix.arch }})" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${TAG}-${{ matrix.arch }}" >> "$GITHUB_STEP_SUMMARY" + echo "${{ steps.build.outputs.digest }}" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + + create_manifests: + name: Create multi-arch manifests + needs: [build_single_arch] + runs-on: ubuntu-latest + + permissions: + packages: read + contents: read + + steps: + - name: Set version + run: echo "TAG=${{ needs.build_single_arch.outputs.tag }}" >> "$GITHUB_ENV" + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create & push manifest (version) + run: | + docker buildx imagetools create \ + -t "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${TAG}" \ + "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${TAG}-amd64" \ + "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${TAG}-arm64" + + - name: Create & push manifest (latest) + run: | + docker buildx imagetools create \ + -t "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" \ + "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest-amd64" \ + "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest-arm64" + + - name: Manifest summary + run: | + echo "### Multi-arch Manifest" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${TAG}" >> "$GITHUB_STEP_SUMMARY" + echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" >> "$GITHUB_STEP_SUMMARY" + docker buildx imagetools inspect "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${TAG}" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" From 28d304548c317ef068a7df648f62576b80803fdc Mon Sep 17 00:00:00 2001 From: wangdong Date: Wed, 8 Jul 2026 16:41:00 +0800 Subject: [PATCH 02/14] fix(ci): correct GHCR workflow checkout ref handling Checkout the triggering commit directly instead of treating the image tag as a git ref. Co-authored-by: Cursor --- .github/workflows/docker-ghcr-custom.yml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/docker-ghcr-custom.yml b/.github/workflows/docker-ghcr-custom.yml index 319eeff95477..72fe5288f685 100644 --- a/.github/workflows/docker-ghcr-custom.yml +++ b/.github/workflows/docker-ghcr-custom.yml @@ -6,8 +6,8 @@ on: - 'v*' workflow_dispatch: inputs: - tag: - description: 'Image tag (e.g. v1.0.0-custom)' + image_tag: + description: 'Docker image tag only (e.g. v1.0.0-custom), not a git ref' required: true type: string @@ -40,14 +40,13 @@ jobs: - name: Check out uses: actions/checkout@v4 with: - fetch-depth: ${{ github.event_name == 'workflow_dispatch' && 0 || 1 }} - ref: ${{ github.event.inputs.tag || github.ref }} + fetch-depth: 0 - name: Resolve tag & write VERSION id: version run: | - if [ -n "${{ github.event.inputs.tag }}" ]; then - TAG="${{ github.event.inputs.tag }}" + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + TAG="${{ github.event.inputs.image_tag }}" else TAG=${GITHUB_REF#refs/tags/} fi From aebda558b7d71b0df81923e0fa354769df3b24e0 Mon Sep 17 00:00:00 2001 From: wangdong Date: Wed, 8 Jul 2026 16:43:46 +0800 Subject: [PATCH 03/14] fix(ci): lowercase GHCR image repository name Docker requires lowercase registry paths; normalize github.repository before tagging. Co-authored-by: Cursor --- .github/workflows/docker-ghcr-custom.yml | 46 +++++++++++++++--------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/.github/workflows/docker-ghcr-custom.yml b/.github/workflows/docker-ghcr-custom.yml index 72fe5288f685..7c31f45fbaba 100644 --- a/.github/workflows/docker-ghcr-custom.yml +++ b/.github/workflows/docker-ghcr-custom.yml @@ -13,11 +13,28 @@ on: env: REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} jobs: + prepare: + name: Prepare build metadata + runs-on: ubuntu-latest + outputs: + image_name: ${{ steps.meta.outputs.image_name }} + tag: ${{ steps.meta.outputs.tag }} + steps: + - id: meta + run: | + echo "image_name=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + TAG="${{ github.event.inputs.image_tag }}" + else + TAG=${GITHUB_REF#refs/tags/} + fi + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + build_single_arch: name: Build & push (${{ matrix.arch }}) + needs: [prepare] strategy: fail-fast: false matrix: @@ -30,7 +47,7 @@ jobs: runner: ubuntu-24.04-arm runs-on: ${{ matrix.runner }} outputs: - tag: ${{ steps.version.outputs.tag }} + tag: ${{ needs.prepare.outputs.tag }} permissions: contents: read @@ -42,18 +59,13 @@ jobs: with: fetch-depth: 0 - - name: Resolve tag & write VERSION - id: version + - name: Write VERSION run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - TAG="${{ github.event.inputs.image_tag }}" - else - TAG=${GITHUB_REF#refs/tags/} - fi + TAG="${{ needs.prepare.outputs.tag }}" echo "TAG=${TAG}" >> "$GITHUB_ENV" - echo "tag=${TAG}" >> "$GITHUB_OUTPUT" echo "${TAG}" > VERSION echo "Building tag: ${TAG} for ${{ matrix.arch }}" + echo "Image: ${{ env.REGISTRY }}/${{ needs.prepare.outputs.image_name }}" - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -69,7 +81,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + images: ${{ env.REGISTRY }}/${{ needs.prepare.outputs.image_name }} - name: Build & push id: build @@ -79,8 +91,8 @@ jobs: platforms: ${{ matrix.platform }} push: true tags: | - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.TAG }}-${{ matrix.arch }} - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest-${{ matrix.arch }} + ${{ env.REGISTRY }}/${{ needs.prepare.outputs.image_name }}:${{ env.TAG }}-${{ matrix.arch }} + ${{ env.REGISTRY }}/${{ needs.prepare.outputs.image_name }}:latest-${{ matrix.arch }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max @@ -89,13 +101,13 @@ jobs: run: | echo "### Docker Image (${{ matrix.arch }})" >> "$GITHUB_STEP_SUMMARY" echo '```' >> "$GITHUB_STEP_SUMMARY" - echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${TAG}-${{ matrix.arch }}" >> "$GITHUB_STEP_SUMMARY" + echo "${{ env.REGISTRY }}/${{ needs.prepare.outputs.image_name }}:${TAG}-${{ matrix.arch }}" >> "$GITHUB_STEP_SUMMARY" echo "${{ steps.build.outputs.digest }}" >> "$GITHUB_STEP_SUMMARY" echo '```' >> "$GITHUB_STEP_SUMMARY" create_manifests: name: Create multi-arch manifests - needs: [build_single_arch] + needs: [prepare, build_single_arch] runs-on: ubuntu-latest permissions: @@ -104,7 +116,9 @@ jobs: steps: - name: Set version - run: echo "TAG=${{ needs.build_single_arch.outputs.tag }}" >> "$GITHUB_ENV" + run: | + echo "TAG=${{ needs.prepare.outputs.tag }}" >> "$GITHUB_ENV" + echo "IMAGE_NAME=${{ needs.prepare.outputs.image_name }}" >> "$GITHUB_ENV" - name: Log in to GHCR uses: docker/login-action@v3 From 7aa1f42fca634f4695b3d694d4d6b8a0cb741c80 Mon Sep 17 00:00:00 2001 From: wangdong Date: Wed, 8 Jul 2026 16:59:30 +0800 Subject: [PATCH 04/14] fix(ci): grant packages write permission for GHCR manifest push The create_manifests job needs write access to publish multi-arch tags. Co-authored-by: Cursor --- .github/workflows/docker-ghcr-custom.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker-ghcr-custom.yml b/.github/workflows/docker-ghcr-custom.yml index 7c31f45fbaba..b4dc27d0ded5 100644 --- a/.github/workflows/docker-ghcr-custom.yml +++ b/.github/workflows/docker-ghcr-custom.yml @@ -14,6 +14,10 @@ on: env: REGISTRY: ghcr.io +permissions: + contents: read + packages: write + jobs: prepare: name: Prepare build metadata @@ -111,8 +115,8 @@ jobs: runs-on: ubuntu-latest permissions: - packages: read contents: read + packages: write steps: - name: Set version From 5955359c89dc1dd3daed3abc7422595720aeae3e Mon Sep 17 00:00:00 2001 From: wangdong Date: Fri, 10 Jul 2026 16:50:01 +0800 Subject: [PATCH 05/14] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=85=91=E6=8D=A2?= =?UTF-8?q?=E7=A0=81=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- model/redemption.go | 27 +++++- model/redemption_test.go | 21 +++++ .../components/redemptions-mutate-drawer.tsx | 87 ++++++++++++++++++- .../components/redemptions-table.tsx | 2 +- web/default/src/i18n/locales/en.json | 4 +- web/default/src/i18n/locales/fr.json | 4 +- web/default/src/i18n/locales/ja.json | 4 +- web/default/src/i18n/locales/ru.json | 4 +- web/default/src/i18n/locales/vi.json | 4 +- web/default/src/i18n/locales/zh-TW.json | 4 +- web/default/src/i18n/locales/zh.json | 4 +- 11 files changed, 155 insertions(+), 10 deletions(-) diff --git a/model/redemption.go b/model/redemption.go index 23985ef474b7..252f939bf23e 100644 --- a/model/redemption.go +++ b/model/redemption.go @@ -1,9 +1,11 @@ package model import ( + "encoding/hex" "errors" "fmt" "strconv" + "strings" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/logger" @@ -74,8 +76,31 @@ func SearchRedemptions(keyword string, status string, startIdx int, num int) (re query := tx.Model(&Redemption{}) if keyword != "" { + keyCol := "`key`" + if common.UsingMainDatabase(common.DatabaseTypePostgreSQL) { + keyCol = `"key"` + } + normalizedKey := strings.ReplaceAll(strings.TrimSpace(keyword), "-", "") + exactKeyMatch := len(normalizedKey) == 32 + if exactKeyMatch { + _, decodeErr := hex.DecodeString(normalizedKey) + exactKeyMatch = decodeErr == nil + } + if id, err := strconv.Atoi(keyword); err == nil { - query = query.Where("id = ? OR name LIKE ?", id, keyword+"%") + if exactKeyMatch { + query = query.Where( + fmt.Sprintf("id = ? OR name LIKE ? OR %s = ?", keyCol), + id, keyword+"%", normalizedKey, + ) + } else { + query = query.Where("id = ? OR name LIKE ?", id, keyword+"%") + } + } else if exactKeyMatch { + query = query.Where( + fmt.Sprintf("name LIKE ? OR %s = ?", keyCol), + keyword+"%", normalizedKey, + ) } else { query = query.Where("name LIKE ?", keyword+"%") } diff --git a/model/redemption_test.go b/model/redemption_test.go index 0ba2e8e8e39f..ac9cdfceb58c 100644 --- a/model/redemption_test.go +++ b/model/redemption_test.go @@ -49,6 +49,27 @@ func TestSearchRedemptionsFiltersAndPaginates(t *testing.T) { wantTotal: 3, wantIds: []int{3, 2, 1}, }, + { + name: "keyword matches full redemption key exactly", + keyword: "00000000000000000000000000000002", + num: 10, + wantTotal: 1, + wantIds: []int{2}, + }, + { + name: "keyword matches full redemption key with dashes", + keyword: "00000000-0000-0000-0000-000000000003", + num: 10, + wantTotal: 1, + wantIds: []int{3}, + }, + { + name: "partial redemption key does not match by key", + keyword: "0000000000000000000000000000000", + num: 10, + wantTotal: 0, + wantIds: []int{}, + }, { name: "enabled status excludes expired rows", status: "1", diff --git a/web/default/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx b/web/default/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx index 47f7a387c34c..af89212d2fce 100644 --- a/web/default/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx +++ b/web/default/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx @@ -30,6 +30,16 @@ import { sideDrawerFormClassName, sideDrawerHeaderClassName, } from '@/components/drawer-layout' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' import { Button } from '@/components/ui/button' import { Form, @@ -72,6 +82,27 @@ type RedemptionsMutateDrawerProps = { currentRow?: Redemption } +type RedemptionExportDialogState = { + open: boolean + keys: string[] + filename: string +} + +function sanitizeDownloadFilename(name: string) { + const sanitized = name.replace(/[/\\?%*:|"<>]/g, '_').trim() + return sanitized || 'redemption-codes' +} + +function downloadRedemptionCodes(keys: string[], filename: string) { + const blob = new Blob([keys.join('\n')], { type: 'text/plain;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + anchor.click() + URL.revokeObjectURL(url) +} + export function RedemptionsMutateDrawer({ open, onOpenChange, @@ -81,6 +112,11 @@ export function RedemptionsMutateDrawer({ const isUpdate = !!currentRow const { triggerRefresh } = useRedemptions() const [isSubmitting, setIsSubmitting] = useState(false) + const [exportDialog, setExportDialog] = useState({ + open: false, + keys: [], + filename: 'redemption-codes.txt', + }) const form = useForm({ resolver: zodResolver(getRedemptionFormSchema(t)), @@ -131,6 +167,15 @@ export function RedemptionsMutateDrawer({ ) onOpenChange(false) triggerRefresh() + if (result.data && result.data.length > 0) { + const redemptionName = + basePayload.name?.trim() || formatQuota(basePayload.quota) + setExportDialog({ + open: true, + keys: result.data, + filename: `${sanitizeDownloadFilename(redemptionName)}.txt`, + }) + } } } } finally { @@ -164,7 +209,8 @@ export function RedemptionsMutateDrawer({ : t('Enter quota in {{currency}}', { currency: currencyLabel }) return ( - + { onOpenChange(v) @@ -339,5 +385,44 @@ export function RedemptionsMutateDrawer({ + + { + if (!open) { + setExportDialog((previous) => ({ ...previous, open: false })) + } + }} + > + + + + {t('Redemption code(s) created successfully')} + + + {t( + 'Do you want to download the created redemption codes as a text file?' + )} +
+ {t('The download will use the redemption name as the filename.')} +
+
+ + {t('Cancel')} + { + downloadRedemptionCodes( + exportDialog.keys, + exportDialog.filename + ) + setExportDialog((previous) => ({ ...previous, open: false })) + }} + > + {t('Download')} + + +
+
+ ) } diff --git a/web/default/src/features/redemption-codes/components/redemptions-table.tsx b/web/default/src/features/redemption-codes/components/redemptions-table.tsx index dce04c1c7caa..70db4eb92f20 100644 --- a/web/default/src/features/redemption-codes/components/redemptions-table.tsx +++ b/web/default/src/features/redemption-codes/components/redemptions-table.tsx @@ -169,7 +169,7 @@ export function RedemptionsTable() { skeletonKeyPrefix='redemptions-skeleton' applyHeaderSize toolbarProps={{ - searchPlaceholder: t('Filter by name or ID...'), + searchPlaceholder: t('Filter by name, ID, or redemption code...'), filters: [ { columnId: 'status', diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 539df00f2741..998d4177ac5d 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -1421,6 +1421,7 @@ "Do not wait one second between polling async tasks for this channel": "Do not wait one second between polling async tasks for this channel", "Do regex replacement in the target field": "Do regex replacement in the target field", "Do string replacement in the target field": "Do string replacement in the target field", + "Do you want to download the created redemption codes as a text file?": "Do you want to download the created redemption codes as a text file?", "Docs": "Docs", "Documentation Link": "Documentation Link", "Documentation or external knowledge base.": "Documentation or external knowledge base.", @@ -1947,8 +1948,8 @@ "Filter by MjProxy task ID": "Filter by MjProxy 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, ID, or key...": "Filter by name, ID, or key...", + "Filter by name, ID, or redemption code...": "Filter by name, ID, or redemption code...", "Filter by name...": "Filter by name...", "Filter by node": "Filter by node", "Filter by price field": "Filter by price field", @@ -4397,6 +4398,7 @@ "The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.": "The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.", "The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.": "The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.", "The deployment node that handled the requests": "The deployment node that handled the requests", + "The download will use the redemption name as the filename.": "The download will use the redemption name as the filename.", "The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "The effective domain for Passkey registration. Must match the current domain or be its parent domain.", "The entered text does not match the required text.": "The entered text does not match the required text.", "The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.": "The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 2d84f89add8e..f113fdde2909 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -1421,6 +1421,7 @@ "Do not wait one second between polling async tasks for this channel": "Ne pas attendre une seconde entre les interrogations des tâches asynchrones pour ce canal", "Do regex replacement in the target field": "Effectuer un remplacement par expression régulière dans le champ cible", "Do string replacement in the target field": "Effectuer un remplacement de chaîne dans le champ cible", + "Do you want to download the created redemption codes as a text file?": "Voulez-vous télécharger les codes de réduction créés sous forme de fichier texte ?", "Docs": "Documents", "Documentation Link": "Lien de la documentation", "Documentation or external knowledge base.": "Documentation ou base de connaissances externe.", @@ -1947,8 +1948,8 @@ "Filter by MjProxy task ID": "Filtrer par ID de tâche MjProxy", "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, ID, or key...": "Filtrer par nom, ID ou clé...", + "Filter by name, ID, or redemption code...": "Filtrer par nom, ID ou code de réduction...", "Filter by name...": "Filtrer par nom...", "Filter by node": "Filtrer par nœud", "Filter by price field": "Filtrer par champ de prix", @@ -4397,6 +4398,7 @@ "The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.": "Le produit associé alimente les recharges de portefeuille : lorsqu’un utilisateur saisit un montant, new-api lance le paiement sur ce produit Pancake unique et remplace le prix pour la session, sans devoir précréer des SKU de 1 $, 5 $ ou 10 $.", "The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.": "La boutique associée est le conteneur parent de tous les produits Pancake que new-api crée depuis cette administration, y compris le produit de recharge de portefeuille et les produits de forfaits d’abonnement. Une seule boutique suffit ; choisissez-en une autre uniquement si vous gérez réellement des catalogues Pancake séparés.", "The deployment node that handled the requests": "Le nœud de déploiement ayant traité les requêtes", + "The download will use the redemption name as the filename.": "Le fichier sera enregistré en utilisant le nom du code de réduction comme nom de fichier.", "The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "Le domaine effectif pour l'enregistrement de la clé d'accès. Doit correspondre au domaine actuel ou être son domaine parent.", "The entered text does not match the required text.": "Le texte saisi ne correspond pas au texte requis.", "The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.": "L’environnement (test ou production) est déterminé par la clé collée ici : utilisez la clé de test pendant l’intégration, puis remplacez-la par la clé de production lors de la mise en ligne.", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index f47e02bb0ad6..7e6cffc0bf5a 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -1421,6 +1421,7 @@ "Do not wait one second between polling async tasks for this channel": "このチャネルの非同期タスクをポーリングする間に1秒待機しない", "Do regex replacement in the target field": "ターゲットフィールドで正規表現置換", "Do string replacement in the target field": "ターゲットフィールドで文字列置換", + "Do you want to download the created redemption codes as a text file?": "作成した引き換えコードをテキストファイルとしてダウンロードしますか?", "Docs": "ドキュメント", "Documentation Link": "ドキュメントリンク", "Documentation or external knowledge base.": "ドキュメントまたは外部知識ベース。", @@ -1947,8 +1948,8 @@ "Filter by MjProxy task ID": "MjProxyタスクIDでフィルター", "Filter by model name...": "モデル名でフィルター...", "Filter by model...": "モデルでフィルタリング...", - "Filter by name or ID...": "名前またはIDでフィルター...", "Filter by name, ID, or key...": "名前、ID、またはキーでフィルター...", + "Filter by name, ID, or redemption code...": "名前、ID、または引き換えコードでフィルター...", "Filter by name...": "名前でフィルター...", "Filter by node": "ノードでフィルター", "Filter by price field": "価格フィールドでフィルター", @@ -4397,6 +4398,7 @@ "The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.": "紐付け済み商品はウォレットチャージに使用されます。ユーザーが任意の金額を入力すると、new-api はこの単一の Pancake 商品でチェックアウトを実行し、セッションごとに価格を上書きします。$1 / $5 / $10 の SKU を事前作成する必要はありません。", "The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.": "紐付け済みストアは、この管理画面から new-api が作成するすべての Pancake 商品の親コンテナです。ウォレットチャージ商品とサブスクリプションプラン商品が含まれます。通常は 1 つのストアで十分です。別々の Pancake カタログを本当に運用する場合のみ別のストアを固定してください。", "The deployment node that handled the requests": "リクエストを処理したデプロイノード", + "The download will use the redemption name as the filename.": "ダウンロードファイル名には引き換えコードの名称が使用されます。", "The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "Passkey登録のための有効なドメイン。現在のドメインまたはその親ドメインと一致する必要があります。", "The entered text does not match the required text.": "入力したテキストが必要なテキストと一致しません。", "The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.": "環境(テスト/本番)はここに貼り付けるキーで決まります。統合中はテストキーを使用し、本番公開時に本番キーへ切り替えてください。", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 521b1d57d1bc..a8071fb21189 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -1421,6 +1421,7 @@ "Do not wait one second between polling async tasks for this channel": "Не ждать одну секунду между опросами асинхронных задач для этого канала", "Do regex replacement in the target field": "Выполнить замену по регулярному выражению в целевом поле", "Do string replacement in the target field": "Выполнить замену строки в целевом поле", + "Do you want to download the created redemption codes as a text file?": "Скачать созданные коды пополнения в виде текстового файла?", "Docs": "Документы", "Documentation Link": "Ссылка на документацию", "Documentation or external knowledge base.": "Документация или внешняя база знаний.", @@ -1947,8 +1948,8 @@ "Filter by MjProxy task ID": "Фильтр по ID задачи MjProxy", "Filter by model name...": "Фильтр по имени модели...", "Filter by model...": "Фильтровать по модели...", - "Filter by name or ID...": "Фильтр по имени или ID...", "Filter by name, ID, or key...": "Фильтровать по имени, ID или ключу...", + "Filter by name, ID, or redemption code...": "Фильтр по имени, ID или коду активации...", "Filter by name...": "Фильтр по имени...", "Filter by node": "Фильтр по узлу", "Filter by price field": "Фильтр по полю цены", @@ -4397,6 +4398,7 @@ "The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.": "Привязанный продукт используется для пополнения кошелька: когда пользователь вводит любую сумму, new-api запускает оплату через этот единственный продукт Pancake и переопределяет цену для каждой сессии — не нужно заранее создавать SKU на $1 / $5 / $10.", "The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.": "Привязанный магазин является родительским контейнером для всех продуктов Pancake, которые new-api создает из этой админки: как продукта пополнения кошелька, так и продуктов планов подписки. Одного магазина достаточно; выбирайте другой только если действительно ведете отдельные каталоги Pancake.", "The deployment node that handled the requests": "Узел развёртывания, обработавший запросы", + "The download will use the redemption name as the filename.": "Файл будет сохранен с именем, совпадающим с названием кода пополнения.", "The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "Действующий домен для регистрации Passkey. Должен совпадать с текущим доменом или быть его родительским доменом.", "The entered text does not match the required text.": "Введенный текст не совпадает с требуемым.", "The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.": "Окружение (тестовое или рабочее) определяется ключом, который вы вставляете здесь: используйте тестовый ключ при интеграции, затем замените его на рабочий при запуске.", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index 2d062b7dfa33..f9e7c7f05f93 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -1421,6 +1421,7 @@ "Do not wait one second between polling async tasks for this channel": "Không chờ một giây giữa các lần thăm dò tác vụ bất đồng bộ cho kênh này", "Do regex replacement in the target field": "Thực hiện thay thế regex trong trường đích", "Do string replacement in the target field": "Thực hiện thay thế chuỗi trong trường đích", + "Do you want to download the created redemption codes as a text file?": "Bạn có muốn tải xuống các mã đổi thưởng vừa tạo dưới dạng tệp văn bản không?", "Docs": "Tài liệu", "Documentation Link": "Liên kết tài liệu", "Documentation or external knowledge base.": "Tài liệu hoặc cơ sở kiến thức bên ngoài.", @@ -1947,8 +1948,8 @@ "Filter by MjProxy task ID": "Lọc theo ID nhiệm vụ MjProxy", "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, ID, or key...": "Lọc theo tên, ID hoặc khóa...", + "Filter by name, ID, or redemption code...": "Lọc theo tên, ID hoặc mã đổi thưởng...", "Filter by name...": "Lọc theo tên...", "Filter by node": "Lọc theo nút", "Filter by price field": "Lọc theo trường giá", @@ -4397,6 +4398,7 @@ "The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.": "Sản phẩm đã liên kết dùng cho nạp ví: khi người dùng nhập bất kỳ số tiền nào, new-api chạy thanh toán trên một sản phẩm Pancake duy nhất này và ghi đè giá theo từng phiên — không cần tạo trước SKU $1 / $5 / $10.", "The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.": "Cửa hàng đã liên kết là vùng chứa cha cho mọi sản phẩm Pancake mà new-api tạo từ trang quản trị này — bao gồm sản phẩm nạp ví và mọi sản phẩm gói đăng ký. Một cửa hàng là đủ; chỉ ghim cửa hàng khác nếu bạn thực sự vận hành các catalog Pancake riêng.", "The deployment node that handled the requests": "Nút triển khai đã xử lý các yêu cầu", + "The download will use the redemption name as the filename.": "Tệp tải xuống sẽ dùng tên mã đổi thưởng làm tên tệp.", "The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "Mi", "The entered text does not match the required text.": "Văn bản đã nhập không khớp với văn bản yêu cầu.", "The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.": "Môi trường (test hay production) được quyết định bởi khóa bạn dán tại đây — dùng khóa Test khi tích hợp, sau đó đổi sang khóa Production khi chạy chính thức.", diff --git a/web/default/src/i18n/locales/zh-TW.json b/web/default/src/i18n/locales/zh-TW.json index 5c8175d73c7a..91476ba47615 100644 --- a/web/default/src/i18n/locales/zh-TW.json +++ b/web/default/src/i18n/locales/zh-TW.json @@ -1421,6 +1421,7 @@ "Do not wait one second between polling async tasks for this channel": "該渠道輪詢異步任務時不等待一秒", "Do regex replacement in the target field": "在目標欄位裡做正則替換", "Do string replacement in the target field": "在目標欄位裡做字串替換", + "Do you want to download the created redemption codes as a text file?": "兌換碼建立成功,是否下載兌換碼?", "Docs": "文件", "Documentation Link": "文件連結", "Documentation or external knowledge base.": "文件或外部知識庫。", @@ -1947,8 +1948,8 @@ "Filter by MjProxy task ID": "按 MjProxy 任務 ID 篩選", "Filter by model name...": "按模型名稱篩選...", "Filter by model...": "按模型篩選...", - "Filter by name or ID...": "按名稱或 ID 篩選...", "Filter by name, ID, or key...": "按名稱、ID 或金鑰篩選...", + "Filter by name, ID, or redemption code...": "按名稱、ID 或兌換碼篩選...", "Filter by name...": "按名稱篩選...", "Filter by node": "按節點篩選", "Filter by price field": "按價格欄位篩選", @@ -4397,6 +4398,7 @@ "The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.": "已連結產品用於錢包儲值:當用戶輸入任意金額時,new-api 會基於這個單一 Pancake 產品發起結帳,並按對話覆蓋價格,無需預先建立 $1 / $5 / $10 的 SKU。", "The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.": "已連結店鋪是 new-api 從此管理端建立的所有 Pancake 產品的父容器,包括錢包儲值產品和訂閱套餐產品。一個店鋪通常足夠;只有在確實運營多個 Pancake 目錄時才需要連結不同店鋪。", "The deployment node that handled the requests": "處理請求的部署節點", + "The download will use the redemption name as the filename.": "兌換碼將以文字檔的形式下載,檔名為兌換碼的名稱。", "The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "用於 Passkey 註冊的有效域。必須與目前域匹配或為其父域。", "The entered text does not match the required text.": "輸入文字與要求文字不匹配。", "The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.": "環境(測試或生產)由你在此貼上的金鑰決定。整合期間使用測試金鑰,上線時再切換為生產金鑰。", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 71ddb799a813..c944ab143620 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -1421,6 +1421,7 @@ "Do not wait one second between polling async tasks for this channel": "该渠道轮询异步任务时不等待一秒", "Do regex replacement in the target field": "在目标字段里做正则替换", "Do string replacement in the target field": "在目标字段里做字符串替换", + "Do you want to download the created redemption codes as a text file?": "兑换码创建成功,是否下载兑换码?", "Docs": "文档", "Documentation Link": "文档链接", "Documentation or external knowledge base.": "文档或外部知识库。", @@ -1947,8 +1948,8 @@ "Filter by MjProxy task ID": "按 MjProxy 任务 ID 筛选", "Filter by model name...": "按模型名称筛选...", "Filter by model...": "按模型筛选...", - "Filter by name or ID...": "按名称或 ID 筛选...", "Filter by name, ID, or key...": "按名称、ID 或密钥筛选...", + "Filter by name, ID, or redemption code...": "按名称、ID 或兑换码筛选...", "Filter by name...": "按名称筛选...", "Filter by node": "按节点筛选", "Filter by price field": "按价格字段筛选", @@ -4397,6 +4398,7 @@ "The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.": "已绑定产品用于钱包充值:当用户输入任意金额时,new-api 会基于这个单一 Pancake 产品发起结账,并按会话覆盖价格,无需预先创建 $1 / $5 / $10 的 SKU。", "The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.": "已绑定店铺是 new-api 从此管理端创建的所有 Pancake 产品的父容器,包括钱包充值产品和订阅套餐产品。一个店铺通常足够;只有在确实运营多个 Pancake 目录时才需要绑定不同店铺。", "The deployment node that handled the requests": "处理请求的部署节点", + "The download will use the redemption name as the filename.": "兑换码将以文本文件的形式下载,文件名为兑换码的名称。", "The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "用于 Passkey 注册的有效域。必须与当前域匹配或为其父域。", "The entered text does not match the required text.": "输入文本与要求文本不匹配。", "The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.": "环境(测试或生产)由你在此粘贴的密钥决定。集成期间使用测试密钥,上线时再切换为生产密钥。", From e1c6690659fdfee34d6d78780fb3fe02f265b52d Mon Sep 17 00:00:00 2001 From: wangdong Date: Sat, 18 Jul 2026 00:10:55 +0800 Subject: [PATCH 06/14] Add Connect Tool wizard for API key deep-link onboarding. Create a token with the selected group and open CC Switch or Cherry Studio via deep link, returning the plaintext key from AddToken for one-click setup. Co-authored-by: Cursor --- controller/token.go | 5 + web/default/src/features/keys/api.ts | 3 +- .../keys/components/api-keys-dialogs.tsx | 5 + .../components/api-keys-primary-buttons.tsx | 10 +- .../dialogs/connect-tool-dialog.tsx | 394 ++++++++++++++++++ .../src/features/keys/lib/connect-tool.ts | 241 +++++++++++ web/default/src/features/keys/types.ts | 7 + .../i18n/locales/_reports/_sync-report.json | 2 +- web/default/src/i18n/locales/en.json | 15 + web/default/src/i18n/locales/fr.json | 15 + web/default/src/i18n/locales/ja.json | 15 + web/default/src/i18n/locales/ru.json | 15 + web/default/src/i18n/locales/vi.json | 15 + web/default/src/i18n/locales/zh-TW.json | 15 + web/default/src/i18n/locales/zh.json | 15 + web/default/src/i18n/static-keys.ts | 14 + 16 files changed, 783 insertions(+), 3 deletions(-) create mode 100644 web/default/src/features/keys/components/dialogs/connect-tool-dialog.tsx create mode 100644 web/default/src/features/keys/lib/connect-tool.ts diff --git a/controller/token.go b/controller/token.go index 836e9b2952ac..47d5f239a83e 100644 --- a/controller/token.go +++ b/controller/token.go @@ -230,6 +230,11 @@ func AddToken(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", + "data": gin.H{ + "id": cleanToken.Id, + "key": cleanToken.Key, + "name": cleanToken.Name, + }, }) } diff --git a/web/default/src/features/keys/api.ts b/web/default/src/features/keys/api.ts index df3cc5ff74bc..25eb29533f6c 100644 --- a/web/default/src/features/keys/api.ts +++ b/web/default/src/features/keys/api.ts @@ -25,6 +25,7 @@ import type { GetApiKeysResponse, SearchApiKeysParams, ApiKeyFormData, + CreateApiKeyResult, } from './types' // ============================================================================ @@ -63,7 +64,7 @@ export async function getApiKey(id: number): Promise> { // Create a new API key export async function createApiKey( data: ApiKeyFormData -): Promise> { +): Promise> { const res = await api.post('/api/token/', data) return res.data } diff --git a/web/default/src/features/keys/components/api-keys-dialogs.tsx b/web/default/src/features/keys/components/api-keys-dialogs.tsx index ae45cdf90893..1a22be4ff66a 100644 --- a/web/default/src/features/keys/components/api-keys-dialogs.tsx +++ b/web/default/src/features/keys/components/api-keys-dialogs.tsx @@ -20,6 +20,7 @@ import { ApiKeysDeleteDialog } from './api-keys-delete-dialog' import { ApiKeysMutateDrawer } from './api-keys-mutate-drawer' import { useApiKeys } from './api-keys-provider' import { CCSwitchDialog } from './dialogs/cc-switch-dialog' +import { ConnectToolDialog } from './dialogs/connect-tool-dialog' export function ApiKeysDialogs() { const { open, setOpen, currentRow, resolvedKey } = useApiKeys() @@ -37,6 +38,10 @@ export function ApiKeysDialogs() { onOpenChange={(isOpen) => !isOpen && setOpen(null)} tokenKey={resolvedKey} /> + !isOpen && setOpen(null)} + /> ) } diff --git a/web/default/src/features/keys/components/api-keys-primary-buttons.tsx b/web/default/src/features/keys/components/api-keys-primary-buttons.tsx index da68dc28e9e8..abecc332a10e 100644 --- a/web/default/src/features/keys/components/api-keys-primary-buttons.tsx +++ b/web/default/src/features/keys/components/api-keys-primary-buttons.tsx @@ -16,7 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { Plus } from 'lucide-react' +import { Plus, Sparkles } from 'lucide-react' import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' @@ -28,6 +28,14 @@ export function ApiKeysPrimaryButtons() { const { setOpen } = useApiKeys() return (
+ + + + } + > +
+ +
+ {ENDPOINT_TYPES.map((endpoint) => { + const enabled = + !isLoadingOptions && availableEndpointIds.includes(endpoint.id) + const selected = endpointId === endpoint.id + return ( + + ) + })} +
+ {isLoadingOptions && ( +

+ {t('Loading available providers...')} +

+ )} + {loadFailed && ( +

+ {t( + 'Could not load pricing data. Open the pricing page or refresh and try again.' + )} +

+ )} + {!isLoadingOptions && + !loadFailed && + availableEndpointIds.length === 0 && ( +

+ {t( + 'No provider types are available. Types unlock when pricing models match Anthropic / OpenAI / Gemini / xAI for your groups — having channels alone is not enough.' + )} +

+ )} +
+ +
+ +
+ +
+
+ +
+ +
+ +
+

+ {t( + 'A recommended model is selected automatically. You can change it.' + )} +

+
+ +
+ + setToolId(value as ConnectToolId)} + className='flex flex-col gap-2' + > +
+ + +
+
+ + +
+
+
+ + {manualHint && createdKey && ( +
+

+ {t( + 'If the app did not open, install the tool and use this API key manually:' + )} +

+ + {createdKey} + +
+ )} + + ) +} diff --git a/web/default/src/features/keys/lib/connect-tool.ts b/web/default/src/features/keys/lib/connect-tool.ts new file mode 100644 index 000000000000..1278c3f0d9e8 --- /dev/null +++ b/web/default/src/features/keys/lib/connect-tool.ts @@ -0,0 +1,241 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import type { PricingModel } from '@/features/pricing/types' + +export type EndpointTypeId = 'anthropic' | 'openai' | 'gemini' | 'xai' +export type ConnectToolId = 'cc-switch' | 'cherry-studio' + +export type EndpointTypeConfig = { + id: EndpointTypeId + label: string + iconKey: string + vendorMatchers: string[] + modelMatchers: RegExp[] + preferPatterns: RegExp[] + /** Pricing `supported_endpoint_types` values that unlock this provider type. */ + endpointMatchers: string[] + ccSwitchApp: 'claude' | 'codex' | 'gemini' +} + +export const ENDPOINT_TYPES: EndpointTypeConfig[] = [ + { + id: 'anthropic', + label: 'Anthropic', + iconKey: 'Anthropic', + vendorMatchers: ['anthropic', 'claude'], + modelMatchers: [/claude/i], + preferPatterns: [/sonnet/i, /opus/i, /haiku/i, /claude/i], + endpointMatchers: ['anthropic'], + ccSwitchApp: 'claude', + }, + { + id: 'openai', + label: 'OpenAI', + iconKey: 'OpenAI', + vendorMatchers: ['openai'], + modelMatchers: [/^(gpt-|o[1-9]|chatgpt-|codex)/i], + preferPatterns: [/codex/i, /gpt-4o/i, /gpt-4\.1/i, /gpt/i], + endpointMatchers: ['openai', 'openai-response', 'openai-response-compact'], + ccSwitchApp: 'codex', + }, + { + id: 'gemini', + label: 'Gemini', + iconKey: 'Gemini', + vendorMatchers: ['gemini', 'google'], + modelMatchers: [/gemini/i], + preferPatterns: [/gemini/i], + endpointMatchers: ['gemini'], + ccSwitchApp: 'gemini', + }, + { + id: 'xai', + label: 'xAI', + iconKey: 'XAI', + vendorMatchers: ['xai', 'x.ai'], + modelMatchers: [/grok/i], + preferPatterns: [/grok/i], + endpointMatchers: [], + ccSwitchApp: 'codex', + }, +] + +export function getEndpointTypeConfig( + id: EndpointTypeId +): EndpointTypeConfig | undefined { + return ENDPOINT_TYPES.find((item) => item.id === id) +} + +function normalizeVendor(value: string | undefined | null): string { + return (value || '').trim().toLowerCase() +} + +export function modelMatchesEndpoint( + model: PricingModel, + endpoint: EndpointTypeConfig +): boolean { + const vendor = normalizeVendor(model.vendor_name) + if ( + vendor && + endpoint.vendorMatchers.some( + (matcher) => vendor === matcher || vendor.includes(matcher) + ) + ) { + return true + } + const modelName = model.model_name || '' + if (endpoint.modelMatchers.some((pattern) => pattern.test(modelName))) { + return true + } + if (endpoint.endpointMatchers.length === 0) return false + const supported = model.supported_endpoint_types || [] + return supported.some((item) => + endpoint.endpointMatchers.includes(String(item).toLowerCase()) + ) +} + +export function filterModelsForEndpoint( + models: PricingModel[], + endpointId: EndpointTypeId +): PricingModel[] { + const endpoint = getEndpointTypeConfig(endpointId) + if (!endpoint) return [] + return models.filter((model) => modelMatchesEndpoint(model, endpoint)) +} + +export function getGroupsForEndpoint( + models: PricingModel[], + endpointId: EndpointTypeId, + usableGroups: string[] +): string[] { + const usable = new Set(usableGroups) + const restrictToUsable = usable.size > 0 + const matched = filterModelsForEndpoint(models, endpointId) + const groups = new Set() + for (const model of matched) { + for (const group of model.enable_groups || []) { + if (!group || group === 'auto') continue + // Pricing may mark a model as available to every usable group. + if (group === 'all') { + if (restrictToUsable) { + for (const item of usable) { + if (item && item !== 'auto') groups.add(item) + } + } + continue + } + if (!restrictToUsable || usable.has(group)) groups.add(group) + } + } + return [...groups].sort((a, b) => a.localeCompare(b)) +} + +export function filterModelsForGroup( + models: PricingModel[], + endpointId: EndpointTypeId, + group: string +): PricingModel[] { + return filterModelsForEndpoint(models, endpointId).filter((model) => + (model.enable_groups || []).includes(group) + ) +} + +export function recommendModelName( + models: PricingModel[], + endpointId: EndpointTypeId +): string { + const endpoint = getEndpointTypeConfig(endpointId) + if (!endpoint || models.length === 0) return '' + for (const pattern of endpoint.preferPatterns) { + const hit = models.find((model) => pattern.test(model.model_name)) + if (hit) return hit.model_name + } + return models[0]?.model_name || '' +} + +export function getServerAddress(): string { + try { + const raw = localStorage.getItem('status') + if (raw) { + const status = JSON.parse(raw) as { server_address?: string } + if (status.server_address) return status.server_address + } + } catch { + /* empty */ + } + return window.location.origin +} + +function normalizeApiKey(apiKey: string): string { + const trimmed = apiKey.trim() + if (!trimmed) return '' + return trimmed.startsWith('sk-') ? trimmed : `sk-${trimmed}` +} + +export function buildCCSwitchImportURL(params: { + app: 'claude' | 'codex' | 'gemini' + name: string + model: string + apiKey: string +}): string { + const serverAddress = getServerAddress() + const endpoint = + params.app === 'codex' ? `${serverAddress}/v1` : serverAddress + const search = new URLSearchParams() + search.set('resource', 'provider') + search.set('app', params.app) + search.set('name', params.name) + search.set('endpoint', endpoint) + search.set('apiKey', normalizeApiKey(params.apiKey)) + search.set('model', params.model) + search.set('homepage', serverAddress) + search.set('enabled', 'true') + return `ccswitch://v1/import?${search.toString()}` +} + +function toBase64(value: string): string { + const bytes = new TextEncoder().encode(value) + let binary = '' + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + return btoa(binary) +} + +export function buildCherryStudioImportURL(apiKey: string): string { + const serverAddress = getServerAddress() + const payload = { + id: 'new-api', + baseUrl: serverAddress, + apiKey: normalizeApiKey(apiKey), + } + const encoded = encodeURIComponent(toBase64(JSON.stringify(payload))) + return `cherrystudio://providers/api-keys?v=1&data=${encoded}` +} + +export function buildConnectTokenName( + endpointLabel: string, + group: string +): string { + const date = new Date() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + const raw = `${endpointLabel} · ${group} · ${month}-${day}` + return raw.length > 50 ? raw.slice(0, 50) : raw +} diff --git a/web/default/src/features/keys/types.ts b/web/default/src/features/keys/types.ts index 1583e6497df7..ec5bc132612c 100644 --- a/web/default/src/features/keys/types.ts +++ b/web/default/src/features/keys/types.ts @@ -104,3 +104,10 @@ export type ApiKeysDialogType = | 'delete' | 'batch-delete' | 'cc-switch' + | 'connect-tool' + +export type CreateApiKeyResult = { + id: number + key: string + name: string +} diff --git a/web/default/src/i18n/locales/_reports/_sync-report.json b/web/default/src/i18n/locales/_reports/_sync-report.json index ba41ffbe288d..805e285773a4 100644 --- a/web/default/src/i18n/locales/_reports/_sync-report.json +++ b/web/default/src/i18n/locales/_reports/_sync-report.json @@ -33,7 +33,7 @@ }, "zh-TW": { "file": "zh-TW.json", - "missingCount": 0, + "missingCount": 3, "extrasCount": 0, "untranslatedCount": 0 }, diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 504e29876f64..de07105df4a8 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -118,6 +118,7 @@ "80,443,8080": "80,443,8080", "A billing multiplier. Lower ratios mean lower API call costs.": "A billing multiplier. Lower ratios mean lower API call costs.", "A focused home for keys, balance, routing, and service health.": "A focused home for keys, balance, routing, and service health.", + "A recommended model is selected automatically. You can change it.": "A recommended model is selected automatically. You can change it.", "About": "About", "About {{days}} days left": "About {{days}} days left", "Accept Unpriced Models": "Accept Unpriced Models", @@ -387,6 +388,7 @@ "API Key (Sandbox)": "API Key (Sandbox)", "API Key *": "API Key *", "API Key created successfully": "API Key created successfully", + "API key created. Opening the selected tool...": "API key created. Opening the selected tool...", "API Key deleted successfully": "API Key deleted successfully", "API Key disabled successfully": "API Key disabled successfully", "API Key enabled successfully": "API Key enabled successfully", @@ -950,6 +952,7 @@ "Configuration for Epay payment integration": "Configuration for Epay payment integration", "Configuration for Stripe payment integration": "Configuration for Stripe payment integration", "Configuration required": "Configuration required", + "Configuration tool": "Configuration tool", "Configure": "Configure", "Configure a Creem product for user recharge options.": "Configure a Creem product for user recharge options.", "Configure a custom ratio for when users use a specific token group.": "Configure a custom ratio for when users use a specific token group.", @@ -982,6 +985,7 @@ "Configure your account preferences and integrations": "Configure your account preferences and integrations", "Configured as PayMethods JSON. The type value decides which payment flow is used: stripe for Stripe, waffo_pancake for Waffo Pancake, and other values are sent to Epay as the type parameter.": "Configured as PayMethods JSON. The type value decides which payment flow is used: stripe for Stripe, waffo_pancake for Waffo Pancake, and other values are sent to Epay as the type parameter.", "Configured routes and latency checks": "Configured routes and latency checks", + "Configuring...": "Configuring...", "Confirm": "Confirm", "Confirm Action": "Confirm Action", "Confirm and enable": "Confirm and enable", @@ -1016,6 +1020,7 @@ "Conflict": "Conflict", "Connect": "Connect", "Connect through OpenAI, Claude, Gemini, and other compatible API routes": "Connect through OpenAI, Claude, Gemini, and other compatible API routes", + "Connect tool": "Connect tool", "Connected to io.net service normally.": "Connected to io.net service normally.", "Connection closed": "Connection closed", "Connection error": "Connection error", @@ -1116,6 +1121,7 @@ "Cost = model price × that one ratio. Nothing else from the group settings enters the formula.": "Cost = model price × that one ratio. Nothing else from the group settings enters the formula.", "Cost in USD per request, regardless of tokens used.": "Cost in USD per request, regardless of tokens used.", "Cost Tracking": "Cost Tracking", + "Could not load pricing data. Open the pricing page or refresh and try again.": "Could not load pricing data. Open the pricing page or refresh and try again.", "Count must be between {{min}} and {{max}}": "Count must be between {{min}} and {{max}}", "Coze": "Coze", "CPU": "CPU", @@ -1127,6 +1133,7 @@ "Create account": "Create account", "Create an account": "Create an account", "Create an API key to unlock the real request": "Create an API key to unlock the real request", + "Create and configure": "Create and configure", "Create and review invite or credit codes.": "Create and review invite or credit codes.", "Create API Key": "Create API Key", "Create cache": "Create cache", @@ -2262,6 +2269,7 @@ "If connecting to upstream One API or New API relay projects, use OpenAI type instead unless you know what you are doing": "If connecting to upstream One API or New API relay projects, use OpenAI type instead unless you know what you are doing", "If default auto group is enabled, newly created tokens start with auto instead of an empty group.": "If default auto group is enabled, newly created tokens start with auto instead of an empty group.", "If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.": "If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.", + "If the app did not open, install the tool and use this API key manually:": "If the app did not open, install the tool and use this API key manually:", "If this keeps happening, please report it on GitHub Issues.": "If this keeps happening, please report it on GitHub Issues.", "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.", "Ignore": "Ignore", @@ -2492,6 +2500,7 @@ "Load template...": "Load template...", "Loader": "Loader", "Loading": "Loading", + "Loading available providers...": "Loading available providers...", "Loading channel details": "Loading channel details", "Loading configuration": "Loading configuration", "Loading content settings...": "Loading content settings...", @@ -2885,6 +2894,7 @@ "No group": "No group", "No group found.": "No group found.", "No group-based rate limits configured. Click \"Add group\" to get started.": "No group-based rate limits configured. Click \"Add group\" to get started.", + "No groups available for this provider type": "No groups available for this provider type", "No groups match your search": "No groups match your search", "No groups yet. Add a group to get started.": "No groups yet. Add a group to get started.", "No header overrides configured.": "No header overrides configured.", @@ -2947,6 +2957,8 @@ "No processable upstream model updates for this channel": "No processable upstream model updates for this channel", "No products configured. Click \"Add product\" to get started.": "No products configured. Click \"Add product\" to get started.", "No products match your search": "No products match your search", + "No provider types are available for your current groups.": "No provider types are available for your current groups.", + "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI / Gemini / xAI for your groups — having channels alone is not enough.": "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI / Gemini / xAI for your groups — having channels alone is not enough.", "No providers available": "No providers available", "No Quota": "No Quota", "No ratio differences found": "No ratio differences found", @@ -3337,6 +3349,7 @@ "Personal use": "Personal use", "Personal use mode": "Personal use mode", "Pick a date": "Pick a date", + "Pick a provider type, group, model, and client. We create an API key and open the tool for you.": "Pick a provider type, group, model, and client. We create an API key and open the tool for you.", "Pick or create both a store and a product before saving.": "Pick or create both a store and a product before saving.", "Ping Interval (seconds)": "Ping Interval (seconds)", "Plan": "Plan", @@ -3533,6 +3546,7 @@ "Provider created successfully": "Provider created successfully", "Provider deleted successfully": "Provider deleted successfully", "Provider Name": "Provider Name", + "Provider type": "Provider type", "Provider type (OpenAI, Anthropic, etc.)": "Provider type (OpenAI, Anthropic, etc.)", "Provider updated successfully": "Provider updated successfully", "Provider-specific endpoint, account, and compatibility settings.": "Provider-specific endpoint, account, and compatibility settings.", @@ -4010,6 +4024,7 @@ "Select a color": "Select a color", "Select a group": "Select a group", "Select a group type": "Select a group type", + "Select a model": "Select a model", "Select a model to edit pricing": "Select a model to edit pricing", "Select a preset...": "Select a preset...", "Select a product": "Select a product", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 05cc2d5e9d05..e6a49a50dce8 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -118,6 +118,7 @@ "80,443,8080": "80,443,8080", "A billing multiplier. Lower ratios mean lower API call costs.": "Un multiplicateur de facturation. Plus le ratio est faible, plus le coût des appels API est bas.", "A focused home for keys, balance, routing, and service health.": "Un accueil dédié aux clés, au solde, au routage et à l'état du service.", + "A recommended model is selected automatically. You can change it.": "Un modèle recommandé est présélectionné. Vous pouvez le modifier.", "About": "À propos", "About {{days}} days left": "Environ {{days}} jours restants", "Accept Unpriced Models": "Accepter les modèles non tarifés", @@ -387,6 +388,7 @@ "API Key (Sandbox)": "Clé API (Sandbox)", "API Key *": "Clé API *", "API Key created successfully": "Clé API créée avec succès", + "API key created. Opening the selected tool...": "Clé API créée. Ouverture de l’outil sélectionné...", "API Key deleted successfully": "Clé API supprimée avec succès", "API Key disabled successfully": "Clé API désactivée avec succès", "API Key enabled successfully": "Clé API activée avec succès", @@ -950,6 +952,7 @@ "Configuration for Epay payment integration": "Configuration pour l'intégration de paiement Epay", "Configuration for Stripe payment integration": "Configuration pour l'intégration de paiement Stripe", "Configuration required": "Configuration requise", + "Configuration tool": "Outil de configuration", "Configure": "Configurer", "Configure a Creem product for user recharge options.": "Configurez un produit Creem pour les options de recharge utilisateur.", "Configure a custom ratio for when users use a specific token group.": "Configurer un ratio personnalisé lorsque les utilisateurs utilisent un groupe de jetons spécifique.", @@ -982,6 +985,7 @@ "Configure your account preferences and integrations": "Configurer les préférences et les intégrations de votre compte", "Configured as PayMethods JSON. The type value decides which payment flow is used: stripe for Stripe, waffo_pancake for Waffo Pancake, and other values are sent to Epay as the type parameter.": "Enregistré comme JSON PayMethods. La valeur type décide du flux de paiement utilisé : stripe pour Stripe, waffo_pancake pour Waffo Pancake, et les autres valeurs sont envoyées à Epay comme paramètre type.", "Configured routes and latency checks": "Routes configurées et contrôles de latence", + "Configuring...": "Configuration...", "Confirm": "Confirmer", "Confirm Action": "Confirmer l'action", "Confirm and enable": "Confirmer et activer", @@ -1016,6 +1020,7 @@ "Conflict": "Conflit", "Connect": "Connecter", "Connect through OpenAI, Claude, Gemini, and other compatible API routes": "Connectez-vous via OpenAI, Claude, Gemini et d'autres routes API compatibles", + "Connect tool": "Connecter un outil", "Connected to io.net service normally.": "Connexion au service io.net réussie.", "Connection closed": "Connexion fermée", "Connection error": "Erreur de connexion", @@ -1116,6 +1121,7 @@ "Cost = model price × that one ratio. Nothing else from the group settings enters the formula.": "Coût = prix du modèle × ce seul taux. Rien d’autre dans les réglages de groupes n’entre dans la formule.", "Cost in USD per request, regardless of tokens used.": "Coût en USD par requête, quel que soit le nombre de jetons utilisés.", "Cost Tracking": "Suivi des coûts", + "Could not load pricing data. Open the pricing page or refresh and try again.": "Impossible de charger les tarifs. Ouvrez la page des prix ou actualisez, puis réessayez.", "Count must be between {{min}} and {{max}}": "Le nombre doit être compris entre {{min}} et {{max}}", "Coze": "Coze", "CPU": "Processeur", @@ -1127,6 +1133,7 @@ "Create account": "Créer un compte", "Create an account": "Créer un compte", "Create an API key to unlock the real request": "Créez une clé API pour débloquer la requête réelle", + "Create and configure": "Créer et configurer", "Create and review invite or credit codes.": "Créer et examiner les codes d'invitation ou de crédit.", "Create API Key": "Créer une clé API", "Create cache": "Créer le cache", @@ -2262,6 +2269,7 @@ "If connecting to upstream One API or New API relay projects, use OpenAI type instead unless you know what you are doing": "Si vous vous connectez à des projets de relais One API ou New API en amont, utilisez le type OpenAI à la place sauf si vous savez ce que vous faites", "If default auto group is enabled, newly created tokens start with auto instead of an empty group.": "Si le groupe auto par défaut est activé, les nouveaux jetons commencent avec auto au lieu d’un groupe vide.", "If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.": "Si le canal affinitaire échoue et qu'une nouvelle tentative réussit sur un autre canal, mettre à jour l'affinité vers le canal ayant réussi.", + "If the app did not open, install the tool and use this API key manually:": "Si l’application ne s’ouvre pas, installez l’outil et utilisez cette clé manuellement :", "If this keeps happening, please report it on GitHub Issues.": "Si cela continue, veuillez le signaler sur GitHub Issues.", "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "Si vous fournissez des services d’IA générative au public en Chine continentale, vous remplirez les obligations légales applicables, notamment le dépôt, l’évaluation de sécurité, la sécurité du contenu, le traitement des plaintes, l’étiquetage du contenu généré, la conservation des journaux et la protection des informations personnelles.", "Ignore": "Ignorer", @@ -2492,6 +2500,7 @@ "Load template...": "Charger le modèle...", "Loader": "Chargeur", "Loading": "Chargement", + "Loading available providers...": "Chargement des fournisseurs…", "Loading channel details": "Chargement des détails du canal", "Loading configuration": "Chargement de la configuration", "Loading content settings...": "Chargement des paramètres de contenu...", @@ -2885,6 +2894,7 @@ "No group": "Aucun groupe", "No group found.": "Aucun groupe trouvé.", "No group-based rate limits configured. Click \"Add group\" to get started.": "Aucune limite de taux basée sur les groupes configurée. Cliquez sur \"Ajouter un groupe\" pour commencer.", + "No groups available for this provider type": "Aucun groupe disponible pour ce type", "No groups match your search": "Aucun groupe ne correspond à votre recherche", "No groups yet. Add a group to get started.": "Aucun groupe pour le moment. Ajoutez un groupe pour commencer.", "No header overrides configured.": "Aucune surcharge d'en-têtes configurée.", @@ -2947,6 +2957,8 @@ "No processable upstream model updates for this channel": "Aucune mise à jour de modèle en amont traitable pour ce canal", "No products configured. Click \"Add product\" to get started.": "Aucun produit configuré. Cliquez sur \"Ajouter un produit\" pour commencer.", "No products match your search": "Aucun produit ne correspond à votre recherche", + "No provider types are available for your current groups.": "Aucun type n’est disponible pour vos groupes actuels.", + "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI / Gemini / xAI for your groups — having channels alone is not enough.": "Aucun type disponible. Les types s’activent quand les modèles tarifés correspondent à Anthropic / OpenAI / Gemini / xAI pour vos groupes — avoir des canaux ne suffit pas.", "No providers available": "Aucun fournisseur disponible", "No Quota": "Aucun quota", "No ratio differences found": "Aucune différence de ratio trouvée", @@ -3337,6 +3349,7 @@ "Personal use": "Usage personnel", "Personal use mode": "Mode usage personnel", "Pick a date": "Choisir une date", + "Pick a provider type, group, model, and client. We create an API key and open the tool for you.": "Choisissez le type, le groupe, le modèle et le client. Nous créons une clé API et ouvrons l’outil.", "Pick or create both a store and a product before saving.": "Choisissez ou créez à la fois une boutique et un produit avant d’enregistrer.", "Ping Interval (seconds)": "Intervalle de ping (secondes)", "Plan": "Plan", @@ -3533,6 +3546,7 @@ "Provider created successfully": "Fournisseur créé avec succès", "Provider deleted successfully": "Fournisseur supprimé avec succès", "Provider Name": "Nom du fournisseur", + "Provider type": "Type de fournisseur", "Provider type (OpenAI, Anthropic, etc.)": "Type de fournisseur (OpenAI, Anthropic, etc.)", "Provider updated successfully": "Fournisseur mis à jour avec succès", "Provider-specific endpoint, account, and compatibility settings.": "Paramètres de point d’accès, de compte et de compatibilité propres au fournisseur.", @@ -4010,6 +4024,7 @@ "Select a color": "Sélectionner une couleur", "Select a group": "Sélectionner un groupe", "Select a group type": "Sélectionner un type de groupe", + "Select a model": "Sélectionner un modèle", "Select a model to edit pricing": "Sélectionnez un modèle pour modifier sa tarification", "Select a preset...": "Sélectionner un préréglage...", "Select a product": "Sélectionner un produit", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index d6d2a815d134..553bd492fdaa 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -118,6 +118,7 @@ "80,443,8080": "80,443,8080", "A billing multiplier. Lower ratios mean lower API call costs.": "課金倍率です。倍率が低いほど API 呼び出しコストは低くなります。", "A focused home for keys, balance, routing, and service health.": "キー、残高、ルーティング、サービス状態を集約したホームです。", + "A recommended model is selected automatically. You can change it.": "推奨モデルが自動選択されています。変更もできます。", "About": "このサービスについて", "About {{days}} days left": "約 {{days}} 日分", "Accept Unpriced Models": "価格設定されていないモデルを許可", @@ -387,6 +388,7 @@ "API Key (Sandbox)": "APIキー(サンドボックス)", "API Key *": "APIキー *", "API Key created successfully": "APIキーが正常に作成されました", + "API key created. Opening the selected tool...": "APIキーを作成しました。選択したツールを開いています...", "API Key deleted successfully": "APIキーが正常に削除されました", "API Key disabled successfully": "APIキーが正常に無効化されました", "API Key enabled successfully": "APIキーが正常に有効化されました", @@ -950,6 +952,7 @@ "Configuration for Epay payment integration": "Epay決済連携のための設定", "Configuration for Stripe payment integration": "Stripe決済連携のための設定", "Configuration required": "設定が必要です", + "Configuration tool": "設定ツール", "Configure": "設定", "Configure a Creem product for user recharge options.": "ユーザー チャージオプション用の Creem 製品を設定。", "Configure a custom ratio for when users use a specific token group.": "ユーザーが特定のトークングループを使用する際のカスタム倍率を設定します。", @@ -982,6 +985,7 @@ "Configure your account preferences and integrations": "アカウントの設定と統合を設定します。", "Configured as PayMethods JSON. The type value decides which payment flow is used: stripe for Stripe, waffo_pancake for Waffo Pancake, and other values are sent to Epay as the type parameter.": "PayMethods JSON として保存されます。type 値で使用する決済フローを決定します。stripe は Stripe、waffo_pancake は Waffo Pancake、それ以外の値は Epay の type パラメーターとして送信されます。", "Configured routes and latency checks": "設定済みルートとレイテンシ確認", + "Configuring...": "設定中...", "Confirm": "確認", "Confirm Action": "アクションの確認", "Confirm and enable": "確認して有効化", @@ -1016,6 +1020,7 @@ "Conflict": "競合", "Connect": "接続", "Connect through OpenAI, Claude, Gemini, and other compatible API routes": "OpenAI、Claude、Gemini、その他の互換APIルートから接続", + "Connect tool": "ツール接続", "Connected to io.net service normally.": "io.net サービスに正常に接続しました。", "Connection closed": "接続が閉じられました", "Connection error": "接続エラー", @@ -1116,6 +1121,7 @@ "Cost = model price × that one ratio. Nothing else from the group settings enters the formula.": "費用 = モデル価格 × この1つの倍率。グループ設定の他の項目は計算式に入りません。", "Cost in USD per request, regardless of tokens used.": "使用されたトークンに関係なく、リクエストあたりのUSDでのコスト。", "Cost Tracking": "コスト追跡", + "Could not load pricing data. Open the pricing page or refresh and try again.": "料金データを読み込めませんでした。料金ページを開くか、更新してから再試行してください。", "Count must be between {{min}} and {{max}}": "カウントは{{min}}から{{max}}の間である必要があります", "Coze": "Coze", "CPU": "CPU", @@ -1127,6 +1133,7 @@ "Create account": "アカウントを作成", "Create an account": "アカウントを作成", "Create an API key to unlock the real request": "実際のリクエストを使うには API キーを作成してください", + "Create and configure": "作成して設定", "Create and review invite or credit codes.": "招待コードまたはクレジットコードを作成および確認。", "Create API Key": "APIキーを作成", "Create cache": "キャッシュを作成", @@ -2262,6 +2269,7 @@ "If connecting to upstream One API or New API relay projects, use OpenAI type instead unless you know what you are doing": "上流の One API または New API リレープロジェクトに接続する場合、知っている場合を除き OpenAI タイプを使用してください", "If default auto group is enabled, newly created tokens start with auto instead of an empty group.": "デフォルト auto グループを有効にすると、新規トークンは空グループではなく auto で開始します。", "If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.": "アフィニティチャネルが失敗し、別のチャネルでリトライが成功した場合、アフィニティを成功したチャネルに更新します。", + "If the app did not open, install the tool and use this API key manually:": "アプリが開かない場合は、ツールをインストールし、次のキーで手動設定してください:", "If this keeps happening, please report it on GitHub Issues.": "この問題が続く場合は、GitHub Issues で報告してください。", "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "中国本土で一般向けに生成 AI サービスを提供する場合、届出、セキュリティ評価、コンテンツ安全、苦情対応、生成コンテンツのラベル表示、ログ保存、個人情報保護などの法的義務を履行します。", "Ignore": "無視", @@ -2492,6 +2500,7 @@ "Load template...": "テンプレートをロード...", "Loader": "ローダー", "Loading": "読み込み中", + "Loading available providers...": "利用可能なタイプを読み込み中…", "Loading channel details": "チャネル詳細を読み込み中", "Loading configuration": "設定を読み込んでいます", "Loading content settings...": "コンテンツ設定をロード中...", @@ -2885,6 +2894,7 @@ "No group": "グループなし", "No group found.": "グループが見つかりません。", "No group-based rate limits configured. Click \"Add group\" to get started.": "グループベースのレート制限が設定されていません。\"グループを追加\" をクリックして開始してください。", + "No groups available for this provider type": "このタイプで利用可能なグループがありません", "No groups match your search": "検索に一致するグループがありません", "No groups yet. Add a group to get started.": "グループはまだありません。グループを追加して開始してください。", "No header overrides configured.": "ヘッダーのオーバーライドが設定されていません。", @@ -2947,6 +2957,8 @@ "No processable upstream model updates for this channel": "このチャネルには処理可能な上流モデル更新がありません", "No products configured. Click \"Add product\" to get started.": "製品が設定されていません。「製品を追加」をクリックして開始してください。", "No products match your search": "検索に一致する製品がありません", + "No provider types are available for your current groups.": "現在のグループで利用可能なタイプがありません。", + "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI / Gemini / xAI for your groups — having channels alone is not enough.": "選択できるタイプがありません。料金のモデルが Anthropic / OpenAI / Gemini / xAI に一致し、利用可能なグループから使える場合に解放されます。チャネルがあるだけでは不十分です。", "No providers available": "利用可能なプロバイダーがありません", "No Quota": "クォータなし", "No ratio differences found": "比率の差異は見つかりませんでした", @@ -3337,6 +3349,7 @@ "Personal use": "個人利用", "Personal use mode": "個人利用モード", "Pick a date": "日付を選択", + "Pick a provider type, group, model, and client. We create an API key and open the tool for you.": "タイプ・グループ・モデル・クライアントを選ぶと、APIキーを作成してツールを開きます。", "Pick or create both a store and a product before saving.": "保存する前に、ストアと商品の両方を選択または作成してください。", "Ping Interval (seconds)": "Ping間隔(秒)", "Plan": "プラン", @@ -3533,6 +3546,7 @@ "Provider created successfully": "プロバイダーの作成に成功しました", "Provider deleted successfully": "プロバイダーの削除に成功しました", "Provider Name": "プロバイダー名", + "Provider type": "プロバイダータイプ", "Provider type (OpenAI, Anthropic, etc.)": "プロバイダタイプ (OpenAI, Anthropic など)", "Provider updated successfully": "プロバイダーが正常に更新されました", "Provider-specific endpoint, account, and compatibility settings.": "プロバイダー固有のエンドポイント、アカウント、互換性設定です。", @@ -4010,6 +4024,7 @@ "Select a color": "色を選択", "Select a group": "グループを選択", "Select a group type": "グループタイプを選択", + "Select a model": "モデルを選択", "Select a model to edit pricing": "料金を編集するモデルを選択", "Select a preset...": "プリセットを選択...", "Select a product": "商品を選択", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 960fc0acaa5a..129ad904b0db 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -118,6 +118,7 @@ "80,443,8080": "80,443,8080", "A billing multiplier. Lower ratios mean lower API call costs.": "Множитель тарификации. Чем ниже коэффициент, тем ниже стоимость вызовов API.", "A focused home for keys, balance, routing, and service health.": "Единый экран для ключей, баланса, маршрутов и состояния сервиса.", + "A recommended model is selected automatically. You can change it.": "Рекомендуемая модель выбрана автоматически. Её можно изменить.", "About": "О проекте", "About {{days}} days left": "Примерно {{days}} дней", "Accept Unpriced Models": "Принимать модели без цены", @@ -387,6 +388,7 @@ "API Key (Sandbox)": "API-ключ (Песочница)", "API Key *": "Ключ API *", "API Key created successfully": "API ключ успешно создан", + "API key created. Opening the selected tool...": "Ключ API создан. Открываем выбранный инструмент...", "API Key deleted successfully": "API ключ успешно удален", "API Key disabled successfully": "API ключ успешно отключен", "API Key enabled successfully": "API ключ успешно включен", @@ -950,6 +952,7 @@ "Configuration for Epay payment integration": "Конфигурация для интеграции платежей Epay", "Configuration for Stripe payment integration": "Конфигурация для интеграции платежей Stripe", "Configuration required": "Требуется настройка", + "Configuration tool": "Инструмент настройки", "Configure": "Настройка", "Configure a Creem product for user recharge options.": "Настройте продукт Creem для опций пополнения пользователя.", "Configure a custom ratio for when users use a specific token group.": "Настроить пользовательский коэффициент при использовании определённой группы токенов.", @@ -982,6 +985,7 @@ "Configure your account preferences and integrations": "Настроить параметры и интеграции вашей учетной записи", "Configured as PayMethods JSON. The type value decides which payment flow is used: stripe for Stripe, waffo_pancake for Waffo Pancake, and other values are sent to Epay as the type parameter.": "Сохраняется как JSON PayMethods. Значение type определяет платежный сценарий: stripe для Stripe, waffo_pancake для Waffo Pancake, остальные значения отправляются в Epay как параметр type.", "Configured routes and latency checks": "Настроенные маршруты и проверки задержки", + "Configuring...": "Настройка...", "Confirm": "Подтверждение", "Confirm Action": "Подтвердить действие", "Confirm and enable": "Подтвердить и включить", @@ -1016,6 +1020,7 @@ "Conflict": "Противоречие", "Connect": "Подключение", "Connect through OpenAI, Claude, Gemini, and other compatible API routes": "Подключайтесь через OpenAI, Claude, Gemini и другие совместимые API-маршруты", + "Connect tool": "Подключить инструмент", "Connected to io.net service normally.": "Соединение с сервисом io.net установлено.", "Connection closed": "Соединение закрыто", "Connection error": "Ошибка соединения", @@ -1116,6 +1121,7 @@ "Cost = model price × that one ratio. Nothing else from the group settings enters the formula.": "Стоимость = цена модели × этот единственный коэффициент. Другие настройки групп в формуле не участвуют.", "Cost in USD per request, regardless of tokens used.": "Стоимость в долларах США за запрос, независимо от использованных токенов.", "Cost Tracking": "Отслеживание затрат", + "Could not load pricing data. Open the pricing page or refresh and try again.": "Не удалось загрузить данные тарифов. Откройте страницу цен или обновите и попробуйте снова.", "Count must be between {{min}} and {{max}}": "Количество должно быть от {{min}} до {{max}}", "Coze": "Coze", "CPU": "ЦП", @@ -1127,6 +1133,7 @@ "Create account": "Создать аккаунт", "Create an account": "Создать аккаунт", "Create an API key to unlock the real request": "Создайте API-ключ, чтобы открыть реальный запрос", + "Create and configure": "Создать и настроить", "Create and review invite or credit codes.": "Создать и просмотреть коды приглашений или кредитов.", "Create API Key": "Создать ключ API", "Create cache": "Создать кеш", @@ -2262,6 +2269,7 @@ "If connecting to upstream One API or New API relay projects, use OpenAI type instead unless you know what you are doing": "При подключении к upstream One API или проектам-ретрансляторам New API используйте тип OpenAI, если только вы точно знаете, что делаете", "If default auto group is enabled, newly created tokens start with auto instead of an empty group.": "Если группа auto включена по умолчанию, новые токены создаются с auto вместо пустой группы.", "If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.": "Если привязанный канал не работает и повторная попытка удалась через другой канал, привязка обновляется на успешный канал.", + "If the app did not open, install the tool and use this API key manually:": "Если приложение не открылось, установите инструмент и используйте этот ключ вручную:", "If this keeps happening, please report it on GitHub Issues.": "Если проблема повторяется, сообщите о ней в GitHub Issues.", "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "Если вы предоставляете услуги генеративного ИИ населению материкового Китая, вы будете выполнять юридические обязанности, включая регистрацию, оценку безопасности, безопасность контента, обработку жалоб, маркировку сгенерированного контента, хранение журналов и защиту персональных данных.", "Ignore": "Игнорировать", @@ -2492,6 +2500,7 @@ "Load template...": "Загрузить шаблон...", "Loader": "Загрузчик", "Loading": "Загрузка", + "Loading available providers...": "Загрузка доступных типов…", "Loading channel details": "Загрузка сведений о канале", "Loading configuration": "Загрузка конфигурации", "Loading content settings...": "Загрузка настроек контента...", @@ -2885,6 +2894,7 @@ "No group": "Без группы", "No group found.": "Группа не найдена.", "No group-based rate limits configured. Click \"Add group\" to get started.": "Групповые лимиты скорости не настроены. Нажмите \"Добавить группу\", чтобы начать.", + "No groups available for this provider type": "Нет групп для этого типа", "No groups match your search": "Нет групп, соответствующих вашему поиску", "No groups yet. Add a group to get started.": "Групп пока нет. Добавьте группу, чтобы начать.", "No header overrides configured.": "Нет настроенных переопределений заголовков.", @@ -2947,6 +2957,8 @@ "No processable upstream model updates for this channel": "Нет обрабатываемых обновлений моделей для этого канала", "No products configured. Click \"Add product\" to get started.": "Продукты не настроены. Нажмите \"Добавить продукт\", чтобы начать.", "No products match your search": "Нет продуктов, соответствующих вашему поиску", + "No provider types are available for your current groups.": "Для ваших текущих групп нет доступных типов.", + "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI / Gemini / xAI for your groups — having channels alone is not enough.": "Нет доступных типов. Типы открываются, когда модели в тарифах соответствуют Anthropic / OpenAI / Gemini / xAI для ваших групп — одних каналов недостаточно.", "No providers available": "Нет доступных провайдеров", "No Quota": "Нет квоты", "No ratio differences found": "Различия в коэффициентах не найдены", @@ -3337,6 +3349,7 @@ "Personal use": "Личное использование", "Personal use mode": "Режим личного использования", "Pick a date": "Выберите дату", + "Pick a provider type, group, model, and client. We create an API key and open the tool for you.": "Выберите тип, группу, модель и клиент. Мы создадим ключ API и откроем инструмент.", "Pick or create both a store and a product before saving.": "Перед сохранением выберите или создайте и магазин, и продукт.", "Ping Interval (seconds)": "Интервал Ping (секунды)", "Plan": "План", @@ -3533,6 +3546,7 @@ "Provider created successfully": "Поставщик успешно создан", "Provider deleted successfully": "Поставщик успешно удален", "Provider Name": "Имя поставщика", + "Provider type": "Тип провайдера", "Provider type (OpenAI, Anthropic, etc.)": "Тип провайдера (OpenAI, Anthropic и т.д.)", "Provider updated successfully": "Поставщик успешно обновлен", "Provider-specific endpoint, account, and compatibility settings.": "Настройки endpoint, аккаунта и совместимости для конкретного провайдера.", @@ -4010,6 +4024,7 @@ "Select a color": "Выбрать цвет", "Select a group": "Выбрать группу", "Select a group type": "Выбрать тип группы", + "Select a model": "Выберите модель", "Select a model to edit pricing": "Выберите модель для редактирования тарифа", "Select a preset...": "Выберите предустановку...", "Select a product": "Выберите продукт", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index da843b9f8948..c84f96a892ce 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -118,6 +118,7 @@ "80,443,8080": "80,443,8080", "A billing multiplier. Lower ratios mean lower API call costs.": "Hệ số tính phí. Tỷ lệ càng thấp thì chi phí gọi API càng thấp.", "A focused home for keys, balance, routing, and service health.": "Trang tổng quan tập trung cho khóa, số dư, định tuyến và trạng thái dịch vụ.", + "A recommended model is selected automatically. You can change it.": "Mô hình đề xuất đã được chọn sẵn. Bạn có thể đổi.", "About": "Giới thiệu", "About {{days}} days left": "Còn khoảng {{days}} ngày", "Accept Unpriced Models": "Chấp nhận các Mô hình chưa định giá", @@ -387,6 +388,7 @@ "API Key (Sandbox)": "Khóa API (Sandbox)", "API Key *": "Khóa API *", "API Key created successfully": "Tạo khóa API thành công", + "API key created. Opening the selected tool...": "Đã tạo API key. Đang mở công cụ đã chọn...", "API Key deleted successfully": "Xóa khóa API thành công", "API Key disabled successfully": "Vô hiệu hóa khóa API thành công", "API Key enabled successfully": "Kích hoạt khóa API thành công", @@ -950,6 +952,7 @@ "Configuration for Epay payment integration": "Cấu hình cho tích hợp thanh toán Epay", "Configuration for Stripe payment integration": "Cấu hình cho tích hợp thanh toán Stripe", "Configuration required": "Cần cấu hình", + "Configuration tool": "Công cụ cấu hình", "Configure": "Cấu hình", "Configure a Creem product for user recharge options.": "Cấu hình một sản phẩm Creem cho các tùy chọn nạp tiền người dùng.", "Configure a custom ratio for when users use a specific token group.": "Cấu hình tỷ lệ tùy chỉnh khi người dùng sử dụng nhóm token cụ thể.", @@ -982,6 +985,7 @@ "Configure your account preferences and integrations": "Cấu hình các tùy chọn và tích hợp tài khoản của bạn", "Configured as PayMethods JSON. The type value decides which payment flow is used: stripe for Stripe, waffo_pancake for Waffo Pancake, and other values are sent to Epay as the type parameter.": "Được lưu dưới dạng JSON PayMethods. Giá trị type quyết định luồng thanh toán sẽ dùng: stripe cho Stripe, waffo_pancake cho Waffo Pancake, các giá trị khác được gửi tới Epay dưới dạng tham số type.", "Configured routes and latency checks": "Tuyến đã cấu hình và kiểm tra độ trễ", + "Configuring...": "Đang cấu hình...", "Confirm": "Xác nhận", "Confirm Action": "Xác nhận hành động", "Confirm and enable": "Xác nhận và bật", @@ -1016,6 +1020,7 @@ "Conflict": "Xung đột", "Connect": "Kết nối", "Connect through OpenAI, Claude, Gemini, and other compatible API routes": "Kết nối qua OpenAI, Claude, Gemini và các tuyến API tương thích khác", + "Connect tool": "Kết nối công cụ", "Connected to io.net service normally.": "Đã kết nối bình thường tới dịch vụ io.net.", "Connection closed": "Kết nối đã đóng", "Connection error": "Lỗi kết nối", @@ -1116,6 +1121,7 @@ "Cost = model price × that one ratio. Nothing else from the group settings enters the formula.": "Chi phí = giá mô hình × đúng một hệ số đó. Không có mục nào khác trong cài đặt nhóm tham gia công thức.", "Cost in USD per request, regardless of tokens used.": "Chi phí bằng USD cho mỗi yêu cầu, bất kể số lượng token được sử dụng.", "Cost Tracking": "Theo dõi chi phí", + "Could not load pricing data. Open the pricing page or refresh and try again.": "Không tải được dữ liệu giá. Mở trang bảng giá hoặc làm mới rồi thử lại.", "Count must be between {{min}} and {{max}}": "Số lượng phải nằm trong khoảng từ {{min}} đến {{max}}.", "Coze": "Coze", "CPU": "CPU", @@ -1127,6 +1133,7 @@ "Create account": "Tạo tài khoản", "Create an account": "Tạo tài khoản", "Create an API key to unlock the real request": "Tạo khóa API để mở yêu cầu thật", + "Create and configure": "Tạo và cấu hình", "Create and review invite or credit codes.": "Tạo và xem xét mã mời hoặc mã tín dụng.", "Create API Key": "Tạo Khóa API", "Create cache": "Tạo bộ nhớ đệm", @@ -2262,6 +2269,7 @@ "If connecting to upstream One API or New API relay projects, use OpenAI type instead unless you know what you are doing": "Nếu kết nối với dự án relay One API hoặc New API upstream, hãy sử dụng loại OpenAI thay thế trừ khi bạn biết mình đang làm gì", "If default auto group is enabled, newly created tokens start with auto instead of an empty group.": "Nếu bật nhóm auto mặc định, token mới sẽ bắt đầu với auto thay vì nhóm trống.", "If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.": "Nếu kênh ưu tiên thất bại và thử lại thành công trên kênh khác, cập nhật ưu tiên sang kênh thành công.", + "If the app did not open, install the tool and use this API key manually:": "Nếu ứng dụng không mở, hãy cài công cụ và dùng API key này để cấu hình thủ công:", "If this keeps happening, please report it on GitHub Issues.": "Nếu sự cố tiếp tục xảy ra, vui lòng báo cáo trên GitHub Issues.", "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "Nếu bạn cung cấp dịch vụ AI tạo sinh cho công chúng tại Trung Quốc đại lục, bạn sẽ thực hiện các nghĩa vụ pháp lý bao gồm đăng ký, đánh giá an toàn, an toàn nội dung, xử lý khiếu nại, gắn nhãn nội dung được tạo, lưu giữ nhật ký và bảo vệ thông tin cá nhân.", "Ignore": "Bỏ qua", @@ -2492,6 +2500,7 @@ "Load template...": "Tải mẫu...", "Loader": "Trình tải", "Loading": "Đang tải", + "Loading available providers...": "Đang tải loại khả dụng…", "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...", @@ -2885,6 +2894,7 @@ "No group": "Không có nhóm", "No group found.": "Không tìm thấy nhóm.", "No group-based rate limits configured. Click \"Add group\" to get started.": "Chưa cấu hình giới hạn tốc độ dựa trên nhóm. Nhấp \"Add group\" để bắt đầu.", + "No groups available for this provider type": "Không có nhóm nào cho loại này", "No groups match your search": "Không có nhóm nào khớp với tìm kiếm của bạn", "No groups yet. Add a group to get started.": "Chưa có nhóm nào. Thêm một nhóm để bắt đầu.", "No header overrides configured.": "Không có ghi đè tiêu đề nào được cấu hình.", @@ -2947,6 +2957,8 @@ "No processable upstream model updates for this channel": "Không có cập nhật mô hình upstream có thể xử lý cho kênh này", "No products configured. Click \"Add product\" to get started.": "Chưa cấu hình sản phẩm nào. Nhấp \"Thêm sản phẩm\" để bắt đầu.", "No products match your search": "Không có sản phẩm nào khớp với tìm kiếm của bạn", + "No provider types are available for your current groups.": "Không có loại nào khả dụng cho các nhóm hiện tại của bạn.", + "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI / Gemini / xAI for your groups — having channels alone is not enough.": "Không có loại nào khả dụng. Loại được mở khi mô hình trong bảng giá khớp Anthropic / OpenAI / Gemini / xAI với nhóm của bạn — chỉ có kênh thì chưa đủ.", "No providers available": "Không có nhà cung cấp khả dụng", "No Quota": "Không hạn ngạch", "No ratio differences found": "Không tìm thấy sự khác biệt tỷ lệ", @@ -3337,6 +3349,7 @@ "Personal use": "Sử dụng cá nhân", "Personal use mode": "Chế độ sử dụng cá nhân", "Pick a date": "Chọn ngày", + "Pick a provider type, group, model, and client. We create an API key and open the tool for you.": "Chọn loại, nhóm, mô hình và client. Hệ thống sẽ tạo API key và mở công cụ.", "Pick or create both a store and a product before saving.": "Hãy chọn hoặc tạo cả cửa hàng và sản phẩm trước khi lưu.", "Ping Interval (seconds)": "Thời gian Ping (giây)", "Plan": "Gói", @@ -3533,6 +3546,7 @@ "Provider created successfully": "Đã tạo nhà cung cấp thành công", "Provider deleted successfully": "Đã xóa nhà cung cấp thành công", "Provider Name": "Tên Nhà cung cấp", + "Provider type": "Loại nhà cung cấp", "Provider type (OpenAI, Anthropic, etc.)": "Loại nhà cung cấp (OpenAI, Anthropic, v.v.)", "Provider updated successfully": "Nhà cung cấp đã được cập nhật thành công", "Provider-specific endpoint, account, and compatibility settings.": "Thiết lập endpoint, tài khoản và tương thích riêng cho nhà cung cấp.", @@ -4010,6 +4024,7 @@ "Select a color": "Chọn một màu", "Select a group": "Chọn một nhóm", "Select a group type": "Chọn loại nhóm", + "Select a model": "Chọn mô hình", "Select a model to edit pricing": "Chọn mô hình để chỉnh sửa giá", "Select a preset...": "Chọn cấu hình sẵn...", "Select a product": "Chọn sản phẩm", diff --git a/web/default/src/i18n/locales/zh-TW.json b/web/default/src/i18n/locales/zh-TW.json index e861b644f974..983e934e6bdc 100644 --- a/web/default/src/i18n/locales/zh-TW.json +++ b/web/default/src/i18n/locales/zh-TW.json @@ -118,6 +118,7 @@ "80,443,8080": "80,443,8080", "A billing multiplier. Lower ratios mean lower API call costs.": "收費乘數,倍率越低,API 呼叫費用越低。", "A focused home for keys, balance, routing, and service health.": "集中展示金鑰、餘額、路由和服務健康狀態。", + "A recommended model is selected automatically. You can change it.": "A recommended model is selected automatically. You can change it.", "About": "關於", "About {{days}} days left": "約剩 {{days}} 日", "Accept Unpriced Models": "接受未定價模型", @@ -387,6 +388,7 @@ "API Key (Sandbox)": "API 金鑰(沙盒)", "API Key *": "API 金鑰 *", "API Key created successfully": "API 金鑰建立成功", + "API key created. Opening the selected tool...": "API key created. Opening the selected tool...", "API Key deleted successfully": "API 金鑰刪除成功", "API Key disabled successfully": "API 金鑰停用成功", "API Key enabled successfully": "API 金鑰啟用成功", @@ -950,6 +952,7 @@ "Configuration for Epay payment integration": "Epay 支付整合的設定", "Configuration for Stripe payment integration": "Stripe 支付整合的設定", "Configuration required": "需要設定", + "Configuration tool": "Configuration tool", "Configure": "設定", "Configure a Creem product for user recharge options.": "為用戶儲值選項設定 Creem 產品。", "Configure a custom ratio for when users use a specific token group.": "設定用戶使用特定令牌分組時的自訂倍率。", @@ -982,6 +985,7 @@ "Configure your account preferences and integrations": "設定您的用戶偏好和整合", "Configured as PayMethods JSON. The type value decides which payment flow is used: stripe for Stripe, waffo_pancake for Waffo Pancake, and other values are sent to Epay as the type parameter.": "儲存為 PayMethods JSON。type 值決定點擊後使用哪個支付流程:stripe 走 Stripe,waffo_pancake 走 Waffo Pancake,其他值作為 Epay 的 type 參數提交。", "Configured routes and latency checks": "已設定路由和延遲檢測", + "Configuring...": "Configuring...", "Confirm": "確認", "Confirm Action": "確認操作", "Confirm and enable": "確認並啟用", @@ -1016,6 +1020,7 @@ "Conflict": "矛盾", "Connect": "連接", "Connect through OpenAI, Claude, Gemini, and other compatible API routes": "透過 OpenAI、Claude、Gemini 以及其他兼容 API 路由接入", + "Connect tool": "Connect tool", "Connected to io.net service normally.": "已正常連接 io.net 服務。", "Connection closed": "連接已關閉", "Connection error": "連接錯誤", @@ -1116,6 +1121,7 @@ "Cost = model price × that one ratio. Nothing else from the group settings enters the formula.": "費用 = 模型價格 × 這一個倍率。分組設定裡的其他項都不參與該公式。", "Cost in USD per request, regardless of tokens used.": "每請求的美元費用,不考慮使用的令牌數。", "Cost Tracking": "成本追蹤", + "Could not load pricing data. Open the pricing page or refresh and try again.": "Could not load pricing data. Open the pricing page or refresh and try again.", "Count must be between {{min}} and {{max}}": "計數必須介於{{min}}和{{max}}之間", "Coze": "Coze", "CPU": "CPU", @@ -1127,6 +1133,7 @@ "Create account": "建立用戶", "Create an account": "建立一個用戶", "Create an API key to unlock the real request": "建立 API 金鑰以解鎖真實請求", + "Create and configure": "Create and configure", "Create and review invite or credit codes.": "建立和審查邀請或信用代碼。", "Create API Key": "建立 API 金鑰", "Create cache": "建立緩存", @@ -2262,6 +2269,7 @@ "If connecting to upstream One API or New API relay projects, use OpenAI type instead unless you know what you are doing": "如果連接上游 One API 或 New API 中繼項目,除非您知道自己在做什麼,否則請使用 OpenAI 類型", "If default auto group is enabled, newly created tokens start with auto instead of an empty group.": "如果啟用預設 auto 分組,新建令牌會預設使用 auto,而不是空分組。", "If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.": "如果親和到的渠道失敗,重試到其他渠道成功後,將親和更新到成功的渠道。", + "If the app did not open, install the tool and use this API key manually:": "If the app did not open, install the tool and use this API key manually:", "If this keeps happening, please report it on GitHub Issues.": "如果問題持續出現,請到 GitHub Issues 反饋。", "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "如果你在中國大陸向公眾提供生成式人工智能服務,你將履行備案、安全評估、內容安全、投訴處理、生成內容標識、日誌留存和個人資訊保護等法律義務。", "Ignore": "忽略", @@ -2492,6 +2500,7 @@ "Load template...": "載入模板...", "Loader": "載入器", "Loading": "載入中", + "Loading available providers...": "Loading available providers...", "Loading channel details": "正在載入渠道詳情", "Loading configuration": "正在載入設定", "Loading content settings...": "正在載入內容設定...", @@ -2885,6 +2894,7 @@ "No group": "未設定", "No group found.": "未找到分組。", "No group-based rate limits configured. Click \"Add group\" to get started.": "未設定基於組的速率限制。點擊「新增組」開始使用。", + "No groups available for this provider type": "No groups available for this provider type", "No groups match your search": "沒有組匹配您的搜尋", "No groups yet. Add a group to get started.": "暫無分組,新增一個分組開始設定。", "No header overrides configured.": "未設定標頭覆蓋。", @@ -2947,6 +2957,8 @@ "No processable upstream model updates for this channel": "該渠道暫無可處理的上游模型更新", "No products configured. Click \"Add product\" to get started.": "未設定產品。點擊「新增產品」開始。", "No products match your search": "沒有產品匹配您的搜尋", + "No provider types are available for your current groups.": "No provider types are available for your current groups.", + "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI / Gemini / xAI for your groups — having channels alone is not enough.": "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI / Gemini / xAI for your groups — having channels alone is not enough.", "No providers available": "暫無可用供應商", "No Quota": "無餘額", "No ratio differences found": "未發現比率差異", @@ -3337,6 +3349,7 @@ "Personal use": "個人使用", "Personal use mode": "個人使用模式", "Pick a date": "選擇日期", + "Pick a provider type, group, model, and client. We create an API key and open the tool for you.": "Pick a provider type, group, model, and client. We create an API key and open the tool for you.", "Pick or create both a store and a product before saving.": "儲存前請同時選擇或建立店鋪和產品。", "Ping Interval (seconds)": "Ping 間隔(秒)", "Plan": "套餐", @@ -3533,6 +3546,7 @@ "Provider created successfully": "供應商建立成功", "Provider deleted successfully": "供應商刪除成功", "Provider Name": "供應商名稱", + "Provider type": "Provider type", "Provider type (OpenAI, Anthropic, etc.)": "供應商類型 (OpenAI、Anthropic 等)", "Provider updated successfully": "供應商更新成功", "Provider-specific endpoint, account, and compatibility settings.": "設定供應商專屬的端點、用戶和兼容性選項。", @@ -4010,6 +4024,7 @@ "Select a color": "選擇顏色", "Select a group": "選擇一個分組", "Select a group type": "選擇分組類型", + "Select a model": "Select a model", "Select a model to edit pricing": "選擇一個模型編輯定價", "Select a preset...": "選擇一個預設...", "Select a product": "選擇產品", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index dcc270a9267f..a8b1b96d2f46 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -118,6 +118,7 @@ "80,443,8080": "80,443,8080", "A billing multiplier. Lower ratios mean lower API call costs.": "计费乘数,倍率越低,API 调用费用越低。", "A focused home for keys, balance, routing, and service health.": "集中展示密钥、余额、路由和服务健康状态。", + "A recommended model is selected automatically. You can change it.": "已自动填入推荐模型,也可手动修改。", "About": "关于", "About {{days}} days left": "约剩 {{days}} 天", "Accept Unpriced Models": "接受未定价模型", @@ -387,6 +388,7 @@ "API Key (Sandbox)": "API 密钥(沙盒)", "API Key *": "API 密钥 *", "API Key created successfully": "API 密钥创建成功", + "API key created. Opening the selected tool...": "令牌已创建,正在打开所选工具...", "API Key deleted successfully": "API 密钥删除成功", "API Key disabled successfully": "API 密钥禁用成功", "API Key enabled successfully": "API 密钥启用成功", @@ -950,6 +952,7 @@ "Configuration for Epay payment integration": "Epay 支付集成的配置", "Configuration for Stripe payment integration": "Stripe 支付集成的配置", "Configuration required": "需要配置", + "Configuration tool": "配置工具", "Configure": "配置", "Configure a Creem product for user recharge options.": "为用户充值选项配置 Creem 产品。", "Configure a custom ratio for when users use a specific token group.": "配置用户使用特定令牌分组时的自定义倍率。", @@ -982,6 +985,7 @@ "Configure your account preferences and integrations": "配置您的账户偏好和集成", "Configured as PayMethods JSON. The type value decides which payment flow is used: stripe for Stripe, waffo_pancake for Waffo Pancake, and other values are sent to Epay as the type parameter.": "保存为 PayMethods JSON。type 值决定点击后使用哪个支付流程:stripe 走 Stripe,waffo_pancake 走 Waffo Pancake,其他值作为 Epay 的 type 参数提交。", "Configured routes and latency checks": "已配置路由和延迟检测", + "Configuring...": "配置中...", "Confirm": "确认", "Confirm Action": "确认操作", "Confirm and enable": "确认并启用", @@ -1016,6 +1020,7 @@ "Conflict": "矛盾", "Connect": "连接", "Connect through OpenAI, Claude, Gemini, and other compatible API routes": "通过 OpenAI、Claude、Gemini 以及其他兼容 API 路由接入", + "Connect tool": "连接工具", "Connected to io.net service normally.": "已正常连接 io.net 服务。", "Connection closed": "连接已关闭", "Connection error": "连接错误", @@ -1116,6 +1121,7 @@ "Cost = model price × that one ratio. Nothing else from the group settings enters the formula.": "费用 = 模型价格 × 这一个倍率。分组设置里的其他项都不参与该公式。", "Cost in USD per request, regardless of tokens used.": "每请求的美元费用,不考虑使用的令牌数。", "Cost Tracking": "成本跟踪", + "Could not load pricing data. Open the pricing page or refresh and try again.": "无法加载定价数据。请打开模型定价页或刷新后重试。", "Count must be between {{min}} and {{max}}": "计数必须介于{{min}}和{{max}}之间", "Coze": "Coze", "CPU": "CPU", @@ -1127,6 +1133,7 @@ "Create account": "创建账户", "Create an account": "创建一个账户", "Create an API key to unlock the real request": "创建 API 密钥以解锁真实请求", + "Create and configure": "创建并配置", "Create and review invite or credit codes.": "创建和审查邀请或信用代码。", "Create API Key": "创建 API 密钥", "Create cache": "创建缓存", @@ -2262,6 +2269,7 @@ "If connecting to upstream One API or New API relay projects, use OpenAI type instead unless you know what you are doing": "如果连接上游 One API 或 New API 中继项目,除非您知道自己在做什么,否则请使用 OpenAI 类型", "If default auto group is enabled, newly created tokens start with auto instead of an empty group.": "如果启用默认 auto 分组,新建令牌会默认使用 auto,而不是空分组。", "If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.": "如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。", + "If the app did not open, install the tool and use this API key manually:": "如果应用未打开,请先安装工具,并使用下面的密钥手动配置:", "If this keeps happening, please report it on GitHub Issues.": "如果问题持续出现,请到 GitHub Issues 反馈。", "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "如果你在中国大陆向公众提供生成式人工智能服务,你将履行备案、安全评估、内容安全、投诉处理、生成内容标识、日志留存和个人信息保护等法律义务。", "Ignore": "忽略", @@ -2492,6 +2500,7 @@ "Load template...": "加载模板...", "Loader": "加载器", "Loading": "加载中", + "Loading available providers...": "正在加载可用类型…", "Loading channel details": "正在加载渠道详情", "Loading configuration": "正在加载配置", "Loading content settings...": "正在加载内容设置...", @@ -2885,6 +2894,7 @@ "No group": "未设置", "No group found.": "未找到分组。", "No group-based rate limits configured. Click \"Add group\" to get started.": "未配置基于组的速率限制。点击“添加组”开始使用。", + "No groups available for this provider type": "该类型下暂无可用分组", "No groups match your search": "没有组匹配您的搜索", "No groups yet. Add a group to get started.": "暂无分组,添加一个分组开始配置。", "No header overrides configured.": "未配置标头覆盖。", @@ -2947,6 +2957,8 @@ "No processable upstream model updates for this channel": "该渠道暂无可处理的上游模型更新", "No products configured. Click \"Add product\" to get started.": "未配置产品。点击 \"添加产品\" 开始。", "No products match your search": "没有产品匹配您的搜索", + "No provider types are available for your current groups.": "当前可用分组下没有可选的类型。", + "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI / Gemini / xAI for your groups — having channels alone is not enough.": "没有可选类型。需要定价中存在匹配 Anthropic / OpenAI / Gemini / xAI 且你可用分组可访问的模型;仅有渠道不会解锁类型。", "No providers available": "暂无可用提供商", "No Quota": "无余额", "No ratio differences found": "未发现比率差异", @@ -3337,6 +3349,7 @@ "Personal use": "个人使用", "Personal use mode": "个人使用模式", "Pick a date": "选择日期", + "Pick a provider type, group, model, and client. We create an API key and open the tool for you.": "选择类型、分组、模型和客户端。系统会创建令牌并打开对应工具完成配置。", "Pick or create both a store and a product before saving.": "保存前请同时选择或创建店铺和产品。", "Ping Interval (seconds)": "Ping 间隔(秒)", "Plan": "套餐", @@ -3533,6 +3546,7 @@ "Provider created successfully": "提供商创建成功", "Provider deleted successfully": "提供商删除成功", "Provider Name": "提供商名称", + "Provider type": "类型", "Provider type (OpenAI, Anthropic, etc.)": "提供商类型 (OpenAI、Anthropic 等)", "Provider updated successfully": "提供商更新成功", "Provider-specific endpoint, account, and compatibility settings.": "配置供应商专属的端点、账户和兼容性选项。", @@ -4010,6 +4024,7 @@ "Select a color": "选择颜色", "Select a group": "选择一个分组", "Select a group type": "选择分组类型", + "Select a model": "选择模型", "Select a model to edit pricing": "选择一个模型编辑定价", "Select a preset...": "选择一个预设...", "Select a product": "选择产品", diff --git a/web/default/src/i18n/static-keys.ts b/web/default/src/i18n/static-keys.ts index 5eed9136f20e..0bc19e6c8610 100644 --- a/web/default/src/i18n/static-keys.ts +++ b/web/default/src/i18n/static-keys.ts @@ -278,6 +278,20 @@ export const STATIC_I18N_KEYS = [ 'Opus Model', 'Enter model name', + // Connect tool wizard + 'Connect tool', + 'Pick a provider type, group, model, and client. We create an API key and open the tool for you.', + 'Provider type', + 'No provider types are available for your current groups.', + 'No groups available for this provider type', + 'Select a model', + 'A recommended model is selected automatically. You can change it.', + 'Configuration tool', + 'Configuring...', + 'Create and configure', + 'API key created. Opening the selected tool...', + 'If the app did not open, install the tool and use this API key manually:', + // User binding dialog 'Account Binding Management', 'Built-in', From 21c1f69940de2422038b92b2348d056402051306 Mon Sep 17 00:00:00 2001 From: wangdong Date: Sat, 18 Jul 2026 11:41:59 +0800 Subject: [PATCH 07/14] Fix Connect Tool group/model filtering by primary endpoint. Expose enable_groups_by_endpoint in pricing and narrow the wizard to Anthropic/OpenAI models for the selected protocol and group. Co-authored-by: Cursor --- model/pricing.go | 49 +++++- .../dialogs/connect-tool-dialog.tsx | 31 ++-- .../src/features/keys/lib/connect-tool.ts | 160 ++++++++++++------ web/default/src/features/pricing/types.ts | 5 + .../i18n/locales/_reports/_sync-report.json | 2 +- web/default/src/i18n/locales/en.json | 1 + web/default/src/i18n/locales/fr.json | 1 + web/default/src/i18n/locales/ja.json | 1 + web/default/src/i18n/locales/ru.json | 1 + web/default/src/i18n/locales/vi.json | 1 + web/default/src/i18n/locales/zh-TW.json | 1 + web/default/src/i18n/locales/zh.json | 1 + 12 files changed, 189 insertions(+), 65 deletions(-) diff --git a/model/pricing.go b/model/pricing.go index 440e1e0999b9..e04f89ab36ee 100644 --- a/model/pricing.go +++ b/model/pricing.go @@ -32,6 +32,11 @@ type Pricing struct { AudioRatio *float64 `json:"audio_ratio,omitempty"` AudioCompletionRatio *float64 `json:"audio_completion_ratio,omitempty"` EnableGroup []string `json:"enable_groups"` + // EnableGroupsByEndpoint maps a channel's primary endpoint type to the + // groups where that model is actually served via that endpoint. Unlike + // EnableGroup (union across all channels), this preserves endpoint×group + // pairing so clients can filter groups per protocol (e.g. Anthropic vs OpenAI). + EnableGroupsByEndpoint map[string][]string `json:"enable_groups_by_endpoint,omitempty"` SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"` BillingMode string `json:"billing_mode,omitempty"` BillingExpr string `json:"billing_expr,omitempty"` @@ -117,6 +122,21 @@ func getPricingEndpointTypesForAbility(ability AbilityWithChannel, advancedCusto return common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model) } +// primaryPricingEndpointType returns the channel's native protocol endpoint, +// skipping image-generation which may be prepended as a capability flag. +func primaryPricingEndpointType(endpoints []constant.EndpointType) constant.EndpointType { + for _, et := range endpoints { + if et == constant.EndpointTypeImageGeneration { + continue + } + return et + } + if len(endpoints) > 0 { + return endpoints[0] + } + return "" +} + // loadPricingAdvancedCustomConfigs runs inside updatePricing while // updatePricingLock is held, and nests channelSyncLock.RLock. This defines the // global lock order updatePricingLock -> channelSyncLock: any code path holding @@ -259,6 +279,8 @@ func updatePricing() { } modelGroupsMap := make(map[string]*types.Set[string]) + // model -> primary endpoint -> groups + modelGroupsByEndpoint := make(map[string]map[string]*types.Set[string]) for _, ability := range enableAbilities { groups, ok := modelGroupsMap[ability.Model] @@ -273,7 +295,7 @@ func updatePricing() { modelSupportEndpointsStr := make(map[string][]string) advancedCustomConfigs := loadPricingAdvancedCustomConfigs(enableAbilities) - // 先根据已有能力填充原生端点 + // 先根据已有能力填充原生端点,并按渠道主端点记录分组 for _, ability := range enableAbilities { endpoints := modelSupportEndpointsStr[ability.Model] channelTypes := getPricingEndpointTypesForAbility(ability, advancedCustomConfigs) @@ -283,6 +305,23 @@ func updatePricing() { } } modelSupportEndpointsStr[ability.Model] = endpoints + + primary := primaryPricingEndpointType(channelTypes) + if primary == "" || ability.Group == "" { + continue + } + byEndpoint, ok := modelGroupsByEndpoint[ability.Model] + if !ok { + byEndpoint = make(map[string]*types.Set[string]) + modelGroupsByEndpoint[ability.Model] = byEndpoint + } + primaryKey := string(primary) + endpointGroups, ok := byEndpoint[primaryKey] + if !ok { + endpointGroups = types.NewSet[string]() + byEndpoint[primaryKey] = endpointGroups + } + endpointGroups.Add(ability.Group) } // 再补充模型自定义端点:若配置有效则追加到已有推断,不再裁剪渠道真实能力 @@ -356,9 +395,17 @@ func updatePricing() { pricingMap = make([]Pricing, 0) for model, groups := range modelGroupsMap { + var enableGroupsByEndpoint map[string][]string + if byEndpoint := modelGroupsByEndpoint[model]; len(byEndpoint) > 0 { + enableGroupsByEndpoint = make(map[string][]string, len(byEndpoint)) + for endpoint, endpointGroups := range byEndpoint { + enableGroupsByEndpoint[endpoint] = endpointGroups.Items() + } + } pricing := Pricing{ ModelName: model, EnableGroup: groups.Items(), + EnableGroupsByEndpoint: enableGroupsByEndpoint, SupportedEndpointTypes: modelSupportEndpointTypes[model], } diff --git a/web/default/src/features/keys/components/dialogs/connect-tool-dialog.tsx b/web/default/src/features/keys/components/dialogs/connect-tool-dialog.tsx index d7495a480876..2e0f87223a91 100644 --- a/web/default/src/features/keys/components/dialogs/connect-tool-dialog.tsx +++ b/web/default/src/features/keys/components/dialogs/connect-tool-dialog.tsx @@ -68,14 +68,16 @@ export function ConnectToolDialog(props: Props) { queryKey: ['pricing', 'connect-tool'], queryFn: getPricing, enabled: props.open, - staleTime: 5 * 60 * 1000, + staleTime: 30 * 1000, + refetchOnMount: 'always', }) const groupsQuery = useQuery({ queryKey: ['user-groups', 'connect-tool'], queryFn: getUserGroups, enabled: props.open, - staleTime: 5 * 60 * 1000, + staleTime: 30 * 1000, + refetchOnMount: 'always', }) const pricingModels = useMemo(() => { @@ -94,13 +96,15 @@ export function ConnectToolDialog(props: Props) { }) }, [pricingQuery.data]) - // Prefer pricing.usable_group (same source pricing already filters with). - // Fall back to /user/self/groups; if both are empty, still allow groups - // discovered on returned models (backend already scoped pricing). + // Merge pricing.usable_group with /user/self/groups so channel-group changes + // are not dropped when one of the two responses is incomplete. + // If both are empty, getGroupsForEndpoint still falls back to model groups. const usableGroups = useMemo(() => { - const fromPricing = Object.keys(pricingQuery.data?.usable_group || {}) - if (fromPricing.length > 0) return fromPricing - return Object.keys(groupsQuery.data?.data || {}) + const merged = new Set([ + ...Object.keys(pricingQuery.data?.usable_group || {}), + ...Object.keys(groupsQuery.data?.data || {}), + ]) + return [...merged] }, [pricingQuery.data?.usable_group, groupsQuery.data?.data]) const availableEndpointIds = useMemo(() => { @@ -160,8 +164,15 @@ export function ConnectToolDialog(props: Props) { setModel('') return } + // Always re-resolve against the current endpoint's groups so switching + // provider type does not keep a group that only belonged to the previous type. setGroup((current) => { - if (groupOptions.some((item) => item.value === current)) return current + if ( + current && + groupOptions.some((item) => item.value === current) + ) { + return current + } return groupOptions[0]?.value || '' }) }, [endpointId, groupOptions]) @@ -307,7 +318,7 @@ export function ConnectToolDialog(props: Props) { availableEndpointIds.length === 0 && (

{t( - 'No provider types are available. Types unlock when pricing models match Anthropic / OpenAI / Gemini / xAI for your groups — having channels alone is not enough.' + 'No provider types are available. Types unlock when pricing models match Anthropic / OpenAI for your groups — having channels alone is not enough.' )}

)} diff --git a/web/default/src/features/keys/lib/connect-tool.ts b/web/default/src/features/keys/lib/connect-tool.ts index 1278c3f0d9e8..311e01141f93 100644 --- a/web/default/src/features/keys/lib/connect-tool.ts +++ b/web/default/src/features/keys/lib/connect-tool.ts @@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com */ import type { PricingModel } from '@/features/pricing/types' -export type EndpointTypeId = 'anthropic' | 'openai' | 'gemini' | 'xai' +export type EndpointTypeId = 'anthropic' | 'openai' export type ConnectToolId = 'cc-switch' | 'cherry-studio' export type EndpointTypeConfig = { @@ -28,9 +28,12 @@ export type EndpointTypeConfig = { vendorMatchers: string[] modelMatchers: RegExp[] preferPatterns: RegExp[] - /** Pricing `supported_endpoint_types` values that unlock this provider type. */ - endpointMatchers: string[] - ccSwitchApp: 'claude' | 'codex' | 'gemini' + /** + * Primary pricing endpoint keys used for group/model filtering via + * `enable_groups_by_endpoint` (channel native protocol, not secondary compat). + */ + groupEndpointKeys: string[] + ccSwitchApp: 'claude' | 'codex' } export const ENDPOINT_TYPES: EndpointTypeConfig[] = [ @@ -41,7 +44,7 @@ export const ENDPOINT_TYPES: EndpointTypeConfig[] = [ vendorMatchers: ['anthropic', 'claude'], modelMatchers: [/claude/i], preferPatterns: [/sonnet/i, /opus/i, /haiku/i, /claude/i], - endpointMatchers: ['anthropic'], + groupEndpointKeys: ['anthropic'], ccSwitchApp: 'claude', }, { @@ -51,27 +54,7 @@ export const ENDPOINT_TYPES: EndpointTypeConfig[] = [ vendorMatchers: ['openai'], modelMatchers: [/^(gpt-|o[1-9]|chatgpt-|codex)/i], preferPatterns: [/codex/i, /gpt-4o/i, /gpt-4\.1/i, /gpt/i], - endpointMatchers: ['openai', 'openai-response', 'openai-response-compact'], - ccSwitchApp: 'codex', - }, - { - id: 'gemini', - label: 'Gemini', - iconKey: 'Gemini', - vendorMatchers: ['gemini', 'google'], - modelMatchers: [/gemini/i], - preferPatterns: [/gemini/i], - endpointMatchers: ['gemini'], - ccSwitchApp: 'gemini', - }, - { - id: 'xai', - label: 'xAI', - iconKey: 'XAI', - vendorMatchers: ['xai', 'x.ai'], - modelMatchers: [/grok/i], - preferPatterns: [/grok/i], - endpointMatchers: [], + groupEndpointKeys: ['openai', 'openai-response', 'openai-response-compact'], ccSwitchApp: 'codex', }, ] @@ -86,7 +69,25 @@ function normalizeVendor(value: string | undefined | null): string { return (value || '').trim().toLowerCase() } -export function modelMatchesEndpoint( +function groupsForModelEndpoint( + model: PricingModel, + endpoint: EndpointTypeConfig +): string[] | null { + const byEndpoint = model.enable_groups_by_endpoint + if (!byEndpoint || endpoint.groupEndpointKeys.length === 0) return null + const groups = new Set() + let found = false + for (const key of endpoint.groupEndpointKeys) { + const list = byEndpoint[key] + if (!list || list.length === 0) continue + found = true + for (const group of list) groups.add(group) + } + return found ? [...groups] : [] +} + +/** Match by vendor name / model name heuristics for this provider type. */ +export function modelMatchesProviderHeuristic( model: PricingModel, endpoint: EndpointTypeConfig ): boolean { @@ -100,14 +101,27 @@ export function modelMatchesEndpoint( return true } const modelName = model.model_name || '' - if (endpoint.modelMatchers.some((pattern) => pattern.test(modelName))) { - return true + return endpoint.modelMatchers.some((pattern) => pattern.test(modelName)) +} + +/** + * A model is usable for a provider type when it is served on that primary + * endpoint (enable_groups_by_endpoint), and also looks like that provider + * (vendor/name). The heuristic avoids dumping every model on a busy OpenAI + * channel into the Codex picker. + */ +export function modelMatchesEndpoint( + model: PricingModel, + endpoint: EndpointTypeConfig +): boolean { + const byEndpointGroups = groupsForModelEndpoint(model, endpoint) + if (byEndpointGroups !== null) { + return ( + byEndpointGroups.length > 0 && + modelMatchesProviderHeuristic(model, endpoint) + ) } - if (endpoint.endpointMatchers.length === 0) return false - const supported = model.supported_endpoint_types || [] - return supported.some((item) => - endpoint.endpointMatchers.includes(String(item).toLowerCase()) - ) + return modelMatchesProviderHeuristic(model, endpoint) } export function filterModelsForEndpoint( @@ -119,29 +133,54 @@ export function filterModelsForEndpoint( return models.filter((model) => modelMatchesEndpoint(model, endpoint)) } +function collectGroups( + groups: Iterable, + usable: Set, + restrictToUsable: boolean, + out: Set +) { + for (const group of groups) { + if (!group || group === 'auto') continue + if (group === 'all') { + if (restrictToUsable) { + for (const item of usable) { + if (item && item !== 'auto') out.add(item) + } + } + continue + } + if (!restrictToUsable || usable.has(group)) out.add(group) + } +} + export function getGroupsForEndpoint( models: PricingModel[], endpointId: EndpointTypeId, usableGroups: string[] ): string[] { + const endpoint = getEndpointTypeConfig(endpointId) + if (!endpoint) return [] const usable = new Set(usableGroups) const restrictToUsable = usable.size > 0 - const matched = filterModelsForEndpoint(models, endpointId) const groups = new Set() - for (const model of matched) { - for (const group of model.enable_groups || []) { - if (!group || group === 'auto') continue - // Pricing may mark a model as available to every usable group. - if (group === 'all') { - if (restrictToUsable) { - for (const item of usable) { - if (item && item !== 'auto') groups.add(item) - } - } - continue - } - if (!restrictToUsable || usable.has(group)) groups.add(group) - } + + let usedByEndpoint = false + for (const model of models) { + // Only count groups from models that belong to this provider type, + // so OpenAI groups are not derived from unrelated channel inventory. + if (!modelMatchesProviderHeuristic(model, endpoint)) continue + const byEndpointGroups = groupsForModelEndpoint(model, endpoint) + if (byEndpointGroups === null) continue + usedByEndpoint = true + collectGroups(byEndpointGroups, usable, restrictToUsable, groups) + } + if (usedByEndpoint) { + return [...groups].sort((a, b) => a.localeCompare(b)) + } + + // Legacy fallback: vendor/name match + union enable_groups + for (const model of filterModelsForEndpoint(models, endpointId)) { + collectGroups(model.enable_groups || [], usable, restrictToUsable, groups) } return [...groups].sort((a, b) => a.localeCompare(b)) } @@ -151,9 +190,24 @@ export function filterModelsForGroup( endpointId: EndpointTypeId, group: string ): PricingModel[] { - return filterModelsForEndpoint(models, endpointId).filter((model) => - (model.enable_groups || []).includes(group) - ) + const endpoint = getEndpointTypeConfig(endpointId) + if (!endpoint) return [] + + if (models.some((model) => model.enable_groups_by_endpoint)) { + return models.filter((model) => { + if (!modelMatchesProviderHeuristic(model, endpoint)) return false + const byEndpointGroups = groupsForModelEndpoint(model, endpoint) + if (byEndpointGroups === null) return false + return ( + byEndpointGroups.includes(group) || byEndpointGroups.includes('all') + ) + }) + } + + return filterModelsForEndpoint(models, endpointId).filter((model) => { + const groups = model.enable_groups || [] + return groups.includes(group) || groups.includes('all') + }) } export function recommendModelName( @@ -189,7 +243,7 @@ function normalizeApiKey(apiKey: string): string { } export function buildCCSwitchImportURL(params: { - app: 'claude' | 'codex' | 'gemini' + app: 'claude' | 'codex' name: string model: string apiKey: string diff --git a/web/default/src/features/pricing/types.ts b/web/default/src/features/pricing/types.ts index 8a0e244d5d09..97987b19277d 100644 --- a/web/default/src/features/pricing/types.ts +++ b/web/default/src/features/pricing/types.ts @@ -46,6 +46,11 @@ export type PricingModel = { audio_ratio?: number | null audio_completion_ratio?: number | null enable_groups: string[] + /** + * Groups keyed by the channel's primary endpoint type (e.g. anthropic, openai). + * Preserves endpoint×group pairing; prefer this over enable_groups when filtering by protocol. + */ + enable_groups_by_endpoint?: Record tags?: string supported_endpoint_types?: string[] key?: string diff --git a/web/default/src/i18n/locales/_reports/_sync-report.json b/web/default/src/i18n/locales/_reports/_sync-report.json index 805e285773a4..14cd75dd0f36 100644 --- a/web/default/src/i18n/locales/_reports/_sync-report.json +++ b/web/default/src/i18n/locales/_reports/_sync-report.json @@ -33,7 +33,7 @@ }, "zh-TW": { "file": "zh-TW.json", - "missingCount": 3, + "missingCount": 1, "extrasCount": 0, "untranslatedCount": 0 }, diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index de07105df4a8..eaa265a40532 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -2959,6 +2959,7 @@ "No products match your search": "No products match your search", "No provider types are available for your current groups.": "No provider types are available for your current groups.", "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI / Gemini / xAI for your groups — having channels alone is not enough.": "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI / Gemini / xAI for your groups — having channels alone is not enough.", + "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI for your groups — having channels alone is not enough.": "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI for your groups — having channels alone is not enough.", "No providers available": "No providers available", "No Quota": "No Quota", "No ratio differences found": "No ratio differences found", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index e6a49a50dce8..7305f7dea1d7 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -2959,6 +2959,7 @@ "No products match your search": "Aucun produit ne correspond à votre recherche", "No provider types are available for your current groups.": "Aucun type n’est disponible pour vos groupes actuels.", "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI / Gemini / xAI for your groups — having channels alone is not enough.": "Aucun type disponible. Les types s’activent quand les modèles tarifés correspondent à Anthropic / OpenAI / Gemini / xAI pour vos groupes — avoir des canaux ne suffit pas.", + "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI for your groups — having channels alone is not enough.": "Aucun type disponible. Les types s’activent quand les modèles tarifés correspondent à Anthropic / OpenAI pour vos groupes — avoir des canaux ne suffit pas.", "No providers available": "Aucun fournisseur disponible", "No Quota": "Aucun quota", "No ratio differences found": "Aucune différence de ratio trouvée", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index 553bd492fdaa..8ce6cf861119 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -2959,6 +2959,7 @@ "No products match your search": "検索に一致する製品がありません", "No provider types are available for your current groups.": "現在のグループで利用可能なタイプがありません。", "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI / Gemini / xAI for your groups — having channels alone is not enough.": "選択できるタイプがありません。料金のモデルが Anthropic / OpenAI / Gemini / xAI に一致し、利用可能なグループから使える場合に解放されます。チャネルがあるだけでは不十分です。", + "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI for your groups — having channels alone is not enough.": "選択できるタイプがありません。料金のモデルが Anthropic / OpenAI に一致し、利用可能なグループから使える場合に解放されます。チャネルがあるだけでは不十分です。", "No providers available": "利用可能なプロバイダーがありません", "No Quota": "クォータなし", "No ratio differences found": "比率の差異は見つかりませんでした", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 129ad904b0db..1e36cb92281b 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -2959,6 +2959,7 @@ "No products match your search": "Нет продуктов, соответствующих вашему поиску", "No provider types are available for your current groups.": "Для ваших текущих групп нет доступных типов.", "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI / Gemini / xAI for your groups — having channels alone is not enough.": "Нет доступных типов. Типы открываются, когда модели в тарифах соответствуют Anthropic / OpenAI / Gemini / xAI для ваших групп — одних каналов недостаточно.", + "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI for your groups — having channels alone is not enough.": "Нет доступных типов. Типы открываются, когда модели в тарифах соответствуют Anthropic / OpenAI для ваших групп — одних каналов недостаточно.", "No providers available": "Нет доступных провайдеров", "No Quota": "Нет квоты", "No ratio differences found": "Различия в коэффициентах не найдены", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index c84f96a892ce..001539025092 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -2959,6 +2959,7 @@ "No products match your search": "Không có sản phẩm nào khớp với tìm kiếm của bạn", "No provider types are available for your current groups.": "Không có loại nào khả dụng cho các nhóm hiện tại của bạn.", "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI / Gemini / xAI for your groups — having channels alone is not enough.": "Không có loại nào khả dụng. Loại được mở khi mô hình trong bảng giá khớp Anthropic / OpenAI / Gemini / xAI với nhóm của bạn — chỉ có kênh thì chưa đủ.", + "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI for your groups — having channels alone is not enough.": "Không có loại nào khả dụng. Loại được mở khi mô hình trong bảng giá khớp Anthropic / OpenAI với nhóm của bạn — chỉ có kênh thì chưa đủ.", "No providers available": "Không có nhà cung cấp khả dụng", "No Quota": "Không hạn ngạch", "No ratio differences found": "Không tìm thấy sự khác biệt tỷ lệ", diff --git a/web/default/src/i18n/locales/zh-TW.json b/web/default/src/i18n/locales/zh-TW.json index 983e934e6bdc..8124e5262157 100644 --- a/web/default/src/i18n/locales/zh-TW.json +++ b/web/default/src/i18n/locales/zh-TW.json @@ -2959,6 +2959,7 @@ "No products match your search": "沒有產品匹配您的搜尋", "No provider types are available for your current groups.": "No provider types are available for your current groups.", "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI / Gemini / xAI for your groups — having channels alone is not enough.": "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI / Gemini / xAI for your groups — having channels alone is not enough.", + "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI for your groups — having channels alone is not enough.": "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI for your groups — having channels alone is not enough.", "No providers available": "暫無可用供應商", "No Quota": "無餘額", "No ratio differences found": "未發現比率差異", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index a8b1b96d2f46..fcb6258707e7 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -2959,6 +2959,7 @@ "No products match your search": "没有产品匹配您的搜索", "No provider types are available for your current groups.": "当前可用分组下没有可选的类型。", "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI / Gemini / xAI for your groups — having channels alone is not enough.": "没有可选类型。需要定价中存在匹配 Anthropic / OpenAI / Gemini / xAI 且你可用分组可访问的模型;仅有渠道不会解锁类型。", + "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI for your groups — having channels alone is not enough.": "没有可选类型。需要定价中存在匹配 Anthropic / OpenAI 且你可用分组可访问的模型;仅有渠道不会解锁类型。", "No providers available": "暂无可用提供商", "No Quota": "无余额", "No ratio differences found": "未发现比率差异", From 65bbbeace2ddcb2fcbd97acb42c76faf0452ae6d Mon Sep 17 00:00:00 2001 From: wangdong Date: Sat, 18 Jul 2026 13:33:47 +0800 Subject: [PATCH 08/14] =?UTF-8?q?=E5=AE=9A=E5=88=B6=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + controller/extensions_availability.go | 87 +++ controller/misc.go | 27 + controller/option.go | 18 + model/log.go | 59 ++ model/log_availability_test.go | 35 + router/api-router.go | 1 + setting/console_setting/availability.go | 28 + setting/console_setting/availability_test.go | 58 ++ setting/console_setting/config.go | 38 +- setting/console_setting/custom_pages_test.go | 64 ++ setting/console_setting/validation.go | 232 ++++++ .../layout/config/system-settings.config.ts | 9 +- web/default/src/features/auth/types.ts | 8 + .../features/extensions/availability/api.ts | 49 ++ .../components/availability-group-card.tsx | 106 +++ .../components/heartbeat-bars.tsx | 84 +++ .../availability/hooks/use-availability.ts | 34 + .../extensions/availability/index.tsx | 91 +++ .../availability/lib/status.test.ts | 32 + .../extensions/availability/lib/status.ts | 40 + .../home/components/home-html-frame.tsx | 79 ++ web/default/src/features/home/index.tsx | 18 +- .../availability-monitor-section.tsx | 184 +++++ .../system-settings/extensions/constants.ts | 120 +++ .../extensions/custom-pages-section.tsx | 685 ++++++++++++++++++ .../system-settings/extensions/index.tsx | 44 ++ .../extensions/section-registry.ts | 74 ++ .../hooks/use-update-option.ts | 3 + .../src/features/system-settings/types.ts | 6 + web/default/src/hooks/use-sidebar-data.ts | 269 ++++--- .../i18n/locales/_reports/_sync-report.json | 2 +- web/default/src/i18n/locales/en.json | 52 ++ web/default/src/i18n/locales/fr.json | 52 ++ web/default/src/i18n/locales/ja.json | 52 ++ web/default/src/i18n/locales/ru.json | 52 ++ web/default/src/i18n/locales/vi.json | 54 +- web/default/src/i18n/locales/zh-TW.json | 52 ++ web/default/src/i18n/locales/zh.json | 54 +- web/default/src/routeTree.gen.ts | 91 +++ .../_authenticated/custom-pages/$pageId.tsx | 141 ++++ .../extensions/availability.tsx | 41 ++ .../system-settings/extensions/$section.tsx | 40 + .../system-settings/extensions/index.tsx | 32 + 44 files changed, 3157 insertions(+), 142 deletions(-) create mode 100644 controller/extensions_availability.go create mode 100644 model/log_availability_test.go create mode 100644 setting/console_setting/availability.go create mode 100644 setting/console_setting/availability_test.go create mode 100644 setting/console_setting/custom_pages_test.go create mode 100644 web/default/src/features/extensions/availability/api.ts create mode 100644 web/default/src/features/extensions/availability/components/availability-group-card.tsx create mode 100644 web/default/src/features/extensions/availability/components/heartbeat-bars.tsx create mode 100644 web/default/src/features/extensions/availability/hooks/use-availability.ts create mode 100644 web/default/src/features/extensions/availability/index.tsx create mode 100644 web/default/src/features/extensions/availability/lib/status.test.ts create mode 100644 web/default/src/features/extensions/availability/lib/status.ts create mode 100644 web/default/src/features/home/components/home-html-frame.tsx create mode 100644 web/default/src/features/system-settings/extensions/availability-monitor-section.tsx create mode 100644 web/default/src/features/system-settings/extensions/constants.ts create mode 100644 web/default/src/features/system-settings/extensions/custom-pages-section.tsx create mode 100644 web/default/src/features/system-settings/extensions/index.tsx create mode 100644 web/default/src/features/system-settings/extensions/section-registry.ts create mode 100644 web/default/src/routes/_authenticated/custom-pages/$pageId.tsx create mode 100644 web/default/src/routes/_authenticated/extensions/availability.tsx create mode 100644 web/default/src/routes/_authenticated/system-settings/extensions/$section.tsx create mode 100644 web/default/src/routes/_authenticated/system-settings/extensions/index.tsx diff --git a/.gitignore b/.gitignore index c3afceb021f1..4fc4a5d7ccfa 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,5 @@ skills-lock.json .local-tests/ service/relayconvert/chat_responses_live_local_test.go service/openaicompat/chat_responses_live_local_test.go +.superpowers +docs \ No newline at end of file diff --git a/controller/extensions_availability.go b/controller/extensions_availability.go new file mode 100644 index 000000000000..935f59d6ef68 --- /dev/null +++ b/controller/extensions_availability.go @@ -0,0 +1,87 @@ +package controller + +import ( + "net/http" + "sort" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/console_setting" + "github.com/QuantumNous/new-api/setting/ratio_setting" + + "github.com/gin-gonic/gin" +) + +type extensionsAvailabilityGroup struct { + Group string `json:"group"` + Records []model.GroupAvailabilityRecord `json:"records"` + SuccessRate float64 `json:"success_rate"` + AvgUseTime float64 `json:"avg_use_time"` + Status string `json:"status"` + Total int `json:"total"` + SuccessCount int `json:"success_count"` +} + +func GetExtensionsAvailability(c *gin.Context) { + isAdmin := c.GetInt("role") >= common.RoleAdminUser + if !console_setting.IsAvailabilityMonitorVisible(isAdmin) { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "availability monitor is not available", + }) + return + } + + userId := c.GetInt("id") + userGroup, _ := model.GetUserGroup(userId, false) + userUsableGroups := service.GetUserUsableGroups(userGroup) + + groupNames := make([]string, 0) + for groupName := range ratio_setting.GetGroupRatioCopy() { + // Match GetUserGroups: only billing groups the user can select (skip "auto"). + if groupName == "auto" { + continue + } + if _, ok := userUsableGroups[groupName]; !ok { + continue + } + groupNames = append(groupNames, groupName) + } + sort.Strings(groupNames) + + groups := make([]extensionsAvailabilityGroup, 0, len(groupNames)) + for _, groupName := range groupNames { + records, err := model.GetRecentGroupAvailabilityLogs(groupName, 100) + if err != nil { + common.ApiError(c, err) + return + } + okCount := 0 + successUseTimeSum := 0 + for _, record := range records { + if record.Ok { + okCount++ + successUseTimeSum += record.UseTime + } + } + successRate, avgUseTime, status := console_setting.SummarizeAvailabilityRecords( + okCount, + len(records), + successUseTimeSum, + ) + groups = append(groups, extensionsAvailabilityGroup{ + Group: groupName, + Records: records, + SuccessRate: successRate, + AvgUseTime: avgUseTime, + Status: status, + Total: len(records), + SuccessCount: okCount, + }) + } + + common.ApiSuccess(c, gin.H{ + "groups": groups, + }) +} diff --git a/controller/misc.go b/controller/misc.go index fb2029878747..842ecd889728 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -19,6 +19,7 @@ import ( "github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/setting/system_setting" + "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" ) @@ -135,6 +136,15 @@ func GetStatus(c *gin.Context) { data["faq"] = console_setting.GetFAQ() } + isLoggedIn, isAdmin := statusViewerRole(c) + if isLoggedIn { + data["custom_pages"] = console_setting.GetCustomPagesForRole(isAdmin) + data["availability_monitor_visible"] = console_setting.IsAvailabilityMonitorVisible(isAdmin) + } else { + data["custom_pages"] = []map[string]interface{}{} + data["availability_monitor_visible"] = false + } + // Add enabled custom OAuth providers customProviders := oauth.GetEnabledCustomProviders() if len(customProviders) > 0 { @@ -171,6 +181,23 @@ func GetStatus(c *gin.Context) { return } +func statusViewerRole(c *gin.Context) (isLoggedIn bool, isAdmin bool) { + session := sessions.Default(c) + if session.Get("id") == nil { + return false, false + } + role := 0 + switch v := session.Get("role").(type) { + case int: + role = v + case int64: + role = int(v) + case float64: + role = int(v) + } + return true, role >= common.RoleAdminUser +} + func GetNotice(c *gin.Context) { common.OptionMapRWMutex.RLock() defer common.OptionMapRWMutex.RUnlock() diff --git a/controller/option.go b/controller/option.go index a97f07b841b7..44ba0bfc62ad 100644 --- a/controller/option.go +++ b/controller/option.go @@ -322,6 +322,24 @@ func UpdateOption(c *gin.Context) { }) return } + case "console_setting.custom_pages": + err = console_setting.ValidateConsoleSettings(option.Value.(string), "CustomPages") + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + case "console_setting.availability_monitor_visibility": + err = console_setting.ValidateAvailabilityMonitorVisibility(option.Value.(string)) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } case "console_setting.uptime_kuma_groups": err = console_setting.ValidateConsoleSettings(option.Value.(string), "UptimeKumaGroups") if err != nil { diff --git a/model/log.go b/model/log.go index 506bd504b686..17de259fda01 100644 --- a/model/log.go +++ b/model/log.go @@ -762,3 +762,62 @@ func DeleteOldLog(ctx context.Context, targetTimestamp int64, limit int) (int64, return total, nil } + +// GroupAvailabilityRecord is a channel-free projection of recent group logs. +type GroupAvailabilityRecord struct { + CreatedAt int64 `json:"created_at"` + UseTime int `json:"use_time"` + Ok bool `json:"ok"` +} + +// GetRecentGroupAvailabilityLogs returns the latest consume/error logs for a billing group. +// Results are chronological (oldest → newest). No channel fields are loaded. +func GetRecentGroupAvailabilityLogs(group string, limit int) ([]GroupAvailabilityRecord, error) { + if group == "" { + return []GroupAvailabilityRecord{}, nil + } + if limit <= 0 { + limit = 100 + } + if limit > 100 { + limit = 100 + } + + type row struct { + CreatedAt int64 `gorm:"column:created_at"` + UseTime int `gorm:"column:use_time"` + Type int `gorm:"column:type"` + } + + var rows []row + order := "created_at desc, id desc" + if common.UsingLogDatabase(common.DatabaseTypeClickHouse) { + order = clickHouseLogOrder("") + } + + err := LOG_DB.Model(&Log{}). + Select("created_at", "use_time", "type"). + Where("type IN ?", []int{LogTypeConsume, LogTypeError}). + Where(logGroupCol+" = ?", group). + Order(order). + Limit(limit). + Find(&rows).Error + if err != nil { + return nil, err + } + + // Reverse to chronological order for PAST → NOW charts. + for i, j := 0, len(rows)-1; i < j; i, j = i+1, j-1 { + rows[i], rows[j] = rows[j], rows[i] + } + + result := make([]GroupAvailabilityRecord, 0, len(rows)) + for _, item := range rows { + result = append(result, GroupAvailabilityRecord{ + CreatedAt: item.CreatedAt, + UseTime: item.UseTime, + Ok: item.Type == LogTypeConsume, + }) + } + return result, nil +} diff --git a/model/log_availability_test.go b/model/log_availability_test.go new file mode 100644 index 000000000000..06cc7a2d2fe1 --- /dev/null +++ b/model/log_availability_test.go @@ -0,0 +1,35 @@ +package model + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGroupAvailabilityRecordJSONWhitelist(t *testing.T) { + t.Parallel() + + typ := reflect.TypeOf(GroupAvailabilityRecord{}) + require.Equal(t, 3, typ.NumField()) + + allowed := map[string]struct{}{ + "created_at": {}, + "use_time": {}, + "ok": {}, + } + forbiddenSubstr := []string{"channel", "token", "username", "request", "other"} + + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + jsonTag := field.Tag.Get("json") + require.NotEmpty(t, jsonTag) + assert.Contains(t, allowed, jsonTag) + lowerName := field.Name + for _, bad := range forbiddenSubstr { + assert.NotContains(t, lowerName, bad) + assert.NotContains(t, jsonTag, bad) + } + } +} diff --git a/router/api-router.go b/router/api-router.go index 83f9259b2132..4a5cc8275126 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -23,6 +23,7 @@ func SetApiRouter(router *gin.Engine) { apiRouter.POST("/setup", anonymousRequestBodyLimit, controller.PostSetup) apiRouter.GET("/status", controller.GetStatus) apiRouter.GET("/uptime/status", controller.GetUptimeKumaStatus) + apiRouter.GET("/extensions/availability", middleware.UserAuth(), controller.GetExtensionsAvailability) apiRouter.GET("/models", middleware.UserAuth(), controller.DashboardListModels) apiRouter.GET("/status/test", middleware.AdminAuth(), controller.TestStatus) apiRouter.GET("/notice", controller.GetNotice) diff --git a/setting/console_setting/availability.go b/setting/console_setting/availability.go new file mode 100644 index 000000000000..134dfbd86bdc --- /dev/null +++ b/setting/console_setting/availability.go @@ -0,0 +1,28 @@ +package console_setting + +// AvailabilityStatusFromSuccessRate maps overall success rate to badge status. +// total == 0 → ok (no data yet). +func AvailabilityStatusFromSuccessRate(successRate float64, total int) string { + if total <= 0 { + return "ok" + } + if successRate >= 0.95 { + return "ok" + } + if successRate >= 0.80 { + return "warn" + } + return "error" +} + +func SummarizeAvailabilityRecords(okCount int, total int, successUseTimeSum int) (successRate float64, avgUseTime float64, status string) { + if total <= 0 { + return 0, 0, "ok" + } + successRate = float64(okCount) / float64(total) + if okCount > 0 { + avgUseTime = float64(successUseTimeSum) / float64(okCount) + } + status = AvailabilityStatusFromSuccessRate(successRate, total) + return successRate, avgUseTime, status +} diff --git a/setting/console_setting/availability_test.go b/setting/console_setting/availability_test.go new file mode 100644 index 000000000000..c95c58b28bfd --- /dev/null +++ b/setting/console_setting/availability_test.go @@ -0,0 +1,58 @@ +package console_setting + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAvailabilityStatusFromSuccessRate(t *testing.T) { + t.Parallel() + + assert.Equal(t, "ok", AvailabilityStatusFromSuccessRate(1, 0)) + assert.Equal(t, "ok", AvailabilityStatusFromSuccessRate(0.95, 100)) + assert.Equal(t, "ok", AvailabilityStatusFromSuccessRate(1, 100)) + assert.Equal(t, "warn", AvailabilityStatusFromSuccessRate(0.949, 100)) + assert.Equal(t, "warn", AvailabilityStatusFromSuccessRate(0.80, 100)) + assert.Equal(t, "error", AvailabilityStatusFromSuccessRate(0.799, 100)) +} + +func TestGetCustomPagesForRoleVisibility(t *testing.T) { + previous := consoleSetting.CustomPages + t.Cleanup(func() { + consoleSetting.CustomPages = previous + }) + + consoleSetting.CustomPages = `[ + {"id":"cp_all","title":"All","icon":"Link","url":"https://a.example.com","enabled":true,"visibility":"all","sort":1}, + {"id":"cp_admin","title":"Admin","icon":"Link","url":"https://b.example.com","enabled":true,"visibility":"admin","sort":2} + ]` + + forAll := GetCustomPagesForRole(false) + assert.Len(t, forAll, 1) + assert.Equal(t, "cp_all", forAll[0]["id"]) + + forAdmin := GetCustomPagesForRole(true) + assert.Len(t, forAdmin, 2) +} + +func TestIsAvailabilityMonitorVisible(t *testing.T) { + previousEnabled := consoleSetting.AvailabilityMonitorEnabled + previousVisibility := consoleSetting.AvailabilityMonitorVisibility + t.Cleanup(func() { + consoleSetting.AvailabilityMonitorEnabled = previousEnabled + consoleSetting.AvailabilityMonitorVisibility = previousVisibility + }) + + consoleSetting.AvailabilityMonitorEnabled = true + consoleSetting.AvailabilityMonitorVisibility = "all" + assert.True(t, IsAvailabilityMonitorVisible(false)) + assert.True(t, IsAvailabilityMonitorVisible(true)) + + consoleSetting.AvailabilityMonitorVisibility = "admin" + assert.False(t, IsAvailabilityMonitorVisible(false)) + assert.True(t, IsAvailabilityMonitorVisible(true)) + + consoleSetting.AvailabilityMonitorEnabled = false + assert.False(t, IsAvailabilityMonitorVisible(true)) +} diff --git a/setting/console_setting/config.go b/setting/console_setting/config.go index 144e95c497be..3f4ae0ab650e 100644 --- a/setting/console_setting/config.go +++ b/setting/console_setting/config.go @@ -3,26 +3,32 @@ package console_setting import "github.com/QuantumNous/new-api/setting/config" type ConsoleSetting struct { - ApiInfo string `json:"api_info"` // 控制台 API 信息 (JSON 数组字符串) - UptimeKumaGroups string `json:"uptime_kuma_groups"` // Uptime Kuma 分组配置 (JSON 数组字符串) - Announcements string `json:"announcements"` // 系统公告 (JSON 数组字符串) - FAQ string `json:"faq"` // 常见问题 (JSON 数组字符串) - ApiInfoEnabled bool `json:"api_info_enabled"` // 是否启用 API 信息面板 - UptimeKumaEnabled bool `json:"uptime_kuma_enabled"` // 是否启用 Uptime Kuma 面板 - AnnouncementsEnabled bool `json:"announcements_enabled"` // 是否启用系统公告面板 - FAQEnabled bool `json:"faq_enabled"` // 是否启用常见问答面板 + ApiInfo string `json:"api_info"` // 控制台 API 信息 (JSON 数组字符串) + UptimeKumaGroups string `json:"uptime_kuma_groups"` // Uptime Kuma 分组配置 (JSON 数组字符串) + Announcements string `json:"announcements"` // 系统公告 (JSON 数组字符串) + FAQ string `json:"faq"` // 常见问题 (JSON 数组字符串) + CustomPages string `json:"custom_pages"` // 拓展定制页面 (JSON 数组字符串) + AvailabilityMonitorEnabled bool `json:"availability_monitor_enabled"` // 是否启用拓展可用性监控 + AvailabilityMonitorVisibility string `json:"availability_monitor_visibility"` // 可用性监控可见范围: all | admin + ApiInfoEnabled bool `json:"api_info_enabled"` // 是否启用 API 信息面板 + UptimeKumaEnabled bool `json:"uptime_kuma_enabled"` // 是否启用 Uptime Kuma 面板 + AnnouncementsEnabled bool `json:"announcements_enabled"` // 是否启用系统公告面板 + FAQEnabled bool `json:"faq_enabled"` // 是否启用常见问答面板 } // 默认配置 var defaultConsoleSetting = ConsoleSetting{ - ApiInfo: "", - UptimeKumaGroups: "", - Announcements: "", - FAQ: "", - ApiInfoEnabled: true, - UptimeKumaEnabled: true, - AnnouncementsEnabled: true, - FAQEnabled: true, + ApiInfo: "", + UptimeKumaGroups: "", + Announcements: "", + FAQ: "", + CustomPages: "[]", + AvailabilityMonitorEnabled: true, + AvailabilityMonitorVisibility: "all", + ApiInfoEnabled: true, + UptimeKumaEnabled: true, + AnnouncementsEnabled: true, + FAQEnabled: true, } // 全局实例 diff --git a/setting/console_setting/custom_pages_test.go b/setting/console_setting/custom_pages_test.go new file mode 100644 index 000000000000..75b22e8ba536 --- /dev/null +++ b/setting/console_setting/custom_pages_test.go @@ -0,0 +1,64 @@ +package console_setting + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateCustomPages(t *testing.T) { + t.Parallel() + + require.NoError(t, ValidateConsoleSettings("[]", "CustomPages")) + require.NoError(t, ValidateConsoleSettings("", "CustomPages")) + + err := ValidateConsoleSettings(`[ + {"id":"cp_a","title":"Docs","icon":"BookOpen","url":"https://example.com","enabled":true,"open_mode":"external","sort":1} + ]`, "CustomPages") + require.NoError(t, err) + + err = ValidateConsoleSettings(`[ + {"id":"cp_a","title":"Docs","url":"https://example.com","enabled":true,"open_mode":"popup"} + ]`, "CustomPages") + require.Error(t, err) + + err = ValidateConsoleSettings(`[ + {"id":"bad id","title":"Docs","url":"https://example.com","enabled":true} + ]`, "CustomPages") + require.Error(t, err) + + err = ValidateConsoleSettings(`[ + {"id":"cp_a","title":"Docs","url":"not-a-url","enabled":true} + ]`, "CustomPages") + require.Error(t, err) + + err = ValidateConsoleSettings(`[ + {"id":"cp_a","title":"Docs","icon":"NotAnIcon","url":"https://example.com","enabled":true} + ]`, "CustomPages") + require.Error(t, err) +} + +func TestGetCustomPagesFiltersAndSorts(t *testing.T) { + previous := consoleSetting.CustomPages + t.Cleanup(func() { + consoleSetting.CustomPages = previous + }) + + consoleSetting.CustomPages = `[ + {"id":"cp_b","title":"B","icon":"Globe","url":"https://b.example.com","enabled":true,"open_mode":"external","sort":2}, + {"id":"cp_off","title":"Off","icon":"Link","url":"https://off.example.com","enabled":false,"sort":0}, + {"id":"cp_a","title":"A","icon":"BookOpen","url":"https://a.example.com","enabled":true,"sort":1}, + {"id":"cp_empty","title":"Empty","icon":"Link","url":"","enabled":true,"sort":0} + ]` + + pages := GetCustomPages() + require.Len(t, pages, 2) + assert.Equal(t, "cp_a", pages[0]["id"]) + assert.Equal(t, "cp_b", pages[1]["id"]) + assert.Equal(t, "BookOpen", pages[0]["icon"]) + assert.Equal(t, "embed", pages[0]["open_mode"]) + assert.Equal(t, "external", pages[1]["open_mode"]) + _, hasSort := pages[0]["sort"] + assert.False(t, hasSort) +} diff --git a/setting/console_setting/validation.go b/setting/console_setting/validation.go index d6e4342c3d8f..50a35bb8585a 100644 --- a/setting/console_setting/validation.go +++ b/setting/console_setting/validation.go @@ -73,11 +73,243 @@ func ValidateConsoleSettings(settingsStr string, settingType string) error { return validateFAQ(settingsStr) case "UptimeKumaGroups": return validateUptimeKumaGroups(settingsStr) + case "CustomPages": + return validateCustomPages(settingsStr) default: return fmt.Errorf("未知的设置类型:%s", settingType) } } +var validCustomPageIcons = map[string]bool{ + "Link": true, "BookOpen": true, "ExternalLink": true, "FileText": true, + "Globe": true, "Layout": true, "Newspaper": true, "HelpCircle": true, + "Bookmark": true, "FolderOpen": true, +} + +var validCustomPageOpenModes = map[string]bool{ + "embed": true, + "external": true, +} + +var validExtensionVisibilities = map[string]bool{ + "all": true, + "admin": true, +} + +func NormalizeExtensionVisibility(visibility string) string { + visibility = strings.TrimSpace(visibility) + if validExtensionVisibilities[visibility] { + return visibility + } + return "all" +} + +func IsAvailabilityMonitorVisible(isAdmin bool) bool { + cs := GetConsoleSetting() + if !cs.AvailabilityMonitorEnabled { + return false + } + visibility := NormalizeExtensionVisibility(cs.AvailabilityMonitorVisibility) + if visibility == "admin" { + return isAdmin + } + return true +} + +func ValidateAvailabilityMonitorVisibility(value string) error { + if !validExtensionVisibilities[strings.TrimSpace(value)] { + return fmt.Errorf("可用性监控可见范围不合法,仅支持 all 或 admin") + } + return nil +} + +func getJSONString(item map[string]interface{}, key string) (string, bool) { + v, ok := item[key].(string) + return v, ok +} + +func getJSONBool(item map[string]interface{}, key string) (bool, bool) { + v, ok := item[key].(bool) + return v, ok +} + +func getJSONSort(item map[string]interface{}) int { + v, exists := item["sort"] + if !exists || v == nil { + return 0 + } + switch n := v.(type) { + case float64: + return int(n) + case int: + return n + case int64: + return int(n) + case json.Number: + i, err := n.Int64() + if err != nil { + return 0 + } + return int(i) + default: + return 0 + } +} + +func validateCustomPages(customPagesStr string) error { + list, err := parseJSONArray(customPagesStr, "定制页面") + if err != nil { + return err + } + + idSet := make(map[string]bool) + for i, page := range list { + id, ok := getJSONString(page, "id") + if !ok || strings.TrimSpace(id) == "" { + return fmt.Errorf("第%d个定制页面缺少id字段", i+1) + } + id = strings.TrimSpace(id) + if len(id) > 64 { + return fmt.Errorf("第%d个定制页面的id长度不能超过64字符", i+1) + } + if !slugRegex.MatchString(id) { + return fmt.Errorf("第%d个定制页面的id只能包含字母、数字、下划线和连字符", i+1) + } + if idSet[id] { + return fmt.Errorf("第%d个定制页面的id与其他项重复", i+1) + } + idSet[id] = true + + title, ok := getJSONString(page, "title") + if !ok || strings.TrimSpace(title) == "" { + return fmt.Errorf("第%d个定制页面缺少标题字段", i+1) + } + title = strings.TrimSpace(title) + if len(title) > 100 { + return fmt.Errorf("第%d个定制页面的标题长度不能超过100字符", i+1) + } + if err := checkDangerousContent(title, i+1, "定制页面"); err != nil { + return err + } + + urlStr, ok := getJSONString(page, "url") + if !ok { + urlStr = "" + } + urlStr = strings.TrimSpace(urlStr) + if urlStr != "" { + if err := validateURL(urlStr, i+1, "定制页面"); err != nil { + return err + } + if len(urlStr) > 500 { + return fmt.Errorf("第%d个定制页面的URL长度不能超过500字符", i+1) + } + } + + icon, ok := getJSONString(page, "icon") + if ok && strings.TrimSpace(icon) != "" { + icon = strings.TrimSpace(icon) + if !validCustomPageIcons[icon] { + return fmt.Errorf("第%d个定制页面的图标不在预设列表中", i+1) + } + } + + if _, exists := page["enabled"]; exists { + if _, ok := getJSONBool(page, "enabled"); !ok { + return fmt.Errorf("第%d个定制页面的enabled字段必须是布尔值", i+1) + } + } + + if openMode, exists := page["open_mode"]; exists && openMode != nil { + openModeStr, ok := openMode.(string) + if !ok || !validCustomPageOpenModes[strings.TrimSpace(openModeStr)] { + return fmt.Errorf("第%d个定制页面的打开方式不合法,仅支持 embed 或 external", i+1) + } + } + + if visibility, exists := page["visibility"]; exists && visibility != nil { + visibilityStr, ok := visibility.(string) + if !ok || !validExtensionVisibilities[strings.TrimSpace(visibilityStr)] { + return fmt.Errorf("第%d个定制页面的可见范围不合法,仅支持 all 或 admin", i+1) + } + } + + if _, exists := page["sort"]; exists && page["sort"] != nil { + switch page["sort"].(type) { + case float64, int, int64, json.Number: + default: + return fmt.Errorf("第%d个定制页面的sort字段必须是数字", i+1) + } + } + } + return nil +} + +// GetCustomPages returns enabled custom pages visible to admins (all visibilities). +func GetCustomPages() []map[string]interface{} { + return GetCustomPagesForRole(true) +} + +// GetCustomPagesForRole returns enabled custom pages with non-empty URLs for the given role. +func GetCustomPagesForRole(isAdmin bool) []map[string]interface{} { + list := getJSONList(GetConsoleSetting().CustomPages) + result := make([]map[string]interface{}, 0, len(list)) + for _, page := range list { + enabled, hasEnabled := getJSONBool(page, "enabled") + if hasEnabled && !enabled { + continue + } + if !hasEnabled { + continue + } + urlStr, _ := getJSONString(page, "url") + urlStr = strings.TrimSpace(urlStr) + if urlStr == "" { + continue + } + visibility, _ := getJSONString(page, "visibility") + visibility = NormalizeExtensionVisibility(visibility) + if visibility == "admin" && !isAdmin { + continue + } + id, _ := getJSONString(page, "id") + title, _ := getJSONString(page, "title") + icon, _ := getJSONString(page, "icon") + icon = strings.TrimSpace(icon) + if icon == "" || !validCustomPageIcons[icon] { + icon = "Link" + } + openMode, _ := getJSONString(page, "open_mode") + openMode = strings.TrimSpace(openMode) + if !validCustomPageOpenModes[openMode] { + openMode = "embed" + } + result = append(result, map[string]interface{}{ + "id": strings.TrimSpace(id), + "title": strings.TrimSpace(title), + "icon": icon, + "url": urlStr, + "open_mode": openMode, + "sort": getJSONSort(page), + }) + } + sort.SliceStable(result, func(i, j int) bool { + si := getJSONSort(result[i]) + sj := getJSONSort(result[j]) + if si != sj { + return si < sj + } + idi, _ := result[i]["id"].(string) + idj, _ := result[j]["id"].(string) + return idi < idj + }) + // Strip sort from public payload + for _, page := range result { + delete(page, "sort") + } + return result +} + func validateApiInfo(apiInfoStr string) error { apiInfoList, err := parseJSONArray(apiInfoStr, "API信息") if err != nil { diff --git a/web/default/src/components/layout/config/system-settings.config.ts b/web/default/src/components/layout/config/system-settings.config.ts index 8469c0278649..3adaf3e5bcfb 100644 --- a/web/default/src/components/layout/config/system-settings.config.ts +++ b/web/default/src/components/layout/config/system-settings.config.ts @@ -16,9 +16,10 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { type TFunction } from 'i18next' +import type { TFunction } from 'i18next' import { Box, + Blocks, CreditCard, Layout, Settings, @@ -80,6 +81,12 @@ function getSystemSettingsNavGroups(t: TFunction): NavGroup[] { icon: Layout, items: getContentSectionNavItems(t), }, + { + title: t('Extensions'), + icon: Blocks, + url: '/system-settings/extensions/pages', + activeUrls: ['/system-settings/extensions'], + }, { title: t('Operations'), icon: Wrench, diff --git a/web/default/src/features/auth/types.ts b/web/default/src/features/auth/types.ts index 21ab480bd189..514feae629e3 100644 --- a/web/default/src/features/auth/types.ts +++ b/web/default/src/features/auth/types.ts @@ -172,6 +172,14 @@ export interface SystemStatus { password_login_enabled?: boolean password_register_enabled?: boolean custom_oauth_providers?: CustomOAuthProviderInfo[] + custom_pages?: Array<{ + id: string + title: string + icon: string + url: string + open_mode?: 'embed' | 'external' + }> + availability_monitor_visible?: boolean [key: string]: unknown } diff --git a/web/default/src/features/extensions/availability/api.ts b/web/default/src/features/extensions/availability/api.ts new file mode 100644 index 000000000000..43bce5a4fd6a --- /dev/null +++ b/web/default/src/features/extensions/availability/api.ts @@ -0,0 +1,49 @@ +/* +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, + 70|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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { api } from '@/lib/api' + +import type { AvailabilityBadgeStatus } from './lib/status' + +export type AvailabilityRecord = { + created_at: number + use_time: number + ok: boolean +} + +export type AvailabilityGroup = { + group: string + records: AvailabilityRecord[] + success_rate: number + avg_use_time: number + status: AvailabilityBadgeStatus + total: number + success_count: number +} + +export type AvailabilityResponse = { + groups: AvailabilityGroup[] +} + +export async function getExtensionsAvailability(): Promise { + const res = await api.get('/api/extensions/availability') + if (!res.data?.success) { + throw new Error(res.data?.message || 'Failed to load availability') + } + return (res.data.data || { groups: [] }) as AvailabilityResponse +} diff --git a/web/default/src/features/extensions/availability/components/availability-group-card.tsx b/web/default/src/features/extensions/availability/components/availability-group-card.tsx new file mode 100644 index 000000000000..404bd43a59c7 --- /dev/null +++ b/web/default/src/features/extensions/availability/components/availability-group-card.tsx @@ -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, + 70|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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useTranslation } from 'react-i18next' + +import { StatusBadge } from '@/components/status-badge' +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@/components/ui/card' + +import type { AvailabilityGroup } from '../api' +import { + formatSuccessRatePercent, + formatUseTimeSeconds, +} from '../lib/status' +import { HeartbeatBars } from './heartbeat-bars' + +type AvailabilityGroupCardProps = { + group: AvailabilityGroup + refreshHint?: string +} + +function badgeForStatus(status: AvailabilityGroup['status']): { + labelKey: string + variant: 'success' | 'warning' | 'danger' +} { + if (status === 'warn') { + return { labelKey: 'Warning', variant: 'warning' } + } + if (status === 'error') { + return { labelKey: 'Abnormal', variant: 'danger' } + } + return { labelKey: 'Normal', variant: 'success' } +} + +export function AvailabilityGroupCard(props: AvailabilityGroupCardProps) { + const { t } = useTranslation() + const badge = badgeForStatus(props.group.status) + + return ( + + +
+ + {props.group.group} + +

+ {t('Recent {{count}} records', { + count: props.group.total, + })} + {props.refreshHint ? ` · ${props.refreshHint}` : null} +

+
+ +
+ +
+
+

+ {t('Avg latency')} +

+

+ {props.group.success_count > 0 + ? formatUseTimeSeconds(props.group.avg_use_time) + : '—'} +

+
+
+

+ {t('Availability')} +

+

+ {formatSuccessRatePercent( + props.group.success_rate, + props.group.total + )} +

+
+
+ +
+
+ ) +} diff --git a/web/default/src/features/extensions/availability/components/heartbeat-bars.tsx b/web/default/src/features/extensions/availability/components/heartbeat-bars.tsx new file mode 100644 index 000000000000..c89641bdbdfa --- /dev/null +++ b/web/default/src/features/extensions/availability/components/heartbeat-bars.tsx @@ -0,0 +1,84 @@ +/* +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, + 70|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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useTranslation } from 'react-i18next' + +import { cn } from '@/lib/utils' + +import type { AvailabilityRecord } from '../api' +import { formatUseTimeSeconds } from '../lib/status' + +type HeartbeatBarsProps = { + records: AvailabilityRecord[] +} + +const MIN_HEIGHT_PCT = 18 +const MAX_HEIGHT_PCT = 100 +const FAIL_HEIGHT_PCT = 28 + +export function HeartbeatBars(props: HeartbeatBarsProps) { + const { t } = useTranslation() + const maxUseTime = props.records.reduce((max, record) => { + if (!record.ok) return max + return Math.max(max, record.use_time) + }, 0) + + return ( +
+
+ {props.records.length === 0 ? ( +

+ {t('No recent requests for this group.')} +

+ ) : ( + props.records.map((record) => { + let heightPct = FAIL_HEIGHT_PCT + if (record.ok) { + if (maxUseTime <= 0) { + heightPct = MIN_HEIGHT_PCT + } else { + heightPct = + MIN_HEIGHT_PCT + + (record.use_time / maxUseTime) * + (MAX_HEIGHT_PCT - MIN_HEIGHT_PCT) + } + } + const title = record.ok + ? formatUseTimeSeconds(record.use_time) + : t('Failed') + return ( +
+ ) + }) + )} +
+
+ {t('Past')} + {t('Now')} +
+
+ ) +} diff --git a/web/default/src/features/extensions/availability/hooks/use-availability.ts b/web/default/src/features/extensions/availability/hooks/use-availability.ts new file mode 100644 index 000000000000..6dd308672e86 --- /dev/null +++ b/web/default/src/features/extensions/availability/hooks/use-availability.ts @@ -0,0 +1,34 @@ +/* +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, + 70|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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useQuery } from '@tanstack/react-query' + +import { getExtensionsAvailability } from '../api' + +const REFRESH_MS = 10_000 + +export function useAvailability() { + return useQuery({ + queryKey: ['extensions-availability'], + queryFn: getExtensionsAvailability, + refetchInterval: REFRESH_MS, + staleTime: REFRESH_MS / 2, + }) +} + +export const AVAILABILITY_REFRESH_SECONDS = REFRESH_MS / 1000 diff --git a/web/default/src/features/extensions/availability/index.tsx b/web/default/src/features/extensions/availability/index.tsx new file mode 100644 index 000000000000..9c8bd2114114 --- /dev/null +++ b/web/default/src/features/extensions/availability/index.tsx @@ -0,0 +1,91 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, + 70|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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { Loader2 } from 'lucide-react' +import { useTranslation } from 'react-i18next' + +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { SectionPageLayout } from '@/components/layout' + +import { AvailabilityGroupCard } from './components/availability-group-card' +import { + AVAILABILITY_REFRESH_SECONDS, + useAvailability, +} from './hooks/use-availability' + +export function AvailabilityMonitorPage() { + const { t } = useTranslation() + const query = useAvailability() + + return ( + + + {t('Availability Monitor')} + + +
+

+ {t( + 'Request health by billing group for the latest 100 consume/error logs. Green bars show latency; red bars are failures. Badge uses overall success rate (≥95% normal, ≥80% warning, below 80% abnormal).' + )} +

+ + {query.isPending ? ( +
+ + + {t('Loading...')} + +
+ ) : null} + + {query.isError ? ( + + {t('Unable to load availability')} + + {query.error instanceof Error + ? query.error.message + : t('Failed to load availability')} + + + ) : null} + + {query.data ? ( +
+ {query.data.groups.map((group) => ( + + ))} +
+ ) : null} + + {query.data && query.data.groups.length === 0 ? ( +

+ {t('No billing groups configured.')} +

+ ) : null} +
+
+
+ ) +} diff --git a/web/default/src/features/extensions/availability/lib/status.test.ts b/web/default/src/features/extensions/availability/lib/status.test.ts new file mode 100644 index 000000000000..2d75a7b0410f --- /dev/null +++ b/web/default/src/features/extensions/availability/lib/status.test.ts @@ -0,0 +1,32 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' + +import { availabilityStatusFromSuccessRate } from './status' + +describe('availabilityStatusFromSuccessRate', () => { + test('maps thresholds', () => { + assert.equal(availabilityStatusFromSuccessRate(1, 0), 'ok') + assert.equal(availabilityStatusFromSuccessRate(0.95, 100), 'ok') + assert.equal(availabilityStatusFromSuccessRate(0.949, 100), 'warn') + assert.equal(availabilityStatusFromSuccessRate(0.8, 100), 'warn') + assert.equal(availabilityStatusFromSuccessRate(0.799, 100), 'error') + }) +}) diff --git a/web/default/src/features/extensions/availability/lib/status.ts b/web/default/src/features/extensions/availability/lib/status.ts new file mode 100644 index 000000000000..12f002d4e4f9 --- /dev/null +++ b/web/default/src/features/extensions/availability/lib/status.ts @@ -0,0 +1,40 @@ +/* +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, + 70|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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +export type AvailabilityBadgeStatus = 'ok' | 'warn' | 'error' + +export function availabilityStatusFromSuccessRate( + successRate: number, + total: number +): AvailabilityBadgeStatus { + if (total <= 0) return 'ok' + if (successRate >= 0.95) return 'ok' + if (successRate >= 0.8) return 'warn' + return 'error' +} + +export function formatUseTimeSeconds(seconds: number): string { + if (!Number.isFinite(seconds) || seconds <= 0) return '<1s' + if (Number.isInteger(seconds)) return `${seconds}s` + return `${seconds.toFixed(1)}s` +} + +export function formatSuccessRatePercent(rate: number, total: number): string { + if (total <= 0) return '—' + return `${(rate * 100).toFixed(2)}%` +} diff --git a/web/default/src/features/home/components/home-html-frame.tsx b/web/default/src/features/home/components/home-html-frame.tsx new file mode 100644 index 000000000000..2c2b210cb60a --- /dev/null +++ b/web/default/src/features/home/components/home-html-frame.tsx @@ -0,0 +1,79 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' + +/** + * Renders admin-configured HomePageContent HTML in a sandboxed iframe. + * + * Isolated Shadow DOM + cloned app stylesheets fights self-contained pages + * (custom + +${html} +` +} diff --git a/web/default/src/features/home/index.tsx b/web/default/src/features/home/index.tsx index c157d352bd0c..fb185399545a 100644 --- a/web/default/src/features/home/index.tsx +++ b/web/default/src/features/home/index.tsx @@ -27,6 +27,7 @@ import { isLikelyHtml } from '@/lib/content-format' import { useAuthStore } from '@/stores/auth-store' import { CTA, Features, Hero, HowItWorks, Stats } from './components' +import { HomeHtmlFrame } from './components/home-html-frame' import { useHomePageContent } from './hooks' export function Home() { @@ -52,11 +53,13 @@ export function Home() { } }, [i18n.language, resolvedTheme]) + const contentIsHtml = !!content && !isUrl && isLikelyHtml(content) + useEffect(() => { - if (isUrl) { + if (isUrl || contentIsHtml) { syncIframePreferences() } - }, [isUrl, syncIframePreferences]) + }, [contentIsHtml, isUrl, syncIframePreferences]) if (!isLoaded) { return ( @@ -92,16 +95,13 @@ export function Home() { ) } - const contentIsHtml = isLikelyHtml(content) - if (contentIsHtml) { return ( - ) diff --git a/web/default/src/features/system-settings/extensions/availability-monitor-section.tsx b/web/default/src/features/system-settings/extensions/availability-monitor-section.tsx new file mode 100644 index 000000000000..aca7c4fb7417 --- /dev/null +++ b/web/default/src/features/system-settings/extensions/availability-monitor-section.tsx @@ -0,0 +1,184 @@ +/* +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, + 70|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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { zodResolver } from '@hookform/resolvers/zod' +import { useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import * as z from 'zod' + +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' + +import { + SettingsForm, + SettingsSwitchContent, + SettingsSwitchItem, +} from '../components/settings-form-layout' +import { SettingsPageFormActions } from '../components/settings-page-context' +import { SettingsSection } from '../components/settings-section' +import { useUpdateOption } from '../hooks/use-update-option' +import { + DEFAULT_EXTENSION_VISIBILITY, + EXTENSION_VISIBILITY_OPTIONS, + resolveExtensionVisibility, +} from './constants' + +const availabilityMonitorSchema = z.object({ + 'console_setting.availability_monitor_enabled': z.boolean(), + 'console_setting.availability_monitor_visibility': z.enum(['all', 'admin']), +}) + +type AvailabilityMonitorFormValues = z.infer + +type AvailabilityMonitorSectionProps = { + defaultValues: AvailabilityMonitorFormValues +} + +export function AvailabilityMonitorSection( + props: AvailabilityMonitorSectionProps +) { + const { t } = useTranslation() + const updateOption = useUpdateOption() + const form = useForm({ + resolver: zodResolver(availabilityMonitorSchema), + defaultValues: { + ...props.defaultValues, + 'console_setting.availability_monitor_visibility': + resolveExtensionVisibility( + props.defaultValues[ + 'console_setting.availability_monitor_visibility' + ] + ), + }, + }) + + useEffect(() => { + form.reset({ + ...props.defaultValues, + 'console_setting.availability_monitor_visibility': + resolveExtensionVisibility( + props.defaultValues[ + 'console_setting.availability_monitor_visibility' + ] || DEFAULT_EXTENSION_VISIBILITY + ), + }) + }, [props.defaultValues, form]) + + const onSubmit = async (values: AvailabilityMonitorFormValues) => { + const updates = Object.entries(values).filter( + ([key, value]) => + value !== + props.defaultValues[key as keyof AvailabilityMonitorFormValues] + ) + + for (const [key, value] of updates) { + await updateOption.mutateAsync({ key, value }) + } + } + + return ( + +
+ + ( + + + {t('Enable availability monitor')} + + {t( + 'Shows a group-level request heartbeat chart under Extensions. Failed requests require ERROR_LOG_ENABLED.' + )} + + + + + + + )} + /> + ( + + {t('Visibility')} + + + {t( + 'Choose who can see the Availability Monitor entry in the Extensions sidebar.' + )} + + + + )} + /> + + +
+
+ ) +} diff --git a/web/default/src/features/system-settings/extensions/constants.ts b/web/default/src/features/system-settings/extensions/constants.ts new file mode 100644 index 000000000000..380593ba45ee --- /dev/null +++ b/web/default/src/features/system-settings/extensions/constants.ts @@ -0,0 +1,120 @@ +/* +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, + 70|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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { + Bookmark, + BookOpen, + ExternalLink, + FileText, + FolderOpen, + Globe, + HelpCircle, + Layout, + Link, + Newspaper, + type LucideIcon, +} from 'lucide-react' + +export const CUSTOM_PAGE_ICON_OPTIONS = [ + { value: 'Link', icon: Link }, + { value: 'BookOpen', icon: BookOpen }, + { value: 'ExternalLink', icon: ExternalLink }, + { value: 'FileText', icon: FileText }, + { value: 'Globe', icon: Globe }, + { value: 'Layout', icon: Layout }, + { value: 'Newspaper', icon: Newspaper }, + { value: 'HelpCircle', icon: HelpCircle }, + { value: 'Bookmark', icon: Bookmark }, + { value: 'FolderOpen', icon: FolderOpen }, +] as const + +export type CustomPageIconName = + (typeof CUSTOM_PAGE_ICON_OPTIONS)[number]['value'] + +export const DEFAULT_CUSTOM_PAGE_ICON: CustomPageIconName = 'Link' + +export const CUSTOM_PAGE_OPEN_MODES = [ + { value: 'embed', labelKey: 'Embed in console' }, + { value: 'external', labelKey: 'Open in new tab' }, +] as const + +export type CustomPageOpenMode = + (typeof CUSTOM_PAGE_OPEN_MODES)[number]['value'] + +export const DEFAULT_CUSTOM_PAGE_OPEN_MODE: CustomPageOpenMode = 'embed' + +export const EXTENSION_VISIBILITY_OPTIONS = [ + { value: 'all', labelKey: 'Everyone' }, + { value: 'admin', labelKey: 'Admins only' }, +] as const + +export type ExtensionVisibility = + (typeof EXTENSION_VISIBILITY_OPTIONS)[number]['value'] + +export const DEFAULT_EXTENSION_VISIBILITY: ExtensionVisibility = 'all' + +const ICON_MAP: Record = Object.fromEntries( + CUSTOM_PAGE_ICON_OPTIONS.map((item) => [item.value, item.icon]) +) + +export function resolveCustomPageIcon( + iconName: string | undefined | null +): LucideIcon { + if (!iconName) return Link + return ICON_MAP[iconName] ?? Link +} + +export function resolveCustomPageOpenMode( + openMode: string | undefined | null +): CustomPageOpenMode { + if (openMode === 'external') return 'external' + return 'embed' +} + +export function resolveExtensionVisibility( + visibility: string | undefined | null +): ExtensionVisibility { + if (visibility === 'admin') return 'admin' + return 'all' +} + +export type CustomPage = { + id: string + title: string + icon: string + url: string + open_mode: CustomPageOpenMode + visibility: ExtensionVisibility + enabled: boolean + sort: number +} + +export type CustomPageStatusItem = { + id: string + title: string + icon: string + url: string + open_mode?: CustomPageOpenMode +} + +export function createCustomPageId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return `cp_${crypto.randomUUID().replaceAll('-', '').slice(0, 16)}` + } + return `cp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` +} diff --git a/web/default/src/features/system-settings/extensions/custom-pages-section.tsx b/web/default/src/features/system-settings/extensions/custom-pages-section.tsx new file mode 100644 index 000000000000..fa958ec9e547 --- /dev/null +++ b/web/default/src/features/system-settings/extensions/custom-pages-section.tsx @@ -0,0 +1,685 @@ +/* +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, + 70|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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { zodResolver } from '@hookform/resolvers/zod' +import { Plus, Save, Trash2 } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import { useForm } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import * as z from 'zod' + +import { StaticDataTable } from '@/components/data-table/static/static-data-table' +import { StaticRowActions } from '@/components/data-table/static/static-row-actions' +import { Dialog } from '@/components/dialog' +import { StatusBadge } from '@/components/status-badge' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' + +import { SettingsSection } from '../components/settings-section' +import { useUpdateOption } from '../hooks/use-update-option' +import { + CUSTOM_PAGE_ICON_OPTIONS, + CUSTOM_PAGE_OPEN_MODES, + DEFAULT_CUSTOM_PAGE_ICON, + DEFAULT_CUSTOM_PAGE_OPEN_MODE, + DEFAULT_EXTENSION_VISIBILITY, + EXTENSION_VISIBILITY_OPTIONS, + createCustomPageId, + resolveCustomPageIcon, + resolveCustomPageOpenMode, + resolveExtensionVisibility, + type CustomPage, +} from './constants' + +type CustomPagesSectionProps = { + data: string +} + +const customPageSchema = z.object({ + title: z + .string() + .min(1, 'Title is required') + .max(100, 'Title must be less than 100 characters'), + icon: z.string().min(1, 'Icon is required'), + url: z + .string() + .trim() + .refine( + (value) => value === '' || /^https?:\/\//i.test(value), + 'URL must start with http:// or https://' + ) + .max(500, 'URL must be less than 500 characters'), + open_mode: z.enum(['embed', 'external']), + visibility: z.enum(['all', 'admin']), + enabled: z.boolean(), + sort: z.number().int(), +}) + +type CustomPageFormValues = z.infer + +const CUSTOM_PAGE_FORM_ID = 'custom-page-form' + +function parseCustomPages(data: string): CustomPage[] { + try { + const parsed = JSON.parse(data || '[]') + if (!Array.isArray(parsed)) return [] + return parsed.map((item, idx) => ({ + id: + typeof item?.id === 'string' && item.id.trim() + ? item.id.trim() + : createCustomPageId(), + title: typeof item?.title === 'string' ? item.title : '', + icon: + typeof item?.icon === 'string' && item.icon.trim() + ? item.icon + : DEFAULT_CUSTOM_PAGE_ICON, + url: typeof item?.url === 'string' ? item.url : '', + open_mode: resolveCustomPageOpenMode(item?.open_mode), + visibility: resolveExtensionVisibility(item?.visibility), + enabled: Boolean(item?.enabled), + sort: Number.isFinite(Number(item?.sort)) ? Number(item.sort) : idx, + })) + } catch { + return [] + } +} + +export function CustomPagesSection(props: CustomPagesSectionProps) { + const { t } = useTranslation() + const updateOption = useUpdateOption() + const [pages, setPages] = useState([]) + const [hasChanges, setHasChanges] = useState(false) + const [selectedIds, setSelectedIds] = useState([]) + const [showDialog, setShowDialog] = useState(false) + const [showDeleteDialog, setShowDeleteDialog] = useState(false) + const [editingPage, setEditingPage] = useState(null) + const [deleteTarget, setDeleteTarget] = useState<'single' | 'batch'>('single') + + const form = useForm({ + resolver: zodResolver(customPageSchema), + defaultValues: { + title: '', + icon: DEFAULT_CUSTOM_PAGE_ICON, + url: '', + open_mode: DEFAULT_CUSTOM_PAGE_OPEN_MODE, + visibility: DEFAULT_EXTENSION_VISIBILITY, + enabled: true, + sort: 0, + }, + }) + + useEffect(() => { + setPages(parseCustomPages(props.data)) + setHasChanges(false) + setSelectedIds([]) + }, [props.data]) + + const sortedPages = useMemo( + () => + [...pages].sort((a, b) => { + if (a.sort !== b.sort) return a.sort - b.sort + return a.id.localeCompare(b.id) + }), + [pages] + ) + + const handleAdd = () => { + setEditingPage(null) + const nextSort = + pages.reduce((max, page) => Math.max(max, page.sort), -1) + 1 + form.reset({ + title: '', + icon: DEFAULT_CUSTOM_PAGE_ICON, + url: '', + open_mode: DEFAULT_CUSTOM_PAGE_OPEN_MODE, + visibility: DEFAULT_EXTENSION_VISIBILITY, + enabled: true, + sort: nextSort, + }) + setShowDialog(true) + } + + const handleEdit = (page: CustomPage) => { + setEditingPage(page) + form.reset({ + title: page.title, + icon: page.icon || DEFAULT_CUSTOM_PAGE_ICON, + url: page.url, + open_mode: page.open_mode || DEFAULT_CUSTOM_PAGE_OPEN_MODE, + visibility: page.visibility || DEFAULT_EXTENSION_VISIBILITY, + enabled: page.enabled, + sort: page.sort, + }) + setShowDialog(true) + } + + const handleDelete = (page: CustomPage) => { + setEditingPage(page) + setDeleteTarget('single') + setShowDeleteDialog(true) + } + + const handleBatchDelete = () => { + if (selectedIds.length === 0) { + toast.error(t('Please select items to delete')) + return + } + setDeleteTarget('batch') + setShowDeleteDialog(true) + } + + const confirmDelete = () => { + if (deleteTarget === 'single' && editingPage) { + setPages((prev) => prev.filter((item) => item.id !== editingPage.id)) + setHasChanges(true) + toast.success( + t('Custom page deleted. Click "Save Settings" to apply.') + ) + } else if (deleteTarget === 'batch') { + setPages((prev) => + prev.filter((item) => !selectedIds.includes(item.id)) + ) + setSelectedIds([]) + setHasChanges(true) + toast.success( + t( + '{{count}} custom pages deleted. Click "Save Settings" to apply.', + { count: selectedIds.length } + ) + ) + } + setShowDeleteDialog(false) + setEditingPage(null) + } + + const handleSubmitForm = (values: CustomPageFormValues) => { + if (editingPage) { + setPages((prev) => + prev.map((item) => + item.id === editingPage.id ? { ...item, ...values } : item + ) + ) + toast.success( + t('Custom page updated. Click "Save Settings" to apply.') + ) + } else { + setPages((prev) => [ + ...prev, + { + id: createCustomPageId(), + ...values, + }, + ]) + toast.success(t('Custom page added. Click "Save Settings" to apply.')) + } + setHasChanges(true) + setShowDialog(false) + } + + const handleSaveAll = async () => { + try { + await updateOption.mutateAsync({ + key: 'console_setting.custom_pages', + value: JSON.stringify(pages), + }) + setHasChanges(false) + toast.success(t('Custom pages saved successfully')) + } catch { + toast.error(t('Failed to save custom pages')) + } + } + + const toggleSelectAll = (checked: boolean) => { + setSelectedIds(checked ? sortedPages.map((item) => item.id) : []) + } + + const toggleSelectOne = (id: string, checked: boolean) => { + setSelectedIds((prev) => + checked ? [...prev, id] : prev.filter((item) => item !== id) + ) + } + + return ( + +
+
+
+ + + +
+
+ +

+ {t( + 'Enabled pages with a URL appear under the Extensions group in the console sidebar and open as embedded pages.' + )} +

+ + page.id} + emptyContent={t( + 'No custom pages yet. Click "Add Custom Page" to create one.' + )} + columns={[ + { + id: 'select', + header: ( + 0 + } + onCheckedChange={toggleSelectAll} + /> + ), + className: 'w-12', + cell: (page) => ( + + toggleSelectOne(page.id, checked as boolean) + } + /> + ), + }, + { + id: 'title', + header: t('Title'), + cellClassName: 'max-w-xs truncate font-medium', + cell: (page) => { + const Icon = resolveCustomPageIcon(page.icon) + return ( + + + {page.title} + + ) + }, + }, + { + id: 'url', + header: t('URL'), + cellClassName: 'text-muted-foreground max-w-md truncate', + cell: (page) => page.url || '—', + }, + { + id: 'open_mode', + header: t('Open mode'), + className: 'w-36', + cell: (page) => + page.open_mode === 'external' + ? t('Open in new tab') + : t('Embed in console'), + }, + { + id: 'visibility', + header: t('Visibility'), + className: 'w-32', + cell: (page) => + page.visibility === 'admin' + ? t('Admins only') + : t('Everyone'), + }, + { + id: 'sort', + header: t('Sort'), + className: 'w-20', + cell: (page) => page.sort, + }, + { + id: 'enabled', + header: t('Status'), + className: 'w-28', + cell: (page) => ( + + ), + }, + { + id: 'actions', + header: t('Actions'), + cell: (page) => ( + handleEdit(page)} + onDelete={() => handleDelete(page)} + /> + ), + }, + ]} + /> +
+ + + + + + } + > +
+ + ( + + {t('Title')} + + + + + {t('Shown in the console sidebar. Maximum 100 characters.')} + + + + )} + /> + ( + + {t('Icon')} + + + + )} + /> + ( + + {t('URL')} + + + + + {t( + 'Must be http(s). Leave empty to keep the page hidden from the sidebar.' + )} + + + + )} + /> + ( + + {t('Open mode')} + + + {t( + 'Use “Open in new tab” for sites that block iframe embedding (for example Taobao / Xianyu short links).' + )} + + + + )} + /> + ( + + {t('Visibility')} + + + {t( + 'Choose who can see this page in the Extensions sidebar.' + )} + + + + )} + /> + ( + + {t('Sort')} + + { + const next = event.target.valueAsNumber + field.onChange(Number.isFinite(next) ? next : 0) + }} + /> + + + {t('Lower numbers appear first in the sidebar.')} + + + + )} + /> + ( + +
+ {t('Enabled')} + + {t( + 'Only enabled pages with a URL are shown in the Extensions sidebar group.' + )} + +
+ + + +
+ )} + /> + + +
+ + + + + {t('Are you sure?')} + + {deleteTarget === 'single' + ? t('This custom page will be removed from the list.') + : t( + '{{count}} custom pages will be removed from the list.', + { count: selectedIds.length } + )} + + + + {t('Cancel')} + + {t('Delete')} + + + + +
+ ) +} diff --git a/web/default/src/features/system-settings/extensions/index.tsx b/web/default/src/features/system-settings/extensions/index.tsx new file mode 100644 index 000000000000..dfeb8dbdfbc3 --- /dev/null +++ b/web/default/src/features/system-settings/extensions/index.tsx @@ -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, + 70|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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { SettingsPage } from '../components/settings-page' +import type { ExtensionsSettings } from '../types' +import { + EXTENSIONS_DEFAULT_SECTION, + getExtensionsSectionContent, + getExtensionsSectionMeta, +} from './section-registry' + +const defaultExtensionsSettings: ExtensionsSettings = { + 'console_setting.custom_pages': '[]', + 'console_setting.availability_monitor_enabled': true, + 'console_setting.availability_monitor_visibility': 'all', +} + +export function ExtensionsSettingsPage() { + return ( + + ) +} diff --git a/web/default/src/features/system-settings/extensions/section-registry.ts b/web/default/src/features/system-settings/extensions/section-registry.ts new file mode 100644 index 000000000000..9c7afc955b9a --- /dev/null +++ b/web/default/src/features/system-settings/extensions/section-registry.ts @@ -0,0 +1,74 @@ +/* +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, + 70|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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { createElement } from 'react' + +import type { ExtensionsSettings } from '../types' +import { createSectionRegistry } from '../utils/section-registry' +import { AvailabilityMonitorSection } from './availability-monitor-section' +import { CustomPagesSection } from './custom-pages-section' +import { + DEFAULT_EXTENSION_VISIBILITY, + resolveExtensionVisibility, +} from './constants' + +const EXTENSIONS_SECTIONS = [ + { + id: 'pages', + titleKey: 'Custom Pages', + build: (settings: ExtensionsSettings) => + createElement(CustomPagesSection, { + data: settings['console_setting.custom_pages'], + }), + }, + { + id: 'availability', + titleKey: 'Availability Monitor', + build: (settings: ExtensionsSettings) => + createElement(AvailabilityMonitorSection, { + defaultValues: { + 'console_setting.availability_monitor_enabled': + settings['console_setting.availability_monitor_enabled'], + 'console_setting.availability_monitor_visibility': + resolveExtensionVisibility( + settings['console_setting.availability_monitor_visibility'] || + DEFAULT_EXTENSION_VISIBILITY + ), + }, + }), + }, +] as const + +export type ExtensionsSectionId = (typeof EXTENSIONS_SECTIONS)[number]['id'] + +const extensionsRegistry = createSectionRegistry< + ExtensionsSectionId, + ExtensionsSettings +>({ + sections: EXTENSIONS_SECTIONS, + defaultSection: 'pages', + basePath: '/system-settings/extensions', + urlStyle: 'path', +}) + +export const EXTENSIONS_SECTION_IDS = extensionsRegistry.sectionIds +export const EXTENSIONS_DEFAULT_SECTION = extensionsRegistry.defaultSection +export const getExtensionsSectionNavItems = + extensionsRegistry.getSectionNavItems +export const getExtensionsSectionContent = extensionsRegistry.getSectionContent +export const getExtensionsSectionMeta = extensionsRegistry.getSectionMeta diff --git a/web/default/src/features/system-settings/hooks/use-update-option.ts b/web/default/src/features/system-settings/hooks/use-update-option.ts index 670ccf9c44c5..3c5be0792d27 100644 --- a/web/default/src/features/system-settings/hooks/use-update-option.ts +++ b/web/default/src/features/system-settings/hooks/use-update-option.ts @@ -37,6 +37,9 @@ const STATUS_RELATED_KEYS = [ 'general_setting.quota_display_type', 'general_setting.custom_currency_symbol', 'general_setting.custom_currency_exchange_rate', + 'console_setting.custom_pages', + 'console_setting.availability_monitor_enabled', + 'console_setting.availability_monitor_visibility', ] export function useUpdateOption() { diff --git a/web/default/src/features/system-settings/types.ts b/web/default/src/features/system-settings/types.ts index 11c51f08adc3..b54c8857ad02 100644 --- a/web/default/src/features/system-settings/types.ts +++ b/web/default/src/features/system-settings/types.ts @@ -187,6 +187,12 @@ export type ContentSettings = { MjActionCheckSuccessEnabled: boolean } +export type ExtensionsSettings = { + 'console_setting.custom_pages': string + 'console_setting.availability_monitor_enabled': boolean + 'console_setting.availability_monitor_visibility': string +} + export type ModelSettings = { 'global.pass_through_request_enabled': boolean 'global.thinking_model_blacklist': string diff --git a/web/default/src/hooks/use-sidebar-data.ts b/web/default/src/hooks/use-sidebar-data.ts index 40a0615aa347..31a88c18df09 100644 --- a/web/default/src/hooks/use-sidebar-data.ts +++ b/web/default/src/hooks/use-sidebar-data.ts @@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com */ import { Activity, + ActivitySquare, Box, CreditCard, FileText, @@ -34,9 +35,15 @@ import { Users, Wallet, } from 'lucide-react' +import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { type SidebarData } from '@/components/layout/types' +import type { NavGroup, NavItem, SidebarData } from '@/components/layout/types' +import { + resolveCustomPageIcon, + type CustomPageStatusItem, +} from '@/features/system-settings/extensions/constants' +import { useStatus } from '@/hooks/use-status' import { ROLE } from '@/lib/roles' /** @@ -47,117 +54,153 @@ import { ROLE } from '@/lib/roles' */ export function useSidebarData(): SidebarData { const { t } = useTranslation() + const { status } = useStatus() + + const extensionsGroup = useMemo((): NavGroup | null => { + const pages = (status?.custom_pages ?? + status?.data?.custom_pages) as CustomPageStatusItem[] | undefined + const monitorVisible = Boolean( + status?.availability_monitor_visible ?? + status?.data?.availability_monitor_visible + ) + const items: NavItem[] = [] + if (monitorVisible) { + items.push({ + title: t('Availability Monitor'), + url: '/extensions/availability', + icon: ActivitySquare, + }) + } + if (Array.isArray(pages)) { + for (const page of pages) { + items.push({ + title: page.title, + url: `/custom-pages/${page.id}`, + icon: resolveCustomPageIcon(page.icon), + }) + } + } + if (items.length === 0) { + return null + } + return { + id: 'extensions', + title: t('Extensions'), + items, + } + }, [status, t]) + + const navGroups: NavGroup[] = [ + { + id: 'chat', + title: t('Chat'), + items: [ + { + title: t('Playground'), + url: '/playground', + icon: FlaskConical, + }, + { + title: t('Chat'), + icon: MessageSquare, + type: 'chat-presets', + }, + ], + }, + { + id: 'general', + title: t('General'), + items: [ + { + title: t('Overview'), + url: '/dashboard/overview', + icon: Activity, + }, + { + title: t('Dashboard'), + url: '/dashboard/models', + icon: LayoutDashboard, + }, + { + title: t('API Keys'), + url: '/keys', + icon: Key, + }, + { + title: t('Usage Logs'), + url: '/usage-logs/common', + icon: FileText, + }, + { + title: t('Task Logs'), + url: '/usage-logs/task', + activeUrls: ['/usage-logs/drawing'], + configUrls: ['/usage-logs/drawing', '/usage-logs/task'], + icon: ListTodo, + }, + ], + }, + { + id: 'personal', + title: t('Personal'), + items: [ + { + title: t('Wallet'), + url: '/wallet', + icon: Wallet, + }, + { + title: t('Profile'), + url: '/profile', + icon: User, + }, + ], + }, + ...(extensionsGroup ? [extensionsGroup] : []), + { + id: 'admin', + title: t('Admin'), + items: [ + { + title: t('Channels'), + url: '/channels', + icon: Radio, + }, + { + title: t('Models'), + url: '/models/metadata', + icon: Box, + }, + { + title: t('Users'), + url: '/users', + icon: Users, + }, + { + title: t('Redemption Codes'), + url: '/redemption-codes', + icon: Ticket, + }, + { + title: t('Subscriptions'), + url: '/subscriptions', + icon: CreditCard, + }, + { + title: t('System Info'), + url: '/system-info', + icon: ServerCog, + requiredRole: ROLE.SUPER_ADMIN, + }, + { + title: t('System Settings'), + url: '/system-settings/site', + activeUrls: ['/system-settings'], + icon: Settings, + }, + ], + }, + ] - return { - navGroups: [ - { - id: 'chat', - title: t('Chat'), - items: [ - { - title: t('Playground'), - url: '/playground', - icon: FlaskConical, - }, - { - title: t('Chat'), - icon: MessageSquare, - type: 'chat-presets', - }, - ], - }, - { - id: 'general', - title: t('General'), - items: [ - { - title: t('Overview'), - url: '/dashboard/overview', - icon: Activity, - }, - { - title: t('Dashboard'), - url: '/dashboard/models', - icon: LayoutDashboard, - }, - { - title: t('API Keys'), - url: '/keys', - icon: Key, - }, - { - title: t('Usage Logs'), - url: '/usage-logs/common', - icon: FileText, - }, - { - title: t('Task Logs'), - url: '/usage-logs/task', - activeUrls: ['/usage-logs/drawing'], - configUrls: ['/usage-logs/drawing', '/usage-logs/task'], - icon: ListTodo, - }, - ], - }, - { - id: 'personal', - title: t('Personal'), - items: [ - { - title: t('Wallet'), - url: '/wallet', - icon: Wallet, - }, - { - title: t('Profile'), - url: '/profile', - icon: User, - }, - ], - }, - { - id: 'admin', - title: t('Admin'), - items: [ - { - title: t('Channels'), - url: '/channels', - icon: Radio, - }, - { - title: t('Models'), - url: '/models/metadata', - icon: Box, - }, - { - title: t('Users'), - url: '/users', - icon: Users, - }, - { - title: t('Redemption Codes'), - url: '/redemption-codes', - icon: Ticket, - }, - { - title: t('Subscriptions'), - url: '/subscriptions', - icon: CreditCard, - }, - { - title: t('System Info'), - url: '/system-info', - icon: ServerCog, - requiredRole: ROLE.SUPER_ADMIN, - }, - { - title: t('System Settings'), - url: '/system-settings/site', - activeUrls: ['/system-settings'], - icon: Settings, - }, - ], - }, - ], - } + return { navGroups } } diff --git a/web/default/src/i18n/locales/_reports/_sync-report.json b/web/default/src/i18n/locales/_reports/_sync-report.json index ba41ffbe288d..493f824fcfd6 100644 --- a/web/default/src/i18n/locales/_reports/_sync-report.json +++ b/web/default/src/i18n/locales/_reports/_sync-report.json @@ -33,7 +33,7 @@ }, "zh-TW": { "file": "zh-TW.json", - "missingCount": 0, + "missingCount": 22, "extrasCount": 0, "untranslatedCount": 0 }, diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 504e29876f64..811c0683ebb2 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -38,6 +38,8 @@ "{{count}} channel(s) enabled": "{{count}} channel(s) enabled", "{{count}} channel(s) failed to disable": "{{count}} channel(s) failed to disable", "{{count}} channel(s) failed to enable": "{{count}} channel(s) failed to enable", + "{{count}} custom pages deleted. Click \"Save Settings\" to apply.": "{{count}} custom pages deleted. Click \"Save Settings\" to apply.", + "{{count}} custom pages will be removed from the list.": "{{count}} custom pages will be removed from the list.", "{{count}} days ago": "{{count}} days ago", "{{count}} days remaining": "{{count}} days remaining", "{{count}} disabled channel(s) deleted": "{{count}} disabled channel(s) deleted", @@ -118,6 +120,7 @@ "80,443,8080": "80,443,8080", "A billing multiplier. Lower ratios mean lower API call costs.": "A billing multiplier. Lower ratios mean lower API call costs.", "A focused home for keys, balance, routing, and service health.": "A focused home for keys, balance, routing, and service health.", + "Abnormal": "Abnormal", "About": "About", "About {{days}} days left": "About {{days}} days left", "Accept Unpriced Models": "Accept Unpriced Models", @@ -174,6 +177,7 @@ "Add Condition": "Add Condition", "Add credits": "Add credits", "Add custom model \"{{value}}\"": "Add custom model \"{{value}}\"", + "Add Custom Page": "Add Custom Page", "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", @@ -239,6 +243,7 @@ "Administer user accounts and roles.": "Administer user accounts and roles.", "Administrator account": "Administrator account", "Administrator username": "Administrator username", + "Admins only": "Admins only", "Advance next reset time": "Advance next reset time", "Advanced": "Advanced", "Advanced Configuration": "Advanced Configuration", @@ -520,7 +525,9 @@ "Automatically replaces upstream callback URLs with the server address.": "Automatically replaces upstream callback URLs with the server address.", "Automatically selects the best available group with circuit breaker mechanism": "Automatically selects the best available group with circuit breaker mechanism", "Automatically sync model list when upstream changes are detected": "Automatically sync model list when upstream changes are detected", + "Availability": "Availability", "Availability (last 24h)": "Availability (last 24h)", + "Availability Monitor": "Availability Monitor", "Available": "Available", "Available credits are ordered by soonest expiration.": "Available credits are ordered by soonest expiration.", "Available disk space": "Available disk space", @@ -535,6 +542,7 @@ "Average tokens per second sustained per group": "Average tokens per second sustained per group", "Average TPM": "Average TPM", "Average TTFT": "Average TTFT", + "Avg latency": "Avg latency", "AWS": "AWS", "AWS Bedrock Claude Compat": "AWS Bedrock Claude Compat", "AWS Key Format": "AWS Key Format", @@ -814,6 +822,8 @@ "Choose the default charts, range, and time granularity for model analytics.": "Choose the default charts, range, and time granularity for model analytics.", "Choose where to fetch upstream metadata.": "Choose where to fetch upstream metadata.", "Choose which charts are selected by default when opening model analytics.": "Choose which charts are selected by default when opening model analytics.", + "Choose who can see the Availability Monitor entry in the Extensions sidebar.": "Choose who can see the Availability Monitor entry in the Extensions sidebar.", + "Choose who can see this page in the Extensions sidebar.": "Choose who can see this page in the Extensions sidebar.", "Clamped to": "Clamped to", "Classic (Legacy Frontend)": "Classic (Legacy Frontend)", "Claude": "Claude", @@ -975,6 +985,8 @@ "Configure rate limiting rules for a specific user group.": "Configure rate limiting rules for a specific user group.", "Configure routes": "Configure routes", "Configure the ratio for this group.": "Configure the ratio for this group.", + "Configure the sidebar title, icon, embed URL, status, and sort order.": "Configure the sidebar title, icon, embed URL, status, and sort order.", + "Configure the sidebar title, icon, URL, open mode, status, and sort order.": "Configure the sidebar title, icon, URL, open mode, status, and sort order.", "Configure upstream providers and routing.": "Configure upstream providers and routing.", "Configure Waffo Pancake hosted checkout integration for USD-priced top-ups": "Configure Waffo Pancake hosted checkout integration for USD-priced top-ups", "Configure Waffo payment aggregation platform integration": "Configure Waffo payment aggregation platform integration", @@ -1215,6 +1227,12 @@ "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.", "Custom OAuth": "Custom OAuth", "Custom OAuth Providers": "Custom OAuth Providers", + "Custom page added. Click \"Save Settings\" to apply.": "Custom page added. Click \"Save Settings\" to apply.", + "Custom page deleted. Click \"Save Settings\" to apply.": "Custom page deleted. Click \"Save Settings\" to apply.", + "Custom page not found": "Custom page not found", + "Custom page updated. Click \"Save Settings\" to apply.": "Custom page updated. Click \"Save Settings\" to apply.", + "Custom Pages": "Custom Pages", + "Custom pages saved successfully": "Custom pages saved successfully", "Custom Seconds": "Custom Seconds", "Custom sidebar section": "Custom sidebar section", "Custom Time Range": "Custom Time Range", @@ -1431,6 +1449,7 @@ "Do string replacement in the target field": "Do string replacement in the target field", "Do you want to download the created redemption codes as a text file?": "Do you want to download the created redemption codes as a text file?", "Docs": "Docs", + "Documentation": "Documentation", "Documentation Link": "Documentation Link", "Documentation or external knowledge base.": "Documentation or external knowledge base.", "does not exist or might have been removed.": "does not exist or might have been removed.", @@ -1523,6 +1542,7 @@ "Edit Channel": "Edit Channel", "Edit channel routing": "Edit channel routing", "Edit chat preset": "Edit chat preset", + "Edit Custom Page": "Edit Custom Page", "Edit discount tier": "Edit discount tier", "Edit FAQ": "Edit FAQ", "Edit group": "Edit group", @@ -1559,6 +1579,7 @@ "Email Field": "Email Field", "Email Verification": "Email Verification", "Email, summarisation, knowledge work": "Email, summarisation, knowledge work", + "Embed in console": "Embed in console", "Embeddings": "Embeddings", "Empty": "Empty", "Empty value will be saved as {}.": "Empty value will be saved as {}.", @@ -1566,6 +1587,7 @@ "Enable {{parameter}}": "Enable {{parameter}}", "Enable 2FA": "Enable 2FA", "Enable All": "Enable All", + "Enable availability monitor": "Enable availability monitor", "Enable check-in feature": "Enable check-in feature", "Enable Data Dashboard": "Enable Data Dashboard", "Enable demo mode with limited functionality": "Enable demo mode with limited functionality", @@ -1608,6 +1630,7 @@ "Enabled": "Enabled", "Enabled all channels with tag: {{tag}}": "Enabled all channels with tag: {{tag}}", "Enabled channels with tag {{tag}}": "Enabled channels with tag {{tag}}", + "Enabled pages with a URL appear under the Extensions group in the console sidebar and open as embedded pages.": "Enabled pages with a URL appear under the Extensions group in the console sidebar and open as embedded pages.", "Enabled Status": "Enabled Status", "Enabling...": "Enabling...", "Encourages introducing new topics": "Encourages introducing new topics", @@ -1723,6 +1746,7 @@ "Estimated cost": "Estimated cost", "Estimated quota cost": "Estimated quota cost", "Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.": "Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.", + "Everyone": "Everyone", "Everything configured for this group, in one place.": "Everything configured for this group, in one place.", "Exact": "Exact", "Exact Match": "Exact Match", @@ -1767,6 +1791,7 @@ "Extend deployment": "Extend deployment", "Extend failed": "Extend failed", "Extended successfully": "Extended successfully", + "Extensions": "Extensions", "External Device": "External Device", "External link for users to purchase quota": "External link for users to purchase quota", "External operations": "External operations", @@ -1845,6 +1870,7 @@ "Failed to initialize system": "Failed to initialize system", "Failed to load": "Failed to load", "Failed to load API keys": "Failed to load API keys", + "Failed to load availability": "Failed to load availability", "Failed to load billing history": "Failed to load billing history", "Failed to load enabled models": "Failed to load enabled models", "Failed to load home page content": "Failed to load home page content", @@ -1876,6 +1902,7 @@ "Failed to save": "Failed to save", "Failed to save announcements": "Failed to save announcements", "Failed to save API info": "Failed to save API info", + "Failed to save custom pages": "Failed to save custom pages", "Failed to save FAQ": "Failed to save FAQ", "Failed to save Uptime Kuma groups": "Failed to save Uptime Kuma groups", "Failed to search API keys": "Failed to search API keys", @@ -2532,6 +2559,7 @@ "Logs": "Logs", "Look for a special ratio rule matching this user group and this billing group. If one exists, use its ratio. Otherwise use the billing group base ratio from the pricing table.": "Look for a special ratio rule matching this user group and this billing group. If one exists, use its ratio. Otherwise use the billing group base ratio from the pricing table.", "Low balance": "Low balance", + "Lower numbers appear first in the sidebar.": "Lower numbers appear first in the sidebar.", "Lowest median first-token latency": "Lowest median first-token latency", "m": "m", "Maintenance": "Maintenance", @@ -2776,6 +2804,7 @@ "Multipliers for recharge pricing based on user groups.": "Multipliers for recharge pricing based on user groups.", "Must be a valid URL": "Must be a valid URL", "Must be at least 8 characters": "Must be at least 8 characters", + "Must be http(s). Leave empty to keep the page hidden from the sidebar.": "Must be http(s). Leave empty to keep the page hidden from the sidebar.", "My Subscriptions": "My Subscriptions", "my-status": "my-status", "MySQL detected": "MySQL detected", @@ -2846,6 +2875,7 @@ "No available Web chat links": "No available Web chat links", "No backup": "No backup", "No base input price": "No base input price", + "No billing groups configured.": "No billing groups configured.", "No billing records found": "No billing records found", "No capabilities reported for this model.": "No capabilities reported for this model.", "No Change": "No Change", @@ -2867,6 +2897,7 @@ "No containers": "No containers", "No content to copy": "No content to copy", "No custom OAuth providers configured yet.": "No custom OAuth providers configured yet.", + "No custom pages yet. Click \"Add Custom Page\" to create one.": "No custom pages yet. Click \"Add Custom Page\" to create one.", "No data": "No data", "No Data": "No Data", "No data available": "No data available", @@ -2950,6 +2981,7 @@ "No providers available": "No providers available", "No Quota": "No Quota", "No ratio differences found": "No ratio differences found", + "No recent requests for this group.": "No recent requests for this group.", "No recent usage": "No recent usage", "No records found. Try adjusting your filters.": "No records found. Try adjusting your filters.", "No redemption codes available. Create your first redemption code to get started.": "No redemption codes available. Create your first redemption code to get started.", @@ -3001,6 +3033,7 @@ "Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.", "None": "None", "noreply@example.com": "noreply@example.com", + "Normal": "Normal", "Normalized:": "Normalized:", "Not available": "Not available", "Not backed up": "Not backed up", @@ -3021,6 +3054,7 @@ "Notification Email": "Notification Email", "Notification Method": "Notification Method", "Notifications": "Notifications", + "Now": "Now", "Now a user whose user group is vip creates tokens with different groups and makes one call with each:": "Now a user whose user group is vip creates tokens with different groups and makes one call with each:", "Nucleus sampling probability mass": "Nucleus sampling probability mass", "Number of codes to create": "Number of codes to create", @@ -3081,6 +3115,7 @@ "Only available for admins. When enabled, you will receive a summary notification via your selected method when the scheduled model check detects upstream model changes or check failures.": "Only available for admins. When enabled, you will receive a summary notification via your selected method when the scheduled model check detects upstream model changes or check failures.", "Only configured combinations are overridden. All other calls keep the billing group base ratio.": "Only configured combinations are overridden. All other calls keep the billing group base ratio.", "Only configured combinations are overridden. All other calls keep the token group base ratio.": "Only configured combinations are overridden. All other calls keep the token group base ratio.", + "Only enabled pages with a URL are shown in the Extensions sidebar group.": "Only enabled pages with a URL are shown in the Extensions sidebar group.", "Only enabled parameters are sent with the request.": "Only enabled parameters are sent with the request.", "Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.", "Only Mine": "Only Mine", @@ -3098,6 +3133,7 @@ "Open in new tab": "Open in new tab", "Open in New Tab": "Open in New Tab", "Open menu": "Open menu", + "Open mode": "Open mode", "Open release": "Open release", "Open source": "Open source", "Open Source": "Open Source", @@ -3264,6 +3300,7 @@ "Password reset: {{password}}": "Password reset: {{password}}", "Passwords do not match": "Passwords do not match", "Passwords don't match.": "Passwords don't match.", + "Past": "Past", "Paste Connection Info": "Paste Connection Info", "Path": "Path", "Path not set": "Path not set", @@ -3624,6 +3661,7 @@ "Receive Upstream Model Update Notifications": "Receive Upstream Model Update Notifications", "Received": "Received", "Received amount": "Received amount", + "Recent {{count}} records": "Recent {{count}} records", "Recent maintenance tasks running across instances and their execution status.": "Recent maintenance tasks running across instances and their execution status.", "Recently completed or failed system task runs.": "Recently completed or failed system task runs.", "Recently launched models": "Recently launched models", @@ -3672,6 +3710,7 @@ "Refresh Cache": "Refresh Cache", "Refresh credential": "Refresh credential", "Refresh details": "Refresh details", + "Refresh every {{seconds}}s": "Refresh every {{seconds}}s", "Refresh failed": "Refresh failed", "Refresh interval (minutes)": "Refresh interval (minutes)", "Refresh Stats": "Refresh Stats", @@ -3759,6 +3798,7 @@ "Request Header Field": "Request Header Field", "Request Header Override": "Request Header Override", "Request Header Overrides": "Request Header Overrides", + "Request health by billing group for the latest 100 consume/error logs. Green bars show latency; red bars are failures. Badge uses overall success rate (≥95% normal, ≥80% warning, below 80% abnormal).": "Request health by billing group for the latest 100 consume/error logs. Green bars show latency; red bars are failures. Badge uses overall success rate (≥95% normal, ≥80% warning, below 80% abnormal).", "Request ID": "Request ID", "Request Limits": "Request Limits", "Request Model": "Request Model", @@ -4024,6 +4064,7 @@ "Select all (filtered)": "Select all (filtered)", "Select all models": "Select all models", "Select All Visible": "Select All Visible", + "Select an icon": "Select an icon", "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.", @@ -4057,6 +4098,7 @@ "Select models or add custom ones": "Select models or add custom ones", "Select models to process. Unselected \"add\" models will be ignored.": "Select models to process. Unselected \"add\" models will be ignored.", "Select models to run batch tests.": "Select models to run batch tests.", + "Select open mode": "Select open mode", "Select or enter color value": "Select or enter color value", "Select or enter method identifier": "Select or enter method identifier", "Select or enter model name": "Select or enter model name", @@ -4082,6 +4124,7 @@ "Select theme preset": "Select theme preset", "Select time granularity": "Select time granularity", "Select vendor": "Select vendor", + "Select visibility": "Select visibility", "Selectable groups": "Selectable groups", "selected": "selected", "Selected {{count}}": "Selected {{count}}", @@ -4165,6 +4208,8 @@ "Showcase core capabilities with demo credentials and limited access.": "Showcase core capabilities with demo credentials and limited access.", "Showing": "Showing", "showing •": "showing •", + "Shown in the console sidebar. Maximum 100 characters.": "Shown in the console sidebar. Maximum 100 characters.", + "Shows a group-level request heartbeat chart under Extensions. Failed requests require ERROR_LOG_ENABLED.": "Shows a group-level request heartbeat chart under Extensions. Failed requests require ERROR_LOG_ENABLED.", "Sidebar": "Sidebar", "Sidebar collapsed by default for new users": "Sidebar collapsed by default for new users", "Sidebar modules": "Sidebar modules", @@ -4470,6 +4515,7 @@ "The name displayed across the application": "The name displayed across the application", "The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations": "The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations", "The requested chat preset does not exist or has been removed.": "The requested chat preset does not exist or has been removed.", + "The requested page does not exist, is disabled, or has no URL configured.": "The requested page does not exist, is disabled, or has no URL configured.", "The reset request stays disabled until a credit is available.": "The reset request stays disabled until a credit is available.", "The setup wizard will use this database during initialization.": "The setup wizard will use this database during initialization.", "The site is not available at the moment.": "The site is not available at the moment.", @@ -4508,6 +4554,7 @@ "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 custom page will be removed from the list.": "This custom page will be removed from the list.", "This data may be unreliable, use with caution": "This data may be unreliable, use with caution", "This device does not support Passkey": "This device does not support Passkey", "This device does not support Passkey verification.": "This device does not support Passkey verification.", @@ -4527,6 +4574,7 @@ "This model is not available in any group, or no group pricing information is configured.": "This model is not available in any group, or no group pricing information is configured.", "This month": "This month", "This page has not been created yet.": "This page has not been created yet.", + "This page opens in a new browser tab because the target site cannot be embedded.": "This page opens in a new browser tab because the target site cannot be embedded.", "This plan does not allow balance redemption": "This plan does not allow balance redemption", "This project must be used in compliance with the": "This project must be used in compliance with the", "This removes {{count}} failed models from this channel. This action cannot be undone.": "This removes {{count}} failed models from this channel. This action cannot be undone.", @@ -4581,6 +4629,7 @@ "times": "times", "Timing": "Timing", "Tip": "Tip", + "Title": "Title", "to access this resource.": "to access this resource.", "To Anthropic Messages": "To Anthropic Messages", "to confirm": "to confirm", @@ -4739,6 +4788,7 @@ "UI granularity only — data is still aggregated hourly": "UI granularity only — data is still aggregated hourly", "Unable to estimate price for this deployment.": "Unable to estimate price for this deployment.", "Unable to generate chat link. Please contact your administrator.": "Unable to generate chat link. Please contact your administrator.", + "Unable to load availability": "Unable to load availability", "Unable to load groups": "Unable to load groups", "Unable to load rankings": "Unable to load rankings", "Unable to load rankings data": "Unable to load rankings data", @@ -4869,6 +4919,7 @@ "USD Exchange Rate": "USD Exchange Rate", "USD price per 1M input tokens.": "USD price per 1M input tokens.", "USD price per 1M tokens.": "USD price per 1M tokens.", + "Use “Open in new tab” for sites that block iframe embedding (for example Taobao / Xianyu short links).": "Use “Open in new tab” for sites that block iframe embedding (for example Taobao / Xianyu short links).", "Use +: to add a group, -: to remove a default selectable group, or no prefix to append a group.": "Use +: to add a group, -: to remove a default selectable group, or no prefix to append a group.", "Use a compatible browser or device with biometric authentication or a security key to register a Passkey.": "Use a compatible browser or device with biometric authentication or a security key to register a Passkey.", "Use a different stable value for each instance, then restart the service.": "Use a different stable value for each instance, then restart the service.", @@ -5021,6 +5072,7 @@ "Violation Marker": "Violation Marker", "vip": "vip", "VIP users with premium access": "VIP users with premium access", + "Visibility": "Visibility", "Visible": "Visible", "Vision": "Vision", "Vision, image / video, document chat": "Vision, image / video, document chat", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 05cc2d5e9d05..d0443d8e30c6 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -38,6 +38,8 @@ "{{count}} channel(s) enabled": "{{count}} canal(canaux) activé(s)", "{{count}} channel(s) failed to disable": "{{count}} canal(canaux) n'ont pas pu être désactivé(s)", "{{count}} channel(s) failed to enable": "{{count}} canal(canaux) n'ont pas pu être activé(s)", + "{{count}} custom pages deleted. Click \"Save Settings\" to apply.": "{{count}} pages supprimées. Cliquez sur « Enregistrer » pour appliquer.", + "{{count}} custom pages will be removed from the list.": "{{count}} pages personnalisées seront retirées de la liste.", "{{count}} days ago": "il y a {{count}} jours", "{{count}} days remaining": "{{count}} days remaining", "{{count}} disabled channel(s) deleted": "{{count}} canal(canaux) désactivé(s) supprimé(s)", @@ -118,6 +120,7 @@ "80,443,8080": "80,443,8080", "A billing multiplier. Lower ratios mean lower API call costs.": "Un multiplicateur de facturation. Plus le ratio est faible, plus le coût des appels API est bas.", "A focused home for keys, balance, routing, and service health.": "Un accueil dédié aux clés, au solde, au routage et à l'état du service.", + "Abnormal": "Anormal", "About": "À propos", "About {{days}} days left": "Environ {{days}} jours restants", "Accept Unpriced Models": "Accepter les modèles non tarifés", @@ -174,6 +177,7 @@ "Add Condition": "Ajouter une condition", "Add credits": "Ajouter des crédits", "Add custom model \"{{value}}\"": "Ajouter le modèle personnalisé « {{value}} »", + "Add Custom Page": "Ajouter une page", "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", @@ -239,6 +243,7 @@ "Administer user accounts and roles.": "Gérer les comptes d'utilisateurs et les rôles.", "Administrator account": "Compte administrateur", "Administrator username": "Nom d'utilisateur administrateur", + "Admins only": "Admins seulement", "Advance next reset time": "Avancer la prochaine réinitialisation", "Advanced": "Avancé", "Advanced Configuration": "Configuration avancée", @@ -520,7 +525,9 @@ "Automatically replaces upstream callback URLs with the server address.": "Remplace automatiquement les URL des callbacks en amont par l'adresse du serveur.", "Automatically selects the best available group with circuit breaker mechanism": "Sélectionne automatiquement le meilleur groupe disponible avec un mécanisme de disjoncteur de circuit", "Automatically sync model list when upstream changes are detected": "Synchroniser automatiquement la liste des modèles lorsque des changements en amont sont détectés", + "Availability": "Disponibilité", "Availability (last 24h)": "Disponibilité (dernières 24 h)", + "Availability Monitor": "Surveillance de disponibilité", "Available": "Disponible", "Available credits are ordered by soonest expiration.": "Les crédits disponibles sont triés par expiration la plus proche.", "Available disk space": "Espace disque disponible", @@ -535,6 +542,7 @@ "Average tokens per second sustained per group": "Tokens par seconde soutenus en moyenne par groupe", "Average TPM": "TPM moyen", "Average TTFT": "TTFT moyen", + "Avg latency": "Latence moyenne", "AWS": "AWS", "AWS Bedrock Claude Compat": "AWS Bedrock Claude Compat", "AWS Key Format": "Format de clé AWS", @@ -814,6 +822,8 @@ "Choose the default charts, range, and time granularity for model analytics.": "Choisissez les graphiques, la plage et la granularité temporelle par défaut pour l'analyse des modèles.", "Choose where to fetch upstream metadata.": "Choisissez où récupérer les métadonnées amont.", "Choose which charts are selected by default when opening model analytics.": "Choisissez les graphiques sélectionnés par défaut à l'ouverture de l'analyse des modèles.", + "Choose who can see the Availability Monitor entry in the Extensions sidebar.": "Choisissez qui voit la surveillance dans Extensions.", + "Choose who can see this page in the Extensions sidebar.": "Choisissez qui voit cette page dans Extensions.", "Clamped to": "Limité à", "Classic (Legacy Frontend)": "Classique (Ancien frontend)", "Claude": "Claude", @@ -975,6 +985,8 @@ "Configure rate limiting rules for a specific user group.": "Configurer les règles de limitation de débit pour un groupe d'utilisateurs spécifique.", "Configure routes": "Configurer les routes", "Configure the ratio for this group.": "Configurer le ratio pour ce groupe.", + "Configure the sidebar title, icon, embed URL, status, and sort order.": "Configurez le titre, l’icône, l’URL intégrée, le statut et l’ordre.", + "Configure the sidebar title, icon, URL, open mode, status, and sort order.": "Configurez le titre, l’icône, l’URL, le mode d’ouverture, le statut et l’ordre.", "Configure upstream providers and routing.": "Configurer les fournisseurs en amont et le routage.", "Configure Waffo Pancake hosted checkout integration for USD-priced top-ups": "Configurer l'intégration du parcours de paiement hébergé Waffo Pancake pour les rechargements en USD", "Configure Waffo payment aggregation platform integration": "Configurer l'intégration de la plateforme d'agrégation de paiement Waffo", @@ -1215,6 +1227,12 @@ "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "Multiplicateurs personnalisés lorsque des groupes d'utilisateurs spécifiques utilisent des groupes de jetons spécifiques. Exemple : les utilisateurs VIP obtiennent un taux de 0,9x lorsqu'ils utilisent les jetons du groupe \"edit_this\".", "Custom OAuth": "OAuth personnalisé", "Custom OAuth Providers": "Fournisseurs OAuth personnalisés", + "Custom page added. Click \"Save Settings\" to apply.": "Page ajoutée. Cliquez sur « Enregistrer » pour appliquer.", + "Custom page deleted. Click \"Save Settings\" to apply.": "Page supprimée. Cliquez sur « Enregistrer » pour appliquer.", + "Custom page not found": "Page personnalisée introuvable", + "Custom page updated. Click \"Save Settings\" to apply.": "Page mise à jour. Cliquez sur « Enregistrer » pour appliquer.", + "Custom Pages": "Pages personnalisées", + "Custom pages saved successfully": "Pages personnalisées enregistrées", "Custom Seconds": "Secondes personnalisées", "Custom sidebar section": "Section de barre latérale personnalisée", "Custom Time Range": "Plage horaire personnalisée", @@ -1431,6 +1449,7 @@ "Do string replacement in the target field": "Effectuer un remplacement de chaîne dans le champ cible", "Do you want to download the created redemption codes as a text file?": "Voulez-vous télécharger les codes de réduction créés sous forme de fichier texte ?", "Docs": "Documents", + "Documentation": "Documentation", "Documentation Link": "Lien de la documentation", "Documentation or external knowledge base.": "Documentation ou base de connaissances externe.", "does not exist or might have been removed.": "n'existe pas ou a peut-être été supprimé.", @@ -1523,6 +1542,7 @@ "Edit Channel": "Modifier le canal", "Edit channel routing": "Modifier le routage des canaux", "Edit chat preset": "Modifier le préréglage de chat", + "Edit Custom Page": "Modifier la page", "Edit discount tier": "Modifier le palier de remise", "Edit FAQ": "Modifier la FAQ", "Edit group": "Modifier le groupe", @@ -1559,6 +1579,7 @@ "Email Field": "Champ d'e-mail", "Email Verification": "Vérification d'e-mail", "Email, summarisation, knowledge work": "Email, résumé, travail intellectuel", + "Embed in console": "Intégrer dans la console", "Embeddings": "Embeddings", "Empty": "Vide", "Empty value will be saved as {}.": "Une valeur vide sera enregistrée comme {}.", @@ -1566,6 +1587,7 @@ "Enable {{parameter}}": "Activer {{parameter}}", "Enable 2FA": "Activer 2FA", "Enable All": "Tout activer", + "Enable availability monitor": "Activer la surveillance", "Enable check-in feature": "Activer la fonction de connexion", "Enable Data Dashboard": "Activer le tableau de bord des données", "Enable demo mode with limited functionality": "Activer le mode démo avec des fonctionnalités limitées", @@ -1608,6 +1630,7 @@ "Enabled": "Activé", "Enabled all channels with tag: {{tag}}": "Tous les canaux avec le tag {{tag}} ont été activés", "Enabled channels with tag {{tag}}": "Canaux avec l'étiquette {{tag}} activés", + "Enabled pages with a URL appear under the Extensions group in the console sidebar and open as embedded pages.": "Les pages activées avec une URL apparaissent dans le groupe Extensions de la barre latérale et s’ouvrent en page intégrée.", "Enabled Status": "Statut activé", "Enabling...": "Activation en cours...", "Encourages introducing new topics": "Encourage l'introduction de nouveaux sujets", @@ -1723,6 +1746,7 @@ "Estimated cost": "Coût estimé", "Estimated quota cost": "Coût de quota estimé", "Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.": "Chaque nom de groupe du tableau tarifaire peut être utilisé à deux endroits : sur un utilisateur (groupe d’utilisateurs, attribué par les admins) et sur un jeton (groupe de jetons, choisi à la création du jeton). Même ensemble de noms, deux rôles différents.", + "Everyone": "Tout le monde", "Everything configured for this group, in one place.": "Toute la configuration de ce groupe, au même endroit.", "Exact": "Exact", "Exact Match": "Correspondance exacte", @@ -1767,6 +1791,7 @@ "Extend deployment": "Prolonger le déploiement", "Extend failed": "Échec de la prolongation", "Extended successfully": "Prolongé avec succès", + "Extensions": "Extensions", "External Device": "Appareil externe", "External link for users to purchase quota": "Lien externe permettant aux utilisateurs d'acheter du quota", "External operations": "Opérations externes", @@ -1845,6 +1870,7 @@ "Failed to initialize system": "Échec de l'initialisation du système", "Failed to load": "Échec du chargement", "Failed to load API keys": "Échec du chargement des Clés API", + "Failed to load availability": "Échec du chargement de la disponibilité", "Failed to load billing history": "Échec du chargement de l'historique de facturation", "Failed to load enabled models": "Échec du chargement des modèles activés", "Failed to load home page content": "Échec du chargement du contenu de la page d'accueil", @@ -1876,6 +1902,7 @@ "Failed to save": "Échec de la sauvegarde", "Failed to save announcements": "Échec de la sauvegarde des annonces", "Failed to save API info": "Échec de l'enregistrement des informations API", + "Failed to save custom pages": "Échec de l’enregistrement des pages personnalisées", "Failed to save FAQ": "Échec de la sauvegarde de la FAQ", "Failed to save Uptime Kuma groups": "Échec de la sauvegarde des groupes Uptime Kuma", "Failed to search API keys": "Échec de la recherche des Clés API", @@ -2532,6 +2559,7 @@ "Logs": "Journaux", "Look for a special ratio rule matching this user group and this billing group. If one exists, use its ratio. Otherwise use the billing group base ratio from the pricing table.": "Cherchez une règle de taux spécial correspondant à ce groupe d’utilisateurs et ce groupe de facturation. Si elle existe, utilisez son taux ; sinon le taux de base du groupe de facturation.", "Low balance": "Solde faible", + "Lower numbers appear first in the sidebar.": "Les nombres plus petits apparaissent en premier.", "Lowest median first-token latency": "Latence médiane de premier jeton la plus faible", "m": "m", "Maintenance": "Maintenance", @@ -2776,6 +2804,7 @@ "Multipliers for recharge pricing based on user groups.": "Multiplicateurs pour la tarification de recharge basés sur les groupes d'utilisateurs.", "Must be a valid URL": "Doit être une URL valide", "Must be at least 8 characters": "Doit contenir au moins 8 caractères", + "Must be http(s). Leave empty to keep the page hidden from the sidebar.": "Doit être en http(s). Laissez vide pour masquer la page.", "My Subscriptions": "Mes abonnements", "my-status": "mon-statut", "MySQL detected": "MySQL détecté", @@ -2846,6 +2875,7 @@ "No available Web chat links": "Aucun lien de chat Web disponible", "No backup": "Pas de sauvegarde", "No base input price": "Aucun prix d’entrée de base", + "No billing groups configured.": "Aucun groupe de facturation configuré.", "No billing records found": "Aucun enregistrement de facturation trouvé", "No capabilities reported for this model.": "Aucune capacité n'a été signalée pour ce modèle.", "No Change": "Aucun changement", @@ -2867,6 +2897,7 @@ "No containers": "Aucun conteneur", "No content to copy": "Aucun contenu à copier", "No custom OAuth providers configured yet.": "Aucun fournisseur OAuth personnalisé configuré pour le moment.", + "No custom pages yet. Click \"Add Custom Page\" to create one.": "Aucune page personnalisée. Cliquez sur « Ajouter une page » pour en créer une.", "No data": "Aucune donnée", "No Data": "Aucune donnée", "No data available": "Aucune donnée disponible", @@ -2950,6 +2981,7 @@ "No providers available": "Aucun fournisseur disponible", "No Quota": "Aucun quota", "No ratio differences found": "Aucune différence de ratio trouvée", + "No recent requests for this group.": "Aucune requête récente pour ce groupe.", "No recent usage": "Aucune utilisation récente", "No records found. Try adjusting your filters.": "Aucun enregistrement trouvé. Essayez d'ajuster vos filtres.", "No redemption codes available. Create your first redemption code to get started.": "Aucun code d'échange disponible. Créez votre premier code d'échange pour commencer.", @@ -3001,6 +3033,7 @@ "Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "Les récompenses d’invitation non nulles nécessitent une confirmation de conformité dans les paramètres de la passerelle de paiement.", "None": "Aucun", "noreply@example.com": "noreply@example.com", + "Normal": "Normal", "Normalized:": "Normalisé :", "Not available": "Non disponible", "Not backed up": "Non sauvegardé", @@ -3021,6 +3054,7 @@ "Notification Email": "E-mail de notification", "Notification Method": "Méthode de notification", "Notifications": "Notifications", + "Now": "Maintenant", "Now a user whose user group is vip creates tokens with different groups and makes one call with each:": "Un utilisateur du groupe vip crée maintenant des jetons avec différents groupes et effectue un appel avec chacun :", "Nucleus sampling probability mass": "Masse probabiliste de l'échantillonnage nucleus", "Number of codes to create": "Nombre de codes à créer", @@ -3081,6 +3115,7 @@ "Only available for admins. When enabled, you will receive a summary notification via your selected method when the scheduled model check detects upstream model changes or check failures.": "Uniquement disponible pour les administrateurs. Lorsque cette option est activée, vous recevrez une notification récapitulative via votre méthode sélectionnée lorsque la vérification planifiée des modèles détecte des changements de modèles en amont ou des échecs de vérification.", "Only configured combinations are overridden. All other calls keep the billing group base ratio.": "Seules les combinaisons configurées sont remplacées. Tous les autres appels gardent le taux de base du groupe de facturation.", "Only configured combinations are overridden. All other calls keep the token group base ratio.": "Seules les combinaisons configurées sont remplacées. Les autres appels conservent le ratio de base du groupe du jeton.", + "Only enabled pages with a URL are shown in the Extensions sidebar group.": "Seules les pages activées avec une URL apparaissent dans Extensions.", "Only enabled parameters are sent with the request.": "Seuls les paramètres activés sont envoyés avec la requête.", "Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "Saisissez uniquement l’origine du site, par exemple https://api.example.com. N’ajoutez aucun chemin comme /api/user/epay/notify. Laissez vide pour utiliser l’adresse du serveur.", "Only Mine": "Uniquement les miens", @@ -3098,6 +3133,7 @@ "Open in new tab": "Ouvrir dans un nouvel onglet", "Open in New Tab": "Ouvrir dans un nouvel onglet", "Open menu": "Ouvrir le menu", + "Open mode": "Mode d’ouverture", "Open release": "Ouvrir la version", "Open source": "Open source", "Open Source": "Open source", @@ -3264,6 +3300,7 @@ "Password reset: {{password}}": "Mot de passe réinitialisé : {{password}}", "Passwords do not match": "Les mots de passe ne correspondent pas", "Passwords don't match.": "Les mots de passe ne correspondent pas.", + "Past": "Passé", "Paste Connection Info": "Coller les infos de connexion", "Path": "Chemin", "Path not set": "Chemin non défini", @@ -3624,6 +3661,7 @@ "Receive Upstream Model Update Notifications": "Recevoir les notifications de mise à jour des modèles en amont", "Received": "Reçu", "Received amount": "Montant reçu", + "Recent {{count}} records": "{{count}} enregistrements récents", "Recent maintenance tasks running across instances and their execution status.": "Tâches de maintenance récentes exécutées sur les instances et leur état d'exécution.", "Recently completed or failed system task runs.": "Exécutions de tâches système récemment terminées ou échouées.", "Recently launched models": "Modèles récemment lancés", @@ -3672,6 +3710,7 @@ "Refresh Cache": "Actualiser le cache", "Refresh credential": "Actualiser l'identifiant", "Refresh details": "Actualiser les détails", + "Refresh every {{seconds}}s": "Actualisation toutes les {{seconds}}s", "Refresh failed": "Échec de l'actualisation", "Refresh interval (minutes)": "Intervalle d'actualisation (minutes)", "Refresh Stats": "Actualiser les statistiques", @@ -3759,6 +3798,7 @@ "Request Header Field": "Champ d'en-tête de requête", "Request Header Override": "Remplacement des en-têtes de requête", "Request Header Overrides": "Remplacements d'en-têtes de requête", + "Request health by billing group for the latest 100 consume/error logs. Green bars show latency; red bars are failures. Badge uses overall success rate (≥95% normal, ≥80% warning, below 80% abnormal).": "Santé des requêtes par groupe (100 derniers logs). Barres vertes = latence, rouges = échecs. Badge selon le taux de succès.", "Request ID": "ID de requête", "Request Limits": "Limites de requêtes", "Request Model": "Modèle demandé", @@ -4024,6 +4064,7 @@ "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 an icon": "Sélectionner une icône", "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.", @@ -4057,6 +4098,7 @@ "Select models or add custom ones": "Sélectionner des modèles ou en ajouter des personnalisés", "Select models to process. Unselected \"add\" models will be ignored.": "Sélectionnez les modèles à traiter. Les modèles « ajout » non sélectionnés seront ignorés.", "Select models to run batch tests.": "Sélectionner les modèles pour exécuter les tests par lots.", + "Select open mode": "Choisir le mode d’ouverture", "Select or enter color value": "Sélectionner ou saisir une valeur de couleur", "Select or enter method identifier": "Sélectionner ou saisir l’identifiant du mode", "Select or enter model name": "Sélectionner ou saisir le nom du modèle", @@ -4082,6 +4124,7 @@ "Select theme preset": "Sélectionner un préréglage de thème", "Select time granularity": "Sélectionner la granularité temporelle", "Select vendor": "Sélectionner le fournisseur", + "Select visibility": "Choisir la visibilité", "Selectable groups": "Groupes sélectionnables", "selected": "sélectionné", "Selected {{count}}": "{{count}} sélectionné(s)", @@ -4165,6 +4208,8 @@ "Showcase core capabilities with demo credentials and limited access.": "Présenter les fonctionnalités principales avec des identifiants de démonstration et un accès limité.", "Showing": "Affichage de", "showing •": "affichage •", + "Shown in the console sidebar. Maximum 100 characters.": "Affiché dans la barre latérale. 100 caractères maximum.", + "Shows a group-level request heartbeat chart under Extensions. Failed requests require ERROR_LOG_ENABLED.": "Affiche un graphique de requêtes par groupe sous Extensions. Les échecs nécessitent ERROR_LOG_ENABLED.", "Sidebar": "Barre latérale", "Sidebar collapsed by default for new users": "Barre latérale masquée par défaut pour les nouveaux utilisateurs", "Sidebar modules": "Modules de la barre latérale", @@ -4470,6 +4515,7 @@ "The name displayed across the application": "Le nom affiché dans l'application", "The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations": "L'URL publique de votre serveur, utilisée pour les rappels OAuth, les webhooks et autres intégrations externes", "The requested chat preset does not exist or has been removed.": "Le préréglage de discussion demandé n'existe pas ou a été supprimé.", + "The requested page does not exist, is disabled, or has no URL configured.": "La page demandée n’existe pas, est désactivée ou n’a pas d’URL.", "The reset request stays disabled until a credit is available.": "La demande de réinitialisation reste désactivée tant qu’aucun crédit n’est disponible.", "The setup wizard will use this database during initialization.": "L'assistant de configuration utilisera cette base de données lors de l'initialisation.", "The site is not available at the moment.": "Le site n'est pas disponible pour le moment.", @@ -4508,6 +4554,7 @@ "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 d’abonnement et de récompenses d’invitation. 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 custom page will be removed from the list.": "Cette page personnalisée sera retirée de la liste.", "This data may be unreliable, use with caution": "Ces données peuvent être peu fiables, utilisez-les avec prudence", "This device does not support Passkey": "Cet appareil ne prend pas en charge Passkey", "This device does not support Passkey verification.": "Cet appareil ne prend pas en charge la vérification par clé d'accès.", @@ -4527,6 +4574,7 @@ "This model is not available in any group, or no group pricing information is configured.": "Ce modèle n'est disponible dans aucun groupe, ou aucune information de tarification de groupe n'est configurée.", "This month": "Ce mois-ci", "This page has not been created yet.": "Cette page n'a pas encore été créée.", + "This page opens in a new browser tab because the target site cannot be embedded.": "Cette page s’ouvre dans un nouvel onglet car le site cible ne peut pas être intégré.", "This plan does not allow balance redemption": "Ce forfait ne permet pas le paiement avec le solde", "This project must be used in compliance with the": "Ce projet doit être utilisé conformément aux", "This removes {{count}} failed models from this channel. This action cannot be undone.": "Cela supprime {{count}} modèles en échec de ce canal. Cette action est irréversible.", @@ -4581,6 +4629,7 @@ "times": "Fois", "Timing": "Durée", "Tip": "Astuce", + "Title": "Titre", "to access this resource.": "pour accéder à cette ressource.", "To Anthropic Messages": "Vers Anthropic Messages", "to confirm": "pour confirmer", @@ -4739,6 +4788,7 @@ "UI granularity only — data is still aggregated hourly": "Granularité de l'interface uniquement — les données sont toujours agrégées par heure", "Unable to estimate price for this deployment.": "Impossible d'estimer le prix pour ce déploiement.", "Unable to generate chat link. Please contact your administrator.": "Impossible de générer le lien de discussion. Veuillez contacter votre administrateur.", + "Unable to load availability": "Impossible de charger la disponibilité", "Unable to load groups": "Impossible de charger les groupes", "Unable to load rankings": "Impossible de charger les classements", "Unable to load rankings data": "Impossible de charger les données des classements", @@ -4869,6 +4919,7 @@ "USD Exchange Rate": "Taux de change USD", "USD price per 1M input tokens.": "Prix en USD par million de tokens d’entrée.", "USD price per 1M tokens.": "Prix en USD par million de tokens.", + "Use “Open in new tab” for sites that block iframe embedding (for example Taobao / Xianyu short links).": "Utilisez « Ouvrir dans un nouvel onglet » pour les sites qui bloquent l’iframe.", "Use +: to add a group, -: to remove a default selectable group, or no prefix to append a group.": "Utilisez +: pour ajouter un groupe, -: pour supprimer un groupe sélectionnable par défaut, ou aucun préfixe pour annexer un groupe.", "Use a compatible browser or device with biometric authentication or a security key to register a Passkey.": "Utilisez un navigateur ou un appareil compatible avec l'authentification biométrique ou une clé de sécurité pour enregistrer une clé d'accès (Passkey).", "Use a different stable value for each instance, then restart the service.": "Utilisez une valeur stable différente pour chaque instance, puis redémarrez le service.", @@ -5021,6 +5072,7 @@ "Violation Marker": "Marqueur de violation", "vip": "vip", "VIP users with premium access": "Utilisateurs VIP avec accès premium", + "Visibility": "Visibilité", "Visible": "Visible", "Vision": "Vision", "Vision, image / video, document chat": "Vision, image / vidéo, conversation sur document", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index d6d2a815d134..113f664c7b85 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -38,6 +38,8 @@ "{{count}} channel(s) enabled": "{{count}} 個のチャネルを有効にしました", "{{count}} channel(s) failed to disable": "{{count}} 個のチャネルの無効化に失敗しました", "{{count}} channel(s) failed to enable": "{{count}} 個のチャネルの有効化に失敗しました", + "{{count}} custom pages deleted. Click \"Save Settings\" to apply.": "{{count}} 件のカスタムページを削除しました。「設定を保存」をクリックして反映してください。", + "{{count}} custom pages will be removed from the list.": "{{count}} 件のカスタムページが一覧から削除されます。", "{{count}} days ago": "{{count}} 日前", "{{count}} days remaining": "残り {{count}} 日", "{{count}} disabled channel(s) deleted": "{{count}} 個の無効チャネルを削除しました", @@ -118,6 +120,7 @@ "80,443,8080": "80,443,8080", "A billing multiplier. Lower ratios mean lower API call costs.": "課金倍率です。倍率が低いほど API 呼び出しコストは低くなります。", "A focused home for keys, balance, routing, and service health.": "キー、残高、ルーティング、サービス状態を集約したホームです。", + "Abnormal": "異常", "About": "このサービスについて", "About {{days}} days left": "約 {{days}} 日分", "Accept Unpriced Models": "価格設定されていないモデルを許可", @@ -174,6 +177,7 @@ "Add Condition": "条件を追加", "Add credits": "クレジットを追加", "Add custom model \"{{value}}\"": "カスタムモデル「{{value}}」を追加", + "Add Custom Page": "カスタムページを追加", "Add discount tier": "割引ティアを追加", "Add each model or tag you want to include.": "含めたい各モデルまたはタグを追加。", "Add FAQ": "FAQ追加", @@ -239,6 +243,7 @@ "Administer user accounts and roles.": "ユーザーアカウントとロールを管理します。", "Administrator account": "管理者アカウント", "Administrator username": "管理者ユーザー名", + "Admins only": "管理者のみ", "Advance next reset time": "次回リセット時刻を進める", "Advanced": "高度な設定", "Advanced Configuration": "詳細設定", @@ -520,7 +525,9 @@ "Automatically replaces upstream callback URLs with the server address.": "アップストリームコールバック URL をサーバーアドレスに自動的に置き換えます。", "Automatically selects the best available group with circuit breaker mechanism": "回路ブレーカーメカニズム付きで最適な利用可能なグループを自動的に選択", "Automatically sync model list when upstream changes are detected": "アップストリームの変更が検出されたときにモデルリストを自動的に同期", + "Availability": "可用性", "Availability (last 24h)": "可用性(過去 24 時間)", + "Availability Monitor": "可用性モニタ", "Available": "空き", "Available credits are ordered by soonest expiration.": "利用可能なクレジットは有効期限の近い順に表示されます。", "Available disk space": "利用可能なディスク容量", @@ -535,6 +542,7 @@ "Average tokens per second sustained per group": "グループごとに持続する平均スループット (tokens/秒)", "Average TPM": "平均TPM", "Average TTFT": "平均 TTFT", + "Avg latency": "平均遅延", "AWS": "AWS", "AWS Bedrock Claude Compat": "AWS Bedrock Claude 互換テンプレート", "AWS Key Format": "AWSキーフォーマット", @@ -814,6 +822,8 @@ "Choose the default charts, range, and time granularity for model analytics.": "モデル分析のデフォルトチャート、範囲、時間粒度を選択します。", "Choose where to fetch upstream metadata.": "アップストリームのメタデータをどこからフェッチするかを選択してください。", "Choose which charts are selected by default when opening model analytics.": "モデル分析を開いたときにデフォルトで選択されるチャートを選択します。", + "Choose who can see the Availability Monitor entry in the Extensions sidebar.": "サイドバーの拡張で可用性モニタを見られる人を選択します。", + "Choose who can see this page in the Extensions sidebar.": "サイドバーの拡張でこのページを見られる人を選択します。", "Clamped to": "制限後の値", "Classic (Legacy Frontend)": "クラシック(旧フロントエンド)", "Claude": "Claude", @@ -975,6 +985,8 @@ "Configure rate limiting rules for a specific user group.": "特定のユーザーグループのレート制限ルールを設定します。", "Configure routes": "ルートを設定", "Configure the ratio for this group.": "このグループの比率を設定します。", + "Configure the sidebar title, icon, embed URL, status, and sort order.": "サイドバーのタイトル、アイコン、埋め込み URL、状態、並び順を設定します。", + "Configure the sidebar title, icon, URL, open mode, status, and sort order.": "サイドバーのタイトル、アイコン、URL、開く方法、状態、並び順を設定します。", "Configure upstream providers and routing.": "アップストリームプロバイダーとルーティングを設定。", "Configure Waffo Pancake hosted checkout integration for USD-priced top-ups": "USD 建てのチャージ用に Waffo Pancake のホスト型チェックアウト連携を設定", "Configure Waffo payment aggregation platform integration": "Waffo決済アグリゲーションプラットフォームの連携を設定", @@ -1215,6 +1227,12 @@ "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "特定のユーザーグループが特定のトークングループを使用する場合のカスタム乗数。例: VIPユーザーが「edit_this」グループトークンを使用する場合、0.9倍のレートが適用されます。", "Custom OAuth": "カスタム OAuth", "Custom OAuth Providers": "カスタムOAuthプロバイダー", + "Custom page added. Click \"Save Settings\" to apply.": "カスタムページを追加しました。「設定を保存」をクリックして反映してください。", + "Custom page deleted. Click \"Save Settings\" to apply.": "カスタムページを削除しました。「設定を保存」をクリックして反映してください。", + "Custom page not found": "カスタムページが見つかりません", + "Custom page updated. Click \"Save Settings\" to apply.": "カスタムページを更新しました。「設定を保存」をクリックして反映してください。", + "Custom Pages": "カスタムページ", + "Custom pages saved successfully": "カスタムページを保存しました", "Custom Seconds": "カスタム秒数", "Custom sidebar section": "カスタムサイドバーセクション", "Custom Time Range": "カスタム時間範囲", @@ -1431,6 +1449,7 @@ "Do string replacement in the target field": "ターゲットフィールドで文字列置換", "Do you want to download the created redemption codes as a text file?": "作成した引き換えコードをテキストファイルとしてダウンロードしますか?", "Docs": "ドキュメント", + "Documentation": "ドキュメント", "Documentation Link": "ドキュメントリンク", "Documentation or external knowledge base.": "ドキュメントまたは外部知識ベース。", "does not exist or might have been removed.": "存在しないか、削除された可能性があります。", @@ -1523,6 +1542,7 @@ "Edit Channel": "チャネルを編集", "Edit channel routing": "チャネルルーティングを編集", "Edit chat preset": "チャットプリセットを編集", + "Edit Custom Page": "カスタムページを編集", "Edit discount tier": "割引ティアを編集", "Edit FAQ": "FAQ を編集", "Edit group": "グループを編集", @@ -1559,6 +1579,7 @@ "Email Field": "メールフィールド", "Email Verification": "メール認証", "Email, summarisation, knowledge work": "メール・要約・ナレッジワーク", + "Embed in console": "コンソール内に埋め込む", "Embeddings": "埋め込み", "Empty": "空", "Empty value will be saved as {}.": "空の値は {} として保存されます。", @@ -1566,6 +1587,7 @@ "Enable {{parameter}}": "{{parameter}}を有効化", "Enable 2FA": "2FA を有効にする", "Enable All": "すべて有効にする", + "Enable availability monitor": "可用性モニタを有効化", "Enable check-in feature": "チェックイン機能を有効にする", "Enable Data Dashboard": "データダッシュボードを有効にする", "Enable demo mode with limited functionality": "機能が制限されたデモモードを有効にする", @@ -1608,6 +1630,7 @@ "Enabled": "有効", "Enabled all channels with tag: {{tag}}": "タグ「{{tag}}」の全チャネルを有効にしました", "Enabled channels with tag {{tag}}": "タグ {{tag}} のチャネルを有効化しました", + "Enabled pages with a URL appear under the Extensions group in the console sidebar and open as embedded pages.": "有効かつ URL のあるページはサイドバーの「拡張」グループに表示され、埋め込みページとして開きます。", "Enabled Status": "有効ステータス", "Enabling...": "有効化中...", "Encourages introducing new topics": "新しい話題への展開を促進します", @@ -1723,6 +1746,7 @@ "Estimated cost": "推定コスト", "Estimated quota cost": "想定クォートコスト", "Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.": "料金表の各グループ名は2つの場所で使えます。ユーザー側(ユーザーグループ、管理者が割り当て)とトークン側(トークングループ、トークン作成時に選択)です。同じ名前プールで、役割は2つです。", + "Everyone": "全員", "Everything configured for this group, in one place.": "このグループのすべての設定を一か所で確認できます。", "Exact": "完全一致", "Exact Match": "完全一致", @@ -1767,6 +1791,7 @@ "Extend deployment": "デプロイメントを延長", "Extend failed": "延長に失敗しました", "Extended successfully": "正常に延長されました", + "Extensions": "拡張", "External Device": "外部デバイス", "External link for users to purchase quota": "ユーザーがクォータを購入するための外部リンク", "External operations": "外部運用", @@ -1845,6 +1870,7 @@ "Failed to initialize system": "システムの初期化に失敗しました", "Failed to load": "読み込みに失敗しました", "Failed to load API keys": "APIキーの読み込みに失敗しました", + "Failed to load availability": "可用性データの読み込みに失敗しました", "Failed to load billing history": "請求履歴の読み込みに失敗しました", "Failed to load enabled models": "有効なモデルの取得に失敗しました", "Failed to load home page content": "ホームページの内容の読み込みに失敗しました", @@ -1876,6 +1902,7 @@ "Failed to save": "保存に失敗", "Failed to save announcements": "お知らせの保存に失敗しました", "Failed to save API info": "API情報の保存に失敗しました", + "Failed to save custom pages": "カスタムページの保存に失敗しました", "Failed to save FAQ": "FAQの保存に失敗しました", "Failed to save Uptime Kuma groups": "Uptime Kumaグループの保存に失敗しました", "Failed to search API keys": "APIキーの検索に失敗しました", @@ -2532,6 +2559,7 @@ "Logs": "ログ", "Look for a special ratio rule matching this user group and this billing group. If one exists, use its ratio. Otherwise use the billing group base ratio from the pricing table.": "このユーザーグループと課金グループに一致する特別倍率ルールを探します。あればその倍率を、なければ料金表の課金グループの基本倍率を使います。", "Low balance": "残高不足", + "Lower numbers appear first in the sidebar.": "数値が小さいほどサイドバーで先に表示されます。", "Lowest median first-token latency": "最初のトークンまでの中央値レイテンシの最小値", "m": "m", "Maintenance": "メンテナンス", @@ -2776,6 +2804,7 @@ "Multipliers for recharge pricing based on user groups.": "ユーザーグループに基づいたリチャージ価格設定の乗数。", "Must be a valid URL": "有効な URL を入力してください", "Must be at least 8 characters": "8文字以上である必要があります", + "Must be http(s). Leave empty to keep the page hidden from the sidebar.": "http(s) である必要があります。空の場合はサイドバーに表示されません。", "My Subscriptions": "マイサブスクリプション", "my-status": "my-status", "MySQL detected": "MySQLが検出されました", @@ -2846,6 +2875,7 @@ "No available Web chat links": "利用可能なWebチャットリンクがありません", "No backup": "バックアップなし", "No base input price": "基本入力価格なし", + "No billing groups configured.": "課金グループが設定されていません。", "No billing records found": "請求記録が見つかりません", "No capabilities reported for this model.": "このモデルには報告されている機能がありません。", "No Change": "変更なし", @@ -2867,6 +2897,7 @@ "No containers": "コンテナがありません", "No content to copy": "コピーする内容がありません", "No custom OAuth providers configured yet.": "カスタムOAuthプロバイダーはまだ設定されていません。", + "No custom pages yet. Click \"Add Custom Page\" to create one.": "カスタムページはまだありません。「カスタムページを追加」をクリックして作成してください。", "No data": "データがありません", "No Data": "データなし", "No data available": "データがありません", @@ -2950,6 +2981,7 @@ "No providers available": "利用可能なプロバイダーがありません", "No Quota": "クォータなし", "No ratio differences found": "比率の差異は見つかりませんでした", + "No recent requests for this group.": "このグループに最近のリクエストはありません。", "No recent usage": "最近の使用なし", "No records found. Try adjusting your filters.": "記録が見つかりません。フィルターを調整してみてください。", "No redemption codes available. Create your first redemption code to get started.": "利用可能な引き換えコードがありません。最初の引き換えコードを作成して開始してください。", @@ -3001,6 +3033,7 @@ "Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "0 以外の招待報酬には、支払いゲートウェイ設定でのコンプライアンス確認が必要です。", "None": "なし", "noreply@example.com": "noreply@example.com", + "Normal": "正常", "Normalized:": "正規化:", "Not available": "利用できません", "Not backed up": "未バックアップ", @@ -3021,6 +3054,7 @@ "Notification Email": "通知メール", "Notification Method": "通知方法", "Notifications": "通知", + "Now": "現在", "Now a user whose user group is vip creates tokens with different groups and makes one call with each:": "ここで、ユーザーグループが vip のユーザーが異なるグループのトークンを作成し、それぞれ1回ずつ呼び出します:", "Nucleus sampling probability mass": "核サンプリングの累積確率", "Number of codes to create": "作成するコードの数", @@ -3081,6 +3115,7 @@ "Only available for admins. When enabled, you will receive a summary notification via your selected method when the scheduled model check detects upstream model changes or check failures.": "管理者のみ利用可能です。有効にすると、スケジュールされたモデルチェックでアップストリームモデルの変更やチェック失敗が検出された際に、選択した方法で概要通知を受け取ります。", "Only configured combinations are overridden. All other calls keep the billing group base ratio.": "設定された組み合わせだけが上書きされます。それ以外の呼び出しは課金グループの基本倍率のままです。", "Only configured combinations are overridden. All other calls keep the token group base ratio.": "設定済みの組み合わせだけが上書きされます。他の呼び出しはトークングループの基本倍率を維持します。", + "Only enabled pages with a URL are shown in the Extensions sidebar group.": "有効かつ URL のあるページのみ「拡張」グループに表示されます。", "Only enabled parameters are sent with the request.": "有効なパラメータだけがリクエストに送信されます。", "Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "サイトのオリジンのみを入力してください。例: https://api.example.com。/api/user/epay/notify などのパスは含めないでください。空欄の場合はサーバーアドレスを使用します。", "Only Mine": "自分のみ", @@ -3098,6 +3133,7 @@ "Open in new tab": "新しいタブで開く", "Open in New Tab": "新しいタブで開く", "Open menu": "メニューを開く", + "Open mode": "開く方法", "Open release": "リリースを開く", "Open source": "オープンソース", "Open Source": "オープンソース", @@ -3264,6 +3300,7 @@ "Password reset: {{password}}": "パスワードがリセットされました:{{password}}", "Passwords do not match": "パスワードが一致しません", "Passwords don't match.": "パスワードが一致しません。", + "Past": "過去", "Paste Connection Info": "接続情報を貼り付け", "Path": "パス", "Path not set": "パス未設定", @@ -3624,6 +3661,7 @@ "Receive Upstream Model Update Notifications": "アップストリームモデル更新通知を受け取る", "Received": "受信済み", "Received amount": "受け取り額", + "Recent {{count}} records": "直近 {{count}} 件", "Recent maintenance tasks running across instances and their execution status.": "各インスタンスで実行された最近のメンテナンスタスクとその実行状態。", "Recently completed or failed system task runs.": "最近完了または失敗したシステムタスク実行です。", "Recently launched models": "最近リリースされたモデル", @@ -3672,6 +3710,7 @@ "Refresh Cache": "キャッシュ更新", "Refresh credential": "認証情報を更新", "Refresh details": "詳細を更新", + "Refresh every {{seconds}}s": "{{seconds}} 秒ごとに更新", "Refresh failed": "更新に失敗しました", "Refresh interval (minutes)": "更新間隔 (分)", "Refresh Stats": "統計を更新", @@ -3759,6 +3798,7 @@ "Request Header Field": "リクエストヘッダーフィールド", "Request Header Override": "リクエストヘッダー上書き", "Request Header Overrides": "リクエストヘッダーの上書き", + "Request health by billing group for the latest 100 consume/error logs. Green bars show latency; red bars are failures. Badge uses overall success rate (≥95% normal, ≥80% warning, below 80% abnormal).": "課金グループごとの直近 100 件。緑は遅延、赤は失敗。バッジは成功率(≥95% 正常、≥80% 警告、80% 未満は異常)。", "Request ID": "リクエストID", "Request Limits": "リクエスト制限", "Request Model": "リクエストモデル", @@ -4024,6 +4064,7 @@ "Select all (filtered)": "フィルタ結果をすべて選択(S)", "Select all models": "すべてのモデルを選択", "Select All Visible": "表示中のすべてを選択", + "Select an icon": "アイコンを選択", "Select an operation mode and enter the amount": "操作モードを選択し、金額を入力してください", "Select announcement type": "アナウンスメントタイプを選択", "Select at least one field to overwrite.": "上書きするフィールドを少なくとも 1 つ選択してください。", @@ -4057,6 +4098,7 @@ "Select models or add custom ones": "モデルを選択するか、カスタムモデルを追加", "Select models to process. Unselected \"add\" models will be ignored.": "処理するモデルを選択してください。未選択の「追加」モデルは無視されます。", "Select models to run batch tests.": "バッチテストを実行するモデルを選択してください。", + "Select open mode": "開く方法を選択", "Select or enter color value": "色の値を選択または入力", "Select or enter method identifier": "決済方法の識別子を選択または入力", "Select or enter model name": "モデル名を選択または入力", @@ -4082,6 +4124,7 @@ "Select theme preset": "テーマプリセットを選択", "Select time granularity": "時間の粒度を選択", "Select vendor": "ベンダーを選択", + "Select visibility": "表示範囲を選択", "Selectable groups": "選択可能なグループ", "selected": "選択済み", "Selected {{count}}": "{{count}} 件選択済み", @@ -4165,6 +4208,8 @@ "Showcase core capabilities with demo credentials and limited access.": "デモ用の認証情報と制限付きアクセスでコア機能を紹介します。", "Showing": "表示", "showing •": "表示中 •", + "Shown in the console sidebar. Maximum 100 characters.": "コンソールのサイドバーに表示されます。最大 100 文字。", + "Shows a group-level request heartbeat chart under Extensions. Failed requests require ERROR_LOG_ENABLED.": "拡張メニューにグループ別リクエスト心拍チャートを表示します。失敗記録には ERROR_LOG_ENABLED が必要です。", "Sidebar": "サイドバー", "Sidebar collapsed by default for new users": "新規ユーザー向けにサイドバーをデフォルトで折りたたむ", "Sidebar modules": "サイドバーモジュール", @@ -4470,6 +4515,7 @@ "The name displayed across the application": "アプリケーション全体に表示される名前", "The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations": "OAuthコールバック、Webhook、その他の外部統合に使用されるサーバーの公開URL", "The requested chat preset does not exist or has been removed.": "要求されたチャットプリセットは存在しないか、削除されました。", + "The requested page does not exist, is disabled, or has no URL configured.": "要求されたページは存在しないか、無効か、URL が未設定です。", "The reset request stays disabled until a credit is available.": "リセット回数が利用可能になるまで、リセット要求は無効です。", "The setup wizard will use this database during initialization.": "セットアップウィザードは初期化時にこのデータベースを使用します。", "The site is not available at the moment.": "現在、このサイトは利用できません。", @@ -4508,6 +4554,7 @@ "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 custom page will be removed from the list.": "このカスタムページは一覧から削除されます。", "This data may be unreliable, use with caution": "このデータは信頼できない可能性があります。注意して使用してください", "This device does not support Passkey": "このデバイスはPasskeyをサポートしていません", "This device does not support Passkey verification.": "このデバイスはPasskey認証をサポートしていません。", @@ -4527,6 +4574,7 @@ "This model is not available in any group, or no group pricing information is configured.": "このモデルはどのグループでも利用できないか、グループの料金情報が設定されていません。", "This month": "今月", "This page has not been created yet.": "このページはまだ作成されていません。", + "This page opens in a new browser tab because the target site cannot be embedded.": "対象サイトを埋め込めないため、新しいタブで開きます。", "This plan does not allow balance redemption": "このプランでは残高での交換は許可されていません", "This project must be used in compliance with the": "このプロジェクトは、以下を遵守して使用する必要があります", "This removes {{count}} failed models from this channel. This action cannot be undone.": "この操作はこのチャンネルから失敗した {{count}} 個のモデルを削除します。元に戻せません。", @@ -4581,6 +4629,7 @@ "times": "回", "Timing": "所要時間", "Tip": "ヒント", + "Title": "タイトル", "to access this resource.": "このリソースにアクセスするには。", "To Anthropic Messages": "Anthropic Messages へ", "to confirm": "確認する", @@ -4739,6 +4788,7 @@ "UI granularity only — data is still aggregated hourly": "UIの粒度のみ — データは引き続き時間単位で集計されます", "Unable to estimate price for this deployment.": "このデプロイメントの価格を推定できません。", "Unable to generate chat link. Please contact your administrator.": "チャットリンクを生成できません。管理者にご連絡ください。", + "Unable to load availability": "可用性データを読み込めません", "Unable to load groups": "グループをロードできません", "Unable to load rankings": "ランキングを読み込めません", "Unable to load rankings data": "ランキングデータを読み込めません", @@ -4869,6 +4919,7 @@ "USD Exchange Rate": "USD 為替レート", "USD price per 1M input tokens.": "100万入力トークンあたりのUSD価格。", "USD price per 1M tokens.": "100万トークンあたりのUSD価格。", + "Use “Open in new tab” for sites that block iframe embedding (for example Taobao / Xianyu short links).": "iframe 埋め込みを拒否するサイトでは「新しいタブで開く」を選んでください。", "Use +: to add a group, -: to remove a default selectable group, or no prefix to append a group.": "+: はグループ追加、-: はデフォルト選択可能グループの削除、接頭辞なしはグループ追記に使います。", "Use a compatible browser or device with biometric authentication or a security key to register a Passkey.": "生体認証またはセキュリティキーを備えた互換性のあるブラウザまたはデバイスを使用して、パスキーを登録してください。", "Use a different stable value for each instance, then restart the service.": "インスタンスごとに異なる安定した値を使用し、その後サービスを再起動してください。", @@ -5021,6 +5072,7 @@ "Violation Marker": "違反マーカー", "vip": "vip", "VIP users with premium access": "プレミアムアクセス権を持つVIPユーザー", + "Visibility": "表示範囲", "Visible": "表示", "Vision": "ビジョン", "Vision, image / video, document chat": "ビジョン・画像/動画・ドキュメントチャット", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 960fc0acaa5a..6a665891521b 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -38,6 +38,8 @@ "{{count}} channel(s) enabled": "Включено {{count}} каналов", "{{count}} channel(s) failed to disable": "Не удалось отключить {{count}} каналов", "{{count}} channel(s) failed to enable": "Не удалось включить {{count}} каналов", + "{{count}} custom pages deleted. Click \"Save Settings\" to apply.": "Удалено страниц: {{count}}. Нажмите «Сохранить», чтобы применить.", + "{{count}} custom pages will be removed from the list.": "Из списка будет удалено страниц: {{count}}.", "{{count}} days ago": "{{count}} дней назад", "{{count}} days remaining": "Осталось {{count}} дней", "{{count}} disabled channel(s) deleted": "Удалено {{count}} отключённых каналов", @@ -118,6 +120,7 @@ "80,443,8080": "80,443,8080", "A billing multiplier. Lower ratios mean lower API call costs.": "Множитель тарификации. Чем ниже коэффициент, тем ниже стоимость вызовов API.", "A focused home for keys, balance, routing, and service health.": "Единый экран для ключей, баланса, маршрутов и состояния сервиса.", + "Abnormal": "Сбой", "About": "О проекте", "About {{days}} days left": "Примерно {{days}} дней", "Accept Unpriced Models": "Принимать модели без цены", @@ -174,6 +177,7 @@ "Add Condition": "Добавить условие", "Add credits": "Добавить средства", "Add custom model \"{{value}}\"": "Добавить пользовательскую модель «{{value}}»", + "Add Custom Page": "Добавить страницу", "Add discount tier": "Добавить уровень скидки", "Add each model or tag you want to include.": "Добавьте каждую модель или тег, который хотите включить.", "Add FAQ": "Добавить вопрос-ответ", @@ -239,6 +243,7 @@ "Administer user accounts and roles.": "Управление учетными записями пользователей и ролями.", "Administrator account": "Учетная запись администратора", "Administrator username": "Имя пользователя администратора", + "Admins only": "Только админы", "Advance next reset time": "Перенести следующее время сброса", "Advanced": "Расширенные", "Advanced Configuration": "Расширенная конфигурация", @@ -520,7 +525,9 @@ "Automatically replaces upstream callback URLs with the server address.": "Автоматически заменяет URL обратных вызовов upstream на адрес сервера.", "Automatically selects the best available group with circuit breaker mechanism": "Автоматически выбирает лучшую доступную группу с механизмом circuit breaker", "Automatically sync model list when upstream changes are detected": "Автоматически синхронизировать список моделей при обнаружении изменений у провайдера", + "Availability": "Доступность", "Availability (last 24h)": "Доступность (последние 24 ч)", + "Availability Monitor": "Мониторинг доступности", "Available": "Доступно", "Available credits are ordered by soonest expiration.": "Доступные сбросы отсортированы по ближайшему истечению.", "Available disk space": "Доступное дисковое пространство", @@ -535,6 +542,7 @@ "Average tokens per second sustained per group": "Средняя устойчивая пропускная способность (токенов/с) по группам", "Average TPM": "Среднее число транзакций в минуту", "Average TTFT": "Средний TTFT", + "Avg latency": "Средняя задержка", "AWS": "AWS", "AWS Bedrock Claude Compat": "AWS Bedrock Claude совместимость", "AWS Key Format": "Формат ключа AWS", @@ -814,6 +822,8 @@ "Choose the default charts, range, and time granularity for model analytics.": "Выберите графики, диапазон и временную детализацию по умолчанию для аналитики моделей.", "Choose where to fetch upstream metadata.": "Выберите, откуда получать метаданные вышестоящего источника.", "Choose which charts are selected by default when opening model analytics.": "Выберите графики, которые будут выбраны по умолчанию при открытии аналитики моделей.", + "Choose who can see the Availability Monitor entry in the Extensions sidebar.": "Кто видит мониторинг в меню Extensions.", + "Choose who can see this page in the Extensions sidebar.": "Кто видит эту страницу в меню Extensions.", "Clamped to": "Ограничено до", "Classic (Legacy Frontend)": "Классический (Старый интерфейс)", "Claude": "Клод", @@ -975,6 +985,8 @@ "Configure rate limiting rules for a specific user group.": "Настроить правила ограничения скорости для конкретной группы пользователей.", "Configure routes": "Настроить маршруты", "Configure the ratio for this group.": "Настроить коэффициент для этой группы.", + "Configure the sidebar title, icon, embed URL, status, and sort order.": "Настройте заголовок, значок, URL встраивания, статус и порядок.", + "Configure the sidebar title, icon, URL, open mode, status, and sort order.": "Настройте заголовок, значок, URL, способ открытия, статус и порядок.", "Configure upstream providers and routing.": "Настроить провайдеров верхнего уровня и маршрутизацию.", "Configure Waffo Pancake hosted checkout integration for USD-priced top-ups": "Настроить хостовую интеграцию Waffo Pancake (hosted checkout) для пополнений в USD", "Configure Waffo payment aggregation platform integration": "Настроить интеграцию платёжной платформы Waffo", @@ -1215,6 +1227,12 @@ "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "Пользовательские множители, когда определенные группы пользователей используют определенные группы токенов. Пример: VIP-пользователи получают ставку 0.9x при использовании токенов группы \"edit_this\".", "Custom OAuth": "Пользовательский OAuth", "Custom OAuth Providers": "Пользовательские OAuth-провайдеры", + "Custom page added. Click \"Save Settings\" to apply.": "Страница добавлена. Нажмите «Сохранить», чтобы применить.", + "Custom page deleted. Click \"Save Settings\" to apply.": "Страница удалена. Нажмите «Сохранить», чтобы применить.", + "Custom page not found": "Пользовательская страница не найдена", + "Custom page updated. Click \"Save Settings\" to apply.": "Страница обновлена. Нажмите «Сохранить», чтобы применить.", + "Custom Pages": "Пользовательские страницы", + "Custom pages saved successfully": "Пользовательские страницы сохранены", "Custom Seconds": "Пользовательские секунды", "Custom sidebar section": "Пользовательский раздел боковой панели", "Custom Time Range": "Пользовательский диапазон времени", @@ -1431,6 +1449,7 @@ "Do string replacement in the target field": "Выполнить замену строки в целевом поле", "Do you want to download the created redemption codes as a text file?": "Скачать созданные коды пополнения в виде текстового файла?", "Docs": "Документы", + "Documentation": "Документация", "Documentation Link": "Ссылка на документацию", "Documentation or external knowledge base.": "Документация или внешняя база знаний.", "does not exist or might have been removed.": "не существует или, возможно, был удален.", @@ -1523,6 +1542,7 @@ "Edit Channel": "Редактировать канал", "Edit channel routing": "Изменение маршрутизации каналов", "Edit chat preset": "Редактировать пресет чата", + "Edit Custom Page": "Изменить страницу", "Edit discount tier": "Редактировать уровень скидки", "Edit FAQ": "Редактировать FAQ", "Edit group": "Редактировать группу", @@ -1559,6 +1579,7 @@ "Email Field": "Поле email", "Email Verification": "Верификация Email", "Email, summarisation, knowledge work": "Электронная почта, резюме, knowledge work", + "Embed in console": "Встроить в консоль", "Embeddings": "Встраивания", "Empty": "Пусто", "Empty value will be saved as {}.": "Пустое значение будет сохранено как {}.", @@ -1566,6 +1587,7 @@ "Enable {{parameter}}": "Включить {{parameter}}", "Enable 2FA": "Включить 2FA", "Enable All": "Включить все", + "Enable availability monitor": "Включить мониторинг", "Enable check-in feature": "Включить функцию прибытия", "Enable Data Dashboard": "Включить панель данных", "Enable demo mode with limited functionality": "Включить демонстрационный режим с ограниченной функциональностью", @@ -1608,6 +1630,7 @@ "Enabled": "Включено", "Enabled all channels with tag: {{tag}}": "Все каналы с тегом {{tag}} включены", "Enabled channels with tag {{tag}}": "Включены каналы с тегом {{tag}}", + "Enabled pages with a URL appear under the Extensions group in the console sidebar and open as embedded pages.": "Включённые страницы с URL появляются в группе «Расширения» боковой панели и открываются как встроенные.", "Enabled Status": "Статус включения", "Enabling...": "Включается...", "Encourages introducing new topics": "Поощряет введение новых тем", @@ -1723,6 +1746,7 @@ "Estimated cost": "Примерная стоимость", "Estimated quota cost": "Ориентир стоимости квоты", "Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.": "Каждое имя группы из таблицы тарифов используется в двух местах: у пользователя (группа пользователя, назначается администратором) и у токена (группа токена, выбирается при создании). Один набор имён — две разные роли.", + "Everyone": "Все", "Everything configured for this group, in one place.": "Все настройки этой группы в одном месте.", "Exact": "Точное", "Exact Match": "Точное совпадение", @@ -1767,6 +1791,7 @@ "Extend deployment": "Продлить развертывание", "Extend failed": "Не удалось продлить", "Extended successfully": "Продлено успешно", + "Extensions": "Расширения", "External Device": "Внешнее устройство", "External link for users to purchase quota": "Внешняя ссылка для пользователей для покупки квоты", "External operations": "Внешние операции", @@ -1845,6 +1870,7 @@ "Failed to initialize system": "Не удалось инициализировать систему", "Failed to load": "Не удалось загрузить", "Failed to load API keys": "Не удалось загрузить API ключи", + "Failed to load availability": "Ошибка загрузки доступности", "Failed to load billing history": "Не удалось загрузить историю платежей", "Failed to load enabled models": "Не удалось загрузить включённые модели", "Failed to load home page content": "Не удалось загрузить содержимое главной страницы", @@ -1876,6 +1902,7 @@ "Failed to save": "Не удалось сохранить", "Failed to save announcements": "Не удалось сохранить объявления", "Failed to save API info": "Не удалось сохранить информацию API", + "Failed to save custom pages": "Не удалось сохранить пользовательские страницы", "Failed to save FAQ": "Не удалось сохранить FAQ", "Failed to save Uptime Kuma groups": "Не удалось сохранить группы Uptime Kuma", "Failed to search API keys": "Не удалось найти API ключи", @@ -2532,6 +2559,7 @@ "Logs": "Журналы", "Look for a special ratio rule matching this user group and this billing group. If one exists, use its ratio. Otherwise use the billing group base ratio from the pricing table.": "Найдите правило особого коэффициента для этой группы пользователя и тарифной группы. Если оно есть — используется его коэффициент, иначе базовый коэффициент тарифной группы.", "Low balance": "Низкий баланс", + "Lower numbers appear first in the sidebar.": "Меньшие числа отображаются выше в боковой панели.", "Lowest median first-token latency": "Минимальная медианная задержка первого токена", "m": "m", "Maintenance": "Обслуживание", @@ -2776,6 +2804,7 @@ "Multipliers for recharge pricing based on user groups.": "Множители для ценообразования пополнения на основе групп пользователей.", "Must be a valid URL": "Должен быть действительный URL", "Must be at least 8 characters": "Должно быть не менее 8 символов", + "Must be http(s). Leave empty to keep the page hidden from the sidebar.": "Должен быть http(s). Оставьте пустым, чтобы скрыть страницу.", "My Subscriptions": "Мои подписки", "my-status": "мой-статус", "MySQL detected": "Обнаружен MySQL", @@ -2846,6 +2875,7 @@ "No available Web chat links": "Нет доступных веб-ссылок для чата", "No backup": "Нет резервной копии", "No base input price": "Нет базовой цены входа", + "No billing groups configured.": "Группы биллинга не настроены.", "No billing records found": "Записи о выставлении счетов не найдены", "No capabilities reported for this model.": "Для этой модели не указаны возможности.", "No Change": "Без изменений", @@ -2867,6 +2897,7 @@ "No containers": "Нет контейнеров", "No content to copy": "Нет содержимого для копирования", "No custom OAuth providers configured yet.": "Пользовательские поставщики OAuth еще не настроены.", + "No custom pages yet. Click \"Add Custom Page\" to create one.": "Пользовательских страниц пока нет. Нажмите «Добавить страницу».", "No data": "Нет данных", "No Data": "Нет данных", "No data available": "Нет доступных данных", @@ -2950,6 +2981,7 @@ "No providers available": "Нет доступных провайдеров", "No Quota": "Нет квоты", "No ratio differences found": "Различия в коэффициентах не найдены", + "No recent requests for this group.": "Нет недавних запросов для группы.", "No recent usage": "Нет недавнего использования", "No records found. Try adjusting your filters.": "Записи не найдены. Попробуйте изменить фильтры.", "No redemption codes available. Create your first redemption code to get started.": "Нет доступных кодов активации. Создайте свой первый код активации, чтобы начать.", @@ -3001,6 +3033,7 @@ "Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "Ненулевые награды за приглашения требуют подтверждения соответствия в настройках платежного шлюза.", "None": "Нет", "noreply@example.com": "noreply@example.com", + "Normal": "Норма", "Normalized:": "Нормализовано:", "Not available": "Недоступно", "Not backed up": "Не сохранено", @@ -3021,6 +3054,7 @@ "Notification Email": "Электронная почта для уведомлений", "Notification Method": "Метод уведомления", "Notifications": "Уведомления", + "Now": "Сейчас", "Now a user whose user group is vip creates tokens with different groups and makes one call with each:": "Теперь пользователь с группой vip создаёт токены с разными группами и делает по одному вызову с каждым:", "Nucleus sampling probability mass": "Накопленная вероятность для nucleus-сэмплинга", "Number of codes to create": "Количество кодов для создания", @@ -3081,6 +3115,7 @@ "Only available for admins. When enabled, you will receive a summary notification via your selected method when the scheduled model check detects upstream model changes or check failures.": "Доступно только для администраторов. При включении вы будете получать сводное уведомление выбранным способом, когда запланированная проверка моделей обнаружит изменения в вышестоящих моделях или сбои проверки.", "Only configured combinations are overridden. All other calls keep the billing group base ratio.": "Переопределяются только настроенные комбинации. Остальные вызовы используют базовый коэффициент тарифной группы.", "Only configured combinations are overridden. All other calls keep the token group base ratio.": "Переопределяются только настроенные комбинации. Остальные вызовы используют базовый коэффициент группы токена.", + "Only enabled pages with a URL are shown in the Extensions sidebar group.": "В группе «Расширения» показываются только включённые страницы с URL.", "Only enabled parameters are sent with the request.": "С запросом отправляются только включенные параметры.", "Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "Введите только origin сайта, например https://api.example.com. Не добавляйте пути, например /api/user/epay/notify. Оставьте пустым, чтобы использовать адрес сервера.", "Only Mine": "Только мои", @@ -3098,6 +3133,7 @@ "Open in new tab": "Открыть в новой вкладке", "Open in New Tab": "Открыть в новой вкладке", "Open menu": "Открыть меню", + "Open mode": "Способ открытия", "Open release": "Открыть выпуск", "Open source": "Открытый исходный код", "Open Source": "Открытый исходный код", @@ -3264,6 +3300,7 @@ "Password reset: {{password}}": "Пароль сброшен: {{password}}", "Passwords do not match": "Пароли не совпадают", "Passwords don't match.": "Пароли не совпадают.", + "Past": "Прошлое", "Paste Connection Info": "Вставить данные подключения", "Path": "Путь", "Path not set": "Путь не задан", @@ -3624,6 +3661,7 @@ "Receive Upstream Model Update Notifications": "Получать уведомления об обновлениях вышестоящих моделей", "Received": "Получено", "Received amount": "Полученная сумма", + "Recent {{count}} records": "Последние {{count}} записей", "Recent maintenance tasks running across instances and their execution status.": "Недавние задачи обслуживания, выполняемые на всех экземплярах, и их статус выполнения.", "Recently completed or failed system task runs.": "Недавние запуски системных задач, завершенные или завершившиеся с ошибкой.", "Recently launched models": "Недавно запущенные модели", @@ -3672,6 +3710,7 @@ "Refresh Cache": "Обновить кэш", "Refresh credential": "Обновить учётные данные", "Refresh details": "Обновить сведения", + "Refresh every {{seconds}}s": "Обновление каждые {{seconds}} с", "Refresh failed": "Ошибка обновления", "Refresh interval (minutes)": "Интервал обновления (минуты)", "Refresh Stats": "Обновить статистику", @@ -3759,6 +3798,7 @@ "Request Header Field": "Поле заголовка запроса", "Request Header Override": "Переопределение заголовков запроса", "Request Header Overrides": "Переопределения заголовков запроса", + "Request health by billing group for the latest 100 consume/error logs. Green bars show latency; red bars are failures. Badge uses overall success rate (≥95% normal, ≥80% warning, below 80% abnormal).": "Здоровье запросов по группам (100 последних логов). Зелёные — задержка, красные — ошибки. Значок по проценту успеха.", "Request ID": "ID запроса", "Request Limits": "Лимиты запросов", "Request Model": "Запрошенная модель", @@ -4024,6 +4064,7 @@ "Select all (filtered)": "& Выбрать все отфильтрованные", "Select all models": "Выбрать все модели", "Select All Visible": "Выбрать все видимые", + "Select an icon": "Выберите значок", "Select an operation mode and enter the amount": "Выберите режим операции и введите сумму", "Select announcement type": "Выбрать тип объявления", "Select at least one field to overwrite.": "Выберите хотя бы одно поле для перезаписи.", @@ -4057,6 +4098,7 @@ "Select models or add custom ones": "Выбрать модели или добавить пользовательские", "Select models to process. Unselected \"add\" models will be ignored.": "Выберите модели для обработки. Невыбранные модели «добавить» будут проигнорированы.", "Select models to run batch tests.": "Выберите модели для запуска пакетных тестов.", + "Select open mode": "Выберите способ открытия", "Select or enter color value": "Выбрать или ввести значение цвета", "Select or enter method identifier": "Выберите или введите идентификатор способа", "Select or enter model name": "Выберите или введите имя модели", @@ -4082,6 +4124,7 @@ "Select theme preset": "Выберите пресет темы", "Select time granularity": "Выбрать детализацию времени", "Select vendor": "Выбрать поставщика", + "Select visibility": "Выберите видимость", "Selectable groups": "Выбираемые группы", "selected": "выбрано", "Selected {{count}}": "Выбрано: {{count}}", @@ -4165,6 +4208,8 @@ "Showcase core capabilities with demo credentials and limited access.": "Демонстрация основных возможностей с демо-учётными данными и ограниченным доступом.", "Showing": "Отображать", "showing •": "отображается •", + "Shown in the console sidebar. Maximum 100 characters.": "Отображается в боковой панели. Максимум 100 символов.", + "Shows a group-level request heartbeat chart under Extensions. Failed requests require ERROR_LOG_ENABLED.": "Показывает график запросов по группам в Extensions. Ошибки требуют ERROR_LOG_ENABLED.", "Sidebar": "Боковая панель", "Sidebar collapsed by default for new users": "Боковая панель свернута по умолчанию для новых пользователей", "Sidebar modules": "Модули боковой панели", @@ -4470,6 +4515,7 @@ "The name displayed across the application": "Имя, отображаемое в приложении", "The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations": "Публичный URL вашего сервера, используемый для OAuth-перенаправлений, вебхуков и других внешних интеграций", "The requested chat preset does not exist or has been removed.": "Запрошенный предустановленный чат не существует или был удален.", + "The requested page does not exist, is disabled, or has no URL configured.": "Запрошенная страница не существует, отключена или без URL.", "The reset request stays disabled until a credit is available.": "Запрос сброса недоступен, пока нет доступного сброса.", "The setup wizard will use this database during initialization.": "Мастер настройки будет использовать эту базу данных при инициализации.", "The site is not available at the moment.": "Сайт в данный момент недоступен.", @@ -4508,6 +4554,7 @@ "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 custom page will be removed from the list.": "Эта страница будет удалена из списка.", "This data may be unreliable, use with caution": "Эти данные могут быть ненадежными, используйте с осторожностью", "This device does not support Passkey": "Это устройство не поддерживает Passkey", "This device does not support Passkey verification.": "Это устройство не поддерживает проверку с помощью Passkey.", @@ -4527,6 +4574,7 @@ "This model is not available in any group, or no group pricing information is configured.": "Эта модель недоступна ни в одной группе, или информация о ценах для групп не настроена.", "This month": "В этом месяце", "This page has not been created yet.": "Эта страница еще не создана.", + "This page opens in a new browser tab because the target site cannot be embedded.": "Страница открывается в новой вкладке, так как сайт нельзя встроить.", "This plan does not allow balance redemption": "Этот план не разрешает оплату балансом", "This project must be used in compliance with the": "Этот проект должен использоваться в соответствии с", "This removes {{count}} failed models from this channel. This action cannot be undone.": "Это удалит {{count}} неуспешных моделей из этого канала. Действие необратимо.", @@ -4581,6 +4629,7 @@ "times": "раз", "Timing": "Время", "Tip": "Совет", + "Title": "Название", "to access this resource.": "для доступа к этому ресурсу.", "To Anthropic Messages": "В Anthropic Messages", "to confirm": "для подтверждения", @@ -4739,6 +4788,7 @@ "UI granularity only — data is still aggregated hourly": "Только детализация пользовательского интерфейса — данные по-прежнему агрегируются ежечасно", "Unable to estimate price for this deployment.": "Не удается оценить цену для этого развертывания.", "Unable to generate chat link. Please contact your administrator.": "Не удалось сгенерировать ссылку для чата. Пожалуйста, свяжитесь с вашим администратором.", + "Unable to load availability": "Не удалось загрузить доступность", "Unable to load groups": "Не удалось загрузить группы", "Unable to load rankings": "Не удалось загрузить рейтинги", "Unable to load rankings data": "Не удалось загрузить данные рейтингов", @@ -4869,6 +4919,7 @@ "USD Exchange Rate": "Обменный курс USD", "USD price per 1M input tokens.": "Цена в USD за 1 млн входных токенов.", "USD price per 1M tokens.": "Цена в USD за 1 млн токенов.", + "Use “Open in new tab” for sites that block iframe embedding (for example Taobao / Xianyu short links).": "Для сайтов, блокирующих iframe, выберите «Открыть в новой вкладке».", "Use +: to add a group, -: to remove a default selectable group, or no prefix to append a group.": "Используйте +: для добавления группы, -: для удаления выбираемой по умолчанию группы, без префикса — для добавления в конец.", "Use a compatible browser or device with biometric authentication or a security key to register a Passkey.": "Используйте совместимый браузер или устройство с биометрической аутентификацией или ключ безопасности для регистрации ключа доступа.", "Use a different stable value for each instance, then restart the service.": "Используйте разные стабильные значения для каждого экземпляра, затем перезапустите сервис.", @@ -5021,6 +5072,7 @@ "Violation Marker": "Маркер нарушения", "vip": "vip", "VIP users with premium access": "VIP-пользователи с премиум-доступом", + "Visibility": "Видимость", "Visible": "Видима", "Vision": "Зрение", "Vision, image / video, document chat": "Зрение, изображения / видео, чат по документам", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index da843b9f8948..2de57f7d1ac3 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -38,6 +38,8 @@ "{{count}} channel(s) enabled": "Đã bật {{count}} kênh", "{{count}} channel(s) failed to disable": "{{count}} kênh không thể tắt", "{{count}} channel(s) failed to enable": "{{count}} kênh không thể bật", + "{{count}} custom pages deleted. Click \"Save Settings\" to apply.": "Đã xóa {{count}} trang. Nhấn “Lưu cài đặt” để áp dụng.", + "{{count}} custom pages will be removed from the list.": "{{count}} trang tùy chỉnh sẽ bị xóa khỏi danh sách.", "{{count}} days ago": "{{count}} ngày trước", "{{count}} days remaining": "{{count}} days remaining", "{{count}} disabled channel(s) deleted": "Đã xóa {{count}} kênh đã tắt", @@ -118,6 +120,7 @@ "80,443,8080": "80,443,8080", "A billing multiplier. Lower ratios mean lower API call costs.": "Hệ số tính phí. Tỷ lệ càng thấp thì chi phí gọi API càng thấp.", "A focused home for keys, balance, routing, and service health.": "Trang tổng quan tập trung cho khóa, số dư, định tuyến và trạng thái dịch vụ.", + "Abnormal": "Bất thường", "About": "Giới thiệu", "About {{days}} days left": "Còn khoảng {{days}} ngày", "Accept Unpriced Models": "Chấp nhận các Mô hình chưa định giá", @@ -174,6 +177,7 @@ "Add Condition": "Thêm điều kiện", "Add credits": "Thêm tín dụng", "Add custom model \"{{value}}\"": "Thêm mô hình tùy chỉnh \"{{value}}\"", + "Add Custom Page": "Thêm trang tùy chỉnh", "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", @@ -239,6 +243,7 @@ "Administer user accounts and roles.": "Quản lý tài khoản người dùng và vai trò.", "Administrator account": "Tài khoản quản trị viên", "Administrator username": "Tên người dùng quản trị viên", + "Admins only": "Chỉ quản trị", "Advance next reset time": "Dời thời gian đặt lại tiếp theo", "Advanced": "Nâng cao", "Advanced Configuration": "Cấu hình nâng cao", @@ -520,7 +525,9 @@ "Automatically replaces upstream callback URLs with the server address.": "Tự động thay thế URL callback upstream bằng địa chỉ máy chủ.", "Automatically selects the best available group with circuit breaker mechanism": "Tự động chọn nhóm tốt nhất hiện có với cơ chế ngắt mạch", "Automatically sync model list when upstream changes are detected": "Tự động đồng bộ danh sách mô hình khi phát hiện thay đổi từ nguồn", + "Availability": "Khả dụng", "Availability (last 24h)": "Khả dụng (24 giờ qua)", + "Availability Monitor": "Giám sát khả dụng", "Available": "Khả dụng", "Available credits are ordered by soonest expiration.": "Các lượt khả dụng được sắp xếp theo thời điểm hết hạn gần nhất.", "Available disk space": "Dung lượng đĩa khả dụng", @@ -535,6 +542,7 @@ "Average tokens per second sustained per group": "Số token mỗi giây trung bình duy trì cho từng nhóm", "Average TPM": "TPM trung bình", "Average TTFT": "TTFT trung bình", + "Avg latency": "Độ trễ TB", "AWS": "AWS", "AWS Bedrock Claude Compat": "AWS Bedrock Claude tương thích", "AWS Key Format": "Định dạng khóa AWS", @@ -814,6 +822,8 @@ "Choose the default charts, range, and time granularity for model analytics.": "Chọn biểu đồ, khoảng thời gian và độ chi tiết thời gian mặc định cho phân tích mô hình.", "Choose where to fetch upstream metadata.": "Chọn nơi để tìm nạp siêu dữ liệu thượng nguồn.", "Choose which charts are selected by default when opening model analytics.": "Chọn biểu đồ được chọn mặc định khi mở phân tích mô hình.", + "Choose who can see the Availability Monitor entry in the Extensions sidebar.": "Chọn ai thấy mục giám sát trong Extensions.", + "Choose who can see this page in the Extensions sidebar.": "Chọn ai thấy trang này trong Extensions.", "Clamped to": "Giới hạn thành", "Classic (Legacy Frontend)": "Cổ điển (Frontend cũ)", "Claude": "Claude", @@ -975,6 +985,8 @@ "Configure rate limiting rules for a specific user group.": "Cấu hình quy tắc giới hạn tốc độ cho một nhóm người dùng cụ thể.", "Configure routes": "Cấu hình route", "Configure the ratio for this group.": "Cấu hình tỷ lệ cho nhóm này.", + "Configure the sidebar title, icon, embed URL, status, and sort order.": "Cấu hình tiêu đề, biểu tượng, URL nhúng, trạng thái và thứ tự.", + "Configure the sidebar title, icon, URL, open mode, status, and sort order.": "Cấu hình tiêu đề, biểu tượng, URL, cách mở, trạng thái và thứ tự.", "Configure upstream providers and routing.": "Cấu hình nhà cung cấp upstream và định tuyến.", "Configure Waffo Pancake hosted checkout integration for USD-priced top-ups": "Cấu hình tích hợp thanh toán Waffo Pancake (hosted checkout) cho nạp tiền theo USD", "Configure Waffo payment aggregation platform integration": "Cấu hình tích hợp nền tảng tổng hợp thanh toán Waffo", @@ -1215,6 +1227,12 @@ "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "Các hệ số nhân tùy chỉnh khi các nhóm người dùng cụ thể sử dụng các nhóm token cụ thể. Ví dụ: Người dùng VIP được hưởng tỷ lệ 0.9x khi sử dụng các token thuộc nhóm \"edit_this\".", "Custom OAuth": "OAuth tùy chỉnh", "Custom OAuth Providers": "Nhà cung cấp OAuth tùy chỉnh", + "Custom page added. Click \"Save Settings\" to apply.": "Đã thêm trang. Nhấn “Lưu cài đặt” để áp dụng.", + "Custom page deleted. Click \"Save Settings\" to apply.": "Đã xóa trang. Nhấn “Lưu cài đặt” để áp dụng.", + "Custom page not found": "Không tìm thấy trang tùy chỉnh", + "Custom page updated. Click \"Save Settings\" to apply.": "Đã cập nhật trang. Nhấn “Lưu cài đặt” để áp dụng.", + "Custom Pages": "Trang tùy chỉnh", + "Custom pages saved successfully": "Đã lưu trang tùy chỉnh", "Custom Seconds": "Giây tùy chỉnh", "Custom sidebar section": "Phần thanh bên tùy chỉnh", "Custom Time Range": "Khoảng thời gian tùy chỉnh", @@ -1431,6 +1449,7 @@ "Do string replacement in the target field": "Thực hiện thay thế chuỗi trong trường đích", "Do you want to download the created redemption codes as a text file?": "Bạn có muốn tải xuống các mã đổi thưởng vừa tạo dưới dạng tệp văn bản không?", "Docs": "Tài liệu", + "Documentation": "Tài liệu", "Documentation Link": "Liên kết tài liệu", "Documentation or external knowledge base.": "Tài liệu hoặc cơ sở kiến thức bên ngoài.", "does not exist or might have been removed.": "không tồn tại hoặc có thể đã bị xóa.", @@ -1523,6 +1542,7 @@ "Edit Channel": "Chỉnh sửa Kênh", "Edit channel routing": "Chỉnh sửa định tuyến kênh", "Edit chat preset": "Chỉnh sửa cài đặt trước trò chuyện", + "Edit Custom Page": "Sửa trang tùy chỉnh", "Edit discount tier": "Chỉnh sửa bậc giảm giá", "Edit FAQ": "Chỉnh sửa câu hỏi thường gặp", "Edit group": "Sửa nhóm", @@ -1559,6 +1579,7 @@ "Email Field": "Trường Email", "Email Verification": "Xác minh Email", "Email, summarisation, knowledge work": "Email, tóm tắt, làm việc tri thức", + "Embed in console": "Nhúng trong console", "Embeddings": "Embeddings", "Empty": "Trống", "Empty value will be saved as {}.": "Giá trị trống sẽ được lưu thành {}.", @@ -1566,6 +1587,7 @@ "Enable {{parameter}}": "Bật {{parameter}}", "Enable 2FA": "Bật 2FA", "Enable All": "Bật tất cả", + "Enable availability monitor": "Bật giám sát khả dụng", "Enable check-in feature": "Bật tính năng điểm danh", "Enable Data Dashboard": "Kích hoạt Trang tổng quan Dữ liệu", "Enable demo mode with limited functionality": "Bật chế độ demo với chức năng hạn chế", @@ -1608,6 +1630,7 @@ "Enabled": "Đã bật", "Enabled all channels with tag: {{tag}}": "Đã bật tất cả kênh với nhãn: {{tag}}", "Enabled channels with tag {{tag}}": "Đã kích hoạt các kênh có thẻ {{tag}}", + "Enabled pages with a URL appear under the Extensions group in the console sidebar and open as embedded pages.": "Các trang đã bật và có URL sẽ hiện trong nhóm Mở rộng trên thanh bên và mở dạng nhúng.", "Enabled Status": "Trạng thái kích hoạt", "Enabling...": "Đang bật...", "Encourages introducing new topics": "Khuyến khích chủ đề mới", @@ -1723,6 +1746,7 @@ "Estimated cost": "Chi phí ước tính", "Estimated quota cost": "Ước tính chi phí hạn mức", "Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.": "Mỗi tên nhóm trong bảng định giá có thể dùng ở hai nơi: trên người dùng (nhóm người dùng, do quản trị viên gán) và trên token (nhóm token, chọn khi tạo token). Cùng một bộ tên, hai vai trò khác nhau.", + "Everyone": "Tất cả mọi người", "Everything configured for this group, in one place.": "Toàn bộ cấu hình của nhóm này, tại một nơi.", "Exact": "Chính xác", "Exact Match": "Khớp chính xác", @@ -1767,6 +1791,7 @@ "Extend deployment": "Gia hạn triển khai", "Extend failed": "Gia hạn thất bại", "Extended successfully": "Gia hạn thành công", + "Extensions": "Mở rộng", "External Device": "Thiết bị ngoại vi", "External link for users to purchase quota": "Liên kết ngoài để người dùng mua hạn mức", "External operations": "Vận hành bên ngoài", @@ -1845,6 +1870,7 @@ "Failed to initialize system": "Không thể khởi tạo hệ thống", "Failed to load": "Tải thất bại", "Failed to load API keys": "Không thể tải khóa API", + "Failed to load availability": "Tải dữ liệu khả dụng thất bại", "Failed to load billing history": "Không thể tải lịch sử thanh toán", "Failed to load enabled models": "Không thể tải các mô hình đã bật", "Failed to load home page content": "Không thể tải nội dung trang chủ", @@ -1876,6 +1902,7 @@ "Failed to save": "Lưu thất bại", "Failed to save announcements": "Không thể lưu thông báo", "Failed to save API info": "Không thể lưu thông tin API", + "Failed to save custom pages": "Lưu trang tùy chỉnh thất bại", "Failed to save FAQ": "Không thể lưu FAQ", "Failed to save Uptime Kuma groups": "Không thể lưu nhóm Uptime Kuma", "Failed to search API keys": "Không thể tìm kiếm khóa API", @@ -2532,6 +2559,7 @@ "Logs": "Nhật ký", "Look for a special ratio rule matching this user group and this billing group. If one exists, use its ratio. Otherwise use the billing group base ratio from the pricing table.": "Tìm quy tắc hệ số đặc biệt khớp với nhóm người dùng và nhóm tính phí này. Nếu có thì dùng hệ số của quy tắc, nếu không thì dùng hệ số cơ bản của nhóm tính phí trong bảng định giá.", "Low balance": "Số dư thấp", + "Lower numbers appear first in the sidebar.": "Số nhỏ hơn sẽ xuất hiện trước trên thanh bên.", "Lowest median first-token latency": "Độ trễ trung vị token đầu tiên thấp nhất", "m": "m", "Maintenance": "Bảo trì", @@ -2776,6 +2804,7 @@ "Multipliers for recharge pricing based on user groups.": "Hệ số nhân cho việc định giá nạp tiền dựa trên nhóm người dùng.", "Must be a valid URL": "Phải là URL hợp lệ", "Must be at least 8 characters": "Phải có ít nhất 8 ký tự", + "Must be http(s). Leave empty to keep the page hidden from the sidebar.": "Phải là http(s). Để trống thì trang sẽ bị ẩn khỏi thanh bên.", "My Subscriptions": "Gói đăng ký của tôi", "my-status": "trạng thái của tôi", "MySQL detected": "Đã phát hiện MySQL", @@ -2846,6 +2875,7 @@ "No available Web chat links": "Không có liên kết Web chat khả dụng", "No backup": "Chưa sao lưu", "No base input price": "Chưa có giá đầu vào cơ bản", + "No billing groups configured.": "Chưa cấu hình nhóm thanh toán.", "No billing records found": "Không tìm thấy hồ sơ thanh toán", "No capabilities reported for this model.": "Chưa có khả năng nào được báo cáo cho mô hình này.", "No Change": "Không thay đổi", @@ -2867,6 +2897,7 @@ "No containers": "Không có container", "No content to copy": "Không có nội dung để sao chép", "No custom OAuth providers configured yet.": "Chưa có nhà cung cấp OAuth tùy chỉnh nào được cấu hình.", + "No custom pages yet. Click \"Add Custom Page\" to create one.": "Chưa có trang tùy chỉnh. Nhấn “Thêm trang tùy chỉnh” để tạo.", "No data": "Không có dữ liệu", "No Data": "Không có dữ liệu", "No data available": "Không có dữ liệu", @@ -2950,6 +2981,7 @@ "No providers available": "Không có nhà cung cấp khả dụng", "No Quota": "Không hạn ngạch", "No ratio differences found": "Không tìm thấy sự khác biệt tỷ lệ", + "No recent requests for this group.": "Nhóm này chưa có yêu cầu gần đây.", "No recent usage": "Chưa có sử dụng gần đây", "No records found. Try adjusting your filters.": "Không tìm thấy bản ghi nào. Hãy thử điều chỉnh bộ lọc của bạn.", "No redemption codes available. Create your first redemption code to get started.": "Hiện không có mã đổi thưởng nào. Hãy tạo mã đổi thưởng đầu tiên của bạn để bắt đầu.", @@ -3001,6 +3033,7 @@ "Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "Phần thưởng mời khác 0 yêu cầu xác nhận tuân thủ trong cài đặt Cổng thanh toán.", "None": "Không có", "noreply@example.com": "noreply@example.com", + "Normal": "Bình thường", "Normalized:": "Chuẩn hóa:", "Not available": "Không khả dụng", "Not backed up": "Chưa sao lưu", @@ -3021,6 +3054,7 @@ "Notification Email": "Email thông báo", "Notification Method": "Phương thức thông báo", "Notifications": "Thông báo", + "Now": "Hiện tại", "Now a user whose user group is vip creates tokens with different groups and makes one call with each:": "Bây giờ, một người dùng có nhóm người dùng là vip tạo các token với nhóm khác nhau và gọi mỗi token một lần:", "Nucleus sampling probability mass": "Tổng xác suất cho nucleus sampling", "Number of codes to create": "Số mã cần tạo", @@ -3081,6 +3115,7 @@ "Only available for admins. When enabled, you will receive a summary notification via your selected method when the scheduled model check detects upstream model changes or check failures.": "Chỉ khả dụng cho quản trị viên. Khi bật, bạn sẽ nhận được thông báo tổng hợp qua phương thức đã chọn khi kiểm tra mô hình định kỳ phát hiện thay đổi mô hình nguồn hoặc lỗi kiểm tra.", "Only configured combinations are overridden. All other calls keep the billing group base ratio.": "Chỉ các tổ hợp đã cấu hình mới bị ghi đè. Các cuộc gọi khác vẫn dùng hệ số cơ bản của nhóm tính phí.", "Only configured combinations are overridden. All other calls keep the token group base ratio.": "Chỉ các tổ hợp đã cấu hình mới bị ghi đè. Các lệnh gọi khác giữ tỷ lệ cơ bản của nhóm token.", + "Only enabled pages with a URL are shown in the Extensions sidebar group.": "Chỉ các trang đã bật và có URL mới hiện trong nhóm Mở rộng.", "Only enabled parameters are sent with the request.": "Chỉ các tham số đã bật mới được gửi trong yêu cầu.", "Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "Chỉ nhập origin của trang, ví dụ https://api.example.com. Không nhập đường dẫn như /api/user/epay/notify. Để trống để dùng địa chỉ máy chủ.", "Only Mine": "Chỉ của tôi", @@ -3095,9 +3130,10 @@ "Open a source model first": "Mở một mô hình nguồn trước", "Open CC Switch": "Mở công tắc CC", "Open in chat": "Mở trong trò chuyện", - "Open in new tab": "Mở trong tab mới", + "Open in new tab": "Mở tab mới", "Open in New Tab": "Mở trong tab mới", "Open menu": "Mở menu", + "Open mode": "Cách mở", "Open release": "Phát hành mở", "Open source": "Mã nguồn mở", "Open Source": "Mã nguồn mở", @@ -3264,6 +3300,7 @@ "Password reset: {{password}}": "Mật khẩu đã đặt lại: {{password}}", "Passwords do not match": "Mật khẩu không khớp", "Passwords don't match.": "Mật khẩu không khớp.", + "Past": "Trước", "Paste Connection Info": "Dán thông tin kết nối", "Path": "Đường dẫn", "Path not set": "Chưa đặt đường dẫn", @@ -3624,6 +3661,7 @@ "Receive Upstream Model Update Notifications": "Nhận thông báo cập nhật mô hình nguồn", "Received": "Đã nhận", "Received amount": "Số tiền đã nhận", + "Recent {{count}} records": "{{count}} bản ghi gần đây", "Recent maintenance tasks running across instances and their execution status.": "Các tác vụ bảo trì gần đây chạy trên các phiên bản và trạng thái thực thi của chúng.", "Recently completed or failed system task runs.": "Các lần chạy tác vụ hệ thống gần đây đã hoàn tất hoặc thất bại.", "Recently launched models": "Các mô hình ra mắt gần đây", @@ -3672,6 +3710,7 @@ "Refresh Cache": "Làm mới bộ nhớ đệm", "Refresh credential": "Làm mới thông tin xác thực", "Refresh details": "Làm mới chi tiết", + "Refresh every {{seconds}}s": "Làm mới mỗi {{seconds}} giây", "Refresh failed": "Làm mới thất bại", "Refresh interval (minutes)": "Khoảng thời gian làm mới (phút)", "Refresh Stats": "Làm mới thống kê", @@ -3759,6 +3798,7 @@ "Request Header Field": "Trường header yêu cầu", "Request Header Override": "Ghi đè header yêu cầu", "Request Header Overrides": "Ghi đè Tiêu đề Yêu cầu", + "Request health by billing group for the latest 100 consume/error logs. Green bars show latency; red bars are failures. Badge uses overall success rate (≥95% normal, ≥80% warning, below 80% abnormal).": "Sức khỏe yêu cầu theo nhóm (100 log gần nhất). Thanh xanh = độ trễ, đỏ = lỗi. Huy hiệu theo tỷ lệ thành công.", "Request ID": "ID yêu cầu", "Request Limits": "Hạn mức yêu cầu", "Request Model": "Mô hình yêu cầu", @@ -4024,6 +4064,7 @@ "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 an icon": "Chọn biểu tượng", "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 đè.", @@ -4057,6 +4098,7 @@ "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", "Select models to process. Unselected \"add\" models will be ignored.": "Chọn các mô hình để xử lý. Các mô hình \"thêm\" không được chọn sẽ bị bỏ qua.", "Select models to run batch tests.": "Chọn mô hình để chạy kiểm thử hàng loạt.", + "Select open mode": "Chọn cách mở", "Select or enter color value": "Chọn hoặc nhập giá trị màu", "Select or enter method identifier": "Chọn hoặc nhập mã định danh phương thức", "Select or enter model name": "Chọn hoặc nhập tên mô hình", @@ -4082,6 +4124,7 @@ "Select theme preset": "Chọn tùy chỉnh chủ đề", "Select time granularity": "Chọn độ chi tiết thời gian", "Select vendor": "Chọn nhà cung cấp", + "Select visibility": "Chọn phạm vi hiển thị", "Selectable groups": "Nhóm có thể chọn", "selected": "đã chọn", "Selected {{count}}": "Đã chọn {{count}}", @@ -4165,6 +4208,8 @@ "Showcase core capabilities with demo credentials and limited access.": "Trình diễn các tính năng cốt lõi với thông tin đăng nhập demo và quyền truy cập hạn chế.", "Showing": "Đang hiển thị", "showing •": "hiển thị •", + "Shown in the console sidebar. Maximum 100 characters.": "Hiển thị trên thanh bên console. Tối đa 100 ký tự.", + "Shows a group-level request heartbeat chart under Extensions. Failed requests require ERROR_LOG_ENABLED.": "Hiển thị biểu đồ heartbeat theo nhóm trong Extensions. Lỗi cần ERROR_LOG_ENABLED.", "Sidebar": "Thanh bên", "Sidebar collapsed by default for new users": "Thanh bên được thu gọn theo mặc định đối với người dùng mới", "Sidebar modules": "Mô-đun thanh bên", @@ -4470,6 +4515,7 @@ "The name displayed across the application": "Tên hiển thị trên ứng dụng", "The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations": "URL công khai của máy chủ, dùng cho callback OAuth, webhook và các tích hợp bên ngoài khác", "The requested chat preset does not exist or has been removed.": "Cài đặt sẵn cuộc trò chuyện được yêu cầu không tồn tại hoặc đã bị xóa.", + "The requested page does not exist, is disabled, or has no URL configured.": "Trang yêu cầu không tồn tại, đã tắt, hoặc chưa cấu hình URL.", "The reset request stays disabled until a credit is available.": "Yêu cầu đặt lại sẽ bị tắt cho đến khi có lượt khả dụng.", "The setup wizard will use this database during initialization.": "Trình hướng dẫn thiết lập sẽ sử dụng cơ sở dữ liệu này trong quá trình khởi tạo.", "The site is not available at the moment.": "Trang web hiện không khả dụng.", @@ -4508,6 +4554,7 @@ "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 custom page will be removed from the list.": "Trang tùy chỉnh này sẽ bị xóa khỏi danh sách.", "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", "This device does not support Passkey": "Thiết bị này không hỗ trợ Passkey", "This device does not support Passkey verification.": "Thiết bị này không hỗ trợ xác minh Passkey.", @@ -4527,6 +4574,7 @@ "This model is not available in any group, or no group pricing information is configured.": "Mô hình này không khả dụng trong bất kỳ nhóm nào, hoặc thông tin giá nhóm chưa được cấu hình.", "This month": "Tháng này", "This page has not been created yet.": "Trang này chưa được tạo.", + "This page opens in a new browser tab because the target site cannot be embedded.": "Trang này mở ở tab mới vì trang đích không thể nhúng.", "This plan does not allow balance redemption": "Gói này không cho phép thanh toán bằng số dư", "This project must be used in compliance with the": "Dự án này phải được sử dụng tuân thủ theo", "This removes {{count}} failed models from this channel. This action cannot be undone.": "Thao tác này sẽ xóa {{count}} mô hình thất bại khỏi kênh này. Không thể hoàn tác.", @@ -4581,6 +4629,7 @@ "times": "lần", "Timing": "Thời gian", "Tip": "Mẹo", + "Title": "Tiêu đề", "to access this resource.": "để truy cập tài nguyên này.", "To Anthropic Messages": "Sang Anthropic Messages", "to confirm": "Chờ xác nhận", @@ -4739,6 +4788,7 @@ "UI granularity only — data is still aggregated hourly": "Chỉ là độ chi tiết UI — dữ liệu vẫn được tổng hợp theo giờ", "Unable to estimate price for this deployment.": "Không thể ước tính giá cho triển khai này.", "Unable to generate chat link. Please contact your administrator.": "Không thể tạo liên kết trò chuyện. Vui lòng liên hệ quản trị viên của bạn.", + "Unable to load availability": "Không tải được dữ liệu khả dụng", "Unable to load groups": "Không thể tải nhóm", "Unable to load rankings": "Không thể tải bảng xếp hạng", "Unable to load rankings data": "Không thể tải dữ liệu bảng xếp hạng", @@ -4869,6 +4919,7 @@ "USD Exchange Rate": "Tỷ giá USD", "USD price per 1M input tokens.": "Giá USD cho mỗi 1 triệu token đầu vào.", "USD price per 1M tokens.": "Giá USD cho mỗi 1 triệu token.", + "Use “Open in new tab” for sites that block iframe embedding (for example Taobao / Xianyu short links).": "Dùng “Mở tab mới” cho các trang chặn iframe (ví dụ liên kết ngắn Taobao / Xianyu).", "Use +: to add a group, -: to remove a default selectable group, or no prefix to append a group.": "Dùng +: để thêm nhóm, -: để xóa nhóm có thể chọn mặc định, hoặc không có tiền tố để nối nhóm.", "Use a compatible browser or device with biometric authentication or a security key to register a Passkey.": "Sử dụng trình duyệt hoặc thiết bị tương thích có xác thực sinh trắc học hoặc khóa bảo mật để đăng ký Khóa truy cập.", "Use a different stable value for each instance, then restart the service.": "Dùng một giá trị ổn định khác nhau cho mỗi phiên bản, sau đó khởi động lại dịch vụ.", @@ -5021,6 +5072,7 @@ "Violation Marker": "Đánh dấu vi phạm", "vip": "vip", "VIP users with premium access": "Người dùng VIP với quyền truy cập cao cấp", + "Visibility": "Phạm vi hiển thị", "Visible": "Hiển thị", "Vision": "Thị giác", "Vision, image / video, document chat": "Thị giác, ảnh / video, hỏi đáp tài liệu", diff --git a/web/default/src/i18n/locales/zh-TW.json b/web/default/src/i18n/locales/zh-TW.json index e861b644f974..8c02319918ed 100644 --- a/web/default/src/i18n/locales/zh-TW.json +++ b/web/default/src/i18n/locales/zh-TW.json @@ -38,6 +38,8 @@ "{{count}} channel(s) enabled": "已啟用 {{count}} 個渠道", "{{count}} channel(s) failed to disable": "{{count}} 個渠道停用失敗", "{{count}} channel(s) failed to enable": "{{count}} 個渠道啟用失敗", + "{{count}} custom pages deleted. Click \"Save Settings\" to apply.": "{{count}} custom pages deleted. Click \"Save Settings\" to apply.", + "{{count}} custom pages will be removed from the list.": "{{count}} custom pages will be removed from the list.", "{{count}} days ago": "{{count}} 日前", "{{count}} days remaining": "剩餘 {{count}} 日", "{{count}} disabled channel(s) deleted": "已刪除 {{count}} 個已停用的渠道", @@ -118,6 +120,7 @@ "80,443,8080": "80,443,8080", "A billing multiplier. Lower ratios mean lower API call costs.": "收費乘數,倍率越低,API 呼叫費用越低。", "A focused home for keys, balance, routing, and service health.": "集中展示金鑰、餘額、路由和服務健康狀態。", + "Abnormal": "Abnormal", "About": "關於", "About {{days}} days left": "約剩 {{days}} 日", "Accept Unpriced Models": "接受未定價模型", @@ -174,6 +177,7 @@ "Add Condition": "新增條件", "Add credits": "增加額度", "Add custom model \"{{value}}\"": "新增自訂模型「{{value}}」", + "Add Custom Page": "Add Custom Page", "Add discount tier": "新增折扣等級", "Add each model or tag you want to include.": "新增您想包含的每個模型或標籤。", "Add FAQ": "新增問答", @@ -239,6 +243,7 @@ "Administer user accounts and roles.": "管理用戶用戶和角色。", "Administrator account": "管理員用戶", "Administrator username": "管理員用戶名", + "Admins only": "Admins only", "Advance next reset time": "推進下次重置時間", "Advanced": "進階", "Advanced Configuration": "進階設定", @@ -520,7 +525,9 @@ "Automatically replaces upstream callback URLs with the server address.": "自動將上游Callback URL 替換為伺服器地址。", "Automatically selects the best available group with circuit breaker mechanism": "自動選擇可用分組,失敗時觸發熔斷切換", "Automatically sync model list when upstream changes are detected": "偵測到上游模型變更時自動同步模型清單", + "Availability": "Availability", "Availability (last 24h)": "可用率(最近 24 小時)", + "Availability Monitor": "Availability Monitor", "Available": "可用", "Available credits are ordered by soonest expiration.": "可用次數按最早到期排序。", "Available disk space": "可用磁碟空間", @@ -535,6 +542,7 @@ "Average tokens per second sustained per group": "各分組持續輸出的平均每秒 token 數", "Average TPM": "平均 TPM", "Average TTFT": "平均首 Token 延遲", + "Avg latency": "Avg latency", "AWS": "AWS", "AWS Bedrock Claude Compat": "AWS Bedrock Claude 兼容模板", "AWS Key Format": "AWS 金鑰格式", @@ -814,6 +822,8 @@ "Choose the default charts, range, and time granularity for model analytics.": "選擇模型呼叫分析的預設圖表、範圍和時間粒度。", "Choose where to fetch upstream metadata.": "選擇從何處獲取上游元數據。", "Choose which charts are selected by default when opening model analytics.": "選擇打開模型呼叫分析時預設選中的圖表。", + "Choose who can see the Availability Monitor entry in the Extensions sidebar.": "Choose who can see the Availability Monitor entry in the Extensions sidebar.", + "Choose who can see this page in the Extensions sidebar.": "Choose who can see this page in the Extensions sidebar.", "Clamped to": "限制為", "Classic (Legacy Frontend)": "經典前端", "Claude": "Claude", @@ -975,6 +985,8 @@ "Configure rate limiting rules for a specific user group.": "設定特定用戶分組的速率限制規則。", "Configure routes": "設定路由", "Configure the ratio for this group.": "設定此分組的比例。", + "Configure the sidebar title, icon, embed URL, status, and sort order.": "Configure the sidebar title, icon, embed URL, status, and sort order.", + "Configure the sidebar title, icon, URL, open mode, status, and sort order.": "Configure the sidebar title, icon, URL, open mode, status, and sort order.", "Configure upstream providers and routing.": "設定上游提供者和路由。", "Configure Waffo Pancake hosted checkout integration for USD-priced top-ups": "設定 Waffo Pancake 託管結帳,用於美元計價的儲值", "Configure Waffo payment aggregation platform integration": "設定 Waffo 支付聚合平台整合", @@ -1215,6 +1227,12 @@ "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "當特定用戶分組使用特定令牌分組時的自訂乘數。示例:VIP 用戶在使用「edit_this」分組令牌時獲得 0.9 倍費率。", "Custom OAuth": "自訂 OAuth", "Custom OAuth Providers": "自訂 OAuth 供應商", + "Custom page added. Click \"Save Settings\" to apply.": "Custom page added. Click \"Save Settings\" to apply.", + "Custom page deleted. Click \"Save Settings\" to apply.": "Custom page deleted. Click \"Save Settings\" to apply.", + "Custom page not found": "Custom page not found", + "Custom page updated. Click \"Save Settings\" to apply.": "Custom page updated. Click \"Save Settings\" to apply.", + "Custom Pages": "Custom Pages", + "Custom pages saved successfully": "Custom pages saved successfully", "Custom Seconds": "自訂秒數", "Custom sidebar section": "自訂側邊欄部分", "Custom Time Range": "自訂時間範圍", @@ -1431,6 +1449,7 @@ "Do string replacement in the target field": "在目標欄位裡做字串替換", "Do you want to download the created redemption codes as a text file?": "兌換碼建立成功,是否下載兌換碼?", "Docs": "文件", + "Documentation": "Documentation", "Documentation Link": "文件連結", "Documentation or external knowledge base.": "文件或外部知識庫。", "does not exist or might have been removed.": "不存在或可能已被移除。", @@ -1523,6 +1542,7 @@ "Edit Channel": "編輯渠道", "Edit channel routing": "編輯渠道路由", "Edit chat preset": "編輯聊天預設", + "Edit Custom Page": "Edit Custom Page", "Edit discount tier": "編輯折扣檔位", "Edit FAQ": "編輯常見問題", "Edit group": "編輯分組", @@ -1559,6 +1579,7 @@ "Email Field": "電郵欄位", "Email Verification": "電郵驗證", "Email, summarisation, knowledge work": "郵件、摘要與知識工作", + "Embed in console": "Embed in console", "Embeddings": "嵌入", "Empty": "空", "Empty value will be saved as {}.": "空值將儲存為 {}。", @@ -1566,6 +1587,7 @@ "Enable {{parameter}}": "啟用 {{parameter}}", "Enable 2FA": "啟用 2FA", "Enable All": "啟用全部", + "Enable availability monitor": "Enable availability monitor", "Enable check-in feature": "啟用簽到功能", "Enable Data Dashboard": "啟用數據儀表板", "Enable demo mode with limited functionality": "啟用功能受限的演示模式", @@ -1608,6 +1630,7 @@ "Enabled": "已啟用", "Enabled all channels with tag: {{tag}}": "已啟用標籤「{{tag}}」下的所有渠道", "Enabled channels with tag {{tag}}": "啟用標籤為 {{tag}} 的渠道", + "Enabled pages with a URL appear under the Extensions group in the console sidebar and open as embedded pages.": "Enabled pages with a URL appear under the Extensions group in the console sidebar and open as embedded pages.", "Enabled Status": "啟用狀態", "Enabling...": "正在啟用...", "Encourages introducing new topics": "鼓勵引入新話題", @@ -1723,6 +1746,7 @@ "Estimated cost": "預計成本", "Estimated quota cost": "估算配額費用", "Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.": "定價表中的每個分組名可用在兩個地方:用戶身上(用戶分組,由管理員分配)和令牌身上(令牌分組,建立令牌時選擇)。同一批名字,兩種不同職責。", + "Everyone": "Everyone", "Everything configured for this group, in one place.": "該分組的全部設定,一處看全。", "Exact": "精確", "Exact Match": "完全匹配", @@ -1767,6 +1791,7 @@ "Extend deployment": "延長部署", "Extend failed": "延長失敗", "Extended successfully": "延長成功", + "Extensions": "Extensions", "External Device": "外部設備", "External link for users to purchase quota": "供用戶購買配額的外部連結", "External operations": "對外運營", @@ -1845,6 +1870,7 @@ "Failed to initialize system": "系統初始化失敗", "Failed to load": "載入失敗", "Failed to load API keys": "載入 API 金鑰失敗", + "Failed to load availability": "Failed to load availability", "Failed to load billing history": "載入收費歷史失敗", "Failed to load enabled models": "獲取啟用模型失敗", "Failed to load home page content": "載入首頁內容失敗", @@ -1876,6 +1902,7 @@ "Failed to save": "儲存失敗", "Failed to save announcements": "儲存公告失敗", "Failed to save API info": "儲存 API 資訊失敗", + "Failed to save custom pages": "Failed to save custom pages", "Failed to save FAQ": "儲存 FAQ 失敗", "Failed to save Uptime Kuma groups": "儲存 Uptime Kuma 組失敗", "Failed to search API keys": "搜尋 API 金鑰失敗", @@ -2532,6 +2559,7 @@ "Logs": "日誌", "Look for a special ratio rule matching this user group and this billing group. If one exists, use its ratio. Otherwise use the billing group base ratio from the pricing table.": "查找匹配「該用戶分組 + 該收費分組」的特殊倍率規則。有就用規則裡的倍率,沒有就用定價分組表中收費分組的基礎倍率。", "Low balance": "餘額偏低", + "Lower numbers appear first in the sidebar.": "Lower numbers appear first in the sidebar.", "Lowest median first-token latency": "最低首 token 延遲中位數", "m": "分鐘", "Maintenance": "維護", @@ -2776,6 +2804,7 @@ "Multipliers for recharge pricing based on user groups.": "基於用戶分組的儲值定價倍率。", "Must be a valid URL": "必須是有效的 URL", "Must be at least 8 characters": "必須至少 8 個字元", + "Must be http(s). Leave empty to keep the page hidden from the sidebar.": "Must be http(s). Leave empty to keep the page hidden from the sidebar.", "My Subscriptions": "我的訂閱", "my-status": "我的狀態", "MySQL detected": "偵測到 MySQL", @@ -2846,6 +2875,7 @@ "No available Web chat links": "沒有可用的 Web 聊天連結", "No backup": "無備份", "No base input price": "未設定基礎輸入價格", + "No billing groups configured.": "No billing groups configured.", "No billing records found": "未找到賬單記錄", "No capabilities reported for this model.": "該模型暫未報告任何能力。", "No Change": "無變化", @@ -2867,6 +2897,7 @@ "No containers": "無容器", "No content to copy": "沒有可複製的內容", "No custom OAuth providers configured yet.": "尚未設定自訂 OAuth 供應商。", + "No custom pages yet. Click \"Add Custom Page\" to create one.": "No custom pages yet. Click \"Add Custom Page\" to create one.", "No data": "暫無數據", "No Data": "無數據", "No data available": "暫無數據", @@ -2950,6 +2981,7 @@ "No providers available": "暫無可用供應商", "No Quota": "無餘額", "No ratio differences found": "未發現比率差異", + "No recent requests for this group.": "No recent requests for this group.", "No recent usage": "暫無使用記錄", "No records found. Try adjusting your filters.": "未找到記錄。嘗試調整您的篩選條件。", "No redemption codes available. Create your first redemption code to get started.": "沒有可用的兌換碼。建立您的第一個兌換碼即可開始使用。", @@ -3001,6 +3033,7 @@ "Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "非零邀請獎勵需要先在支付閘道設定中確認合規條款。", "None": "無", "noreply@example.com": "noreply@example.com", + "Normal": "Normal", "Normalized:": "已歸一化:", "Not available": "不可用", "Not backed up": "未備份", @@ -3021,6 +3054,7 @@ "Notification Email": "通知電郵", "Notification Method": "通知方式", "Notifications": "通知", + "Now": "Now", "Now a user whose user group is vip creates tokens with different groups and makes one call with each:": "現在,一個用戶分組為 vip 的用戶建立了不同分組的令牌,各呼叫一次:", "Nucleus sampling probability mass": "核採樣累積概率", "Number of codes to create": "要建立的代碼數量", @@ -3081,6 +3115,7 @@ "Only available for admins. When enabled, you will receive a summary notification via your selected method when the scheduled model check detects upstream model changes or check failures.": "僅管理員可用。啟用後,當定時模型檢查偵測到上游模型變更或檢查失敗時,您將透過所選方式收到摘要通知。", "Only configured combinations are overridden. All other calls keep the billing group base ratio.": "只有設定過的組合才會被覆蓋,其餘呼叫仍使用收費分組的基礎倍率。", "Only configured combinations are overridden. All other calls keep the token group base ratio.": "只有已設定的組合會被覆蓋,其他呼叫仍使用令牌分組的基礎倍率。", + "Only enabled pages with a URL are shown in the Extensions sidebar group.": "Only enabled pages with a URL are shown in the Extensions sidebar group.", "Only enabled parameters are sent with the request.": "只有啟用的參數會隨請求傳送。", "Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "只填寫站點根域名,例如 https://api.example.com。不要填寫 /api/user/epay/notify 這類路徑。留空則使用伺服器地址。", "Only Mine": "僅自己", @@ -3098,6 +3133,7 @@ "Open in new tab": "在新標籤頁中打開", "Open in New Tab": "在新標籤頁中打開", "Open menu": "打開選單", + "Open mode": "Open mode", "Open release": "打開版本", "Open source": "開源", "Open Source": "開源項目", @@ -3264,6 +3300,7 @@ "Password reset: {{password}}": "密碼已重置:{{password}}", "Passwords do not match": "密碼不匹配", "Passwords don't match.": "兩次輸入的密碼不一致。", + "Past": "Past", "Paste Connection Info": "貼上連線資訊", "Path": "路徑", "Path not set": "未設定路徑", @@ -3624,6 +3661,7 @@ "Receive Upstream Model Update Notifications": "接收上游模型更新通知", "Received": "獲得", "Received amount": "已收額度", + "Recent {{count}} records": "Recent {{count}} records", "Recent maintenance tasks running across instances and their execution status.": "跨實例執行的近期維護任務及其執行狀態。", "Recently completed or failed system task runs.": "最近已完成或失敗的系統任務執行記錄。", "Recently launched models": "近期發佈的模型", @@ -3672,6 +3710,7 @@ "Refresh Cache": "重新整理緩存", "Refresh credential": "重新整理憑證", "Refresh details": "重新整理詳情", + "Refresh every {{seconds}}s": "Refresh every {{seconds}}s", "Refresh failed": "重新整理失敗", "Refresh interval (minutes)": "重新整理間隔 (分鐘)", "Refresh Stats": "重新整理統計", @@ -3759,6 +3798,7 @@ "Request Header Field": "請求頭欄位", "Request Header Override": "請求頭覆蓋", "Request Header Overrides": "請求頭覆蓋", + "Request health by billing group for the latest 100 consume/error logs. Green bars show latency; red bars are failures. Badge uses overall success rate (≥95% normal, ≥80% warning, below 80% abnormal).": "Request health by billing group for the latest 100 consume/error logs. Green bars show latency; red bars are failures. Badge uses overall success rate (≥95% normal, ≥80% warning, below 80% abnormal).", "Request ID": "請求 ID", "Request Limits": "請求限制", "Request Model": "請求模型", @@ -4024,6 +4064,7 @@ "Select all (filtered)": "全選(篩選結果)", "Select all models": "選擇所有模型", "Select All Visible": "全選目前", + "Select an icon": "Select an icon", "Select an operation mode and enter the amount": "選擇操作模式並輸入金額", "Select announcement type": "選擇公告類型", "Select at least one field to overwrite.": "請選擇至少一個要覆蓋的欄位。", @@ -4057,6 +4098,7 @@ "Select models or add custom ones": "選擇模型或新增自訂模型", "Select models to process. Unselected \"add\" models will be ignored.": "勾選要處理的模型,未勾選的「新增」模型將作為忽略處理。", "Select models to run batch tests.": "選擇要執行大量測試的模型。", + "Select open mode": "Select open mode", "Select or enter color value": "選擇或輸入顏色值", "Select or enter method identifier": "選擇或輸入支付方式標識", "Select or enter model name": "選擇或輸入模型名稱", @@ -4082,6 +4124,7 @@ "Select theme preset": "選擇主題預設", "Select time granularity": "選擇時間粒度", "Select vendor": "選擇供應商", + "Select visibility": "Select visibility", "Selectable groups": "可選分組", "selected": "已選擇", "Selected {{count}}": "已選 {{count}} 個", @@ -4165,6 +4208,8 @@ "Showcase core capabilities with demo credentials and limited access.": "使用演示憑證和有限存取權限展示核心功能。", "Showing": "顯示第", "showing •": "顯示 •", + "Shown in the console sidebar. Maximum 100 characters.": "Shown in the console sidebar. Maximum 100 characters.", + "Shows a group-level request heartbeat chart under Extensions. Failed requests require ERROR_LOG_ENABLED.": "Shows a group-level request heartbeat chart under Extensions. Failed requests require ERROR_LOG_ENABLED.", "Sidebar": "側邊欄", "Sidebar collapsed by default for new users": "預設情況下為新用戶摺疊側邊欄", "Sidebar modules": "側邊欄模組", @@ -4470,6 +4515,7 @@ "The name displayed across the application": "在整個套用程式中顯示的名稱", "The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations": "伺服器的公開URL,用於OAuthCallback、Webhook和其他外部整合", "The requested chat preset does not exist or has been removed.": "請求的聊天預設不存在或已被刪除。", + "The requested page does not exist, is disabled, or has no URL configured.": "The requested page does not exist, is disabled, or has no URL configured.", "The reset request stays disabled until a credit is available.": "沒有可用次數時,重置請求會保持停用。", "The setup wizard will use this database during initialization.": "設定精靈將在初始化過程中使用此資料庫。", "The site is not available at the moment.": "該站點目前不可用。", @@ -4508,6 +4554,7 @@ "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 custom page will be removed from the list.": "This custom page will be removed from the list.", "This data may be unreliable, use with caution": "此數據可能不可靠,請謹慎使用", "This device does not support Passkey": "此設備不支援 Passkey", "This device does not support Passkey verification.": "此設備不支援 Passkey 驗證。", @@ -4527,6 +4574,7 @@ "This model is not available in any group, or no group pricing information is configured.": "此模型在任何分組中均不可用,或未設定分組定價資訊。", "This month": "本月獲得", "This page has not been created yet.": "此頁面尚未建立。", + "This page opens in a new browser tab because the target site cannot be embedded.": "This page opens in a new browser tab because the target site cannot be embedded.", "This plan does not allow balance redemption": "該套餐不允許使用餘額兌換", "This project must be used in compliance with the": "此項目的使用必須遵守", "This removes {{count}} failed models from this channel. This action cannot be undone.": "此操作將從該渠道移除 {{count}} 個測試失敗的模型,且無法撤銷。", @@ -4581,6 +4629,7 @@ "times": "次", "Timing": "耗時", "Tip": "提示", + "Title": "Title", "to access this resource.": "存取此資源。", "To Anthropic Messages": "轉 Anthropic Messages", "to confirm": "以確認", @@ -4739,6 +4788,7 @@ "UI granularity only — data is still aggregated hourly": "僅 UI 粒度 — 數據仍按小時匯總", "Unable to estimate price for this deployment.": "無法為該部署估算價格。", "Unable to generate chat link. Please contact your administrator.": "無法生成聊天連結。請聯絡您的管理員。", + "Unable to load availability": "Unable to load availability", "Unable to load groups": "無法載入分組", "Unable to load rankings": "無法載入排行榜", "Unable to load rankings data": "無法載入排行榜數據", @@ -4869,6 +4919,7 @@ "USD Exchange Rate": "美元匯率", "USD price per 1M input tokens.": "每 100 萬輸入 token 的美元價格。", "USD price per 1M tokens.": "每 100 萬 token 的美元價格。", + "Use “Open in new tab” for sites that block iframe embedding (for example Taobao / Xianyu short links).": "Use “Open in new tab” for sites that block iframe embedding (for example Taobao / Xianyu short links).", "Use +: to add a group, -: to remove a default selectable group, or no prefix to append a group.": "使用 +: 新增分組,使用 -: 移除預設可選分組,不加前綴則追加分組。", "Use a compatible browser or device with biometric authentication or a security key to register a Passkey.": "請使用支援生物識別認證或安全金鑰的兼容瀏覽器或設備來註冊通行金鑰。", "Use a different stable value for each instance, then restart the service.": "每個實例使用不同且穩定的值,然後重啟服務。", @@ -5021,6 +5072,7 @@ "Violation Marker": "違規標記", "vip": "vip", "VIP users with premium access": "擁有高級存取權限的 VIP 用戶", + "Visibility": "Visibility", "Visible": "可見", "Vision": "視覺", "Vision, image / video, document chat": "視覺理解、圖像 / 影片、文檔對話", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index dcc270a9267f..11abd5f9db9a 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -38,6 +38,8 @@ "{{count}} channel(s) enabled": "已启用 {{count}} 个渠道", "{{count}} channel(s) failed to disable": "{{count}} 个渠道禁用失败", "{{count}} channel(s) failed to enable": "{{count}} 个渠道启用失败", + "{{count}} custom pages deleted. Click \"Save Settings\" to apply.": "已删除 {{count}} 个定制页面。点击“保存设置”以生效。", + "{{count}} custom pages will be removed from the list.": "将从列表中移除 {{count}} 个定制页面。", "{{count}} days ago": "{{count}} 天前", "{{count}} days remaining": "剩余 {{count}} 天", "{{count}} disabled channel(s) deleted": "已删除 {{count}} 个已禁用的渠道", @@ -118,6 +120,7 @@ "80,443,8080": "80,443,8080", "A billing multiplier. Lower ratios mean lower API call costs.": "计费乘数,倍率越低,API 调用费用越低。", "A focused home for keys, balance, routing, and service health.": "集中展示密钥、余额、路由和服务健康状态。", + "Abnormal": "异常", "About": "关于", "About {{days}} days left": "约剩 {{days}} 天", "Accept Unpriced Models": "接受未定价模型", @@ -174,6 +177,7 @@ "Add Condition": "添加条件", "Add credits": "添加额度", "Add custom model \"{{value}}\"": "添加自定义模型“{{value}}”", + "Add Custom Page": "添加定制页面", "Add discount tier": "添加折扣等级", "Add each model or tag you want to include.": "添加您想要包含的每个模型或标签。", "Add FAQ": "添加问答", @@ -239,6 +243,7 @@ "Administer user accounts and roles.": "管理用户账户和角色。", "Administrator account": "管理员账户", "Administrator username": "管理员用户名", + "Admins only": "仅管理员", "Advance next reset time": "推进下次重置时间", "Advanced": "高级", "Advanced Configuration": "高级配置", @@ -520,7 +525,9 @@ "Automatically replaces upstream callback URLs with the server address.": "自动将上游回调 URL 替换为服务器地址。", "Automatically selects the best available group with circuit breaker mechanism": "自动选择可用分组,失败时触发熔断切换", "Automatically sync model list when upstream changes are detected": "检测到上游模型变更时自动同步模型列表", + "Availability": "可用性", "Availability (last 24h)": "可用率(最近 24 小时)", + "Availability Monitor": "可用性监控", "Available": "可用", "Available credits are ordered by soonest expiration.": "可用次数按最早到期排序。", "Available disk space": "可用磁盘空间", @@ -535,6 +542,7 @@ "Average tokens per second sustained per group": "各分组持续输出的平均每秒 token 数", "Average TPM": "平均 TPM", "Average TTFT": "平均首 Token 延迟", + "Avg latency": "平均延迟", "AWS": "AWS", "AWS Bedrock Claude Compat": "AWS Bedrock Claude 兼容模板", "AWS Key Format": "AWS 密钥格式", @@ -814,6 +822,8 @@ "Choose the default charts, range, and time granularity for model analytics.": "选择模型调用分析的默认图表、范围和时间粒度。", "Choose where to fetch upstream metadata.": "选择从何处获取上游元数据。", "Choose which charts are selected by default when opening model analytics.": "选择打开模型调用分析时默认选中的图表。", + "Choose who can see the Availability Monitor entry in the Extensions sidebar.": "选择谁可以在侧栏「拓展」中看到可用性监控入口。", + "Choose who can see this page in the Extensions sidebar.": "选择谁可以在侧栏「拓展」中看到此页面。", "Clamped to": "钳制为", "Classic (Legacy Frontend)": "经典前端", "Claude": "Claude", @@ -975,6 +985,8 @@ "Configure rate limiting rules for a specific user group.": "配置特定用户分组的速率限制规则。", "Configure routes": "配置路由", "Configure the ratio for this group.": "配置此分组的比例。", + "Configure the sidebar title, icon, embed URL, status, and sort order.": "配置侧栏标题、图标、嵌入 URL、状态与排序。", + "Configure the sidebar title, icon, URL, open mode, status, and sort order.": "配置侧栏标题、图标、URL、打开方式、状态与排序。", "Configure upstream providers and routing.": "配置上游提供者和路由。", "Configure Waffo Pancake hosted checkout integration for USD-priced top-ups": "配置 Waffo Pancake 托管结账,用于美元计价的充值", "Configure Waffo payment aggregation platform integration": "配置 Waffo 支付聚合平台集成", @@ -1215,6 +1227,12 @@ "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "当特定用户分组使用特定令牌分组时的自定义乘数。示例:VIP 用户在使用“edit_this”分组令牌时获得 0.9 倍费率。", "Custom OAuth": "自定义 OAuth", "Custom OAuth Providers": "自定义OAuth提供商", + "Custom page added. Click \"Save Settings\" to apply.": "定制页面已添加。点击“保存设置”以生效。", + "Custom page deleted. Click \"Save Settings\" to apply.": "定制页面已删除。点击“保存设置”以生效。", + "Custom page not found": "未找到定制页面", + "Custom page updated. Click \"Save Settings\" to apply.": "定制页面已更新。点击“保存设置”以生效。", + "Custom Pages": "定制页面", + "Custom pages saved successfully": "定制页面保存成功", "Custom Seconds": "自定义秒数", "Custom sidebar section": "自定义侧边栏部分", "Custom Time Range": "自定义时间范围", @@ -1431,6 +1449,7 @@ "Do string replacement in the target field": "在目标字段里做字符串替换", "Do you want to download the created redemption codes as a text file?": "兑换码创建成功,是否下载兑换码?", "Docs": "文档", + "Documentation": "文档", "Documentation Link": "文档链接", "Documentation or external knowledge base.": "文档或外部知识库。", "does not exist or might have been removed.": "不存在或可能已被移除。", @@ -1523,6 +1542,7 @@ "Edit Channel": "编辑渠道", "Edit channel routing": "编辑渠道路由", "Edit chat preset": "编辑聊天预设", + "Edit Custom Page": "编辑定制页面", "Edit discount tier": "编辑折扣档位", "Edit FAQ": "编辑常见问题", "Edit group": "编辑分组", @@ -1559,6 +1579,7 @@ "Email Field": "邮箱字段", "Email Verification": "电子邮件验证", "Email, summarisation, knowledge work": "邮件、摘要与知识工作", + "Embed in console": "控制台内嵌", "Embeddings": "嵌入", "Empty": "空", "Empty value will be saved as {}.": "空值将保存为 {}。", @@ -1566,6 +1587,7 @@ "Enable {{parameter}}": "启用 {{parameter}}", "Enable 2FA": "启用 2FA", "Enable All": "启用全部", + "Enable availability monitor": "启用可用性监控", "Enable check-in feature": "启用签到功能", "Enable Data Dashboard": "启用数据仪表板", "Enable demo mode with limited functionality": "启用功能受限的演示模式", @@ -1608,6 +1630,7 @@ "Enabled": "已启用", "Enabled all channels with tag: {{tag}}": "已启用标签「{{tag}}」下的所有渠道", "Enabled channels with tag {{tag}}": "启用标签为 {{tag}} 的渠道", + "Enabled pages with a URL appear under the Extensions group in the console sidebar and open as embedded pages.": "已启用且填写了 URL 的页面会出现在控制台侧栏「拓展」分组中,并以内嵌页面打开。", "Enabled Status": "启用状态", "Enabling...": "正在启用...", "Encourages introducing new topics": "鼓励引入新话题", @@ -1723,6 +1746,7 @@ "Estimated cost": "预计成本", "Estimated quota cost": "估算配额费用", "Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.": "定价表中的每个分组名可用在两个地方:用户身上(用户分组,由管理员分配)和令牌身上(令牌分组,创建令牌时选择)。同一批名字,两种不同职责。", + "Everyone": "所有人", "Everything configured for this group, in one place.": "该分组的全部配置,一处看全。", "Exact": "精确", "Exact Match": "完全匹配", @@ -1767,6 +1791,7 @@ "Extend deployment": "延长部署", "Extend failed": "延长失败", "Extended successfully": "延长成功", + "Extensions": "拓展", "External Device": "外部设备", "External link for users to purchase quota": "供用户购买配额的外部链接", "External operations": "对外运营", @@ -1845,6 +1870,7 @@ "Failed to initialize system": "系统初始化失败", "Failed to load": "加载失败", "Failed to load API keys": "加载 API 密钥失败", + "Failed to load availability": "加载可用性数据失败", "Failed to load billing history": "加载计费历史失败", "Failed to load enabled models": "获取启用模型失败", "Failed to load home page content": "加载首页内容失败", @@ -1876,6 +1902,7 @@ "Failed to save": "保存失败", "Failed to save announcements": "保存公告失败", "Failed to save API info": "保存 API 信息失败", + "Failed to save custom pages": "保存定制页面失败", "Failed to save FAQ": "保存 FAQ 失败", "Failed to save Uptime Kuma groups": "保存 Uptime Kuma 组失败", "Failed to search API keys": "搜索 API 密钥失败", @@ -2532,6 +2559,7 @@ "Logs": "日志", "Look for a special ratio rule matching this user group and this billing group. If one exists, use its ratio. Otherwise use the billing group base ratio from the pricing table.": "查找匹配「该用户分组 + 该计费分组」的特殊倍率规则。有就用规则里的倍率,没有就用定价分组表中计费分组的基础倍率。", "Low balance": "余额偏低", + "Lower numbers appear first in the sidebar.": "数字越小,侧栏中排序越靠前。", "Lowest median first-token latency": "最低首 token 延迟中位数", "m": "分钟", "Maintenance": "维护", @@ -2776,6 +2804,7 @@ "Multipliers for recharge pricing based on user groups.": "基于用户分组的充值定价倍率。", "Must be a valid URL": "必须是有效的 URL", "Must be at least 8 characters": "必须至少 8 个字符", + "Must be http(s). Leave empty to keep the page hidden from the sidebar.": "必须为 http(s)。留空则不会出现在侧栏中。", "My Subscriptions": "我的订阅", "my-status": "我的状态", "MySQL detected": "检测到 MySQL", @@ -2846,6 +2875,7 @@ "No available Web chat links": "没有可用的 Web 聊天链接", "No backup": "无备份", "No base input price": "未设置基础输入价格", + "No billing groups configured.": "尚未配置计费分组。", "No billing records found": "未找到账单记录", "No capabilities reported for this model.": "该模型暂未报告任何能力。", "No Change": "无变化", @@ -2867,6 +2897,7 @@ "No containers": "无容器", "No content to copy": "没有可复制的内容", "No custom OAuth providers configured yet.": "尚未配置自定义 OAuth 提供商。", + "No custom pages yet. Click \"Add Custom Page\" to create one.": "暂无定制页面。点击“添加定制页面”创建。", "No data": "暂无数据", "No Data": "无数据", "No data available": "暂无数据", @@ -2950,6 +2981,7 @@ "No providers available": "暂无可用提供商", "No Quota": "无余额", "No ratio differences found": "未发现比率差异", + "No recent requests for this group.": "该分组暂无近期请求。", "No recent usage": "暂无使用记录", "No records found. Try adjusting your filters.": "未找到记录。尝试调整您的筛选条件。", "No redemption codes available. Create your first redemption code to get started.": "没有可用的兑换码。创建您的第一个兑换码即可开始使用。", @@ -3001,6 +3033,7 @@ "Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "非零邀请奖励需要先在支付网关设置中确认合规条款。", "None": "无", "noreply@example.com": "noreply@example.com", + "Normal": "正常", "Normalized:": "已归一化:", "Not available": "不可用", "Not backed up": "未备份", @@ -3021,6 +3054,7 @@ "Notification Email": "通知邮箱", "Notification Method": "通知方式", "Notifications": "通知", + "Now": "现在", "Now a user whose user group is vip creates tokens with different groups and makes one call with each:": "现在,一个用户分组为 vip 的用户创建了不同分组的令牌,各调用一次:", "Nucleus sampling probability mass": "核采样累计概率", "Number of codes to create": "要创建的代码数量", @@ -3081,6 +3115,7 @@ "Only available for admins. When enabled, you will receive a summary notification via your selected method when the scheduled model check detects upstream model changes or check failures.": "仅管理员可用。启用后,当定时模型检查检测到上游模型变更或检查失败时,您将通过所选方式收到汇总通知。", "Only configured combinations are overridden. All other calls keep the billing group base ratio.": "只有配置过的组合才会被覆盖,其余调用仍使用计费分组的基础倍率。", "Only configured combinations are overridden. All other calls keep the token group base ratio.": "只有已配置的组合会被覆盖,其他调用仍使用令牌分组的基础倍率。", + "Only enabled pages with a URL are shown in the Extensions sidebar group.": "仅「已启用」且填写了 URL 的页面会显示在侧栏「拓展」分组中。", "Only enabled parameters are sent with the request.": "只有启用的参数会随请求发送。", "Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "只填写站点根域名,例如 https://api.example.com。不要填写 /api/user/epay/notify 这类路径。留空则使用服务器地址。", "Only Mine": "仅自己", @@ -3095,9 +3130,10 @@ "Open a source model first": "请先打开一个源模型", "Open CC Switch": "打开 CC Switch", "Open in chat": "在聊天中打开", - "Open in new tab": "在新标签页中打开", + "Open in new tab": "新标签页打开", "Open in New Tab": "在新标签页中打开", "Open menu": "打开菜单", + "Open mode": "打开方式", "Open release": "打开版本", "Open source": "开源", "Open Source": "开源项目", @@ -3264,6 +3300,7 @@ "Password reset: {{password}}": "密码已重置:{{password}}", "Passwords do not match": "密码不匹配", "Passwords don't match.": "两次输入的密码不一致。", + "Past": "过去", "Paste Connection Info": "粘贴连接信息", "Path": "路径", "Path not set": "未设置路径", @@ -3624,6 +3661,7 @@ "Receive Upstream Model Update Notifications": "接收上游模型更新通知", "Received": "获得", "Received amount": "已收额度", + "Recent {{count}} records": "近 {{count}} 次记录", "Recent maintenance tasks running across instances and their execution status.": "跨实例运行的近期维护任务及其执行状态。", "Recently completed or failed system task runs.": "最近已完成或失败的系统任务运行记录。", "Recently launched models": "近期发布的模型", @@ -3672,6 +3710,7 @@ "Refresh Cache": "刷新缓存", "Refresh credential": "刷新凭据", "Refresh details": "刷新详情", + "Refresh every {{seconds}}s": "每 {{seconds}} 秒刷新", "Refresh failed": "刷新失败", "Refresh interval (minutes)": "刷新间隔 (分钟)", "Refresh Stats": "刷新统计", @@ -3759,6 +3798,7 @@ "Request Header Field": "请求头字段", "Request Header Override": "请求头覆盖", "Request Header Overrides": "请求头覆盖", + "Request health by billing group for the latest 100 consume/error logs. Green bars show latency; red bars are failures. Badge uses overall success rate (≥95% normal, ≥80% warning, below 80% abnormal).": "按计费分组统计最近 100 条消费/错误日志。绿色竖条表示延迟,红色为失败。右上徽章按整体成功率:≥95% 正常,≥80% 警告,低于 80% 异常。", "Request ID": "请求 ID", "Request Limits": "请求限制", "Request Model": "请求模型", @@ -4024,6 +4064,7 @@ "Select all (filtered)": "全选(筛选结果)", "Select all models": "选择所有模型", "Select All Visible": "全选当前", + "Select an icon": "选择图标", "Select an operation mode and enter the amount": "选择操作模式并输入金额", "Select announcement type": "选择公告类型", "Select at least one field to overwrite.": "请选择至少一个要覆盖的字段。", @@ -4057,6 +4098,7 @@ "Select models or add custom ones": "选择模型或添加自定义模型", "Select models to process. Unselected \"add\" models will be ignored.": "勾选要处理的模型,未勾选的「新增」模型将作为忽略处理。", "Select models to run batch tests.": "选择要运行批量测试的模型。", + "Select open mode": "选择打开方式", "Select or enter color value": "选择或输入颜色值", "Select or enter method identifier": "选择或输入支付方式标识", "Select or enter model name": "选择或输入模型名称", @@ -4082,6 +4124,7 @@ "Select theme preset": "选择主题预设", "Select time granularity": "选择时间粒度", "Select vendor": "选择供应商", + "Select visibility": "选择可见范围", "Selectable groups": "可选分组", "selected": "已选择", "Selected {{count}}": "已选 {{count}} 个", @@ -4165,6 +4208,8 @@ "Showcase core capabilities with demo credentials and limited access.": "使用演示凭据和有限访问权限展示核心功能。", "Showing": "显示第", "showing •": "显示 •", + "Shown in the console sidebar. Maximum 100 characters.": "显示在控制台侧栏中,最多 100 个字符。", + "Shows a group-level request heartbeat chart under Extensions. Failed requests require ERROR_LOG_ENABLED.": "在「拓展」下按计费分组展示请求心跳图。失败点依赖 ERROR_LOG_ENABLED。", "Sidebar": "侧边栏", "Sidebar collapsed by default for new users": "默认情况下为新用户折叠侧边栏", "Sidebar modules": "侧边栏模块", @@ -4470,6 +4515,7 @@ "The name displayed across the application": "在整个应用程序中显示的名称", "The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations": "服务器的公开URL,用于OAuth回调、Webhook和其他外部集成", "The requested chat preset does not exist or has been removed.": "请求的聊天预设不存在或已被删除。", + "The requested page does not exist, is disabled, or has no URL configured.": "请求的页面不存在、已禁用,或尚未配置 URL。", "The reset request stays disabled until a credit is available.": "没有可用次数时,重置请求会保持禁用。", "The setup wizard will use this database during initialization.": "设置向导将在初始化过程中使用此数据库。", "The site is not available at the moment.": "该站点目前不可用。", @@ -4508,6 +4554,7 @@ "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 custom page will be removed from the list.": "此定制页面将从列表中移除。", "This data may be unreliable, use with caution": "此数据可能不可靠,请谨慎使用", "This device does not support Passkey": "此设备不支持 Passkey", "This device does not support Passkey verification.": "此设备不支持 Passkey 验证。", @@ -4527,6 +4574,7 @@ "This model is not available in any group, or no group pricing information is configured.": "此模型在任何分组中均不可用,或未配置分组定价信息。", "This month": "本月获得", "This page has not been created yet.": "此页面尚未创建。", + "This page opens in a new browser tab because the target site cannot be embedded.": "该页面会在新标签页打开,因为目标网站不允许被内嵌。", "This plan does not allow balance redemption": "该套餐不允许使用余额兑换", "This project must be used in compliance with the": "此项目的使用必须遵守", "This removes {{count}} failed models from this channel. This action cannot be undone.": "此操作将从该渠道移除 {{count}} 个测试失败的模型,且无法撤销。", @@ -4581,6 +4629,7 @@ "times": "次", "Timing": "耗时", "Tip": "提示", + "Title": "标题", "to access this resource.": "访问此资源。", "To Anthropic Messages": "转 Anthropic Messages", "to confirm": "以确认", @@ -4739,6 +4788,7 @@ "UI granularity only — data is still aggregated hourly": "仅 UI 粒度 — 数据仍按小时汇总", "Unable to estimate price for this deployment.": "无法为该部署估算价格。", "Unable to generate chat link. Please contact your administrator.": "无法生成聊天链接。请联系您的管理员。", + "Unable to load availability": "无法加载可用性数据", "Unable to load groups": "无法加载分组", "Unable to load rankings": "无法加载排行榜", "Unable to load rankings data": "无法加载排行榜数据", @@ -4869,6 +4919,7 @@ "USD Exchange Rate": "美元汇率", "USD price per 1M input tokens.": "每 100 万输入 token 的美元价格。", "USD price per 1M tokens.": "每 100 万 token 的美元价格。", + "Use “Open in new tab” for sites that block iframe embedding (for example Taobao / Xianyu short links).": "若目标网站禁止被 iframe 嵌入(例如淘宝 / 闲鱼短链),请选择「新标签页打开」。", "Use +: to add a group, -: to remove a default selectable group, or no prefix to append a group.": "使用 +: 添加分组,使用 -: 移除默认可选分组,不加前缀则追加分组。", "Use a compatible browser or device with biometric authentication or a security key to register a Passkey.": "请使用支持生物识别认证或安全密钥的兼容浏览器或设备来注册通行密钥。", "Use a different stable value for each instance, then restart the service.": "每个实例使用不同且稳定的值,然后重启服务。", @@ -5021,6 +5072,7 @@ "Violation Marker": "违规标记", "vip": "vip", "VIP users with premium access": "拥有高级访问权限的 VIP 用户", + "Visibility": "可见范围", "Visible": "可见", "Vision": "视觉", "Vision, image / video, document chat": "视觉理解、图像 / 视频、文档对话", diff --git a/web/default/src/routeTree.gen.ts b/web/default/src/routeTree.gen.ts index e0add2a9b93d..8bf79b08e041 100644 --- a/web/default/src/routeTree.gen.ts +++ b/web/default/src/routeTree.gen.ts @@ -51,14 +51,17 @@ import { Route as AuthenticatedDashboardIndexRouteImport } from './routes/_authe import { Route as AuthenticatedChannelsIndexRouteImport } from './routes/_authenticated/channels/index' import { Route as AuthenticatedUsageLogsSectionRouteImport } from './routes/_authenticated/usage-logs/$section' import { Route as AuthenticatedModelsSectionRouteImport } from './routes/_authenticated/models/$section' +import { Route as AuthenticatedExtensionsAvailabilityRouteImport } from './routes/_authenticated/extensions/availability' import { Route as AuthenticatedErrorsErrorRouteImport } from './routes/_authenticated/errors/$error' import { Route as AuthenticatedDashboardSectionRouteImport } from './routes/_authenticated/dashboard/$section' +import { Route as AuthenticatedCustomPagesPageIdRouteImport } from './routes/_authenticated/custom-pages/$pageId' import { Route as AuthenticatedChatChatIdRouteImport } from './routes/_authenticated/chat/$chatId' import { Route as authUserResetRouteImport } from './routes/(auth)/user/reset' import { Route as AuthenticatedSystemSettingsSiteIndexRouteImport } from './routes/_authenticated/system-settings/site/index' import { Route as AuthenticatedSystemSettingsSecurityIndexRouteImport } from './routes/_authenticated/system-settings/security/index' import { Route as AuthenticatedSystemSettingsOperationsIndexRouteImport } from './routes/_authenticated/system-settings/operations/index' import { Route as AuthenticatedSystemSettingsModelsIndexRouteImport } from './routes/_authenticated/system-settings/models/index' +import { Route as AuthenticatedSystemSettingsExtensionsIndexRouteImport } from './routes/_authenticated/system-settings/extensions/index' import { Route as AuthenticatedSystemSettingsContentIndexRouteImport } from './routes/_authenticated/system-settings/content/index' import { Route as AuthenticatedSystemSettingsBillingIndexRouteImport } from './routes/_authenticated/system-settings/billing/index' import { Route as AuthenticatedSystemSettingsAuthIndexRouteImport } from './routes/_authenticated/system-settings/auth/index' @@ -66,6 +69,7 @@ import { Route as AuthenticatedSystemSettingsSiteSectionRouteImport } from './ro import { Route as AuthenticatedSystemSettingsSecuritySectionRouteImport } from './routes/_authenticated/system-settings/security/$section' import { Route as AuthenticatedSystemSettingsOperationsSectionRouteImport } from './routes/_authenticated/system-settings/operations/$section' import { Route as AuthenticatedSystemSettingsModelsSectionRouteImport } from './routes/_authenticated/system-settings/models/$section' +import { Route as AuthenticatedSystemSettingsExtensionsSectionRouteImport } from './routes/_authenticated/system-settings/extensions/$section' import { Route as AuthenticatedSystemSettingsContentSectionRouteImport } from './routes/_authenticated/system-settings/content/$section' import { Route as AuthenticatedSystemSettingsBillingSectionRouteImport } from './routes/_authenticated/system-settings/billing/$section' import { Route as AuthenticatedSystemSettingsAuthSectionRouteImport } from './routes/_authenticated/system-settings/auth/$section' @@ -292,6 +296,12 @@ const AuthenticatedModelsSectionRoute = path: '/models/$section', getParentRoute: () => AuthenticatedRouteRoute, } as any) +const AuthenticatedExtensionsAvailabilityRoute = + AuthenticatedExtensionsAvailabilityRouteImport.update({ + id: '/extensions/availability', + path: '/extensions/availability', + getParentRoute: () => AuthenticatedRouteRoute, + } as any) const AuthenticatedErrorsErrorRoute = AuthenticatedErrorsErrorRouteImport.update({ id: '/errors/$error', @@ -304,6 +314,12 @@ const AuthenticatedDashboardSectionRoute = path: '/dashboard/$section', getParentRoute: () => AuthenticatedRouteRoute, } as any) +const AuthenticatedCustomPagesPageIdRoute = + AuthenticatedCustomPagesPageIdRouteImport.update({ + id: '/custom-pages/$pageId', + path: '/custom-pages/$pageId', + getParentRoute: () => AuthenticatedRouteRoute, + } as any) const AuthenticatedChatChatIdRoute = AuthenticatedChatChatIdRouteImport.update({ id: '/chat/$chatId', path: '/chat/$chatId', @@ -338,6 +354,12 @@ const AuthenticatedSystemSettingsModelsIndexRoute = path: '/models/', getParentRoute: () => AuthenticatedSystemSettingsRouteRoute, } as any) +const AuthenticatedSystemSettingsExtensionsIndexRoute = + AuthenticatedSystemSettingsExtensionsIndexRouteImport.update({ + id: '/extensions/', + path: '/extensions/', + getParentRoute: () => AuthenticatedSystemSettingsRouteRoute, + } as any) const AuthenticatedSystemSettingsContentIndexRoute = AuthenticatedSystemSettingsContentIndexRouteImport.update({ id: '/content/', @@ -380,6 +402,12 @@ const AuthenticatedSystemSettingsModelsSectionRoute = path: '/models/$section', getParentRoute: () => AuthenticatedSystemSettingsRouteRoute, } as any) +const AuthenticatedSystemSettingsExtensionsSectionRoute = + AuthenticatedSystemSettingsExtensionsSectionRouteImport.update({ + id: '/extensions/$section', + path: '/extensions/$section', + getParentRoute: () => AuthenticatedSystemSettingsRouteRoute, + } as any) const AuthenticatedSystemSettingsContentSectionRoute = AuthenticatedSystemSettingsContentSectionRouteImport.update({ id: '/content/$section', @@ -426,8 +454,10 @@ export interface FileRoutesByFullPath { '/setup/': typeof SetupIndexRoute '/user/reset': typeof authUserResetRoute '/chat/$chatId': typeof AuthenticatedChatChatIdRoute + '/custom-pages/$pageId': typeof AuthenticatedCustomPagesPageIdRoute '/dashboard/$section': typeof AuthenticatedDashboardSectionRoute '/errors/$error': typeof AuthenticatedErrorsErrorRoute + '/extensions/availability': typeof AuthenticatedExtensionsAvailabilityRoute '/models/$section': typeof AuthenticatedModelsSectionRoute '/usage-logs/$section': typeof AuthenticatedUsageLogsSectionRoute '/channels/': typeof AuthenticatedChannelsIndexRoute @@ -447,6 +477,7 @@ export interface FileRoutesByFullPath { '/system-settings/auth/$section': typeof AuthenticatedSystemSettingsAuthSectionRoute '/system-settings/billing/$section': typeof AuthenticatedSystemSettingsBillingSectionRoute '/system-settings/content/$section': typeof AuthenticatedSystemSettingsContentSectionRoute + '/system-settings/extensions/$section': typeof AuthenticatedSystemSettingsExtensionsSectionRoute '/system-settings/models/$section': typeof AuthenticatedSystemSettingsModelsSectionRoute '/system-settings/operations/$section': typeof AuthenticatedSystemSettingsOperationsSectionRoute '/system-settings/security/$section': typeof AuthenticatedSystemSettingsSecuritySectionRoute @@ -454,6 +485,7 @@ export interface FileRoutesByFullPath { '/system-settings/auth/': typeof AuthenticatedSystemSettingsAuthIndexRoute '/system-settings/billing/': typeof AuthenticatedSystemSettingsBillingIndexRoute '/system-settings/content/': typeof AuthenticatedSystemSettingsContentIndexRoute + '/system-settings/extensions/': typeof AuthenticatedSystemSettingsExtensionsIndexRoute '/system-settings/models/': typeof AuthenticatedSystemSettingsModelsIndexRoute '/system-settings/operations/': typeof AuthenticatedSystemSettingsOperationsIndexRoute '/system-settings/security/': typeof AuthenticatedSystemSettingsSecurityIndexRoute @@ -485,8 +517,10 @@ export interface FileRoutesByTo { '/setup': typeof SetupIndexRoute '/user/reset': typeof authUserResetRoute '/chat/$chatId': typeof AuthenticatedChatChatIdRoute + '/custom-pages/$pageId': typeof AuthenticatedCustomPagesPageIdRoute '/dashboard/$section': typeof AuthenticatedDashboardSectionRoute '/errors/$error': typeof AuthenticatedErrorsErrorRoute + '/extensions/availability': typeof AuthenticatedExtensionsAvailabilityRoute '/models/$section': typeof AuthenticatedModelsSectionRoute '/usage-logs/$section': typeof AuthenticatedUsageLogsSectionRoute '/channels': typeof AuthenticatedChannelsIndexRoute @@ -506,6 +540,7 @@ export interface FileRoutesByTo { '/system-settings/auth/$section': typeof AuthenticatedSystemSettingsAuthSectionRoute '/system-settings/billing/$section': typeof AuthenticatedSystemSettingsBillingSectionRoute '/system-settings/content/$section': typeof AuthenticatedSystemSettingsContentSectionRoute + '/system-settings/extensions/$section': typeof AuthenticatedSystemSettingsExtensionsSectionRoute '/system-settings/models/$section': typeof AuthenticatedSystemSettingsModelsSectionRoute '/system-settings/operations/$section': typeof AuthenticatedSystemSettingsOperationsSectionRoute '/system-settings/security/$section': typeof AuthenticatedSystemSettingsSecuritySectionRoute @@ -513,6 +548,7 @@ export interface FileRoutesByTo { '/system-settings/auth': typeof AuthenticatedSystemSettingsAuthIndexRoute '/system-settings/billing': typeof AuthenticatedSystemSettingsBillingIndexRoute '/system-settings/content': typeof AuthenticatedSystemSettingsContentIndexRoute + '/system-settings/extensions': typeof AuthenticatedSystemSettingsExtensionsIndexRoute '/system-settings/models': typeof AuthenticatedSystemSettingsModelsIndexRoute '/system-settings/operations': typeof AuthenticatedSystemSettingsOperationsIndexRoute '/system-settings/security': typeof AuthenticatedSystemSettingsSecurityIndexRoute @@ -548,8 +584,10 @@ export interface FileRoutesById { '/setup/': typeof SetupIndexRoute '/(auth)/user/reset': typeof authUserResetRoute '/_authenticated/chat/$chatId': typeof AuthenticatedChatChatIdRoute + '/_authenticated/custom-pages/$pageId': typeof AuthenticatedCustomPagesPageIdRoute '/_authenticated/dashboard/$section': typeof AuthenticatedDashboardSectionRoute '/_authenticated/errors/$error': typeof AuthenticatedErrorsErrorRoute + '/_authenticated/extensions/availability': typeof AuthenticatedExtensionsAvailabilityRoute '/_authenticated/models/$section': typeof AuthenticatedModelsSectionRoute '/_authenticated/usage-logs/$section': typeof AuthenticatedUsageLogsSectionRoute '/_authenticated/channels/': typeof AuthenticatedChannelsIndexRoute @@ -569,6 +607,7 @@ export interface FileRoutesById { '/_authenticated/system-settings/auth/$section': typeof AuthenticatedSystemSettingsAuthSectionRoute '/_authenticated/system-settings/billing/$section': typeof AuthenticatedSystemSettingsBillingSectionRoute '/_authenticated/system-settings/content/$section': typeof AuthenticatedSystemSettingsContentSectionRoute + '/_authenticated/system-settings/extensions/$section': typeof AuthenticatedSystemSettingsExtensionsSectionRoute '/_authenticated/system-settings/models/$section': typeof AuthenticatedSystemSettingsModelsSectionRoute '/_authenticated/system-settings/operations/$section': typeof AuthenticatedSystemSettingsOperationsSectionRoute '/_authenticated/system-settings/security/$section': typeof AuthenticatedSystemSettingsSecuritySectionRoute @@ -576,6 +615,7 @@ export interface FileRoutesById { '/_authenticated/system-settings/auth/': typeof AuthenticatedSystemSettingsAuthIndexRoute '/_authenticated/system-settings/billing/': typeof AuthenticatedSystemSettingsBillingIndexRoute '/_authenticated/system-settings/content/': typeof AuthenticatedSystemSettingsContentIndexRoute + '/_authenticated/system-settings/extensions/': typeof AuthenticatedSystemSettingsExtensionsIndexRoute '/_authenticated/system-settings/models/': typeof AuthenticatedSystemSettingsModelsIndexRoute '/_authenticated/system-settings/operations/': typeof AuthenticatedSystemSettingsOperationsIndexRoute '/_authenticated/system-settings/security/': typeof AuthenticatedSystemSettingsSecurityIndexRoute @@ -610,8 +650,10 @@ export interface FileRouteTypes { | '/setup/' | '/user/reset' | '/chat/$chatId' + | '/custom-pages/$pageId' | '/dashboard/$section' | '/errors/$error' + | '/extensions/availability' | '/models/$section' | '/usage-logs/$section' | '/channels/' @@ -631,6 +673,7 @@ export interface FileRouteTypes { | '/system-settings/auth/$section' | '/system-settings/billing/$section' | '/system-settings/content/$section' + | '/system-settings/extensions/$section' | '/system-settings/models/$section' | '/system-settings/operations/$section' | '/system-settings/security/$section' @@ -638,6 +681,7 @@ export interface FileRouteTypes { | '/system-settings/auth/' | '/system-settings/billing/' | '/system-settings/content/' + | '/system-settings/extensions/' | '/system-settings/models/' | '/system-settings/operations/' | '/system-settings/security/' @@ -669,8 +713,10 @@ export interface FileRouteTypes { | '/setup' | '/user/reset' | '/chat/$chatId' + | '/custom-pages/$pageId' | '/dashboard/$section' | '/errors/$error' + | '/extensions/availability' | '/models/$section' | '/usage-logs/$section' | '/channels' @@ -690,6 +736,7 @@ export interface FileRouteTypes { | '/system-settings/auth/$section' | '/system-settings/billing/$section' | '/system-settings/content/$section' + | '/system-settings/extensions/$section' | '/system-settings/models/$section' | '/system-settings/operations/$section' | '/system-settings/security/$section' @@ -697,6 +744,7 @@ export interface FileRouteTypes { | '/system-settings/auth' | '/system-settings/billing' | '/system-settings/content' + | '/system-settings/extensions' | '/system-settings/models' | '/system-settings/operations' | '/system-settings/security' @@ -731,8 +779,10 @@ export interface FileRouteTypes { | '/setup/' | '/(auth)/user/reset' | '/_authenticated/chat/$chatId' + | '/_authenticated/custom-pages/$pageId' | '/_authenticated/dashboard/$section' | '/_authenticated/errors/$error' + | '/_authenticated/extensions/availability' | '/_authenticated/models/$section' | '/_authenticated/usage-logs/$section' | '/_authenticated/channels/' @@ -752,6 +802,7 @@ export interface FileRouteTypes { | '/_authenticated/system-settings/auth/$section' | '/_authenticated/system-settings/billing/$section' | '/_authenticated/system-settings/content/$section' + | '/_authenticated/system-settings/extensions/$section' | '/_authenticated/system-settings/models/$section' | '/_authenticated/system-settings/operations/$section' | '/_authenticated/system-settings/security/$section' @@ -759,6 +810,7 @@ export interface FileRouteTypes { | '/_authenticated/system-settings/auth/' | '/_authenticated/system-settings/billing/' | '/_authenticated/system-settings/content/' + | '/_authenticated/system-settings/extensions/' | '/_authenticated/system-settings/models/' | '/_authenticated/system-settings/operations/' | '/_authenticated/system-settings/security/' @@ -1082,6 +1134,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedModelsSectionRouteImport parentRoute: typeof AuthenticatedRouteRoute } + '/_authenticated/extensions/availability': { + id: '/_authenticated/extensions/availability' + path: '/extensions/availability' + fullPath: '/extensions/availability' + preLoaderRoute: typeof AuthenticatedExtensionsAvailabilityRouteImport + parentRoute: typeof AuthenticatedRouteRoute + } '/_authenticated/errors/$error': { id: '/_authenticated/errors/$error' path: '/errors/$error' @@ -1096,6 +1155,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedDashboardSectionRouteImport parentRoute: typeof AuthenticatedRouteRoute } + '/_authenticated/custom-pages/$pageId': { + id: '/_authenticated/custom-pages/$pageId' + path: '/custom-pages/$pageId' + fullPath: '/custom-pages/$pageId' + preLoaderRoute: typeof AuthenticatedCustomPagesPageIdRouteImport + parentRoute: typeof AuthenticatedRouteRoute + } '/_authenticated/chat/$chatId': { id: '/_authenticated/chat/$chatId' path: '/chat/$chatId' @@ -1138,6 +1204,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedSystemSettingsModelsIndexRouteImport parentRoute: typeof AuthenticatedSystemSettingsRouteRoute } + '/_authenticated/system-settings/extensions/': { + id: '/_authenticated/system-settings/extensions/' + path: '/extensions' + fullPath: '/system-settings/extensions/' + preLoaderRoute: typeof AuthenticatedSystemSettingsExtensionsIndexRouteImport + parentRoute: typeof AuthenticatedSystemSettingsRouteRoute + } '/_authenticated/system-settings/content/': { id: '/_authenticated/system-settings/content/' path: '/content' @@ -1187,6 +1260,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedSystemSettingsModelsSectionRouteImport parentRoute: typeof AuthenticatedSystemSettingsRouteRoute } + '/_authenticated/system-settings/extensions/$section': { + id: '/_authenticated/system-settings/extensions/$section' + path: '/extensions/$section' + fullPath: '/system-settings/extensions/$section' + preLoaderRoute: typeof AuthenticatedSystemSettingsExtensionsSectionRouteImport + parentRoute: typeof AuthenticatedSystemSettingsRouteRoute + } '/_authenticated/system-settings/content/$section': { id: '/_authenticated/system-settings/content/$section' path: '/content/$section' @@ -1242,6 +1322,7 @@ interface AuthenticatedSystemSettingsRouteRouteChildren { AuthenticatedSystemSettingsAuthSectionRoute: typeof AuthenticatedSystemSettingsAuthSectionRoute AuthenticatedSystemSettingsBillingSectionRoute: typeof AuthenticatedSystemSettingsBillingSectionRoute AuthenticatedSystemSettingsContentSectionRoute: typeof AuthenticatedSystemSettingsContentSectionRoute + AuthenticatedSystemSettingsExtensionsSectionRoute: typeof AuthenticatedSystemSettingsExtensionsSectionRoute AuthenticatedSystemSettingsModelsSectionRoute: typeof AuthenticatedSystemSettingsModelsSectionRoute AuthenticatedSystemSettingsOperationsSectionRoute: typeof AuthenticatedSystemSettingsOperationsSectionRoute AuthenticatedSystemSettingsSecuritySectionRoute: typeof AuthenticatedSystemSettingsSecuritySectionRoute @@ -1249,6 +1330,7 @@ interface AuthenticatedSystemSettingsRouteRouteChildren { AuthenticatedSystemSettingsAuthIndexRoute: typeof AuthenticatedSystemSettingsAuthIndexRoute AuthenticatedSystemSettingsBillingIndexRoute: typeof AuthenticatedSystemSettingsBillingIndexRoute AuthenticatedSystemSettingsContentIndexRoute: typeof AuthenticatedSystemSettingsContentIndexRoute + AuthenticatedSystemSettingsExtensionsIndexRoute: typeof AuthenticatedSystemSettingsExtensionsIndexRoute AuthenticatedSystemSettingsModelsIndexRoute: typeof AuthenticatedSystemSettingsModelsIndexRoute AuthenticatedSystemSettingsOperationsIndexRoute: typeof AuthenticatedSystemSettingsOperationsIndexRoute AuthenticatedSystemSettingsSecurityIndexRoute: typeof AuthenticatedSystemSettingsSecurityIndexRoute @@ -1265,6 +1347,8 @@ const AuthenticatedSystemSettingsRouteRouteChildren: AuthenticatedSystemSettings AuthenticatedSystemSettingsBillingSectionRoute, AuthenticatedSystemSettingsContentSectionRoute: AuthenticatedSystemSettingsContentSectionRoute, + AuthenticatedSystemSettingsExtensionsSectionRoute: + AuthenticatedSystemSettingsExtensionsSectionRoute, AuthenticatedSystemSettingsModelsSectionRoute: AuthenticatedSystemSettingsModelsSectionRoute, AuthenticatedSystemSettingsOperationsSectionRoute: @@ -1279,6 +1363,8 @@ const AuthenticatedSystemSettingsRouteRouteChildren: AuthenticatedSystemSettings AuthenticatedSystemSettingsBillingIndexRoute, AuthenticatedSystemSettingsContentIndexRoute: AuthenticatedSystemSettingsContentIndexRoute, + AuthenticatedSystemSettingsExtensionsIndexRoute: + AuthenticatedSystemSettingsExtensionsIndexRoute, AuthenticatedSystemSettingsModelsIndexRoute: AuthenticatedSystemSettingsModelsIndexRoute, AuthenticatedSystemSettingsOperationsIndexRoute: @@ -1298,8 +1384,10 @@ interface AuthenticatedRouteRouteChildren { AuthenticatedSystemSettingsRouteRoute: typeof AuthenticatedSystemSettingsRouteRouteWithChildren AuthenticatedChat2linkRoute: typeof AuthenticatedChat2linkRoute AuthenticatedChatChatIdRoute: typeof AuthenticatedChatChatIdRoute + AuthenticatedCustomPagesPageIdRoute: typeof AuthenticatedCustomPagesPageIdRoute AuthenticatedDashboardSectionRoute: typeof AuthenticatedDashboardSectionRoute AuthenticatedErrorsErrorRoute: typeof AuthenticatedErrorsErrorRoute + AuthenticatedExtensionsAvailabilityRoute: typeof AuthenticatedExtensionsAvailabilityRoute AuthenticatedModelsSectionRoute: typeof AuthenticatedModelsSectionRoute AuthenticatedUsageLogsSectionRoute: typeof AuthenticatedUsageLogsSectionRoute AuthenticatedChannelsIndexRoute: typeof AuthenticatedChannelsIndexRoute @@ -1321,8 +1409,11 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = { AuthenticatedSystemSettingsRouteRouteWithChildren, AuthenticatedChat2linkRoute: AuthenticatedChat2linkRoute, AuthenticatedChatChatIdRoute: AuthenticatedChatChatIdRoute, + AuthenticatedCustomPagesPageIdRoute: AuthenticatedCustomPagesPageIdRoute, AuthenticatedDashboardSectionRoute: AuthenticatedDashboardSectionRoute, AuthenticatedErrorsErrorRoute: AuthenticatedErrorsErrorRoute, + AuthenticatedExtensionsAvailabilityRoute: + AuthenticatedExtensionsAvailabilityRoute, AuthenticatedModelsSectionRoute: AuthenticatedModelsSectionRoute, AuthenticatedUsageLogsSectionRoute: AuthenticatedUsageLogsSectionRoute, AuthenticatedChannelsIndexRoute: AuthenticatedChannelsIndexRoute, diff --git a/web/default/src/routes/_authenticated/custom-pages/$pageId.tsx b/web/default/src/routes/_authenticated/custom-pages/$pageId.tsx new file mode 100644 index 000000000000..38de7c22156e --- /dev/null +++ b/web/default/src/routes/_authenticated/custom-pages/$pageId.tsx @@ -0,0 +1,141 @@ +/* +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, + 70|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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { Link, createFileRoute } from '@tanstack/react-router' +import { ExternalLink, MessageCircleWarning } from 'lucide-react' +import { useEffect, useMemo, useRef } from 'react' +import { useTranslation } from 'react-i18next' + +import { Button } from '@/components/ui/button' +import { + resolveCustomPageOpenMode, + type CustomPageStatusItem, +} from '@/features/system-settings/extensions/constants' +import { useStatus } from '@/hooks/use-status' + +export const Route = createFileRoute('/_authenticated/custom-pages/$pageId')({ + component: CustomPageRouteComponent, +}) + +function CustomPageRouteComponent() { + const { t } = useTranslation() + const { pageId } = Route.useParams() + const { status, loading } = useStatus() + const autoOpenedRef = useRef(false) + + const page = useMemo(() => { + const pages = (status?.custom_pages ?? + status?.data?.custom_pages) as CustomPageStatusItem[] | undefined + if (!Array.isArray(pages)) return undefined + return pages.find((item) => item.id === pageId) + }, [pageId, status]) + + const openMode = resolveCustomPageOpenMode(page?.open_mode) + + useEffect(() => { + autoOpenedRef.current = false + }, [page?.id, page?.url, openMode]) + + useEffect(() => { + if (!page?.url || openMode !== 'external' || autoOpenedRef.current) { + return + } + autoOpenedRef.current = true + window.open(page.url, '_blank', 'noopener,noreferrer') + }, [openMode, page?.url]) + + if (loading && !page) { + return ( +
+

{t('Loading...')}

+
+ ) + } + + if (!page || !page.url) { + return ( +
+ +
+

+ {t('Custom page not found')} +

+

+ {t( + 'The requested page does not exist, is disabled, or has no URL configured.' + )} +

+
+ +
+ ) + } + + if (openMode === 'external') { + return ( +
+ +
+

{page.title}

+

+ {t( + 'This page opens in a new browser tab because the target site cannot be embedded.' + )} +

+
+
+ + +
+
+ ) + } + + return ( +
+
+

{page.title}

+ +
+