mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-09 00:32:13 +03:00
Compare commits
5 Commits
fix/deepse
...
fix/i18n-h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2925fde9f | ||
|
|
9a64e0e17d | ||
|
|
e2eba05e5c | ||
|
|
9e628e73fa | ||
|
|
9061d7c70d |
1
changelog.d/fixes/9235-french-ui-catalog.md
Normal file
1
changelog.d/fixes/9235-french-ui-catalog.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(i18n):** completed the French UI catalog by adding all missing keys and replacing every placeholder translation ([#9235](https://github.com/diegosouzapw/OmniRoute/pull/9235)) — thanks @alex-jordan547
|
||||
1
changelog.d/fixes/9245-hardcoded-web-ui-i18n.md
Normal file
1
changelog.d/fixes/9245-hardcoded-web-ui-i18n.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(i18n):** localized hardcoded web UI copy across public pages, dashboard views, and shared components, with complete French and Vietnamese coverage ([#9245](https://github.com/diegosouzapw/OmniRoute/pull/9245)) — thanks @alex-jordan547
|
||||
@@ -27,22 +27,17 @@ export default function BootstrapBanner() {
|
||||
<span className="text-amber-500 dark:text-amber-400 text-base shrink-0 mt-0.5">⚠️</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-amber-900 dark:text-amber-300">
|
||||
Running in zero-config mode
|
||||
{t("zeroConfigBannerTitle")}
|
||||
</p>
|
||||
<p className="mt-0.5 text-amber-800/80 dark:text-amber-200/80">
|
||||
OmniRoute auto-generated secure encryption keys on first launch. They are persisted to{" "}
|
||||
<code className="font-mono bg-amber-200/50 dark:bg-amber-500/20 px-1 rounded text-xs">
|
||||
{dataDir}
|
||||
</code>
|
||||
. No action is required — your data is encrypted and safe. To use custom keys, add{" "}
|
||||
<code className="font-mono bg-amber-200/50 dark:bg-amber-500/20 px-1 rounded text-xs">
|
||||
JWT_SECRET
|
||||
</code>{" "}
|
||||
and{" "}
|
||||
<code className="font-mono bg-amber-200/50 dark:bg-amber-500/20 px-1 rounded text-xs">
|
||||
STORAGE_ENCRYPTION_KEY
|
||||
</code>{" "}
|
||||
to that file.
|
||||
{t.rich("zeroConfigBannerBody", {
|
||||
dataDir,
|
||||
code: (chunks) => (
|
||||
<code className="font-mono bg-amber-200/50 dark:bg-amber-500/20 px-1 rounded text-xs">
|
||||
{chunks}
|
||||
</code>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -144,31 +144,31 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
const cleanLatest = latest.replace(/^v/, "");
|
||||
if (platform === "darwin") {
|
||||
return {
|
||||
label: "Download DMG (macOS)",
|
||||
label: t("downloadDmg"),
|
||||
url: `https://github.com/diegosouzapw/OmniRoute/releases/download/v${cleanLatest}/OmniRoute-${cleanLatest}.dmg`,
|
||||
desc: `A new version of the OmniRoute desktop app is available. Please download and install the macOS DMG installer to update (current: v${versionInfo?.current || ""}).`,
|
||||
desc: t("downloadDmgDescription", { version: versionInfo?.current || "" }),
|
||||
};
|
||||
}
|
||||
if (platform === "win32") {
|
||||
return {
|
||||
label: "Download EXE (Windows)",
|
||||
label: t("downloadExe"),
|
||||
url: `https://github.com/diegosouzapw/OmniRoute/releases/download/v${cleanLatest}/OmniRoute.Setup.${cleanLatest}.exe`,
|
||||
desc: `A new version of the OmniRoute desktop app is available. Please download and install the Windows EXE installer to update (current: v${versionInfo?.current || ""}).`,
|
||||
desc: t("downloadExeDescription", { version: versionInfo?.current || "" }),
|
||||
};
|
||||
}
|
||||
if (platform === "linux") {
|
||||
return {
|
||||
label: "Download AppImage (Linux)",
|
||||
label: t("downloadAppImage"),
|
||||
url: `https://github.com/diegosouzapw/OmniRoute/releases/download/v${cleanLatest}/OmniRoute-${cleanLatest}.AppImage`,
|
||||
desc: `A new version of the OmniRoute desktop app is available. Please download the Linux AppImage package to update (current: v${versionInfo?.current || ""}).`,
|
||||
desc: t("downloadAppImageDescription", { version: versionInfo?.current || "" }),
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: "Download Update",
|
||||
label: t("downloadUpdate"),
|
||||
url: `https://github.com/diegosouzapw/OmniRoute/releases/tag/v${cleanLatest}`,
|
||||
desc: `A new version of the OmniRoute desktop app is available. Please download the respective app format for your system to update (current: v${versionInfo?.current || ""}).`,
|
||||
desc: t("downloadUpdateDescription", { version: versionInfo?.current || "" }),
|
||||
};
|
||||
}, [platform, versionInfo?.latest, versionInfo?.current]);
|
||||
}, [platform, t, versionInfo?.latest, versionInfo?.current]);
|
||||
|
||||
// Electron internal auto-updater state and listeners
|
||||
const [electronUpdateStatus, setElectronUpdateStatus] = useState<{
|
||||
@@ -539,29 +539,29 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
{
|
||||
step: "install",
|
||||
status: "done",
|
||||
message: message || `Queued update to v${targetVersion}.`,
|
||||
message: message || t("updateQueued", { version: targetVersion }),
|
||||
},
|
||||
{
|
||||
step: "rebuild",
|
||||
status: "running",
|
||||
message: "Docker image is rebuilding in the background.",
|
||||
message: t("updateDockerRebuilding"),
|
||||
},
|
||||
{
|
||||
step: "restart",
|
||||
status: "pending",
|
||||
message: "Waiting for OmniRoute to restart with the new version.",
|
||||
message: t("updateWaitingRestart"),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
step: "install",
|
||||
status: "running",
|
||||
message: message || `Installing v${targetVersion}.`,
|
||||
message: message || t("updateInstalling", { version: targetVersion }),
|
||||
},
|
||||
{
|
||||
step: "restart",
|
||||
status: "pending",
|
||||
message: "Waiting for OmniRoute to restart with the new version.",
|
||||
message: t("updateWaitingRestart"),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -593,14 +593,14 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
next = mergeUpdateStep(next, {
|
||||
step: "complete",
|
||||
status: "done",
|
||||
message: `OmniRoute is now running v${targetVersion}.`,
|
||||
message: t("updateRunning", { version: targetVersion }),
|
||||
});
|
||||
|
||||
return next;
|
||||
});
|
||||
setUpdating(false);
|
||||
setUpdatePhase("done");
|
||||
notify.success(`OmniRoute updated to v${targetVersion}.`);
|
||||
notify.success(t("updateCompleted", { version: targetVersion }));
|
||||
await fetchData();
|
||||
return;
|
||||
}
|
||||
@@ -611,20 +611,20 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
next = mergeUpdateStep(next, {
|
||||
step: "rebuild",
|
||||
status: "running",
|
||||
message: `Docker image is still rebuilding for v${targetVersion}.`,
|
||||
message: t("updateDockerStillRebuilding", { version: targetVersion }),
|
||||
});
|
||||
} else {
|
||||
next = mergeUpdateStep(next, {
|
||||
step: "install",
|
||||
status: "running",
|
||||
message: `Installing v${targetVersion} in the background.`,
|
||||
message: t("updateInstallingBackground", { version: targetVersion }),
|
||||
});
|
||||
}
|
||||
|
||||
next = mergeUpdateStep(next, {
|
||||
step: "restart",
|
||||
status: "pending",
|
||||
message: `Waiting for OmniRoute to come back on v${targetVersion}.`,
|
||||
message: t("updateWaitingVersion", { version: targetVersion }),
|
||||
});
|
||||
|
||||
return next;
|
||||
@@ -636,20 +636,20 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
next = mergeUpdateStep(next, {
|
||||
step: "rebuild",
|
||||
status: "running",
|
||||
message: "Docker rebuild is still in progress.",
|
||||
message: t("updateDockerStillInProgress"),
|
||||
});
|
||||
} else {
|
||||
next = mergeUpdateStep(next, {
|
||||
step: "install",
|
||||
status: "running",
|
||||
message: `Installing v${targetVersion} in the background.`,
|
||||
message: t("updateInstallingBackground", { version: targetVersion }),
|
||||
});
|
||||
}
|
||||
|
||||
next = mergeUpdateStep(next, {
|
||||
step: "restart",
|
||||
status: "running",
|
||||
message: "Service restart in progress. Waiting for OmniRoute to come back online...",
|
||||
message: t("updateRestarting"),
|
||||
});
|
||||
|
||||
return next;
|
||||
@@ -661,14 +661,14 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
mergeUpdateStep(prev, {
|
||||
step: "error",
|
||||
status: "failed",
|
||||
message: `Update started, but v${targetVersion} did not become available before timeout. Refresh the page or check server logs.`,
|
||||
message: t("updateTimeout", { version: targetVersion }),
|
||||
})
|
||||
);
|
||||
setUpdating(false);
|
||||
setUpdatePhase("failed");
|
||||
notify.error(`Update to v${targetVersion} timed out.`);
|
||||
notify.error(t("updateTimedOut", { version: targetVersion }));
|
||||
},
|
||||
[fetchData]
|
||||
[fetchData, t]
|
||||
);
|
||||
|
||||
const handleUpdate = async () => {
|
||||
@@ -689,12 +689,12 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
// Passing the raw object to notify.error() rendered it as a React child →
|
||||
// "Minified React error #31" crash ("Internal Server Error" screen), e.g. on
|
||||
// the 403 from the loopback-only /api/system/version. Extract the string.
|
||||
notify.error(extractApiErrorMessage(data, "Failed to start update."));
|
||||
notify.error(extractApiErrorMessage(data, t("updateStartFailed")));
|
||||
setUpdating(false);
|
||||
setUpdatePhase("idle");
|
||||
return;
|
||||
}
|
||||
notify.success(data.message || "Update started.");
|
||||
notify.success(data.message || t("updateStarted"));
|
||||
await pollBackgroundUpdate({
|
||||
channel: data.channel || "docker-compose",
|
||||
message: data.message || "",
|
||||
@@ -705,7 +705,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
|
||||
// SSE stream — read progress events
|
||||
if (!res.body) {
|
||||
notify.error("No response stream received.");
|
||||
notify.error(t("noResponseStream"));
|
||||
setUpdating(false);
|
||||
setUpdatePhase("idle");
|
||||
return;
|
||||
@@ -735,10 +735,10 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
if (event.step === "complete") {
|
||||
setUpdatePhase("done");
|
||||
setUpdating(false);
|
||||
notify.success(event.message || "Update complete!");
|
||||
notify.success(event.message || t("updateComplete"));
|
||||
} else if (event.step === "error") {
|
||||
setUpdatePhase("failed");
|
||||
notify.error(event.message || "Update failed.");
|
||||
notify.error(event.message || t("updateFailed"));
|
||||
setUpdating(false);
|
||||
}
|
||||
} catch {
|
||||
@@ -753,7 +753,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
{
|
||||
step: "error",
|
||||
status: "failed",
|
||||
message: "Network error — connection lost during update.",
|
||||
message: t("updateNetworkError"),
|
||||
},
|
||||
]);
|
||||
setUpdating(false);
|
||||
@@ -769,11 +769,11 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
return () => clearTimeout(timer);
|
||||
}, [updatePhase]);
|
||||
const stepLabels: Record<string, string> = {
|
||||
install: "Install Package",
|
||||
rebuild: "Rebuild Native Modules",
|
||||
restart: "Restart Service",
|
||||
complete: "Complete",
|
||||
error: "Error",
|
||||
install: t("stepInstallPackage"),
|
||||
rebuild: t("stepRebuildNativeModules"),
|
||||
restart: t("stepRestartService"),
|
||||
complete: t("stepComplete"),
|
||||
error: t("stepError"),
|
||||
};
|
||||
const showUpdateOverlay = updatePhase !== "idle";
|
||||
|
||||
@@ -801,17 +801,17 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
<div>
|
||||
<h3 className="text-lg font-bold">
|
||||
{updatePhase === "done"
|
||||
? "Update Complete!"
|
||||
? t("updateCompleteTitle")
|
||||
: updatePhase === "failed"
|
||||
? "Update Failed"
|
||||
: "Updating OmniRoute..."}
|
||||
? t("updateFailedTitle")
|
||||
: t("updatingTitle")}
|
||||
</h3>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
{updatePhase === "done"
|
||||
? "The page will reload automatically in a few seconds."
|
||||
? t("reloadNotice")
|
||||
: updatePhase === "failed"
|
||||
? "Please try again or update manually via the CLI."
|
||||
: "Do not close this page. The system will restart automatically."}
|
||||
? t("retryNotice")
|
||||
: t("restartNotice")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -871,7 +871,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
<div className="mt-1 px-3 py-2.5 rounded-lg border border-green-500/30 bg-green-500/5">
|
||||
<p className="text-sm font-semibold text-green-500 flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px]">check_circle</span>
|
||||
{updateSteps.find((s) => s.step === "complete")?.message || "Update complete!"}
|
||||
{updateSteps.find((s) => s.step === "complete")?.message || t("updateComplete")}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-1">{t("reloadingPageAutomatically")}</p>
|
||||
</div>
|
||||
@@ -891,11 +891,11 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
if (updatePhase === "done") globalThis.window.location.reload();
|
||||
}}
|
||||
>
|
||||
{updatePhase === "done" ? "Reload Now" : "Close"}
|
||||
{updatePhase === "done" ? t("reloadNow") : t("closeUpdate")}
|
||||
</Button>
|
||||
{updatePhase === "failed" && (
|
||||
<Button size="sm" variant="secondary" fullWidth onClick={handleUpdate}>
|
||||
Retry
|
||||
{t("retryUpdate")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -917,30 +917,32 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-semibold text-sm">
|
||||
Update Available: v{versionInfo.latest} {isElectron && "(Desktop App)"}
|
||||
{t("updateAvailableTitle", {
|
||||
version: versionInfo.latest,
|
||||
desktop: isElectron ? ` ${t("desktopAppLabel")}` : "",
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs opacity-80 mt-0.5">
|
||||
{isElectron ? (
|
||||
<>
|
||||
{electronUpdateStatus.status === "checking" && "Checking for updates..."}
|
||||
{electronUpdateStatus.status === "checking" && t("checkingForUpdates")}
|
||||
{electronUpdateStatus.status === "available" &&
|
||||
`Version v${versionInfo.latest} is available for download.`}
|
||||
t("versionAvailableForDownload", { version: versionInfo.latest })}
|
||||
{electronUpdateStatus.status === "downloading" &&
|
||||
`Downloading update... ${electronUpdateStatus.percent || 0}% complete.`}
|
||||
{electronUpdateStatus.status === "downloaded" &&
|
||||
"Update downloaded successfully! Click Restart & Install to apply."}
|
||||
t("downloadingUpdate", { percent: electronUpdateStatus.percent || 0 })}
|
||||
{electronUpdateStatus.status === "downloaded" && t("updateDownloaded")}
|
||||
{electronUpdateStatus.status === "error" &&
|
||||
`Auto-update failed: ${electronUpdateStatus.message || "Unknown error"}.`}
|
||||
t("autoUpdateFailed", {
|
||||
reason: electronUpdateStatus.message || t("unknownUpdateError"),
|
||||
})}
|
||||
{(electronUpdateStatus.status === "idle" ||
|
||||
electronUpdateStatus.status === "not-available") &&
|
||||
`Version v${versionInfo.latest} is available for the desktop app.`}
|
||||
t("versionAvailableDesktop", { version: versionInfo.latest })}
|
||||
</>
|
||||
) : versionInfo.autoUpdateSupported ? (
|
||||
t("updateAvailableDesc") ||
|
||||
`You are currently using v${versionInfo.current}. Update to access the latest features and bug fixes.`
|
||||
t("updateAvailableDesc")
|
||||
) : (
|
||||
versionInfo.autoUpdateError ||
|
||||
"Manual update required for this installation type."
|
||||
versionInfo.autoUpdateError || t("manualUpdateRequired")
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -954,7 +956,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
onClick={() => globalThis.window.electronAPI?.downloadUpdate()}
|
||||
className="font-semibold"
|
||||
>
|
||||
Download Update
|
||||
{t("downloadUpdate")}
|
||||
</Button>
|
||||
)}
|
||||
{electronUpdateStatus.status === "downloading" && (
|
||||
@@ -973,7 +975,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
onClick={() => globalThis.window.electronAPI?.installUpdate()}
|
||||
className="font-semibold animate-pulse"
|
||||
>
|
||||
Restart & Install
|
||||
{t("restartAndInstall")}
|
||||
</Button>
|
||||
)}
|
||||
{(electronUpdateStatus.status === "error" ||
|
||||
@@ -989,7 +991,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
}}
|
||||
className="font-semibold"
|
||||
>
|
||||
Check for Update
|
||||
{t("checkForUpdate")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1001,9 +1003,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
className="ml-4 shrink-0 font-semibold"
|
||||
title={versionInfo.autoUpdateError || ""}
|
||||
>
|
||||
{versionInfo.autoUpdateSupported
|
||||
? t("updateNow") || "Update Now"
|
||||
: "Manual Update"}
|
||||
{versionInfo.autoUpdateSupported ? t("updateNow") : t("manualUpdate")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1015,9 +1015,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
electronUpdateStatus.status === "available" ||
|
||||
electronUpdateStatus.status === "not-available") && (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between border-t border-primary/20 mt-2 pt-3 gap-2">
|
||||
<p className="text-xs opacity-75">
|
||||
Or download the respective installer format directly:
|
||||
</p>
|
||||
<p className="text-xs opacity-75">{t("directDownloadHint")}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -1029,7 +1027,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
}
|
||||
className="font-semibold text-xs py-1"
|
||||
>
|
||||
Release Notes
|
||||
{t("releaseNotes")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -1067,7 +1065,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
rel="noopener noreferrer"
|
||||
className="ml-4 inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-border bg-bg px-4 py-2 text-xs font-semibold text-text-main transition-colors hover:border-primary/30 hover:text-primary"
|
||||
>
|
||||
{versionInfo.news.linkLabel || "Ler Mais"}
|
||||
{versionInfo.news.linkLabel || t("readMore")}
|
||||
<span className="material-symbols-outlined text-[14px]">arrow_forward</span>
|
||||
</a>
|
||||
)}
|
||||
@@ -1214,7 +1212,7 @@ function ProviderOverviewCard({
|
||||
item.errors > 0 ? "text-red-500" : item.connected > 0 ? "text-green-500" : "text-text-muted";
|
||||
|
||||
const authTypeConfig = {
|
||||
"no-auth": { color: "bg-stone-500", label: "No Auth" },
|
||||
"no-auth": { color: "bg-stone-500", label: t("noAuthLabel") },
|
||||
free: { color: "bg-green-500", label: tc("free") },
|
||||
oauth: { color: "bg-blue-500", label: t("oauthLabel") },
|
||||
apikey: { color: "bg-amber-500", label: t("apiKeyLabel") },
|
||||
|
||||
@@ -42,9 +42,7 @@ export default function AutoRoutingAnalyticsTab() {
|
||||
if (!stats) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-8 text-text-muted">
|
||||
No auto-routing analytics available. Make requests using the auto/ prefix to see metrics.
|
||||
</div>
|
||||
<div className="text-center py-8 text-text-muted">{t("autoRoutingNoDataAvailable")}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -110,7 +108,9 @@ export default function AutoRoutingAnalyticsTab() {
|
||||
const percentage = stats.totalRequests > 0 ? (count / stats.totalRequests) * 100 : 0;
|
||||
return (
|
||||
<div key={variant} className="flex items-center gap-3">
|
||||
<div className="w-32 text-sm font-medium capitalize">{variant || "default"}</div>
|
||||
<div className="w-32 text-sm font-medium capitalize">
|
||||
{variant || t("defaultVariantLabel")}
|
||||
</div>
|
||||
<div className="flex-1 h-3 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-indigo-500 rounded-full transition-all"
|
||||
@@ -133,9 +133,9 @@ export default function AutoRoutingAnalyticsTab() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left py-2 px-3 font-medium">Provider</th>
|
||||
<th className="text-right py-2 px-3 font-medium">Requests</th>
|
||||
<th className="text-right py-2 px-3 font-medium">Share</th>
|
||||
<th className="text-left py-2 px-3 font-medium">{t("chartProvider")}</th>
|
||||
<th className="text-right py-2 px-3 font-medium">{t("chartRequests")}</th>
|
||||
<th className="text-right py-2 px-3 font-medium">{t("chartShare")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -50,11 +50,13 @@ function ProviderBar({
|
||||
count,
|
||||
total,
|
||||
costUsd,
|
||||
queriesLabel,
|
||||
}: {
|
||||
provider: string;
|
||||
count: number;
|
||||
total: number;
|
||||
costUsd: number;
|
||||
queriesLabel: string;
|
||||
}) {
|
||||
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
|
||||
return (
|
||||
@@ -62,7 +64,7 @@ function ProviderBar({
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium text-text">{provider}</span>
|
||||
<span className="text-text-muted">
|
||||
{count} queries · ${costUsd.toFixed(4)}
|
||||
{count} {queriesLabel} · ${costUsd.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-bg-muted overflow-hidden">
|
||||
@@ -99,7 +101,7 @@ export default function SearchAnalyticsTab() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16 text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin mr-2">progress_activity</span>
|
||||
Loading search analytics…
|
||||
{t("searchAnalyticsLoading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -108,9 +110,11 @@ export default function SearchAnalyticsTab() {
|
||||
return (
|
||||
<div className="card p-6 text-center text-text-muted">
|
||||
<span className="material-symbols-outlined text-[32px] mb-2 block">search_off</span>
|
||||
{error || "No search data available yet."}
|
||||
{error || t("searchAnalyticsNoData")}
|
||||
<p className="text-xs mt-2">
|
||||
Search requests will appear here after the first search via /v1/search.
|
||||
{t.rich("searchAnalyticsNoDataDescription", {
|
||||
code: (chunks) => <code className="bg-bg-muted px-1 rounded">{chunks}</code>,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -126,25 +130,29 @@ export default function SearchAnalyticsTab() {
|
||||
icon="manage_search"
|
||||
label={t("searchAnalyticsTotalSearches")}
|
||||
value={stats.total.toLocaleString()}
|
||||
sub={`${stats.today} today`}
|
||||
sub={t("searchAnalyticsToday", { count: stats.today })}
|
||||
/>
|
||||
<StatCard
|
||||
icon="cached"
|
||||
label={t("searchAnalyticsCacheHitRate")}
|
||||
value={`${stats.cacheHitRate}%`}
|
||||
sub={`${stats.cached} cached requests`}
|
||||
sub={t("searchAnalyticsCachedRequests", { count: stats.cached })}
|
||||
/>
|
||||
<StatCard
|
||||
icon="attach_money"
|
||||
label={t("searchAnalyticsTotalCost")}
|
||||
value={`$${stats.totalCostUsd.toFixed(4)}`}
|
||||
sub="search API costs"
|
||||
sub={t("searchAnalyticsApiCosts")}
|
||||
/>
|
||||
<StatCard
|
||||
icon="timer"
|
||||
label={t("searchAnalyticsAvgResponse")}
|
||||
value={`${stats.avgDurationMs}ms`}
|
||||
sub={stats.errors > 0 ? `${stats.errors} errors` : "No errors"}
|
||||
sub={
|
||||
stats.errors > 0
|
||||
? t("searchAnalyticsErrors", { count: stats.errors })
|
||||
: t("searchAnalyticsNoErrors")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -153,7 +161,7 @@ export default function SearchAnalyticsTab() {
|
||||
<div className="card p-5">
|
||||
<h3 className="font-semibold text-text mb-4 flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-primary text-[20px]">hub</span>
|
||||
Provider Breakdown
|
||||
{t("searchAnalyticsProviderBreakdown")}
|
||||
</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
{providers.map(([prov, data]) => (
|
||||
@@ -163,6 +171,7 @@ export default function SearchAnalyticsTab() {
|
||||
count={data.count}
|
||||
total={stats.total}
|
||||
costUsd={data.costUsd}
|
||||
queriesLabel={t("searchAnalyticsQueries")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -177,8 +186,9 @@ export default function SearchAnalyticsTab() {
|
||||
</span>
|
||||
<p className="font-medium text-text">{t("searchAnalyticsNoSearchesYet")}</p>
|
||||
<p className="text-sm mt-1">
|
||||
Use <code className="bg-bg-muted px-1 rounded">POST /v1/search</code> to start routing
|
||||
web searches.
|
||||
{t.rich("searchAnalyticsEmptyDescription", {
|
||||
code: (chunks) => <code className="bg-bg-muted px-1 rounded">{chunks}</code>,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -189,8 +199,9 @@ export default function SearchAnalyticsTab() {
|
||||
check_circle
|
||||
</span>
|
||||
<span>
|
||||
<strong>Free tier available:</strong> Serper (2,500/mo), Brave (2,000/mo), Exa (1,000/mo),
|
||||
Tavily (1,000/mo) — total 6,500+ free searches/month with automatic failover.
|
||||
{t.rich("searchAnalyticsFreeTier", {
|
||||
strong: (chunks) => <strong>{chunks}</strong>,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -122,23 +122,23 @@ export default function McpAuditTab() {
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
{[
|
||||
{
|
||||
label: "Calls (24h)",
|
||||
label: t("mcpMetricCalls24h"),
|
||||
value: stats.totalCalls.toLocaleString(),
|
||||
icon: "terminal",
|
||||
},
|
||||
{
|
||||
label: "Success rate",
|
||||
label: t("mcpMetricSuccessRate"),
|
||||
value: `${Math.round(stats.successRate * 100)}%`,
|
||||
icon: "check_circle",
|
||||
highlight: stats.successRate >= 0.9,
|
||||
},
|
||||
{
|
||||
label: "Avg duration",
|
||||
label: t("mcpMetricAvgDuration"),
|
||||
value: `${Math.round(stats.avgDurationMs)}ms`,
|
||||
icon: "timer",
|
||||
},
|
||||
{
|
||||
label: "Top tool",
|
||||
label: t("mcpMetricTopTool"),
|
||||
value: stats.topTools[0]?.tool ?? "—",
|
||||
icon: "star",
|
||||
},
|
||||
|
||||
@@ -4,7 +4,9 @@ import { useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useBatchActions } from "./components/useBatchActions";
|
||||
|
||||
function relativeTime(ts: number): string {
|
||||
type BatchTranslator = ReturnType<typeof useTranslations>;
|
||||
|
||||
function relativeTime(ts: number, t: BatchTranslator): string {
|
||||
const diffMs = Date.now() - ts * 1000;
|
||||
const isFuture = diffMs < 0;
|
||||
const absDiffMs = Math.abs(diffMs);
|
||||
@@ -22,8 +24,9 @@ function relativeTime(ts: number): string {
|
||||
}
|
||||
}
|
||||
|
||||
if (isFuture) return `in ${res}`;
|
||||
return `${res} ago`;
|
||||
return isFuture
|
||||
? t("batchRelativeTimeIn", { value: res })
|
||||
: t("batchRelativeTimeAgo", { value: res });
|
||||
}
|
||||
|
||||
interface BatchRecord {
|
||||
@@ -93,6 +96,22 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
expired_with_failures: "expired (partial)",
|
||||
};
|
||||
|
||||
const STATUS_TRANSLATION_KEYS: Record<string, string> = {
|
||||
completed: "batchStatusCompleted",
|
||||
completed_with_failures: "batchStatusCompletedWithFailures",
|
||||
failed: "batchStatusFailed",
|
||||
in_progress: "batchStatusInProgress",
|
||||
in_progress_with_failures: "batchStatusInProgressWithFailures",
|
||||
finalizing: "batchStatusFinalizing",
|
||||
finalizing_with_failures: "batchStatusFinalizingWithFailures",
|
||||
validating: "batchStatusValidating",
|
||||
cancelling: "batchStatusCancelling",
|
||||
cancelled: "batchStatusCancelled",
|
||||
cancelled_with_failures: "batchStatusCancelledWithFailures",
|
||||
expired: "batchStatusExpired",
|
||||
expired_with_failures: "batchStatusExpiredWithFailures",
|
||||
};
|
||||
|
||||
function effectiveStatus(batch: BatchRecord): string {
|
||||
const hasFailed = (batch.requestCountsFailed ?? 0) > 0;
|
||||
if (!hasFailed) return batch.status;
|
||||
@@ -106,10 +125,12 @@ function effectiveStatus(batch: BatchRecord): string {
|
||||
return map[batch.status] ?? batch.status;
|
||||
}
|
||||
|
||||
function StatusBadge({ batch }: { batch: BatchRecord }) {
|
||||
function StatusBadge({ batch, t }: { batch: BatchRecord; t: BatchTranslator }) {
|
||||
const key = effectiveStatus(batch);
|
||||
const cls = STATUS_STYLES[key] ?? "bg-gray-500/15 text-gray-400 border-gray-500/25";
|
||||
const label = STATUS_LABELS[key] ?? key.replace(/_/g, " ");
|
||||
const label = STATUS_TRANSLATION_KEYS[key]
|
||||
? t(STATUS_TRANSLATION_KEYS[key])
|
||||
: (STATUS_LABELS[key] ?? key.replace(/_/g, " "));
|
||||
return (
|
||||
<span className={`inline-block px-2 py-0.5 rounded-md text-xs font-medium border ${cls}`}>
|
||||
{label}
|
||||
@@ -141,12 +162,24 @@ function formatTs(ts: number | null | undefined): string {
|
||||
});
|
||||
}
|
||||
|
||||
export default function BatchDetailModal({ batch, files, onClose, onActionDone }: BatchDetailModalProps) {
|
||||
export default function BatchDetailModal({
|
||||
batch,
|
||||
files,
|
||||
onClose,
|
||||
onActionDone,
|
||||
}: BatchDetailModalProps) {
|
||||
const t = useTranslations("common");
|
||||
|
||||
// ── Action hook (F7) ─────────────────────────────────────────────────────────
|
||||
const { cancelling, retrying, error: actionError, cancel, retry, downloadHrefOutput, downloadHrefErrors } =
|
||||
useBatchActions({ onRefresh: onActionDone, t });
|
||||
const {
|
||||
cancelling,
|
||||
retrying,
|
||||
error: actionError,
|
||||
cancel,
|
||||
retry,
|
||||
downloadHrefOutput,
|
||||
downloadHrefErrors,
|
||||
} = useBatchActions({ onRefresh: onActionDone, t });
|
||||
|
||||
// ── Status flags ──────────────────────────────────────────────────────────────
|
||||
const isTerminal = ["completed", "failed", "cancelled", "expired"].includes(batch.status);
|
||||
@@ -195,8 +228,11 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
||||
pending_actions
|
||||
</span>
|
||||
<div>
|
||||
<h2 id="batch-detail-modal-title" className="text-base font-semibold text-[var(--color-text-main)]">
|
||||
Batch Details
|
||||
<h2
|
||||
id="batch-detail-modal-title"
|
||||
className="text-base font-semibold text-[var(--color-text-main)]"
|
||||
>
|
||||
{t("batchDetailsTitle")}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<p className="text-xs text-[var(--color-text-muted)] font-mono">{batch.id}</p>
|
||||
@@ -227,16 +263,18 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
|
||||
Status
|
||||
{t("status")}
|
||||
</span>
|
||||
<StatusBadge batch={batch} />
|
||||
<StatusBadge batch={batch} t={t} />
|
||||
</div>
|
||||
<Field label={t("batchDetailEndpoint")} value={batch.endpoint} />
|
||||
{batch.model && <Field label={t("batchDetailModel")} value={batch.model} />}
|
||||
<Field label={t("batchDetailWindow")} value={batch.completionWindow} />
|
||||
<Field
|
||||
label={t("batchDetailCreated")}
|
||||
value={<span title={formatTs(batch.createdAt)}>{relativeTime(batch.createdAt)}</span>}
|
||||
value={
|
||||
<span title={formatTs(batch.createdAt)}>{relativeTime(batch.createdAt, t)}</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -245,7 +283,7 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-[var(--color-text-muted)] uppercase tracking-wider font-medium">
|
||||
Progress
|
||||
{t("batchProgress")}
|
||||
</span>
|
||||
<span className="text-[var(--color-text-muted)]">
|
||||
{completed} / {total} ({pct}%)
|
||||
@@ -262,17 +300,9 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs text-[var(--color-text-muted)]">
|
||||
<span>
|
||||
<span className="text-emerald-400 font-medium">{completed}</span> completed
|
||||
</span>
|
||||
{failed > 0 && (
|
||||
<span>
|
||||
<span className="text-red-400 font-medium">{failed}</span> failed
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
<span className="font-medium">{total - completed - failed}</span> pending
|
||||
</span>
|
||||
<span>{t("batchCompletedCount", { count: completed })}</span>
|
||||
{failed > 0 && <span>{t("batchFailedCount", { count: failed })}</span>}
|
||||
<span>{t("batchPendingCount", { count: total - completed - failed })}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -280,19 +310,19 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
||||
{/* Timestamps */}
|
||||
<div>
|
||||
<h3 className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)] mb-3">
|
||||
Timeline
|
||||
{t("batchTimeline")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 text-sm">
|
||||
{[
|
||||
{ label: "Created", ts: batch.createdAt },
|
||||
{ label: "In Progress", ts: batch.inProgressAt },
|
||||
{ label: "Finalizing", ts: batch.finalizingAt },
|
||||
{ label: "Completed", ts: batch.completedAt },
|
||||
{ label: "Failed", ts: batch.failedAt },
|
||||
{ label: "Expires", ts: batch.expiresAt },
|
||||
{ label: "Expired", ts: batch.expiredAt },
|
||||
{ label: "Cancelling", ts: batch.cancellingAt },
|
||||
{ label: "Cancelled", ts: batch.cancelledAt },
|
||||
{ label: t("batchDetailCreated"), ts: batch.createdAt },
|
||||
{ label: t("batchTimelineInProgress"), ts: batch.inProgressAt },
|
||||
{ label: t("batchTimelineFinalizing"), ts: batch.finalizingAt },
|
||||
{ label: t("batchTimelineCompleted"), ts: batch.completedAt },
|
||||
{ label: t("batchTimelineFailed"), ts: batch.failedAt },
|
||||
{ label: t("batchTimelineExpires"), ts: batch.expiresAt },
|
||||
{ label: t("batchTimelineExpired"), ts: batch.expiredAt },
|
||||
{ label: t("batchTimelineCancelling"), ts: batch.cancellingAt },
|
||||
{ label: t("batchTimelineCancelled"), ts: batch.cancelledAt },
|
||||
]
|
||||
.filter((t) => t.ts)
|
||||
.map(({ label, ts }) => (
|
||||
@@ -315,9 +345,21 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ role: "Input", fileId: batch.inputFileId, record: inputFile },
|
||||
{ role: "Output", fileId: batch.outputFileId, record: outputFile },
|
||||
{ role: "Errors", fileId: batch.errorFileId, record: errorFile },
|
||||
{
|
||||
role: t("filesListUsedByRoleInput"),
|
||||
fileId: batch.inputFileId,
|
||||
record: inputFile,
|
||||
},
|
||||
{
|
||||
role: t("filesListUsedByRoleOutput"),
|
||||
fileId: batch.outputFileId,
|
||||
record: outputFile,
|
||||
},
|
||||
{
|
||||
role: t("filesListUsedByRoleError"),
|
||||
fileId: batch.errorFileId,
|
||||
record: errorFile,
|
||||
},
|
||||
]
|
||||
.filter((f) => f.fileId)
|
||||
.map(({ role, fileId, record }) => (
|
||||
@@ -350,7 +392,7 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-muted)] hover:text-[var(--color-text-main)] transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">download</span>
|
||||
Download
|
||||
{t("filesListDownload")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -362,7 +404,7 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
||||
{batch.usage && (
|
||||
<div>
|
||||
<h3 className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)] mb-3">
|
||||
Token Usage
|
||||
{t("batchTokenUsage")}
|
||||
</h3>
|
||||
<pre className="p-3 rounded-lg bg-[var(--color-bg-alt)] border border-[var(--color-border)] text-xs font-mono text-[var(--color-text-main)] overflow-x-auto">
|
||||
{JSON.stringify(batch.usage, null, 2)}
|
||||
@@ -374,7 +416,7 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
||||
{batch.errors && (
|
||||
<div>
|
||||
<h3 className="text-[11px] uppercase tracking-wider font-medium text-red-400 mb-3">
|
||||
Errors
|
||||
{t("errors")}
|
||||
</h3>
|
||||
<pre className="p-3 rounded-lg bg-red-500/5 border border-red-500/20 text-xs font-mono text-red-300 overflow-x-auto">
|
||||
{JSON.stringify(batch.errors, null, 2)}
|
||||
@@ -386,7 +428,7 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
||||
{batch.metadata && Object.keys(batch.metadata).length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)] mb-3">
|
||||
Metadata
|
||||
{t("batchMetadata")}
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(batch.metadata).map(([k, v]) => (
|
||||
@@ -438,7 +480,7 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
||||
if (
|
||||
window.confirm(
|
||||
t("batchDetailActionRetry") +
|
||||
` (${batch.requestCountsFailed} ${t("batchActionRetry")})?`,
|
||||
` (${batch.requestCountsFailed} ${t("batchActionRetry")})?`
|
||||
)
|
||||
) {
|
||||
const result = await retry({
|
||||
|
||||
@@ -4,22 +4,24 @@ import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/shared/components";
|
||||
|
||||
function relativeTime(ts: number): string {
|
||||
type FileTranslator = ReturnType<typeof useTranslations>;
|
||||
|
||||
function relativeTime(ts: number, t: FileTranslator): string {
|
||||
const diffMs = Date.now() - ts * 1000;
|
||||
const diffSec = Math.round(diffMs / 1000);
|
||||
if (diffSec < 60) return `${diffSec}s ago`;
|
||||
if (diffSec < 60) return t("batchRelativeTimeAgo", { value: `${diffSec}s` });
|
||||
const diffMin = Math.round(diffSec / 60);
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
if (diffMin < 60) return t("batchRelativeTimeAgo", { value: `${diffMin}m` });
|
||||
const diffHr = Math.round(diffMin / 60);
|
||||
if (diffHr < 24) return `${diffHr}h ago`;
|
||||
if (diffHr < 24) return t("batchRelativeTimeAgo", { value: `${diffHr}h` });
|
||||
const diffDays = Math.round(diffHr / 24);
|
||||
return `${diffDays}d ago`;
|
||||
return t("batchRelativeTimeAgo", { value: `${diffDays}d` });
|
||||
}
|
||||
|
||||
function relativeExpiration(ts: number | null): string {
|
||||
if (!ts) return "Never";
|
||||
function relativeExpiration(ts: number | null, t: FileTranslator): string {
|
||||
if (!ts) return t("batchFilesNeverExpires");
|
||||
const diffMs = ts * 1000 - Date.now();
|
||||
if (diffMs <= 0) return "Expired";
|
||||
if (diffMs <= 0) return t("expirationBadgeExpired");
|
||||
const diffSec = Math.round(diffMs / 1000);
|
||||
if (diffSec < 60) return `${diffSec}s`;
|
||||
const diffMin = Math.round(diffSec / 60);
|
||||
@@ -55,6 +57,22 @@ interface BatchRecord {
|
||||
model?: string | null;
|
||||
}
|
||||
|
||||
const BATCH_STATUS_TRANSLATION_KEYS: Record<string, string> = {
|
||||
completed: "batchStatusCompleted",
|
||||
completed_with_failures: "batchStatusCompletedWithFailures",
|
||||
failed: "batchStatusFailed",
|
||||
in_progress: "batchStatusInProgress",
|
||||
in_progress_with_failures: "batchStatusInProgressWithFailures",
|
||||
finalizing: "batchStatusFinalizing",
|
||||
finalizing_with_failures: "batchStatusFinalizingWithFailures",
|
||||
validating: "batchStatusValidating",
|
||||
cancelling: "batchStatusCancelling",
|
||||
cancelled: "batchStatusCancelled",
|
||||
cancelled_with_failures: "batchStatusCancelledWithFailures",
|
||||
expired: "batchStatusExpired",
|
||||
expired_with_failures: "batchStatusExpiredWithFailures",
|
||||
};
|
||||
|
||||
interface FileDetailModalProps {
|
||||
file: FileRecord;
|
||||
contents: string | null;
|
||||
@@ -133,7 +151,7 @@ export default function FileDetailModal({
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-main)]">
|
||||
File Contents
|
||||
{t("batchFileContents")}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<p className="text-xs text-[var(--color-text-muted)] font-mono">{file.id}</p>
|
||||
@@ -164,7 +182,7 @@ export default function FileDetailModal({
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 p-4 rounded-xl bg-[var(--color-bg-alt)] border border-[var(--color-border)]">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
|
||||
Size
|
||||
{t("batchFilesSizeColumn")}
|
||||
</span>
|
||||
<span className="text-sm text-[var(--color-text-main)]">
|
||||
{formatBytes(file.bytes)}
|
||||
@@ -172,24 +190,24 @@ export default function FileDetailModal({
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
|
||||
Purpose
|
||||
{t("batchFilesPurpose")}
|
||||
</span>
|
||||
<span className="text-sm text-[var(--color-text-main)]">{file.purpose}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
|
||||
Created
|
||||
{t("batchDetailCreated")}
|
||||
</span>
|
||||
<span className="text-sm text-[var(--color-text-main)]">
|
||||
{createdAtTs ? relativeTime(createdAtTs) : "—"}
|
||||
{createdAtTs ? relativeTime(createdAtTs, t) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
|
||||
Expires
|
||||
{t("batchFilesExpires")}
|
||||
</span>
|
||||
<span className="text-sm text-[var(--color-text-main)]">
|
||||
{expiresAtTs ? relativeExpiration(expiresAtTs) : "Never"}
|
||||
{expiresAtTs ? relativeExpiration(expiresAtTs, t) : t("batchFilesNeverExpires")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -198,7 +216,7 @@ export default function FileDetailModal({
|
||||
{relatedBatches.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)] mb-2">
|
||||
Used by {relatedBatches.length} batch{relatedBatches.length > 1 ? "es" : ""}
|
||||
{t("batchFileUsedByCount", { count: relatedBatches.length })}
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
{relatedBatches.map((b) => (
|
||||
@@ -219,7 +237,9 @@ export default function FileDetailModal({
|
||||
: "bg-gray-500/15 text-gray-400 border-gray-500/25"
|
||||
}`}
|
||||
>
|
||||
{b.status.replaceAll("_", " ")}
|
||||
{BATCH_STATUS_TRANSLATION_KEYS[b.status]
|
||||
? t(BATCH_STATUS_TRANSLATION_KEYS[b.status])
|
||||
: b.status.replaceAll("_", " ")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -231,7 +251,7 @@ export default function FileDetailModal({
|
||||
<div className="flex-1 flex flex-col min-h-[300px]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Preview
|
||||
{t("batchFilePreview")}
|
||||
</h3>
|
||||
{contents && (
|
||||
<button
|
||||
@@ -241,7 +261,7 @@ export default function FileDetailModal({
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{copied ? "check" : "content_copy"}
|
||||
</span>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
{copied ? t("copied") : t("copy")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -259,7 +279,7 @@ export default function FileDetailModal({
|
||||
{isTruncated && (
|
||||
<div className="mt-3 p-3 bg-yellow-500/10 border border-yellow-500/25 rounded-lg text-xs text-yellow-400 flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[16px]">warning</span>
|
||||
Showing first 1000 lines ({lineCount} total lines)
|
||||
{t("batchFilePreviewTruncated", { shown: 1000, total: lineCount })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -281,7 +301,7 @@ export default function FileDetailModal({
|
||||
className="flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-lg bg-[var(--color-accent)] text-white hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">download</span>
|
||||
Download Full File
|
||||
{t("batchFileDownloadFull")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
16
src/app/(dashboard)/dashboard/chaos/chaosI18n.ts
Normal file
16
src/app/(dashboard)/dashboard/chaos/chaosI18n.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type ChaosTranslator = ((
|
||||
key: string,
|
||||
values?: Record<string, string | number>
|
||||
) => string) & {
|
||||
has?: (key: string) => boolean;
|
||||
};
|
||||
|
||||
export function chaosText(
|
||||
t: ChaosTranslator,
|
||||
key: string,
|
||||
fallback: string,
|
||||
values?: Record<string, string | number>
|
||||
): string {
|
||||
if (typeof t.has !== "function" || !t.has(key)) return fallback;
|
||||
return values ? t(key, values) : t(key);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { chaosText, type ChaosTranslator } from "../chaosI18n";
|
||||
|
||||
/**
|
||||
* Save/Reset + Test-run action buttons for the Chaos Mode config page.
|
||||
@@ -22,7 +23,7 @@ export function ChaosConfigActionsBar({
|
||||
onReset: () => void;
|
||||
onTest: () => void;
|
||||
}) {
|
||||
const t = useTranslations("chaosConfig");
|
||||
const t = useTranslations("chaosConfig") as ChaosTranslator;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -66,7 +67,7 @@ export function ChaosConfigActionsBar({
|
||||
) : (
|
||||
<span className="material-symbols-outlined text-[16px]">play_arrow</span>
|
||||
)}
|
||||
{testing ? "Running..." : t("testButton")}
|
||||
{testing ? chaosText(t, "running", "Running...") : t("testButton")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { chaosText, type ChaosTranslator } from "../chaosI18n";
|
||||
|
||||
export interface ChaosModelResult {
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
@@ -26,14 +29,24 @@ export interface ChaosTestResult {
|
||||
* complexity/size ratchet (config/quality/complexity-baseline.json).
|
||||
*/
|
||||
export function ChaosTestResultsPanel({ result }: { result: ChaosTestResult }) {
|
||||
const t = useTranslations("chaosConfig") as ChaosTranslator;
|
||||
const resultsTitle = chaosText(
|
||||
t,
|
||||
"testResults",
|
||||
`Test Results — ${result.mode} mode (${result.totalProviders} providers)`,
|
||||
{ mode: result.mode, count: result.totalProviders }
|
||||
);
|
||||
const startedLabel = chaosText(
|
||||
t,
|
||||
"started",
|
||||
`Started: ${new Date(result.startedAt).toLocaleTimeString()}`,
|
||||
{ time: new Date(result.startedAt).toLocaleTimeString() }
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-3 rounded-lg border border-border bg-surface/40 space-y-3">
|
||||
<h3 className="text-sm font-bold text-text-main">
|
||||
Test Results — {result.mode} mode ({result.totalProviders} providers)
|
||||
</h3>
|
||||
<div className="text-xs text-text-muted">
|
||||
Started: {new Date(result.startedAt).toLocaleTimeString()}
|
||||
</div>
|
||||
<h3 className="text-sm font-bold text-text-main">{resultsTitle}</h3>
|
||||
<div className="text-xs text-text-muted">{startedLabel}</div>
|
||||
{result.models.map((model, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* /dashboard/chaos/page.tsx — Chaos Mode Configuration
|
||||
*/
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import ChaosConfigPageClient from "./ChaosConfigPageClient";
|
||||
|
||||
export const metadata = {
|
||||
title: "Chaos Mode — OmniRoute",
|
||||
};
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("chaosConfig");
|
||||
return { title: `${t("pageTitle")} — OmniRoute` };
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return <ChaosConfigPageClient />;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { chaosText, type ChaosTranslator } from "./chaosI18n";
|
||||
import type { ChaosPageConfig, ChaosPageMessage } from "./chaosPageTypes";
|
||||
|
||||
/**
|
||||
@@ -13,7 +14,7 @@ export function useChaosConfigPersistence(
|
||||
config: ChaosPageConfig,
|
||||
setConfig: Dispatch<SetStateAction<ChaosPageConfig>>
|
||||
) {
|
||||
const t = useTranslations("chaosConfig");
|
||||
const t = useTranslations("chaosConfig") as ChaosTranslator;
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<ChaosPageMessage>(null);
|
||||
|
||||
@@ -49,17 +50,26 @@ export function useChaosConfigPersistence(
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setConfig(data.config);
|
||||
setMessage({ type: "success", text: "Config reset to defaults" });
|
||||
setMessage({
|
||||
type: "success",
|
||||
text: chaosText(t, "resetSuccess", "Config reset to defaults"),
|
||||
});
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({ error: "Reset failed" }));
|
||||
setMessage({ type: "error", text: err.error || "Reset failed" });
|
||||
const err = await res.json().catch(() => ({ error: null }));
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: err.error || chaosText(t, "resetFailed", "Reset failed"),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: "error", text: "Failed to reset config" });
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: chaosText(t, "resetFailed", "Failed to reset config"),
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [setConfig]);
|
||||
}, [setConfig, t]);
|
||||
|
||||
return { t, saving, message, setMessage, saveConfig, resetConfig };
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { chaosText, type ChaosTranslator } from "./chaosI18n";
|
||||
import type { ChaosTestResult } from "./components/ChaosTestResultsPanel";
|
||||
import type { ChaosPageConfig, ChaosPageMessage } from "./chaosPageTypes";
|
||||
|
||||
@@ -10,8 +11,11 @@ import type { ChaosPageConfig, ChaosPageMessage } from "./chaosPageTypes";
|
||||
* of the page component to keep it under the complexity/size ratchet
|
||||
* (config/quality/complexity-baseline.json).
|
||||
*/
|
||||
export function useChaosTestRun(config: ChaosPageConfig, setMessage: (message: ChaosPageMessage) => void) {
|
||||
const t = useTranslations("chaosConfig");
|
||||
export function useChaosTestRun(
|
||||
config: ChaosPageConfig,
|
||||
setMessage: (message: ChaosPageMessage) => void
|
||||
) {
|
||||
const t = useTranslations("chaosConfig") as ChaosTranslator;
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<ChaosTestResult | null>(null);
|
||||
|
||||
@@ -34,11 +38,17 @@ export function useChaosTestRun(config: ChaosPageConfig, setMessage: (message: C
|
||||
const data: ChaosTestResult = await res.json();
|
||||
setTestResult(data);
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({ error: "Unknown error" }));
|
||||
setMessage({ type: "error", text: err.error || "Test failed" });
|
||||
const err = await res.json().catch(() => ({ error: null }));
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: err.error || chaosText(t, "testFailed", "Test failed"),
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
setMessage({ type: "error", text: err.message || "Test failed" });
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: err.message || chaosText(t, "testFailed", "Test failed"),
|
||||
});
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, Button } from "@/shared/components";
|
||||
|
||||
interface ToolState {
|
||||
@@ -24,6 +25,7 @@ interface UpdateInfo {
|
||||
}
|
||||
|
||||
export default function CliproxyapiToolCard({ isExpanded = false, onToggle = () => {} }) {
|
||||
const t = useTranslations("cliTools");
|
||||
const [toolState, setToolState] = useState<ToolState | null>(null);
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
@@ -71,7 +73,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: data.message || `${action} succeeded` });
|
||||
setMessage({ type: "success", text: data.message || t("cliproxyapiActionSucceeded") });
|
||||
await fetchStatus();
|
||||
if (action === "install" || action === "restart") await fetchUpdateInfo();
|
||||
} else {
|
||||
@@ -79,11 +81,14 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
||||
type: "error",
|
||||
text:
|
||||
(typeof data.error === "string" ? data.error : data.error?.message) ||
|
||||
`${action} failed`,
|
||||
t("cliproxyapiActionFailed"),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage({ type: "error", text: err instanceof Error ? err.message : "Request failed" });
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: err instanceof Error ? err.message : t("cliproxyapiRequestFailed"),
|
||||
});
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
@@ -93,14 +98,26 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
||||
if (!toolState) return null;
|
||||
const s = toolState.status;
|
||||
const map: Record<string, { label: string; color: string }> = {
|
||||
running: { label: "Running", color: "bg-green-500/10 text-green-600 dark:text-green-400" },
|
||||
stopped: { label: "Stopped", color: "bg-zinc-500/10 text-zinc-500 dark:text-zinc-400" },
|
||||
running: {
|
||||
label: t("cliproxyapiStatusRunning"),
|
||||
color: "bg-green-500/10 text-green-600 dark:text-green-400",
|
||||
},
|
||||
stopped: {
|
||||
label: t("cliproxyapiStatusStopped"),
|
||||
color: "bg-zinc-500/10 text-zinc-500 dark:text-zinc-400",
|
||||
},
|
||||
not_installed: {
|
||||
label: "Not Installed",
|
||||
label: t("cliproxyapiStatusNotInstalled"),
|
||||
color: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400",
|
||||
},
|
||||
installed: { label: "Installed", color: "bg-blue-500/10 text-blue-600 dark:text-blue-400" },
|
||||
error: { label: "Error", color: "bg-red-500/10 text-red-600 dark:text-red-400" },
|
||||
installed: {
|
||||
label: t("cliproxyapiStatusInstalled"),
|
||||
color: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
|
||||
},
|
||||
error: {
|
||||
label: t("cliproxyapiStatusError"),
|
||||
color: "bg-red-500/10 text-red-600 dark:text-red-400",
|
||||
},
|
||||
};
|
||||
const badge = map[s] || map.not_installed;
|
||||
return (
|
||||
@@ -125,9 +142,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
||||
<h3 className="font-medium text-sm">CLIProxyAPI</h3>
|
||||
{statusBadge()}
|
||||
</div>
|
||||
<p className="text-xs text-text-muted truncate">
|
||||
Upstream proxy fallback (Go-based OAuth)
|
||||
</p>
|
||||
<p className="text-xs text-text-muted truncate">{t("cliproxyapiDescription")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
@@ -161,7 +176,10 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
||||
system_update
|
||||
</span>
|
||||
<span className="text-sm text-yellow-700 dark:text-yellow-300">
|
||||
Update available: v{updateInfo.current} → v{updateInfo.latest}
|
||||
{t("cliproxyapiUpdateAvailable", {
|
||||
current: updateInfo.current,
|
||||
latest: updateInfo.latest,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
@@ -170,32 +188,34 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
||||
onClick={() => apiCall("install", { version: updateInfo.latest })}
|
||||
loading={loading === "install"}
|
||||
>
|
||||
Update
|
||||
{t("cliproxyapiUpdate")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="p-3 rounded-lg bg-bg-secondary">
|
||||
<p className="text-xs text-text-muted mb-1">Version</p>
|
||||
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiVersion")}</p>
|
||||
<p className="text-sm font-medium">
|
||||
{toolState?.installedVersion ? `v${toolState.installedVersion}` : "Not installed"}
|
||||
{toolState?.installedVersion
|
||||
? `v${toolState.installedVersion}`
|
||||
: t("cliproxyapiNotInstalledValue")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-bg-secondary">
|
||||
<p className="text-xs text-text-muted mb-1">Health</p>
|
||||
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiHealth")}</p>
|
||||
<p
|
||||
className={`text-sm font-medium ${toolState?.healthStatus === "healthy" ? "text-green-600 dark:text-green-400" : toolState?.healthStatus === "unhealthy" ? "text-red-600 dark:text-red-400" : "text-text-muted"}`}
|
||||
>
|
||||
{toolState?.healthStatus === "healthy"
|
||||
? `Healthy`
|
||||
? t("cliproxyapiHealthy")
|
||||
: toolState?.healthStatus === "unhealthy"
|
||||
? "Unhealthy"
|
||||
: "Unknown"}
|
||||
? t("cliproxyapiUnhealthy")
|
||||
: t("cliproxyapiUnknown")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-bg-secondary">
|
||||
<p className="text-xs text-text-muted mb-1">Port</p>
|
||||
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiPort")}</p>
|
||||
<p className="text-sm font-mono">{toolState?.port || 8317}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -209,7 +229,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
||||
loading={loading === "install"}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">download</span>
|
||||
Install
|
||||
{t("cliproxyapiInstall")}
|
||||
</Button>
|
||||
)}
|
||||
{toolState?.status === "running" ? (
|
||||
@@ -220,7 +240,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
||||
loading={loading === "stop"}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">stop</span>
|
||||
Stop
|
||||
{t("cliproxyapiStop")}
|
||||
</Button>
|
||||
) : toolState?.installedVersion ? (
|
||||
<Button
|
||||
@@ -230,7 +250,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
||||
loading={loading === "start"}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">play_arrow</span>
|
||||
Start
|
||||
{t("cliproxyapiStart")}
|
||||
</Button>
|
||||
) : null}
|
||||
{toolState?.status === "running" && (
|
||||
@@ -241,7 +261,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
||||
loading={loading === "restart"}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">restart_alt</span>
|
||||
Restart
|
||||
{t("cliproxyapiRestart")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
@@ -251,7 +271,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
||||
loading={loading === "check"}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">sync</span>
|
||||
Check Updates
|
||||
{t("cliproxyapiCheckUpdates")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -133,8 +133,8 @@ export default function IntelligentComboPanel({
|
||||
<code className="rounded bg-black/5 dark:bg-white/5 px-2 py-1 text-text-main">
|
||||
{combo?.name}
|
||||
</code>
|
||||
<span>{allCombos.length} intelligent combo(s)</span>
|
||||
<span>{providerScopeCount} providers in scope</span>
|
||||
<span>{t("intelligentComboCount", { count: allCombos.length })}</span>
|
||||
<span>{t("providersInScope", { count: providerScopeCount })}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -161,7 +161,7 @@ export default function IntelligentComboPanel({
|
||||
</div>
|
||||
<div className="rounded-lg bg-black/5 dark:bg-white/5 px-3 py-2 text-right">
|
||||
<p className="text-[10px] uppercase tracking-wide text-text-muted">
|
||||
Candidate Pool
|
||||
{getI18nOrFallback(t, "candidatePoolLabel", "Candidate Pool")}
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-text-main">{providerScopeCount}</p>
|
||||
</div>
|
||||
@@ -183,7 +183,12 @@ export default function IntelligentComboPanel({
|
||||
</p>
|
||||
</div>
|
||||
{savingModePack && (
|
||||
<span className="text-[11px] text-text-muted">Saving {savingModePack}…</span>
|
||||
<span className="text-[11px] text-text-muted">
|
||||
{getI18nOrFallback(t, "savingModePack", "Saving {pack}…").replace(
|
||||
"{pack}",
|
||||
savingModePack
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -308,7 +313,7 @@ export default function IntelligentComboPanel({
|
||||
</div>
|
||||
<div className="rounded-lg border border-black/8 bg-white/60 p-3 dark:border-white/8 dark:bg-white/[0.03]">
|
||||
<p className="text-[11px] uppercase tracking-wide text-text-muted">
|
||||
Exploration Rate
|
||||
{getI18nOrFallback(t, "explorationRateLabel", "Exploration Rate")}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-semibold text-text-main">
|
||||
{Math.round(normalizedConfig.explorationRate * 100)}%
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function CombosError({
|
||||
error: _error,
|
||||
reset,
|
||||
@@ -7,6 +9,8 @@ export default function CombosError({
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const t = useTranslations("combos");
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center min-h-[400px]"
|
||||
@@ -14,14 +18,10 @@ export default function CombosError({
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div className="text-center space-y-4">
|
||||
<h2 className="text-xl font-semibold text-red-600 dark:text-red-400">
|
||||
Failed to load combos
|
||||
</h2>
|
||||
<p className="text-text-muted max-w-md">
|
||||
We could not load combo data right now. Check your connection and try again.
|
||||
</p>
|
||||
<h2 className="text-xl font-semibold text-red-600 dark:text-red-400">{t("errorTitle")}</h2>
|
||||
<p className="text-text-muted max-w-md">{t("errorDescription")}</p>
|
||||
{_error?.digest && (
|
||||
<p className="text-xs text-text-muted font-mono">Error ID: {_error.digest}</p>
|
||||
<p className="text-xs text-text-muted font-mono">{t("errorId", { id: _error.digest })}</p>
|
||||
)}
|
||||
{process.env.NODE_ENV === "development" && _error?.message && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 font-mono">{_error.message}</p>
|
||||
@@ -30,7 +30,7 @@ export default function CombosError({
|
||||
onClick={reset}
|
||||
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors focus:outline-2 focus:outline-offset-2 focus:outline-primary"
|
||||
>
|
||||
Try Again
|
||||
{t("errorRetry")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export const metadata = {
|
||||
title: "Compression",
|
||||
description: "Configure context compression settings to reduce token usage and costs.",
|
||||
};
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("metadata");
|
||||
return {
|
||||
title: t("compressionTitle"),
|
||||
description: t("compressionDescription"),
|
||||
};
|
||||
}
|
||||
|
||||
export default function CompressionPage() {
|
||||
redirect("/dashboard/context/caveman");
|
||||
|
||||
@@ -534,7 +534,10 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
if (!cloudConfigured) {
|
||||
setCloudStatus({
|
||||
type: "warning",
|
||||
message: "Cloud sync is not configured on this instance.",
|
||||
message: translateOrFallback(
|
||||
"cloudSyncNotConfigured",
|
||||
"Cloud sync is not configured on this instance."
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1822,7 +1825,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="material-symbols-outlined text-sm text-primary">hub</span>
|
||||
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||
{t("categoryCore") || "Core APIs"}
|
||||
{t("categoryCore")}
|
||||
</h3>
|
||||
<div className="flex-1 h-px bg-border/50" />
|
||||
</div>
|
||||
@@ -1843,7 +1846,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
icon="code"
|
||||
iconColor="text-indigo-500"
|
||||
iconBg="bg-indigo-500/10"
|
||||
title={t("responses") || "Responses API"}
|
||||
title={t("responses")}
|
||||
path="/v1/responses"
|
||||
models={endpointData.chat}
|
||||
copy={copy}
|
||||
@@ -1855,7 +1858,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
icon="text_fields"
|
||||
iconColor="text-orange-500"
|
||||
iconBg="bg-orange-500/10"
|
||||
title={t("completionsLegacy") || "Completions (Legacy)"}
|
||||
title={t("completionsLegacy")}
|
||||
path="/v1/completions"
|
||||
models={endpointData.chat}
|
||||
copy={copy}
|
||||
@@ -1867,7 +1870,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
icon="psychology"
|
||||
iconColor="text-violet-500"
|
||||
iconBg="bg-violet-500/10"
|
||||
title={t("messagesApi") || "Messages"}
|
||||
title={t("messagesApi")}
|
||||
path="/v1/messages"
|
||||
models={null}
|
||||
badge="Anthropic"
|
||||
@@ -1883,7 +1886,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="material-symbols-outlined text-sm text-purple-400">perm_media</span>
|
||||
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||
{t("categoryMedia") || "Media & Multi-Modal"}
|
||||
{t("categoryMedia")}
|
||||
</h3>
|
||||
<div className="flex-1 h-px bg-border/50" />
|
||||
</div>
|
||||
@@ -1916,7 +1919,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
icon="edit_square"
|
||||
iconColor="text-violet-500"
|
||||
iconBg="bg-violet-500/10"
|
||||
title={t("imageEdits") || "Image Edits"}
|
||||
title={t("imageEdits")}
|
||||
path="/v1/images/edits"
|
||||
models={endpointData.images}
|
||||
copy={copy}
|
||||
@@ -1952,7 +1955,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
icon="music_note"
|
||||
iconColor="text-fuchsia-500"
|
||||
iconBg="bg-fuchsia-500/10"
|
||||
title={t("musicGeneration") || "Music Generation"}
|
||||
title={t("musicGeneration")}
|
||||
path="/v1/music/generations"
|
||||
models={endpointData.music}
|
||||
copy={copy}
|
||||
@@ -1964,7 +1967,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
icon="videocam"
|
||||
iconColor="text-red-500"
|
||||
iconBg="bg-red-500/10"
|
||||
title={t("videoGeneration") || "Video Generation"}
|
||||
title={t("videoGeneration")}
|
||||
path="/v1/videos/generations"
|
||||
models={endpointData.video}
|
||||
copy={copy}
|
||||
@@ -1983,7 +1986,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
travel_explore
|
||||
</span>
|
||||
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||
{t("categorySearch") || "Search & Discovery"}
|
||||
{t("categorySearch")}
|
||||
</h3>
|
||||
<div className="flex-1 h-px bg-border/50" />
|
||||
</div>
|
||||
@@ -1992,7 +1995,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
icon="search"
|
||||
iconColor="text-cyan-500"
|
||||
iconBg="bg-cyan-500/10"
|
||||
title={t("webSearch") || "Web Search"}
|
||||
title={t("webSearch")}
|
||||
path="/v1/search"
|
||||
models={searchProviders.map((p) => ({ id: p.id, owned_by: p.id, type: "search" }))}
|
||||
copy={copy}
|
||||
@@ -2008,7 +2011,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="material-symbols-outlined text-sm text-amber-400">build</span>
|
||||
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||
{t("categoryUtility") || "Utility & Management"}
|
||||
{t("categoryUtility")}
|
||||
</h3>
|
||||
<div className="flex-1 h-px bg-border/50" />
|
||||
</div>
|
||||
@@ -2041,7 +2044,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
icon="view_list"
|
||||
iconColor="text-teal-500"
|
||||
iconBg="bg-teal-500/10"
|
||||
title={t("batchApi") || "Batch API"}
|
||||
title={t("batchApi")}
|
||||
path="/v1/batches"
|
||||
models={null}
|
||||
badge="OpenAI"
|
||||
@@ -2053,7 +2056,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
icon="folder"
|
||||
iconColor="text-yellow-500"
|
||||
iconBg="bg-yellow-500/10"
|
||||
title={t("filesApi") || "Files API"}
|
||||
title={t("filesApi")}
|
||||
path="/v1/files"
|
||||
models={null}
|
||||
copy={copy}
|
||||
@@ -2064,7 +2067,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
icon="list"
|
||||
iconColor="text-teal-500"
|
||||
iconBg="bg-teal-500/10"
|
||||
title={t("listModels") || "List Models"}
|
||||
title={t("listModels")}
|
||||
path="/v1/models"
|
||||
models={null}
|
||||
copy={copy}
|
||||
|
||||
@@ -429,7 +429,7 @@ export default function A2ADashboardPage() {
|
||||
<th className="text-left py-2 pr-2">{t("tableTask")}</th>
|
||||
<th className="text-left py-2 pr-2">{t("tableSkill")}</th>
|
||||
<th className="text-left py-2 pr-2">{t("tableState")}</th>
|
||||
<th className="text-left py-2 pr-2">{t("tablePhase") || "FSM Status"}</th>
|
||||
<th className="text-left py-2 pr-2">{t("tablePhase")}</th>
|
||||
<th className="text-left py-2 pr-2">{t("tableUpdated")}</th>
|
||||
<th className="text-left py-2">{t("tableActions")}</th>
|
||||
</tr>
|
||||
|
||||
@@ -12,6 +12,18 @@ export default function NotionSourceCard() {
|
||||
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const translateOrFallback = (key: string, fallback: string) => {
|
||||
try {
|
||||
const translated = t(key as never);
|
||||
if (!translated || translated === key || translated === `endpoint.${key}`) {
|
||||
return fallback;
|
||||
}
|
||||
return translated;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchConfig = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/settings/notion");
|
||||
@@ -37,7 +49,10 @@ export default function NotionSourceCard() {
|
||||
|
||||
const handleSaveToken = async () => {
|
||||
if (!token.trim()) {
|
||||
setMessage({ type: "error", text: "Please enter a Notion integration token" });
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: translateOrFallback("notionEnterToken", "Please enter a Notion integration token"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
@@ -53,11 +68,20 @@ export default function NotionSourceCard() {
|
||||
setConnected(true);
|
||||
setMessage({ type: "success", text: data.message });
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.error ?? "Failed to connect" });
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: data.error ?? translateOrFallback("notionConnectFailed", "Failed to connect"),
|
||||
});
|
||||
setConnected(false);
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage({ type: "error", text: err instanceof Error ? err.message : "Connection failed" });
|
||||
setMessage({
|
||||
type: "error",
|
||||
text:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: translateOrFallback("notionConnectionFailed", "Connection failed"),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -74,10 +98,19 @@ export default function NotionSourceCard() {
|
||||
setToken("");
|
||||
setMessage({ type: "success", text: data.message });
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.error ?? "Failed to disconnect" });
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: data.error ?? translateOrFallback("notionDisconnectFailed", "Failed to disconnect"),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage({ type: "error", text: err instanceof Error ? err.message : "Disconnect failed" });
|
||||
setMessage({
|
||||
type: "error",
|
||||
text:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: translateOrFallback("notionDisconnectFailed", "Disconnect failed"),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -97,11 +130,16 @@ export default function NotionSourceCard() {
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-sm">Notion</span>
|
||||
<Badge variant={connected ? "success" : "default"}>
|
||||
{connected ? "Connected" : "Not connected"}
|
||||
{connected
|
||||
? translateOrFallback("notionConnected", "Connected")
|
||||
: translateOrFallback("notionNotConnected", "Not connected")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
Search, read, query, and write to Notion through routed AI models
|
||||
{translateOrFallback(
|
||||
"notionDescription",
|
||||
"Search, read, query, and write to Notion through routed AI models"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
@@ -131,7 +169,10 @@ export default function NotionSourceCard() {
|
||||
{!connected ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs text-text-muted font-medium">
|
||||
Notion Internal Integration Token
|
||||
{translateOrFallback(
|
||||
"notionIntegrationToken",
|
||||
"Notion Internal Integration Token"
|
||||
)}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
@@ -143,11 +184,14 @@ export default function NotionSourceCard() {
|
||||
className="font-mono text-sm flex-1"
|
||||
/>
|
||||
<Button onClick={handleSaveToken} loading={busy} variant="primary" size="sm">
|
||||
Connect
|
||||
{translateOrFallback("notionConnect", "Connect")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-text-muted">
|
||||
Create an Internal Integration at{" "}
|
||||
{translateOrFallback(
|
||||
"notionIntegrationHelp",
|
||||
"Create an Internal Integration at"
|
||||
)}{" "}
|
||||
<code className="text-primary font-mono bg-surface/80 px-1 rounded">
|
||||
https://www.notion.so/profile/integrations
|
||||
</code>
|
||||
@@ -156,7 +200,10 @@ export default function NotionSourceCard() {
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-muted flex-1">
|
||||
Token configured. Notion tools are available via MCP.
|
||||
{translateOrFallback(
|
||||
"notionTokenConfigured",
|
||||
"Token configured. Notion tools are available via MCP."
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
onClick={handleDisconnect}
|
||||
@@ -165,7 +212,7 @@ export default function NotionSourceCard() {
|
||||
size="sm"
|
||||
className="border-red-500/30! text-red-400! hover:bg-red-500/10!"
|
||||
>
|
||||
Disconnect
|
||||
{translateOrFallback("notionDisconnect", "Disconnect")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -15,11 +15,7 @@ import {
|
||||
getCodexEffectiveServiceTier,
|
||||
type CodexGlobalServiceMode,
|
||||
} from "@/lib/providers/codexFastTier";
|
||||
import {
|
||||
normalizeCodexLimitPolicy,
|
||||
providerText,
|
||||
ERROR_TYPE_LABELS,
|
||||
} from "../providerPageHelpers";
|
||||
import { normalizeCodexLimitPolicy, providerText, ERROR_TYPE_LABELS } from "../providerPageHelpers";
|
||||
import { getCodexPlanLabel } from "../codexPlanLabel";
|
||||
import ProviderQuotaVisibilityToggle from "./ProviderQuotaVisibilityToggle";
|
||||
|
||||
@@ -245,7 +241,7 @@ function getStatusPresentation(
|
||||
if (errorType === "account_deactivated") {
|
||||
return {
|
||||
statusVariant: "error",
|
||||
statusLabel: t("statusDeactivated", "Deactivated"),
|
||||
statusLabel: providerText(t, "statusDeactivated", "Deactivated"),
|
||||
errorType,
|
||||
errorBadge,
|
||||
errorTextClass: "text-red-600 font-bold",
|
||||
@@ -300,7 +296,7 @@ function getStatusPresentation(
|
||||
if (errorType === "banned") {
|
||||
return {
|
||||
statusVariant: "error",
|
||||
statusLabel: t("statusBanned", "Banned (403)"),
|
||||
statusLabel: providerText(t, "statusBanned", "Banned (403)"),
|
||||
errorType,
|
||||
errorBadge,
|
||||
errorTextClass: "text-red-600 font-bold",
|
||||
@@ -310,7 +306,7 @@ function getStatusPresentation(
|
||||
if (errorType === "credits_exhausted") {
|
||||
return {
|
||||
statusVariant: "warning",
|
||||
statusLabel: t("statusCreditsExhausted", "Out of Credits"),
|
||||
statusLabel: providerText(t, "statusCreditsExhausted", "Out of Credits"),
|
||||
errorType,
|
||||
errorBadge,
|
||||
errorTextClass: "text-amber-500",
|
||||
@@ -391,22 +387,10 @@ export default function ConnectionRow({
|
||||
t("oauthAccount")
|
||||
)
|
||||
: connection.name;
|
||||
const applyCodexAuthLabel =
|
||||
typeof t.has === "function" && t.has("applyCodexAuthLocal")
|
||||
? t("applyCodexAuthLocal")
|
||||
: "Apply auth";
|
||||
const exportCodexAuthLabel =
|
||||
typeof t.has === "function" && t.has("exportCodexAuthFile")
|
||||
? t("exportCodexAuthFile")
|
||||
: "Export auth";
|
||||
const applyClaudeAuthLabel =
|
||||
typeof t.has === "function" && t.has("applyClaudeAuthLocal")
|
||||
? t("applyClaudeAuthLocal")
|
||||
: "Apply auth";
|
||||
const exportClaudeAuthLabel =
|
||||
typeof t.has === "function" && t.has("exportClaudeAuthFile")
|
||||
? t("exportClaudeAuthFile")
|
||||
: "Export auth";
|
||||
const applyCodexAuthLabel = providerText(t, "applyCodexAuthLocal", "Apply auth");
|
||||
const exportCodexAuthLabel = providerText(t, "exportCodexAuthFile", "Export auth");
|
||||
const applyClaudeAuthLabel = providerText(t, "applyClaudeAuthLocal", "Apply auth");
|
||||
const exportClaudeAuthLabel = providerText(t, "exportClaudeAuthFile", "Export auth");
|
||||
// Use useState + useEffect for impure Date.now() to avoid calling during render
|
||||
const [isCooldown, setIsCooldown] = useState(false);
|
||||
// T12: token expiry status — lazy init avoids calling Date.now() during render;
|
||||
|
||||
@@ -22,8 +22,10 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { formatResetCountdown } from "@/shared/utils/formatting";
|
||||
import type { ConnectionRowConnection } from "./ConnectionRow";
|
||||
import { providerText } from "../providerPageHelpers";
|
||||
|
||||
export interface CoolingConnectionsPanelProps {
|
||||
readonly connections: readonly ConnectionRowConnection[];
|
||||
@@ -37,6 +39,7 @@ function isCoolingNow(connection: ConnectionRowConnection, now: number): boolean
|
||||
|
||||
export default function CoolingConnectionsPanel(props: CoolingConnectionsPanelProps) {
|
||||
const { connections } = props;
|
||||
const t = useTranslations("providers");
|
||||
// Tick once per second so the human-readable countdown updates.
|
||||
const [now, setNow] = useState<number>(() => Date.now());
|
||||
useEffect(() => {
|
||||
@@ -58,12 +61,17 @@ export default function CoolingConnectionsPanel(props: CoolingConnectionsPanelPr
|
||||
className="inline-block h-2 w-2 animate-pulse rounded-full bg-amber-500"
|
||||
/>
|
||||
<h3 className="text-sm font-medium text-amber-700 dark:text-amber-300">
|
||||
Currently cooling ({cooling.length})
|
||||
{providerText(t, "coolingConnectionsTitle", "Currently cooling ({count})", {
|
||||
count: cooling.length,
|
||||
})}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
These connections returned a 429 (rate-limit) on their last request. OmniRoute will skip
|
||||
them until the timer expires — no manual disable required.
|
||||
{providerText(
|
||||
t,
|
||||
"coolingConnectionsDescription",
|
||||
"These connections returned a 429 (rate-limit) on their last request. OmniRoute will skip them until the timer expires — no manual disable required."
|
||||
)}
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{cooling.map((c) => {
|
||||
@@ -72,7 +80,9 @@ export default function CoolingConnectionsPanel(props: CoolingConnectionsPanelPr
|
||||
c.displayName ||
|
||||
c.name ||
|
||||
c.email ||
|
||||
(c.id ? `connection ${c.id.slice(0, 8)}` : "connection");
|
||||
(c.id
|
||||
? `${providerText(t, "connectionFallback", "connection")} ${c.id.slice(0, 8)}`
|
||||
: providerText(t, "connectionFallback", "connection"));
|
||||
return (
|
||||
<li
|
||||
key={c.id ?? label}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
effectivePreserveForProtocol,
|
||||
effectiveUpstreamHeadersForProtocol,
|
||||
formatProviderModelsErrorResponse,
|
||||
providerText,
|
||||
targetFormatBadgeI18nKey,
|
||||
type CompatModelRow,
|
||||
type CompatByProtocolMap,
|
||||
@@ -285,17 +286,32 @@ export default function CustomModelsSection({
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = await formatProviderModelsErrorResponse(res);
|
||||
throw new Error(detail || "Failed to save model endpoint settings");
|
||||
throw new Error(
|
||||
detail ||
|
||||
providerText(
|
||||
t,
|
||||
"failedSaveModelEndpointSettings",
|
||||
"Failed to save model endpoint settings"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await fetchCustomModels();
|
||||
onModelsChanged?.();
|
||||
notify.success("Saved model endpoint settings");
|
||||
notify.success(
|
||||
providerText(t, "savedModelEndpointSettings", "Saved model endpoint settings")
|
||||
);
|
||||
cancelEdit();
|
||||
} catch (e) {
|
||||
console.error("Failed to save custom model:", e);
|
||||
notify.error(
|
||||
e instanceof Error && e.message ? e.message : "Failed to save model endpoint settings"
|
||||
e instanceof Error && e.message
|
||||
? e.message
|
||||
: providerText(
|
||||
t,
|
||||
"failedSaveModelEndpointSettings",
|
||||
"Failed to save model endpoint settings"
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
setSavingModelId(null);
|
||||
@@ -305,7 +321,9 @@ export default function CustomModelsSection({
|
||||
const saveEdit = async (modelId: string) => {
|
||||
if (!editingModelId || editingModelId !== modelId) return;
|
||||
if (!editingEndpoints.length) {
|
||||
notify.error("Select at least one supported endpoint");
|
||||
notify.error(
|
||||
providerText(t, "selectSupportedEndpoint", "Select at least one supported endpoint")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -429,7 +447,7 @@ export default function CustomModelsSection({
|
||||
: ep === "embeddings"
|
||||
? `📐 ${t("supportedEndpointEmbeddings")}`
|
||||
: ep === "rerank"
|
||||
? "Rerank"
|
||||
? providerText(t, "rerankEndpoint", "Rerank")
|
||||
: ep === "images"
|
||||
? `🖼️ ${t("supportedEndpointImages")}`
|
||||
: `🔊 ${t("supportedEndpointAudio")}`}
|
||||
@@ -661,7 +679,7 @@ export default function CustomModelsSection({
|
||||
: ep === "embeddings"
|
||||
? `📐 ${t("supportedEndpointEmbeddings")}`
|
||||
: ep === "rerank"
|
||||
? "Rerank"
|
||||
? providerText(t, "rerankEndpoint", "Rerank")
|
||||
: ep === "images"
|
||||
? `🖼️ ${t("supportedEndpointImages")}`
|
||||
: `🔊 ${t("supportedEndpointAudio")}`}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Modal } from "@/shared/components";
|
||||
import type { ProviderMessageTranslator } from "../providerPageHelpers";
|
||||
import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers";
|
||||
|
||||
type KimiCodeAuthMethodModalProps = {
|
||||
isOpen: boolean;
|
||||
@@ -50,7 +50,9 @@ export default function KimiCodeAuthMethodModal({
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="material-symbols-outlined mt-0.5 text-primary">key</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="mb-1 font-semibold">Kimi Code API Key</h3>
|
||||
<h3 className="mb-1 font-semibold">
|
||||
{providerText(t, "kimiCodeApiKeyLabel", "Kimi Code API Key")}
|
||||
</h3>
|
||||
<p className="text-sm text-text-muted">{t("apiKeySecure")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
UPSTREAM_HEADERS_UI_MAX,
|
||||
headerRowsToRecord,
|
||||
compatProtocolLabelKey,
|
||||
providerText,
|
||||
type HeaderDraftRow,
|
||||
} from "../providerPageHelpers";
|
||||
|
||||
@@ -353,7 +354,7 @@ export default function ModelCompatPopover({
|
||||
{/* Param filters — model-level block/allow (#6625) */}
|
||||
<div className="mt-4 space-y-2.5">
|
||||
<label className="block text-[11px] font-semibold text-text-main">
|
||||
{t("compatParamFiltersLabel") ?? "Param Filters"}
|
||||
{providerText(t, "compatParamFiltersLabel", "Param Filters")}
|
||||
</label>
|
||||
<div>
|
||||
<input
|
||||
@@ -369,7 +370,11 @@ export default function ModelCompatPopover({
|
||||
className="mb-1 w-full rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-[11px] font-mono text-text-main placeholder:text-text-muted focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 dark:border-zinc-600 dark:bg-zinc-900"
|
||||
/>
|
||||
<p className="text-[10px] text-text-muted">
|
||||
{t("compatBlockedParamsHint") ?? "Blocked params (stripped from requests)"}
|
||||
{providerText(
|
||||
t,
|
||||
"compatBlockedParamsHint",
|
||||
"Blocked params (stripped from requests)"
|
||||
)}
|
||||
{paramSaving && ` ● ${t("compatSaving")}`}
|
||||
</p>
|
||||
</div>
|
||||
@@ -387,7 +392,11 @@ export default function ModelCompatPopover({
|
||||
className="mb-1 w-full rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-[11px] font-mono text-text-main placeholder:text-text-muted focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 dark:border-zinc-600 dark:bg-zinc-900"
|
||||
/>
|
||||
<p className="text-[10px] text-text-muted">
|
||||
{t("compatAllowedParamsHint") ?? "Allowed params (re-added after deny)"}
|
||||
{providerText(
|
||||
t,
|
||||
"compatAllowedParamsHint",
|
||||
"Allowed params (re-added after deny)"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -411,7 +411,7 @@ export default function ModelRow({
|
||||
: testStatus === "ok"
|
||||
? "OK"
|
||||
: testStatus === "error"
|
||||
? "Error"
|
||||
? providerText(t, "errorShort", "Error")
|
||||
: t("testModel")
|
||||
}
|
||||
>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
|
||||
import { NoAuthAccountCard, NoAuthProviderCard } from "@/shared/components";
|
||||
import { getProviderAlias, supportsNoAuthProviderProxy } from "@/shared/constants/providers";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import { providerText } from "../providerPageHelpers";
|
||||
|
||||
const ACCOUNT_PROVIDER_NAMES: Record<string, string> = {
|
||||
mimocode: "MiMoCode",
|
||||
@@ -123,7 +124,10 @@ export default function NoAuthProviderControls({
|
||||
const res = await fetch("/api/dahl/tokens", { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.token) {
|
||||
throw new Error(data?.error || "Failed to create Dahl token");
|
||||
throw new Error(
|
||||
data?.error ||
|
||||
providerText(t, "createDahlTokenFailed", "Failed to create Dahl token")
|
||||
);
|
||||
}
|
||||
return data.token as string;
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ export default function PassthroughModelRow({
|
||||
: testStatus === "ok"
|
||||
? "OK"
|
||||
: testStatus === "error"
|
||||
? "Error"
|
||||
? providerText(t, "errorShort", "Error")
|
||||
: t("testModel")
|
||||
}
|
||||
>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// a single-kind panel or the LlmChatCard for standard LLM providers.
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { LlmChatCard } from "@/app/(dashboard)/dashboard/media-providers/components/LlmChatCard";
|
||||
import { ServiceKindTabs } from "@/app/(dashboard)/dashboard/media-providers/components/ServiceKindTabs";
|
||||
import { EmbeddingExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/EmbeddingExampleCard";
|
||||
@@ -18,6 +19,7 @@ import { VideoExampleCard } from "@/app/(dashboard)/dashboard/media-providers/co
|
||||
import { MusicExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/MusicExampleCard";
|
||||
import type { ServiceKind } from "@/shared/constants/providers";
|
||||
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { providerText } from "../providerPageHelpers";
|
||||
|
||||
export const MEDIA_SERVICE_KINDS: ServiceKind[] = [
|
||||
"embedding",
|
||||
@@ -56,12 +58,12 @@ export function renderKindPanel(kind: ServiceKind, providerId: string): JSX.Elem
|
||||
}
|
||||
|
||||
export default function ProviderPlaygroundPanel({ providerId }: { providerId: string }) {
|
||||
const t = useTranslations("providers");
|
||||
// Resolve serviceKinds from AI_PROVIDERS.
|
||||
// For providers without explicit serviceKinds (most LLM providers), we infer
|
||||
// "llm" as the default.
|
||||
const providerEntry = AI_PROVIDERS[providerId as keyof typeof AI_PROVIDERS] as
|
||||
| (Record<string, unknown> & { serviceKinds?: string[] })
|
||||
| undefined;
|
||||
(Record<string, unknown> & { serviceKinds?: string[] }) | undefined;
|
||||
|
||||
const rawKinds: string[] = providerEntry?.serviceKinds ?? [];
|
||||
|
||||
@@ -93,7 +95,7 @@ export default function ProviderPlaygroundPanel({ providerId }: { providerId: st
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-lg font-semibold">Playground</h2>
|
||||
<h2 className="text-lg font-semibold">{providerText(t, "playgroundTitle", "Playground")}</h2>
|
||||
<ServiceKindTabs
|
||||
kinds={playgroundableKinds}
|
||||
activeKind={activeKind}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button, Badge, Input, Modal, Select } from "@/shared/components";
|
||||
import { CC_COMPATIBLE_DEFAULT_CHAT_PATH } from "../../providerDetailConstants";
|
||||
import { providerText } from "../../providerPageHelpers";
|
||||
interface EditCompatibleNodeModalNode {
|
||||
id?: string;
|
||||
name?: string;
|
||||
@@ -45,9 +46,11 @@ export default function EditCompatibleNodeModal({
|
||||
const [checkKey, setCheckKey] = useState("");
|
||||
const [checkModelId, setCheckModelId] = useState("");
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState<
|
||||
null | { valid: boolean; error?: string | null; method?: string | null }
|
||||
>(null);
|
||||
const [validationResult, setValidationResult] = useState<null | {
|
||||
valid: boolean;
|
||||
error?: string | null;
|
||||
method?: string | null;
|
||||
}>(null);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -130,7 +133,10 @@ export default function EditCompatibleNodeModal({
|
||||
method: data.method ?? null,
|
||||
});
|
||||
} catch {
|
||||
setValidationResult({ valid: false, error: "Network error" });
|
||||
setValidationResult({
|
||||
valid: false,
|
||||
error: providerText(t, "networkError", "Network error"),
|
||||
});
|
||||
} finally {
|
||||
setValidating(false);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { type CommandCodeAuthFlowState } from "../providerPageHelpers";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { providerText, type CommandCodeAuthFlowState } from "../providerPageHelpers";
|
||||
|
||||
export type UseCommandCodeAuthParams = {
|
||||
providerId: string;
|
||||
@@ -15,6 +16,7 @@ export function useCommandCodeAuth({
|
||||
setShowAddApiKeyModal,
|
||||
notify,
|
||||
}: UseCommandCodeAuthParams) {
|
||||
const t = useTranslations("providers");
|
||||
const [commandCodeAuthState, setCommandCodeAuthState] = useState<CommandCodeAuthFlowState>({
|
||||
phase: "idle",
|
||||
state: "",
|
||||
@@ -62,7 +64,7 @@ export function useCommandCodeAuth({
|
||||
setCommandCodeAuthState((current) => ({
|
||||
...current,
|
||||
phase: "applying",
|
||||
message: "Applying browser-approved key…",
|
||||
message: providerText(t, "commandCodeApplyingKey", "Applying browser-approved key…"),
|
||||
}));
|
||||
|
||||
try {
|
||||
@@ -74,7 +76,9 @@ export function useCommandCodeAuth({
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMessage = data.error || "Failed to apply Command Code auth";
|
||||
const errorMessage =
|
||||
data.error ||
|
||||
providerText(t, "commandCodeApplyFailed", "Failed to apply Command Code auth");
|
||||
setCommandCodeAuthState((current) => ({
|
||||
...current,
|
||||
phase: "error",
|
||||
@@ -87,26 +91,30 @@ export function useCommandCodeAuth({
|
||||
setCommandCodeAuthState((current) => ({
|
||||
...current,
|
||||
phase: "applied",
|
||||
message: "Command Code connected",
|
||||
message: providerText(t, "commandCodeConnected", "Command Code connected"),
|
||||
}));
|
||||
commandCodeAuthWindowRef.current?.close?.();
|
||||
commandCodeAuthWindowRef.current = null;
|
||||
await fetchConnections();
|
||||
handleCloseAddApiKeyModal();
|
||||
notify.success("Command Code connection added");
|
||||
notify.success(
|
||||
providerText(t, "commandCodeConnectionAdded", "Command Code connection added")
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error applying Command Code auth:", error);
|
||||
setCommandCodeAuthState((current) => ({
|
||||
...current,
|
||||
phase: "error",
|
||||
message: "Failed to apply Command Code auth",
|
||||
message: providerText(t, "commandCodeApplyFailed", "Failed to apply Command Code auth"),
|
||||
}));
|
||||
notify.error("Failed to apply Command Code auth");
|
||||
notify.error(
|
||||
providerText(t, "commandCodeApplyFailed", "Failed to apply Command Code auth")
|
||||
);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[fetchConnections, handleCloseAddApiKeyModal, notify]
|
||||
[fetchConnections, handleCloseAddApiKeyModal, notify, t]
|
||||
);
|
||||
|
||||
const handleStartCommandCodeAuth = useCallback(async () => {
|
||||
@@ -124,7 +132,7 @@ export function useCommandCodeAuth({
|
||||
authUrl: "",
|
||||
callbackUrl: "",
|
||||
expiresAt: null,
|
||||
message: "Opening Command Code Studio…",
|
||||
message: providerText(t, "commandCodeOpeningStudio", "Opening Command Code Studio…"),
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -135,7 +143,9 @@ export function useCommandCodeAuth({
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok || !data.state || !data.authUrl) {
|
||||
const errorMessage = data.error || "Failed to start Command Code auth";
|
||||
const errorMessage =
|
||||
data.error ||
|
||||
providerText(t, "commandCodeStartFailed", "Failed to start Command Code auth");
|
||||
setCommandCodeAuthState((current) => ({
|
||||
...current,
|
||||
phase: "error",
|
||||
@@ -152,7 +162,11 @@ export function useCommandCodeAuth({
|
||||
authUrl: data.authUrl,
|
||||
callbackUrl: data.callbackUrl || "",
|
||||
expiresAt: data.expiresAt || null,
|
||||
message: "Open the auth URL, approve access, then paste the returned key/JSON/URL below…",
|
||||
message: providerText(
|
||||
t,
|
||||
"commandCodeApprovalInstructions",
|
||||
"Open the auth URL, approve access, then paste the returned key/JSON/URL below…"
|
||||
),
|
||||
});
|
||||
|
||||
if (popup) {
|
||||
@@ -169,9 +183,19 @@ export function useCommandCodeAuth({
|
||||
setCommandCodeAuthState((current) => ({
|
||||
...current,
|
||||
phase: "error",
|
||||
message: "Popup blocked. Please allow popups and try Command Code Connect again.",
|
||||
message: providerText(
|
||||
t,
|
||||
"commandCodePopupBlocked",
|
||||
"Popup blocked. Please allow popups and try Command Code Connect again."
|
||||
),
|
||||
}));
|
||||
notify.error("Popup blocked. Please allow popups and try Command Code Connect again.");
|
||||
notify.error(
|
||||
providerText(
|
||||
t,
|
||||
"commandCodePopupBlocked",
|
||||
"Popup blocked. Please allow popups and try Command Code Connect again."
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
commandCodeAuthWindowRef.current = fallbackPopup;
|
||||
@@ -183,11 +207,11 @@ export function useCommandCodeAuth({
|
||||
setCommandCodeAuthState((current) => ({
|
||||
...current,
|
||||
phase: "expired",
|
||||
message: "Command Code link expired",
|
||||
message: providerText(t, "commandCodeLinkExpired", "Command Code link expired"),
|
||||
}));
|
||||
commandCodeAuthWindowRef.current?.close?.();
|
||||
commandCodeAuthWindowRef.current = null;
|
||||
notify.error("Command Code auth expired");
|
||||
notify.error(providerText(t, "commandCodeAuthExpired", "Command Code auth expired"));
|
||||
clearCommandCodeAuthTimer();
|
||||
return;
|
||||
}
|
||||
@@ -206,11 +230,11 @@ export function useCommandCodeAuth({
|
||||
setCommandCodeAuthState((current) => ({
|
||||
...current,
|
||||
phase: "expired",
|
||||
message: "Command Code link expired",
|
||||
message: providerText(t, "commandCodeLinkExpired", "Command Code link expired"),
|
||||
}));
|
||||
commandCodeAuthWindowRef.current?.close?.();
|
||||
commandCodeAuthWindowRef.current = null;
|
||||
notify.error("Command Code auth expired");
|
||||
notify.error(providerText(t, "commandCodeAuthExpired", "Command Code auth expired"));
|
||||
clearCommandCodeAuthTimer();
|
||||
return;
|
||||
}
|
||||
@@ -219,13 +243,15 @@ export function useCommandCodeAuth({
|
||||
setCommandCodeAuthState((current) => ({
|
||||
...current,
|
||||
phase: "applied",
|
||||
message: "Command Code connected",
|
||||
message: providerText(t, "commandCodeConnected", "Command Code connected"),
|
||||
}));
|
||||
commandCodeAuthWindowRef.current?.close?.();
|
||||
commandCodeAuthWindowRef.current = null;
|
||||
await fetchConnections();
|
||||
handleCloseAddApiKeyModal();
|
||||
notify.success("Command Code connection added");
|
||||
notify.success(
|
||||
providerText(t, "commandCodeConnectionAdded", "Command Code connection added")
|
||||
);
|
||||
clearCommandCodeAuthTimer();
|
||||
return;
|
||||
}
|
||||
@@ -234,7 +260,11 @@ export function useCommandCodeAuth({
|
||||
setCommandCodeAuthState((current) => ({
|
||||
...current,
|
||||
phase: "received",
|
||||
message: "Browser approved, applying…",
|
||||
message: providerText(
|
||||
t,
|
||||
"commandCodeApplyingApproval",
|
||||
"Browser approved, applying…"
|
||||
),
|
||||
}));
|
||||
clearCommandCodeAuthTimer();
|
||||
await handleCommandCodeAuthApply(
|
||||
@@ -258,9 +288,9 @@ export function useCommandCodeAuth({
|
||||
setCommandCodeAuthState((current) => ({
|
||||
...current,
|
||||
phase: "error",
|
||||
message: "Failed to start Command Code auth",
|
||||
message: providerText(t, "commandCodeStartFailed", "Failed to start Command Code auth"),
|
||||
}));
|
||||
notify.error("Failed to start Command Code auth");
|
||||
notify.error(providerText(t, "commandCodeStartFailed", "Failed to start Command Code auth"));
|
||||
popup?.close?.();
|
||||
commandCodeAuthWindowRef.current = null;
|
||||
clearCommandCodeAuthTimer();
|
||||
@@ -272,6 +302,7 @@ export function useCommandCodeAuth({
|
||||
fetchConnections,
|
||||
handleCommandCodeAuthApply,
|
||||
notify,
|
||||
t,
|
||||
]);
|
||||
|
||||
const handleOpenCommandCodeConnect = useCallback(() => {
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { providerText } from "../providerPageHelpers";
|
||||
|
||||
export interface ConnectionDeleteConfirmTarget {
|
||||
id: string;
|
||||
@@ -34,6 +36,7 @@ export function useConnectionDeleteConfirm(
|
||||
fetchConnections: () => Promise<void>,
|
||||
notify: NotifyLike
|
||||
): ConnectionDeleteConfirmState {
|
||||
const t = useTranslations("providers");
|
||||
const [connection, setConnection] = useState<ConnectionDeleteConfirmTarget | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
@@ -56,24 +59,24 @@ export function useConnectionDeleteConfirm(
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${connectionId}`, { method: "DELETE" });
|
||||
if (res.ok) {
|
||||
notify.success("Connection deleted");
|
||||
notify.success(providerText(t, "connectionDeleted", "Connection deleted"));
|
||||
await fetchConnections();
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const message =
|
||||
(typeof data?.error === "string" && data.error) ||
|
||||
data?.error?.message ||
|
||||
"Failed to delete connection";
|
||||
providerText(t, "failedDeleteConnection", "Failed to delete connection");
|
||||
notify.error(message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting connection:", error);
|
||||
notify.error("Failed to delete connection");
|
||||
notify.error(providerText(t, "failedDeleteConnection", "Failed to delete connection"));
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setConnection(null);
|
||||
}
|
||||
}, [connection, fetchConnections, notify]);
|
||||
}, [connection, fetchConnections, notify, t]);
|
||||
|
||||
return { connection, deleting, request, confirm, cancel };
|
||||
}
|
||||
|
||||
@@ -316,11 +316,13 @@ export function useModelVisibilityHandlers({
|
||||
// extractApiErrorMessage coerces any object-shaped `error` (e.g. a Zod
|
||||
// format object) to a string so notify.error never hands the toast a
|
||||
// non-string child (React #31 → frozen page).
|
||||
notify.error(extractApiErrorMessage(data, "Model test failed"));
|
||||
notify.error(
|
||||
extractApiErrorMessage(data, providerText(t, "modelTestFailed", "Model test failed"))
|
||||
);
|
||||
setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" }));
|
||||
}
|
||||
} catch (err) {
|
||||
notify.error("Network error testing model");
|
||||
notify.error(providerText(t, "modelTestNetworkError", "Network error testing model"));
|
||||
setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" }));
|
||||
} finally {
|
||||
setTestingModelId(null);
|
||||
|
||||
@@ -27,7 +27,7 @@ import { useNotificationStore } from "@/store/notificationStore";
|
||||
import { isClaudeCodeCompatibleProvider } from "@/shared/constants/providers";
|
||||
import type { ConnectionRowConnection } from "../components/ConnectionRow";
|
||||
import { connectionBelongsToProviderPage } from "../../providerPageUtils";
|
||||
import { normalizeCodexLimitPolicy } from "../providerPageHelpers";
|
||||
import { normalizeCodexLimitPolicy, providerText } from "../providerPageHelpers";
|
||||
import { useProviderQuotaVisibility } from "./useProviderQuotaVisibility";
|
||||
import { useReorderByAvailability } from "./useReorderByAvailability";
|
||||
import {
|
||||
@@ -378,7 +378,14 @@ export function useProviderConnections(
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
notify.error(data.error || "Failed to update Claude extra-usage policy");
|
||||
notify.error(
|
||||
data.error ||
|
||||
providerText(
|
||||
t,
|
||||
"failedUpdateClaudeExtraUsagePolicy",
|
||||
"Failed to update Claude extra-usage policy"
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -408,12 +415,26 @@ export function useProviderConnections(
|
||||
);
|
||||
notify.success(
|
||||
enabled
|
||||
? "Claude extra-usage blocking enabled (extra usage will be blocked)"
|
||||
: "Claude extra-usage blocking disabled (extra usage is allowed)"
|
||||
? providerText(
|
||||
t,
|
||||
"claudeExtraUsageBlockingEnabled",
|
||||
"Claude extra-usage blocking enabled (extra usage will be blocked)"
|
||||
)
|
||||
: providerText(
|
||||
t,
|
||||
"claudeExtraUsageBlockingDisabled",
|
||||
"Claude extra-usage blocking disabled (extra usage is allowed)"
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error toggling Claude extra-usage policy:", error);
|
||||
notify.error("Failed to update Claude extra-usage policy");
|
||||
notify.error(
|
||||
providerText(
|
||||
t,
|
||||
"failedUpdateClaudeExtraUsagePolicy",
|
||||
"Failed to update Claude extra-usage policy"
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -447,7 +468,10 @@ export function useProviderConnections(
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
notify.error(data.error || "Failed to update Codex limit policy");
|
||||
notify.error(
|
||||
data.error ||
|
||||
providerText(t, "failedUpdateCodexLimitPolicy", "Failed to update Codex limit policy")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -464,10 +488,12 @@ export function useProviderConnections(
|
||||
: connection
|
||||
)
|
||||
);
|
||||
notify.success("Codex limit policy updated");
|
||||
notify.success(providerText(t, "codexLimitPolicyUpdated", "Codex limit policy updated"));
|
||||
} catch (error) {
|
||||
console.error("Error toggling Codex quota policy:", error);
|
||||
notify.error("Failed to update Codex limit policy");
|
||||
notify.error(
|
||||
providerText(t, "failedUpdateCodexLimitPolicy", "Failed to update Codex limit policy")
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -481,18 +507,27 @@ export function useProviderConnections(
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
notify.error(data.error || "Failed to update CLIProxyAPI routing");
|
||||
notify.error(
|
||||
data.error ||
|
||||
providerText(t, "failedUpdateCliproxyRouting", "Failed to update CLIProxyAPI routing")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setCpaProviderEnabled(enabled);
|
||||
notify.success(
|
||||
enabled
|
||||
? "Requests now route through CLIProxyAPI (deeper emulation)"
|
||||
: "Requests now use native OmniRoute (direct)"
|
||||
? providerText(
|
||||
t,
|
||||
"cliproxyRoutingEnabled",
|
||||
"Requests now route through CLIProxyAPI (deeper emulation)"
|
||||
)
|
||||
: providerText(t, "cliproxyRoutingDisabled", "Requests now use native OmniRoute (direct)")
|
||||
);
|
||||
} catch {
|
||||
notify.error("Failed to update CLIProxyAPI routing");
|
||||
notify.error(
|
||||
providerText(t, "failedUpdateCliproxyRouting", "Failed to update CLIProxyAPI routing")
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -664,10 +699,10 @@ export function useProviderConnections(
|
||||
if (onAfter) await onAfter();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
notify.error(data.error || "Batch delete failed");
|
||||
notify.error(data.error || providerText(t, "batchDeleteFailed", "Batch delete failed"));
|
||||
}
|
||||
} catch {
|
||||
notify.error("Network error during batch delete");
|
||||
notify.error(providerText(t, "batchDeleteNetworkError", "Network error during batch delete"));
|
||||
} finally {
|
||||
setBatchDeleting(false);
|
||||
}
|
||||
@@ -689,7 +724,11 @@ export function useProviderConnections(
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error?.message || data.error || "Batch update failed");
|
||||
throw new Error(
|
||||
data.error?.message ||
|
||||
data.error ||
|
||||
providerText(t, "batchUpdateFailed", "Batch update failed")
|
||||
);
|
||||
}
|
||||
const data = await res.json();
|
||||
updated += data.updated ?? 0;
|
||||
@@ -710,7 +749,10 @@ export function useProviderConnections(
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
notify.error(error?.message || "Network error during batch update");
|
||||
notify.error(
|
||||
error?.message ||
|
||||
providerText(t, "batchUpdateNetworkError", "Network error during batch update")
|
||||
);
|
||||
} finally {
|
||||
setBatchUpdating(null);
|
||||
}
|
||||
@@ -805,7 +847,13 @@ export function useProviderConnections(
|
||||
const proxiesData = await proxiesRes.json();
|
||||
const savedProxies = (proxiesData?.items || []).filter((p: any) => p.status === "active");
|
||||
if (savedProxies.length === 0) {
|
||||
notify.error("No saved proxies found. Add proxies in Settings → Proxy first.");
|
||||
notify.error(
|
||||
providerText(
|
||||
t,
|
||||
"noSavedProxies",
|
||||
"No saved proxies found. Add proxies in Settings → Proxy first."
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -856,11 +904,16 @@ export function useProviderConnections(
|
||||
await fetchConnections();
|
||||
const tagLabel = tagFilter ? `"${tagFilter}" ` : "";
|
||||
notify.success(
|
||||
`Distributed ${assigned} proxy assignment(s) across ${tagLabel}${sorted.length} connection(s).`
|
||||
providerText(
|
||||
t,
|
||||
"proxiesDistributed",
|
||||
"Distributed {assigned} proxy assignment(s) across {tagLabel}{total} connection(s).",
|
||||
{ assigned, tagLabel, total: sorted.length }
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Error distributing proxies:", err);
|
||||
notify.error("Failed to distribute proxies.");
|
||||
notify.error(providerText(t, "failedDistributeProxies", "Failed to distribute proxies."));
|
||||
} finally {
|
||||
setDistributingProxies(false);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import type { CompatModelRow } from "../providerPageHelpers";
|
||||
import { providerText, type CompatModelRow } from "../providerPageHelpers";
|
||||
|
||||
// ──── types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -79,11 +79,13 @@ export function useProviderModels(
|
||||
notify.success(t("setAliasSuccess", { alias }));
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
notify.error(data?.error?.message || "Failed to set alias");
|
||||
notify.error(
|
||||
data?.error?.message || providerText(t, "failedSetAlias", "Failed to set alias")
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error setting alias:", error);
|
||||
notify.error("Network error setting alias");
|
||||
notify.error(providerText(t, "networkErrorSettingAlias", "Network error setting alias"));
|
||||
}
|
||||
},
|
||||
[fetchAliases, t, notify]
|
||||
@@ -100,11 +102,13 @@ export function useProviderModels(
|
||||
notify.success(t("deleteAliasSuccess", { alias }));
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
notify.error(data?.error?.message || "Failed to delete alias");
|
||||
notify.error(
|
||||
data?.error?.message || providerText(t, "failedDeleteAlias", "Failed to delete alias")
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error deleting alias:", error);
|
||||
notify.error("Network error deleting alias");
|
||||
notify.error(providerText(t, "networkErrorDeletingAlias", "Network error deleting alias"));
|
||||
}
|
||||
},
|
||||
[fetchAliases, t, notify]
|
||||
@@ -113,10 +117,9 @@ export function useProviderModels(
|
||||
const fetchProviderModelMeta = useCallback(async () => {
|
||||
if (isSearchProvider) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/provider-models?provider=${encodeURIComponent(providerId)}`,
|
||||
{ cache: "no-store" }
|
||||
);
|
||||
const res = await fetch(`/api/provider-models?provider=${encodeURIComponent(providerId)}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
setModelMeta({
|
||||
|
||||
@@ -115,9 +115,7 @@ export function useProviderSettings(providerId: string): UseProviderSettingsRetu
|
||||
} catch (error) {
|
||||
if (!isCurrentRequest()) return;
|
||||
setCodexSettingsLoaded(false);
|
||||
setCodexSettingsLoadError(
|
||||
error instanceof Error ? error.message : "Failed to load settings"
|
||||
);
|
||||
setCodexSettingsLoadError(error instanceof Error ? error.message : "Failed to load settings");
|
||||
}
|
||||
}, [providerId]);
|
||||
|
||||
@@ -184,15 +182,20 @@ export function useProviderSettings(providerId: string): UseProviderSettingsRetu
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setCodexGlobalServiceMode(previousMode);
|
||||
notify.error(data.error || "Failed to update Codex service mode");
|
||||
notify.error(
|
||||
data.error ||
|
||||
providerText(t, "failedUpdateCodexServiceMode", "Failed to update Codex service mode")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
notify.success("Codex service mode updated");
|
||||
notify.success(providerText(t, "codexServiceModeUpdated", "Codex service mode updated"));
|
||||
} catch (error) {
|
||||
setCodexGlobalServiceMode(previousMode);
|
||||
console.error("Error updating Codex service mode:", error);
|
||||
notify.error("Failed to update Codex service mode");
|
||||
notify.error(
|
||||
providerText(t, "failedUpdateCodexServiceMode", "Failed to update Codex service mode")
|
||||
);
|
||||
} finally {
|
||||
setSavingCodexGlobalServiceMode(false);
|
||||
}
|
||||
@@ -215,7 +218,14 @@ export function useProviderSettings(providerId: string): UseProviderSettingsRetu
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setPreferClaudeCodeForUnprefixedClaudeModels(previous);
|
||||
notify.error(data.error || "Failed to update Claude Code routing preference");
|
||||
notify.error(
|
||||
data.error ||
|
||||
providerText(
|
||||
t,
|
||||
"failedUpdateClaudeRoutingPreference",
|
||||
"Failed to update Claude Code routing preference"
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -227,14 +237,26 @@ export function useProviderSettings(providerId: string): UseProviderSettingsRetu
|
||||
}
|
||||
notify.success(
|
||||
enabled
|
||||
? "Unprefixed Claude models now prefer Claude Code"
|
||||
: "Unprefixed Claude models no longer prefer Claude Code"
|
||||
? providerText(
|
||||
t,
|
||||
"claudeRoutingPreferenceEnabled",
|
||||
"Unprefixed Claude models now prefer Claude Code"
|
||||
)
|
||||
: providerText(
|
||||
t,
|
||||
"claudeRoutingPreferenceDisabled",
|
||||
"Unprefixed Claude models no longer prefer Claude Code"
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
setPreferClaudeCodeForUnprefixedClaudeModels(previous);
|
||||
console.error("Error updating Claude Code routing preference:", error);
|
||||
notify.error(
|
||||
providerText(t, "failedUpdateClaudeRoutingPreference", "Failed to update Claude Code routing preference")
|
||||
providerText(
|
||||
t,
|
||||
"failedUpdateClaudeRoutingPreference",
|
||||
"Failed to update Claude Code routing preference"
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
setSavingClaudeRoutingPreference(false);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CLIENT_IDENTITY_PROFILE_OPTIONS,
|
||||
getClientIdentityProfileHeaders,
|
||||
} from "@/shared/constants/clientIdentityProfiles";
|
||||
import { providerText } from "../[id]/providerPageHelpers";
|
||||
|
||||
type CompatibleMode = "openai" | "anthropic" | "cc";
|
||||
type CompatibleProviderNode = { id: string } & Record<string, unknown>;
|
||||
@@ -100,9 +101,11 @@ export default function AddCompatibleProviderModal({
|
||||
const [checkKey, setCheckKey] = useState("");
|
||||
const [checkModelId, setCheckModelId] = useState("");
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState<
|
||||
null | { valid: boolean; error?: string | null; method?: string | null }
|
||||
>(null);
|
||||
const [validationResult, setValidationResult] = useState<null | {
|
||||
valid: boolean;
|
||||
error?: string | null;
|
||||
method?: string | null;
|
||||
}>(null);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
const apiTypeOptions = useMemo(
|
||||
@@ -242,7 +245,10 @@ export default function AddCompatibleProviderModal({
|
||||
method: data.method ?? null,
|
||||
});
|
||||
} catch {
|
||||
setValidationResult({ valid: false, error: "Network error" });
|
||||
setValidationResult({
|
||||
valid: false,
|
||||
error: providerText(t, "networkError", "Network error"),
|
||||
});
|
||||
} finally {
|
||||
setValidating(false);
|
||||
}
|
||||
|
||||
@@ -30,17 +30,17 @@ interface ProviderStats {
|
||||
codexServiceTier?: "default" | "priority" | "flex" | null;
|
||||
}
|
||||
|
||||
const KIND_LABEL: Record<string, string> = {
|
||||
llm: "Chat",
|
||||
embedding: "Embed",
|
||||
image: "Image",
|
||||
imageToText: "I→T",
|
||||
tts: "TTS",
|
||||
stt: "STT",
|
||||
webSearch: "Search",
|
||||
webFetch: "Fetch",
|
||||
video: "Video",
|
||||
music: "Music",
|
||||
const KIND_LABEL_KEYS: Record<string, { key: string; fallback: string }> = {
|
||||
llm: { key: "serviceKindChat", fallback: "Chat" },
|
||||
embedding: { key: "serviceKindEmbedding", fallback: "Embed" },
|
||||
image: { key: "serviceKindImage", fallback: "Image" },
|
||||
imageToText: { key: "serviceKindImageToText", fallback: "I→T" },
|
||||
tts: { key: "serviceKindTts", fallback: "TTS" },
|
||||
stt: { key: "serviceKindStt", fallback: "STT" },
|
||||
webSearch: { key: "serviceKindWebSearch", fallback: "Search" },
|
||||
webFetch: { key: "serviceKindWebFetch", fallback: "Fetch" },
|
||||
video: { key: "serviceKindVideo", fallback: "Video" },
|
||||
music: { key: "serviceKindMusic", fallback: "Music" },
|
||||
};
|
||||
|
||||
/** Maps a compatible-provider `apiType` to its `KIND_LABEL` key (#6936: non-chat
|
||||
@@ -166,6 +166,10 @@ const ProviderCard = forwardRef<ProviderCardHandle, ProviderCardProps>(function
|
||||
const t = useTranslations("providers");
|
||||
const tc = useTranslations("common");
|
||||
const tp = useTranslations("miniPlayground");
|
||||
const kindLabel = (kind: string) => {
|
||||
const entry = KIND_LABEL_KEYS[kind];
|
||||
return entry ? providerText(t, entry.key, entry.fallback) : kind;
|
||||
};
|
||||
const [testExpanded, setTestExpanded] = useState<boolean>(false);
|
||||
const innerRef = useRef<HTMLDivElement>(null);
|
||||
const linkElementRef = useRef<HTMLAnchorElement>(null);
|
||||
@@ -426,15 +430,15 @@ const ProviderCard = forwardRef<ProviderCardHandle, ProviderCardProps>(function
|
||||
key={k}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded bg-bg-subtle border border-border text-text-muted leading-none"
|
||||
>
|
||||
{KIND_LABEL[k] ?? k}
|
||||
{kindLabel(k)}
|
||||
</span>
|
||||
))}
|
||||
{isCompatible && (
|
||||
<Badge variant="default" size="sm">
|
||||
{provider.apiType === "responses"
|
||||
? t("responses")
|
||||
: (KIND_LABEL[COMPATIBLE_API_TYPE_KIND[provider.apiType ?? ""] ?? ""] ??
|
||||
t("chat"))}
|
||||
: kindLabel(COMPATIBLE_API_TYPE_KIND[provider.apiType ?? ""] ?? "") ||
|
||||
t("chat")}
|
||||
</Badge>
|
||||
)}
|
||||
{isCcCompatible && (
|
||||
|
||||
@@ -110,7 +110,12 @@ export default function ProviderSummaryCard({
|
||||
const categories = [
|
||||
{ key: null, color: null, label: t("providerSummaryAll"), stat: summaryStats.all },
|
||||
{ key: "oauth", color: "bg-blue-500", label: t("oauthLabel"), stat: summaryStats.oauth },
|
||||
{ key: "ide", color: "bg-cyan-500", label: "IDE", stat: summaryStats.ide },
|
||||
{
|
||||
key: "ide",
|
||||
color: "bg-cyan-500",
|
||||
label: providerText(t, "categoryIde", "IDE"),
|
||||
stat: summaryStats.ide,
|
||||
},
|
||||
{
|
||||
key: "free",
|
||||
color: "bg-green-500",
|
||||
@@ -132,8 +137,18 @@ export default function ProviderSummaryCard({
|
||||
label: t("compatibleLabel"),
|
||||
stat: summaryStats.compatible,
|
||||
},
|
||||
{ key: "webcookie", color: "bg-purple-500", label: "Web Cookie", stat: summaryStats.webcookie },
|
||||
{ key: "search", color: "bg-teal-500", label: "Search", stat: summaryStats.search },
|
||||
{
|
||||
key: "webcookie",
|
||||
color: "bg-purple-500",
|
||||
label: providerText(t, "categoryWebCookie", "Web Cookie"),
|
||||
stat: summaryStats.webcookie,
|
||||
},
|
||||
{
|
||||
key: "search",
|
||||
color: "bg-teal-500",
|
||||
label: providerText(t, "categorySearch", "Search"),
|
||||
stat: summaryStats.search,
|
||||
},
|
||||
{
|
||||
key: "webfetch",
|
||||
color: "bg-orange-500",
|
||||
@@ -141,12 +156,22 @@ export default function ProviderSummaryCard({
|
||||
stat: summaryStats.webfetch,
|
||||
title: t("webFetchTooltip"),
|
||||
},
|
||||
{ key: "audio", color: "bg-rose-500", label: "Audio", stat: summaryStats.audio },
|
||||
{ key: "local", color: "bg-emerald-500", label: "Local", stat: summaryStats.local },
|
||||
{
|
||||
key: "audio",
|
||||
color: "bg-rose-500",
|
||||
label: providerText(t, "categoryAudio", "Audio"),
|
||||
stat: summaryStats.audio,
|
||||
},
|
||||
{
|
||||
key: "local",
|
||||
color: "bg-emerald-500",
|
||||
label: providerText(t, "categoryLocal", "Local"),
|
||||
stat: summaryStats.local,
|
||||
},
|
||||
{
|
||||
key: "cloudagent",
|
||||
color: "bg-violet-500",
|
||||
label: "Cloud Agent",
|
||||
label: providerText(t, "categoryCloudAgent", "Cloud Agent"),
|
||||
stat: summaryStats.cloudagent,
|
||||
},
|
||||
].filter((category) => category.key !== "no-auth" || category.stat.total > 0);
|
||||
@@ -178,8 +203,8 @@ export default function ProviderSummaryCard({
|
||||
<Input
|
||||
value={modelSearchQuery}
|
||||
onChange={(e) => setModelSearchQuery(e.target.value)}
|
||||
placeholder={t("searchByModel") || "Search by model…"}
|
||||
aria-label={t("searchByModel") || "Search by model"}
|
||||
placeholder={providerText(t, "searchByModel", "Search by model…")}
|
||||
aria-label={providerText(t, "searchByModelAria", "Search by model")}
|
||||
icon="psychology"
|
||||
inputClassName={modelSearchQuery ? "pr-9" : ""}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type ProviderMessageTranslator = ((key: string, values?: Record<string, unknown>) => string) & {
|
||||
has?: (key: string) => boolean;
|
||||
};
|
||||
|
||||
function providerText(
|
||||
t: ProviderMessageTranslator,
|
||||
key: string,
|
||||
fallback: string,
|
||||
values?: Record<string, unknown>
|
||||
): string {
|
||||
if (typeof t.has === "function" && t.has(key)) return t(key, values);
|
||||
if (values) {
|
||||
return Object.entries(values).reduce(
|
||||
(acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)),
|
||||
fallback
|
||||
);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export default function ProvidersError({
|
||||
error: _error,
|
||||
reset,
|
||||
@@ -7,6 +29,8 @@ export default function ProvidersError({
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const t = useTranslations("providers");
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center min-h-[400px]"
|
||||
@@ -15,13 +39,19 @@ export default function ProvidersError({
|
||||
>
|
||||
<div className="text-center space-y-4">
|
||||
<h2 className="text-xl font-semibold text-red-600 dark:text-red-400">
|
||||
Failed to load providers
|
||||
{providerText(t, "pageLoadErrorTitle", "Failed to load providers")}
|
||||
</h2>
|
||||
<p className="text-text-muted max-w-md">
|
||||
We could not load provider data right now. Check your connection and try again.
|
||||
{providerText(
|
||||
t,
|
||||
"pageLoadErrorDescription",
|
||||
"We could not load provider data right now. Check your connection and try again."
|
||||
)}
|
||||
</p>
|
||||
{_error?.digest && (
|
||||
<p className="text-xs text-text-muted font-mono">Error ID: {_error.digest}</p>
|
||||
<p className="text-xs text-text-muted font-mono">
|
||||
{providerText(t, "pageLoadErrorId", "Error ID: {id}", { id: _error.digest })}
|
||||
</p>
|
||||
)}
|
||||
{process.env.NODE_ENV === "development" && _error?.message && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 font-mono">{_error.message}</p>
|
||||
@@ -30,7 +60,7 @@ export default function ProvidersError({
|
||||
onClick={reset}
|
||||
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors focus:outline-2 focus:outline-offset-2 focus:outline-primary"
|
||||
>
|
||||
Try Again
|
||||
{providerText(t, "pageLoadErrorRetry", "Try Again")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export interface ProviderModel {
|
||||
id: string;
|
||||
@@ -29,6 +30,7 @@ interface UseProviderModelsResult {
|
||||
* `providerId` changes).
|
||||
*/
|
||||
export function useProviderModels(providerId: string): UseProviderModelsResult {
|
||||
const t = useTranslations("providers");
|
||||
const [models, setModels] = useState<ProviderModel[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -49,7 +51,7 @@ export function useProviderModels(providerId: string): UseProviderModelsResult {
|
||||
const body = (await res.json().catch(() => null)) as {
|
||||
error?: { message?: string };
|
||||
} | null;
|
||||
const msg = body?.error?.message ?? `HTTP ${res.status}`;
|
||||
const msg = body?.error?.message ?? `${t("providerTestFailed")} (HTTP ${res.status})`;
|
||||
if (!cancelled) setError(msg);
|
||||
return;
|
||||
}
|
||||
@@ -113,7 +115,7 @@ export function useProviderModels(providerId: string): UseProviderModelsResult {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [providerId]);
|
||||
}, [providerId, t]);
|
||||
|
||||
return { models, loading, error };
|
||||
}
|
||||
|
||||
@@ -134,22 +134,28 @@ type ProviderBatchTestResults = {
|
||||
error?: string | { message?: string };
|
||||
};
|
||||
|
||||
function getConnectionErrorTag(connection) {
|
||||
function getConnectionErrorTag(connection, t: ProviderMessageTranslator) {
|
||||
if (!connection) return null;
|
||||
|
||||
const explicitType = connection.lastErrorType;
|
||||
if (explicitType === "runtime_error") return "Runtime";
|
||||
if (explicitType === "runtime_error") return providerText(t, "errorTypeRuntime", "Runtime");
|
||||
if (
|
||||
explicitType === "upstream_auth_error" ||
|
||||
explicitType === "auth_missing" ||
|
||||
explicitType === "token_refresh_failed" ||
|
||||
explicitType === "token_expired"
|
||||
) {
|
||||
return "Auth";
|
||||
return providerText(t, "errorTypeUpstreamAuth", "Auth");
|
||||
}
|
||||
if (explicitType === "upstream_rate_limited") {
|
||||
return providerText(t, "errorTypeRateLimited", "Rate limited");
|
||||
}
|
||||
if (explicitType === "upstream_unavailable") {
|
||||
return providerText(t, "errorTypeUpstreamUnavailable", "Server error");
|
||||
}
|
||||
if (explicitType === "network_error") {
|
||||
return providerText(t, "errorTypeNetworkError", "Network");
|
||||
}
|
||||
if (explicitType === "upstream_rate_limited") return "Rate limited";
|
||||
if (explicitType === "upstream_unavailable") return "Server error";
|
||||
if (explicitType === "network_error") return "Network";
|
||||
|
||||
const numericCode = Number(connection.errorCode);
|
||||
if (Number.isFinite(numericCode) && numericCode >= 400) {
|
||||
@@ -157,19 +163,21 @@ function getConnectionErrorTag(connection) {
|
||||
}
|
||||
|
||||
const fromMessage = getErrorCode(connection.lastError);
|
||||
if (fromMessage === "401" || fromMessage === "403") return "Auth";
|
||||
if (fromMessage === "401" || fromMessage === "403") {
|
||||
return providerText(t, "errorTypeUpstreamAuth", "Auth");
|
||||
}
|
||||
if (fromMessage && fromMessage !== "ERR") return fromMessage;
|
||||
|
||||
const msg = (connection.lastError || "").toLowerCase();
|
||||
if (msg.includes("runtime") || msg.includes("not runnable") || msg.includes("not installed"))
|
||||
return "Runtime";
|
||||
return providerText(t, "errorTypeRuntime", "Runtime");
|
||||
if (
|
||||
msg.includes("invalid api key") ||
|
||||
msg.includes("token invalid") ||
|
||||
msg.includes("revoked") ||
|
||||
msg.includes("unauthorized")
|
||||
)
|
||||
return "Auth";
|
||||
return providerText(t, "errorTypeUpstreamAuth", "Auth");
|
||||
|
||||
return "ERR";
|
||||
}
|
||||
@@ -352,7 +360,7 @@ export default function ProvidersPage() {
|
||||
(a: any, b: any) =>
|
||||
(new Date(b.lastErrorAt || 0) as any) - (new Date(a.lastErrorAt || 0) as any)
|
||||
)[0];
|
||||
const errorCode = latestError ? getConnectionErrorTag(latestError) : null;
|
||||
const errorCode = latestError ? getConnectionErrorTag(latestError, t) : null;
|
||||
const errorTime = latestError?.lastErrorAt ? getRelativeTime(latestError.lastErrorAt) : null;
|
||||
|
||||
// Check expirations
|
||||
@@ -822,11 +830,14 @@ export default function ProvidersPage() {
|
||||
<span className="material-symbols-outlined text-[32px] text-primary">dns</span>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-text-main">
|
||||
{t("addFirstProvider") || "Add your first provider"}
|
||||
{providerText(t, "addFirstProvider", "Add your first provider")}
|
||||
</h2>
|
||||
<p className="text-sm text-text-muted mt-2 max-w-md">
|
||||
{t("addFirstProviderDesc") ||
|
||||
"Connect an AI provider to start routing requests through OmniRoute. You can use free providers, API keys, or OAuth accounts."}
|
||||
{providerText(
|
||||
t,
|
||||
"addFirstProviderDesc",
|
||||
"Connect an AI provider to start routing requests through OmniRoute. You can use free providers, API keys, or OAuth accounts."
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
|
||||
<Button icon="add" onClick={() => router.push("/dashboard/providers/new")}>
|
||||
@@ -839,7 +850,7 @@ export default function ProvidersPage() {
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-lg border border-border text-text-muted hover:text-text-main hover:bg-bg-subtle transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">help</span>
|
||||
{t("learnMore") || "Learn more"}
|
||||
{providerText(t, "learnMore", "Learn more")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1094,10 +1105,10 @@ export default function ProvidersPage() {
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2 flex-1 min-w-0">
|
||||
{t("ideProviders") || "IDE Providers"}{" "}
|
||||
{providerText(t, "ideProviders", "IDE Providers")}{" "}
|
||||
<span
|
||||
className="size-2.5 rounded-full bg-cyan-500"
|
||||
title={t("ideProviders") || "IDE Providers"}
|
||||
title={providerText(t, "ideProviders", "IDE Providers")}
|
||||
/>
|
||||
<ProviderCountBadge {...countConfigured(ideProviderEntriesAll)} />
|
||||
</h2>
|
||||
@@ -1121,12 +1132,15 @@ export default function ProvidersPage() {
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted -mt-2">
|
||||
{t("ideProvidersDesc") ||
|
||||
"Editors with built-in AI subscription. Use the provider page to import credentials directly from the IDE's keychain."}
|
||||
{providerText(
|
||||
t,
|
||||
"ideProvidersDesc",
|
||||
"Editors with built-in AI subscription. Use the provider page to import credentials directly from the IDE's keychain."
|
||||
)}
|
||||
</p>
|
||||
{ideProviderEntries.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border bg-bg-subtle p-6 text-center text-sm text-text-muted">
|
||||
{t("noIdeProviders") || "No IDE providers match the current filters."}
|
||||
{providerText(t, "noIdeProviders", "No IDE providers match the current filters.")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
|
||||
|
||||
@@ -6,12 +6,34 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, Toggle } from "@/shared/components";
|
||||
import { useServiceStatus } from "../hooks/useServiceStatus";
|
||||
|
||||
type ServiceMessageTranslator = ((key: string, values?: Record<string, unknown>) => string) & {
|
||||
has?: (key: string) => boolean;
|
||||
};
|
||||
|
||||
function serviceText(
|
||||
t: ServiceMessageTranslator,
|
||||
key: string,
|
||||
fallback: string,
|
||||
values?: Record<string, unknown>
|
||||
): string {
|
||||
if (typeof t.has === "function" && t.has(key)) return t(key, values);
|
||||
if (values) {
|
||||
return Object.entries(values).reduce(
|
||||
(acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)),
|
||||
fallback
|
||||
);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const NAME = "cliproxy";
|
||||
|
||||
export function CliproxyProviderExposureCard() {
|
||||
const t = useTranslations("embeddedServices");
|
||||
const { data, mutate } = useServiceStatus(NAME);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
@@ -28,14 +50,30 @@ export function CliproxyProviderExposureCard() {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
const errorMsg =
|
||||
body?.error?.message ?? body?.message ?? `Failed to update (HTTP ${res.status})`;
|
||||
body?.error?.message ??
|
||||
body?.message ??
|
||||
serviceText(
|
||||
t,
|
||||
"cliproxyProviderExposureUpdateFailed",
|
||||
"Failed to update (HTTP {status})",
|
||||
{
|
||||
status: res.status,
|
||||
}
|
||||
);
|
||||
setMsg({ ok: false, text: errorMsg });
|
||||
return;
|
||||
}
|
||||
setMsg(null);
|
||||
mutate();
|
||||
} catch {
|
||||
setMsg({ ok: false, text: "Network error — could not update provider exposure setting" });
|
||||
setMsg({
|
||||
ok: false,
|
||||
text: serviceText(
|
||||
t,
|
||||
"cliproxyProviderExposureNetworkFailed",
|
||||
"Network error — could not update provider exposure setting"
|
||||
),
|
||||
});
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
@@ -48,10 +86,15 @@ export function CliproxyProviderExposureCard() {
|
||||
<span className="material-symbols-outlined text-sky-500 text-xl">hub</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-sm">Provider Exposure</h3>
|
||||
<h3 className="font-medium text-sm">
|
||||
{serviceText(t, "cliproxyProviderExposureTitle", "Provider Exposure")}
|
||||
</h3>
|
||||
<p className="text-xs text-text-muted">
|
||||
Expose CLIProxyAPI models as a routing target under the{" "}
|
||||
<code className="font-mono bg-bg-subtle px-1 rounded">cliproxyapi/</code> prefix.
|
||||
{serviceText(
|
||||
t,
|
||||
"cliproxyProviderExposureDescription",
|
||||
"Expose CLIProxyAPI models as a routing target under the cliproxyapi/ prefix."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -74,16 +117,23 @@ export function CliproxyProviderExposureCard() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm">
|
||||
Expose as{" "}
|
||||
{serviceText(t, "cliproxyProviderExposureLabel", "Expose as")}{" "}
|
||||
<code className="font-mono bg-bg-subtle px-1 rounded text-xs">cliproxyapi/...</code>
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
When enabled, discovered models appear in provider selects across OmniRoute.
|
||||
{serviceText(
|
||||
t,
|
||||
"cliproxyProviderExposureHint",
|
||||
"When enabled, discovered models appear in provider selects across OmniRoute."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle checked={data?.providerExpose ?? false} onChange={handleToggle} disabled={pending || !data} />
|
||||
<Toggle
|
||||
checked={data?.providerExpose ?? false}
|
||||
onChange={handleToggle}
|
||||
disabled={pending || !data}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import RelayProxyClient from "./RelayProxyClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "OmniRoute — Relay Proxies",
|
||||
description: "Serverless relay proxy endpoints for your AI infrastructure",
|
||||
};
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("metadata");
|
||||
return {
|
||||
title: t("relayTitle"),
|
||||
description: t("relayDescription"),
|
||||
};
|
||||
}
|
||||
|
||||
export default function RelayProxyPage() {
|
||||
return <RelayProxyClient />;
|
||||
|
||||
@@ -320,7 +320,7 @@ export default function AuthzSection() {
|
||||
onClick={() => handleRemovePrefix(prefix)}
|
||||
disabled={locked || submitting}
|
||||
>
|
||||
Remove
|
||||
{t("authz.remove")}
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function AutoDisableCard() {
|
||||
const savedData = await res.json();
|
||||
setData(savedData);
|
||||
setEditMode(false);
|
||||
notify.success(t("savedSuccessfully") || "Saved successfully");
|
||||
notify.success(t("savedSuccessfully"));
|
||||
} catch (err) {
|
||||
notify.error(err instanceof Error ? err.message : "Error saving");
|
||||
} finally {
|
||||
|
||||
@@ -93,18 +93,12 @@ export default function BackgroundDegradationTab() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("backgroundDegradationTitle") || "Background Task Degradation"}
|
||||
</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
{t("backgroundDegradationDesc") ||
|
||||
"Auto-redirect background requests (titles, summaries) to cheaper models"}
|
||||
</p>
|
||||
<h3 className="text-lg font-semibold">{t("backgroundDegradationTitle")}</h3>
|
||||
<p className="text-sm text-text-muted">{t("backgroundDegradationDesc")}</p>
|
||||
</div>
|
||||
{status === "saved" && (
|
||||
<span className="text-xs font-medium text-emerald-500 flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[14px]">check_circle</span>{" "}
|
||||
{t("saved") || "Saved"}
|
||||
<span className="material-symbols-outlined text-[14px]">check_circle</span> {t("saved")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -112,9 +106,7 @@ export default function BackgroundDegradationTab() {
|
||||
{/* Toggle */}
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-surface/30 border border-border/30 mb-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t("enableDegradation") || "Enable Background Degradation"}
|
||||
</p>
|
||||
<p className="text-sm font-medium">{t("enableDegradation")}</p>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
{t("enableDegradationHint") ||
|
||||
"Automatically use cheaper models for background utility tasks"}
|
||||
@@ -124,7 +116,7 @@ export default function BackgroundDegradationTab() {
|
||||
checked={config.enabled}
|
||||
onChange={(enabled) => save({ enabled })}
|
||||
disabled={loading || saving}
|
||||
ariaLabel={t("enableDegradation") || "Enable Background Degradation"}
|
||||
ariaLabel={t("enableDegradation")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -133,9 +125,7 @@ export default function BackgroundDegradationTab() {
|
||||
<div className="flex items-center gap-4 p-3 rounded-lg bg-sky-500/5 border border-sky-500/20 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[16px] text-sky-400">analytics</span>
|
||||
<span className="text-xs text-text-muted">
|
||||
{t("tasksDetected") || "Tasks detected"}:
|
||||
</span>
|
||||
<span className="text-xs text-text-muted">{t("tasksDetected")}:</span>
|
||||
<span className="text-sm font-mono font-semibold text-sky-400">
|
||||
{config.stats.detected}
|
||||
</span>
|
||||
@@ -148,7 +138,7 @@ export default function BackgroundDegradationTab() {
|
||||
{/* Degradation Map */}
|
||||
<div className="mb-4">
|
||||
<p className="text-xs font-medium text-text-muted uppercase tracking-wider mb-2">
|
||||
{t("degradationMap") || "Model Degradation Map"}
|
||||
{t("degradationMap")}
|
||||
</p>
|
||||
|
||||
{/* Add new mapping */}
|
||||
@@ -157,23 +147,19 @@ export default function BackgroundDegradationTab() {
|
||||
<ModelSelectField
|
||||
value={newFrom}
|
||||
onChange={setNewFrom}
|
||||
placeholder={t("premiumModel") || "Premium model"}
|
||||
placeholder={t("premiumModel")}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-text-muted text-lg">→</span>
|
||||
<div className="flex-1">
|
||||
<ModelSelectField
|
||||
value={newTo}
|
||||
onChange={setNewTo}
|
||||
placeholder={t("cheapModel") || "Cheap model"}
|
||||
/>
|
||||
<ModelSelectField value={newTo} onChange={setNewTo} placeholder={t("cheapModel")} />
|
||||
</div>
|
||||
<button
|
||||
onClick={addMapping}
|
||||
disabled={saving || !newFrom.trim() || !newTo.trim()}
|
||||
className="px-3 py-2 rounded-lg text-sm font-medium bg-sky-500/10 text-sky-500 hover:bg-sky-500/20 disabled:opacity-50 transition-all"
|
||||
>
|
||||
{t("add") || "Add"}
|
||||
{t("add")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -206,15 +192,14 @@ export default function BackgroundDegradationTab() {
|
||||
<span className="material-symbols-outlined text-[14px] group-open:rotate-90 transition-transform">
|
||||
chevron_right
|
||||
</span>
|
||||
{t("detectionPatterns") || "Detection Patterns"} (
|
||||
{config.detectionPatterns?.length || 0})
|
||||
{t("detectionPatterns")} ({config.detectionPatterns?.length || 0})
|
||||
</summary>
|
||||
|
||||
{/* Add new pattern */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("newPattern") || 'e.g. "generate a title"'}
|
||||
placeholder={t("newPattern")}
|
||||
value={newPattern}
|
||||
onChange={(e) => setNewPattern(e.target.value)}
|
||||
className="flex-1 px-3 py-2 rounded-lg text-sm bg-surface border border-border/50 focus:border-sky-500/50 focus:outline-none"
|
||||
@@ -224,7 +209,7 @@ export default function BackgroundDegradationTab() {
|
||||
disabled={saving || !newPattern.trim()}
|
||||
className="px-3 py-2 rounded-lg text-sm font-medium bg-sky-500/10 text-sky-500 hover:bg-sky-500/20 disabled:opacity-50 transition-all"
|
||||
>
|
||||
{t("add") || "Add"}
|
||||
{t("add")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -49,17 +49,21 @@ export default function CliproxyapiSettingsTab() {
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setImportResult(
|
||||
`Imported ${data.imported ?? 0} account(s) (scanned ${data.scanned ?? 0}, skipped ${data.skipped ?? 0}).`
|
||||
t("cliproxyapiImportResult", {
|
||||
imported: data.imported ?? 0,
|
||||
scanned: data.scanned ?? 0,
|
||||
skipped: data.skipped ?? 0,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
setImportResult(data.error || "Import failed.");
|
||||
setImportResult(data.error || t("cliproxyapiImportFailed"));
|
||||
}
|
||||
} catch {
|
||||
setImportResult("Import failed.");
|
||||
setImportResult(t("cliproxyapiImportFailed"));
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/settings")
|
||||
@@ -95,34 +99,37 @@ export default function CliproxyapiSettingsTab() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateSetting = useCallback(async (key: string, value: boolean | string) => {
|
||||
if (key === "cliproxyapi_url" && typeof value === "string" && value.trim() !== "") {
|
||||
if (!isValidUrl(value)) {
|
||||
setMessage({ type: "error", text: "Invalid URL format. Use http:// or https://" });
|
||||
return;
|
||||
const updateSetting = useCallback(
|
||||
async (key: string, value: boolean | string) => {
|
||||
if (key === "cliproxyapi_url" && typeof value === "string" && value.trim() !== "") {
|
||||
if (!isValidUrl(value)) {
|
||||
setMessage({ type: "error", text: t("cliproxyapiInvalidUrl") });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ [key]: value }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Server returned ${res.status}`);
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ [key]: value }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Server returned ${res.status}`);
|
||||
}
|
||||
await res.json();
|
||||
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||
setMessage({ type: "success", text: t("settingSaved") });
|
||||
} catch {
|
||||
setMessage({ type: "error", text: t("settingSaveFailed") });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
await res.json();
|
||||
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||
setMessage({ type: "success", text: "Setting saved" });
|
||||
} catch {
|
||||
setMessage({ type: "error", text: "Failed to save setting" });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, []);
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const cpaEnabled = settings.cliproxyapi_fallback_enabled === true;
|
||||
const cpaUrl = settings.cliproxyapi_url || "http://127.0.0.1:8317";
|
||||
@@ -148,14 +155,14 @@ export default function CliproxyapiSettingsTab() {
|
||||
<div className="flex items-start gap-2 px-3 py-2.5 rounded-lg bg-blue-500/10 text-blue-700 dark:text-blue-300 text-xs">
|
||||
<span className="material-symbols-outlined text-[14px] mt-0.5 shrink-0">info</span>
|
||||
<span>
|
||||
CLIProxyAPI lifecycle management (install, start, stop) has moved to{" "}
|
||||
{t("cliproxyapiLifecycleNoticeBefore")}{" "}
|
||||
<Link
|
||||
href="/dashboard/providers/services"
|
||||
className="underline underline-offset-2 hover:opacity-80"
|
||||
>
|
||||
Providers → Services
|
||||
{t("cliproxyapiLifecycleNoticeLink")}
|
||||
</Link>
|
||||
. Fallback routing settings below remain here.
|
||||
{t("cliproxyapiLifecycleNoticeAfter")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -181,9 +188,7 @@ export default function CliproxyapiSettingsTab() {
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-sm">{t("cliproxyapiFallback")}</h3>
|
||||
<p className="text-xs text-text-muted">
|
||||
When enabled, failed requests are retried through CLIProxyAPI (localhost:8317)
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">{t("cliproxyapiFallbackDescription")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -212,7 +217,7 @@ export default function CliproxyapiSettingsTab() {
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1.5 block">
|
||||
Fallback Status Codes (comma-separated)
|
||||
{t("cliproxyapiFallbackCodes")}
|
||||
</label>
|
||||
<Input
|
||||
value={cpaCodes}
|
||||
@@ -233,31 +238,31 @@ export default function CliproxyapiSettingsTab() {
|
||||
<span className="material-symbols-outlined animate-spin text-base">
|
||||
progress_activity
|
||||
</span>
|
||||
Loading...
|
||||
{t("loading")}
|
||||
</div>
|
||||
) : toolStateError ? (
|
||||
<p className="text-sm text-text-muted">{toolStateError}</p>
|
||||
) : toolState ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 rounded-lg bg-bg-secondary">
|
||||
<p className="text-xs text-text-muted mb-1">Status</p>
|
||||
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiStatusLabel")}</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`material-symbols-outlined text-sm ${statusColor}`}>
|
||||
{statusIcon}
|
||||
</span>
|
||||
<p className={`text-sm font-medium capitalize ${statusColor}`}>
|
||||
{toolState.status?.replace("_", " ") || "Unknown"}
|
||||
{toolState.status?.replace("_", " ") || t("unknown")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-bg-secondary">
|
||||
<p className="text-xs text-text-muted mb-1">Version</p>
|
||||
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiVersionLabel")}</p>
|
||||
<p className="text-sm font-medium">
|
||||
{toolState.installedVersion ? `v${toolState.installedVersion}` : "Not installed"}
|
||||
{toolState.installedVersion ? `v${toolState.installedVersion}` : t("notInstalled")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-bg-secondary">
|
||||
<p className="text-xs text-text-muted mb-1">Health</p>
|
||||
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiHealthLabel")}</p>
|
||||
<p
|
||||
className={`text-sm font-medium ${
|
||||
toolState.healthStatus === "healthy"
|
||||
@@ -268,14 +273,14 @@ export default function CliproxyapiSettingsTab() {
|
||||
}`}
|
||||
>
|
||||
{toolState.healthStatus === "healthy"
|
||||
? "Healthy"
|
||||
? t("healthy")
|
||||
: toolState.healthStatus === "unhealthy"
|
||||
? "Unhealthy"
|
||||
: "Unknown"}
|
||||
? t("unhealthy")
|
||||
: t("unknown")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-bg-secondary">
|
||||
<p className="text-xs text-text-muted mb-1">Port</p>
|
||||
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiPortLabel")}</p>
|
||||
<p className="text-sm font-mono">{toolState.port || 8317}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -369,7 +369,10 @@ export default function MemorySkillsTab() {
|
||||
role="note"
|
||||
data-testid="memory-token-cost-warning"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px] leading-none mt-0.5" aria-hidden="true">
|
||||
<span
|
||||
className="material-symbols-outlined text-[18px] leading-none mt-0.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
info
|
||||
</span>
|
||||
<p className="text-xs leading-relaxed">
|
||||
@@ -540,7 +543,7 @@ export default function MemorySkillsTab() {
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="p-4 rounded-lg bg-surface/30 border border-border/30">
|
||||
<label className="text-sm font-medium block mb-2">Host</label>
|
||||
<label className="text-sm font-medium block mb-2">{t("host")}</label>
|
||||
<input
|
||||
value={qdrant.host}
|
||||
onChange={(e) => setQdrant((s) => ({ ...s, host: e.target.value }))}
|
||||
@@ -567,7 +570,7 @@ export default function MemorySkillsTab() {
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-surface/30 border border-border/30">
|
||||
<label className="text-sm font-medium block mb-2">Collection</label>
|
||||
<label className="text-sm font-medium block mb-2">{t("collection")}</label>
|
||||
<input
|
||||
value={qdrant.collection}
|
||||
onChange={(e) => setQdrant((s) => ({ ...s, collection: e.target.value }))}
|
||||
@@ -781,9 +784,7 @@ export default function MemorySkillsTab() {
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{t("memorySkillsSkillsmpMarketplace")}</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
Connect to SkillsMP to discover and install skills from the marketplace.
|
||||
</p>
|
||||
<p className="text-sm text-text-muted">{t("memorySkillsSkillsmpDescription")}</p>
|
||||
</div>
|
||||
{skillsmpStatus === "saved" && (
|
||||
<span className="ml-auto text-xs font-medium text-emerald-500 flex items-center gap-1">
|
||||
@@ -813,12 +814,12 @@ export default function MemorySkillsTab() {
|
||||
disabled={skillsmpSaving}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-violet-500 text-white hover:bg-violet-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{skillsmpSaving ? "Saving..." : "Save"}
|
||||
{skillsmpSaving ? t("saving") : t("save")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-2">
|
||||
Get your API key from <span className="text-violet-400">skillsmp.com</span>. Rate limit:
|
||||
500 requests/day.
|
||||
{t("skillsmpApiKeyHintBefore")} <span className="text-violet-400">skillsmp.com</span>
|
||||
{t("skillsmpApiKeyHintAfter", { limit: 500 })}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -833,9 +834,7 @@ export default function MemorySkillsTab() {
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{t("memorySkillsActiveSkillsProvider")}</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
Choose which provider the Skills page uses for search and install.
|
||||
</p>
|
||||
<p className="text-sm text-text-muted">{t("memorySkillsActiveProviderDescription")}</p>
|
||||
</div>
|
||||
{skillsProviderStatus === "saved" && (
|
||||
<span className="ml-auto text-xs font-medium text-emerald-500 flex items-center gap-1">
|
||||
@@ -864,10 +863,10 @@ export default function MemorySkillsTab() {
|
||||
<p
|
||||
className={`text-sm font-medium ${skillsProvider === "skillsmp" ? "text-indigo-400" : ""}`}
|
||||
>
|
||||
SkillsMP Marketplace
|
||||
{t("memorySkillsSkillsmpProviderTitle")}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-0.5 leading-relaxed">
|
||||
Authenticated marketplace (uses your SkillsMP API key).
|
||||
{t("memorySkillsSkillsmpProviderDescription")}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
@@ -884,10 +883,10 @@ export default function MemorySkillsTab() {
|
||||
<p
|
||||
className={`text-sm font-medium ${skillsProvider === "skillssh" ? "text-indigo-400" : ""}`}
|
||||
>
|
||||
skills.sh Directory
|
||||
{t("memorySkillsSkillsshProviderTitle")}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-0.5 leading-relaxed">
|
||||
Public directory provider (no API key required).
|
||||
{t("memorySkillsSkillsshProviderDescription")}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -82,26 +82,23 @@ export default function ModelAliasesTab() {
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{t("modelAliasesTitle") || "Model Aliases"}</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
{t("modelAliasesDesc") || "Auto-forward deprecated model IDs to their replacements"}
|
||||
</p>
|
||||
<h3 className="text-lg font-semibold">{t("modelAliasesTitle")}</h3>
|
||||
<p className="text-sm text-text-muted">{t("modelAliasesDesc")}</p>
|
||||
</div>
|
||||
{status === "saved" && (
|
||||
<span className="ml-auto text-xs font-medium text-emerald-500 flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[14px]">check_circle</span>{" "}
|
||||
{t("saved") || "Saved"}
|
||||
<span className="material-symbols-outlined text-[14px]">check_circle</span> {t("saved")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add custom alias */}
|
||||
<div className="p-4 rounded-lg bg-surface/30 border border-border/30 mb-4">
|
||||
<p className="text-sm font-medium mb-3">{t("addCustomAlias") || "Add Custom Alias"}</p>
|
||||
<p className="text-sm font-medium mb-3">{t("addCustomAlias")}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("deprecatedModelId") || "Deprecated model ID"}
|
||||
placeholder={t("deprecatedModelId")}
|
||||
value={newFrom}
|
||||
onChange={(e) => setNewFrom(e.target.value)}
|
||||
className="flex-1 px-3 py-2 rounded-lg text-sm bg-surface border border-border/50 focus:border-amber-500/50 focus:outline-none"
|
||||
@@ -109,7 +106,7 @@ export default function ModelAliasesTab() {
|
||||
<span className="text-text-muted text-lg">→</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("newModelId") || "New model ID"}
|
||||
placeholder={t("newModelId")}
|
||||
value={newTo}
|
||||
onChange={(e) => setNewTo(e.target.value)}
|
||||
className="flex-1 px-3 py-2 rounded-lg text-sm bg-surface border border-border/50 focus:border-amber-500/50 focus:outline-none"
|
||||
@@ -119,7 +116,7 @@ export default function ModelAliasesTab() {
|
||||
disabled={saving || !newFrom.trim() || !newTo.trim()}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-amber-500/10 text-amber-500 hover:bg-amber-500/20 disabled:opacity-50 transition-all"
|
||||
>
|
||||
{t("add") || "Add"}
|
||||
{t("add")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -128,7 +125,7 @@ export default function ModelAliasesTab() {
|
||||
{customEntries.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="text-xs font-medium text-text-muted uppercase tracking-wider mb-2">
|
||||
{t("customAliases") || "Custom Aliases"}
|
||||
{t("customAliases")}
|
||||
</p>
|
||||
<div className="rounded-lg border border-border/30 divide-y divide-border/20">
|
||||
{customEntries.map(([from, to]) => (
|
||||
@@ -157,7 +154,7 @@ export default function ModelAliasesTab() {
|
||||
<span className="material-symbols-outlined text-[14px] group-open:rotate-90 transition-transform">
|
||||
chevron_right
|
||||
</span>
|
||||
{t("builtInAliases") || "Built-in Aliases"} ({builtInEntries.length})
|
||||
{t("builtInAliases")} ({builtInEntries.length})
|
||||
</summary>
|
||||
<div className="rounded-lg border border-border/30 divide-y divide-border/20 max-h-60 overflow-y-auto">
|
||||
{builtInEntries.map(([from, to]) => (
|
||||
|
||||
@@ -85,7 +85,7 @@ export default function ModelsDevSyncTab() {
|
||||
});
|
||||
fetchStatus();
|
||||
} else {
|
||||
setFeedback({ type: "error", message: result.error || "Sync failed" });
|
||||
setFeedback({ type: "error", message: result.error || t("syncFailed") });
|
||||
}
|
||||
} else {
|
||||
setFeedback({ type: "error", message: "Sync request failed" });
|
||||
@@ -110,7 +110,7 @@ export default function ModelsDevSyncTab() {
|
||||
});
|
||||
if (!res.ok) {
|
||||
setEnabled(!newVal);
|
||||
setFeedback({ type: "error", message: t("enableSyncError") || "Failed to update" });
|
||||
setFeedback({ type: "error", message: t("enableSyncError") });
|
||||
} else {
|
||||
setFeedback({ type: "success", message: "Settings saved" });
|
||||
}
|
||||
@@ -136,7 +136,7 @@ export default function ModelsDevSyncTab() {
|
||||
if (!res.ok) {
|
||||
setIntervalHours(oldInterval);
|
||||
setDraftIntervalHours(oldInterval);
|
||||
setFeedback({ type: "error", message: t("enableSyncError") || "Failed to update" });
|
||||
setFeedback({ type: "error", message: t("enableSyncError") });
|
||||
} else {
|
||||
setFeedback({ type: "success", message: "Interval updated" });
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ export default function OneproxyTab() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncResult, setSyncResult] = useState<string | null>(null);
|
||||
const [syncSucceeded, setSyncSucceeded] = useState(false);
|
||||
const [filterProtocol, setFilterProtocol] = useState("");
|
||||
const [filterCountry, setFilterCountry] = useState("");
|
||||
const [minQuality, setMinQuality] = useState("");
|
||||
@@ -83,24 +84,32 @@ export default function OneproxyTab() {
|
||||
const handleSync = async () => {
|
||||
setSyncing(true);
|
||||
setSyncResult(null);
|
||||
setSyncSucceeded(false);
|
||||
try {
|
||||
const res = await fetch("/api/settings/oneproxy", { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setSyncResult(`Synced ${data.total} proxies (${data.added} new, ${data.updated} updated)`);
|
||||
setSyncSucceeded(true);
|
||||
setSyncResult(
|
||||
t("oneproxySyncSuccess", {
|
||||
total: data.total,
|
||||
added: data.added,
|
||||
updated: data.updated,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
setSyncResult(`Sync failed: ${data.error}`);
|
||||
setSyncResult(t("oneproxySyncFailed", { error: data.error }));
|
||||
}
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
setSyncResult(`Sync failed: ${err}`);
|
||||
setSyncResult(t("oneproxySyncFailed", { error: String(err) }));
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
if (!confirm("Clear all 1proxy proxies?")) return;
|
||||
if (!confirm(t("oneproxyClearAllConfirm"))) return;
|
||||
try {
|
||||
await fetch("/api/settings/oneproxy?clearAll=1", { method: "DELETE" });
|
||||
await loadData();
|
||||
@@ -141,17 +150,15 @@ export default function OneproxyTab() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text-main">{t("oneproxyTitle")}</h2>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
Fetch and rotate free validated proxies from the 1proxy community platform
|
||||
</p>
|
||||
<p className="text-sm text-text-muted mt-1">{t("oneproxyDescription")}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSync} disabled={syncing} variant="primary">
|
||||
{syncing ? "Syncing..." : "Sync Now"}
|
||||
{syncing ? t("oneproxySyncing") : t("oneproxySyncNow")}
|
||||
</Button>
|
||||
{proxies.length > 0 && (
|
||||
<Button onClick={handleClearAll} variant="danger">
|
||||
Clear All
|
||||
{t("oneproxyClearAll")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -160,7 +167,7 @@ export default function OneproxyTab() {
|
||||
{syncResult && (
|
||||
<div
|
||||
className={`p-3 rounded-lg text-sm ${
|
||||
syncResult.startsWith("Synced")
|
||||
syncSucceeded
|
||||
? "bg-green-50 text-green-800 dark:bg-green-900/30 dark:text-green-300"
|
||||
: "bg-red-50 text-red-800 dark:bg-red-900/30 dark:text-red-300"
|
||||
}`}
|
||||
@@ -177,7 +184,7 @@ export default function OneproxyTab() {
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="text-2xl font-bold text-green-600">{stats.active}</div>
|
||||
<div className="text-sm text-text-muted">Active</div>
|
||||
<div className="text-sm text-text-muted">{t("oneproxyActive")}</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="text-2xl font-bold text-text-main">
|
||||
@@ -230,22 +237,36 @@ export default function OneproxyTab() {
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-text-muted">{t("oneproxyLoadingProxies")}</div>
|
||||
) : proxies.length === 0 ? (
|
||||
<div className="text-center py-8 text-text-muted">
|
||||
No 1proxy proxies found. Click "Sync Now" to fetch free proxies.
|
||||
</div>
|
||||
<div className="text-center py-8 text-text-muted">{t("oneproxyEmpty")}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left py-2 px-3 text-text-muted font-medium">Host</th>
|
||||
<th className="text-left py-2 px-3 text-text-muted font-medium">Protocol</th>
|
||||
<th className="text-left py-2 px-3 text-text-muted font-medium">Country</th>
|
||||
<th className="text-left py-2 px-3 text-text-muted font-medium">Quality</th>
|
||||
<th className="text-left py-2 px-3 text-text-muted font-medium">Latency</th>
|
||||
<th className="text-left py-2 px-3 text-text-muted font-medium">Anonymity</th>
|
||||
<th className="text-left py-2 px-3 text-text-muted font-medium">Google</th>
|
||||
<th className="text-left py-2 px-3 text-text-muted font-medium">Actions</th>
|
||||
<th className="text-left py-2 px-3 text-text-muted font-medium">
|
||||
{t("oneproxyHost")}
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-text-muted font-medium">
|
||||
{t("oneproxyProtocol")}
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-text-muted font-medium">
|
||||
{t("oneproxyCountry")}
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-text-muted font-medium">
|
||||
{t("oneproxyQuality")}
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-text-muted font-medium">
|
||||
{t("oneproxyLatency")}
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-text-muted font-medium">
|
||||
{t("oneproxyAnonymity")}
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-text-muted font-medium">
|
||||
{t("oneproxyGoogle")}
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-text-muted font-medium">
|
||||
{t("oneproxyActions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -289,7 +310,7 @@ export default function OneproxyTab() {
|
||||
onClick={() => handleDelete(proxy.id)}
|
||||
className="text-red-500 hover:text-red-700 text-xs"
|
||||
>
|
||||
Delete
|
||||
{t("oneproxyDelete")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface ProxyStatusBadgeProps {
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export function ProxyStatusBadge({ status }: ProxyStatusBadgeProps) {
|
||||
const t = useTranslations("settings");
|
||||
const isInactive = status === "inactive";
|
||||
return (
|
||||
<span
|
||||
@@ -17,7 +20,7 @@ export function ProxyStatusBadge({ status }: ProxyStatusBadgeProps) {
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${isInactive ? "bg-red-400" : "bg-emerald-400"}`}
|
||||
/>
|
||||
{isInactive ? "Inactive" : "Active"}
|
||||
{isInactive ? t("proxyStatusInactive") : t("proxyStatusActive")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -702,7 +702,7 @@ function ComboCooldownWaitCard({
|
||||
setDraft(value);
|
||||
}, [value]);
|
||||
|
||||
const title = t("resilienceComboCooldownWaitTitle") || "Combo cooldown wait";
|
||||
const title = t("resilienceComboCooldownWaitTitle");
|
||||
const desc =
|
||||
t("resilienceComboCooldownWaitDesc") ||
|
||||
"For all combo strategies: wait out a short transient cooldown and re-dispatch instead of returning a 429 immediately. Never waits on quota_exhausted.";
|
||||
@@ -735,7 +735,7 @@ function ComboCooldownWaitCard({
|
||||
{editing ? (
|
||||
<>
|
||||
<BooleanField
|
||||
label={t("resilienceEnableServerWait") || "Enabled"}
|
||||
label={t("resilienceEnableServerWait")}
|
||||
description={
|
||||
t("resilienceComboCooldownWaitToggleDesc") ||
|
||||
"All combo strategies; never waits on quota_exhausted."
|
||||
@@ -744,20 +744,20 @@ function ComboCooldownWaitCard({
|
||||
onChange={(enabled) => setDraft((prev) => ({ ...prev, enabled }))}
|
||||
/>
|
||||
<NumberField
|
||||
label={t("resilienceComboCooldownMaxWaitMs") || "Max wait per attempt"}
|
||||
label={t("resilienceComboCooldownMaxWaitMs")}
|
||||
value={draft.maxWaitMs}
|
||||
min={0}
|
||||
suffix="ms"
|
||||
onChange={(maxWaitMs) => setDraft((prev) => ({ ...prev, maxWaitMs }))}
|
||||
/>
|
||||
<NumberField
|
||||
label={t("resilienceMaxAttempts") || "Max attempts"}
|
||||
label={t("resilienceMaxAttempts")}
|
||||
value={draft.maxAttempts}
|
||||
min={0}
|
||||
onChange={(maxAttempts) => setDraft((prev) => ({ ...prev, maxAttempts }))}
|
||||
/>
|
||||
<NumberField
|
||||
label={t("resilienceComboCooldownBudgetMs") || "Total wait budget"}
|
||||
label={t("resilienceComboCooldownBudgetMs")}
|
||||
value={draft.budgetMs}
|
||||
min={0}
|
||||
suffix="ms"
|
||||
@@ -767,31 +767,23 @@ function ComboCooldownWaitCard({
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">
|
||||
{t("resilienceEnableServerWait") || "Enabled"}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted">{t("resilienceEnableServerWait")}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">
|
||||
{value.enabled ? t("statusEnabled") : t("statusDisabled")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">
|
||||
{t("resilienceComboCooldownMaxWaitMs") || "Max wait per attempt"}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted">{t("resilienceComboCooldownMaxWaitMs")}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">
|
||||
{formatMs(value.maxWaitMs)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">
|
||||
{t("resilienceMaxAttempts") || "Max attempts"}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted">{t("resilienceMaxAttempts")}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">{value.maxAttempts}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">
|
||||
{t("resilienceComboCooldownBudgetMs") || "Total wait budget"}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted">{t("resilienceComboCooldownBudgetMs")}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">
|
||||
{formatMs(value.budgetMs)}
|
||||
</div>
|
||||
@@ -820,8 +812,7 @@ function QuotaShareConcurrencyLimitCard({
|
||||
setDraft(value);
|
||||
}, [value]);
|
||||
|
||||
const title =
|
||||
t("resilienceQuotaShareConcurrencyTitle") || "Quota-share per-connection concurrency";
|
||||
const title = t("resilienceQuotaShareConcurrencyTitle");
|
||||
const desc =
|
||||
t("resilienceQuotaShareConcurrencyDesc") ||
|
||||
"For quota-share combos only: when a connection sets a Max Concurrent cap, serialize concurrent requests to that subscription account so it is never flooded past its ceiling — excess requests wait in the queue instead of getting a 429. The cap comes from each connection's Max Concurrent field; this switch only enables/disables honoring it.";
|
||||
@@ -853,7 +844,7 @@ function QuotaShareConcurrencyLimitCard({
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{editing ? (
|
||||
<BooleanField
|
||||
label={t("resilienceEnableServerWait") || "Enabled"}
|
||||
label={t("resilienceEnableServerWait")}
|
||||
description={
|
||||
t("resilienceQuotaShareConcurrencyToggleDesc") ||
|
||||
"Quota-share combos only; honors each connection's Max Concurrent cap."
|
||||
@@ -863,9 +854,7 @@ function QuotaShareConcurrencyLimitCard({
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">
|
||||
{t("resilienceEnableServerWait") || "Enabled"}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted">{t("resilienceEnableServerWait")}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">
|
||||
{value.enabled ? t("statusEnabled") : t("statusDisabled")}
|
||||
</div>
|
||||
|
||||
@@ -180,17 +180,17 @@ const DEFAULT_SYSTEM_TRANSFORMS_CLIENT = {
|
||||
|
||||
const PROVIDER_TILE_DISPLAY: Record<
|
||||
string,
|
||||
{ name: string; description: string; icon: string; tone: string }
|
||||
{ nameKey: string; descriptionKey: string; icon: string; tone: string }
|
||||
> = {
|
||||
[PROVIDER_CLAUDE]: {
|
||||
name: "Claude (OAuth)",
|
||||
description: "Native Claude provider with OAuth-issued tokens.",
|
||||
nameKey: "routingClaudeProviderName",
|
||||
descriptionKey: "routingClaudeProviderDescription",
|
||||
icon: "anthropic",
|
||||
tone: "indigo",
|
||||
},
|
||||
[PROVIDER_CC_BRIDGE]: {
|
||||
name: "Claude-Code Bridge",
|
||||
description: "Relay endpoints using API keys (anthropic-compatible-cc-*).",
|
||||
nameKey: "routingCcBridgeName",
|
||||
descriptionKey: "routingCcBridgeDescription",
|
||||
icon: "hub",
|
||||
tone: "purple",
|
||||
},
|
||||
@@ -335,7 +335,7 @@ function StringListEditor({
|
||||
onClick={() => onChange([...items, ""])}
|
||||
className="self-start"
|
||||
>
|
||||
{tCommon("add") || "Add entry"}
|
||||
{t("routingAddEntry")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -567,7 +567,11 @@ function OpEditor({
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return <p className="text-xs text-text-muted">Unknown op kind: {op?.kind}</p>;
|
||||
return (
|
||||
<p className="text-xs text-text-muted">
|
||||
{t("routingUnknownOpKind", { kind: String(op?.kind ?? "") })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -625,16 +629,16 @@ function summarizeTransformOp(op: any, t: any): string {
|
||||
|
||||
// Client-side validator — light shape check before we PATCH; the server
|
||||
// re-validates with the full zod schema in settingsSchemas.ts.
|
||||
function validateProviderTransformsConfig(value: unknown): string | null {
|
||||
if (!value || typeof value !== "object") return "Config must be a JSON object";
|
||||
function validateProviderTransformsConfig(value: unknown, t: any): string | null {
|
||||
if (!value || typeof value !== "object") return t("routingConfigMustBeObject");
|
||||
const cfg = value as { enabled?: unknown; pipeline?: unknown };
|
||||
if (typeof cfg.enabled !== "boolean") return "`enabled` must be true or false";
|
||||
if (!Array.isArray(cfg.pipeline)) return "`pipeline` must be an array of ops";
|
||||
if (cfg.pipeline.length > 50) return "Pipeline cannot exceed 50 ops";
|
||||
if (typeof cfg.enabled !== "boolean") return t("routingEnabledMustBeBoolean");
|
||||
if (!Array.isArray(cfg.pipeline)) return t("routingPipelineMustBeArray");
|
||||
if (cfg.pipeline.length > 50) return t("routingPipelineTooLong");
|
||||
for (let i = 0; i < cfg.pipeline.length; i++) {
|
||||
const op = cfg.pipeline[i] as { kind?: unknown };
|
||||
if (!op || typeof op !== "object" || typeof op.kind !== "string") {
|
||||
return `Op #${i + 1}: missing or invalid \`kind\``;
|
||||
return t("routingOpMissingKind", { index: i + 1 });
|
||||
}
|
||||
const validKinds = [
|
||||
"drop_paragraph_if_contains",
|
||||
@@ -648,7 +652,7 @@ function validateProviderTransformsConfig(value: unknown): string | null {
|
||||
"obfuscate_words",
|
||||
];
|
||||
if (!validKinds.includes(op.kind)) {
|
||||
return `Op #${i + 1}: unknown kind "${op.kind}"`;
|
||||
return t("routingOpUnknownKind", { index: i + 1, kind: op.kind });
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -813,11 +817,11 @@ export default function RoutingTab() {
|
||||
} catch (err) {
|
||||
setJsonErrors((prev) => ({
|
||||
...prev,
|
||||
[providerId]: `Invalid JSON: ${(err as Error).message}`,
|
||||
[providerId]: t("routingInvalidJson", { error: (err as Error).message }),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const validationError = validateProviderTransformsConfig(parsed);
|
||||
const validationError = validateProviderTransformsConfig(parsed, t);
|
||||
if (validationError) {
|
||||
setJsonErrors((prev) => ({ ...prev, [providerId]: validationError }));
|
||||
return;
|
||||
@@ -1017,7 +1021,7 @@ export default function RoutingTab() {
|
||||
</option>
|
||||
{availableProvidersToAdd.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} ({p.id})
|
||||
{p.id === PROVIDER_CC_BRIDGE ? t("routingCcBridgeCatalogName") : p.name} ({p.id})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
@@ -1039,12 +1043,11 @@ export default function RoutingTab() {
|
||||
<div className="flex flex-col gap-3">
|
||||
{Object.entries(systemTransforms.providers).map(([providerId, providerCfg]) => {
|
||||
const isBuiltin = BUILTIN_PROVIDERS.has(providerId);
|
||||
const display = PROVIDER_TILE_DISPLAY[providerId] ?? {
|
||||
name: providerId,
|
||||
description: "Custom provider.",
|
||||
icon: "extension",
|
||||
tone: "purple",
|
||||
};
|
||||
const display = PROVIDER_TILE_DISPLAY[providerId];
|
||||
const displayName = display ? t(display.nameKey) : providerId;
|
||||
const displayDescription = display
|
||||
? t(display.descriptionKey)
|
||||
: t("routingCustomProviderDescription");
|
||||
const draft = jsonDrafts[providerId] ?? JSON.stringify(providerCfg, null, 2);
|
||||
const errorMsg = jsonErrors[providerId] ?? null;
|
||||
const opCount = Array.isArray(providerCfg.pipeline) ? providerCfg.pipeline.length : 0;
|
||||
@@ -1066,7 +1069,7 @@ export default function RoutingTab() {
|
||||
<code className="text-xs font-mono rounded bg-surface px-1.5 py-0.5">
|
||||
{providerId}
|
||||
</code>
|
||||
<span className="text-sm font-medium">{display.name}</span>
|
||||
<span className="text-sm font-medium">{displayName}</span>
|
||||
</div>
|
||||
}
|
||||
subtitle={
|
||||
@@ -1081,7 +1084,7 @@ export default function RoutingTab() {
|
||||
onChange={(checked) => toggleProviderEnabled(providerId, checked)}
|
||||
disabled={loading}
|
||||
ariaLabel={
|
||||
tCommon("enable") + " " + display.name + " " + t("systemTransforms")
|
||||
tCommon("enable") + " " + displayName + " " + t("systemTransforms")
|
||||
}
|
||||
/>
|
||||
{!isBuiltin && (
|
||||
@@ -1098,7 +1101,7 @@ export default function RoutingTab() {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-xs text-text-muted mb-3">{display.description}</p>
|
||||
<p className="text-xs text-text-muted mb-3">{displayDescription}</p>
|
||||
{providerSaveErrors[providerId] && (
|
||||
<div
|
||||
role="alert"
|
||||
@@ -1211,13 +1214,13 @@ export default function RoutingTab() {
|
||||
className="text-[11px] text-primary hover:underline"
|
||||
>
|
||||
{isJsonOpen
|
||||
? "▾ " + tCommon("hide") + " JSON editor"
|
||||
: "▸ Import / export JSON"}
|
||||
? `▾ ${t("routingJsonEditorHide")}`
|
||||
: `▸ ${t("routingJsonEditorImportExport")}`}
|
||||
</button>
|
||||
{isJsonOpen && (
|
||||
<div className="mt-2">
|
||||
<label className="text-[11px] font-medium text-text-muted block mb-1">
|
||||
JSON ({tCommon("edit")} & Apply, or paste to import)
|
||||
{t("routingJsonEditorLabel")}
|
||||
</label>
|
||||
<textarea
|
||||
value={draft}
|
||||
@@ -1240,7 +1243,7 @@ export default function RoutingTab() {
|
||||
size="sm"
|
||||
icon="check"
|
||||
>
|
||||
Apply JSON
|
||||
{t("routingApplyJson")}
|
||||
</Button>
|
||||
{hasDefault && (
|
||||
<Button
|
||||
@@ -1262,10 +1265,7 @@ export default function RoutingTab() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-[11px] text-text-muted">
|
||||
All transform ops are idempotent on re-run. Changes take effect immediately on the next
|
||||
request.
|
||||
</p>
|
||||
<p className="mt-3 text-[11px] text-text-muted">{t("routingTransformsFootnote")}</p>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
@@ -1471,13 +1471,8 @@ export default function RoutingTab() {
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("echoRequestedModelTitle") || "Echo requested model name in responses"}
|
||||
</h3>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
{t("echoRequestedModelDesc") ||
|
||||
"When enabled, the response model field echoes the alias or combo name the client requested instead of the upstream model name."}
|
||||
</p>
|
||||
<h3 className="text-lg font-semibold">{t("echoRequestedModelTitle")}</h3>
|
||||
<p className="text-sm text-text-muted mt-1">{t("echoRequestedModelDesc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
@@ -1500,20 +1495,15 @@ export default function RoutingTab() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("webSearchRouteTitle") || "Web search routing"}
|
||||
</h3>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
{t("webSearchRouteDesc") ||
|
||||
"When a request includes a native web_search tool, route the whole request to this model instead of the default — useful for providers that don't implement Anthropic's web_search server tool. Leave blank to disable."}
|
||||
</p>
|
||||
<h3 className="text-lg font-semibold">{t("webSearchRouteTitle")}</h3>
|
||||
<p className="text-sm text-text-muted mt-1">{t("webSearchRouteDesc")}</p>
|
||||
<div className="mt-3">
|
||||
<ModelSelectField
|
||||
value={String(settings.webSearchRouteModel ?? "")}
|
||||
onChange={(v) => updateSetting({ webSearchRouteModel: v })}
|
||||
placeholder={t("webSearchRoutePlaceholder") || "Search or select a model…"}
|
||||
placeholder={t("webSearchRoutePlaceholder")}
|
||||
disabled={loading}
|
||||
ariaLabel={t("webSearchRouteTitle") || "Web search routing model"}
|
||||
ariaLabel={t("webSearchRouteTitle")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1529,13 +1519,8 @@ export default function RoutingTab() {
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("lkgpToggleTitle") || "Last Known Good Provider (LKGP)"}
|
||||
</h3>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
{t("lkgpToggleDesc") ||
|
||||
"When enabled, the router remembers which provider last served a successful response and tries it first on subsequent requests."}
|
||||
</p>
|
||||
<h3 className="text-lg font-semibold">{t("lkgpToggleTitle")}</h3>
|
||||
<p className="text-sm text-text-muted mt-1">{t("lkgpToggleDesc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
@@ -1561,19 +1546,18 @@ export default function RoutingTab() {
|
||||
if (res.ok) {
|
||||
setLkgpCacheStatus({
|
||||
type: "success",
|
||||
message: t("lkgpCacheCleared") || "LKGP cache cleared successfully",
|
||||
message: t("lkgpCacheCleared"),
|
||||
});
|
||||
} else {
|
||||
setLkgpCacheStatus({
|
||||
type: "error",
|
||||
message:
|
||||
data.error || t("lkgpCacheClearFailed") || "Failed to clear LKGP cache",
|
||||
message: data.error || t("lkgpCacheClearFailed"),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setLkgpCacheStatus({
|
||||
type: "error",
|
||||
message: t("errorOccurred") || "An error occurred",
|
||||
message: t("errorOccurred"),
|
||||
});
|
||||
} finally {
|
||||
setLkgpCacheLoading(false);
|
||||
@@ -1583,7 +1567,7 @@ export default function RoutingTab() {
|
||||
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
|
||||
delete_sweep
|
||||
</span>
|
||||
{t("clearLkgpCache") || "Clear LKGP Cache"}
|
||||
{t("clearLkgpCache")}
|
||||
</Button>
|
||||
{lkgpCacheStatus.message && (
|
||||
<span
|
||||
@@ -1604,13 +1588,8 @@ export default function RoutingTab() {
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("adaptiveVolumeRouting") || "Adaptive Volume Routing"}
|
||||
</h3>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
{t("adaptiveVolumeRoutingDesc") ||
|
||||
"Automatically adjusts traffic volume between providers based on real-time latency and error rates."}
|
||||
</p>
|
||||
<h3 className="text-lg font-semibold">{t("adaptiveVolumeRouting")}</h3>
|
||||
<p className="text-sm text-text-muted mt-1">{t("adaptiveVolumeRoutingDesc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
|
||||
@@ -81,13 +81,11 @@ export default function SecurityTab() {
|
||||
setRequireLoginModalOpen(false);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setRequireLoginError(
|
||||
data?.error?.message || t("errorOccurred", { fallback: "An error occurred" })
|
||||
);
|
||||
setRequireLoginError(data?.error?.message || t("errorOccurred"));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to update require login:", err);
|
||||
setRequireLoginError(t("errorOccurred", { fallback: "An error occurred" }));
|
||||
setRequireLoginError(t("errorOccurred"));
|
||||
} finally {
|
||||
setRequireLoginLoading(false);
|
||||
}
|
||||
@@ -196,9 +194,7 @@ export default function SecurityTab() {
|
||||
title={t("currentPassword")}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-text-muted">
|
||||
{t("enterCurrentPassword", { fallback: "Enter your current password to continue" })}
|
||||
</p>
|
||||
<p className="text-sm text-text-muted">{t("enterCurrentPassword")}</p>
|
||||
<Input
|
||||
label={t("currentPassword")}
|
||||
type="password"
|
||||
@@ -226,7 +222,7 @@ export default function SecurityTab() {
|
||||
loading={requireLoginLoading}
|
||||
disabled={!requireLoginPassword}
|
||||
>
|
||||
{t("confirm", { fallback: "Confirm" })}
|
||||
{t("confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -236,12 +236,12 @@ export default function SystemStorageTab() {
|
||||
if (res.ok) {
|
||||
setClearCacheStatus({
|
||||
type: "success",
|
||||
message: t("cacheCleared") || "Cache cleared successfully",
|
||||
message: t("cacheCleared"),
|
||||
});
|
||||
} else {
|
||||
setClearCacheStatus({
|
||||
type: "error",
|
||||
message: data?.error || t("clearCacheFailed") || "Failed to clear cache",
|
||||
message: data?.error || t("clearCacheFailed"),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
@@ -266,7 +266,7 @@ export default function SystemStorageTab() {
|
||||
} else {
|
||||
setPurgeLogsStatus({
|
||||
type: "error",
|
||||
message: data?.error || t("purgeLogsFailed") || "Failed to purge logs",
|
||||
message: data?.error || t("purgeLogsFailed"),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
@@ -1391,7 +1391,7 @@ export default function SystemStorageTab() {
|
||||
<span className="material-symbols-outlined text-[18px] text-blue-500" aria-hidden="true">
|
||||
build
|
||||
</span>
|
||||
<p className="font-medium">{t("maintenance") || "Maintenance"}</p>
|
||||
<p className="font-medium">{t("maintenance")}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
@@ -1403,7 +1403,7 @@ export default function SystemStorageTab() {
|
||||
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
|
||||
delete_sweep
|
||||
</span>
|
||||
{t("clearCache") || "Clear Cache"}
|
||||
{t("clearCache")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -1414,7 +1414,7 @@ export default function SystemStorageTab() {
|
||||
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
|
||||
auto_delete
|
||||
</span>
|
||||
{t("purgeExpiredLogs") || "Purge Expired Logs"}
|
||||
{t("purgeExpiredLogs")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -1469,7 +1469,7 @@ export default function SystemStorageTab() {
|
||||
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
|
||||
restart_alt
|
||||
</span>
|
||||
{t("resetUsageData") || "Reset Usage Data"}
|
||||
{t("resetUsageData")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4 border-t border-border/50 pt-3">
|
||||
@@ -1543,7 +1543,7 @@ export default function SystemStorageTab() {
|
||||
isOpen={resetUsageModalOpen}
|
||||
onClose={() => !resetUsageLoading && setResetUsageModalOpen(false)}
|
||||
onConfirm={handleResetUsageHistory}
|
||||
title={t("resetUsageData") || "Reset Usage Data"}
|
||||
title={t("resetUsageData")}
|
||||
message={
|
||||
<div className="space-y-3">
|
||||
<p className="text-text-muted">
|
||||
@@ -1563,7 +1563,7 @@ export default function SystemStorageTab() {
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
confirmText={resetUsageLoading ? t("resetting") || "Resetting..." : t("reset") || "Reset"}
|
||||
confirmText={resetUsageLoading ? t("resetting") : t("reset")}
|
||||
variant="danger"
|
||||
loading={resetUsageLoading}
|
||||
/>
|
||||
|
||||
@@ -41,23 +41,22 @@ export default function DocumentationTab() {
|
||||
<h3 className="font-semibold mb-2">SOCKS5</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
{t("proxyDocumentationSocks5DescBefore")}{" "}
|
||||
<code className="bg-surface-alt px-1 rounded">ENABLE_SOCKS5_PROXY=false</code> to disable
|
||||
(ON by default).
|
||||
<code className="bg-surface-alt px-1 rounded">ENABLE_SOCKS5_PROXY=false</code>{" "}
|
||||
{t("proxyDocumentationSocks5DescAfter")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="font-semibold mb-2">{t("freePoolTab")}</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
{t("proxyDocumentationFreePoolDesc")}
|
||||
</p>
|
||||
<p className="text-sm text-text-muted">{t("proxyDocumentationFreePoolDesc")}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="font-semibold mb-2">Vercel Relay</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
{t("proxyDocumentationVercelRelayDescBefore")} (
|
||||
<code className="bg-surface-alt px-1 rounded">x-relay-auth</code>). {t("proxyDocumentationVercelRelayDescAfter")}
|
||||
<code className="bg-surface-alt px-1 rounded">x-relay-auth</code>).{" "}
|
||||
{t("proxyDocumentationVercelRelayDescAfter")}
|
||||
</p>
|
||||
</section>
|
||||
</Card>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export interface FreeProxyRowData {
|
||||
id: string;
|
||||
source: string;
|
||||
@@ -28,6 +30,7 @@ export default function FreeProxyRow({
|
||||
onAddToPool,
|
||||
adding,
|
||||
}: FreeProxyRowProps) {
|
||||
const t = useTranslations("settings");
|
||||
const qualityColor =
|
||||
proxy.qualityScore == null
|
||||
? "text-text-muted"
|
||||
@@ -46,7 +49,7 @@ export default function FreeProxyRow({
|
||||
onChange={() => onToggleSelect(proxy.id)}
|
||||
className="rounded"
|
||||
disabled={proxy.inPool}
|
||||
aria-label={`Select ${proxy.host}:${proxy.port}`}
|
||||
aria-label={t("proxyFreePoolSelectProxy", { endpoint: `${proxy.host}:${proxy.port}` })}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-text-muted text-xs">{proxy.source}</td>
|
||||
@@ -64,16 +67,16 @@ export default function FreeProxyRow({
|
||||
<td className="px-3 py-2">
|
||||
{proxy.inPool ? (
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
|
||||
in pool
|
||||
{t("proxyFreePoolInPool")}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onAddToPool(proxy.id)}
|
||||
disabled={adding}
|
||||
aria-label={`Add ${proxy.host}:${proxy.port} to pool`}
|
||||
aria-label={t("proxyFreePoolAddProxy", { endpoint: `${proxy.host}:${proxy.port}` })}
|
||||
className="px-2 py-0.5 rounded text-xs bg-primary/15 text-primary border border-primary/30 hover:bg-primary/25 disabled:opacity-50"
|
||||
>
|
||||
{adding ? "..." : "⊕"}
|
||||
{adding ? t("proxyFreePoolAdding") : "⊕"}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
|
||||
@@ -59,19 +59,22 @@ export default function SubscriptionTab() {
|
||||
// Resolve a subscription `error` value into a localized message. Values are
|
||||
// either a `{ code, detail? }` JSON (user-facing, i18n'd) or a plain
|
||||
// diagnostic string (technical fetch/sync errors) shown verbatim.
|
||||
const resolveSubError = useCallback((raw: string | null): string | null => {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { code?: string; detail?: string };
|
||||
if (parsed?.code) {
|
||||
const base = t(`proxySubscription.error.${parsed.code}`);
|
||||
return parsed.detail ? `${base}(${parsed.detail})` : base;
|
||||
const resolveSubError = useCallback(
|
||||
(raw: string | null): string | null => {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { code?: string; detail?: string };
|
||||
if (parsed?.code) {
|
||||
const base = t(`proxySubscription.error.${parsed.code}`);
|
||||
return parsed.detail ? `${base}(${parsed.detail})` : base;
|
||||
}
|
||||
} catch {
|
||||
// plain diagnostic string — show as-is
|
||||
}
|
||||
} catch {
|
||||
// plain diagnostic string — show as-is
|
||||
}
|
||||
return raw;
|
||||
}, [t]);
|
||||
return raw;
|
||||
},
|
||||
[t]
|
||||
);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
@@ -85,7 +88,7 @@ export default function SubscriptionTab() {
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/v1/management/proxy-subscriptions");
|
||||
if (!res.ok) throw new Error("加载订阅列表失败");
|
||||
if (!res.ok) throw new Error(t("proxySubscription.loadFailed"));
|
||||
const data = await res.json();
|
||||
setSubs(Array.isArray(data.items) ? data.items : []);
|
||||
} catch (e) {
|
||||
@@ -93,7 +96,7 @@ export default function SubscriptionTab() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const loadProviders = useCallback(async () => {
|
||||
try {
|
||||
@@ -143,10 +146,10 @@ export default function SubscriptionTab() {
|
||||
setSaving(true);
|
||||
setFormError(null);
|
||||
try {
|
||||
if (!form.name.trim()) throw new Error("请填写名称");
|
||||
if (!form.url.trim()) throw new Error("请填写订阅链接");
|
||||
if (!form.name.trim()) throw new Error(t("proxySubscription.nameRequired"));
|
||||
if (!form.url.trim()) throw new Error(t("proxySubscription.urlRequired"));
|
||||
if (form.mode === "rule" && form.ruleProviders.length === 0) {
|
||||
throw new Error("规则模式下请至少选择一个 Provider");
|
||||
throw new Error(t("proxySubscription.providerRequired"));
|
||||
}
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
@@ -170,7 +173,7 @@ export default function SubscriptionTab() {
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "保存失败");
|
||||
throw new Error(data.error || t("proxySubscription.saveFailed"));
|
||||
}
|
||||
resetForm();
|
||||
await load();
|
||||
@@ -189,7 +192,7 @@ export default function SubscriptionTab() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: !sub.enabled }),
|
||||
});
|
||||
if (!res.ok) throw new Error("切换开关失败");
|
||||
if (!res.ok) throw new Error(t("proxySubscription.toggleFailed"));
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
@@ -204,7 +207,7 @@ export default function SubscriptionTab() {
|
||||
const res = await fetch(`/api/v1/management/proxy-subscriptions/${sub.id}/refresh`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) throw new Error("刷新失败");
|
||||
if (!res.ok) throw new Error(t("proxySubscription.refreshFailed"));
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
@@ -214,13 +217,13 @@ export default function SubscriptionTab() {
|
||||
};
|
||||
|
||||
const remove = async (sub: SubscriptionRecord) => {
|
||||
if (!window.confirm(`确定删除订阅「${sub.name}」?相关代理节点也会一并移除。`)) return;
|
||||
if (!window.confirm(t("proxySubscription.deleteConfirm", { name: sub.name }))) return;
|
||||
setBusyId(sub.id);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/management/proxy-subscriptions/${sub.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) throw new Error("删除失败");
|
||||
if (!res.ok) throw new Error(t("proxySubscription.deleteFailed"));
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
@@ -238,13 +241,10 @@ export default function SubscriptionTab() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-text-muted">
|
||||
粘贴你的代理订阅链接,开启后即可按全局或规则(指定 Provider)模式走代理。
|
||||
订阅节点会自动同步进代理池,并复用既有的轮询、健康检查与防泄漏机制。
|
||||
</p>
|
||||
<p className="text-sm text-text-muted">{t("proxySubscription.description")}</p>
|
||||
{!showForm && (
|
||||
<Button size="sm" variant="primary" icon="add" onClick={() => setShowForm(true)}>
|
||||
新增订阅
|
||||
{t("proxySubscription.add")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -259,38 +259,38 @@ export default function SubscriptionTab() {
|
||||
<div className="rounded-lg border border-border bg-surface p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{editingId ? "编辑订阅" : "新增订阅"}
|
||||
{editingId ? t("proxySubscription.edit") : t("proxySubscription.add")}
|
||||
</h3>
|
||||
<Button size="sm" variant="secondary" icon="close" onClick={resetForm}>
|
||||
取消
|
||||
{t("proxySubscription.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="text-text-muted">名称</span>
|
||||
<span className="text-text-muted">{t("proxySubscription.name")}</span>
|
||||
<input
|
||||
className="rounded border border-border bg-surface px-2 py-1.5 text-text outline-none focus:border-primary"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="例如:我的订阅A"
|
||||
placeholder={t("proxySubscription.namePlaceholder")}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="text-text-muted">订阅链接</span>
|
||||
<span className="text-text-muted">{t("proxySubscription.url")}</span>
|
||||
<input
|
||||
className="rounded border border-border bg-surface px-2 py-1.5 text-text outline-none focus:border-primary"
|
||||
value={form.url}
|
||||
onChange={(e) => setForm({ ...form, url: e.target.value })}
|
||||
placeholder="https://.../subscribe?token=..."
|
||||
placeholder={t("proxySubscription.urlPlaceholder")}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
<span className="text-text-muted">模式</span>
|
||||
<span className="text-text-muted">{t("proxySubscription.mode")}</span>
|
||||
<div className="flex gap-2">
|
||||
{(["global", "rule"] as const).map((m) => (
|
||||
<button
|
||||
@@ -303,19 +303,21 @@ export default function SubscriptionTab() {
|
||||
: "border-border text-text-muted hover:text-text"
|
||||
}`}
|
||||
>
|
||||
{m === "global" ? "全局模式" : "规则模式"}
|
||||
{m === "global"
|
||||
? t("proxySubscription.globalMode")
|
||||
: t("proxySubscription.ruleMode")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-text-muted">
|
||||
{form.mode === "global"
|
||||
? "所有 Provider 流量都走该订阅的代理池。"
|
||||
: "仅所选 Provider 的流量走代理,其余直连。"}
|
||||
? t("proxySubscription.globalModeDescription")
|
||||
: t("proxySubscription.ruleModeDescription")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="text-text-muted">本地内核 SOCKS5/HTTP 端点(可选)</span>
|
||||
<span className="text-text-muted">{t("proxySubscription.localCoreEndpoint")}</span>
|
||||
<input
|
||||
className="rounded border border-border bg-surface px-2 py-1.5 text-text outline-none focus:border-primary"
|
||||
value={form.localCoreEndpoint}
|
||||
@@ -323,16 +325,18 @@ export default function SubscriptionTab() {
|
||||
placeholder="socks5://127.0.0.1:1080"
|
||||
/>
|
||||
<span className="text-xs text-text-muted">
|
||||
仅接受 127.0.0.1 / localhost(SS/VMess/Trojan/VLESS 需本地 sing-box/clash 内核)。
|
||||
{t("proxySubscription.localCoreHint")}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{form.mode === "rule" && (
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
<span className="text-text-muted">按 Provider 路由(多选)</span>
|
||||
<span className="text-text-muted">{t("proxySubscription.providerRouting")}</span>
|
||||
{providers.length === 0 ? (
|
||||
<span className="text-xs text-text-muted">正在加载 Provider 列表…</span>
|
||||
<span className="text-xs text-text-muted">
|
||||
{t("proxySubscription.loadingProviders")}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{providers.map((p) => {
|
||||
@@ -366,7 +370,7 @@ export default function SubscriptionTab() {
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="text-text-muted">自动刷新间隔(分钟)</span>
|
||||
<span className="text-text-muted">{t("proxySubscription.refreshInterval")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={5}
|
||||
@@ -383,7 +387,7 @@ export default function SubscriptionTab() {
|
||||
checked={form.enabled}
|
||||
onChange={(e) => setForm({ ...form, enabled: e.target.checked })}
|
||||
/>
|
||||
<span>创建后启用(立即同步并生效)</span>
|
||||
<span>{t("proxySubscription.enableAfterCreate")}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -391,129 +395,147 @@ export default function SubscriptionTab() {
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={resetForm}>
|
||||
取消
|
||||
{t("proxySubscription.cancel")}
|
||||
</Button>
|
||||
<Button size="sm" variant="primary" icon="save" onClick={save} disabled={saving}>
|
||||
{saving ? "保存中…" : editingId ? "保存修改" : "创建订阅"}
|
||||
{saving
|
||||
? t("proxySubscription.saving")
|
||||
: editingId
|
||||
? t("proxySubscription.saveChanges")
|
||||
: t("proxySubscription.create")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{loading && <p className="text-sm text-text-muted">加载中…</p>}
|
||||
{loading && <p className="text-sm text-text-muted">{t("proxySubscription.loading")}</p>}
|
||||
{!loading && subs.length === 0 && (
|
||||
<p className="text-sm text-text-muted">还没有任何订阅。点击「新增订阅」开始吧。</p>
|
||||
<p className="text-sm text-text-muted">{t("proxySubscription.empty")}</p>
|
||||
)}
|
||||
{subs.map((sub) => {
|
||||
const needsCoreNodes = (sub.lastNodes ?? []).filter(isNeedsCoreNode);
|
||||
const showCoreHint = needsCoreNodes.length > 0 && !sub.localCoreEndpoint;
|
||||
return (
|
||||
<div
|
||||
key={sub.id}
|
||||
className="rounded-lg border border-border bg-surface p-3 flex flex-col gap-2"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium truncate">{sub.name}</span>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded border ${
|
||||
statusBadge[sub.status] || statusBadge.empty
|
||||
}`}
|
||||
>
|
||||
{sub.status === "ok" ? "正常" : sub.status === "error" ? "错误" : "空"}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded border border-border text-text-muted">
|
||||
{sub.mode === "global" ? "全局" : "规则"}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded border border-border text-text-muted">
|
||||
{sub.enabled ? "已启用" : "已停用"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted truncate mt-1" title={sub.url}>
|
||||
{sub.url}
|
||||
</p>
|
||||
{resolveSubError(sub.error) && (
|
||||
<p className="text-xs text-amber-600 mt-1 break-words">{resolveSubError(sub.error)}</p>
|
||||
)}
|
||||
{showCoreHint && (
|
||||
<div className="mt-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 space-y-1.5">
|
||||
<p className="font-medium">
|
||||
该订阅有 {needsCoreNodes.length} 个节点需要本地代理内核(SS / VMess / Trojan / VLESS 等),当前未被路由。
|
||||
</p>
|
||||
<p>
|
||||
这些协议无法被 OmniRoute 直接转发。请在本机启动一个 <code>sing-box</code> 或{" "}
|
||||
<code>clash(Clash.Meta)</code> 内核,并把它暴露为一个 SOCKS5/HTTP 端点,然后在「编辑」中填入该端点(仅接受 127.0.0.1 / localhost)。
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<code className="rounded bg-surface px-2 py-1 border border-border">socks5://127.0.0.1:2080</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigator.clipboard?.writeText("socks5://127.0.0.1:2080")}
|
||||
className="px-2 py-1 rounded border border-border hover:border-primary/50"
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startEdit(sub)}
|
||||
className="px-2 py-1 rounded border border-border hover:border-primary/50"
|
||||
>
|
||||
去配置
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
key={sub.id}
|
||||
className="rounded-lg border border-border bg-surface p-3 flex flex-col gap-2"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium truncate">{sub.name}</span>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded border ${
|
||||
statusBadge[sub.status] || statusBadge.empty
|
||||
}`}
|
||||
>
|
||||
{sub.status === "ok"
|
||||
? t("proxySubscription.statusOk")
|
||||
: sub.status === "error"
|
||||
? t("proxySubscription.statusError")
|
||||
: t("proxySubscription.statusEmpty")}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded border border-border text-text-muted">
|
||||
{sub.mode === "global"
|
||||
? t("proxySubscription.global")
|
||||
: t("proxySubscription.rule")}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded border border-border text-text-muted">
|
||||
{sub.enabled
|
||||
? t("proxySubscription.enabled")
|
||||
: t("proxySubscription.disabled")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
节点数:{sub.lastNodes?.length ?? 0}
|
||||
{needsCoreNodes.length > 0 ? `(${needsCoreNodes.length} 个需本地内核)` : ""}
|
||||
{sub.lastFetchedAt ? ` · 上次同步:${sub.lastFetchedAt}` : ""}
|
||||
{sub.consecutiveFailures > 0 ? ` · 连续失败 ${sub.consecutiveFailures} 次` : ""}
|
||||
{sub.lastErrorAt ? ` · 上次错误:${sub.lastErrorAt}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyId === sub.id}
|
||||
onClick={() => toggleEnabled(sub)}
|
||||
className="px-2 py-1 text-xs rounded border border-border hover:border-primary/50"
|
||||
title={sub.enabled ? "停用" : "启用"}
|
||||
>
|
||||
{sub.enabled ? "停用" : "启用"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyId === sub.id}
|
||||
onClick={() => refresh(sub)}
|
||||
className="px-2 py-1 text-xs rounded border border-border hover:border-primary/50"
|
||||
title="刷新节点"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyId === sub.id}
|
||||
onClick={() => startEdit(sub)}
|
||||
className="px-2 py-1 text-xs rounded border border-border hover:border-primary/50"
|
||||
title="编辑"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyId === sub.id}
|
||||
onClick={() => remove(sub)}
|
||||
className="px-2 py-1 text-xs rounded border border-red-500/30 text-red-600 hover:bg-red-500/10"
|
||||
title="删除"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
<p className="text-xs text-text-muted truncate mt-1" title={sub.url}>
|
||||
{sub.url}
|
||||
</p>
|
||||
{resolveSubError(sub.error) && (
|
||||
<p className="text-xs text-amber-600 mt-1 break-words">
|
||||
{resolveSubError(sub.error)}
|
||||
</p>
|
||||
)}
|
||||
{showCoreHint && (
|
||||
<div className="mt-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 space-y-1.5">
|
||||
<p className="font-medium">
|
||||
{t("proxySubscription.coreNodesHint", { count: needsCoreNodes.length })}
|
||||
</p>
|
||||
<p>{t("proxySubscription.coreEndpointHint")}</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<code className="rounded bg-surface px-2 py-1 border border-border">
|
||||
socks5://127.0.0.1:2080
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigator.clipboard?.writeText("socks5://127.0.0.1:2080")}
|
||||
className="px-2 py-1 rounded border border-border hover:border-primary/50"
|
||||
>
|
||||
{t("proxySubscription.copyEndpoint")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startEdit(sub)}
|
||||
className="px-2 py-1 rounded border border-border hover:border-primary/50"
|
||||
>
|
||||
{t("proxySubscription.configure")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
{t("proxySubscription.nodeSummary", {
|
||||
count: sub.lastNodes?.length ?? 0,
|
||||
coreCount: needsCoreNodes.length,
|
||||
lastFetchedAt: sub.lastFetchedAt ?? "",
|
||||
failures: sub.consecutiveFailures,
|
||||
lastErrorAt: sub.lastErrorAt ?? "",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyId === sub.id}
|
||||
onClick={() => toggleEnabled(sub)}
|
||||
className="px-2 py-1 text-xs rounded border border-border hover:border-primary/50"
|
||||
title={
|
||||
sub.enabled ? t("proxySubscription.disable") : t("proxySubscription.enable")
|
||||
}
|
||||
>
|
||||
{sub.enabled ? t("proxySubscription.disable") : t("proxySubscription.enable")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyId === sub.id}
|
||||
onClick={() => refresh(sub)}
|
||||
className="px-2 py-1 text-xs rounded border border-border hover:border-primary/50"
|
||||
title={t("proxySubscription.refreshNodes")}
|
||||
>
|
||||
{t("proxySubscription.refresh")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyId === sub.id}
|
||||
onClick={() => startEdit(sub)}
|
||||
className="px-2 py-1 text-xs rounded border border-border hover:border-primary/50"
|
||||
title={t("proxySubscription.edit")}
|
||||
>
|
||||
{t("proxySubscription.edit")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyId === sub.id}
|
||||
onClick={() => remove(sub)}
|
||||
className="px-2 py-1 text-xs rounded border border-red-500/30 text-red-600 hover:bg-red-500/10"
|
||||
title={t("proxySubscription.delete")}
|
||||
>
|
||||
{t("proxySubscription.delete")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
); })}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function SettingsError({
|
||||
error: _error,
|
||||
reset,
|
||||
@@ -7,6 +9,8 @@ export default function SettingsError({
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const t = useTranslations("settings");
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center min-h-[400px]"
|
||||
@@ -15,13 +19,13 @@ export default function SettingsError({
|
||||
>
|
||||
<div className="text-center space-y-4">
|
||||
<h2 className="text-xl font-semibold text-red-600 dark:text-red-400">
|
||||
Failed to load settings
|
||||
{t("errorPage.title")}
|
||||
</h2>
|
||||
<p className="text-text-muted max-w-md">
|
||||
We could not load settings right now. Please retry in a few seconds.
|
||||
</p>
|
||||
<p className="text-text-muted max-w-md">{t("errorPage.description")}</p>
|
||||
{_error?.digest && (
|
||||
<p className="text-xs text-text-muted font-mono">Error ID: {_error.digest}</p>
|
||||
<p className="text-xs text-text-muted font-mono">
|
||||
{t("errorPage.errorId", { id: _error.digest })}
|
||||
</p>
|
||||
)}
|
||||
{process.env.NODE_ENV === "development" && _error?.message && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 font-mono">{_error.message}</p>
|
||||
@@ -30,7 +34,7 @@ export default function SettingsError({
|
||||
onClick={reset}
|
||||
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors focus:outline-2 focus:outline-offset-2 focus:outline-primary"
|
||||
>
|
||||
Try Again
|
||||
{t("errorPage.retry")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { TrafficInspectorPageClient } from "./TrafficInspectorPageClient";
|
||||
|
||||
export const metadata = {
|
||||
title: "Traffic Inspector — OmniRoute",
|
||||
description: "Monitor LLM calls + debug any application's HTTPS traffic",
|
||||
};
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("metadata");
|
||||
return {
|
||||
title: t("trafficInspectorTitle"),
|
||||
description: t("trafficInspectorDescription"),
|
||||
};
|
||||
}
|
||||
|
||||
export default function TrafficInspectorPage() {
|
||||
return <TrafficInspectorPageClient />;
|
||||
|
||||
@@ -24,11 +24,12 @@ export interface PipelineViewProps extends Omit<AdvancedAccordionProps, "slug">
|
||||
}
|
||||
|
||||
/** Default demo steps shown when no real pipeline is running. */
|
||||
const DEMO_STEPS: PipelineStep[] = [
|
||||
const DEMO_STEP_CONTENT: Array<
|
||||
Pick<PipelineStep, "id" | "format" | "content" | "status"> & { translationKey: string }
|
||||
> = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Client Request",
|
||||
description: "Request received in client format",
|
||||
translationKey: "pipelineStepClientRequest",
|
||||
format: "claude",
|
||||
content:
|
||||
'{\n "model": "claude-sonnet-4-20250514",\n "messages": [\n { "role": "user", "content": "Hello!" }\n ]\n}',
|
||||
@@ -36,16 +37,14 @@ const DEMO_STEPS: PipelineStep[] = [
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Format Detected",
|
||||
description: "Auto-detected source format",
|
||||
translationKey: "pipelineStepFormatDetected",
|
||||
format: "claude",
|
||||
content: '{\n "detectedFormat": "claude",\n "confidence": "high"\n}',
|
||||
status: "done",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "OpenAI Intermediate",
|
||||
description: "Translated to OpenAI hub format",
|
||||
translationKey: "pipelineStepOpenAIIntermediate",
|
||||
format: "openai",
|
||||
content:
|
||||
'{\n "model": "claude-sonnet-4-20250514",\n "messages": [\n { "role": "user", "content": "Hello!" }\n ],\n "stream": true\n}',
|
||||
@@ -53,8 +52,7 @@ const DEMO_STEPS: PipelineStep[] = [
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "Provider Format",
|
||||
description: "Translated to provider target format",
|
||||
translationKey: "pipelineStepProviderFormat",
|
||||
format: "gemini",
|
||||
content:
|
||||
'{\n "model": "gemini-2.5-flash",\n "contents": [\n { "role": "user", "parts": [{ "text": "Hello!" }] }\n ]\n}',
|
||||
@@ -62,8 +60,7 @@ const DEMO_STEPS: PipelineStep[] = [
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
name: "Provider Response",
|
||||
description: "Streaming response from provider",
|
||||
translationKey: "pipelineStepProviderResponse",
|
||||
format: "openai",
|
||||
content:
|
||||
'data: {"choices":[{"delta":{"content":"Hello! How can I help you today?"}}]}\ndata: [DONE]',
|
||||
@@ -151,7 +148,13 @@ export default function PipelineView({
|
||||
[onOpenChange]
|
||||
);
|
||||
|
||||
const steps = pipelineSteps ?? DEMO_STEPS;
|
||||
const steps =
|
||||
pipelineSteps ??
|
||||
DEMO_STEP_CONTENT.map((step) => ({
|
||||
...step,
|
||||
name: t(step.translationKey as Parameters<typeof t>[0]),
|
||||
description: t(`${step.translationKey}Desc` as Parameters<typeof t>[0]),
|
||||
}));
|
||||
|
||||
const tr = (key: string, fallback: string): string => {
|
||||
try {
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function BadRequestPage() {
|
||||
const t = useTranslations("publicSystem");
|
||||
|
||||
return (
|
||||
<ErrorPageScaffold
|
||||
code="400"
|
||||
icon="rule"
|
||||
title="Bad Request"
|
||||
description="The request payload is invalid or incomplete."
|
||||
title={t("statusPages.400.title")}
|
||||
description={t("statusPages.400.description")}
|
||||
suggestions={[
|
||||
"Review required fields and payload format before retrying.",
|
||||
"If you are using the API, validate the JSON schema locally.",
|
||||
"If this keeps happening, open the request in Translator Playground to inspect the payload.",
|
||||
t("statusPages.400.suggestion1"),
|
||||
t("statusPages.400.suggestion2"),
|
||||
t("statusPages.400.suggestion3"),
|
||||
]}
|
||||
primaryAction={{ href: "/docs", label: "Open Documentation" }}
|
||||
secondaryAction={{ href: "/dashboard/translator", label: "Open Translator" }}
|
||||
primaryAction={{ href: "/docs", label: t("statusPages.400.primaryAction") }}
|
||||
secondaryAction={{
|
||||
href: "/dashboard/translator",
|
||||
label: t("statusPages.400.secondaryAction"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function UnauthorizedPage() {
|
||||
const t = useTranslations("publicSystem");
|
||||
|
||||
return (
|
||||
<ErrorPageScaffold
|
||||
code="401"
|
||||
icon="lock"
|
||||
title="Unauthorized"
|
||||
description="Authentication is required to access this resource."
|
||||
title={t("statusPages.401.title")}
|
||||
description={t("statusPages.401.description")}
|
||||
suggestions={[
|
||||
"Sign in again and retry the operation.",
|
||||
"For API calls, confirm the Bearer token is present and valid.",
|
||||
"If the token was recently rotated, update your client credentials.",
|
||||
t("statusPages.401.suggestion1"),
|
||||
t("statusPages.401.suggestion2"),
|
||||
t("statusPages.401.suggestion3"),
|
||||
]}
|
||||
primaryAction={{ href: "/login", label: "Go to Login" }}
|
||||
secondaryAction={{ href: "/dashboard/api-manager", label: "Manage API Keys" }}
|
||||
primaryAction={{ href: "/login", label: t("statusPages.401.primaryAction") }}
|
||||
secondaryAction={{
|
||||
href: "/dashboard/api-manager",
|
||||
label: t("statusPages.401.secondaryAction"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function ForbiddenStatusPage() {
|
||||
const t = useTranslations("publicSystem");
|
||||
|
||||
return (
|
||||
<ErrorPageScaffold
|
||||
code="403"
|
||||
icon="gpp_bad"
|
||||
title="Forbidden"
|
||||
description="Your request was understood, but access is denied by policy."
|
||||
title={t("statusPages.403.title")}
|
||||
description={t("statusPages.403.description")}
|
||||
suggestions={[
|
||||
"Check IP allowlist/blocklist rules in settings.",
|
||||
"Verify model and budget policies assigned to your API key.",
|
||||
"Ask an administrator to grant the required permission scope.",
|
||||
t("statusPages.403.suggestion1"),
|
||||
t("statusPages.403.suggestion2"),
|
||||
t("statusPages.403.suggestion3"),
|
||||
]}
|
||||
primaryAction={{ href: "/forbidden", label: "Open Access Help" }}
|
||||
primaryAction={{ href: "/forbidden", label: t("statusPages.403.primaryAction") }}
|
||||
secondaryAction={{
|
||||
href: "/dashboard/settings?tab=security",
|
||||
label: "Open Security Settings",
|
||||
label: t("statusPages.403.secondaryAction"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function RequestTimeoutPage() {
|
||||
const t = useTranslations("publicSystem");
|
||||
|
||||
return (
|
||||
<ErrorPageScaffold
|
||||
code="408"
|
||||
icon="timer_off"
|
||||
title="Request Timeout"
|
||||
description="The server did not receive a complete request in time."
|
||||
title={t("statusPages.408.title")}
|
||||
description={t("statusPages.408.description")}
|
||||
suggestions={[
|
||||
"Retry the request with a smaller payload.",
|
||||
"Check your network stability and VPN/proxy latency.",
|
||||
"For long operations, enable streaming or split the request.",
|
||||
t("statusPages.408.suggestion1"),
|
||||
t("statusPages.408.suggestion2"),
|
||||
t("statusPages.408.suggestion3"),
|
||||
]}
|
||||
primaryAction={{ href: "/dashboard/endpoint", label: "Open Endpoint Guide" }}
|
||||
secondaryAction={{ href: "/status", label: "Check Network Status" }}
|
||||
primaryAction={{ href: "/dashboard/endpoint", label: t("statusPages.408.primaryAction") }}
|
||||
secondaryAction={{ href: "/status", label: t("statusPages.408.secondaryAction") }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function TooManyRequestsPage() {
|
||||
const t = useTranslations("publicSystem");
|
||||
|
||||
return (
|
||||
<ErrorPageScaffold
|
||||
code="429"
|
||||
icon="hourglass_top"
|
||||
title="Too Many Requests"
|
||||
description="Rate limits were exceeded for this client, key, or provider."
|
||||
title={t("statusPages.429.title")}
|
||||
description={t("statusPages.429.description")}
|
||||
suggestions={[
|
||||
"Wait for cooldown and retry after the suggested interval.",
|
||||
"Switch to a combo with fallback providers.",
|
||||
"Tune provider resilience/rate-limit profiles in settings.",
|
||||
t("statusPages.429.suggestion1"),
|
||||
t("statusPages.429.suggestion2"),
|
||||
t("statusPages.429.suggestion3"),
|
||||
]}
|
||||
primaryAction={{
|
||||
href: "/dashboard/settings?tab=resilience",
|
||||
label: "Open Resilience Settings",
|
||||
label: t("statusPages.429.primaryAction"),
|
||||
}}
|
||||
secondaryAction={{ href: "/dashboard/combos", label: "Open Combos" }}
|
||||
secondaryAction={{ href: "/dashboard/combos", label: t("statusPages.429.secondaryAction") }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function InternalServerErrorPage() {
|
||||
const t = useTranslations("publicSystem");
|
||||
|
||||
return (
|
||||
<ErrorPageScaffold
|
||||
code="500"
|
||||
icon="warning"
|
||||
title="Internal Server Error"
|
||||
description="An unexpected server-side error occurred while processing your request."
|
||||
title={t("statusPages.500.title")}
|
||||
description={t("statusPages.500.description")}
|
||||
suggestions={[
|
||||
"Retry once in a few seconds.",
|
||||
"Check health telemetry and server logs for correlated request IDs.",
|
||||
"If persistent, report the issue with timestamp and request context.",
|
||||
t("statusPages.500.suggestion1"),
|
||||
t("statusPages.500.suggestion2"),
|
||||
t("statusPages.500.suggestion3"),
|
||||
]}
|
||||
primaryAction={{ href: "/dashboard/health", label: "Open Health Dashboard" }}
|
||||
secondaryAction={{ href: "/dashboard/logs", label: "Open Logs" }}
|
||||
primaryAction={{ href: "/dashboard/health", label: t("statusPages.500.primaryAction") }}
|
||||
secondaryAction={{ href: "/dashboard/logs", label: t("statusPages.500.secondaryAction") }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function BadGatewayPage() {
|
||||
const t = useTranslations("publicSystem");
|
||||
|
||||
return (
|
||||
<ErrorPageScaffold
|
||||
code="502"
|
||||
icon="hub"
|
||||
title="Bad Gateway"
|
||||
description="Upstream provider or gateway integration returned an invalid response."
|
||||
title={t("statusPages.502.title")}
|
||||
description={t("statusPages.502.description")}
|
||||
suggestions={[
|
||||
"Retry with another provider or active combo route.",
|
||||
"Check provider credentials and model availability.",
|
||||
"Inspect translator output if format conversion is involved.",
|
||||
t("statusPages.502.suggestion1"),
|
||||
t("statusPages.502.suggestion2"),
|
||||
t("statusPages.502.suggestion3"),
|
||||
]}
|
||||
primaryAction={{ href: "/dashboard/providers", label: "Open Providers" }}
|
||||
secondaryAction={{ href: "/dashboard/translator", label: "Open Translator" }}
|
||||
primaryAction={{ href: "/dashboard/providers", label: t("statusPages.502.primaryAction") }}
|
||||
secondaryAction={{
|
||||
href: "/dashboard/translator",
|
||||
label: t("statusPages.502.secondaryAction"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function ServiceUnavailablePage() {
|
||||
const t = useTranslations("publicSystem");
|
||||
|
||||
return (
|
||||
<ErrorPageScaffold
|
||||
code="503"
|
||||
icon="build_circle"
|
||||
title="Service Unavailable"
|
||||
description="The service is temporarily unavailable due to maintenance or degraded dependencies."
|
||||
title={t("statusPages.503.title")}
|
||||
description={t("statusPages.503.description")}
|
||||
suggestions={[
|
||||
"Wait a moment and retry.",
|
||||
"Check maintenance notices and system status.",
|
||||
"Use fallback providers if your workflow is latency-sensitive.",
|
||||
t("statusPages.503.suggestion1"),
|
||||
t("statusPages.503.suggestion2"),
|
||||
t("statusPages.503.suggestion3"),
|
||||
]}
|
||||
primaryAction={{ href: "/maintenance", label: "Maintenance Details" }}
|
||||
secondaryAction={{ href: "/status", label: "System Status" }}
|
||||
primaryAction={{ href: "/maintenance", label: t("statusPages.503.primaryAction") }}
|
||||
secondaryAction={{ href: "/status", label: t("statusPages.503.secondaryAction") }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { createProviderConnection } from "@/models";
|
||||
import { parseTraeCallbackQuery } from "./parseCallback";
|
||||
|
||||
@@ -29,7 +30,7 @@ import { parseTraeCallbackQuery } from "./parseCallback";
|
||||
* authorize URL; Trae echoes it back as `loginTraceID`. The modal verifies
|
||||
* the echoed state before trusting the postMessage.
|
||||
*/
|
||||
function htmlClose(message: Record<string, unknown>): NextResponse {
|
||||
function htmlClose(message: Record<string, unknown>, t: (key: string) => string): NextResponse {
|
||||
// Embedding values: only emit the small/sanitized status payload — never the
|
||||
// raw token. We post to the loopback origin pair (localhost + 127.0.0.1) on
|
||||
// this same port rather than "*": Trae forces the callback onto 127.0.0.1,
|
||||
@@ -40,10 +41,12 @@ function htmlClose(message: Record<string, unknown>): NextResponse {
|
||||
type: "trae-oauth-callback",
|
||||
...message,
|
||||
}).replace(/</g, "\\u003c");
|
||||
const title = message.success ? t("traeAuthorizationSuccess") : t("traeAuthorizationFailed");
|
||||
const body = message.success ? t("closeAuthorizationWindow") : t("returnToDashboard");
|
||||
return new NextResponse(
|
||||
`<!doctype html><html><body style="font:16px sans-serif;padding:40px">
|
||||
<h2 style="margin:0 0 8px">Trae authorization ${message.success ? "✓" : "failed"}</h2>
|
||||
<p>${message.success ? "You can close this window." : "Return to the dashboard."}</p>
|
||||
<h2 style="margin:0 0 8px">${title}</h2>
|
||||
<p>${body}</p>
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
@@ -64,21 +67,25 @@ function htmlClose(message: Record<string, unknown>): NextResponse {
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const t = await getTranslations("auth");
|
||||
const url = new URL(request.url);
|
||||
const q = url.searchParams;
|
||||
const parsed = parseTraeCallbackQuery(q);
|
||||
if (!parsed.ok) {
|
||||
return htmlClose({ success: false, error: parsed.error });
|
||||
return htmlClose({ success: false, error: parsed.error }, t);
|
||||
}
|
||||
try {
|
||||
const connection: any = await createProviderConnection(parsed.record);
|
||||
return htmlClose({
|
||||
success: true,
|
||||
connectionId: connection.id,
|
||||
loginTraceId: q.get("loginTraceID") || null,
|
||||
});
|
||||
return htmlClose(
|
||||
{
|
||||
success: true,
|
||||
connectionId: connection.id,
|
||||
loginTraceId: q.get("loginTraceID") || null,
|
||||
},
|
||||
t
|
||||
);
|
||||
} catch (err: any) {
|
||||
console.error("[trae callback] error:", err);
|
||||
return htmlClose({ success: false, error: "Internal error during callback" });
|
||||
return htmlClose({ success: false, error: "Internal error during callback" }, t);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Button from "@/shared/components/Button";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import {
|
||||
@@ -17,6 +18,8 @@ type Status = "validating" | "ready" | "starting" | "awaiting" | "saving" | "suc
|
||||
* tokens back to the ticket-gated completion endpoint for persistence.
|
||||
*/
|
||||
export default function CodexConnectClient({ token }: { token: string }) {
|
||||
const t = useTranslations("auth");
|
||||
const tc = useTranslations("common");
|
||||
const [status, setStatus] = useState<Status>("validating");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [userCode, setUserCode] = useState<CodexUserCode | null>(null);
|
||||
@@ -34,12 +37,12 @@ export default function CodexConnectClient({ token }: { token: string }) {
|
||||
setStatus("ready");
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setError(data?.error || "This link is invalid or expired.");
|
||||
setError(data?.error || t("codexLinkInvalidOrExpired"));
|
||||
setStatus("error");
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setError("Could not reach the server to validate this link.");
|
||||
setError(t("codexValidationServerError"));
|
||||
setStatus("error");
|
||||
}
|
||||
}
|
||||
@@ -47,7 +50,7 @@ export default function CodexConnectClient({ token }: { token: string }) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token]);
|
||||
}, [token, t]);
|
||||
|
||||
// Abort any in-flight device flow if the visitor leaves.
|
||||
useEffect(() => () => abortRef.current?.abort(), []);
|
||||
@@ -79,26 +82,26 @@ export default function CodexConnectClient({ token }: { token: string }) {
|
||||
if (res.ok && data?.success) {
|
||||
setStatus("success");
|
||||
} else {
|
||||
setError(data?.error || "Could not save the connection. The link may have expired.");
|
||||
setError(data?.error || t("codexSaveConnectionError"));
|
||||
setStatus("error");
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof CodexDeviceFlowError) {
|
||||
setError(
|
||||
err.code === "device_disabled"
|
||||
? "Device code login is disabled for this OpenAI account. Enable it in ChatGPT security settings (or ask your workspace admin)."
|
||||
? t("codexDeviceLoginDisabled")
|
||||
: err.code === "timeout"
|
||||
? "Authorization timed out. Click Start again to retry."
|
||||
? t("codexAuthorizationTimedOut")
|
||||
: err.code === "aborted"
|
||||
? "Authentication was cancelled."
|
||||
? t("authenticationCancelled")
|
||||
: err.message
|
||||
);
|
||||
} else {
|
||||
setError("Unexpected error during authentication. Please try again.");
|
||||
setError(t("unexpectedAuthenticationError"));
|
||||
}
|
||||
setStatus("error");
|
||||
}
|
||||
}, [token]);
|
||||
}, [token, t]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-bg-base px-4 py-10">
|
||||
@@ -107,40 +110,33 @@ export default function CodexConnectClient({ token }: { token: string }) {
|
||||
<div className="mb-3 inline-flex h-14 w-14 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<span className="material-symbols-outlined text-[28px]">key</span>
|
||||
</div>
|
||||
<h1 className="text-lg font-semibold text-text-main">Connect OpenAI Codex</h1>
|
||||
<p className="mt-1 text-sm text-text-muted">
|
||||
Authorize a ChatGPT account to finish setting up this connection.
|
||||
</p>
|
||||
<h1 className="text-lg font-semibold text-text-main">{t("connectOpenAiCodexTitle")}</h1>
|
||||
<p className="mt-1 text-sm text-text-muted">{t("codexConnectDescription")}</p>
|
||||
</div>
|
||||
|
||||
{status === "validating" && (
|
||||
<p className="text-center text-sm text-text-muted">Validating link…</p>
|
||||
<p className="text-center text-sm text-text-muted">{t("validatingCodexLink")}</p>
|
||||
)}
|
||||
|
||||
{status === "ready" && (
|
||||
<div className="text-center">
|
||||
<p className="mb-4 text-sm text-text-muted">
|
||||
Click below to generate a one-time code, then sign in to OpenAI.
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-text-muted">{t("codexGenerateCodeDescription")}</p>
|
||||
<Button onClick={start} icon="login" className="w-full">
|
||||
Start
|
||||
{t("startCodexFlow")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "starting" && (
|
||||
<p className="text-center text-sm text-text-muted">Requesting code from OpenAI…</p>
|
||||
<p className="text-center text-sm text-text-muted">{t("requestingOpenAiCode")}</p>
|
||||
)}
|
||||
|
||||
{status === "awaiting" && userCode && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-text-muted">
|
||||
1. Open the OpenAI verification page and 2. enter this code. This page updates
|
||||
automatically once you authorize.
|
||||
</p>
|
||||
<p className="text-sm text-text-muted">{t("codexVerificationInstructions")}</p>
|
||||
|
||||
<div className="rounded-lg border border-border bg-bg-base p-3">
|
||||
<p className="mb-1 text-xs text-text-muted">Your code</p>
|
||||
<p className="mb-1 text-xs text-text-muted">{t("yourCode")}</p>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<code className="text-lg font-semibold tracking-widest text-text-main">
|
||||
{userCode.userCode}
|
||||
@@ -151,7 +147,7 @@ export default function CodexConnectClient({ token }: { token: string }) {
|
||||
icon="content_copy"
|
||||
onClick={() => copy(userCode.userCode, "code")}
|
||||
>
|
||||
{copied === "code" ? "Copied" : "Copy"}
|
||||
{copied === "code" ? tc("copied") : tc("copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,23 +158,23 @@ export default function CodexConnectClient({ token }: { token: string }) {
|
||||
icon="open_in_new"
|
||||
onClick={() => window.open(userCode.verificationUri, "_blank", "noopener")}
|
||||
>
|
||||
Open verification page
|
||||
{t("openVerificationPage")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="link"
|
||||
onClick={() => copy(userCode.verificationUri, "url")}
|
||||
>
|
||||
{copied === "url" ? "Copied" : "Copy URL"}
|
||||
{copied === "url" ? tc("copied") : t("copyUrlShort")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-text-muted">Waiting for authorization…</p>
|
||||
<p className="text-center text-xs text-text-muted">{t("waitingForAuthorization")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "saving" && (
|
||||
<p className="text-center text-sm text-text-muted">Saving connection…</p>
|
||||
<p className="text-center text-sm text-text-muted">{t("savingConnection")}</p>
|
||||
)}
|
||||
|
||||
{status === "success" && (
|
||||
@@ -186,10 +182,8 @@ export default function CodexConnectClient({ token }: { token: string }) {
|
||||
<div className="mb-3 inline-flex h-12 w-12 items-center justify-center rounded-full bg-green-500/10 text-green-500">
|
||||
<span className="material-symbols-outlined text-[26px]">check_circle</span>
|
||||
</div>
|
||||
<p className="font-medium text-text-main">Connected!</p>
|
||||
<p className="mt-1 text-sm text-text-muted">
|
||||
The Codex account was registered. You can close this tab.
|
||||
</p>
|
||||
<p className="font-medium text-text-main">{t("codexConnected")}</p>
|
||||
<p className="mt-1 text-sm text-text-muted">{t("codexConnectionRegistered")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -200,7 +194,7 @@ export default function CodexConnectClient({ token }: { token: string }) {
|
||||
</div>
|
||||
<p className="mb-4 text-sm text-text-muted">{error}</p>
|
||||
<Button variant="secondary" icon="refresh" onClick={start} className="w-full">
|
||||
Try again
|
||||
{t("tryAgain")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,7 @@ import path from "node:path";
|
||||
import { marked } from "marked";
|
||||
import { sanitizeDocsHtml } from "@/lib/docsSanitizer";
|
||||
import { resolveSafeI18nSectionDir } from "@/lib/docsI18nPath";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
// ── Locale detection ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -111,9 +112,10 @@ export async function generateMetadata(props: {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug);
|
||||
if (!page) return {};
|
||||
const t = await getTranslations("docs");
|
||||
|
||||
return {
|
||||
title: `${page.data.title} — OmniRoute Docs`,
|
||||
description: page.data.description ?? `OmniRoute documentation: ${page.data.title}`,
|
||||
title: t("pageMetadataTitle", { title: page.data.title }),
|
||||
description: page.data.description ?? t("pageMetadataDescription", { title: page.data.title }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import { Metadata } from "next";
|
||||
import { ApiExplorerClient } from "../components/ApiExplorerClient";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "API Explorer — OmniRoute Docs",
|
||||
description: "Interactive API explorer — try OmniRoute endpoints live with real-time responses",
|
||||
};
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("docs");
|
||||
return {
|
||||
title: t("apiExplorerMetadataTitle"),
|
||||
description: t("apiExplorerMetadataDescription"),
|
||||
};
|
||||
}
|
||||
|
||||
export default function ApiExplorerPage() {
|
||||
const t = useTranslations("docs");
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-text-main mb-2">API Explorer</h1>
|
||||
<p className="text-text-muted mb-8">
|
||||
Try OmniRoute endpoints live. Select an endpoint, configure your request, and see the
|
||||
response in real time.
|
||||
</p>
|
||||
<h1 className="text-3xl font-bold text-text-main mb-2">{t("apiExplorerTitle")}</h1>
|
||||
<p className="text-text-muted mb-8">{t("apiExplorerDescription")}</p>
|
||||
<ApiExplorerClient />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
OPENAPI_ENDPOINTS,
|
||||
OPENAPI_TAGS,
|
||||
@@ -88,6 +89,8 @@ const EXAMPLE_BODIES: Record<string, string> = {
|
||||
};
|
||||
|
||||
export function ApiExplorerClient() {
|
||||
const t = useTranslations("docs");
|
||||
const te = useTranslations("endpoint");
|
||||
const [selected, setSelected] = useState<OpenApiEndpoint | null>(null);
|
||||
const [baseUrl, setBaseUrl] = useState("http://localhost:20128");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
@@ -130,13 +133,17 @@ export function ApiExplorerClient() {
|
||||
const contentType = res.headers.get("content-type") || "";
|
||||
|
||||
if (contentType.includes("text/event-stream")) {
|
||||
setResponse("SSE stream started — check the terminal/devtools for real-time output.");
|
||||
setResponse(t("apiExplorerSseStarted"));
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setResponse(JSON.stringify(data, null, 2));
|
||||
}
|
||||
} catch (err) {
|
||||
setResponse(`Error: ${err instanceof Error ? err.message : "Request failed"}`);
|
||||
setResponse(
|
||||
t("apiExplorerError", {
|
||||
message: err instanceof Error ? err.message : te("requestFailed"),
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -157,7 +164,7 @@ export function ApiExplorerClient() {
|
||||
className={`px-2.5 py-1 text-xs rounded-full border transition-colors
|
||||
${!filterTag ? "bg-primary/10 text-primary border-primary/20" : "border-border text-text-muted hover:text-text-main"}`}
|
||||
>
|
||||
All
|
||||
{te("all")}
|
||||
</button>
|
||||
{OPENAPI_TAGS.map((tag) => (
|
||||
<button
|
||||
@@ -211,7 +218,7 @@ export function ApiExplorerClient() {
|
||||
<span className="font-mono text-sm text-text-main">{selected.path}</span>
|
||||
{selected.requiresAuth && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-mono rounded border border-amber-500/30 bg-amber-500/10 text-amber-600">
|
||||
auth
|
||||
{t("apiExplorerAuth")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -222,7 +229,7 @@ export function ApiExplorerClient() {
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-text-muted block mb-1">Base URL</label>
|
||||
<label className="text-xs text-text-muted block mb-1">{te("baseUrl")}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={baseUrl}
|
||||
@@ -231,7 +238,7 @@ export function ApiExplorerClient() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted block mb-1">API Key</label>
|
||||
<label className="text-xs text-text-muted block mb-1">{te("apiKey")}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
@@ -244,7 +251,7 @@ export function ApiExplorerClient() {
|
||||
|
||||
{selected.method !== "GET" && selected.hasRequestBody && (
|
||||
<div>
|
||||
<label className="text-xs text-text-muted block mb-1">Request Body</label>
|
||||
<label className="text-xs text-text-muted block mb-1">{te("requestBody")}</label>
|
||||
<textarea
|
||||
value={requestBody}
|
||||
onChange={(e) => setRequestBody(e.target.value)}
|
||||
@@ -259,12 +266,14 @@ export function ApiExplorerClient() {
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? "Sending..." : "Send Request"}
|
||||
{loading ? te("sending") : te("sendRequest")}
|
||||
</button>
|
||||
|
||||
{response !== null && (
|
||||
<div>
|
||||
<label className="text-xs text-text-muted block mb-1">Response</label>
|
||||
<label className="text-xs text-text-muted block mb-1">
|
||||
{t("apiExplorerResponseLabel")}
|
||||
</label>
|
||||
<pre className="bg-bg-subtle p-4 rounded-lg overflow-x-auto text-xs font-mono text-text-main max-h-80">
|
||||
{response}
|
||||
</pre>
|
||||
@@ -274,10 +283,8 @@ export function ApiExplorerClient() {
|
||||
) : (
|
||||
<div className="text-center py-16 text-text-muted">
|
||||
<span className="material-symbols-outlined text-4xl mb-2 block">api</span>
|
||||
<p className="text-lg font-medium">Select an endpoint to explore</p>
|
||||
<p className="text-sm mt-1">
|
||||
Choose an API from the sidebar to see details and try it live
|
||||
</p>
|
||||
<p className="text-lg font-medium">{t("apiExplorerSelectEndpoint")}</p>
|
||||
<p className="text-sm mt-1">{t("apiExplorerChooseApi")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function FeedbackWidget({ slug }: { slug: string }) {
|
||||
const t = useTranslations("docs");
|
||||
const tc = useTranslations("common");
|
||||
const [feedback, setFeedback] = useState<"yes" | "no" | null>(null);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
@@ -22,28 +25,28 @@ export function FeedbackWidget({ slug }: { slug: string }) {
|
||||
<span className="material-symbols-outlined text-primary text-2xl block mb-1">
|
||||
check_circle
|
||||
</span>
|
||||
<p className="text-sm text-text-main">Thanks for your feedback!</p>
|
||||
<p className="text-sm text-text-main">{t("feedbackThanks")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-8 p-4 bg-bg-subtle border border-border rounded-lg">
|
||||
<p className="text-sm text-text-main mb-3">Was this page helpful?</p>
|
||||
<p className="text-sm text-text-main mb-3">{t("feedbackQuestion")}</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => handleFeedback("yes")}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-border rounded-lg hover:border-primary hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">thumb_up</span>
|
||||
Yes
|
||||
{tc("yes")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleFeedback("no")}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-border rounded-lg hover:border-red-400 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">thumb_down</span>
|
||||
No
|
||||
{tc("no")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,45 +5,49 @@ import type { ReactNode } from "react";
|
||||
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
|
||||
import { Suspense } from "react";
|
||||
import LanguageSelector from "@/shared/components/LanguageSelector";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export const metadata = {
|
||||
title: {
|
||||
template: "%s — OmniRoute Docs",
|
||||
default: "OmniRoute Documentation",
|
||||
},
|
||||
description:
|
||||
"Comprehensive documentation for OmniRoute AI gateway — setup, API, compression, deployment, and more.",
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("docs");
|
||||
return {
|
||||
title: {
|
||||
template: t("metadataTitleTemplate"),
|
||||
default: t("metadataDefaultTitle"),
|
||||
},
|
||||
description: t("metadataDescription"),
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const docsLayoutOptions: BaseLayoutProps = {
|
||||
nav: {
|
||||
title: "OmniRoute Docs",
|
||||
url: "/docs",
|
||||
children: (
|
||||
<Suspense fallback={<div className="w-24 h-8" />}>
|
||||
<LanguageSelector />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
links: [
|
||||
{
|
||||
text: "Docs Home",
|
||||
export default async function Layout({ children }: { children: ReactNode }) {
|
||||
const t = await getTranslations("docs");
|
||||
const docsLayoutOptions: BaseLayoutProps = {
|
||||
nav: {
|
||||
title: t("layoutNavTitle"),
|
||||
url: "/docs",
|
||||
children: (
|
||||
<Suspense fallback={<div className="w-24 h-8" />}>
|
||||
<LanguageSelector />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
text: "\u2190 Back to Dashboard",
|
||||
url: "/dashboard",
|
||||
secondary: true,
|
||||
},
|
||||
],
|
||||
githubUrl: "https://github.com/diegosouzapw/OmniRoute",
|
||||
};
|
||||
links: [
|
||||
{
|
||||
text: t("layoutHomeLink"),
|
||||
url: "/docs",
|
||||
},
|
||||
{
|
||||
text: t("layoutDashboardLink"),
|
||||
url: "/dashboard",
|
||||
secondary: true,
|
||||
},
|
||||
],
|
||||
githubUrl: "https://github.com/diegosouzapw/OmniRoute",
|
||||
};
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<RootProvider
|
||||
theme={{
|
||||
|
||||
@@ -1,79 +1,90 @@
|
||||
import Link from "next/link";
|
||||
import { Metadata } from "next";
|
||||
import { source } from "@/lib/source";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "OmniRoute Documentation",
|
||||
description:
|
||||
"Everything you need to route, compress, and scale your AI — setup guides, API reference, compression, deployment, and more.",
|
||||
openGraph: {
|
||||
title: "OmniRoute Documentation",
|
||||
description:
|
||||
"Comprehensive docs for OmniRoute AI gateway — setup, API, compression, deployment, and more.",
|
||||
type: "website",
|
||||
url: "https://omniroute.online/docs",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "OmniRoute Documentation",
|
||||
description: "Comprehensive docs for OmniRoute AI gateway",
|
||||
},
|
||||
};
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("docs");
|
||||
return {
|
||||
title: t("homeTitle"),
|
||||
description: t("homeMetadataDescription"),
|
||||
openGraph: {
|
||||
title: t("homeTitle"),
|
||||
description: t("homeMetadataDescription"),
|
||||
type: "website",
|
||||
url: "https://omniroute.online/docs",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: t("homeTitle"),
|
||||
description: t("homeTwitterDescription"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const featuredLinks = [
|
||||
{
|
||||
href: "/docs/getting-started/quick-start",
|
||||
title: "Quick Start",
|
||||
titleKey: "featuredQuickStartTitle",
|
||||
icon: "rocket_launch",
|
||||
desc: "Get OmniRoute running in 3 minutes",
|
||||
descriptionKey: "featuredQuickStartDescription",
|
||||
},
|
||||
{
|
||||
href: "/docs/getting-started/auto-combo-guide",
|
||||
title: "Auto-Combo Guide",
|
||||
titleKey: "featuredAutoComboTitle",
|
||||
icon: "auto_awesome",
|
||||
desc: "Let OmniRoute pick the best AI for you",
|
||||
descriptionKey: "featuredAutoComboDescription",
|
||||
},
|
||||
{
|
||||
href: "/docs/getting-started/providers-guide",
|
||||
title: "Providers Guide",
|
||||
titleKey: "featuredProvidersTitle",
|
||||
icon: "link",
|
||||
desc: "Connect AI providers in minutes",
|
||||
descriptionKey: "featuredProvidersDescription",
|
||||
},
|
||||
];
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: "For Non-Tech Users",
|
||||
subtitle: "Get started quickly — no technical background needed",
|
||||
titleKey: "nonTechUsersTitle",
|
||||
subtitleKey: "nonTechUsersSubtitle",
|
||||
icon: "rocket_launch",
|
||||
color: "green",
|
||||
folders: ["getting-started", "guides"],
|
||||
},
|
||||
{
|
||||
title: "For Tech Users",
|
||||
subtitle: "Deep dive into architecture, APIs, and internals",
|
||||
titleKey: "techUsersTitle",
|
||||
subtitleKey: "techUsersSubtitle",
|
||||
icon: "code",
|
||||
color: "blue",
|
||||
folders: ["architecture", "reference", "frameworks", "routing", "security", "compression", "ops"],
|
||||
folders: [
|
||||
"architecture",
|
||||
"reference",
|
||||
"frameworks",
|
||||
"routing",
|
||||
"security",
|
||||
"compression",
|
||||
"ops",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function DocsHomePage() {
|
||||
export default async function DocsHomePage() {
|
||||
const t = await getTranslations("docs");
|
||||
const pages = source.getPages();
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto py-8 px-4">
|
||||
<div className="text-center mb-16 mt-8">
|
||||
<h1 className="text-4xl font-bold text-fd-foreground mb-5">OmniRoute Documentation</h1>
|
||||
<p className="text-lg text-fd-muted-foreground mb-6">
|
||||
Everything you need to route, compress, and scale your AI
|
||||
</p>
|
||||
<h1 className="text-4xl font-bold text-fd-foreground mb-5">{t("homeTitle")}</h1>
|
||||
<p className="text-lg text-fd-muted-foreground mb-6">{t("homeDescription")}</p>
|
||||
<p className="text-sm text-fd-muted-foreground">
|
||||
Press{" "}
|
||||
<kbd className="px-1.5 py-0.5 bg-fd-muted border border-fd-border rounded font-mono text-xs">
|
||||
Ctrl K
|
||||
</kbd>{" "}
|
||||
to search the docs
|
||||
{t.rich("homeSearchHint", {
|
||||
kbd: (chunks) => (
|
||||
<kbd className="px-1.5 py-0.5 bg-fd-muted border border-fd-border rounded font-mono text-xs">
|
||||
{chunks}
|
||||
</kbd>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -89,9 +100,9 @@ export default function DocsHomePage() {
|
||||
{link.icon}
|
||||
</span>
|
||||
<span className="font-semibold text-fd-foreground group-hover:text-fd-primary transition-colors">
|
||||
{link.title}
|
||||
{t(link.titleKey)}
|
||||
</span>
|
||||
<span className="text-sm text-fd-muted-foreground mt-2">{link.desc}</span>
|
||||
<span className="text-sm text-fd-muted-foreground mt-2">{t(link.descriptionKey)}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
@@ -103,7 +114,7 @@ export default function DocsHomePage() {
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={section.title}
|
||||
key={section.titleKey}
|
||||
className="border border-fd-border rounded-xl p-6 hover:border-fd-primary/30 transition-colors bg-fd-card/50"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
@@ -111,8 +122,10 @@ export default function DocsHomePage() {
|
||||
{section.icon}
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-fd-foreground">{section.title}</h2>
|
||||
<p className="text-sm text-fd-muted-foreground">{section.subtitle}</p>
|
||||
<h2 className="text-base font-semibold text-fd-foreground">
|
||||
{t(section.titleKey)}
|
||||
</h2>
|
||||
<p className="text-sm text-fd-muted-foreground">{t(section.subtitleKey)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="space-y-2.5">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
/**
|
||||
* Server Error Page — P-1
|
||||
*
|
||||
@@ -13,6 +15,9 @@ interface ErrorProps {
|
||||
}
|
||||
|
||||
export default function Error({ error, reset }: ErrorProps) {
|
||||
const t = useTranslations("publicSystem");
|
||||
const tc = useTranslations("common");
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center min-h-[60vh] p-6 text-center"
|
||||
@@ -23,21 +28,20 @@ export default function Error({ error, reset }: ErrorProps) {
|
||||
🔧
|
||||
</div>
|
||||
<h1 className="text-[28px] font-bold mb-2 text-[var(--color-text-main)]">
|
||||
Internal Server Error
|
||||
{t("error.title")}
|
||||
</h1>
|
||||
<p className="text-[15px] text-[var(--color-text-muted)] max-w-[400px] leading-relaxed mb-2">
|
||||
Something went wrong while processing your request. Our team has been notified and is
|
||||
working on a fix.
|
||||
{t("error.description")}
|
||||
</p>
|
||||
{error?.digest && (
|
||||
<p className="text-xs text-[var(--color-text-muted)] mb-6 font-mono">
|
||||
Error ID: {error.digest}
|
||||
{t("error.errorId", { id: error.digest })}
|
||||
</p>
|
||||
)}
|
||||
{process.env.NODE_ENV === "development" && error?.message && (
|
||||
<pre
|
||||
className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-500 text-xs max-w-[600px] overflow-auto text-left mb-6"
|
||||
aria-label="Error details"
|
||||
aria-label={t("error.detailsAriaLabel")}
|
||||
>
|
||||
{error.message}
|
||||
</pre>
|
||||
@@ -45,24 +49,24 @@ export default function Error({ error, reset }: ErrorProps) {
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={reset}
|
||||
aria-label="Retry loading the page"
|
||||
aria-label={t("error.retryAriaLabel")}
|
||||
className="px-6 py-2.5 rounded-lg text-white text-sm font-semibold cursor-pointer transition-all duration-200 motion-reduce:transition-none bg-[var(--color-accent)] hover:bg-[var(--color-accent-hover)] focus:outline-2 focus:outline-offset-2 focus:outline-[var(--color-accent)]"
|
||||
>
|
||||
Try Again
|
||||
{t("error.tryAgain")}
|
||||
</button>
|
||||
<a
|
||||
href="/dashboard"
|
||||
className="px-6 py-2.5 rounded-lg text-[var(--color-text-main)] text-sm font-semibold cursor-pointer transition-all duration-200 motion-reduce:transition-none border border-[var(--color-border)] hover:bg-[var(--color-bg-alt)] no-underline focus:outline-2 focus:outline-offset-2 focus:outline-[var(--color-accent)]"
|
||||
aria-label="Return to dashboard"
|
||||
aria-label={t("error.dashboardAriaLabel")}
|
||||
>
|
||||
Go to Dashboard
|
||||
{tc("goToDashboard")}
|
||||
</a>
|
||||
<a
|
||||
href="/status"
|
||||
className="px-6 py-2.5 rounded-lg text-[var(--color-text-main)] text-sm font-semibold cursor-pointer transition-all duration-200 motion-reduce:transition-none border border-[var(--color-border)] hover:bg-[var(--color-bg-alt)] no-underline focus:outline-2 focus:outline-offset-2 focus:outline-[var(--color-accent)]"
|
||||
aria-label="Open system status"
|
||||
aria-label={t("error.statusAriaLabel")}
|
||||
>
|
||||
System Status
|
||||
{t("error.systemStatus")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -48,14 +48,12 @@ export default function ForgotPasswordPage() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-lg font-semibold mb-1">Reset via App Data</h2>
|
||||
<p className="text-sm text-text-muted mb-3">
|
||||
Delete the settings file from the app data directory to reset your password:
|
||||
</p>
|
||||
<h2 className="text-lg font-semibold mb-1">{t("resetViaAppDataTitle")}</h2>
|
||||
<p className="text-sm text-text-muted mb-3">{t("resetViaAppDataDescription")}</p>
|
||||
<ol className="text-sm text-text-muted space-y-2 list-decimal list-inside mb-3">
|
||||
<li>Quit the OmniRoute desktop app completely</li>
|
||||
<li>{t("quitDesktopApp")}</li>
|
||||
<li>
|
||||
Navigate to the app data directory:
|
||||
{t("navigateToAppDataDirectory")}
|
||||
{dataDir ? (
|
||||
<div className="bg-black/30 rounded-lg p-2 mt-1 font-mono text-xs text-green-400 border border-white/5 break-all">
|
||||
{dataDir}
|
||||
@@ -63,7 +61,7 @@ export default function ForgotPasswordPage() {
|
||||
) : (
|
||||
<div className="bg-black/30 rounded-lg p-2 mt-1 font-mono text-xs text-green-400 border border-white/5">
|
||||
<span className="text-text-muted/60">
|
||||
(Check your system app data folder)
|
||||
{t("checkSystemAppDataFolder")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -73,7 +71,7 @@ export default function ForgotPasswordPage() {
|
||||
<code className="bg-black/30 px-1 rounded text-text-main">settings.json</code>{" "}
|
||||
({t("orRemovePasswordHashField")})
|
||||
</li>
|
||||
<li>Relaunch the OmniRoute desktop app — it will start fresh setup</li>
|
||||
<li>{t("relaunchDesktopFreshSetup")}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
@@ -88,14 +86,14 @@ export default function ForgotPasswordPage() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-lg font-semibold mb-1">Alternative: Set New Password</h2>
|
||||
<h2 className="text-lg font-semibold mb-1">{t("alternativeSetPasswordTitle")}</h2>
|
||||
<p className="text-sm text-text-muted mb-3">
|
||||
Set a new initial password via the server environment file:
|
||||
{t("alternativeSetPasswordDescription")}
|
||||
</p>
|
||||
<ol className="text-sm text-text-muted space-y-2 list-decimal list-inside mb-3">
|
||||
<li>Quit the OmniRoute desktop app completely</li>
|
||||
<li>{t("quitDesktopApp")}</li>
|
||||
<li>
|
||||
Open{" "}
|
||||
{t("openServerEnvFilePrefix")}{" "}
|
||||
<code className="bg-black/30 px-1 rounded text-text-main">server.env</code> in
|
||||
the data directory
|
||||
{dataDir && (
|
||||
@@ -115,7 +113,7 @@ export default function ForgotPasswordPage() {
|
||||
<code className="bg-black/30 px-1 rounded text-text-main">settings.json</code>{" "}
|
||||
from the data directory
|
||||
</li>
|
||||
<li>Relaunch the OmniRoute desktop app</li>
|
||||
<li>{t("relaunchDesktopApp")}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { NextIntlClientProvider, useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { DEFAULT_LOCALE, LOCALES, LOCALE_COOKIE } from "@/i18n/config";
|
||||
import enMessages from "@/i18n/messages/en.json";
|
||||
|
||||
/**
|
||||
* Global Error Page — FASE-04 Error Handling
|
||||
*
|
||||
@@ -13,48 +18,104 @@ interface GlobalErrorProps {
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export default function GlobalError({ error, reset }: GlobalErrorProps) {
|
||||
function getCookieLocale() {
|
||||
const cookie = document.cookie
|
||||
.split(";")
|
||||
.map((entry) => entry.trim())
|
||||
.find((entry) => entry.startsWith(`${LOCALE_COOKIE}=`));
|
||||
const locale = cookie?.slice(`${LOCALE_COOKIE}=`.length) || DEFAULT_LOCALE;
|
||||
return LOCALES.includes(locale) ? locale : DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
function buildGlobalErrorMessages(localeMessages: Record<string, unknown>) {
|
||||
const publicSystem = localeMessages.publicSystem;
|
||||
const globalError =
|
||||
publicSystem && typeof publicSystem === "object" && !Array.isArray(publicSystem)
|
||||
? (publicSystem as Record<string, unknown>).globalError
|
||||
: null;
|
||||
const translated =
|
||||
globalError && typeof globalError === "object" && !Array.isArray(globalError)
|
||||
? Object.fromEntries(
|
||||
Object.entries(globalError).filter(
|
||||
([, value]) => typeof value === "string" && !value.startsWith("__MISSING__:")
|
||||
)
|
||||
)
|
||||
: {};
|
||||
|
||||
return {
|
||||
publicSystem: {
|
||||
globalError: {
|
||||
...enMessages.publicSystem.globalError,
|
||||
...translated,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function GlobalErrorContent({ error, reset }: GlobalErrorProps) {
|
||||
const t = useTranslations("publicSystem");
|
||||
|
||||
return (
|
||||
// lang="en" is intentional: global-error is a client-side root boundary that
|
||||
// renders ABOVE the next-intl provider, so the active locale isn't reliably
|
||||
// available here. Its visible text is static English, so lang="en" stays
|
||||
// consistent with the content. User-facing locale is handled by the normal
|
||||
// layout (<html lang={locale}> in src/app/layout.tsx).
|
||||
<html lang="en">
|
||||
<main role="alert" aria-live="assertive" className="flex flex-col items-center">
|
||||
<div className="text-[64px] mb-4" aria-hidden="true">
|
||||
⚠️
|
||||
</div>
|
||||
<h1 className="text-[28px] font-bold mb-2">{t("globalError.title")}</h1>
|
||||
<p className="text-[15px] text-text-muted max-w-[400px] leading-relaxed mb-6">
|
||||
{t("globalError.description")}
|
||||
</p>
|
||||
{process.env.NODE_ENV === "development" && error?.message && (
|
||||
<pre
|
||||
className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-500 text-xs max-w-[600px] overflow-auto text-left mb-6"
|
||||
aria-label={t("globalError.detailsAriaLabel")}
|
||||
>
|
||||
{error.message}
|
||||
</pre>
|
||||
)}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
onClick={reset}
|
||||
aria-label={t("globalError.retryAriaLabel")}
|
||||
className="px-8 py-3 rounded-[10px] text-white border-none text-sm font-semibold cursor-pointer transition-transform duration-200 motion-reduce:transition-none motion-reduce:transform-none shadow-warm hover:-translate-y-0.5 bg-gradient-to-br from-primary to-primary-hover focus:outline-2 focus:outline-offset-2 focus:outline-primary"
|
||||
>
|
||||
{t("globalError.tryAgain")}
|
||||
</button>
|
||||
<a
|
||||
href="/status"
|
||||
className="px-8 py-3 rounded-[10px] text-sm font-semibold border border-[var(--color-border)] hover:bg-[var(--color-bg-alt)] no-underline focus:outline-2 focus:outline-offset-2 focus:outline-primary"
|
||||
aria-label={t("globalError.statusAriaLabel")}
|
||||
>
|
||||
{t("globalError.systemStatus")}
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GlobalError({ error, reset }: GlobalErrorProps) {
|
||||
const [locale, setLocale] = useState<string>(DEFAULT_LOCALE);
|
||||
const [messages, setMessages] = useState<Record<string, unknown>>(() =>
|
||||
buildGlobalErrorMessages(enMessages)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const nextLocale = getCookieLocale();
|
||||
setLocale(nextLocale);
|
||||
if (nextLocale === DEFAULT_LOCALE) return;
|
||||
|
||||
void import(`../i18n/messages/${nextLocale}.json`)
|
||||
.then((module) =>
|
||||
setMessages(buildGlobalErrorMessages(module.default as Record<string, unknown>))
|
||||
)
|
||||
.catch(() => setMessages(buildGlobalErrorMessages(enMessages)));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<html lang={locale}>
|
||||
<body className="flex flex-col items-center justify-center min-h-screen p-6 bg-bg text-text-main font-[system-ui,-apple-system,sans-serif] text-center m-0">
|
||||
<main role="alert" aria-live="assertive" className="flex flex-col items-center">
|
||||
<div className="text-[64px] mb-4" aria-hidden="true">
|
||||
⚠️
|
||||
</div>
|
||||
<h1 className="text-[28px] font-bold mb-2">Something went wrong</h1>
|
||||
<p className="text-[15px] text-text-muted max-w-[400px] leading-relaxed mb-6">
|
||||
An unexpected error occurred. This has been logged and our team will investigate.
|
||||
</p>
|
||||
{process.env.NODE_ENV === "development" && error?.message && (
|
||||
<pre
|
||||
className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-500 text-xs max-w-[600px] overflow-auto text-left mb-6"
|
||||
aria-label="Error details"
|
||||
>
|
||||
{error.message}
|
||||
</pre>
|
||||
)}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
onClick={reset}
|
||||
aria-label="Retry loading the page"
|
||||
className="px-8 py-3 rounded-[10px] text-white border-none text-sm font-semibold cursor-pointer transition-transform duration-200 motion-reduce:transition-none motion-reduce:transform-none shadow-warm hover:-translate-y-0.5 bg-gradient-to-br from-primary to-primary-hover focus:outline-2 focus:outline-offset-2 focus:outline-primary"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
<a
|
||||
href="/status"
|
||||
className="px-8 py-3 rounded-[10px] text-sm font-semibold border border-[var(--color-border)] hover:bg-[var(--color-bg-alt)] no-underline focus:outline-2 focus:outline-offset-2 focus:outline-primary"
|
||||
aria-label="Open system status"
|
||||
>
|
||||
System Status
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
<GlobalErrorContent error={error} reset={reset} />
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -276,7 +276,7 @@ export default function LoginPage() {
|
||||
className="w-full h-11 text-sm font-medium"
|
||||
onClick={() => (window.location.href = "/api/auth/oidc/login")}
|
||||
>
|
||||
{t("continueWithOidc") || "Continue with OIDC"}
|
||||
{t("continueWithOidc")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function MaintenancePage() {
|
||||
const t = useTranslations("publicSystem");
|
||||
|
||||
return (
|
||||
<main className="min-h-screen text-text-main flex items-center justify-center p-6">
|
||||
<section className="w-full max-w-xl rounded-2xl border border-border bg-surface p-8 shadow-soft text-center">
|
||||
<span className="material-symbols-outlined text-5xl text-primary mb-3" aria-hidden="true">
|
||||
construction
|
||||
</span>
|
||||
<h1 className="text-2xl font-semibold">Scheduled Maintenance</h1>
|
||||
<p className="mt-3 text-text-muted leading-relaxed">
|
||||
Some services are temporarily unavailable while maintenance is in progress. Core routing
|
||||
usually remains online, but management features may be degraded.
|
||||
</p>
|
||||
<h1 className="text-2xl font-semibold">{t("maintenance.title")}</h1>
|
||||
<p className="mt-3 text-text-muted leading-relaxed">{t("maintenance.description")}</p>
|
||||
|
||||
<ul className="mt-6 text-sm text-text-muted text-left rounded-xl border border-border bg-bg-alt p-4 space-y-2">
|
||||
<li className="flex items-start gap-2">
|
||||
@@ -21,7 +21,7 @@ export default function MaintenancePage() {
|
||||
>
|
||||
info
|
||||
</span>
|
||||
Retry after a few minutes.
|
||||
{t("maintenance.suggestion1")}
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span
|
||||
@@ -30,7 +30,7 @@ export default function MaintenancePage() {
|
||||
>
|
||||
info
|
||||
</span>
|
||||
Check current health indicators and provider status before retrying.
|
||||
{t("maintenance.suggestion2")}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -39,13 +39,13 @@ export default function MaintenancePage() {
|
||||
href="/status"
|
||||
className="inline-flex items-center justify-center px-6 py-3 rounded-lg text-white text-sm font-semibold bg-gradient-to-br from-primary to-primary-hover hover:shadow-elevated transition-all duration-200 motion-reduce:transition-none"
|
||||
>
|
||||
View System Status
|
||||
{t("maintenance.systemStatus")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/dashboard/health"
|
||||
className="inline-flex items-center justify-center px-6 py-3 rounded-lg text-sm font-semibold border border-border hover:bg-bg-alt transition-colors duration-200 motion-reduce:transition-none"
|
||||
>
|
||||
Open Health Dashboard
|
||||
{t("maintenance.healthDashboard")}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function NotFound() {
|
||||
const t = useTranslations("publicSystem");
|
||||
const tc = useTranslations("common");
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center min-h-screen p-6 bg-bg text-text-main text-center"
|
||||
@@ -16,25 +20,25 @@ export default function NotFound() {
|
||||
404
|
||||
</div>
|
||||
<h1 id="not-found-title" className="text-2xl font-semibold mb-2">
|
||||
Page not found
|
||||
{t("notFound.title")}
|
||||
</h1>
|
||||
<p className="text-[15px] text-text-muted max-w-[400px] leading-relaxed mb-8">
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
{t("notFound.description")}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row items-center gap-3">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="px-8 py-3 rounded-xl text-white text-sm font-medium no-underline transition-all duration-200 motion-reduce:transition-none shadow-warm hover:-translate-y-0.5 bg-gradient-to-br from-primary to-primary-hover hover:shadow-elevated focus:outline-2 focus:outline-offset-2 focus:outline-primary"
|
||||
aria-label="Return to dashboard"
|
||||
aria-label={t("notFound.dashboardAriaLabel")}
|
||||
>
|
||||
Go to Dashboard
|
||||
{tc("goToDashboard")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/status"
|
||||
className="px-8 py-3 rounded-xl text-sm font-medium no-underline border border-border hover:bg-bg-alt transition-colors duration-200 motion-reduce:transition-none focus:outline-2 focus:outline-offset-2 focus:outline-primary"
|
||||
aria-label="Open system status page"
|
||||
aria-label={t("notFound.statusAriaLabel")}
|
||||
>
|
||||
System Status
|
||||
{t("notFound.systemStatus")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
function subscribeToOnline(callback: () => void) {
|
||||
window.addEventListener("online", callback);
|
||||
@@ -13,6 +14,7 @@ function subscribeToOnline(callback: () => void) {
|
||||
}
|
||||
|
||||
export default function OfflinePage() {
|
||||
const t = useTranslations("publicSystem");
|
||||
const isOnline = useSyncExternalStore(
|
||||
subscribeToOnline,
|
||||
() => navigator.onLine,
|
||||
@@ -25,10 +27,8 @@ export default function OfflinePage() {
|
||||
<span className="material-symbols-outlined text-5xl text-primary mb-3" aria-hidden="true">
|
||||
wifi_off
|
||||
</span>
|
||||
<h1 className="text-2xl font-semibold">Connectivity Issue</h1>
|
||||
<p className="mt-3 text-text-muted leading-relaxed">
|
||||
OmniRoute cannot reach the network right now. Check your internet, VPN, or proxy settings.
|
||||
</p>
|
||||
<h1 className="text-2xl font-semibold">{t("offline.title")}</h1>
|
||||
<p className="mt-3 text-text-muted leading-relaxed">{t("offline.description")}</p>
|
||||
|
||||
<div
|
||||
className={`mt-6 inline-flex items-center gap-2 rounded-full px-3 py-1.5 text-sm border ${
|
||||
@@ -41,7 +41,9 @@ export default function OfflinePage() {
|
||||
<span className="material-symbols-outlined text-base" aria-hidden="true">
|
||||
{isOnline ? "wifi" : "wifi_off"}
|
||||
</span>
|
||||
<span>{isOnline ? "Connection restored" : "Offline mode detected"}</span>
|
||||
<span>
|
||||
{isOnline ? t("offline.connectionRestored") : t("offline.offlineModeDetected")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-col sm:flex-row gap-3">
|
||||
@@ -49,13 +51,13 @@ export default function OfflinePage() {
|
||||
onClick={() => window.location.reload()}
|
||||
className="inline-flex items-center justify-center px-6 py-3 rounded-lg text-white text-sm font-semibold bg-gradient-to-br from-primary to-primary-hover hover:shadow-elevated transition-all duration-200 motion-reduce:transition-none"
|
||||
>
|
||||
Retry Connection
|
||||
{t("offline.retryConnection")}
|
||||
</button>
|
||||
<Link
|
||||
href="/status"
|
||||
className="inline-flex items-center justify-center px-6 py-3 rounded-lg text-sm font-semibold border border-border hover:bg-bg-alt transition-colors duration-200 motion-reduce:transition-none"
|
||||
>
|
||||
Open Status Page
|
||||
{t("offline.openStatusPage")}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Spinner } from "@/shared/components/Loading";
|
||||
|
||||
interface HealthPayload {
|
||||
@@ -18,43 +19,48 @@ interface HealthPayload {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function formatUptime(seconds?: number) {
|
||||
if (!seconds || seconds <= 0) return "0m";
|
||||
function formatUptime(
|
||||
seconds: number | undefined,
|
||||
format: (key: string, values?: Record<string, number>) => string
|
||||
) {
|
||||
if (!seconds || seconds <= 0) return format("status.uptimeMinutes", { minutes: 0 });
|
||||
const total = Math.floor(seconds);
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
return `${minutes}m`;
|
||||
if (hours > 0) return format("status.uptimeHoursMinutes", { hours, minutes });
|
||||
return format("status.uptimeMinutes", { minutes });
|
||||
}
|
||||
|
||||
export default function StatusPage() {
|
||||
const t = useTranslations("publicSystem");
|
||||
const tc = useTranslations("common");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [health, setHealth] = useState<HealthPayload | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function loadHealth() {
|
||||
const loadHealth = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch("/api/monitoring/health", { cache: "no-store" });
|
||||
const data = (await response.json()) as HealthPayload;
|
||||
if (!response.ok) {
|
||||
setError(data.error || "Failed to load system health.");
|
||||
setError(data.error || t("status.failedToLoad"));
|
||||
setHealth(null);
|
||||
return;
|
||||
}
|
||||
setHealth(data);
|
||||
} catch {
|
||||
setError("Unable to reach health endpoint. Check connectivity and retry.");
|
||||
setError(t("status.unableToReachHealth"));
|
||||
setHealth(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadHealth();
|
||||
}, []);
|
||||
}, [loadHealth]);
|
||||
|
||||
const providerStats = useMemo(() => {
|
||||
const providers = Object.entries(health?.providerHealth || {});
|
||||
@@ -69,16 +75,14 @@ export default function StatusPage() {
|
||||
<section className="max-w-4xl mx-auto space-y-6">
|
||||
<header className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">System Status</h1>
|
||||
<p className="text-text-muted mt-1">
|
||||
Live operational snapshot for OmniRoute core services.
|
||||
</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t("status.title")}</h1>
|
||||
<p className="text-text-muted mt-1">{t("status.description")}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void loadHealth()}
|
||||
className="inline-flex items-center justify-center px-4 py-2 rounded-lg text-sm font-semibold bg-gradient-to-br from-primary to-primary-hover text-white transition-all duration-200 motion-reduce:transition-none"
|
||||
>
|
||||
Refresh
|
||||
{tc("refresh")}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@@ -89,14 +93,14 @@ export default function StatusPage() {
|
||||
aria-live="polite"
|
||||
>
|
||||
<Spinner size="md" />
|
||||
<span className="text-text-muted">Loading health metrics...</span>
|
||||
<span className="text-text-muted">{t("status.loadingHealth")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<div className="rounded-xl border border-red-500/30 bg-red-500/10 p-6" role="alert">
|
||||
<h2 className="text-lg font-semibold text-red-600 dark:text-red-400">
|
||||
Health Check Failed
|
||||
{t("status.healthCheckFailed")}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-text-muted">{error}</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
@@ -104,13 +108,13 @@ export default function StatusPage() {
|
||||
href="/offline"
|
||||
className="px-3 py-2 rounded-lg border border-border text-sm font-medium hover:bg-bg-alt transition-colors"
|
||||
>
|
||||
Open Connectivity Help
|
||||
{t("status.openConnectivityHelp")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/maintenance"
|
||||
className="px-3 py-2 rounded-lg border border-border text-sm font-medium hover:bg-bg-alt transition-colors"
|
||||
>
|
||||
Maintenance Info
|
||||
{t("status.maintenanceInfo")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,32 +124,43 @@ export default function StatusPage() {
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="rounded-xl border border-border bg-surface p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-text-muted">Service</p>
|
||||
<p className="mt-2 text-xl font-semibold">{health.status || "unknown"}</p>
|
||||
<p className="text-xs uppercase tracking-wide text-text-muted">
|
||||
{t("status.service")}
|
||||
</p>
|
||||
<p className="mt-2 text-xl font-semibold">{health.status || t("status.unknown")}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-surface p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-text-muted">Version</p>
|
||||
<p className="mt-2 text-xl font-semibold">{health.system?.version || "n/a"}</p>
|
||||
<p className="text-xs uppercase tracking-wide text-text-muted">{tc("version")}</p>
|
||||
<p className="mt-2 text-xl font-semibold">
|
||||
{health.system?.version || t("status.notAvailable")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-surface p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-text-muted">Uptime</p>
|
||||
<p className="mt-2 text-xl font-semibold">{formatUptime(health.system?.uptime)}</p>
|
||||
<p className="text-xs uppercase tracking-wide text-text-muted">{tc("uptime")}</p>
|
||||
<p className="mt-2 text-xl font-semibold">
|
||||
{formatUptime(health.system?.uptime, t)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-surface p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-text-muted">Providers Tracked</p>
|
||||
<p className="text-xs uppercase tracking-wide text-text-muted">
|
||||
{t("status.providersTracked")}
|
||||
</p>
|
||||
<p className="mt-2 text-xl font-semibold">{providerStats.total}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border bg-surface p-6">
|
||||
<h2 className="text-lg font-semibold">Provider Circuit Breaker State</h2>
|
||||
<h2 className="text-lg font-semibold">{t("status.circuitBreakerState")}</h2>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
OPEN: {providerStats.open} · HALF_OPEN: {providerStats.halfOpen} · CLOSED:{" "}
|
||||
{providerStats.closed}
|
||||
</p>
|
||||
<p className="mt-4 text-xs text-text-muted">
|
||||
Last update:{" "}
|
||||
{health.timestamp ? new Date(health.timestamp).toLocaleString() : "n/a"}
|
||||
{t("status.lastUpdate", {
|
||||
timestamp: health.timestamp
|
||||
? new Date(health.timestamp).toLocaleString()
|
||||
: t("status.notAvailable"),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -235,7 +235,7 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
className="relative w-full max-w-3xl bg-surface border border-black/10 dark:border-white/10 rounded-xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Command palette"
|
||||
aria-label={t("commandPalette.title")}
|
||||
>
|
||||
<div className="flex items-center gap-3 px-6 py-4 border-b border-black/5 dark:border-white/5">
|
||||
<span className="material-symbols-outlined text-[20px] text-text-muted shrink-0">
|
||||
@@ -245,7 +245,7 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="flex-1 bg-transparent text-text placeholder:text-text-muted outline-none text-base"
|
||||
placeholder="Search pages, settings, tools..."
|
||||
placeholder={t("commandPalette.searchPlaceholder")}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
@@ -262,7 +262,7 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
setSelectedIndex(0);
|
||||
}}
|
||||
tabIndex={-1}
|
||||
aria-label="Clear search"
|
||||
aria-label={t("commandPalette.clearSearch")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">close</span>
|
||||
</button>
|
||||
@@ -353,7 +353,9 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="py-10 text-center text-text-muted text-sm">No results</div>
|
||||
<div className="py-10 text-center text-text-muted text-sm">
|
||||
{t("commandPalette.noResults")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 px-4 py-2 border-t border-black/5 dark:border-white/5 text-[11px] text-text-muted">
|
||||
@@ -361,19 +363,19 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
<kbd className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 font-mono">
|
||||
↑↓
|
||||
</kbd>
|
||||
navigate
|
||||
{t("commandPalette.navigate")}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 font-mono">
|
||||
↵
|
||||
</kbd>
|
||||
open
|
||||
{t("commandPalette.open")}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 font-mono">
|
||||
Esc
|
||||
</kbd>
|
||||
close
|
||||
{t("commandPalette.close")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function DegradationBadge() {
|
||||
title={t("warning")} // Using common warning text, or we could just use English / fixed string if i18n is not strict
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">healing</span>
|
||||
<span className="text-xs font-semibold whitespace-nowrap">Degraded</span>
|
||||
<span className="text-xs font-semibold whitespace-nowrap">{t("degraded")}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type ButtonState = "idle" | "distributing" | "complete";
|
||||
|
||||
@@ -22,6 +23,7 @@ export default function DistributeProxiesButton({
|
||||
size = "md",
|
||||
}: DistributeProxiesButtonProps) {
|
||||
const [state, setState] = useState<ButtonState>("idle");
|
||||
const t = useTranslations("sharedComponents.distributeProxies");
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -44,8 +46,7 @@ export default function DistributeProxiesButton({
|
||||
|
||||
const isDisabled = disabled || state === "distributing";
|
||||
|
||||
const sizeClasses =
|
||||
size === "sm" ? "px-2 py-1 text-[11px]" : "px-3 py-1.5 text-xs";
|
||||
const sizeClasses = size === "sm" ? "px-2 py-1 text-[11px]" : "px-3 py-1.5 text-xs";
|
||||
|
||||
const stateClasses =
|
||||
state === "distributing"
|
||||
@@ -55,7 +56,12 @@ export default function DistributeProxiesButton({
|
||||
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40";
|
||||
|
||||
const icon = state === "distributing" ? "sync" : state === "complete" ? "check" : "swap_horiz";
|
||||
const displayLabel = state === "distributing" ? "Distributing..." : state === "complete" ? "Complete" : label;
|
||||
const displayLabel =
|
||||
state === "distributing"
|
||||
? t("distributing")
|
||||
: state === "complete"
|
||||
? t("complete")
|
||||
: label || t("defaultLabel");
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -65,7 +71,9 @@ export default function DistributeProxiesButton({
|
||||
title={displayLabel}
|
||||
aria-label={displayLabel}
|
||||
>
|
||||
<span className={`material-symbols-outlined text-[14px] ${state === "distributing" ? "animate-spin" : ""}`}>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[14px] ${state === "distributing" ? "animate-spin" : ""}`}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
{displayLabel}
|
||||
|
||||
@@ -7,38 +7,38 @@ import { APP_CONFIG } from "@/shared/constants/appConfig";
|
||||
|
||||
const footerLinks = {
|
||||
product: [
|
||||
{ label: "Features", href: "#features" },
|
||||
{ label: "Pricing", href: "#pricing" },
|
||||
{ key: "featuresLink", href: "#features" },
|
||||
{ key: "pricing", href: "#pricing" },
|
||||
{
|
||||
label: "Changelog",
|
||||
key: "changelog",
|
||||
href: "https://github.com/diegosouzapw/OmniRoute/releases",
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
resources: [
|
||||
{ label: "Documentation", href: "/docs" },
|
||||
{ label: "API Reference", href: "/docs#api-reference" },
|
||||
{ key: "documentation", href: "/docs" },
|
||||
{ key: "apiReference", href: "/docs#api-reference" },
|
||||
{
|
||||
label: "Help Center",
|
||||
key: "helpCenter",
|
||||
href: "https://github.com/diegosouzapw/OmniRoute/discussions",
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
company: [
|
||||
{ label: "About", href: "https://github.com/diegosouzapw/OmniRoute", external: true },
|
||||
{ label: "Blog", href: "https://github.com/diegosouzapw/OmniRoute/releases", external: true },
|
||||
{ key: "about", href: "https://github.com/diegosouzapw/OmniRoute", external: true },
|
||||
{ key: "blog", href: "https://github.com/diegosouzapw/OmniRoute/releases", external: true },
|
||||
{
|
||||
label: "Contact",
|
||||
key: "contact",
|
||||
href: "https://github.com/diegosouzapw/OmniRoute/issues/new/choose",
|
||||
external: true,
|
||||
},
|
||||
{ label: "Terms", href: "/terms" },
|
||||
{ label: "Privacy", href: "/privacy" },
|
||||
{ key: "terms", href: "/terms" },
|
||||
{ key: "privacy", href: "/privacy" },
|
||||
],
|
||||
};
|
||||
|
||||
export default function Footer() {
|
||||
const t = useTranslations("stats");
|
||||
const t = useTranslations("landing");
|
||||
const renderFooterLink = (link) => {
|
||||
if (link.external) {
|
||||
return (
|
||||
@@ -48,7 +48,7 @@ export default function Footer() {
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-primary transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
{t(link.key)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -77,9 +77,7 @@ export default function Footer() {
|
||||
</div>
|
||||
<span className="text-xl font-bold text-text-main">{APP_CONFIG.name}</span>
|
||||
</div>
|
||||
<p className="text-text-muted mb-6 max-w-sm font-light">
|
||||
The unified interface for modern AI infrastructure. Secure, observable, and scalable.
|
||||
</p>
|
||||
<p className="text-text-muted mb-6 max-w-sm font-light">{t("footerDescription")}</p>
|
||||
{/* Social links */}
|
||||
<div className="flex gap-4">
|
||||
<a
|
||||
@@ -87,7 +85,7 @@ export default function Footer() {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-400 hover:text-primary transition-colors"
|
||||
aria-label="Community Discussions"
|
||||
aria-label={t("communityDiscussions")}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M22.46 6c-.77.35-1.6.58-2.46.69.88-.53 1.56-1.37 1.88-2.38-.83.5-1.75.85-2.72 1.05C18.37 4.5 17.26 4 16 4c-2.35 0-4.27 1.92-4.27 4.29 0 .34.04.67.11.98C8.28 9.09 5.11 7.38 3 4.79c-.37.63-.58 1.37-.58 2.15 0 1.49.75 2.81 1.91 3.56-.71 0-1.37-.2-1.95-.5v.03c0 2.08 1.48 3.82 3.44 4.21a4.22 4.22 0 0 1-1.93.07 4.28 4.28 0 0 0 4 2.98 8.521 8.521 0 0 1-5.33 1.84c-.34 0-.68-.02-1.02-.06C3.44 20.29 5.7 21 8.12 21 16 21 20.33 14.46 20.33 8.79c0-.19 0-.37-.01-.56.84-.6 1.56-1.36 2.14-2.23z" />
|
||||
@@ -112,7 +110,7 @@ export default function Footer() {
|
||||
<h4 className="font-semibold text-text-main mb-4">{t("product")}</h4>
|
||||
<ul className="flex flex-col gap-3 text-sm text-text-muted font-light">
|
||||
{footerLinks.product.map((link) => (
|
||||
<li key={link.label}>{renderFooterLink(link)}</li>
|
||||
<li key={link.key}>{renderFooterLink(link)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -122,7 +120,7 @@ export default function Footer() {
|
||||
<h4 className="font-semibold text-text-main mb-4">{t("resources")}</h4>
|
||||
<ul className="flex flex-col gap-3 text-sm text-text-muted font-light">
|
||||
{footerLinks.resources.map((link) => (
|
||||
<li key={link.label}>{renderFooterLink(link)}</li>
|
||||
<li key={link.key}>{renderFooterLink(link)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -132,7 +130,7 @@ export default function Footer() {
|
||||
<h4 className="font-semibold text-text-main mb-4">{t("company")}</h4>
|
||||
<ul className="flex flex-col gap-3 text-sm text-text-muted font-light">
|
||||
{footerLinks.company.map((link) => (
|
||||
<li key={link.label}>{renderFooterLink(link)}</li>
|
||||
<li key={link.key}>{renderFooterLink(link)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -141,17 +139,17 @@ export default function Footer() {
|
||||
{/* Bottom */}
|
||||
<div className="border-t border-border pt-8 flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<p className="text-sm text-text-muted">
|
||||
© {new Date().getFullYear()} {APP_CONFIG.name} Inc. All rights reserved.
|
||||
{t("copyright", { year: new Date().getFullYear() })}
|
||||
</p>
|
||||
<div className="flex gap-6 text-sm text-text-muted">
|
||||
<Link href="/docs" className="hover:text-primary transition-colors">
|
||||
Documentation
|
||||
{t("documentation")}
|
||||
</Link>
|
||||
<Link href="/terms" className="hover:text-primary transition-colors">
|
||||
Terms
|
||||
{t("terms")}
|
||||
</Link>
|
||||
<Link href="/privacy" className="hover:text-primary transition-colors">
|
||||
Privacy
|
||||
{t("privacy")}
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/diegosouzapw/OmniRoute/blob/main/LICENSE"
|
||||
@@ -159,7 +157,7 @@ export default function Footer() {
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-primary transition-colors"
|
||||
>
|
||||
License
|
||||
{t("license")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Modal from "./Modal";
|
||||
import Button from "./Button";
|
||||
import Input from "./Input";
|
||||
@@ -24,6 +25,7 @@ export default function KiroAuthModal({
|
||||
onMethodSelect,
|
||||
onClose,
|
||||
}: KiroAuthModalProps) {
|
||||
const t = useTranslations("kiroAuthModal");
|
||||
const [selectedMethod, setSelectedMethod] = useState(null);
|
||||
const [idcStartUrl, setIdcStartUrl] = useState("");
|
||||
const [idcRegion, setIdcRegion] = useState("us-east-1");
|
||||
@@ -65,17 +67,17 @@ export default function KiroAuthModal({
|
||||
onClose();
|
||||
return;
|
||||
} else {
|
||||
setError(data.error || "Could not auto-detect token");
|
||||
setError(data.error || t("errorAutoDetect"));
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Failed to auto-detect token");
|
||||
setError(t("errorAutoDetectFailed"));
|
||||
} finally {
|
||||
setAutoDetecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
autoDetect();
|
||||
}, [providerId, selectedMethod, isOpen, onMethodSelect, onClose]);
|
||||
}, [providerId, selectedMethod, isOpen, onMethodSelect, onClose, t]);
|
||||
|
||||
const handleMethodSelect = (method) => {
|
||||
setSelectedMethod(method);
|
||||
@@ -89,7 +91,7 @@ export default function KiroAuthModal({
|
||||
|
||||
const handleImportToken = async () => {
|
||||
if (!refreshToken.trim()) {
|
||||
setError("Please enter a refresh token");
|
||||
setError(t("errorRefreshTokenRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -111,14 +113,14 @@ export default function KiroAuthModal({
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "Import failed");
|
||||
throw new Error(data.error || t("errorImportFailed"));
|
||||
}
|
||||
|
||||
// Success - close modal
|
||||
onMethodSelect("import");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Import failed");
|
||||
setError(err instanceof Error ? err.message : t("errorImportFailed"));
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
@@ -126,7 +128,7 @@ export default function KiroAuthModal({
|
||||
|
||||
const handleImportApiKey = async () => {
|
||||
if (!apiKey.trim()) {
|
||||
setError("Please enter a Kiro API key");
|
||||
setError(t("errorApiKeyRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -149,13 +151,13 @@ export default function KiroAuthModal({
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error?.message || data.error || "API key import failed");
|
||||
throw new Error(data.error?.message || data.error || t("errorApiKeyImportFailed"));
|
||||
}
|
||||
|
||||
onMethodSelect("api-key");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "API key import failed");
|
||||
setError(err instanceof Error ? err.message : t("errorApiKeyImportFailed"));
|
||||
} finally {
|
||||
setImportingApiKey(false);
|
||||
}
|
||||
@@ -163,7 +165,7 @@ export default function KiroAuthModal({
|
||||
|
||||
const handleIdcContinue = () => {
|
||||
if (!idcStartUrl.trim()) {
|
||||
setError("Please enter your IDC start URL");
|
||||
setError(t("errorIdcStartUrlRequired"));
|
||||
return;
|
||||
}
|
||||
onMethodSelect("idc", { startUrl: idcStartUrl.trim(), region: idcRegion });
|
||||
@@ -174,12 +176,12 @@ export default function KiroAuthModal({
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} title={`Connect ${providerLabel}`} onClose={onClose} size="lg">
|
||||
<Modal isOpen={isOpen} title={t("title", { providerLabel })} onClose={onClose} size="lg">
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Method Selection */}
|
||||
{!selectedMethod && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-text-muted mb-4">Choose your authentication method:</p>
|
||||
<p className="text-sm text-text-muted mb-4">{t("chooseMethod")}</p>
|
||||
|
||||
{/* AWS Builder ID */}
|
||||
<button
|
||||
@@ -189,10 +191,9 @@ export default function KiroAuthModal({
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="material-symbols-outlined text-primary mt-0.5">shield</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold mb-1">AWS Builder ID</h3>
|
||||
<h3 className="font-semibold mb-1">{t("builderId")}</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
Recommended for most users. Sign in with the AWS account linked to{" "}
|
||||
{providerLabel}.
|
||||
{t("builderDescription", { providerLabel })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -206,11 +207,11 @@ export default function KiroAuthModal({
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="material-symbols-outlined text-primary mt-0.5">business</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold mb-1">
|
||||
Your Organization (AWS IAM Identity Center)
|
||||
</h3>
|
||||
<h3 className="font-semibold mb-1">{t("organization")}</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
Use your company SSO start URL (example: https://your-org.awsapps.com/start).
|
||||
{t("organizationDescription", {
|
||||
url: "https://your-org.awsapps.com/start",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -226,8 +227,8 @@ export default function KiroAuthModal({
|
||||
account_circle
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold mb-1">Google Account</h3>
|
||||
<p className="text-sm text-text-muted">Login with your Google account.</p>
|
||||
<h3 className="font-semibold mb-1">{t("googleAccount")}</h3>
|
||||
<p className="text-sm text-text-muted">{t("googleDescription")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -240,8 +241,8 @@ export default function KiroAuthModal({
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="material-symbols-outlined text-primary mt-0.5">code</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold mb-1">GitHub Account</h3>
|
||||
<p className="text-sm text-text-muted">Login with your GitHub account.</p>
|
||||
<h3 className="font-semibold mb-1">{t("githubAccount")}</h3>
|
||||
<p className="text-sm text-text-muted">{t("githubDescription")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -254,9 +255,9 @@ export default function KiroAuthModal({
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="material-symbols-outlined text-primary mt-0.5">file_upload</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold mb-1">Import Token</h3>
|
||||
<h3 className="font-semibold mb-1">{t("importToken")}</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
Paste a refresh token exported from {providerLabel}.
|
||||
{t("importDescription", { providerLabel })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -270,10 +271,9 @@ export default function KiroAuthModal({
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="material-symbols-outlined text-primary mt-0.5">key</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold mb-1">API Key</h3>
|
||||
<h3 className="font-semibold mb-1">{t("apiKey")}</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
Paste a long-lived {providerLabel} / CodeWhisperer API key. It is stored as a
|
||||
bearer credential with no refresh token; profile discovery is best-effort.
|
||||
{t("apiKeyDescription", { providerLabel })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -286,40 +286,36 @@ export default function KiroAuthModal({
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
IDC Start URL <span className="text-red-500">*</span>
|
||||
{t("idcStartUrl")} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={idcStartUrl}
|
||||
onChange={(e) => setIdcStartUrl(e.target.value)}
|
||||
placeholder="https://your-org.awsapps.com/start"
|
||||
placeholder={t("idcStartUrlPlaceholder")}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Your organization's AWS IAM Identity Center URL
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-1">{t("idcStartUrlDescription")}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">AWS Region</label>
|
||||
<label className="block text-sm font-medium mb-2">{t("awsRegion")}</label>
|
||||
<Input
|
||||
value={idcRegion}
|
||||
onChange={(e) => setIdcRegion(e.target.value)}
|
||||
placeholder="us-east-1"
|
||||
placeholder={t("regionPlaceholder")}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
AWS region for your Identity Center (default: us-east-1)
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-1">{t("idcRegionDescription")}</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleIdcContinue} fullWidth>
|
||||
Continue
|
||||
{t("continue")}
|
||||
</Button>
|
||||
<Button onClick={handleBack} variant="ghost" fullWidth>
|
||||
Back
|
||||
{t("back")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -336,9 +332,9 @@ export default function KiroAuthModal({
|
||||
progress_activity
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Auto-detecting token...</h3>
|
||||
<h3 className="text-lg font-semibold mb-2">{t("autoDetecting")}</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
Reading {providerLabel} credentials from AWS SSO cache
|
||||
{t("readingCredentials", { providerLabel })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -354,8 +350,7 @@ export default function KiroAuthModal({
|
||||
info
|
||||
</span>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
{providerLabel} token was not auto-detected. Please paste your refresh token
|
||||
manually.
|
||||
{t("tokenNotDetected", { providerLabel })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -363,13 +358,13 @@ export default function KiroAuthModal({
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Refresh Token <span className="text-red-500">*</span>
|
||||
{t("refreshToken")} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={refreshToken}
|
||||
onChange={(e) => setRefreshToken(e.target.value)}
|
||||
placeholder="Token will be auto-filled..."
|
||||
placeholder={t("tokenPlaceholder")}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
@@ -386,10 +381,10 @@ export default function KiroAuthModal({
|
||||
fullWidth
|
||||
disabled={importing || !refreshToken.trim()}
|
||||
>
|
||||
{importing ? "Importing..." : "Import Token"}
|
||||
{importing ? t("importing") : t("importToken")}
|
||||
</Button>
|
||||
<Button onClick={handleBack} variant="ghost" fullWidth>
|
||||
Back
|
||||
{t("back")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -402,31 +397,27 @@ export default function KiroAuthModal({
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
API Key <span className="text-red-500">*</span>
|
||||
{t("apiKey")} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={`Paste your ${providerLabel} API key...`}
|
||||
placeholder={t("apiKeyPlaceholder", { providerLabel })}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Stored encrypted as a long-lived bearer credential. There is no refresh flow.
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-1">{t("apiKeyStoredDescription")}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">AWS Region</label>
|
||||
<label className="block text-sm font-medium mb-2">{t("awsRegion")}</label>
|
||||
<Input
|
||||
value={apiKeyRegion}
|
||||
onChange={(e) => setApiKeyRegion(e.target.value)}
|
||||
placeholder="us-east-1"
|
||||
placeholder={t("regionPlaceholder")}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
AWS region for the key (default: us-east-1)
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-1">{t("apiKeyRegionDescription")}</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -441,10 +432,10 @@ export default function KiroAuthModal({
|
||||
fullWidth
|
||||
disabled={importingApiKey || !apiKey.trim()}
|
||||
>
|
||||
{importingApiKey ? "Validating..." : "Validate and Save API Key"}
|
||||
{importingApiKey ? t("validating") : t("validateAndSaveApiKey")}
|
||||
</Button>
|
||||
<Button onClick={handleBack} variant="ghost" fullWidth>
|
||||
Back
|
||||
{t("back")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Modal from "./Modal";
|
||||
import Button from "./Button";
|
||||
import { copyToClipboard } from "@/shared/utils/clipboard";
|
||||
@@ -23,6 +24,7 @@ export default function KiroSocialOAuthModal({
|
||||
onSuccess,
|
||||
onClose,
|
||||
}: KiroSocialOAuthModalProps) {
|
||||
const t = useTranslations("kiroSocialOAuthModal");
|
||||
const [step, setStep] = useState<"loading" | "polling" | "success" | "error">("loading");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [userCode, setUserCode] = useState("");
|
||||
@@ -60,7 +62,7 @@ export default function KiroSocialOAuthModal({
|
||||
if (cancelled) return;
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "Failed to start authorization");
|
||||
throw new Error(data.error || t("errorStartAuthorization"));
|
||||
}
|
||||
|
||||
setUserCode(data.userCode || "");
|
||||
@@ -80,7 +82,7 @@ export default function KiroSocialOAuthModal({
|
||||
pollRef.current = null;
|
||||
if (cancelled) return;
|
||||
if (Date.now() >= expiresAt) {
|
||||
fail("Authorization expired. Start the login flow again.");
|
||||
fail(t("errorAuthorizationExpired"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -101,7 +103,7 @@ export default function KiroSocialOAuthModal({
|
||||
}
|
||||
|
||||
if (!pollData.pending) {
|
||||
fail(pollData.error || "Authorization failed");
|
||||
fail(pollData.error || t("errorAuthorizationFailed"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -124,7 +126,7 @@ export default function KiroSocialOAuthModal({
|
||||
cancelled = true;
|
||||
stopPolling();
|
||||
};
|
||||
}, [isOpen, provider, targetProvider]);
|
||||
}, [isOpen, provider, targetProvider, t]);
|
||||
|
||||
const handleClose = () => {
|
||||
if (pollRef.current) {
|
||||
@@ -139,7 +141,7 @@ export default function KiroSocialOAuthModal({
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
title={`Connect ${providerLabel} via ${providerName}`}
|
||||
title={t("title", { providerLabel, providerName })}
|
||||
onClose={handleClose}
|
||||
size="lg"
|
||||
>
|
||||
@@ -151,8 +153,8 @@ export default function KiroSocialOAuthModal({
|
||||
progress_activity
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Initializing...</h3>
|
||||
<p className="text-sm text-text-muted">Setting up {providerName} authentication</p>
|
||||
<h3 className="text-lg font-semibold mb-2">{t("initializing")}</h3>
|
||||
<p className="text-sm text-text-muted">{t("settingUp", { providerName })}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -163,10 +165,8 @@ export default function KiroSocialOAuthModal({
|
||||
open_in_browser
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Open this link in an Incognito window</h3>
|
||||
<p className="text-sm text-text-muted mb-3">
|
||||
Use an Incognito/Private window to avoid session conflicts with existing accounts.
|
||||
</p>
|
||||
<h3 className="text-lg font-semibold mb-2">{t("openIncognito")}</h3>
|
||||
<p className="text-sm text-text-muted mb-3">{t("incognitoDescription")}</p>
|
||||
{authUrl && (
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 justify-center">
|
||||
@@ -181,7 +181,7 @@ export default function KiroSocialOAuthModal({
|
||||
<button
|
||||
onClick={() => copyToClipboard(authUrl)}
|
||||
className="shrink-0 p-1 rounded hover:bg-sidebar"
|
||||
title="Copy link"
|
||||
title={t("copyLink")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-base">content_copy</span>
|
||||
</button>
|
||||
@@ -190,7 +190,7 @@ export default function KiroSocialOAuthModal({
|
||||
)}
|
||||
{userCode && (
|
||||
<div className="mb-4">
|
||||
<p className="text-xs text-text-muted mb-1">Verification code</p>
|
||||
<p className="text-xs text-text-muted mb-1">{t("verificationCode")}</p>
|
||||
<p className="font-mono text-2xl font-bold tracking-widest">{userCode}</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -198,11 +198,11 @@ export default function KiroSocialOAuthModal({
|
||||
<span className="material-symbols-outlined text-base animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
Waiting for authorization...
|
||||
{t("waiting")}
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Button onClick={handleClose} variant="ghost" fullWidth>
|
||||
Cancel
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -215,12 +215,12 @@ export default function KiroSocialOAuthModal({
|
||||
check_circle
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Connected Successfully!</h3>
|
||||
<h3 className="text-lg font-semibold mb-2">{t("successTitle")}</h3>
|
||||
<p className="text-sm text-text-muted mb-4">
|
||||
Your {providerLabel} account via {providerName} has been connected.
|
||||
{t("successMessage", { providerLabel, providerName })}
|
||||
</p>
|
||||
<Button onClick={handleClose} fullWidth>
|
||||
Done
|
||||
{t("done")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -230,11 +230,11 @@ export default function KiroSocialOAuthModal({
|
||||
<div className="size-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-3xl text-red-600">error</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Connection Failed</h3>
|
||||
<h3 className="text-lg font-semibold mb-2">{t("errorTitle")}</h3>
|
||||
<p className="text-sm text-red-600 mb-4">{error}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleClose} variant="ghost" fullWidth>
|
||||
Close
|
||||
{t("close")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@ interface Combo {
|
||||
|
||||
export default function ModelRoutingSection({ combos: externalCombos }: { combos?: Combo[] } = {}) {
|
||||
const t = useTranslations("settings");
|
||||
const tCommon = useTranslations("common");
|
||||
const [mappings, setMappings] = useState<ModelMapping[]>([]);
|
||||
const [internalCombos, setInternalCombos] = useState<Combo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -250,7 +251,7 @@ export default function ModelRoutingSection({ combos: externalCombos }: { combos
|
||||
<input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Route Opus models to frontier combo"
|
||||
placeholder={t("modelRoutingDescriptionPlaceholder")}
|
||||
className="w-full mt-0.5 px-2.5 py-1.5 text-xs rounded-lg border border-black/10 dark:border-white/10
|
||||
bg-white dark:bg-black/20 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
@@ -328,7 +329,7 @@ export default function ModelRoutingSection({ combos: externalCombos }: { combos
|
||||
<button
|
||||
onClick={() => handleEdit(m)}
|
||||
className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
title="Edit"
|
||||
title={tCommon("edit")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] text-text-muted">
|
||||
edit
|
||||
@@ -337,7 +338,7 @@ export default function ModelRoutingSection({ combos: externalCombos }: { combos
|
||||
<button
|
||||
onClick={() => handleDelete(m.id)}
|
||||
className="p-1 rounded hover:bg-red-500/10 transition-colors"
|
||||
title="Delete"
|
||||
title={tCommon("delete")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] text-red-500">delete</span>
|
||||
</button>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user