From 7ad618dff859fad536b9b6ce896b51c8a4bbe815 Mon Sep 17 00:00:00 2001 From: zodyp Date: Fri, 18 Sep 2026 14:19:51 -0300 Subject: [PATCH 1/6] fix(settings): make the INITIAL_PASSWORD onboarding bootstrap one-time (C-06) getSettings() re-forced setupComplete=true on every read while INITIAL_PASSWORD was set, so PATCH /api/settings {setupComplete:false} answered 200 without effect and the wizard could never be re-run on a headless deploy. The headless contract is unchanged on first boot (setupComplete and requireLogin are set on the first read; the pinned tests db-settings-crud:87, management-password:101 and login-bootstrap-route:72 stay green untouched). A marker row (_initialPasswordBootstrapped, underscore keys never surface as settings) now records that the bootstrap ran, so a later explicit setupComplete=false is honoured. Installs bootstrapped before the marker existed only get the marker written. ensurePersistentManagementPasswordHash needs no change: it only forces setupComplete while migrating a non-bcrypt password, which happens once. Regression: tests/unit/initial-password-bootstrap-once.test.ts (4 of 5 cases fail on the previous code). Co-Authored-By: Claude Opus 5 --- src/lib/db/settings.ts | 48 +++++-- .../initial-password-bootstrap-once.test.ts | 118 ++++++++++++++++++ 2 files changed, 154 insertions(+), 12 deletions(-) create mode 100644 tests/unit/initial-password-bootstrap-once.test.ts diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index 609173bfd8d..84b43195d1d 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -138,6 +138,41 @@ function applySessionAffinityLegacyFallback(settings: Record): } } +/** + * Marker row of the INITIAL_PASSWORD bootstrap. Underscore keys are skipped when the settings + * object is built, so it never surfaces as a setting. + */ +const INITIAL_PASSWORD_BOOTSTRAP_KEY = "_initialPasswordBootstrapped"; + +/** + * Auto-complete onboarding for pre-configured deployments (Docker/VM): when INITIAL_PASSWORD + * is set via env this is a headless deploy, so the first read marks setup complete and + * requires login — the wizard is skipped on first boot. + * + * It is a ONE-TIME bootstrap (audit C-06): the marker row records that it ran, so an explicit + * PATCH /api/settings {setupComplete:false} (re-running the wizard) is honoured instead of + * being answered 200 and silently re-forced on the next read. Installs bootstrapped before + * the marker existed (setupComplete already true) only get the marker written. + */ +function applyInitialPasswordBootstrap( + db: ReturnType, + settings: Record, + rows: unknown[] +): void { + if (!process.env.INITIAL_PASSWORD) return; + if (rows.some((row) => toRecord(row).key === INITIAL_PASSWORD_BOOTSTRAP_KEY)) return; + const upsert = db.prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('settings', ?, ?)" + ); + if (!settings.setupComplete) { + settings.setupComplete = true; + settings.requireLogin = true; + upsert.run("setupComplete", "true"); + upsert.run("requireLogin", "true"); + } + upsert.run(INITIAL_PASSWORD_BOOTSTRAP_KEY, "true"); +} + export async function getSettings() { const db = getDbInstance(); const rows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'settings'").all(); @@ -286,18 +321,7 @@ export async function getSettings() { } applySessionAffinityLegacyFallback(settings); - // Auto-complete onboarding for pre-configured deployments (Docker/VM) - // If INITIAL_PASSWORD is set via env, this is a headless deploy — skip the wizard - if (!settings.setupComplete && process.env.INITIAL_PASSWORD) { - settings.setupComplete = true; - settings.requireLogin = true; - db.prepare( - "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('settings', 'setupComplete', 'true')" - ).run(); - db.prepare( - "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('settings', 'requireLogin', 'true')" - ).run(); - } + applyInitialPasswordBootstrap(db, settings, rows); return settings; } diff --git a/tests/unit/initial-password-bootstrap-once.test.ts b/tests/unit/initial-password-bootstrap-once.test.ts new file mode 100644 index 00000000000..25ea25fa867 --- /dev/null +++ b/tests/unit/initial-password-bootstrap-once.test.ts @@ -0,0 +1,118 @@ +/** + * Audit C-06: with INITIAL_PASSWORD set, `getSettings()` re-forced `setupComplete=true` on + * EVERY read, so `PATCH /api/settings {setupComplete:false}` (re-run the onboarding wizard) + * answered 200 and had no effect. The headless bootstrap must still happen on first read + * (wizard skipped on first boot, login required), but only once. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-initial-pw-once-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD; + +const core = await import("../../src/lib/db/core.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); +const managementPassword = await import("../../src/lib/auth/managementPassword.ts"); + +function readRawSetting(key: string): string | undefined { + const row = core + .getDbInstance() + .prepare("SELECT value FROM key_value WHERE namespace = 'settings' AND key = ?") + .get(key) as { value?: string } | undefined; + return row?.value; +} + +async function resetStorage() { + core.resetDbInstance(); + for (let attempt = 0; attempt < 10; attempt++) { + try { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + break; + } catch (error: unknown) { + const code = (error as { code?: string })?.code; + if ((code === "EBUSY" || code === "EPERM") && attempt < 9) { + await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); + } else { + throw error; + } + } + } + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + delete process.env.INITIAL_PASSWORD; +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + if (ORIGINAL_INITIAL_PASSWORD === undefined) delete process.env.INITIAL_PASSWORD; + else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD; +}); + +test("first read with INITIAL_PASSWORD still completes setup and requires login", async () => { + process.env.INITIAL_PASSWORD = "bootstrap-secret"; + + const settings = await settingsDb.getSettings(); + + assert.equal(settings.setupComplete, true); + assert.equal(settings.requireLogin, true); + assert.equal(readRawSetting("setupComplete"), "true"); + // The marker is internal: underscore keys never surface in the settings object. + assert.equal(readRawSetting("_initialPasswordBootstrapped"), "true"); + assert.equal("_initialPasswordBootstrapped" in settings, false); +}); + +test("an explicit setupComplete=false after the bootstrap is honoured (no 200-without-effect)", async () => { + process.env.INITIAL_PASSWORD = "bootstrap-secret"; + await settingsDb.getSettings(); + + const updated = await settingsDb.updateSettings({ setupComplete: false }); + const reread = await settingsDb.getSettings(); + + assert.equal(updated.setupComplete, false); + assert.equal(reread.setupComplete, false, "the next read must not re-force setupComplete"); + assert.equal(reread.requireLogin, true, "login stays required"); +}); + +test("the startup password migration does not re-force setupComplete after a re-run request", async () => { + process.env.INITIAL_PASSWORD = "bootstrap-secret"; + await managementPassword.ensurePersistentManagementPasswordHash({ source: "test" }); + await settingsDb.updateSettings({ setupComplete: false }); + + const second = await managementPassword.ensurePersistentManagementPasswordHash({ + source: "test", + }); + const settings = await settingsDb.getSettings(); + + assert.equal(second.migrated, false, "the stored bcrypt hash is reused"); + assert.equal(settings.setupComplete, false); + assert.equal(managementPassword.isBcryptHash(settings.password), true); +}); + +test("an install bootstrapped before the marker existed only gets the marker", async () => { + await settingsDb.updateSettings({ setupComplete: true, requireLogin: false }); + process.env.INITIAL_PASSWORD = "bootstrap-secret"; + + const first = await settingsDb.getSettings(); + assert.equal(first.setupComplete, true); + assert.equal(first.requireLogin, false, "an operator choice is not overwritten"); + assert.equal(readRawSetting("_initialPasswordBootstrapped"), "true"); + + await settingsDb.updateSettings({ setupComplete: false }); + assert.equal((await settingsDb.getSettings()).setupComplete, false); +}); + +test("without INITIAL_PASSWORD nothing is forced and no marker is written", async () => { + const settings = await settingsDb.getSettings(); + + assert.notEqual(settings.setupComplete, true); + assert.equal(readRawSetting("_initialPasswordBootstrapped"), undefined); +}); From fdfa48a10571389db6714cdb1e8e4a24dd01ae48 Mon Sep 17 00:00:00 2001 From: zodyp Date: Fri, 18 Sep 2026 14:36:11 -0300 Subject: [PATCH 2/6] i18n(onboarding): first-use copy, pt-BR login strings and provider-form placeholders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - onboarding.*: new strings for the first-use flow (errorGuide why/fix per failure kind, model choice, test request, client configuration, first request, existing-password notice) in en, pt-BR and vi (vi keeps strict key parity, tests/unit/i18n-vi-completeness.test.ts). - auth.nodeIncompatible* (login page) translated to pt-BR; the en/vi description no longer recommends the unsupported Node.js 20.x — it now matches SUPPORTED_NODE_RANGE (22.x / 24.x LTS), as the hint already did. - Real copy for 22 humanized-key placeholders shown on the first-use path: providers.* fields of the add/edit connection modals (custom User-Agent, excluded models, routing tags, tag/group, extra API keys, search engine id, local-provider API-key hint) and the common.* onboarding duplicates. config/quality/i18n-placeholder-baseline.json pruned 162 -> 140 with scripts/i18n/check-ui-keys-coverage.mjs --update-placeholder-baseline. Co-Authored-By: Claude Opus 5 --- config/quality/i18n-placeholder-baseline.json | 22 --- src/i18n/messages/en.json | 145 +++++++++++++++--- src/i18n/messages/pt-BR.json | 119 ++++++++++++-- src/i18n/messages/vi.json | 135 +++++++++++++--- 4 files changed, 342 insertions(+), 79 deletions(-) diff --git a/config/quality/i18n-placeholder-baseline.json b/config/quality/i18n-placeholder-baseline.json index 501adece875..a979393c8b5 100644 --- a/config/quality/i18n-placeholder-baseline.json +++ b/config/quality/i18n-placeholder-baseline.json @@ -29,11 +29,9 @@ "common.configuredProvidersHint", "common.configuredProvidersLabel", "common.confirmDbImportDesc", - "common.confirmPasswordPlaceholder", "common.defaultStrategyDesc", "common.disableCloudTitle", "common.domainPlaceholder", - "common.doneDesc", "common.embeddingsDesc", "common.enableCloudTitle", "common.error_description", @@ -76,7 +74,6 @@ "common.protocolToolsLabel", "common.protocolsDescription", "common.protocolsTitle", - "common.providerDesc", "common.providerHealthStatusAria", "common.providerLabel", "common.providerMaxRetriesAria", @@ -93,16 +90,13 @@ "common.searchProvidersHeading", "common.sectionDescription", "common.sectionTitle", - "common.securityDesc", "common.stickyLimitDesc", "common.tabsAria", "common.templateLoadHint", - "common.testDesc", "common.textToSpeechDesc", "common.trackMetricsDesc", "common.videoDesc", "common.webSearchDesc", - "common.welcomeDesc", "common.zedImportHint", "endpoint.requestBody", "providers.apiFormatLabel", @@ -111,19 +105,11 @@ "providers.bailianBaseUrlHint", "providers.blackboxWebCookieHint", "providers.blackboxWebCookiePlaceholder", - "providers.customUserAgentHint", - "providers.customUserAgentLabel", "providers.databricksBaseUrlHint", - "providers.excludedModelsHint", - "providers.excludedModelsLabel", - "providers.excludedModelsPlaceholder", - "providers.extraApiKeysHint", - "providers.extraApiKeysLabel", "providers.grokWebCookieHint", "providers.grokWebCookiePlaceholder", "providers.herokuBaseUrlHint", "providers.imagesShortLabel", - "providers.localProviderApiKeyOptionalHint", "providers.localProviderBaseUrlHint", "providers.museSparkWebCookieHint", "providers.museSparkWebCookiePlaceholder", @@ -132,19 +118,11 @@ "providers.refreshOauthTokenTitle", "providers.regionHint", "providers.regionLabel", - "providers.routingTagsHint", - "providers.routingTagsLabel", - "providers.routingTagsPlaceholder", - "providers.searchEngineIdHint", - "providers.searchEngineIdLabel", "providers.searchProviderDesc", "providers.searchProvidersHeading", "providers.searxngBaseUrlHint", "providers.sessionCookieLabel", "providers.snowflakeBaseUrlHint", - "providers.tagGroupHint", - "providers.tagGroupLabel", - "providers.tagGroupPlaceholder", "providers.xiaomiMimoBaseUrlHint", "providers.zedImportHint", "settings.cliproxyapiHealthLabel", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 50073b506c4..521cc7e84ba 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -352,7 +352,7 @@ "comboNamePlaceholder": "Combo Name Placeholder", "memoryRss": "Memory Rss", "disableProvider": "Disable Provider", - "welcomeDesc": "Welcome Desc", + "welcomeDesc": "OmniRoute is your local AI API proxy. It routes requests to multiple AI providers with load balancing, failover, and usage tracking.", "nameLabel": "Name Label", "allOperational": "All Operational", "backupNow": "Backup Now", @@ -361,7 +361,7 @@ "machineId": "Machine Id", "globalProxy": "Global Proxy", "hitsMisses": "Hits Misses", - "testDesc": "Test Desc", + "testDesc": "Let's verify your provider connection works.", "chatDesc": "Chat Desc", "importSuccess": "Import Success", "chat": "Chat", @@ -492,7 +492,7 @@ "enableProvider": "Enable Provider", "modeTest": "Mode Test", "failedDeleteChain": "Failed Delete Chain", - "providerDesc": "Provider Desc", + "providerDesc": "Connect your first AI provider. You can add more later.", "templateLoadHint": "Template Load Hint", "lastFailure": "Last Failure", "moveDown": "Move Down", @@ -587,7 +587,7 @@ "zedImportHint": "Zed Import Hint", "modelsAcrossEndpoints": "Models Across Endpoints", "autoDisableBannedAccounts": "Auto Disable Banned Accounts", - "securityDesc": "Security Desc", + "securityDesc": "Set a password to protect your dashboard, or skip for now.", "listModels": "List Models", "backupRestore": "Backup Restore", "target": "Target", @@ -604,7 +604,7 @@ "signatureTool": "Signature Tool", "connectionFailed": "Connection Failed", "activeLimitersPlural": "Active Limiters Plural", - "doneDesc": "Done Desc", + "doneDesc": "You're all set! Your OmniRoute instance is configured and ready to proxy AI requests.", "down": "Down", "noDataYet": "No Data Yet", "activeProviders": "Active Providers", @@ -674,7 +674,7 @@ "addAtLeastOneProvider": "Add At Least One Provider", "reasonSeparator": "Reason Separator", "zedImporting": "Zed Importing", - "confirmPasswordPlaceholder": "Confirm Password Placeholder", + "confirmPasswordPlaceholder": "Confirm password", "whatYouGet": "What You Get", "signatureSession": "Signature Session", "errorCountNoCode": "Error Count No Code", @@ -5064,7 +5064,102 @@ "skipped": "already configured", "failed": "failed" } - } + }, + "errorGuide": { + "whyLabel": "Why", + "fixLabel": "How to fix", + "retryPossible": "You can try again.", + "retryAfterFix": "Trying again will not help until the cause above is fixed.", + "docsLink": "Open the guide", + "network": { + "why": "The dashboard could not reach the OmniRoute server.", + "fix": "Check that OmniRoute is still running (terminal or Docker container) and that this page uses the same address, then try again." + }, + "timeout": { + "why": "The request took longer than the wizard waits.", + "fix": "The provider or your network is slow. Try again; if it keeps timing out, check the provider URL and your proxy settings." + }, + "unreachable": { + "why": "OmniRoute could not open a connection to the provider's server.", + "fix": "Check the provider URL (leave the custom URL empty to use the default), your internet connection, firewall and proxy settings." + }, + "tls": { + "why": "The secure (TLS) connection to the provider failed, usually because of an invalid certificate or a proxy that intercepts HTTPS.", + "fix": "Make sure a custom URL uses https with a valid certificate, or configure the proxy and CA certificate OmniRoute should trust." + }, + "credential": { + "why": "The provider rejected the credential: the key is invalid, expired, revoked or has no access to this API.", + "fix": "Create or copy a valid key in the provider's console, then go back to the provider step and add it again." + }, + "rateLimited": { + "why": "The provider is limiting requests for this key (rate limit or exhausted quota).", + "fix": "Wait a minute and try again, or check the plan and quota in the provider's console." + }, + "providerDown": { + "why": "The provider answered with a server error; the problem is on the provider's side.", + "fix": "Try again in a few minutes or check the provider's status page." + }, + "invalidInput": { + "why": "The server did not accept the data that was sent.", + "fix": "Review the fields of this step (the message above names the problem) and submit again." + }, + "sessionExpired": { + "why": "Your dashboard session is missing or has expired, so the server refused the request.", + "fix": "Sign in again at /login, then open the setup wizard again." + }, + "server": { + "why": "OmniRoute hit an internal error while handling the request.", + "fix": "Try again. If it happens again, look for the error in the OmniRoute server log (terminal or docker logs)." + }, + "noConnection": { + "why": "There is no provider connection to test yet.", + "fix": "Go back to the provider step and add a provider, or add one later from Providers." + }, + "noModels": { + "why": "The provider did not return any chat model for this credential.", + "fix": "Type a model ID this provider serves, or check the key's access in the provider's console." + }, + "paidModelBlocked": { + "why": "The \"hide paid models\" setting is on, so OmniRoute refused to test a paid model.", + "fix": "Choose a free model, or turn off \"hide paid models\" in Settings." + }, + "unknown": { + "why": "The server did not say why this failed.", + "fix": "Try again. If it keeps failing, check the OmniRoute server log and the troubleshooting guide." + } + }, + "connectionLabel": "Connection to test", + "backToProvider": "Back to provider", + "chooseModelTitle": "Choose a model", + "chooseModelDesc": "Models the provider listed for this credential. Pick one to send a short test request.", + "loadingModels": "Loading models…", + "modelLabel": "Model", + "modelIdManualPlaceholder": "Model ID, e.g. gpt-4o-mini", + "failedLoadModels": "Could not load the model list.", + "noModelsListed": "The provider did not list any chat models.", + "sendTestRequest": "Send test request", + "sendingTestRequest": "Sending test request…", + "testRequestOk": "The model answered in {ms} ms.", + "testRequestFailed": "The test request failed.", + "clientConfigTitle": "Client configuration", + "clientConfigDesc": "Use these settings in any OpenAI-compatible tool (SDKs, IDE extensions, CLI agents).", + "apiKeyLabel": "API key", + "apiKeyHint": "Replace the placeholder with an OmniRoute API key created in API Manager. It is not your provider key.", + "copyConfig": "Copy configuration", + "copied": "Copied!", + "copyFailed": "Could not copy automatically. Select the text above and copy it by hand.", + "openApiManager": "Create an API key", + "openEndpoints": "More client setups", + "firstRequestTitle": "Your first request", + "firstRequestLoading": "Looking for your first request…", + "firstRequestFound": "Latest request: {model} (status {status}).", + "firstRequestNone": "No request recorded yet. Send one with the configuration above; it will appear in the request logs.", + "firstRequestFailedLoad": "Could not read the request logs right now.", + "refreshRequests": "Check again", + "openLogs": "Open request logs", + "firstStepsGuide": "Guide: your first 10 minutes", + "passwordAlreadySet": "A dashboard password is already set (for example through INITIAL_PASSWORD). Keep it, or type a new one below to replace it.", + "keepPassword": "Keep current password" }, "providers": { "title": "Providers", @@ -5851,22 +5946,22 @@ "featureFlagNewApiAggregatorBalanceDescription": "Enable balance detection for New-API / One-API / Sub2API aggregator compatible nodes", "cpaModeDisabledTitle": "CLIProxyAPI compatibility mode is disabled", "cpaModeEnabledTitle": "CLIProxyAPI compatibility mode is enabled", - "customUserAgentHint": "Custom User Agent Hint", - "customUserAgentLabel": "Custom User Agent Label", + "customUserAgentHint": "Optional override sent upstream as this connection's User-Agent header.", + "customUserAgentLabel": "Custom User-Agent", "databricksBaseUrlHint": "Databricks Base Url Hint", "defaultThinkingStrengthHint": "Used when a request does not specify reasoning effort.", "defaultThinkingStrengthLabel": "Default thinking strength", "deleteAllExtraApiKeys": "Delete all", - "excludedModelsHint": "Excluded Models Hint", - "excludedModelsLabel": "Excluded Models Label", - "excludedModelsPlaceholder": "Excluded Models Placeholder", + "excludedModelsHint": "Comma-separated wildcard patterns. This connection is skipped for matching models.", + "excludedModelsLabel": "Excluded models", + "excludedModelsPlaceholder": "gpt-5*, claude-opus-*, gemini-*-pro*", "expirationBannerExpired": "{count, plural, one {# connection has expired} other {# connections have expired}}", "expirationBannerExpiredDesc": "Immediate action required. Expired connections fail permanently until they are re-authenticated.", "expirationBannerExpiringSoon": "{count, plural, one {# connection expires soon} other {# connections expire soon}}", "expirationBannerExpiringSoonDesc": "Review and renew the connections that are about to expire to avoid interruptions.", "extraApiKeyMasked": "Key {index}: {prefix}••••{suffix}", - "extraApiKeysHint": "Extra Api Keys Hint", - "extraApiKeysLabel": "Extra Api Keys Label", + "extraApiKeysHint": "optional, rotated round-robin", + "extraApiKeysLabel": "Extra API keys", "apiKeyHealthLabel": "API Key Health", "apiKeyStatusActive": "Active", "apiKeyStatusWarning": "Warning ({count} failures)", @@ -5894,7 +5989,7 @@ "embeddingRerankProviders": "Embeddings & Rerank", "imagesShortLabel": "Images Short Label", "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", @@ -5941,13 +6036,13 @@ "regionHint": "Region Hint", "regionLabel": "Region Label", "removeThisKey": "Remove This Key", - "routingTagsHint": "Routing Tags Hint", - "routingTagsLabel": "Routing Tags Label", - "routingTagsPlaceholder": "Routing Tags Placeholder", + "routingTagsHint": "Comma-separated tags, matched against the request's metadata.tags for tag-based routing.", + "routingTagsLabel": "Routing tags", + "routingTagsPlaceholder": "fast, cheap, eu-region", "search": "Search", - "searchEngineIdHint": "Search Engine Id Hint", - "searchEngineIdLabel": "Search Engine Id Label", - "searchEngineIdRequired": "Search Engine Id Required", + "searchEngineIdHint": "Required. Find it in the overview of your Programmable Search Engine.", + "searchEngineIdLabel": "Search Engine ID (cx)", + "searchEngineIdRequired": "The Programmable Search Engine ID (cx) is required.", "searchProvider": "Search Provider", "searchProviderDesc": "Search Provider Desc", "searchProviders": "Search Providers", @@ -5963,9 +6058,9 @@ "supportedEndpointEmbeddings": "Embeddings", "supportedEndpointImages": "Images", "supportedEndpointsLabel": "Supported endpoints", - "tagGroupHint": "Tag Group Hint", - "tagGroupLabel": "Tag Group Label", - "tagGroupPlaceholder": "Tag Group Placeholder", + "tagGroupHint": "Used to group accounts in the provider view.", + "tagGroupLabel": "Tag / group", + "tagGroupPlaceholder": "e.g. personal, work, team-a", "testModel": "Test Model", "testingModel": "Testing Model", "modelTestQuotaTooltip": "Quota exhausted — resets tomorrow or needs a top-up", @@ -9893,7 +9988,7 @@ "waitingForQoderAuthorization": "Waiting for Qoder authorization...", "exchangingCodeForTokens": "Exchanging code for tokens...", "nodeIncompatibleTitle": "Incompatible Node.js Version", - "nodeIncompatibleDesc": "You are running Node.js {version}, which is outside OmniRoute's supported secure runtime policy. Use a patched Node.js 20.x or 22.x LTS release.", + "nodeIncompatibleDesc": "You are running Node.js {version}, which is outside OmniRoute's supported secure runtime policy. Use a patched Node.js 22.x or 24.x LTS release.", "nodeIncompatibleFixLabel": "Fix: install a patched Node.js 22 LTS release", "nodeIncompatibleHint": "OmniRoute requires Node.js 22.22.2+ (22.x LTS) or 24.0.0+ (24.x LTS). Node 24 LTS is recommended.", "resetViaAppDataTitle": "Reset via App Data", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 92a9a94bb18..278d4f168b6 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -352,7 +352,7 @@ "comboNamePlaceholder": "Combo Name Placeholder", "memoryRss": "Memory Rss", "disableProvider": "Disable Provider", - "welcomeDesc": "Welcome Desc", + "welcomeDesc": "O OmniRoute é o seu proxy local de APIs de IA. Ele encaminha requisições para vários provedores de IA com balanceamento de carga, failover e acompanhamento de uso.", "nameLabel": "Name Label", "allOperational": "All Operational", "backupNow": "Backup Now", @@ -361,7 +361,7 @@ "machineId": "Machine Id", "globalProxy": "Global Proxy", "hitsMisses": "Hits Misses", - "testDesc": "Test Desc", + "testDesc": "Vamos verificar se a conexão com o provedor funciona.", "chatDesc": "Chat Desc", "importSuccess": "Import Success", "chat": "Chat", @@ -492,7 +492,7 @@ "enableProvider": "Enable Provider", "modeTest": "Mode Test", "failedDeleteChain": "Failed Delete Chain", - "providerDesc": "Provider Desc", + "providerDesc": "Conecte o seu primeiro provedor de IA. Você pode adicionar outros depois.", "templateLoadHint": "Template Load Hint", "lastFailure": "Last Failure", "moveDown": "Move Down", @@ -587,7 +587,7 @@ "zedImportHint": "Zed Import Hint", "modelsAcrossEndpoints": "Models Across Endpoints", "autoDisableBannedAccounts": "Auto Disable Banned Accounts", - "securityDesc": "Security Desc", + "securityDesc": "Defina uma senha para proteger o painel, ou pule por enquanto.", "listModels": "List Models", "backupRestore": "Backup Restore", "target": "Target", @@ -604,7 +604,7 @@ "signatureTool": "Signature Tool", "connectionFailed": "Connection Failed", "activeLimitersPlural": "Active Limiters Plural", - "doneDesc": "Done Desc", + "doneDesc": "Tudo pronto! A sua instância do OmniRoute está configurada e pronta para encaminhar requisições de IA.", "down": "Down", "noDataYet": "No Data Yet", "activeProviders": "Active Providers", @@ -674,7 +674,7 @@ "addAtLeastOneProvider": "Add At Least One Provider", "reasonSeparator": "Reason Separator", "zedImporting": "Zed Importing", - "confirmPasswordPlaceholder": "Confirm Password Placeholder", + "confirmPasswordPlaceholder": "Confirme a senha", "whatYouGet": "What You Get", "signatureSession": "Signature Session", "errorCountNoCode": "Error Count No Code", @@ -5064,7 +5064,102 @@ "skipped": "já configurado", "failed": "falhou" } - } + }, + "errorGuide": { + "whyLabel": "Por quê", + "fixLabel": "Como corrigir", + "retryPossible": "Você pode tentar novamente.", + "retryAfterFix": "Tentar de novo não resolve até a causa acima ser corrigida.", + "docsLink": "Abrir o guia", + "network": { + "why": "O painel não conseguiu falar com o servidor do OmniRoute.", + "fix": "Confira se o OmniRoute continua rodando (terminal ou contêiner Docker) e se esta página usa o mesmo endereço; depois tente novamente." + }, + "timeout": { + "why": "A requisição demorou mais do que o assistente espera.", + "fix": "O provedor ou a sua rede estão lentos. Tente novamente; se continuar estourando o tempo, confira a URL do provedor e as configurações de proxy." + }, + "unreachable": { + "why": "O OmniRoute não conseguiu abrir conexão com o servidor do provedor.", + "fix": "Confira a URL do provedor (deixe a URL personalizada vazia para usar a padrão), a sua conexão com a internet, o firewall e o proxy." + }, + "tls": { + "why": "A conexão segura (TLS) com o provedor falhou, em geral por certificado inválido ou por um proxy que intercepta HTTPS.", + "fix": "Garanta que a URL personalizada use https com certificado válido, ou configure o proxy e o certificado de CA em que o OmniRoute deve confiar." + }, + "credential": { + "why": "O provedor recusou a credencial: a chave é inválida, expirou, foi revogada ou não tem acesso a esta API.", + "fix": "Crie ou copie uma chave válida no console do provedor, volte à etapa do provedor e adicione-a de novo." + }, + "rateLimited": { + "why": "O provedor está limitando as requisições desta chave (limite de taxa ou cota esgotada).", + "fix": "Aguarde um minuto e tente novamente, ou confira o plano e a cota no console do provedor." + }, + "providerDown": { + "why": "O provedor respondeu com erro de servidor; o problema está do lado do provedor.", + "fix": "Tente novamente em alguns minutos ou consulte a página de status do provedor." + }, + "invalidInput": { + "why": "O servidor não aceitou os dados enviados.", + "fix": "Revise os campos desta etapa (a mensagem acima indica o problema) e envie de novo." + }, + "sessionExpired": { + "why": "A sua sessão do painel não existe ou expirou, então o servidor recusou a requisição.", + "fix": "Entre novamente em /login e abra o assistente de configuração outra vez." + }, + "server": { + "why": "O OmniRoute encontrou um erro interno ao processar a requisição.", + "fix": "Tente novamente. Se voltar a acontecer, procure o erro no log do servidor do OmniRoute (terminal ou docker logs)." + }, + "noConnection": { + "why": "Ainda não existe conexão de provedor para testar.", + "fix": "Volte à etapa do provedor e adicione um, ou adicione depois em Provedores." + }, + "noModels": { + "why": "O provedor não retornou nenhum modelo de chat para esta credencial.", + "fix": "Digite o ID de um modelo que este provedor oferece, ou confira o acesso da chave no console do provedor." + }, + "paidModelBlocked": { + "why": "A configuração \"ocultar modelos pagos\" está ativa, então o OmniRoute recusou testar um modelo pago.", + "fix": "Escolha um modelo gratuito, ou desative \"ocultar modelos pagos\" nas Configurações." + }, + "unknown": { + "why": "O servidor não informou o motivo da falha.", + "fix": "Tente novamente. Se continuar falhando, confira o log do servidor do OmniRoute e o guia de solução de problemas." + } + }, + "connectionLabel": "Conexão a testar", + "backToProvider": "Voltar ao provedor", + "chooseModelTitle": "Escolha um modelo", + "chooseModelDesc": "Modelos que o provedor listou para esta credencial. Escolha um para enviar uma requisição de teste curta.", + "loadingModels": "Carregando modelos…", + "modelLabel": "Modelo", + "modelIdManualPlaceholder": "ID do modelo, ex.: gpt-4o-mini", + "failedLoadModels": "Não foi possível carregar a lista de modelos.", + "noModelsListed": "O provedor não listou nenhum modelo de chat.", + "sendTestRequest": "Enviar requisição de teste", + "sendingTestRequest": "Enviando requisição de teste…", + "testRequestOk": "O modelo respondeu em {ms} ms.", + "testRequestFailed": "A requisição de teste falhou.", + "clientConfigTitle": "Configuração do cliente", + "clientConfigDesc": "Use estas configurações em qualquer ferramenta compatível com OpenAI (SDKs, extensões de IDE, agentes de CLI).", + "apiKeyLabel": "Chave de API", + "apiKeyHint": "Troque o marcador por uma chave de API do OmniRoute criada no Gerenciador de API. Ela não é a chave do provedor.", + "copyConfig": "Copiar configuração", + "copied": "Copiado!", + "copyFailed": "Não foi possível copiar automaticamente. Selecione o texto acima e copie manualmente.", + "openApiManager": "Criar uma chave de API", + "openEndpoints": "Mais configurações de cliente", + "firstRequestTitle": "Sua primeira requisição", + "firstRequestLoading": "Procurando a sua primeira requisição…", + "firstRequestFound": "Requisição mais recente: {model} (status {status}).", + "firstRequestNone": "Nenhuma requisição registrada ainda. Envie uma com a configuração acima; ela aparecerá nos logs de requisições.", + "firstRequestFailedLoad": "Não foi possível ler os logs de requisições agora.", + "refreshRequests": "Verificar de novo", + "openLogs": "Abrir logs de requisições", + "firstStepsGuide": "Guia: seus primeiros 10 minutos", + "passwordAlreadySet": "Já existe uma senha do painel (por exemplo, definida por INITIAL_PASSWORD). Mantenha-a ou digite uma nova abaixo para substituí-la.", + "keepPassword": "Manter a senha atual" }, "providers": { "title": "Provedores", @@ -5894,7 +5989,7 @@ "embeddingRerankProviders": "Incorporações e reclassificação", "imagesShortLabel": "Imagens", "llmProviders": "Provedores LLM", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Opcional para {provider}: deixe vazio se o seu servidor local não exigir chave de API.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local / Self-Hosted", "maxConcurrentWholeNumberError": "Max concurrent deve ser um número inteiro maior ou igual a 0.", @@ -9892,10 +9987,10 @@ "waitingForAntigravityAuthorization": "Aguardando autorização antigravidade...", "waitingForQoderAuthorization": "Aguardando autorização do Qoder...", "exchangingCodeForTokens": "Trocando código por tokens...", - "nodeIncompatibleTitle": "Incompatible Node.js Version", - "nodeIncompatibleDesc": "You are running Node.js {version}, which is outside OmniRoute's supported secure runtime policy. Use a patched Node.js 20.x or 22.x LTS release.", - "nodeIncompatibleFixLabel": "Fix: install a patched Node.js 22 LTS release", - "nodeIncompatibleHint": "OmniRoute requires Node.js 22.22.2+ (22.x LTS) or 24.0.0+ (24.x LTS). Node 24 LTS is recommended.", + "nodeIncompatibleTitle": "Versão do Node.js incompatível", + "nodeIncompatibleDesc": "Você está usando o Node.js {version}, que está fora da política de runtime seguro suportada pelo OmniRoute. Use uma versão LTS corrigida do Node.js 22.x ou 24.x.", + "nodeIncompatibleFixLabel": "Correção: instale uma versão LTS corrigida do Node.js 22", + "nodeIncompatibleHint": "O OmniRoute exige Node.js 22.22.2+ (22.x LTS) ou 24.0.0+ (24.x LTS). O Node 24 LTS é o recomendado.", "resetViaAppDataTitle": "Redefinir via Dados do App", "resetViaAppDataDescription": "Exclua o arquivo de configurações do diretório de dados do aplicativo para redefinir sua senha:", "quitDesktopApp": "Saia do aplicativo desktop OmniRoute completamente", diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index 20f5d26427b..658c15df45b 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -674,7 +674,7 @@ "addAtLeastOneProvider": "Thêm ít nhất một nhà cung cấp", "reasonSeparator": "Dấu phân cách lý do", "zedImporting": "Đang nhập Zed", - "confirmPasswordPlaceholder": "Văn bản gợi ý xác nhận mật khẩu", + "confirmPasswordPlaceholder": "Xác nhận mật khẩu", "whatYouGet": "Bạn nhận được gì", "signatureSession": "Phiên chữ ký", "errorCountNoCode": "Số lỗi không có mã", @@ -5064,7 +5064,102 @@ "skipped": "đã được cấu hình", "failed": "thất bại" } - } + }, + "errorGuide": { + "whyLabel": "Nguyên nhân", + "fixLabel": "Cách khắc phục", + "retryPossible": "Bạn có thể thử lại.", + "retryAfterFix": "Thử lại sẽ không có tác dụng cho đến khi nguyên nhân ở trên được khắc phục.", + "docsLink": "Mở hướng dẫn", + "network": { + "why": "Bảng điều khiển không thể kết nối tới máy chủ OmniRoute.", + "fix": "Kiểm tra OmniRoute vẫn đang chạy (terminal hoặc container Docker) và trang này dùng đúng địa chỉ, sau đó thử lại." + }, + "timeout": { + "why": "Yêu cầu mất nhiều thời gian hơn mức trình hướng dẫn chờ.", + "fix": "Nhà cung cấp hoặc mạng của bạn đang chậm. Hãy thử lại; nếu vẫn hết thời gian chờ, kiểm tra URL nhà cung cấp và cấu hình proxy." + }, + "unreachable": { + "why": "OmniRoute không thể mở kết nối tới máy chủ của nhà cung cấp.", + "fix": "Kiểm tra URL nhà cung cấp (để trống URL tùy chỉnh để dùng mặc định), kết nối internet, tường lửa và cấu hình proxy." + }, + "tls": { + "why": "Kết nối bảo mật (TLS) tới nhà cung cấp thất bại, thường do chứng chỉ không hợp lệ hoặc proxy chặn HTTPS.", + "fix": "Đảm bảo URL tùy chỉnh dùng https với chứng chỉ hợp lệ, hoặc cấu hình proxy và chứng chỉ CA mà OmniRoute cần tin cậy." + }, + "credential": { + "why": "Nhà cung cấp từ chối thông tin xác thực: khóa không hợp lệ, đã hết hạn, bị thu hồi hoặc không có quyền truy cập API này.", + "fix": "Tạo hoặc sao chép một khóa hợp lệ trong bảng điều khiển của nhà cung cấp, sau đó quay lại bước nhà cung cấp và thêm lại." + }, + "rateLimited": { + "why": "Nhà cung cấp đang giới hạn yêu cầu cho khóa này (giới hạn tốc độ hoặc hết hạn mức).", + "fix": "Đợi một phút rồi thử lại, hoặc kiểm tra gói dịch vụ và hạn mức trong bảng điều khiển của nhà cung cấp." + }, + "providerDown": { + "why": "Nhà cung cấp trả về lỗi máy chủ; sự cố nằm ở phía nhà cung cấp.", + "fix": "Thử lại sau vài phút hoặc xem trang trạng thái của nhà cung cấp." + }, + "invalidInput": { + "why": "Máy chủ không chấp nhận dữ liệu đã gửi.", + "fix": "Xem lại các trường của bước này (thông báo ở trên nêu rõ vấn đề) rồi gửi lại." + }, + "sessionExpired": { + "why": "Phiên đăng nhập bảng điều khiển không có hoặc đã hết hạn nên máy chủ từ chối yêu cầu.", + "fix": "Đăng nhập lại tại /login, sau đó mở lại trình hướng dẫn thiết lập." + }, + "server": { + "why": "OmniRoute gặp lỗi nội bộ khi xử lý yêu cầu.", + "fix": "Hãy thử lại. Nếu lỗi lặp lại, tìm lỗi trong nhật ký máy chủ OmniRoute (terminal hoặc docker logs)." + }, + "noConnection": { + "why": "Chưa có kết nối nhà cung cấp nào để kiểm tra.", + "fix": "Quay lại bước nhà cung cấp để thêm một nhà cung cấp, hoặc thêm sau trong mục Nhà cung cấp." + }, + "noModels": { + "why": "Nhà cung cấp không trả về mô hình chat nào cho thông tin xác thực này.", + "fix": "Nhập ID của một mô hình mà nhà cung cấp này phục vụ, hoặc kiểm tra quyền truy cập của khóa trong bảng điều khiển của nhà cung cấp." + }, + "paidModelBlocked": { + "why": "Cài đặt \"ẩn mô hình trả phí\" đang bật nên OmniRoute từ chối kiểm tra mô hình trả phí.", + "fix": "Chọn một mô hình miễn phí, hoặc tắt \"ẩn mô hình trả phí\" trong Cài đặt." + }, + "unknown": { + "why": "Máy chủ không cho biết lý do thất bại.", + "fix": "Hãy thử lại. Nếu vẫn thất bại, kiểm tra nhật ký máy chủ OmniRoute và hướng dẫn khắc phục sự cố." + } + }, + "connectionLabel": "Kết nối cần kiểm tra", + "backToProvider": "Quay lại nhà cung cấp", + "chooseModelTitle": "Chọn một mô hình", + "chooseModelDesc": "Các mô hình mà nhà cung cấp liệt kê cho thông tin xác thực này. Chọn một mô hình để gửi một yêu cầu kiểm tra ngắn.", + "loadingModels": "Đang tải mô hình…", + "modelLabel": "Mô hình", + "modelIdManualPlaceholder": "ID mô hình, ví dụ: gpt-4o-mini", + "failedLoadModels": "Không thể tải danh sách mô hình.", + "noModelsListed": "Nhà cung cấp không liệt kê mô hình chat nào.", + "sendTestRequest": "Gửi yêu cầu kiểm tra", + "sendingTestRequest": "Đang gửi yêu cầu kiểm tra…", + "testRequestOk": "Mô hình đã phản hồi trong {ms} ms.", + "testRequestFailed": "Yêu cầu kiểm tra thất bại.", + "clientConfigTitle": "Cấu hình máy khách", + "clientConfigDesc": "Dùng các thiết lập này trong bất kỳ công cụ tương thích OpenAI nào (SDK, tiện ích IDE, tác tử CLI).", + "apiKeyLabel": "Khóa API", + "apiKeyHint": "Thay phần giữ chỗ bằng khóa API OmniRoute được tạo trong Trình quản lý API. Đây không phải khóa của nhà cung cấp.", + "copyConfig": "Sao chép cấu hình", + "copied": "Đã sao chép!", + "copyFailed": "Không thể tự động sao chép. Hãy chọn văn bản ở trên và sao chép thủ công.", + "openApiManager": "Tạo khóa API", + "openEndpoints": "Thêm cấu hình máy khách", + "firstRequestTitle": "Yêu cầu đầu tiên của bạn", + "firstRequestLoading": "Đang tìm yêu cầu đầu tiên của bạn…", + "firstRequestFound": "Yêu cầu gần nhất: {model} (trạng thái {status}).", + "firstRequestNone": "Chưa có yêu cầu nào được ghi lại. Hãy gửi một yêu cầu với cấu hình ở trên; yêu cầu sẽ xuất hiện trong nhật ký yêu cầu.", + "firstRequestFailedLoad": "Hiện không thể đọc nhật ký yêu cầu.", + "refreshRequests": "Kiểm tra lại", + "openLogs": "Mở nhật ký yêu cầu", + "firstStepsGuide": "Hướng dẫn: 10 phút đầu tiên của bạn", + "passwordAlreadySet": "Mật khẩu bảng điều khiển đã được đặt (ví dụ qua INITIAL_PASSWORD). Giữ nguyên, hoặc nhập mật khẩu mới bên dưới để thay thế.", + "keepPassword": "Giữ mật khẩu hiện tại" }, "providers": { "title": "Nhà cung cấp", @@ -5851,22 +5946,22 @@ "featureFlagNewApiAggregatorBalanceDescription": "Bật tính năng phát hiện số dư cho các node tương thích với bộ tổng hợp New-API / One-API / Sub2API", "cpaModeDisabledTitle": "Chế độ tương thích CLIProxyAPI đã bị tắt", "cpaModeEnabledTitle": "Chế độ tương thích CLIProxyAPI đã được bật", - "customUserAgentHint": "Gợi ý User Agent tùy chỉnh", - "customUserAgentLabel": "Nhãn User Agent tùy chỉnh", + "customUserAgentHint": "Giá trị tùy chọn gửi lên nhà cung cấp làm tiêu đề User-Agent của kết nối này.", + "customUserAgentLabel": "User-Agent tùy chỉnh", "databricksBaseUrlHint": "Gợi ý URL cơ sở của Databricks", "defaultThinkingStrengthHint": "Được sử dụng khi một yêu cầu không chỉ định mức độ suy luận.", "defaultThinkingStrengthLabel": "Mức độ suy luận mặc định", "deleteAllExtraApiKeys": "Xóa tất cả", - "excludedModelsHint": "Gợi ý các mô hình bị loại trừ", - "excludedModelsLabel": "Nhãn các mô hình bị loại trừ", - "excludedModelsPlaceholder": "Nhập các mô hình bị loại trừ", + "excludedModelsHint": "Các mẫu wildcard phân tách bằng dấu phẩy. Kết nối này sẽ bị bỏ qua với các mô hình khớp mẫu.", + "excludedModelsLabel": "Mô hình bị loại trừ", + "excludedModelsPlaceholder": "gpt-5*, claude-opus-*, gemini-*-pro*", "expirationBannerExpired": "{count, plural, one {# kết nối đã hết hạn} other {# kết nối đã hết hạn}}", "expirationBannerExpiredDesc": "Cần xử lý ngay. Các kết nối đã hết hạn sẽ liên tục thất bại cho đến khi được xác thực lại.", "expirationBannerExpiringSoon": "{count, plural, one {# kết nối sắp hết hạn} other {# kết nối sắp hết hạn}}", "expirationBannerExpiringSoonDesc": "Hãy kiểm tra và gia hạn các kết nối sắp hết hạn để tránh gián đoạn.", "extraApiKeyMasked": "Khóa {index}: {prefix}••••{suffix}", - "extraApiKeysHint": "Gợi ý API key bổ sung", - "extraApiKeysLabel": "Nhãn các API key bổ sung", + "extraApiKeysHint": "không bắt buộc, xoay vòng lần lượt", + "extraApiKeysLabel": "Khóa API bổ sung", "apiKeyHealthLabel": "Tình trạng API key", "apiKeyStatusActive": "Hoạt động", "apiKeyStatusWarning": "Cảnh báo ({count} lần thất bại)", @@ -5894,7 +5989,7 @@ "embeddingRerankProviders": "Nhúng & xếp hạng lại", "imagesShortLabel": "Nhãn ngắn cho hình ảnh", "llmProviders": "Nhà cung cấp LLM", - "localProviderApiKeyOptionalHint": "Gợi ý tùy chọn API key của nhà cung cấp cục bộ", + "localProviderApiKeyOptionalHint": "Không bắt buộc với {provider}: để trống nếu máy chủ cục bộ của bạn không yêu cầu khóa API.", "localProviderBaseUrlHint": "Gợi ý URL cơ sở của nhà cung cấp cục bộ", "localProviders": "Nhà cung cấp cục bộ", "maxConcurrentWholeNumberError": "Lỗi số nguyên cho số lượng đồng thời tối đa", @@ -5941,13 +6036,13 @@ "regionHint": "Gợi ý vùng", "regionLabel": "Nhãn vùng", "removeThisKey": "Xóa khóa này", - "routingTagsHint": "Gợi ý thẻ định tuyến", - "routingTagsLabel": "Nhãn thẻ định tuyến", - "routingTagsPlaceholder": "Nhập thẻ định tuyến...", + "routingTagsHint": "Các thẻ phân tách bằng dấu phẩy, được so khớp với metadata.tags của yêu cầu để định tuyến theo thẻ.", + "routingTagsLabel": "Thẻ định tuyến", + "routingTagsPlaceholder": "fast, cheap, eu-region", "search": "Tìm kiếm", - "searchEngineIdHint": "Gợi ý ID công cụ tìm kiếm", - "searchEngineIdLabel": "Nhãn ID công cụ tìm kiếm", - "searchEngineIdRequired": "Yêu cầu ID công cụ tìm kiếm", + "searchEngineIdHint": "Bắt buộc. Xem trong phần tổng quan của Programmable Search Engine của bạn.", + "searchEngineIdLabel": "ID công cụ tìm kiếm (cx)", + "searchEngineIdRequired": "Bắt buộc phải có ID (cx) của Programmable Search Engine.", "searchProvider": "Nhà cung cấp tìm kiếm", "searchProviderDesc": "Mô tả nhà cung cấp tìm kiếm", "searchProviders": "Các nhà cung cấp tìm kiếm", @@ -5963,9 +6058,9 @@ "supportedEndpointEmbeddings": "Phần nhúng", "supportedEndpointImages": "Hình ảnh", "supportedEndpointsLabel": "Nhãn các endpoint được hỗ trợ", - "tagGroupHint": "Gợi ý nhóm thẻ", - "tagGroupLabel": "Nhãn nhóm thẻ", - "tagGroupPlaceholder": "Nhập nhóm thẻ...", + "tagGroupHint": "Dùng để nhóm các tài khoản trong giao diện nhà cung cấp.", + "tagGroupLabel": "Thẻ / nhóm", + "tagGroupPlaceholder": "ví dụ: personal, work, team-a", "testModel": "Kiểm tra mô hình", "testingModel": "Đang kiểm tra mô hình", "modelTestQuotaTooltip": "Đã hết hạn mức — sẽ được đặt lại vào ngày mai hoặc cần nạp thêm", @@ -9893,7 +9988,7 @@ "waitingForQoderAuthorization": "Đang chờ cấp quyền qua Qoder...", "exchangingCodeForTokens": "Đang đổi mã lấy token...", "nodeIncompatibleTitle": "Phiên bản Node.js không tương thích", - "nodeIncompatibleDesc": "Bạn đang chạy Node.js {version}, phiên bản này nằm ngoài chính sách môi trường chạy an toàn được hỗ trợ của OmniRoute. Vui lòng sử dụng bản phát hành Node.js 20.x hoặc 22.x LTS đã được vá.", + "nodeIncompatibleDesc": "Bạn đang chạy Node.js {version}, phiên bản này nằm ngoài chính sách môi trường chạy an toàn được hỗ trợ của OmniRoute. Vui lòng sử dụng bản phát hành Node.js 22.x hoặc 24.x LTS đã được vá.", "nodeIncompatibleFixLabel": "Khắc phục: cài đặt bản phát hành Node.js 22 LTS đã được vá", "nodeIncompatibleHint": "OmniRoute yêu cầu Node.js 22.22.2+ (22.x LTS) hoặc 24.0.0+ (24.x LTS). Khuyến nghị sử dụng Node 24 LTS.", "resetViaAppDataTitle": "Đặt lại bằng dữ liệu ứng dụng", From 02c2990e71f08e93adfec4c6fe9c752632d23787 Mon Sep 17 00:00:00 2001 From: zodyp Date: Fri, 18 Sep 2026 14:40:37 -0300 Subject: [PATCH 3/6] feat(onboarding): end-to-end first-use flow with actionable errors The wizard's test step tested connections[0] instead of the provider the user had just added, and the flow stopped at a bare endpoint string. It now covers the whole first use: - Validate (step 5): POST /api/providers/{id}/test on the connection created in this run (from POST /api/providers or the free-provider card), with a selector when several exist; 15 s bound and retry kept. - Choose a model (6): GET /api/providers/{id}/models?chatOnly&excludeHidden; when the provider lists none, a model id can be typed. - Test request (7): POST /api/models/test with providerId/modelId/connectionId, routed through the real chat pipeline so it lands in the call logs. - Client configuration (8): Base URL / API-key placeholder / provider/model with a copy button, links to API Manager and Endpoints. - First request (9): newest row of /api/usage/call-logs (model + status only, no bodies) with a link to /dashboard/logs; docs link to docs/getting-started/FIRST_10_MINUTES.md (/docs/getting-started/first_10_minutes). - A password configured before the wizard (INITIAL_PASSWORD) is detected via GET /api/settings/require-login: "Keep current password" replaces the skip-and-disable-login option. ?rerun=1 opens the wizard after setup. Errors: src/shared/utils/actionableError.ts classifies failures from the existing envelopes (HTTP status, diagnosis.type/code of the connection test, /api/models/test status + upstream statusCode) into kinds; the new ActionableErrorCallout renders what happened (role=alert headline), why, how to fix, whether retrying can help, a retry action and a docs link. No HTTP error envelope changed. Layout: the step indicator uses shrinking connectors instead of fixed widths and the footer wraps, so the wizard never scrolls horizontally at 320-1024 px. The existing pinned jsdom suites (onboarding-error-visible, -public-endpoint, -test-timeout, -test-valid-false, free-provider-onboarding) pass unchanged. New: tests/unit/ui/onboarding-first-use-flow.test.tsx (8) and tests/unit/actionable-error-guide.test.ts (5). Complexity-ratchet violations on the touched files unchanged (8 before / 8 after; the new files add none). Co-Authored-By: Claude Opus 5 --- .../components/ActionableErrorCallout.tsx | 79 ++++ .../onboarding/components/WizardProgress.tsx | 45 +++ .../(dashboard)/dashboard/onboarding/page.tsx | 365 +++++++----------- .../onboarding/steps/FirstUseDone.tsx | 165 ++++++++ .../steps/FreeProviderOnboardingCard.tsx | 20 +- .../onboarding/steps/ValidateAndTryStep.tsx | 190 +++++++++ .../dashboard/onboarding/steps/firstUseApi.ts | 180 +++++++++ .../onboarding/steps/useCredentialCheck.ts | 166 ++++++++ .../onboarding/steps/useModelTrial.ts | 102 +++++ src/shared/utils/actionableError.ts | 158 ++++++++ tests/unit/actionable-error-guide.test.ts | 123 ++++++ .../ui/onboarding-first-use-flow.test.tsx | 361 +++++++++++++++++ 12 files changed, 1732 insertions(+), 222 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/onboarding/components/ActionableErrorCallout.tsx create mode 100644 src/app/(dashboard)/dashboard/onboarding/components/WizardProgress.tsx create mode 100644 src/app/(dashboard)/dashboard/onboarding/steps/FirstUseDone.tsx create mode 100644 src/app/(dashboard)/dashboard/onboarding/steps/ValidateAndTryStep.tsx create mode 100644 src/app/(dashboard)/dashboard/onboarding/steps/firstUseApi.ts create mode 100644 src/app/(dashboard)/dashboard/onboarding/steps/useCredentialCheck.ts create mode 100644 src/app/(dashboard)/dashboard/onboarding/steps/useModelTrial.ts create mode 100644 src/shared/utils/actionableError.ts create mode 100644 tests/unit/actionable-error-guide.test.ts create mode 100644 tests/unit/ui/onboarding-first-use-flow.test.tsx diff --git a/src/app/(dashboard)/dashboard/onboarding/components/ActionableErrorCallout.tsx b/src/app/(dashboard)/dashboard/onboarding/components/ActionableErrorCallout.tsx new file mode 100644 index 00000000000..c1f8d5c1416 --- /dev/null +++ b/src/app/(dashboard)/dashboard/onboarding/components/ActionableErrorCallout.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import type { ActionableErrorGuide } from "@/shared/utils/actionableError"; + +interface ActionableErrorCalloutProps { + /** What happened — the readable headline (announced via role="alert"). */ + message: string; + guide: ActionableErrorGuide; + onRetry?: () => void; + /** Optional second action, e.g. "Back to provider" for a rejected credential. */ + secondaryAction?: { label: string; onClick: () => void }; +} + +/** + * Error block of the first-use flow: what happened, why, how to fix it, whether trying + * again can help, and a link to the guide. Only the headline carries role="alert" so + * screen readers announce the failure once; the guidance stays readable below it. + */ +export function ActionableErrorCallout({ + message, + guide, + onRetry, + secondaryAction, +}: ActionableErrorCalloutProps) { + const t = useTranslations("onboarding"); + return ( +
+

+ {message} +

+
+
+
{t("errorGuide.whyLabel")}:
+
{t(`errorGuide.${guide.kind}.why`)}
+
+
+
{t("errorGuide.fixLabel")}:
+
{t(`errorGuide.${guide.kind}.fix`)}
+
+
+

+ {guide.retryable ? t("errorGuide.retryPossible") : t("errorGuide.retryAfterFix")} +

+
+ {onRetry && ( + + )} + {secondaryAction && ( + + )} + + {t("errorGuide.docsLink")} + +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/onboarding/components/WizardProgress.tsx b/src/app/(dashboard)/dashboard/onboarding/components/WizardProgress.tsx new file mode 100644 index 00000000000..ab4d33d398b --- /dev/null +++ b/src/app/(dashboard)/dashboard/onboarding/components/WizardProgress.tsx @@ -0,0 +1,45 @@ +"use client"; + +/** + * Step indicator of the onboarding wizard. The connectors shrink (flex-1 with a small + * minimum) instead of using fixed widths, so six steps fit the card from 320 px up and + * never push the page into horizontal scroll at intermediate widths (audit C-09). + */ +export function WizardProgress({ stepCount, current }: { stepCount: number; current: number }) { + return ( +
    + {Array.from({ length: stepCount }, (_, i) => ( +
  1. +
    + {i < current ? ( + + ) : ( + i + 1 + )} +
    + {i < stepCount - 1 && ( +
    + )} +
  2. + ))} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/onboarding/page.tsx b/src/app/(dashboard)/dashboard/onboarding/page.tsx index a8816b8d7a9..17f4c33216a 100644 --- a/src/app/(dashboard)/dashboard/onboarding/page.tsx +++ b/src/app/(dashboard)/dashboard/onboarding/page.tsx @@ -6,11 +6,16 @@ import { useTranslations } from "next-intl"; import { useDisplayBaseUrl } from "@/shared/hooks"; import { FreeProviderOnboardingCard } from "./steps/FreeProviderOnboardingCard"; import { TierTour } from "./steps/TierTour"; -import { presentApiError, presentConnectionTestFailure } from "@/shared/utils/apiErrorPresentation"; +import { ValidateAndTryStep } from "./steps/ValidateAndTryStep"; +import { FirstUseDone } from "./steps/FirstUseDone"; +import { useCredentialCheck, type StepFailure } from "./steps/useCredentialCheck"; +import { useModelTrial } from "./steps/useModelTrial"; +import { ActionableErrorCallout } from "./components/ActionableErrorCallout"; +import { WizardProgress } from "./components/WizardProgress"; +import { presentApiError } from "@/shared/utils/apiErrorPresentation"; +import { actionableGuide, guideForHttpStatus } from "@/shared/utils/actionableError"; const STEP_IDS = ["welcome", "tiers", "security", "provider", "test", "done"]; -/** U8: upper bound for the onboarding connection test (list + probe). */ -const PROVIDER_TEST_TIMEOUT_MS = 15_000; const STEP_ICONS = ["waving_hand", "layers", "lock", "dns", "play_circle", "check_circle"]; const COMMON_PROVIDERS = [ @@ -22,6 +27,34 @@ const COMMON_PROVIDERS = [ { id: "mistral", name: "Mistral", color: "#FF7000" }, ]; +const DEFAULT_PROVIDER_URLS: Record = { + openai: "https://api.openai.com", + anthropic: "https://api.anthropic.com", + google: "https://generativelanguage.googleapis.com", + openrouter: "https://openrouter.ai/api", + groq: "https://api.groq.com/openai", + mistral: "https://api.mistral.ai", +}; + +const INPUT_CLASS = + "w-full px-4 py-2.5 bg-white/[0.04] border border-white/10 rounded-lg text-text-main text-sm placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/40"; +const PRIMARY_BUTTON_CLASS = + "px-6 py-2.5 bg-primary rounded-lg text-white font-medium text-sm hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"; + +/** `/dashboard/onboarding?rerun=1` opens the wizard even after setup was completed. */ +function isRerunRequested(): boolean { + try { + return new URLSearchParams(window.location.search).get("rerun") === "1"; + } catch { + return false; + } +} + +async function readJsonOrNull(res: Response): Promise | null> { + const body = await res.json().catch(() => null); + return body && typeof body === "object" ? (body as Record) : null; +} + export default function OnboardingWizard() { const router = useRouter(); const t = useTranslations("onboarding"); @@ -36,16 +69,21 @@ export default function OnboardingWizard() { const [confirmPassword, setConfirmPassword] = useState(""); const [skipSecurity, setSkipSecurity] = useState(false); const [capsLockOn, setCapsLockOn] = useState(false); + // A password configured before the wizard (INITIAL_PASSWORD or an earlier run). + const [existingPassword, setExistingPassword] = useState(false); // Provider step state - const [selectedProvider, setSelectedProvider] = useState(null); + const [selectedProvider, setSelectedProvider] = useState(null); const [providerUrl, setProviderUrl] = useState(""); const [providerKey, setProviderKey] = useState(""); const [providerName, setProviderName] = useState(""); + // The connection created in this run — the one the test step validates. + const [createdConnectionId, setCreatedConnectionId] = useState(null); - // Test step state - const [testStatus, setTestStatus] = useState("idle"); // idle, testing, success, error - const [testMessage, setTestMessage] = useState(""); + const trial = useModelTrial(t); + const credential = useCredentialCheck(createdConnectionId, t, tc, (connection) => { + void trial.loadModels(connection); + }); // Check if setup is already complete useEffect(() => { @@ -54,7 +92,7 @@ export default function OnboardingWizard() { const res = await fetch("/api/settings"); if (res.ok) { const settings = await res.json(); - if (settings.setupComplete) { + if (settings.setupComplete && !isRerunRequested()) { router.replace("/dashboard"); return; } @@ -62,6 +100,12 @@ export default function OnboardingWizard() { } catch { // Continue with setup } + try { + const res = await fetch("/api/settings/require-login"); + if (res.ok) setExistingPassword((await readJsonOrNull(res))?.hasPassword === true); + } catch { + // Unknown — the security step then behaves as on a fresh install. + } setLoading(false); }; checkSetup(); @@ -78,34 +122,31 @@ export default function OnboardingWizard() { // U1: API failures land here and are rendered (role="alert") inside the step that // produced them; changing step clears it so a stale message never follows the user. - const [errorMessage, setErrorMessage] = useState(""); + const [stepFailure, setStepFailure] = useState(null); // U4: the body may be `{ error: "text" }` or `{ error: { code, message } }` — always a string here. - const describeFailure = async (res: Response, fallback: string) => { + const describeFailure = async (res: Response, fallback: string): Promise => { const body = await res.json().catch(() => null); - return presentApiError(body, { + const message = presentApiError(body, { translate: (key) => (typeof tc.has !== "function" || tc.has(key) ? tc(key) : null), fallback, status: res.status, }).message; + return { message, guide: guideForHttpStatus(res.status) }; }; + const networkFailure = (): StepFailure => ({ + message: t("connectionError"), + guide: actionableGuide("network"), + }); - const handleNext = () => { - setErrorMessage(""); - if (step < STEPS.length - 1) setStep(step + 1); - }; - - const handleBack = () => { - setErrorMessage(""); - if (step > 0) setStep(step - 1); + const goTo = (next: number) => { + setStepFailure(null); + setStep(Math.min(Math.max(next, 0), STEPS.length - 1)); }; + const handleNext = () => goTo(step + 1); + const handleBack = () => goTo(step - 1); - const stepError = errorMessage ? ( -

- {errorMessage} -

+ const stepError = stepFailure ? ( + ) : null; const handleSetPassword = async () => { @@ -121,8 +162,12 @@ export default function OnboardingWizard() { handleNext(); return; } + if (existingPassword && !password) { + handleNext(); + return; + } if (password !== confirmPassword) return; - setErrorMessage(""); + setStepFailure(null); try { const res = await fetch("/api/settings/require-login", { method: "POST", @@ -130,7 +175,7 @@ export default function OnboardingWizard() { body: JSON.stringify({ requireLogin: true, password }), }); if (!res.ok) { - setErrorMessage(await describeFailure(res, t("failedSetPassword"))); + setStepFailure(await describeFailure(res, t("failedSetPassword"))); return; } const loginRes = await fetch("/api/auth/login", { @@ -139,110 +184,41 @@ export default function OnboardingWizard() { body: JSON.stringify({ password }), }); if (!loginRes.ok) { - setErrorMessage(await describeFailure(loginRes, t("connectionError"))); + setStepFailure(await describeFailure(loginRes, t("connectionError"))); return; } + setExistingPassword(true); handleNext(); } catch { - setErrorMessage(t("connectionError")); + setStepFailure(networkFailure()); } }; const handleAddProvider = async () => { if (!selectedProvider || !providerKey) return; - setErrorMessage(""); + setStepFailure(null); try { const provider = COMMON_PROVIDERS.find((p) => p.id === selectedProvider); - const defaultUrls = { - openai: "https://api.openai.com", - anthropic: "https://api.anthropic.com", - google: "https://generativelanguage.googleapis.com", - openrouter: "https://openrouter.ai/api", - groq: "https://api.groq.com/openai", - mistral: "https://api.mistral.ai", - }; const res = await fetch("/api/providers", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider: selectedProvider, name: providerName || provider?.name || selectedProvider, - url: providerUrl || defaultUrls[selectedProvider] || "", + url: providerUrl || DEFAULT_PROVIDER_URLS[selectedProvider] || "", apiKey: providerKey, isActive: true, }), }); if (!res.ok) { - setErrorMessage(await describeFailure(res, t("failedAddProvider"))); + setStepFailure(await describeFailure(res, t("failedAddProvider"))); return; } + const created = (await readJsonOrNull(res))?.connection as { id?: unknown } | undefined; + if (typeof created?.id === "string") setCreatedConnectionId(created.id); handleNext(); } catch { - setErrorMessage(t("connectionError")); - } - }; - - const handleTestProvider = async () => { - setTestStatus("testing"); - setTestMessage(t("testingConnection")); - // U8: a provider that never answers must not leave the wizard spinning forever. - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), PROVIDER_TEST_TIMEOUT_MS); - try { - const res = await fetch("/api/providers", { signal: controller.signal }); - if (!res.ok) throw new Error("Failed to fetch"); - const data = await res.json(); - const conn = data.connections?.[0]; - if (!conn) { - setTestStatus("error"); - setTestMessage(t("noProviderFound")); - return; - } - const testRes = await fetch(`/api/providers/${conn.id}/test`, { - method: "POST", - signal: controller.signal, - }); - if (testRes.ok) { - // The test route answers 200 for a probe that RAN; whether the provider is reachable - // is in the body (`valid`). Treating HTTP 200 as success told a first-time user - // "connection successful" for a dead host (found by the final audit's C-03 fix). - const result = (await testRes.json().catch(() => null)) as { - valid?: boolean; - error?: string | null; - warning?: string | null; - diagnosis?: { code?: string | null; message?: string | null } | null; - } | null; - if (result && result.valid === false) { - setTestStatus("error"); - setTestMessage( - presentConnectionTestFailure(result, { - // Forward the interpolation values ({host}, {seconds}) — dropping them would - // render the typed message without the host the user needs to check. - translate: (key, values) => - typeof tc.has !== "function" || tc.has(key) ? tc(key, values) : null, - fallback: t("testFailed"), - }).message - ); - return; - } - setTestStatus("success"); - setTestMessage(t("connectionSuccessful")); - } else { - const err = await testRes.json().catch(() => ({})); - setTestStatus("error"); - setTestMessage( - typeof err.error === "string" && err.error.trim() ? err.error : t("testFailed") - ); - } - } catch (err) { - setTestStatus("error"); - setTestMessage( - controller.signal.aborted || (err as { name?: string })?.name === "AbortError" - ? t("testTimedOut") - : t("couldNotTest") - ); - } finally { - clearTimeout(timeout); + setStepFailure(networkFailure()); } }; @@ -280,47 +256,30 @@ export default function OnboardingWizard() { ); } + const passwordsDiffer = password !== confirmPassword; + const securityBlocked = existingPassword + ? Boolean(password) && passwordsDiffer + : !password || passwordsDiffer; + const securityLabel = skipSecurity + ? t("skipAndContinue") + : existingPassword && !password + ? t("keepPassword") + : t("setPassword"); + return (
-
- {/* Progress Indicator */} -
- {STEPS.map((s, i) => ( -
-
- {i < step ? ( - check - ) : ( - i + 1 - )} -
- {i < STEPS.length - 1 && ( -
- )} -
- ))} -
+
+ {/* Card */} -
+
{/* Step Header */}
@@ -349,7 +308,10 @@ export default function OnboardingWizard() { className="h-full bg-white/[0.03] rounded-xl p-3 text-center border border-white/[0.06]" >
- + {f.label} @@ -367,15 +329,21 @@ export default function OnboardingWizard() { {currentStep.id === "security" && (

{t("securityDesc")}

- + {existingPassword ? ( +

+ {t("passwordAlreadySet")} +

+ ) : ( + + )} {skipSecurity && (

{t("securityDescSkipWarning")} @@ -391,7 +359,7 @@ export default function OnboardingWizard() { onChange={(e) => setPassword(e.target.value)} onKeyDown={(e) => setCapsLockOn(e.getModifierState("CapsLock"))} onKeyUp={(e) => setCapsLockOn(e.getModifierState("CapsLock"))} - className="w-full px-4 py-2.5 bg-white/[0.04] border border-white/10 rounded-lg text-text-main text-sm placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/40" + className={INPUT_CLASS} /> setConfirmPassword(e.target.value)} onKeyDown={(e) => setCapsLockOn(e.getModifierState("CapsLock"))} onKeyUp={(e) => setCapsLockOn(e.getModifierState("CapsLock"))} - className="w-full px-4 py-2.5 bg-white/[0.04] border border-white/10 rounded-lg text-text-main text-sm placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/40" + className={INPUT_CLASS} /> {capsLockOn && (

@@ -411,7 +379,7 @@ export default function OnboardingWizard() { {tc("capsLockOn")}

)} - {password && confirmPassword && password !== confirmPassword && ( + {password && confirmPassword && passwordsDiffer && (

{t("passwordsMismatch")}

)}
@@ -429,7 +397,13 @@ export default function OnboardingWizard() {

{t("providerRequiresPassword")}

)} - {!skipSecurity && } + {!skipSecurity && ( + { + if (ids[0]) setCreatedConnectionId(ids[0]); + }} + /> + )} {!skipSecurity && (
@@ -465,7 +439,7 @@ export default function OnboardingWizard() { aria-label={t("apiKeyRequired")} value={providerKey} onChange={(e) => setProviderKey(e.target.value)} - className="w-full px-4 py-2.5 bg-white/[0.04] border border-white/10 rounded-lg text-text-main text-sm placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/40" + className={INPUT_CLASS} /> setProviderUrl(e.target.value)} - className="w-full px-4 py-2.5 bg-white/[0.04] border border-white/10 rounded-lg text-text-main text-sm placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/40" + className={INPUT_CLASS} />
)} @@ -481,63 +455,23 @@ export default function OnboardingWizard() {
)} - {/* Test */} + {/* Test: validate the credential, choose a model, send a test request */} {currentStep.id === "test" && ( -
-

{t("testDesc")}

- {testStatus === "idle" && ( - - )} - {testStatus === "testing" && ( -
- - progress_activity - - {testMessage} -
- )} - {testStatus === "success" && ( -
- check_circle - {testMessage} -
- )} - {testStatus === "error" && ( -
-
- warning - {testMessage} -
- -
- )} -
+ goTo(STEP_IDS.indexOf("provider"))} + /> )} - {/* Done */} + {/* Done: client configuration + first request */} {currentStep.id === "done" && ( -
-

{t("doneDesc")}

-
-

{t("yourEndpoint")}

- {apiEndpoint} -
-
+ )}
{/* Footer Actions */} -
+
{step > 0 && !isLastStep && (
-
+
{!isLastStep && step > 0 && ( )} {currentStep.id === "tiers" && ( - )} {currentStep.id === "security" && ( )} {currentStep.id === "provider" && !skipSecurity ? ( ) : null} {currentStep.id === "test" && ( - )} {isLastStep && ( diff --git a/src/app/(dashboard)/dashboard/onboarding/steps/FirstUseDone.tsx b/src/app/(dashboard)/dashboard/onboarding/steps/FirstUseDone.tsx new file mode 100644 index 00000000000..a27c6b24995 --- /dev/null +++ b/src/app/(dashboard)/dashboard/onboarding/steps/FirstUseDone.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { FIRST_USE_DOCS } from "@/shared/utils/actionableError"; +import { loadRecentRequest, type RecentRequest } from "./firstUseApi"; + +/** Placeholder the operator replaces with a key created in API Manager (never a real key). */ +const API_KEY_PLACEHOLDER = "sk-your-omniroute-key"; +const MODEL_PLACEHOLDER = ""; + +type CopyState = "idle" | "copied" | "failed"; +type RequestState = + { status: "loading" } | { status: "failed" } | { status: "ready"; request: RecentRequest | null }; + +function ConfigRow({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +/** Step 8: the settings any OpenAI-compatible client needs, with a copy button. */ +function ClientConfig({ apiEndpoint, modelId }: { apiEndpoint: string; modelId: string | null }) { + const t = useTranslations("onboarding"); + const [copyState, setCopyState] = useState("idle"); + const model = modelId || MODEL_PLACEHOLDER; + const text = `Base URL: ${apiEndpoint}\nAPI key: ${API_KEY_PLACEHOLDER}\nModel: ${model}\n`; + + const copy = async () => { + try { + await navigator.clipboard.writeText(text); + setCopyState("copied"); + } catch { + setCopyState("failed"); + } + }; + + return ( +
+
+

{t("clientConfigTitle")}

+

{t("clientConfigDesc")}

+
+
+ + + +
+

{t("apiKeyHint")}

+
+ + + {t("openApiManager")} + + + {t("openEndpoints")} + +
+ {copyState === "failed" && ( +

+ {t("copyFailed")} +

+ )} +
+ ); +} + +function toRequestState(result: Awaited>): RequestState { + return result.kind === "ok" ? { status: "ready", request: result.request } : { status: "failed" }; +} + +function RequestSummary({ state }: { state: RequestState }) { + const t = useTranslations("onboarding"); + if (state.status === "loading") return {t("firstRequestLoading")}; + if (state.status === "failed") return {t("firstRequestFailedLoad")}; + if (!state.request) return {t("firstRequestNone")}; + return ( + + {t("firstRequestFound", { + model: state.request.model, + status: state.request.status ?? "—", + })} + + ); +} + +/** Step 9: the newest request recorded in the call logs, with a link to the full logs. */ +function FirstRequest() { + const t = useTranslations("onboarding"); + const [state, setState] = useState({ status: "loading" }); + + const refresh = useCallback(async () => { + setState(toRequestState(await loadRecentRequest())); + }, []); + + useEffect(() => { + let active = true; + void loadRecentRequest().then((result) => { + if (active) setState(toRequestState(result)); + }); + return () => { + active = false; + }; + }, []); + + return ( +
+

{t("firstRequestTitle")}

+

+ +

+
+ + + {t("openLogs")} + +
+
+ ); +} + +/** Done step: copy the client configuration, see the first request, read the guide. */ +export function FirstUseDone({ + apiEndpoint, + modelId, +}: { + apiEndpoint: string; + modelId: string | null; +}) { + const t = useTranslations("onboarding"); + return ( +
+

{t("doneDesc")}

+ + + + {t("firstStepsGuide")} + +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/onboarding/steps/FreeProviderOnboardingCard.tsx b/src/app/(dashboard)/dashboard/onboarding/steps/FreeProviderOnboardingCard.tsx index af4c4c99827..1320236cb9c 100644 --- a/src/app/(dashboard)/dashboard/onboarding/steps/FreeProviderOnboardingCard.tsx +++ b/src/app/(dashboard)/dashboard/onboarding/steps/FreeProviderOnboardingCard.tsx @@ -15,13 +15,22 @@ interface SetupResult { providerId: string; status: "created" | "skipped" | "failed"; reason?: string; + connectionId?: string; } async function readJson(response: Response): Promise> { return (await response.json().catch(() => ({}))) as Record; } -export function FreeProviderOnboardingCard() { +/** + * @param onConnectionsCreated receives the ids of the connections this card created, so the + * wizard's test step validates one of them instead of an arbitrary existing connection. + */ +export function FreeProviderOnboardingCard({ + onConnectionsCreated, +}: { + onConnectionsCreated?: (connectionIds: string[]) => void; +} = {}) { const t = useTranslations("onboarding.freeProviders"); const [providers, setProviders] = useState([]); const [selectedIds, setSelectedIds] = useState([]); @@ -80,7 +89,14 @@ export function FreeProviderOnboardingCard() { }); const data = await readJson(response); if (!response.ok) throw new Error("setup-failed"); - setResults(Array.isArray(data.results) ? (data.results as SetupResult[]) : []); + const nextResults = Array.isArray(data.results) ? (data.results as SetupResult[]) : []; + setResults(nextResults); + const createdIds = nextResults.flatMap((result) => + result.status === "created" && typeof result.connectionId === "string" + ? [result.connectionId] + : [] + ); + if (createdIds.length > 0) onConnectionsCreated?.(createdIds); } catch { setError(t("setupFailed")); } finally { diff --git a/src/app/(dashboard)/dashboard/onboarding/steps/ValidateAndTryStep.tsx b/src/app/(dashboard)/dashboard/onboarding/steps/ValidateAndTryStep.tsx new file mode 100644 index 00000000000..5c3f81e2709 --- /dev/null +++ b/src/app/(dashboard)/dashboard/onboarding/steps/ValidateAndTryStep.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { ActionableErrorCallout } from "../components/ActionableErrorCallout"; +import type { useCredentialCheck } from "./useCredentialCheck"; +import type { useModelTrial } from "./useModelTrial"; + +type CredentialCheck = ReturnType; +type ModelTrial = ReturnType; + +const INPUT_CLASS = + "w-full min-w-0 px-3 py-2 bg-white/[0.04] border border-white/10 rounded-lg text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/40"; + +function Spinner({ label }: { label: string }) { + return ( +
+ + {label} +
+ ); +} + +function CredentialPanel({ + credential, + onBackToProvider, +}: { + credential: CredentialCheck; + onBackToProvider: () => void; +}) { + const t = useTranslations("onboarding"); + const { status, message, failure, connections, connection } = credential; + return ( +
+ {connections.length > 1 && status !== "testing" && ( + + )} + {status === "idle" && ( + + )} + {status === "testing" && } + {status === "success" && ( +
+ + {message} +
+ )} + {status === "error" && failure && ( + void credential.run()} + secondaryAction={ + failure.guide.kind === "credential" || failure.guide.kind === "noConnection" + ? { label: t("backToProvider"), onClick: onBackToProvider } + : undefined + } + /> + )} +
+ ); +} + +function ModelPicker({ trial }: { trial: ModelTrial }) { + const t = useTranslations("onboarding"); + if (trial.models.length > 0) { + return ( + + ); + } + return ( + trial.setModel(event.target.value)} + className={INPUT_CLASS} + /> + ); +} + +function TrialResult({ trial }: { trial: ModelTrial }) { + const t = useTranslations("onboarding"); + if (trial.trialState === "running") return ; + if (trial.trialState === "ok") { + return ( +

+ + {t("testRequestOk", { ms: trial.latencyMs ?? 0 })} +

+ ); + } + if (trial.trialState === "error" && trial.trialFailure) { + return ( + void trial.runTrial()} + /> + ); + } + return null; +} + +function ModelTrialPanel({ trial }: { trial: ModelTrial }) { + const t = useTranslations("onboarding"); + if (trial.listState === "idle") return null; + if (trial.listState === "loading") return ; + return ( +
+
+

{t("chooseModelTitle")}

+

{t("chooseModelDesc")}

+
+ {trial.listFailure && trial.connection && ( + { + if (trial.connection) void trial.loadModels(trial.connection); + }} + /> + )} + + + +
+ ); +} + +/** Steps 5–7: validate the credential, choose an available model, send a test request. */ +export function ValidateAndTryStep({ + credential, + trial, + onBackToProvider, +}: { + credential: CredentialCheck; + trial: ModelTrial; + onBackToProvider: () => void; +}) { + const t = useTranslations("onboarding"); + return ( +
+

{t("testDesc")}

+ + {credential.status === "success" && } +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/onboarding/steps/firstUseApi.ts b/src/app/(dashboard)/dashboard/onboarding/steps/firstUseApi.ts new file mode 100644 index 00000000000..48b49086171 --- /dev/null +++ b/src/app/(dashboard)/dashboard/onboarding/steps/firstUseApi.ts @@ -0,0 +1,180 @@ +/** + * Management-API calls of the first-use flow (validate credential → choose model → test + * request → first request in the logs). Every call reads the existing HTTP contracts; none + * of them is new. Failures are returned as values (never thrown) so the wizard can render + * an actionable error for each one. + */ + +export interface ConnectionOption { + id: string; + provider: string; + name: string; +} + +type ModelListResult = + | { kind: "ok"; models: string[] } + | { kind: "error"; httpStatus: number | null; message: string | null }; + +export interface ModelTestOutcome { + ok: boolean; + httpStatus: number; + latencyMs: number | null; + error: string | null; + statusCode: number | null; + rateLimited: boolean; +} + +export interface RecentRequest { + model: string; + status: number | null; + timestamp: string | null; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function readString(record: Record | null, key: string): string | null { + const value = record?.[key]; + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function readNumber(record: Record | null, key: string): number | null { + const value = record?.[key]; + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function toStatus(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value !== "string" || !value.trim()) return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +async function readJson(res: Response): Promise { + return res.json().catch(() => null); +} + +/** `GET /api/providers` → `{ connections: [...] }`, reduced to what the wizard needs. */ +export function toConnectionOptions(body: unknown): ConnectionOption[] { + const list = asRecord(body)?.connections; + if (!Array.isArray(list)) return []; + return list.flatMap((item) => { + const record = asRecord(item); + const id = readString(record, "id"); + if (!id) return []; + const provider = readString(record, "provider") ?? ""; + return [{ id, provider, name: readString(record, "name") ?? (provider || id) }]; + }); +} + +/** Prefer the connection the user just created; otherwise the first one listed. */ +export function pickConnection( + connections: ConnectionOption[], + preferredId: string | null +): ConnectionOption | null { + if (preferredId) { + const preferred = connections.find((connection) => connection.id === preferredId); + if (preferred) return preferred; + } + return connections[0] ?? null; +} + +/** Model ids from `GET /api/providers/{id}/models` (`{ models: [{ id }] }`), de-duplicated. */ +function toModelIds(body: unknown): string[] { + const list = asRecord(body)?.models; + if (!Array.isArray(list)) return []; + const ids = list.flatMap((item) => { + const id = typeof item === "string" ? item.trim() : readString(asRecord(item), "id"); + return id ? [id] : []; + }); + return Array.from(new Set(ids)); +} + +/** The model string a client sends: `provider/model` unless the id already carries a prefix. */ +export function toClientModelId(provider: string, modelId: string): string { + const trimmed = modelId.trim(); + if (!trimmed || trimmed.includes("/") || !provider) return trimmed; + return `${provider}/${trimmed}`; +} + +export async function loadConnectionModels(connectionId: string): Promise { + try { + const res = await fetch( + `/api/providers/${encodeURIComponent(connectionId)}/models?chatOnly=true&excludeHidden=true` + ); + const body = await readJson(res); + if (!res.ok) { + return { + kind: "error", + httpStatus: res.status, + message: readString(asRecord(body), "error"), + }; + } + return { kind: "ok", models: toModelIds(body) }; + } catch { + return { kind: "error", httpStatus: null, message: null }; + } +} + +function toModelTestOutcome(httpStatus: number, body: unknown): ModelTestOutcome { + const record = asRecord(body); + const error = record?.error; + return { + ok: httpStatus >= 200 && httpStatus < 300 && record?.status === "ok", + httpStatus, + latencyMs: readNumber(record, "latencyMs"), + error: typeof error === "string" && error.trim() ? error.trim() : null, + statusCode: readNumber(record, "statusCode"), + rateLimited: record?.rateLimited === true, + }; +} + +/** `POST /api/models/test` — a short request routed through the real chat pipeline. */ +export async function runModelTest( + connection: ConnectionOption, + modelId: string +): Promise { + try { + const res = await fetch("/api/models/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + providerId: connection.provider, + modelId, + connectionId: connection.id, + }), + }); + return toModelTestOutcome(res.status, await readJson(res)); + } catch { + return null; + } +} + +/** Newest row of `GET /api/usage/call-logs` (an array, newest first). No request/response bodies. */ +function toRecentRequest(body: unknown): RecentRequest | null { + const rows = Array.isArray(body) ? body : asRecord(body)?.logs; + if (!Array.isArray(rows) || rows.length === 0) return null; + const row = asRecord(rows[0]); + const model = readString(row, "model"); + if (!model) return null; + return { + model, + status: toStatus(row?.status), + timestamp: readString(row, "timestamp"), + }; +} + +export async function loadRecentRequest(): Promise< + { kind: "ok"; request: RecentRequest | null } | { kind: "error" } +> { + try { + const res = await fetch("/api/usage/call-logs?limit=5"); + if (!res.ok) return { kind: "error" }; + return { kind: "ok", request: toRecentRequest(await readJson(res)) }; + } catch { + return { kind: "error" }; + } +} diff --git a/src/app/(dashboard)/dashboard/onboarding/steps/useCredentialCheck.ts b/src/app/(dashboard)/dashboard/onboarding/steps/useCredentialCheck.ts new file mode 100644 index 00000000000..cb2d8168557 --- /dev/null +++ b/src/app/(dashboard)/dashboard/onboarding/steps/useCredentialCheck.ts @@ -0,0 +1,166 @@ +"use client"; + +import { useState } from "react"; +import { presentConnectionTestFailure } from "@/shared/utils/apiErrorPresentation"; +import { + actionableGuide, + guideForConnectionTest, + guideForHttpStatus, + type ActionableErrorGuide, +} from "@/shared/utils/actionableError"; +import { pickConnection, toConnectionOptions, type ConnectionOption } from "./firstUseApi"; + +/** U8: upper bound for the onboarding connection test (list + probe). */ +const PROVIDER_TEST_TIMEOUT_MS = 15_000; + +export type Translate = (key: string, values?: Record) => string; +export type TranslateWithHas = Translate & { has?: (key: string) => boolean }; + +export interface StepFailure { + message: string; + guide: ActionableErrorGuide; +} + +type CheckStatus = "idle" | "testing" | "success" | "error"; + +interface ConnectionTestBody { + valid?: boolean; + error?: string | null; + warning?: string | null; + diagnosis?: { + type?: string | null; + code?: string | null; + message?: string | null; + params?: { host?: string | null; timeoutMs?: number | null } | null; + } | null; +} + +type ProbeOutcome = + | { kind: "listFailed"; status: number } + | { kind: "noConnection" } + | { + kind: "tested"; + connection: ConnectionOption; + connections: ConnectionOption[]; + ok: boolean; + status: number; + body: ConnectionTestBody | null; + }; + +/** List the connections, pick the one to test, run `POST /api/providers/{id}/test`. */ +async function probeConnection( + signal: AbortSignal, + preferredId: string | null +): Promise { + const res = await fetch("/api/providers", { signal }); + if (!res.ok) return { kind: "listFailed", status: res.status }; + const connections = toConnectionOptions(await res.json().catch(() => null)); + const connection = pickConnection(connections, preferredId); + if (!connection) return { kind: "noConnection" }; + const testRes = await fetch(`/api/providers/${encodeURIComponent(connection.id)}/test`, { + method: "POST", + signal, + }); + const body = (await testRes.json().catch(() => null)) as ConnectionTestBody | null; + return { kind: "tested", connection, connections, ok: testRes.ok, status: testRes.status, body }; +} + +function describeTestedFailure( + outcome: Extract, + t: Translate, + tc: TranslateWithHas +): StepFailure { + if (!outcome.ok) { + const error = outcome.body?.error; + return { + message: typeof error === "string" && error.trim() ? error : t("testFailed"), + guide: guideForHttpStatus(outcome.status), + }; + } + // The route answers 200 for a probe that RAN; reachability is `valid` in the body (C-03). + const presented = presentConnectionTestFailure(outcome.body, { + translate: (key, values) => + typeof tc.has !== "function" || tc.has(key) ? tc(key, values) : null, + fallback: t("testFailed"), + }); + return { message: presented.message, guide: guideForConnectionTest(outcome.body) }; +} + +function describeOutcome( + outcome: ProbeOutcome, + t: Translate, + tc: TranslateWithHas +): StepFailure | null { + if (outcome.kind === "listFailed") { + return { message: t("couldNotTest"), guide: guideForHttpStatus(outcome.status) }; + } + if (outcome.kind === "noConnection") { + return { message: t("noProviderFound"), guide: actionableGuide("noConnection") }; + } + if (outcome.ok && outcome.body?.valid !== false) return null; + return describeTestedFailure(outcome, t, tc); +} + +/** + * Step 5 of the first-use flow: validate the credential of the connection the user just + * added (not simply the first connection of the instance), with a 15 s bound and retry. + */ +export function useCredentialCheck( + preferredConnectionId: string | null, + t: Translate, + tc: TranslateWithHas, + onValidated: (connection: ConnectionOption) => void +) { + const [status, setStatus] = useState("idle"); + const [message, setMessage] = useState(""); + const [failure, setFailure] = useState(null); + const [connections, setConnections] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [connection, setConnection] = useState(null); + + const fail = (next: StepFailure) => { + setStatus("error"); + setMessage(next.message); + setFailure(next); + }; + + const run = async (overrideId?: string) => { + setStatus("testing"); + setMessage(t("testingConnection")); + setFailure(null); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), PROVIDER_TEST_TIMEOUT_MS); + try { + const outcome = await probeConnection( + controller.signal, + overrideId ?? selectedId ?? preferredConnectionId + ); + if (outcome.kind === "tested") { + setConnections(outcome.connections); + setConnection(outcome.connection); + } + const described = describeOutcome(outcome, t, tc); + if (described) return fail(described); + setStatus("success"); + setMessage(t("connectionSuccessful")); + if (outcome.kind === "tested") onValidated(outcome.connection); + } catch (err) { + const aborted = + controller.signal.aborted || (err as { name?: string })?.name === "AbortError"; + fail( + aborted + ? { message: t("testTimedOut"), guide: actionableGuide("timeout") } + : { message: t("couldNotTest"), guide: actionableGuide("network") } + ); + } finally { + clearTimeout(timeout); + } + }; + + const selectConnection = (id: string) => { + setSelectedId(id); + void run(id); + }; + + return { status, message, failure, connections, connection, run, selectConnection }; +} diff --git a/src/app/(dashboard)/dashboard/onboarding/steps/useModelTrial.ts b/src/app/(dashboard)/dashboard/onboarding/steps/useModelTrial.ts new file mode 100644 index 00000000000..01a245a117c --- /dev/null +++ b/src/app/(dashboard)/dashboard/onboarding/steps/useModelTrial.ts @@ -0,0 +1,102 @@ +"use client"; + +import { useState } from "react"; +import { + actionableGuide, + guideForHttpStatus, + guideForModelTest, +} from "@/shared/utils/actionableError"; +import { + loadConnectionModels, + runModelTest, + toClientModelId, + type ConnectionOption, +} from "./firstUseApi"; +import type { StepFailure, Translate } from "./useCredentialCheck"; + +type ListState = "idle" | "loading" | "ready" | "error"; +type TrialState = "idle" | "running" | "ok" | "error"; + +/** + * Steps 6 and 7 of the first-use flow: list the models the provider exposes for the + * validated credential, let the user pick one (or type an id when the provider lists none) + * and send one short request through the real chat pipeline (`POST /api/models/test`), so + * the first request also shows up in the request logs. + */ +export function useModelTrial(t: Translate) { + const [connection, setConnection] = useState(null); + const [models, setModels] = useState([]); + const [listState, setListState] = useState("idle"); + const [listFailure, setListFailure] = useState(null); + const [model, setModel] = useState(""); + const [trialState, setTrialState] = useState("idle"); + const [trialFailure, setTrialFailure] = useState(null); + const [latencyMs, setLatencyMs] = useState(null); + + const loadModels = async (target: ConnectionOption) => { + setConnection(target); + setListState("loading"); + setListFailure(null); + setTrialState("idle"); + const result = await loadConnectionModels(target.id); + if (result.kind === "error") { + setModels([]); + setListState("error"); + setListFailure({ + message: result.message || t("failedLoadModels"), + guide: + result.httpStatus === null + ? actionableGuide("network") + : guideForHttpStatus(result.httpStatus), + }); + return; + } + setModels(result.models); + setModel(result.models[0] ?? ""); + setListState("ready"); + if (result.models.length === 0) { + setListFailure({ message: t("noModelsListed"), guide: actionableGuide("noModels") }); + } + }; + + const runTrial = async () => { + const modelId = model.trim(); + if (!connection || !modelId) return; + setTrialState("running"); + setTrialFailure(null); + const outcome = await runModelTest(connection, modelId); + if (!outcome) { + setTrialState("error"); + setTrialFailure({ message: t("couldNotTest"), guide: actionableGuide("network") }); + return; + } + setLatencyMs(outcome.latencyMs); + if (outcome.ok) { + setTrialState("ok"); + return; + } + setTrialState("error"); + setTrialFailure({ + message: outcome.error || t("testRequestFailed"), + guide: guideForModelTest(outcome.httpStatus, outcome), + }); + }; + + const clientModelId = + connection && model.trim() ? toClientModelId(connection.provider, model) : null; + + return { + connection, + models, + listState, + listFailure, + model, + setModel, + trialState, + trialFailure, + latencyMs, + clientModelId, + loadModels, + runTrial, + }; +} diff --git a/src/shared/utils/actionableError.ts b/src/shared/utils/actionableError.ts new file mode 100644 index 00000000000..999327caa53 --- /dev/null +++ b/src/shared/utils/actionableError.ts @@ -0,0 +1,158 @@ +/** + * Actionable error guidance for the first-use flow (onboarding wizard). + * + * `presentApiError` / `presentConnectionTestFailure` (apiErrorPresentation.ts) answer + * "what happened" with one readable headline. A first-time user also needs to know why it + * happened, how to fix it, whether trying again can help and where the docs are. This + * module classifies a failure into a small set of kinds; the UI renders the why/fix text + * for the kind from the `onboarding.errorGuide.` messages. It never changes the + * HTTP error envelopes — it only reads them. + */ + +type ActionableErrorKind = + | "network" + | "timeout" + | "unreachable" + | "tls" + | "credential" + | "rateLimited" + | "providerDown" + | "invalidInput" + | "sessionExpired" + | "server" + | "noConnection" + | "noModels" + | "paidModelBlocked" + | "unknown"; + +export interface ActionableErrorGuide { + kind: ActionableErrorKind; + /** True when trying the same action again, unchanged, can succeed. */ + retryable: boolean; + /** In-app docs route (served by src/app/docs/[...slug]). */ + docsHref: string; +} + +/** Docs routes used by the guidance. Fumadocs serves lowercased slugs of docs/
/.md. */ +export const FIRST_USE_DOCS = { + firstSteps: "/docs/getting-started/first_10_minutes", + providers: "/docs/getting-started/providers-guide", + troubleshooting: "/docs/guides/troubleshooting", +} as const; + +const KIND_DEFAULTS: Readonly>> = { + network: { retryable: true, docsHref: FIRST_USE_DOCS.troubleshooting }, + timeout: { retryable: true, docsHref: FIRST_USE_DOCS.troubleshooting }, + unreachable: { retryable: true, docsHref: FIRST_USE_DOCS.troubleshooting }, + tls: { retryable: false, docsHref: FIRST_USE_DOCS.troubleshooting }, + credential: { retryable: false, docsHref: FIRST_USE_DOCS.providers }, + rateLimited: { retryable: true, docsHref: FIRST_USE_DOCS.providers }, + providerDown: { retryable: true, docsHref: FIRST_USE_DOCS.troubleshooting }, + invalidInput: { retryable: false, docsHref: FIRST_USE_DOCS.firstSteps }, + sessionExpired: { retryable: false, docsHref: FIRST_USE_DOCS.firstSteps }, + server: { retryable: true, docsHref: FIRST_USE_DOCS.troubleshooting }, + noConnection: { retryable: false, docsHref: FIRST_USE_DOCS.firstSteps }, + noModels: { retryable: true, docsHref: FIRST_USE_DOCS.providers }, + paidModelBlocked: { retryable: false, docsHref: FIRST_USE_DOCS.firstSteps }, + unknown: { retryable: true, docsHref: FIRST_USE_DOCS.troubleshooting }, +}; + +export function actionableGuide(kind: ActionableErrorKind): ActionableErrorGuide { + return { kind, ...KIND_DEFAULTS[kind] }; +} + +/** Guidance for a failed management call (`/api/*`) from its HTTP status. */ +export function guideForHttpStatus(status: number | null | undefined): ActionableErrorGuide { + if (status === 401 || status === 403) return actionableGuide("sessionExpired"); + if (status === 408) return actionableGuide("timeout"); + if (status === 429) return actionableGuide("rateLimited"); + if (typeof status === "number" && status >= 400 && status < 500) { + return actionableGuide("invalidInput"); + } + if (typeof status === "number" && status >= 500) return actionableGuide("server"); + return actionableGuide("unknown"); +} + +const TRANSPORT_CODE_KINDS: Readonly> = { + UPSTREAM_TIMEOUT: "unreachable", + UPSTREAM_UNREACHABLE: "unreachable", + UPSTREAM_TLS: "tls", +}; + +/** `diagnosis.type` values of `/api/providers/{id}/test` (publicErrorBoundary.ts). */ +const DIAGNOSIS_TYPE_KINDS: Readonly> = { + upstream_auth_error: "credential", + upstream_ambiguous_auth_or_quota: "credential", + token_expired: "credential", + token_refresh_failed: "credential", + account_deactivated: "credential", + upstream_rate_limited: "rateLimited", + upstream_unavailable: "providerDown", + network_error: "unreachable", + unsupported: "invalidInput", +}; + +interface ConnectionTestDiagnosisLike { + diagnosis?: { type?: string | null; code?: string | null } | null; +} + +/** Guidance for a `valid: false` connection-test verdict. */ +export function guideForConnectionTest( + result: ConnectionTestDiagnosisLike | null | undefined +): ActionableErrorGuide { + const code = result?.diagnosis?.code; + if (typeof code === "string" && TRANSPORT_CODE_KINDS[code]) { + return actionableGuide(TRANSPORT_CODE_KINDS[code]); + } + const type = result?.diagnosis?.type; + if (typeof type === "string" && DIAGNOSIS_TYPE_KINDS[type]) { + return actionableGuide(DIAGNOSIS_TYPE_KINDS[type]); + } + return actionableGuide("unknown"); +} + +interface ModelTestFailureLike { + statusCode?: number | null; + rateLimited?: boolean | null; +} + +/** Kind decided by the upstream provider status carried in the body, when there is one. */ +function modelTestUpstreamKind( + body: ModelTestFailureLike | null | undefined +): ActionableErrorKind | null { + const upstream = body?.statusCode; + if (body?.rateLimited === true || upstream === 429) return "rateLimited"; + if (upstream === 401 || upstream === 403) return "credential"; + if (typeof upstream === "number" && upstream >= 500) return "providerDown"; + return null; +} + +/** Kind decided by the route's own HTTP status. */ +const MODEL_TEST_ROUTE_STATUS_KINDS: Readonly> = { + 400: "invalidInput", + 401: "sessionExpired", + 403: "paidModelBlocked", + 404: "invalidInput", + 408: "timeout", + 409: "invalidInput", + 429: "rateLimited", + 504: "timeout", +}; + +/** + * Guidance for a failed `POST /api/models/test`. `httpStatus` is the route's own status + * (403 = paid model blocked by hidePaidModels, 409 = managed lease connection); the body's + * `statusCode` is the upstream provider status when the request reached it. + */ +export function guideForModelTest( + httpStatus: number, + body: ModelTestFailureLike | null | undefined +): ActionableErrorGuide { + // 403 is route policy (hidePaidModels) — it never reached the provider. + if (httpStatus === 403) return actionableGuide("paidModelBlocked"); + const kind = + modelTestUpstreamKind(body) ?? + MODEL_TEST_ROUTE_STATUS_KINDS[httpStatus] ?? + (httpStatus >= 500 ? "providerDown" : "unknown"); + return actionableGuide(kind); +} diff --git a/tests/unit/actionable-error-guide.test.ts b/tests/unit/actionable-error-guide.test.ts new file mode 100644 index 00000000000..06be427cce6 --- /dev/null +++ b/tests/unit/actionable-error-guide.test.ts @@ -0,0 +1,123 @@ +/** + * Phase 6: every first-use failure maps to a guidance kind (why / how to fix / retry / docs). + * The mapping only reads the existing HTTP error envelopes; it never changes them. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +const guide = await import("../../src/shared/utils/actionableError.ts"); +const en = JSON.parse( + fs.readFileSync(path.join(process.cwd(), "src/i18n/messages/en.json"), "utf8") +) as { onboarding: { errorGuide: Record } }; +const ptBR = JSON.parse( + fs.readFileSync(path.join(process.cwd(), "src/i18n/messages/pt-BR.json"), "utf8") +) as { onboarding: { errorGuide: Record } }; + +test("management call statuses map to session / input / rate-limit / server guidance", () => { + assert.equal(guide.guideForHttpStatus(401).kind, "sessionExpired"); + assert.equal(guide.guideForHttpStatus(403).kind, "sessionExpired"); + assert.equal(guide.guideForHttpStatus(400).kind, "invalidInput"); + assert.equal(guide.guideForHttpStatus(409).kind, "invalidInput"); + assert.equal(guide.guideForHttpStatus(408).kind, "timeout"); + assert.equal(guide.guideForHttpStatus(429).kind, "rateLimited"); + assert.equal(guide.guideForHttpStatus(500).kind, "server"); + assert.equal(guide.guideForHttpStatus(503).kind, "server"); + assert.equal(guide.guideForHttpStatus(undefined).kind, "unknown"); + assert.equal(guide.guideForHttpStatus(302).kind, "unknown"); +}); + +test("connection-test verdicts: typed transport codes win over the diagnosis type", () => { + const timeout = guide.guideForConnectionTest({ + diagnosis: { type: "network_error", code: "UPSTREAM_TIMEOUT" }, + }); + assert.deepEqual(timeout, { + kind: "unreachable", + retryable: true, + docsHref: "/docs/guides/troubleshooting", + }); + assert.equal( + guide.guideForConnectionTest({ diagnosis: { type: "network_error", code: "UPSTREAM_TLS" } }) + .kind, + "tls" + ); + assert.equal( + guide.guideForConnectionTest({ diagnosis: { type: "upstream_auth_error", code: "401" } }).kind, + "credential" + ); + assert.equal( + guide.guideForConnectionTest({ diagnosis: { type: "upstream_rate_limited", code: "429" } }) + .kind, + "rateLimited" + ); + assert.equal( + guide.guideForConnectionTest({ diagnosis: { type: "upstream_unavailable", code: "503" } }).kind, + "providerDown" + ); + assert.equal( + guide.guideForConnectionTest({ diagnosis: { type: "something_new" } }).kind, + "unknown" + ); + assert.equal(guide.guideForConnectionTest(null).kind, "unknown"); +}); + +test("a rejected credential is not retryable as-is and points to the providers guide", () => { + const credential = guide.guideForConnectionTest({ + diagnosis: { type: "upstream_ambiguous_auth_or_quota" }, + }); + assert.equal(credential.retryable, false); + assert.equal(credential.docsHref, "/docs/getting-started/providers-guide"); +}); + +test("model-test failures: route policy, upstream status and timeouts", () => { + assert.equal(guide.guideForModelTest(403, { statusCode: null }).kind, "paidModelBlocked"); + assert.equal(guide.guideForModelTest(429, null).kind, "rateLimited"); + assert.equal(guide.guideForModelTest(502, { rateLimited: true }).kind, "rateLimited"); + assert.equal(guide.guideForModelTest(401, { statusCode: 401 }).kind, "credential"); + assert.equal(guide.guideForModelTest(502, { statusCode: 403 }).kind, "credential"); + assert.equal(guide.guideForModelTest(502, { statusCode: 503 }).kind, "providerDown"); + assert.equal(guide.guideForModelTest(400, { statusCode: null }).kind, "invalidInput"); + assert.equal(guide.guideForModelTest(409, null).kind, "invalidInput"); + assert.equal(guide.guideForModelTest(401, null).kind, "sessionExpired"); + assert.equal(guide.guideForModelTest(504, null).kind, "timeout"); + assert.equal(guide.guideForModelTest(500, null).kind, "providerDown"); + assert.equal(guide.guideForModelTest(418, null).kind, "unknown"); +}); + +test("every guidance kind has why/fix copy in en and pt-BR, and a real docs route", () => { + const kinds = [ + "network", + "timeout", + "unreachable", + "tls", + "credential", + "rateLimited", + "providerDown", + "invalidInput", + "sessionExpired", + "server", + "noConnection", + "noModels", + "paidModelBlocked", + "unknown", + ] as const; + const docsFiles: Record = { + "/docs/getting-started/first_10_minutes": "docs/getting-started/FIRST_10_MINUTES.md", + "/docs/getting-started/providers-guide": "docs/getting-started/PROVIDERS-GUIDE.md", + "/docs/guides/troubleshooting": "docs/guides/TROUBLESHOOTING.md", + }; + for (const kind of kinds) { + const g = guide.actionableGuide(kind); + assert.equal(g.kind, kind); + for (const catalog of [en, ptBR]) { + const entry = catalog.onboarding.errorGuide[kind] as { why?: string; fix?: string }; + assert.ok(entry?.why?.trim(), `${kind}.why`); + assert.ok(entry?.fix?.trim(), `${kind}.fix`); + } + const file = docsFiles[g.docsHref]; + assert.ok(file, `docs route for ${kind}: ${g.docsHref}`); + assert.ok(fs.existsSync(path.join(process.cwd(), file)), file); + } + assert.equal(guide.FIRST_USE_DOCS.firstSteps, "/docs/getting-started/first_10_minutes"); +}); diff --git a/tests/unit/ui/onboarding-first-use-flow.test.tsx b/tests/unit/ui/onboarding-first-use-flow.test.tsx new file mode 100644 index 00000000000..e074a95125c --- /dev/null +++ b/tests/unit/ui/onboarding-first-use-flow.test.tsx @@ -0,0 +1,361 @@ +// @vitest-environment jsdom +/** + * Phase 6 first-use flow in the onboarding wizard: the test step validates the connection + * the user JUST added (it used to test `connections[0]`), lists the provider's models, sends + * a test request through /api/models/test, and the done step shows the client configuration + * and the first request from the call logs. Every failure renders what/why/fix/retry/docs. + */ +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +const { pushMock, replaceMock } = vi.hoisted(() => ({ + pushMock: vi.fn(), + replaceMock: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: pushMock, replace: replaceMock }), +})); +vi.mock("next/link", () => ({ + default: ({ href, children, ...rest }: { href: string; children: React.ReactNode }) => ( + + {children} + + ), +})); +vi.mock("next-intl", () => ({ + useTranslations: () => { + const t = (key: string, values?: Record) => + values ? `${key}(${JSON.stringify(values)})` : key; + t.has = (key: string) => key.startsWith("apiErrors."); + return t; + }, +})); +vi.mock("@/shared/hooks", () => ({ + useDisplayBaseUrl: () => "https://gw.example.com", +})); +vi.mock( + "../../../src/app/(dashboard)/dashboard/onboarding/steps/FreeProviderOnboardingCard", + () => ({ FreeProviderOnboardingCard: () => null }) +); + +const { default: OnboardingWizard } = + await import("../../../src/app/(dashboard)/dashboard/onboarding/page"); + +type Handler = (init?: RequestInit) => { status: number; body: unknown }; +let handlers: Record; +let calls: Array<{ url: string; method: string; body: unknown }>; + +function reply(status: number, body: unknown) { + return () => ({ status, body }); +} + +let container: HTMLDivElement; +let root: ReturnType; + +async function tick(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +async function waitFor(predicate: () => boolean, label: string): Promise { + for (let attempt = 0; attempt < 40; attempt += 1) { + if (predicate()) return; + await tick(); + } + throw new Error(`Timed out waiting for: ${label}`); +} + +const waitForText = (text: string) => + waitFor(() => Boolean(container.textContent?.includes(text)), text); + +function button(label: string): HTMLButtonElement { + const found = Array.from(container.querySelectorAll("button")).find( + (candidate) => candidate.textContent?.trim() === label + ); + if (!found) throw new Error(`Button not found: ${label}`); + return found; +} + +async function click(label: string): Promise { + await act(async () => { + button(label).click(); + }); + await tick(); +} + +async function typeInto(selector: string, value: string): Promise { + const input = container.querySelector(selector); + if (!input) throw new Error(`Input not found: ${selector}`); + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + await act(async () => { + setter.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + +function errorKinds(): string[] { + return Array.from( + container.querySelectorAll('[data-testid="actionable-error"]') + ).map((el) => el.dataset.errorKind ?? ""); +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + pushMock.mockReset(); + replaceMock.mockReset(); + calls = []; + handlers = { + "GET /api/settings": reply(200, { setupComplete: false }), + "GET /api/settings/require-login": reply(200, { hasPassword: false }), + "POST /api/settings/require-login": reply(200, { success: true }), + "POST /api/auth/login": reply(200, { success: true }), + "POST /api/providers": reply(201, { + connection: { id: "conn-new", provider: "openai", name: "OpenAI" }, + }), + // An OLDER connection is listed first: the wizard must still test the new one. + "GET /api/providers": reply(200, { + connections: [ + { id: "conn-old", provider: "groq", name: "Old Groq" }, + { id: "conn-new", provider: "openai", name: "OpenAI" }, + ], + }), + "POST /api/providers/conn-new/test": reply(200, { valid: true, error: null }), + "GET /api/providers/conn-new/models?chatOnly=true&excludeHidden=true": reply(200, { + models: [{ id: "gpt-4o-mini" }, { id: "gpt-4o" }], + }), + "POST /api/models/test": reply(200, { status: "ok", latencyMs: 321, responseText: "hi" }), + "GET /api/usage/call-logs?limit=5": reply(200, [ + { model: "openai/gpt-4o-mini", status: 200, timestamp: "2026-09-18T10:00:00Z" }, + ]), + }; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = (init?.method ?? "GET").toUpperCase(); + const body = typeof init?.body === "string" ? JSON.parse(init.body) : null; + calls.push({ url, method, body }); + const handler = handlers[`${method} ${url}`]; + if (!handler) throw new Error(`Unexpected request: ${method} ${url}`); + const { status, body: payload } = handler(init); + return { + ok: status >= 200 && status < 300, + status, + json: async () => payload, + } as Response; + }) + ); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText: vi.fn(async () => undefined) }, + }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + window.history.replaceState(null, "", "/"); +}); + +async function reachProviderStep(): Promise { + await act(async () => { + root.render(); + }); + await waitForText("getStarted"); + await click("getStarted"); + await click("continue"); + await typeInto('input[aria-label="enterPassword"]', "hunter22!"); + await typeInto('input[aria-label="confirmPasswordPlaceholder"]', "hunter22!"); + await click("setPassword"); + await waitForText("providerDesc"); +} + +async function addProviderAndOpenTest(): Promise { + await reachProviderStep(); + await click("OpenAI"); + await typeInto('input[aria-label="apiKeyRequired"]', "sk-test-not-a-real-key"); + await click("addProvider"); + await waitForText("runTest"); +} + +it("walks validate → choose model → test request → client config → first request", async () => { + await addProviderAndOpenTest(); + await click("runTest"); + await waitForText("connectionSuccessful"); + + expect(calls.some((c) => c.url === "/api/providers/conn-new/test")).toBe(true); + expect(calls.some((c) => c.url === "/api/providers/conn-old/test")).toBe(false); + + await waitForText("chooseModelTitle"); + const select = container.querySelector('select[aria-label="modelLabel"]'); + expect(Array.from(select?.options ?? []).map((o) => o.value)).toEqual(["gpt-4o-mini", "gpt-4o"]); + + await click("sendTestRequest"); + await waitForText("testRequestOk"); + const trial = calls.find((c) => c.url === "/api/models/test"); + expect(trial?.body).toEqual({ + providerId: "openai", + modelId: "gpt-4o-mini", + connectionId: "conn-new", + }); + expect(container.textContent).toContain('testRequestOk({"ms":321})'); + + await click("continue"); + await waitForText("clientConfigTitle"); + expect(container.textContent).toContain("https://gw.example.com/api/v1"); + expect(container.textContent).toContain("openai/gpt-4o-mini"); + expect(container.textContent).toContain("sk-your-omniroute-key"); + + await click("copyConfig"); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + "Base URL: https://gw.example.com/api/v1\nAPI key: sk-your-omniroute-key\nModel: openai/gpt-4o-mini\n" + ); + await waitForText("copied"); + + await waitFor( + () => Boolean(container.querySelector('[data-testid="first-request"]')), + "first request row" + ); + expect(container.querySelector('[data-testid="first-request"]')?.textContent).toContain( + '"model":"openai/gpt-4o-mini"' + ); + const hrefs = Array.from(container.querySelectorAll("a")).map((a) => a.getAttribute("href")); + expect(hrefs).toEqual( + expect.arrayContaining([ + "/dashboard/logs", + "/dashboard/api-manager", + "/docs/getting-started/first_10_minutes", + ]) + ); +}); + +it("a rejected credential explains why and how to fix, and leads back to the provider step", async () => { + handlers["POST /api/providers/conn-new/test"] = reply(200, { + valid: false, + error: "Invalid API key", + diagnosis: { type: "upstream_auth_error", code: "401", message: "Invalid API key" }, + }); + await addProviderAndOpenTest(); + await click("runTest"); + await waitForText("Invalid API key"); + + expect(errorKinds()).toEqual(["credential"]); + expect(container.textContent).toContain("errorGuide.credential.why"); + expect(container.textContent).toContain("errorGuide.credential.fix"); + expect(container.textContent).toContain("errorGuide.retryAfterFix"); + const docs = container.querySelector('[data-testid="actionable-error"] a'); + expect(docs?.getAttribute("href")).toBe("/docs/getting-started/providers-guide"); + expect(container.textContent).not.toContain("chooseModelTitle"); + + await click("backToProvider"); + await waitForText("providerDesc"); +}); + +it("a transport failure is retryable and the retry re-runs the probe", async () => { + let attempts = 0; + handlers["POST /api/providers/conn-new/test"] = () => { + attempts += 1; + return attempts === 1 + ? { + status: 200, + body: { + valid: false, + error: "Could not connect to api.openai.com", + diagnosis: { type: "network_error", code: "UPSTREAM_UNREACHABLE" }, + }, + } + : { status: 200, body: { valid: true } }; + }; + await addProviderAndOpenTest(); + await click("runTest"); + await waitFor(() => errorKinds().includes("unreachable"), "unreachable guidance"); + expect(container.textContent).toContain("errorGuide.retryPossible"); + + await click("retry"); + await waitForText("connectionSuccessful"); + expect(attempts).toBe(2); +}); + +it("a blocked paid model and an empty model list get their own guidance", async () => { + handlers["GET /api/providers/conn-new/models?chatOnly=true&excludeHidden=true"] = reply(200, { + models: [], + }); + handlers["POST /api/models/test"] = reply(403, { + status: "error", + error: "Paid model blocked while hidePaidModels is enabled", + }); + await addProviderAndOpenTest(); + await click("runTest"); + await waitFor(() => errorKinds().includes("noModels"), "noModels guidance"); + + // No listed model: the user can still type an id. + await typeInto('input[aria-label="modelLabel"]', "gpt-4o"); + await click("sendTestRequest"); + await waitFor(() => errorKinds().includes("paidModelBlocked"), "paid model guidance"); + expect(container.textContent).toContain("Paid model blocked while hidePaidModels is enabled"); +}); + +it("an existing password (INITIAL_PASSWORD) is kept without the skip-and-disable-login option", async () => { + handlers["GET /api/settings/require-login"] = reply(200, { hasPassword: true }); + await act(async () => { + root.render(); + }); + await waitForText("getStarted"); + await click("getStarted"); + await click("continue"); + await waitForText("passwordAlreadySet"); + + expect(container.textContent).not.toContain("skipPassword"); + await click("keepPassword"); + await waitForText("providerDesc"); + expect(calls.some((c) => c.url === "/api/settings/require-login" && c.method === "POST")).toBe( + false + ); +}); + +it("?rerun=1 opens the wizard even when setup is already complete", async () => { + handlers["GET /api/settings"] = reply(200, { setupComplete: true }); + window.history.replaceState(null, "", "/dashboard/onboarding?rerun=1"); + await act(async () => { + root.render(); + }); + await waitForText("getStarted"); + expect(replaceMock).not.toHaveBeenCalled(); +}); + +it("without ?rerun=1 a completed setup still redirects to the dashboard", async () => { + handlers["GET /api/settings"] = reply(200, { setupComplete: true }); + await act(async () => { + root.render(); + }); + await waitFor(() => replaceMock.mock.calls.length > 0, "redirect"); + expect(replaceMock).toHaveBeenCalledWith("/dashboard"); +}); + +it("a failed provider save shows guidance; a network failure says the server is unreachable", async () => { + handlers["POST /api/providers"] = reply(400, { error: "Invalid provider" }); + await reachProviderStep(); + await click("OpenAI"); + await typeInto('input[aria-label="apiKeyRequired"]', "sk-test-not-a-real-key"); + await click("addProvider"); + await waitForText("Invalid provider"); + expect(errorKinds()).toEqual(["invalidInput"]); + + handlers["POST /api/providers"] = () => { + throw new TypeError("Failed to fetch"); + }; + await click("addProvider"); + await waitFor(() => errorKinds().includes("network"), "network guidance"); + expect(container.textContent).toContain("connectionError"); +}); From d84189629d7572eda6b903c0be41100f3c729400 Mon Sep 17 00:00:00 2001 From: zodyp Date: Fri, 18 Sep 2026 14:40:49 -0300 Subject: [PATCH 4/6] docs(first-10-minutes): INITIAL_PASSWORD bootstrap is one-time; wizard re-run Documents that the INITIAL_PASSWORD first-read bootstrap now runs once, that /dashboard/onboarding?rerun=1 offers the guided setup after sign-in (keeping the existing password), and that PATCH /api/settings {"setupComplete": false} now takes effect. en + pt-BR. Co-Authored-By: Claude Opus 5 --- docs/getting-started/FIRST_10_MINUTES.md | 4 +++- docs/i18n/pt-BR/docs/getting-started/FIRST_10_MINUTES.md | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/getting-started/FIRST_10_MINUTES.md b/docs/getting-started/FIRST_10_MINUTES.md index e4bf79c19d3..7703beb081b 100644 --- a/docs/getting-started/FIRST_10_MINUTES.md +++ b/docs/getting-started/FIRST_10_MINUTES.md @@ -58,6 +58,8 @@ Open `http://localhost:20128`. What you see depends on `INITIAL_PASSWORD`: | not set | The onboarding wizard (`/dashboard/onboarding`) asks you to set a dashboard password (or explicitly continue without one for local-only use). | | set | **The onboarding wizard is skipped.** On first settings read OmniRoute stores `setupComplete=true` and `requireLogin=true` (`src/lib/db/settings.ts`), so you land on `/login` and sign in with that password. | +The bootstrap runs once. After signing in you can still run the guided setup at `/dashboard/onboarding?rerun=1`: it keeps the password you already have and walks through adding a provider, validating the credential, choosing a model, a test request, the client configuration and the first request in the logs. `PATCH /api/settings` with `{"setupComplete": false}` also brings the wizard back, and the setting is no longer re-forced on the next read. + If the password is the `.env.example` placeholder `CHANGEME`, change it right away in **Settings → Security** (`/dashboard/settings/security`). Forgot it? Run `omniroute-reset-password` (from source: `node bin/reset-password.mjs`). **Confirm:** you see the dashboard home while logged in. @@ -159,7 +161,7 @@ Model: ## Known caveats -- Setting `INITIAL_PASSWORD` skips onboarding entirely (see the table above). Use it for headless deploys, not for a first local try if you want the wizard. +- Setting `INITIAL_PASSWORD` skips the onboarding wizard on first boot (see the table above). Use it for headless deploys; to get the guided setup anyway, sign in and open `/dashboard/onboarding?rerun=1`. - Data location: `DATA_DIR` when set; otherwise an existing `~/.omniroute` is kept, then `%APPDATA%\omniroute` on Windows, `$XDG_CONFIG_HOME/omniroute` when `XDG_CONFIG_HOME` is set, else `~/.omniroute` (`src/lib/dataPaths.ts`). Back it up before upgrading: [BACKUP_RESTORE.md](../ops/BACKUP_RESTORE.md). - `:next` Docker images change under you. Upgrades, pinning and rollback: [MIGRATION_GUIDE.md](../guides/MIGRATION_GUIDE.md). - Binding to `127.0.0.1` (as in the Docker example) keeps the gateway local. Before exposing it, read [SECURITY.md](../../SECURITY.md) and [VM_DEPLOYMENT_GUIDE.md](../ops/VM_DEPLOYMENT_GUIDE.md). diff --git a/docs/i18n/pt-BR/docs/getting-started/FIRST_10_MINUTES.md b/docs/i18n/pt-BR/docs/getting-started/FIRST_10_MINUTES.md index e36869c91c3..cafc1ebdf02 100644 --- a/docs/i18n/pt-BR/docs/getting-started/FIRST_10_MINUTES.md +++ b/docs/i18n/pt-BR/docs/getting-started/FIRST_10_MINUTES.md @@ -58,6 +58,8 @@ Abra `http://localhost:20128`. O que aparece depende de `INITIAL_PASSWORD`: | não definida | O assistente inicial (`/dashboard/onboarding`) pede para você definir uma senha do painel (ou continuar explicitamente sem senha, para uso apenas local). | | definida | **O assistente inicial é pulado.** Na primeira leitura das configurações o OmniRoute grava `setupComplete=true` e `requireLogin=true` (`src/lib/db/settings.ts`), então você cai em `/login` e entra com essa senha. | +Essa inicialização acontece uma única vez. Depois de entrar, você ainda pode fazer a configuração guiada em `/dashboard/onboarding?rerun=1`: ela mantém a senha que você já tem e passa por adicionar um provedor, validar a credencial, escolher um modelo, uma requisição de teste, a configuração do cliente e a primeira requisição nos logs. `PATCH /api/settings` com `{"setupComplete": false}` também traz o assistente de volta, e o valor não é mais forçado de novo na leitura seguinte. + Se a senha for o valor de exemplo `CHANGEME` do `.env.example`, troque imediatamente em **Configurações → Segurança** (`/dashboard/settings/security`). Esqueceu a senha? Rode `omniroute-reset-password` (a partir do código-fonte: `node bin/reset-password.mjs`). **Confirme:** você vê a página inicial do painel, logado. @@ -159,7 +161,7 @@ Model: ## Ressalvas conhecidas -- Definir `INITIAL_PASSWORD` pula o assistente inicial por completo (veja a tabela acima). Use em implantações headless, não num primeiro teste local se você quiser o assistente. +- Definir `INITIAL_PASSWORD` pula o assistente inicial na primeira inicialização (veja a tabela acima). Use em implantações headless; para ter a configuração guiada mesmo assim, entre e abra `/dashboard/onboarding?rerun=1`. - Local dos dados: `DATA_DIR` quando definido; senão um `~/.omniroute` já existente é mantido; depois `%APPDATA%\omniroute` no Windows, `$XDG_CONFIG_HOME/omniroute` quando `XDG_CONFIG_HOME` está definido, e por fim `~/.omniroute` (`src/lib/dataPaths.ts`). Faça backup antes de atualizar: [Backup e restauração](../ops/BACKUP_RESTORE.md). - Imagens Docker `:next` mudam sem aviso. Atualização, fixação de versão e rollback: [Guia de atualização e migração](../guides/MIGRATION_GUIDE.md). - Publicar a porta em `127.0.0.1` (como no exemplo Docker) mantém o gateway local. Antes de expor, leia o [SECURITY.md](../../SECURITY.md) e o [Guia de implantação em VM](../ops/VM_DEPLOYMENT_GUIDE.md). From 96194098fe64e702fc837cab33388956716b542f Mon Sep 17 00:00:00 2001 From: zodyp Date: Fri, 18 Sep 2026 16:00:43 -0300 Subject: [PATCH 5/6] test(e2e): onboarding first-use path, INITIAL_PASSWORD re-run and layout widths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/e2e/onboarding-first-use.spec.ts drives the real app: - sign in (real /login with the INITIAL_PASSWORD the Playwright server sets), open /dashboard/onboarding?rerun=1, keep the existing password, add a provider, validate the NEW connection (an older one is listed first), pick a model, send a test request, copy the client configuration (clipboard read back), see the first request, docs link resolves (HTTP 200), finish; - a rejected credential renders what/why/fix/retry/docs and "Back to provider"; - C-06: PATCH /api/settings {setupComplete:false} is persisted and reopens the wizard; "Skip wizard entirely" restores setupComplete=true; - no horizontal overflow at 375/768/900/1024 px on welcome/test/model/done. Provider-side routes (create/test/models/models-test/call-logs) are intercepted with page.route — no real AI provider is contacted. Weight 268 (= line count) added to config/quality/e2e-timings.json. Local run (webpack dev server, OMNIROUTE_USE_TURBOPACK=0, isolated DATA_DIR, port 20955): 4 passed (11.1m). Co-Authored-By: Claude Opus 5 --- config/quality/e2e-timings.json | 1 + tests/e2e/onboarding-first-use.spec.ts | 268 +++++++++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 tests/e2e/onboarding-first-use.spec.ts diff --git a/config/quality/e2e-timings.json b/config/quality/e2e-timings.json index 673c0d2e3e1..d912a6096b4 100644 --- a/config/quality/e2e-timings.json +++ b/config/quality/e2e-timings.json @@ -21,6 +21,7 @@ "memory-qdrant-routes.spec.ts": 360, "memory-settings.spec.ts": 193, "navigation.spec.ts": 28, + "onboarding-first-use.spec.ts": 268, "playground-compare.spec.ts": 138, "playground-studio.spec.ts": 101, "protocol-visibility.spec.ts": 25, diff --git a/tests/e2e/onboarding-first-use.spec.ts b/tests/e2e/onboarding-first-use.spec.ts new file mode 100644 index 00000000000..cc0e78dc0b1 --- /dev/null +++ b/tests/e2e/onboarding-first-use.spec.ts @@ -0,0 +1,268 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; + +/** + * Phase 6 — first-use path through the onboarding wizard against the real app: + * start → dashboard → authenticate → add provider → validate credential → choose model → + * test request → copy client configuration → first request. + * + * Provider-side calls (create/test/list models/model test/call logs) are intercepted with + * page.route: no real AI provider is ever contacted and no connection is persisted. The + * settings/auth routes stay real, so the INITIAL_PASSWORD path that the Playwright server + * runs with (scripts/dev/run-next-playwright.mjs, bootstrap mode "auth") is exercised for + * real, including the one-time bootstrap fix (audit C-06). + */ + +const CONNECTION = { id: "e2e-first-use-conn", provider: "openai", name: "OpenAI (e2e)" }; +const OLDER_CONNECTION = { id: "e2e-older-conn", provider: "groq", name: "Older (e2e)" }; +const MODEL = "gpt-4o-mini"; +const E2E_PASSWORD = + process.env.OMNIROUTE_E2E_PASSWORD || process.env.INITIAL_PASSWORD || "omniroute-e2e-password"; + +type Recorded = { method: string; path: string; body: unknown }; + +async function fulfillJson(route: Route, body: unknown, status = 200) { + await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); +} + +function providerMocks(): Record { + return { + "POST /api/providers": { status: 201, body: { connection: CONNECTION } }, + // An older connection is listed first: the wizard must test the one just added. + "GET /api/providers": { status: 200, body: { connections: [OLDER_CONNECTION, CONNECTION] } }, + [`POST /api/providers/${CONNECTION.id}/test`]: { status: 200, body: { valid: true } }, + [`GET /api/providers/${CONNECTION.id}/models`]: { + status: 200, + body: { models: [{ id: MODEL }, { id: "gpt-4o" }] }, + }, + "POST /api/models/test": { status: 200, body: { status: "ok", latencyMs: 42 } }, + "GET /api/usage/call-logs": { + status: 200, + body: [ + { + model: `${CONNECTION.provider}/${MODEL}`, + status: 200, + timestamp: new Date().toISOString(), + }, + ], + }, + // Only reached when the server runs without a password: never change the shared password. + "POST /api/settings/require-login": { status: 200, body: { success: true } }, + "POST /api/auth/login": { status: 200, body: { success: true } }, + }; +} + +async function installProviderMocks(page: Page, recorded: Recorded[], overrides = {}) { + const mocks: Record = { ...providerMocks(), ...overrides }; + await page.route("**/api/**", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const key = `${request.method()} ${url.pathname}`; + const mock = mocks[key] as { status: number; body: unknown } | undefined; + if (!mock) { + await route.continue(); + return; + } + recorded.push({ method: request.method(), path: url.pathname, body: request.postDataJSON() }); + await fulfillJson(route, mock.body, mock.status); + }); +} + +/** Step 3: sign in through the real login page when the server requires it. */ +async function signIn(page: Page) { + const state = (await (await page.request.get("/api/settings/require-login")).json()) as { + authenticated?: boolean; + requireLogin?: boolean; + hasPassword?: boolean; + }; + if (state.authenticated || state.requireLogin === false || !state.hasPassword) return; + await page.goto("/login", { waitUntil: "domcontentloaded" }); + await page.locator('input[type="password"]').first().fill(E2E_PASSWORD); + await Promise.all([ + page.waitForResponse( + (r) => r.url().endsWith("/api/auth/login") && r.request().method() === "POST" + ), + page.locator("form").getByRole("button").first().click(), + ]); + await page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: 300_000 }); +} + +async function openWizard(page: Page, recorded: Recorded[], overrides = {}) { + // Authenticate first with the real auth routes (the Playwright server sets INITIAL_PASSWORD), + // then intercept the provider-side calls and re-open the wizard. + await signIn(page); + await installProviderMocks(page, recorded, overrides); + await page.goto("/dashboard/onboarding?rerun=1", { waitUntil: "domcontentloaded" }); + await expect(page.getByRole("button", { name: "Get Started" })).toBeVisible({ + timeout: 120_000, + }); +} + +async function passSecurityStep(page: Page) { + const keep = page.getByRole("button", { name: "Keep current password" }); + if (await keep.isVisible()) { + await expect(page.getByText(/A dashboard password is already set/)).toBeVisible(); + await expect(page.getByText("Skip password setup")).toHaveCount(0); + await keep.click(); + return; + } + await page.getByLabel("Enter password").fill("e2e-first-use-password"); + await page.getByLabel("Confirm password").fill("e2e-first-use-password"); + await page.getByRole("button", { name: "Set Password" }).click(); +} + +async function reachTestStep(page: Page) { + await page.getByRole("button", { name: "Get Started" }).click(); + await page.getByRole("button", { name: "Continue" }).click(); + await passSecurityStep(page); + await expect(page.getByText("Connect your first AI provider.", { exact: false })).toBeVisible(); + await page.getByRole("button", { name: "OpenAI", exact: true }).click(); + await page.getByLabel("API Key (required)").fill("sk-e2e-fake-key-not-real"); + await page.getByRole("button", { name: "Add your first provider" }).click(); + await expect(page.getByRole("button", { name: "Run Connection Test" })).toBeVisible(); +} + +async function expectNoHorizontalOverflow(page: Page, label: string) { + const overflow = await page.evaluate(() => { + const root = document.documentElement; + return root.scrollWidth - root.clientWidth; + }); + expect(overflow, `horizontal overflow at ${label}`).toBeLessThanOrEqual(1); +} + +test.describe("Onboarding first use", () => { + test("validate → choose model → test request → client config → first request", async ({ + page, + context, + }) => { + const recorded: Recorded[] = []; + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await openWizard(page, recorded); + await reachTestStep(page); + + const created = recorded.find((r) => r.method === "POST" && r.path === "/api/providers"); + expect(created?.body).toMatchObject({ provider: "openai", apiKey: "sk-e2e-fake-key-not-real" }); + + // 5. validate the credential of the connection just added + await page.getByRole("button", { name: "Run Connection Test" }).click(); + await expect(page.getByText("Connection successful! Your provider is ready.")).toBeVisible(); + const tested = recorded.filter((r) => r.path.endsWith("/test") && r.method === "POST"); + expect(tested.map((r) => r.path)).toEqual([`/api/providers/${CONNECTION.id}/test`]); + + // 6. choose an available model, 7. run a test request + await expect(page.getByRole("heading", { name: "Choose a model" })).toBeVisible(); + await expect(page.getByLabel("Model", { exact: true })).toHaveValue(MODEL); + await page.getByRole("button", { name: "Send test request" }).click(); + await expect(page.getByText("The model answered in 42 ms.")).toBeVisible(); + expect(recorded.find((r) => r.path === "/api/models/test")?.body).toEqual({ + providerId: CONNECTION.provider, + modelId: MODEL, + connectionId: CONNECTION.id, + }); + + // 8. copy the client configuration + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page.getByRole("heading", { name: "Client configuration" })).toBeVisible(); + await expect(page.getByText(`${CONNECTION.provider}/${MODEL}`, { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Copy configuration" }).click(); + await expect(page.getByRole("button", { name: "Copied!" })).toBeVisible(); + const clipboard = await page.evaluate(() => navigator.clipboard.readText()); + // The OS clipboard may turn \n into \r\n (Windows). + expect(clipboard).toMatch(/^Base URL: http:\/\/\S+\/api\/v1\r?\n/); + expect(clipboard).toContain(`Model: ${CONNECTION.provider}/${MODEL}`); + + // 9. see the first request + await expect(page.getByTestId("first-request")).toContainText( + `${CONNECTION.provider}/${MODEL}` + ); + await expect(page.getByRole("link", { name: "Open request logs" })).toHaveAttribute( + "href", + "/dashboard/logs" + ); + const guideHref = await page + .getByRole("link", { name: "Guide: your first 10 minutes" }) + .getAttribute("href"); + expect(guideHref).toBe("/docs/getting-started/first_10_minutes"); + const guide = await page.request.get(guideHref as string); + expect(guide.status(), "the docs link must resolve").toBe(200); + + await page.getByRole("button", { name: /Go to Dashboard/ }).click(); + await page.waitForURL(/\/dashboard\/?$/); + }); + + test("a rejected credential explains why, how to fix, and links to the guide", async ({ + page, + }) => { + const recorded: Recorded[] = []; + await openWizard(page, recorded, { + [`POST /api/providers/${CONNECTION.id}/test`]: { + status: 200, + body: { + valid: false, + error: "Invalid API key provided", + diagnosis: { type: "upstream_auth_error", code: "401", message: "Invalid API key" }, + }, + }, + }); + await reachTestStep(page); + await page.getByRole("button", { name: "Run Connection Test" }).click(); + + const callout = page.getByTestId("actionable-error"); + await expect(callout).toHaveAttribute("data-error-kind", "credential"); + await expect(callout.getByRole("alert")).toHaveText("Invalid API key provided"); + await expect(callout).toContainText("The provider rejected the credential"); + await expect(callout).toContainText("Create or copy a valid key"); + await expect(callout).toContainText("Trying again will not help"); + await expect(callout.getByRole("link", { name: "Open the guide" })).toHaveAttribute( + "href", + "/docs/getting-started/providers-guide" + ); + await expect(callout.getByRole("button", { name: "Retry" })).toBeVisible(); + await callout.getByRole("button", { name: "Back to provider" }).click(); + await expect(page.getByRole("button", { name: "Add your first provider" })).toBeVisible(); + }); + + test("INITIAL_PASSWORD bootstrap is one-time: setupComplete=false reopens the wizard", async ({ + page, + }) => { + await signIn(page); + try { + const patch = await page.request.patch("/api/settings", { data: { setupComplete: false } }); + expect(patch.ok(), `PATCH /api/settings -> ${patch.status()}`).toBeTruthy(); + const settings = (await (await page.request.get("/api/settings")).json()) as { + setupComplete?: boolean; + }; + expect(settings.setupComplete, "not re-forced on the next read").toBe(false); + + await page.goto("/dashboard/onboarding", { waitUntil: "domcontentloaded" }); + await expect(page.getByRole("button", { name: "Get Started" })).toBeVisible({ + timeout: 120_000, + }); + await page.getByRole("button", { name: "Skip wizard entirely" }).click(); + await page.waitForURL(/\/dashboard\/?$/); + const after = (await (await page.request.get("/api/settings")).json()) as { + setupComplete?: boolean; + }; + expect(after.setupComplete).toBe(true); + } finally { + await page.request.patch("/api/settings", { data: { setupComplete: true } }); + } + }); + + test("the wizard never scrolls horizontally at 375/768/900/1024 px", async ({ page }) => { + const recorded: Recorded[] = []; + for (const width of [375, 768, 900, 1024]) { + await page.setViewportSize({ width, height: 900 }); + await page.unrouteAll({ behavior: "ignoreErrors" }); + await openWizard(page, recorded); + await expectNoHorizontalOverflow(page, `${width}px welcome`); + await reachTestStep(page); + await expectNoHorizontalOverflow(page, `${width}px test`); + await page.getByRole("button", { name: "Run Connection Test" }).click(); + await expect(page.getByRole("heading", { name: "Choose a model" })).toBeVisible(); + await expectNoHorizontalOverflow(page, `${width}px model`); + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page.getByRole("heading", { name: "Client configuration" })).toBeVisible(); + await expectNoHorizontalOverflow(page, `${width}px done`); + } + }); +}); From 37dcfad9ca162172e99278097b0482c8f2bf01c8 Mon Sep 17 00:00:00 2001 From: zodyp Date: Fri, 18 Sep 2026 16:43:49 -0300 Subject: [PATCH 6/6] i18n: keep the {provider} placeholder in every locale's local-provider key hint Phase 6 gave providers.localProviderApiKeyOptionalHint real copy with a {provider} argument (the add/edit key modals pass it). The other locales still held the humanized placeholder without {provider}, so tests/unit/i18n-placeholder-parity.test.ts failed on PR #36. Those 39 locales now fall back to the English copy (as other untranslated strings do) until translated; en, pt-BR and vi keep their translations. Placeholder parity, vi key parity and UI key coverage pass. Co-Authored-By: Claude Opus 5 --- src/i18n/messages/ar.json | 2 +- src/i18n/messages/az.json | 2 +- src/i18n/messages/bg.json | 2 +- src/i18n/messages/bn.json | 2 +- src/i18n/messages/cs.json | 2 +- src/i18n/messages/da.json | 2 +- src/i18n/messages/de.json | 2 +- src/i18n/messages/es.json | 2 +- src/i18n/messages/fa.json | 2 +- src/i18n/messages/fi.json | 2 +- src/i18n/messages/fr.json | 2 +- src/i18n/messages/gu.json | 2 +- src/i18n/messages/he.json | 2 +- src/i18n/messages/hi.json | 2 +- src/i18n/messages/hu.json | 2 +- src/i18n/messages/id.json | 2 +- src/i18n/messages/it.json | 2 +- src/i18n/messages/ja.json | 2 +- src/i18n/messages/ko.json | 2 +- src/i18n/messages/mr.json | 2 +- src/i18n/messages/ms.json | 2 +- src/i18n/messages/nl.json | 2 +- src/i18n/messages/no.json | 2 +- src/i18n/messages/phi.json | 2 +- src/i18n/messages/pl.json | 2 +- src/i18n/messages/pt.json | 2 +- src/i18n/messages/ro.json | 2 +- src/i18n/messages/ru.json | 2 +- src/i18n/messages/sk.json | 2 +- src/i18n/messages/sv.json | 2 +- src/i18n/messages/sw.json | 2 +- src/i18n/messages/ta.json | 2 +- src/i18n/messages/te.json | 2 +- src/i18n/messages/th.json | 2 +- src/i18n/messages/tr.json | 2 +- src/i18n/messages/uk-UA.json | 2 +- src/i18n/messages/ur.json | 2 +- src/i18n/messages/zh-CN.json | 2 +- src/i18n/messages/zh-TW.json | 2 +- 39 files changed, 39 insertions(+), 39 deletions(-) diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 8160a614845..7feafe71667 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "التضمين وإعادة الترتيب", "imagesShortLabel": "الصور", "llmProviders": "مزودو LLM", - "localProviderApiKeyOptionalHint": "مفتاح API اختياري للمزود المحلي", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "عنوان URL الأساسي للمزود المحلي", "localProviders": "المزودون المحليون", "maxConcurrentWholeNumberError": "يجب أن يكون الحد الأقصى للتزامن عددًا صحيحًا", diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index 657dd58331a..cc3eec47d8d 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "Embeddings & Rerank", "imagesShortLabel": "Images Short Label", "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index ece3b116c47..98a3b29ba03 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "Вграждания и прекласиране", "imagesShortLabel": "Images Short Label", "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index d939696ab80..4f5ee5d5798 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "এমবেডিং এবং রির্যাঙ্ক", "imagesShortLabel": "Images Short Label", "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index dd52ef28a59..a595655c657 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "Vložení a přehodnocení", "imagesShortLabel": "Images Short Label", "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index 11d193c2782..fbd85658154 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "Indlejringer og omrangering", "imagesShortLabel": "Images Short Label", "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 54114e54341..b181f0a8bed 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "Einbettungen und Neubewertung", "imagesShortLabel": "Images Short Label", "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 4368f001152..67f221bba10 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "Incrustaciones y reclasificación", "imagesShortLabel": "Images Short Label", "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index a00a1127c08..0a817d1db98 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "جاسازی ها و رتبه بندی مجدد", "imagesShortLabel": "Images Short Label", "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index b487a616698..b17238b007f 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "Upotukset ja uudelleensijoitukset", "imagesShortLabel": "Images Short Label", "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 0f8c28a16a8..738199bce8c 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "Intégrations et classement", "imagesShortLabel": "Images Short Label", "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index 5f900e7d065..4e34053e3a6 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "એમ્બેડિંગ્સ અને ફરીથી રેન્ક", "imagesShortLabel": "Images Short Label", "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index c65224fca00..0e070de041d 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "הטמעות ודירוג מחדש", "imagesShortLabel": "Images Short Label", "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index cf4ca413055..14b4427d9eb 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "एंबेडिंग्स और रीरैंक", "imagesShortLabel": "Images Short Label", "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index 12f2cfd234c..cd62f023ac2 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "Beágyazások és áthelyezés", "imagesShortLabel": "Images Short Label", "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index 757cbc20bdb..946c3c21cd1 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "Penyematan & Pemeringkatan Ulang", "imagesShortLabel": "Images Short Label", "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index e4658884a63..af31694d45c 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "Incorporamenti e riclassificazione", "imagesShortLabel": "Images Short Label", "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 6d3d4f02678..e7900f3c05b 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "埋め込みと再ランク付け", "imagesShortLabel": "Images Short Label", "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 91017f7fc4c..0d8a4275f96 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "임베딩 및 순위 재지정", "imagesShortLabel": "Images Short Label", "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index c39305b5357..42efe144a2c 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "एम्बेडिंग आणि रँक", "imagesShortLabel": "Images Short Label", "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index ed5fef77313..c29ef2581ee 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "Pembenaman & Kedudukan Semula", "imagesShortLabel": "Images Short Label", "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index 3aef4b2a8f8..559748d328a 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "Insluitingen en herrangschikking", "imagesShortLabel": "Images Short Label", "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index fded5ce0e97..598821e163f 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "Innebygging og omrangering", "imagesShortLabel": "Images Short Label", "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index 7a0e2541851..7d7efe3d5dc 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "Mga Pag-embed at Muling Ranggo", "imagesShortLabel": "Images Short Label", "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index 7861316defd..6688bfde2ca 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "Embeddings i Rerank", "imagesShortLabel": "Obrazy", "llmProviders": "LLM Providers", - "localProviderApiKeyOptionalHint": "Wskazówka: klucz API dla lokalnego provider jest opcjonalny", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Wskazówka dotycząca bazowego URL lokalnego provider", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Błąd: maksymalna współbieżność musi być liczbą całkowitą", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 16627117065..fe7a8109e4d 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "Incorporações e reclassificação", "imagesShortLabel": "Imagens", "llmProviders": "Fornecedores de LLM", - "localProviderApiKeyOptionalHint": "Dica de Chave de API Opcional do Fornecedor Local", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Dica de URL Base do Fornecedor Local", "localProviders": "Fornecedores Locais", "maxConcurrentWholeNumberError": "Erro de Número Inteiro de Concorrência Máxima", diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index e05fe033295..0231a73826e 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "Embeddings & Rerank", "imagesShortLabel": "Images Short Label", "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index df520189beb..fd7e06c29be 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "Вложения и переранжирование", "imagesShortLabel": "Images Short Label", "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index fb2f998af11..1c49a7c1cb1 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "Vloženie a prehodnotenie", "imagesShortLabel": "Images Short Label", "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index c2e8b13d347..83ad565d384 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "Inbäddningar och omplacering", "imagesShortLabel": "Images Short Label", "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index eaeb4d8abbf..376323cbaef 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "Upachikaji & Weka Nafasi", "imagesShortLabel": "Images Short Label", "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index 54a4a0b5625..4beaec3ea87 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "உட்பொதிப்புகள் & மறுவரிசைப்படுத்துதல்", "imagesShortLabel": "Images Short Label", "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index 701f62777b7..b8fd470d526 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "ఎంబెడ్డింగ్‌లు & రీర్యాంక్", "imagesShortLabel": "Images Short Label", "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index 722dd11f15a..0cf6032a224 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "การฝังและการจัดอันดับใหม่", "imagesShortLabel": "Images Short Label", "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index 6322b168f9b..d7464058855 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "Yerleştirmeler ve Yeniden Sıralama", "imagesShortLabel": "Images Short Label", "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index 59770da87ff..81fe1087e37 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "Вбудовування та зміна рейтингу", "imagesShortLabel": "Images Short Label", "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index 6fa47e791a0..04256c8964f 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "ایمبیڈنگز اور رینک", "imagesShortLabel": "Images Short Label", "llmProviders": "Llm Providers", - "localProviderApiKeyOptionalHint": "Local Provider Api Key Optional Hint", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 8c661cc99de..3084d0da45d 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "嵌入与重排序", "imagesShortLabel": "图像", "llmProviders": "LLM 提供者", - "localProviderApiKeyOptionalHint": "本地提供者的 API 密钥通常是可选的。", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "输入本地提供者的 Base URL。", "localProviders": "本地 Provider", "maxConcurrentWholeNumberError": "最大并发必须是整数", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index aeb1c6e5dbe..42d4b77c456 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -5894,7 +5894,7 @@ "embeddingRerankProviders": "嵌入與重排序", "imagesShortLabel": "影像", "llmProviders": "LLM Provider", - "localProviderApiKeyOptionalHint": "本地提供者的 API 金鑰通常是可選的。", + "localProviderApiKeyOptionalHint": "Optional for {provider}: leave empty if your local server does not require an API key.", "localProviderBaseUrlHint": "輸入本地提供者的 Base URL。", "localProviders": "本地 Provider", "maxConcurrentWholeNumberError": "最大併發必須是整數",