mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 22:02:08 +03:00
Release v3.7.4 (#1730)
* chore(release): v3.7.4 — version bump, openapi and changelog sync * fix: preserve previous_response_id and conversation_id fields on empty input array (#1729) * fix: bypass UI validation block for optional API keys and fix string fallback typing (#1721) * fix(proxy): disable HTTP keep-alive and pipelining in Undici proxy dispatcher to prevent socket hang up * feat(proxy): implement bulk proxy import via pipe-delimited parser with update-or-create logic * docs: update changelog for v3.7.4 fixes and proxy features * test: update responses store expectations for empty input arrays * feat(pwa): add fullscreen installable PWA with manifest, service worker, and cross-platform app icons. (#1728) Integrated into release/v3.7.4 * Fix image provider validation and Stability image requests (#1726) Integrated into release/v3.7.4 * docs: add PR 1726 and PR 1728 to v3.7.4 changelog * fix(security): replace insecure Math.random with crypto.getRandomValues for fallback UUID generation * fix(migrations): intercept 007 migration to use IF NOT EXISTS logic on fresh installs Fixes #1733 * test: fix typescript compilation errors in unit tests * fix(db): reconcile legacy reasoning cache migration * chore(release): bump to v3.7.4 — changelog, docs, version sync * fix(cc-compatible): preserve Claude Code system skeleton (#1740) Integrated into release/v3.7.4 * docs(changelog): update for PR #1740 merge * docs(changelog): include workflow updates * fix(db): reconcile legacy reasoning cache migration (#1734) Integrated into release/v3.7.4 * Add endpoint tunnel visibility settings (#1743) Integrated into release/v3.7.4 * Normalize max reasoning effort for Codex routing (#1744) Integrated into release/v3.7.4 * Fix Claude Code gateway config helper (#1745) Integrated into release/v3.7.4 * Refresh CLI fingerprint provider profiles (#1746) Integrated into release/v3.7.4 * Integrated into release/v3.7.4 (PR #1742) * docs(changelog): update for PRs 1742-1746 --------- Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com> Co-authored-by: Yash Ghule <y.ghule77@gmail.com> Co-authored-by: backryun <bakryun0718@proton.me> Co-authored-by: dhaern <manker_lol@hotmail.com> Co-authored-by: Randi <55005611+rdself@users.noreply.github.com> Co-authored-by: Duncan L <leungd@gmail.com>
This commit is contained in:
committed by
GitHub
parent
4cdd0dfd1a
commit
0cd388efb8
@@ -12,6 +12,7 @@ import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
import { AI_PROVIDERS, FREE_PROVIDERS, OAUTH_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import { copyToClipboard } from "@/shared/utils/clipboard";
|
||||
import type { NewsAnnouncement } from "@/shared/utils/releaseNotes";
|
||||
|
||||
type UpdateStep = {
|
||||
step: string;
|
||||
@@ -19,6 +20,16 @@ type UpdateStep = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
type VersionInfo = {
|
||||
current: string;
|
||||
latest: string;
|
||||
updateAvailable: boolean;
|
||||
channel: string;
|
||||
autoUpdateSupported: boolean;
|
||||
autoUpdateError?: string | null;
|
||||
news?: NewsAnnouncement | null;
|
||||
};
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function mergeUpdateStep(steps: UpdateStep[], nextStep: UpdateStep) {
|
||||
@@ -43,7 +54,7 @@ export default function HomePageClient({ machineId }) {
|
||||
const [selectedProvider, setSelectedProvider] = useState(null);
|
||||
const [providerMetrics, setProviderMetrics] = useState({});
|
||||
|
||||
const [versionInfo, setVersionInfo] = useState<any>(null);
|
||||
const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [updateSteps, setUpdateSteps] = useState<UpdateStep[]>([]);
|
||||
const [updatePhase, setUpdatePhase] = useState<"idle" | "running" | "done" | "failed">("idle");
|
||||
@@ -540,29 +551,64 @@ export default function HomePageClient({ machineId }) {
|
||||
|
||||
{/* Update Notification Banner */}
|
||||
{versionInfo?.updateAvailable && !showUpdateOverlay && (
|
||||
<div className="bg-primary/10 border border-primary/20 text-primary px-5 py-4 rounded-xl flex items-center justify-between min-h-[64px]">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="material-symbols-outlined text-[24px]">system_update_alt</span>
|
||||
<div>
|
||||
<p className="font-semibold text-sm">Update Available: v{versionInfo.latest}</p>
|
||||
<p className="text-xs opacity-80 mt-0.5">
|
||||
{versionInfo.autoUpdateSupported
|
||||
? t("updateAvailableDesc") ||
|
||||
`You are currently using v${versionInfo.current}. Update to access the latest features and bug fixes.`
|
||||
: versionInfo.autoUpdateError ||
|
||||
"Manual update required for this installation type."}
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex min-h-[64px] items-center justify-between rounded-lg border border-primary/20 bg-primary/10 px-5 py-4 text-primary">
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<span className="material-symbols-outlined shrink-0 text-[24px]">
|
||||
system_update_alt
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-semibold text-sm">Update Available: v{versionInfo.latest}</p>
|
||||
<p className="text-xs opacity-80 mt-0.5">
|
||||
{versionInfo.autoUpdateSupported
|
||||
? t("updateAvailableDesc") ||
|
||||
`You are currently using v${versionInfo.current}. Update to access the latest features and bug fixes.`
|
||||
: versionInfo.autoUpdateError ||
|
||||
"Manual update required for this installation type."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={versionInfo.autoUpdateSupported ? handleUpdate : undefined}
|
||||
disabled={updating || !versionInfo.autoUpdateSupported}
|
||||
className="ml-4 shrink-0 font-semibold"
|
||||
title={versionInfo.autoUpdateError || ""}
|
||||
>
|
||||
{versionInfo.autoUpdateSupported ? t("updateNow") || "Update Now" : "Manual Update"}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={versionInfo.autoUpdateSupported ? handleUpdate : undefined}
|
||||
disabled={updating || !versionInfo.autoUpdateSupported}
|
||||
className="shrink-0 ml-4 font-semibold"
|
||||
title={versionInfo.autoUpdateError || ""}
|
||||
>
|
||||
{versionInfo.autoUpdateSupported ? t("updateNow") || "Update Now" : "Manual Update"}
|
||||
</Button>
|
||||
|
||||
{/* News Notification Banner */}
|
||||
{versionInfo?.news && (
|
||||
<div className="flex min-h-[64px] items-center justify-between rounded-lg border border-border bg-surface px-5 py-4">
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-bg text-text-muted">
|
||||
<span className="material-symbols-outlined text-[22px] text-primary">
|
||||
{versionInfo.news.icon || "campaign"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-text-main">{versionInfo.news.title}</p>
|
||||
<p className="mt-0.5 max-w-[560px] text-xs leading-relaxed text-text-muted">
|
||||
{versionInfo.news.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{versionInfo.news.link && (
|
||||
<a
|
||||
href={versionInfo.news.link}
|
||||
target="_blank"
|
||||
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"}
|
||||
<span className="material-symbols-outlined text-[14px]">arrow_forward</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -6,7 +6,12 @@ import { Card, Button, Input } from "@/shared/components";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { CLI_COMPAT_PROVIDER_IDS } from "@/shared/constants/cliCompatProviders";
|
||||
import { CLI_TOOLS } from "@/shared/constants/cliTools";
|
||||
import {
|
||||
CLI_COMPAT_PROVIDER_IDS,
|
||||
CLI_COMPAT_TOGGLE_IDS,
|
||||
normalizeCliCompatProviderId,
|
||||
} from "@/shared/constants/cliCompatProviders";
|
||||
|
||||
interface AgentInfo {
|
||||
id: string;
|
||||
@@ -106,6 +111,14 @@ export default function AgentsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const normalizedCliCompatProviders = Array.from(
|
||||
new Set(
|
||||
(settings.cliCompatProviders || [])
|
||||
.map((providerId: string) => normalizeCliCompatProviderId(providerId))
|
||||
.filter((providerId: string) => CLI_COMPAT_PROVIDER_IDS.includes(providerId))
|
||||
)
|
||||
);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
@@ -307,19 +320,19 @@ export default function AgentsPage() {
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-text-muted">{ts("cliFingerprintDesc")}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CLI_COMPAT_PROVIDER_IDS.map((providerId) => {
|
||||
const providerMeta = Object.values(AI_PROVIDERS).find(
|
||||
(p: any) => p.id === providerId
|
||||
) as any;
|
||||
const isEnabled = (settings.cliCompatProviders || []).includes(providerId);
|
||||
const displayName = providerMeta?.name || providerId;
|
||||
{CLI_COMPAT_TOGGLE_IDS.map((toggleId) => {
|
||||
const providerId = normalizeCliCompatProviderId(toggleId);
|
||||
const providerMeta = Object.values(AI_PROVIDERS).find((p: any) => p.id === providerId) as any;
|
||||
const toolMeta = CLI_TOOLS[toggleId as keyof typeof CLI_TOOLS] as any;
|
||||
const isEnabled = normalizedCliCompatProviders.includes(providerId);
|
||||
const displayName = toolMeta?.name || providerMeta?.name || toggleId;
|
||||
const icon = providerMeta?.icon || "terminal";
|
||||
const color = providerMeta?.color || "#888";
|
||||
return (
|
||||
<button
|
||||
key={providerId}
|
||||
key={toggleId}
|
||||
onClick={() => {
|
||||
const current: string[] = settings.cliCompatProviders || [];
|
||||
const current = normalizedCliCompatProviders;
|
||||
const updated = current.includes(providerId)
|
||||
? current.filter((p) => p !== providerId)
|
||||
: [...current, providerId];
|
||||
@@ -347,11 +360,11 @@ export default function AgentsPage() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{(settings.cliCompatProviders || []).length > 0 && (
|
||||
{normalizedCliCompatProviders.length > 0 && (
|
||||
<p className="text-xs text-emerald-600 dark:text-emerald-400 mt-1 flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[14px]">verified</span>
|
||||
{ts("cliFingerprintEnabled", {
|
||||
count: (settings.cliCompatProviders || []).length,
|
||||
count: normalizedCliCompatProviders.length,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import { Button } from "@/shared/components";
|
||||
import {
|
||||
CHANGELOG_GITHUB_URL,
|
||||
CHANGELOG_RAW_URL,
|
||||
getLatestChangelogMarkdown,
|
||||
} from "@/shared/utils/releaseNotes";
|
||||
|
||||
function resolveChangelogHref(href: string | undefined): string | null {
|
||||
if (!href) return null;
|
||||
if (href.startsWith("#")) return href;
|
||||
|
||||
try {
|
||||
const url = new URL(href, "https://github.com/diegosouzapw/OmniRoute/blob/main/");
|
||||
return url.protocol === "https:" ? url.toString() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const markdownComponents: Components = {
|
||||
h1({ children }) {
|
||||
return <h1 className="mb-6 text-2xl font-bold text-text-main">{children}</h1>;
|
||||
},
|
||||
h2({ children }) {
|
||||
return (
|
||||
<h2 className="mt-8 mb-4 flex items-center gap-2 text-lg font-bold text-text-main first:mt-0">
|
||||
<span className="material-symbols-outlined text-[20px] text-primary">sell</span>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
},
|
||||
h3({ children }) {
|
||||
return (
|
||||
<h3 className="mt-5 mb-2 text-sm font-semibold uppercase text-text-main/80">{children}</h3>
|
||||
);
|
||||
},
|
||||
p({ children }) {
|
||||
return <p className="mb-2 text-sm leading-relaxed text-text-muted">{children}</p>;
|
||||
},
|
||||
ul({ children }) {
|
||||
return <ul className="my-3 flex flex-col gap-2">{children}</ul>;
|
||||
},
|
||||
li({ children }) {
|
||||
return (
|
||||
<li className="ml-2 flex items-start text-sm leading-relaxed text-text-muted">
|
||||
<span className="mr-3 mt-2 size-1.5 shrink-0 rounded-full bg-text-muted/30" />
|
||||
<span>{children}</span>
|
||||
</li>
|
||||
);
|
||||
},
|
||||
strong({ children }) {
|
||||
return <strong className="font-semibold text-text-main">{children}</strong>;
|
||||
},
|
||||
code({ children }) {
|
||||
return (
|
||||
<code className="rounded border border-black/5 bg-bg-subtle px-1.5 py-0.5 font-mono text-[13px] text-text-main dark:border-white/5">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
a({ href, children }) {
|
||||
const resolvedHref = resolveChangelogHref(href);
|
||||
if (!resolvedHref) return <span>{children}</span>;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={resolvedHref}
|
||||
target={resolvedHref.startsWith("#") ? undefined : "_blank"}
|
||||
rel={resolvedHref.startsWith("#") ? undefined : "noopener noreferrer"}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default function ChangelogViewer() {
|
||||
const [markdown, setMarkdown] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchChangelog() {
|
||||
try {
|
||||
const res = await fetch(CHANGELOG_RAW_URL, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`Changelog fetch failed with ${res.status}`);
|
||||
|
||||
const text = await res.text();
|
||||
setMarkdown(getLatestChangelogMarkdown(text, 10));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchChangelog();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center space-y-4 py-32">
|
||||
<span className="material-symbols-outlined animate-spin text-[32px] text-text-muted/50">
|
||||
sync
|
||||
</span>
|
||||
<p className="text-sm text-text-muted">Loading changelog from GitHub...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-text-muted">
|
||||
<span className="material-symbols-outlined mb-4 text-[48px] text-red-500/50">
|
||||
error_outline
|
||||
</span>
|
||||
<p>Could not load the changelog. Please try again later.</p>
|
||||
<Button variant="secondary" className="mt-4" onClick={() => globalThis.location.reload()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="max-w-none">
|
||||
<ReactMarkdown components={markdownComponents}>{markdown}</ReactMarkdown>
|
||||
</div>
|
||||
<div className="mt-12 flex justify-center border-t border-border pt-6">
|
||||
<a href={CHANGELOG_GITHUB_URL} target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="secondary" className="gap-2 text-xs">
|
||||
<span className="material-symbols-outlined text-[16px]">open_in_new</span>
|
||||
View Full History on GitHub
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/shared/components";
|
||||
import {
|
||||
NEWS_JSON_URL,
|
||||
parseActiveNewsPayload,
|
||||
type NewsAnnouncement,
|
||||
} from "@/shared/utils/releaseNotes";
|
||||
|
||||
export default function NewsViewer() {
|
||||
const [news, setNews] = useState<NewsAnnouncement | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchNews() {
|
||||
try {
|
||||
const res = await fetch(NEWS_JSON_URL, { cache: "no-store" });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setNews(parseActiveNewsPayload(data));
|
||||
} else {
|
||||
setError(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch news:", err);
|
||||
setError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchNews();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-20">
|
||||
<span className="material-symbols-outlined animate-spin text-[32px] text-text-muted">
|
||||
sync
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-text-muted">
|
||||
<span className="material-symbols-outlined text-[48px] text-red-500/50 mb-4">
|
||||
error_outline
|
||||
</span>
|
||||
<p>Could not load announcements. Please try again later.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!news || !news.active) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-text-muted">
|
||||
<span className="material-symbols-outlined text-[48px] opacity-50 mb-4">
|
||||
notifications_off
|
||||
</span>
|
||||
<p>No new announcements at this time.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex flex-col gap-6 border-l-4 border-primary pl-5 md:flex-row md:items-center md:pl-6">
|
||||
<div className="size-14 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<span className="material-symbols-outlined text-[30px] text-primary">
|
||||
{news.icon || "campaign"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-bold text-text-main mb-2">{news.title}</h2>
|
||||
<p className="text-sm text-text-muted leading-relaxed max-w-2xl">{news.message}</p>
|
||||
</div>
|
||||
|
||||
{news.link && (
|
||||
<div className="shrink-0 md:ml-auto">
|
||||
<a href={news.link} target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="primary" className="gap-2">
|
||||
{news.linkLabel || "Learn More"}
|
||||
<span className="material-symbols-outlined text-[18px]">arrow_forward</span>
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
src/app/(dashboard)/dashboard/changelog/page.tsx
Normal file
40
src/app/(dashboard)/dashboard/changelog/page.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, SegmentedControl } from "@/shared/components";
|
||||
import ChangelogViewer from "./components/ChangelogViewer";
|
||||
import NewsViewer from "./components/NewsViewer";
|
||||
|
||||
export default function ChangelogPage() {
|
||||
const [activeTab, setActiveTab] = useState<"news" | "changelog">("news");
|
||||
const t = useTranslations("sidebar");
|
||||
const title = typeof t.has === "function" && t.has("changelog") ? t("changelog") : "Changelog";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 max-w-5xl mx-auto w-full">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-main">{title}</h1>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
Stay up to date with the latest platform features and announcements.
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0 w-full sm:w-[240px]">
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ label: "News", value: "news" },
|
||||
{ label: "Changelog", value: "changelog" },
|
||||
]}
|
||||
value={activeTab}
|
||||
onChange={(val) => setActiveTab(val as "news" | "changelog")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="min-h-[500px] overflow-hidden" padding="none">
|
||||
{activeTab === "news" ? <NewsViewer /> : <ChangelogViewer />}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,10 @@ import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/comp
|
||||
import Image from "next/image";
|
||||
import CliStatusBadge from "./CliStatusBadge";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
getStoredClaudeAuthValue,
|
||||
normalizeClaudeBaseUrl,
|
||||
} from "@/shared/services/claudeCliConfig";
|
||||
|
||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
|
||||
@@ -98,7 +102,7 @@ export default function ClaudeToolCard({
|
||||
}
|
||||
});
|
||||
// Restore selected key from file: match token stored in file against known keys
|
||||
const tokenFromFile = env.ANTHROPIC_AUTH_TOKEN;
|
||||
const tokenFromFile = getStoredClaudeAuthValue(env);
|
||||
if (tokenFromFile) {
|
||||
// (#523) Keys from /api/keys are masked (first 8 + "****" + last 4).
|
||||
// Mask the token from file to compare against the masked list.
|
||||
@@ -124,12 +128,12 @@ export default function ClaudeToolCard({
|
||||
|
||||
const getEffectiveBaseUrl = () => {
|
||||
const url = customBaseUrl || baseUrl;
|
||||
return url.endsWith("/v1") ? url : `${url}/v1`;
|
||||
return normalizeClaudeBaseUrl(url);
|
||||
};
|
||||
|
||||
const getDisplayUrl = () => {
|
||||
const url = customBaseUrl || baseUrl;
|
||||
return url.endsWith("/v1") ? url : `${url}/v1`;
|
||||
return normalizeClaudeBaseUrl(url);
|
||||
};
|
||||
|
||||
const handleApplySettings = async () => {
|
||||
@@ -139,13 +143,9 @@ export default function ClaudeToolCard({
|
||||
const env: any = { ANTHROPIC_BASE_URL: getEffectiveBaseUrl() };
|
||||
|
||||
// (#523) Prefer keyId lookup so the backend writes the real key to disk.
|
||||
// Fall back to sk_omniroute for localhost-only setups without a key.
|
||||
// If no key is selected, leave auth unset so local installs can rely on
|
||||
// anonymous access instead of persisting a fake placeholder token.
|
||||
const selectedKeyId = selectedApiKey?.trim() || (apiKeys?.length > 0 ? apiKeys[0].id : null);
|
||||
const skOmnirouteFallback = !cloudEnabled ? "sk_omniroute" : null;
|
||||
|
||||
if (!selectedKeyId && skOmnirouteFallback) {
|
||||
env.ANTHROPIC_AUTH_TOKEN = skOmnirouteFallback;
|
||||
}
|
||||
|
||||
tool.defaultModels.forEach((model) => {
|
||||
const targetModel = modelMappings[model.alias] || model.defaultValue || "";
|
||||
@@ -221,13 +221,13 @@ export default function ClaudeToolCard({
|
||||
|
||||
// Generate settings.json content for manual copy
|
||||
const getManualConfigs = () => {
|
||||
const keyToUse =
|
||||
selectedApiKey && selectedApiKey.trim()
|
||||
? selectedApiKey
|
||||
: !cloudEnabled
|
||||
? "sk_omniroute"
|
||||
: "<API_KEY_FROM_DASHBOARD>";
|
||||
const env = { ANTHROPIC_BASE_URL: getEffectiveBaseUrl(), ANTHROPIC_AUTH_TOKEN: keyToUse };
|
||||
const env = { ANTHROPIC_BASE_URL: getEffectiveBaseUrl() };
|
||||
if (selectedApiKey && selectedApiKey.trim()) {
|
||||
env.ANTHROPIC_AUTH_TOKEN = "<API_KEY_FROM_DASHBOARD>";
|
||||
} else if (cloudEnabled) {
|
||||
env.ANTHROPIC_AUTH_TOKEN = "<API_KEY_FROM_DASHBOARD>";
|
||||
}
|
||||
|
||||
tool.defaultModels.forEach((model) => {
|
||||
const targetModel = modelMappings[model.alias];
|
||||
if (targetModel && model.envKey) env[model.envKey] = targetModel;
|
||||
@@ -443,7 +443,7 @@ export default function ClaudeToolCard({
|
||||
</select>
|
||||
) : (
|
||||
<span className="flex-1 text-xs text-text-muted px-2 py-1.5">
|
||||
{cloudEnabled ? t("noApiKeysCreateOne") : t("defaultOmnirouteKey")}
|
||||
{cloudEnabled ? t("noApiKeysCreateOne") : t("noApiKeysAvailable")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -103,6 +103,8 @@ export default function APIPageClient({ machineId }) {
|
||||
const [tailscaleInstallBusy, setTailscaleInstallBusy] = useState(false);
|
||||
const [tailscaleInstallLog, setTailscaleInstallLog] = useState<string[]>([]);
|
||||
const [tailscalePassword, setTailscalePassword] = useState("");
|
||||
const [showCloudflaredTunnel, setShowCloudflaredTunnel] = useState(true);
|
||||
const [showTailscaleFunnel, setShowTailscaleFunnel] = useState(true);
|
||||
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
|
||||
@@ -308,6 +310,8 @@ export default function APIPageClient({ machineId }) {
|
||||
if (data.machineId) {
|
||||
setResolvedMachineId(data.machineId);
|
||||
}
|
||||
setShowCloudflaredTunnel(data.hideEndpointCloudflaredTunnel !== true);
|
||||
setShowTailscaleFunnel(data.hideEndpointTailscaleFunnel !== true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error loading cloud settings:", error);
|
||||
@@ -989,236 +993,242 @@ export default function APIPageClient({ machineId }) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border/70 bg-surface/40 p-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{translateOrFallback("cloudflaredTitle", "Cloudflare Quick Tunnel")}
|
||||
</h3>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2 py-1 text-xs font-medium ${cloudflaredPhaseMeta[cloudflaredPhase].className}`}
|
||||
>
|
||||
{cloudflaredPhaseMeta[cloudflaredPhase].label}
|
||||
</span>
|
||||
{showCloudflaredTunnel && (
|
||||
<div className="rounded-xl border border-border/70 bg-surface/40 p-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{translateOrFallback("cloudflaredTitle", "Cloudflare Quick Tunnel")}
|
||||
</h3>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2 py-1 text-xs font-medium ${cloudflaredPhaseMeta[cloudflaredPhase].className}`}
|
||||
>
|
||||
{cloudflaredPhaseMeta[cloudflaredPhase].label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cloudflaredStatus?.supported !== false && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={cloudflaredStatus?.running ? "secondary" : "primary"}
|
||||
icon={cloudflaredStatus?.running ? "cloud_off" : "cloud_upload"}
|
||||
onClick={() =>
|
||||
handleCloudflaredAction(cloudflaredStatus?.running ? "disable" : "enable")
|
||||
}
|
||||
loading={cloudflaredBusy}
|
||||
className={
|
||||
cloudflaredStatus?.running
|
||||
? "border-border/70! text-text-muted! hover:text-text!"
|
||||
: "bg-linear-to-r from-primary to-cyan-500 hover:from-primary-hover hover:to-cyan-600"
|
||||
}
|
||||
>
|
||||
{cloudflaredActionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{cloudflaredNotice && (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-sm ${
|
||||
cloudflaredNotice.type === "success"
|
||||
? "border-green-500/30 bg-green-500/10 text-green-400"
|
||||
: cloudflaredNotice.type === "info"
|
||||
? "border-blue-500/30 bg-blue-500/10 text-blue-400"
|
||||
: "border-red-500/30 bg-red-500/10 text-red-400"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">
|
||||
{cloudflaredNotice.type === "success"
|
||||
? "check_circle"
|
||||
: cloudflaredNotice.type === "info"
|
||||
? "info"
|
||||
: "error"}
|
||||
</span>
|
||||
<span className="flex-1">{cloudflaredNotice.message}</span>
|
||||
<button
|
||||
onClick={() => setCloudflaredNotice(null)}
|
||||
className="rounded p-0.5 transition-colors hover:bg-white/10"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-text-muted">{cloudflaredUrlNotice}</p>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Input
|
||||
value={cloudflaredStatus?.apiUrl || ""}
|
||||
readOnly
|
||||
placeholder="https://*.trycloudflare.com/v1"
|
||||
className="flex-1 min-w-0 font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={copied === "cloudflared_url" ? "check" : "content_copy"}
|
||||
onClick={() =>
|
||||
cloudflaredStatus?.apiUrl && copy(cloudflaredStatus.apiUrl, "cloudflared_url")
|
||||
}
|
||||
disabled={!cloudflaredStatus?.apiUrl}
|
||||
className="shrink-0 self-start sm:self-auto"
|
||||
>
|
||||
{copied === "cloudflared_url" ? tc("copied") : tc("copy")}
|
||||
</Button>
|
||||
</div>
|
||||
{cloudflaredStatus?.lastError && (
|
||||
<p className="text-xs text-red-400">
|
||||
{translateOrFallback("cloudflaredLastError", "Last error: {error}", {
|
||||
error: cloudflaredStatus.lastError,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-xl border border-border/70 bg-surface/40 p-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{translateOrFallback("tailscaleTitle", "Tailscale Funnel")}
|
||||
</h3>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2 py-1 text-xs font-medium ${tailscalePhaseMeta[tailscalePhase].className}`}
|
||||
>
|
||||
{tailscalePhaseMeta[tailscalePhase].label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tailscaleStatus?.supported !== false && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={tailscaleStatus?.running ? "secondary" : "primary"}
|
||||
icon={tailscaleStatus?.running ? "vpn_lock_off" : "vpn_lock"}
|
||||
onClick={() => {
|
||||
if (tailscaleStatus?.running) {
|
||||
void handleTailscaleDisable();
|
||||
} else if (!tailscaleStatus?.installed) {
|
||||
setShowTailscaleInstallModal(true);
|
||||
} else {
|
||||
void handleTailscaleEnable();
|
||||
{cloudflaredStatus?.supported !== false && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={cloudflaredStatus?.running ? "secondary" : "primary"}
|
||||
icon={cloudflaredStatus?.running ? "cloud_off" : "cloud_upload"}
|
||||
onClick={() =>
|
||||
handleCloudflaredAction(cloudflaredStatus?.running ? "disable" : "enable")
|
||||
}
|
||||
}}
|
||||
loading={tailscaleBusy}
|
||||
className={
|
||||
tailscaleStatus?.running
|
||||
? "border-border/70! text-text-muted! hover:text-text!"
|
||||
: "bg-linear-to-r from-indigo-500 to-cyan-500 hover:from-indigo-600 hover:to-cyan-600"
|
||||
}
|
||||
>
|
||||
{tailscaleActionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tailscaleNotice && (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-sm ${
|
||||
tailscaleNotice.type === "success"
|
||||
? "border-green-500/30 bg-green-500/10 text-green-400"
|
||||
: tailscaleNotice.type === "info"
|
||||
? "border-blue-500/30 bg-blue-500/10 text-blue-400"
|
||||
: "border-red-500/30 bg-red-500/10 text-red-400"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">
|
||||
{tailscaleNotice.type === "success"
|
||||
? "check_circle"
|
||||
: tailscaleNotice.type === "info"
|
||||
? "info"
|
||||
: "error"}
|
||||
</span>
|
||||
<span className="flex-1">{tailscaleNotice.message}</span>
|
||||
<button
|
||||
onClick={() => setTailscaleNotice(null)}
|
||||
className="rounded p-0.5 transition-colors hover:bg-white/10"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-text-muted">{tailscaleUrlNotice}</p>
|
||||
{tailscaleStatus?.phase === "needs_login" && (
|
||||
<p className="text-xs text-blue-400">
|
||||
{translateOrFallback(
|
||||
"tailscaleNeedsLoginHint",
|
||||
"Authenticate this machine with Tailscale, then enable Funnel."
|
||||
loading={cloudflaredBusy}
|
||||
className={
|
||||
cloudflaredStatus?.running
|
||||
? "border-border/70! text-text-muted! hover:text-text!"
|
||||
: "bg-linear-to-r from-primary to-cyan-500 hover:from-primary-hover hover:to-cyan-600"
|
||||
}
|
||||
>
|
||||
{cloudflaredActionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{/* Sudo password input — shown when Tailscale is installed but not running (needs sudo to start daemon) */}
|
||||
{tailscaleStatus?.installed &&
|
||||
!tailscaleStatus?.running &&
|
||||
tailscaleStatus?.platform !== "win32" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-text-muted">
|
||||
{translateOrFallback(
|
||||
"tailscaleSudoLabel",
|
||||
"Sudo Password (required on macOS/Linux)"
|
||||
)}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={tailscalePassword}
|
||||
onChange={(event) => setTailscalePassword(event.target.value)}
|
||||
placeholder={translateOrFallback(
|
||||
"tailscaleSudoPlaceholder",
|
||||
"Enter sudo password"
|
||||
)}
|
||||
disabled={tailscaleBusy}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{cloudflaredNotice && (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-sm ${
|
||||
cloudflaredNotice.type === "success"
|
||||
? "border-green-500/30 bg-green-500/10 text-green-400"
|
||||
: cloudflaredNotice.type === "info"
|
||||
? "border-blue-500/30 bg-blue-500/10 text-blue-400"
|
||||
: "border-red-500/30 bg-red-500/10 text-red-400"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">
|
||||
{cloudflaredNotice.type === "success"
|
||||
? "check_circle"
|
||||
: cloudflaredNotice.type === "info"
|
||||
? "info"
|
||||
: "error"}
|
||||
</span>
|
||||
<span className="flex-1">{cloudflaredNotice.message}</span>
|
||||
<button
|
||||
onClick={() => setCloudflaredNotice(null)}
|
||||
className="rounded p-0.5 transition-colors hover:bg-white/10"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Input
|
||||
value={tailscaleStatus?.apiUrl || ""}
|
||||
readOnly
|
||||
placeholder="https://your-device.tailnet.ts.net/v1"
|
||||
className="flex-1 min-w-0 font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={copied === "tailscale_url" ? "check" : "content_copy"}
|
||||
onClick={() =>
|
||||
tailscaleStatus?.apiUrl && copy(tailscaleStatus.apiUrl, "tailscale_url")
|
||||
}
|
||||
disabled={!tailscaleStatus?.apiUrl}
|
||||
className="shrink-0 self-start sm:self-auto"
|
||||
>
|
||||
{copied === "tailscale_url" ? tc("copied") : tc("copy")}
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-text-muted">{cloudflaredUrlNotice}</p>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Input
|
||||
value={cloudflaredStatus?.apiUrl || ""}
|
||||
readOnly
|
||||
placeholder="https://*.trycloudflare.com/v1"
|
||||
className="flex-1 min-w-0 font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={copied === "cloudflared_url" ? "check" : "content_copy"}
|
||||
onClick={() =>
|
||||
cloudflaredStatus?.apiUrl && copy(cloudflaredStatus.apiUrl, "cloudflared_url")
|
||||
}
|
||||
disabled={!cloudflaredStatus?.apiUrl}
|
||||
className="shrink-0 self-start sm:self-auto"
|
||||
>
|
||||
{copied === "cloudflared_url" ? tc("copied") : tc("copy")}
|
||||
</Button>
|
||||
</div>
|
||||
{cloudflaredStatus?.lastError && (
|
||||
<p className="text-xs text-red-400">
|
||||
{translateOrFallback("cloudflaredLastError", "Last error: {error}", {
|
||||
error: cloudflaredStatus.lastError,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{tailscaleStatus?.binaryPath && (
|
||||
<p className="text-xs text-text-muted">
|
||||
{translateOrFallback("tailscaleBinaryPath", "Binary: {path}", {
|
||||
path: tailscaleStatus.binaryPath,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{tailscaleStatus?.lastError && (
|
||||
<p className="text-xs text-red-400">
|
||||
{translateOrFallback("tailscaleLastError", "Last error: {error}", {
|
||||
error: tailscaleStatus.lastError,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showTailscaleFunnel && (
|
||||
<div
|
||||
className={`${showCloudflaredTunnel ? "mt-4 " : ""}rounded-xl border border-border/70 bg-surface/40 p-4`}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{translateOrFallback("tailscaleTitle", "Tailscale Funnel")}
|
||||
</h3>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2 py-1 text-xs font-medium ${tailscalePhaseMeta[tailscalePhase].className}`}
|
||||
>
|
||||
{tailscalePhaseMeta[tailscalePhase].label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tailscaleStatus?.supported !== false && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={tailscaleStatus?.running ? "secondary" : "primary"}
|
||||
icon={tailscaleStatus?.running ? "vpn_lock_off" : "vpn_lock"}
|
||||
onClick={() => {
|
||||
if (tailscaleStatus?.running) {
|
||||
void handleTailscaleDisable();
|
||||
} else if (!tailscaleStatus?.installed) {
|
||||
setShowTailscaleInstallModal(true);
|
||||
} else {
|
||||
void handleTailscaleEnable();
|
||||
}
|
||||
}}
|
||||
loading={tailscaleBusy}
|
||||
className={
|
||||
tailscaleStatus?.running
|
||||
? "border-border/70! text-text-muted! hover:text-text!"
|
||||
: "bg-linear-to-r from-indigo-500 to-cyan-500 hover:from-indigo-600 hover:to-cyan-600"
|
||||
}
|
||||
>
|
||||
{tailscaleActionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tailscaleNotice && (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-sm ${
|
||||
tailscaleNotice.type === "success"
|
||||
? "border-green-500/30 bg-green-500/10 text-green-400"
|
||||
: tailscaleNotice.type === "info"
|
||||
? "border-blue-500/30 bg-blue-500/10 text-blue-400"
|
||||
: "border-red-500/30 bg-red-500/10 text-red-400"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">
|
||||
{tailscaleNotice.type === "success"
|
||||
? "check_circle"
|
||||
: tailscaleNotice.type === "info"
|
||||
? "info"
|
||||
: "error"}
|
||||
</span>
|
||||
<span className="flex-1">{tailscaleNotice.message}</span>
|
||||
<button
|
||||
onClick={() => setTailscaleNotice(null)}
|
||||
className="rounded p-0.5 transition-colors hover:bg-white/10"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-text-muted">{tailscaleUrlNotice}</p>
|
||||
{tailscaleStatus?.phase === "needs_login" && (
|
||||
<p className="text-xs text-blue-400">
|
||||
{translateOrFallback(
|
||||
"tailscaleNeedsLoginHint",
|
||||
"Authenticate this machine with Tailscale, then enable Funnel."
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{/* Sudo password input — shown when Tailscale is installed but not running (needs sudo to start daemon) */}
|
||||
{tailscaleStatus?.installed &&
|
||||
!tailscaleStatus?.running &&
|
||||
tailscaleStatus?.platform !== "win32" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-text-muted">
|
||||
{translateOrFallback(
|
||||
"tailscaleSudoLabel",
|
||||
"Sudo Password (required on macOS/Linux)"
|
||||
)}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={tailscalePassword}
|
||||
onChange={(event) => setTailscalePassword(event.target.value)}
|
||||
placeholder={translateOrFallback(
|
||||
"tailscaleSudoPlaceholder",
|
||||
"Enter sudo password"
|
||||
)}
|
||||
disabled={tailscaleBusy}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Input
|
||||
value={tailscaleStatus?.apiUrl || ""}
|
||||
readOnly
|
||||
placeholder="https://your-device.tailnet.ts.net/v1"
|
||||
className="flex-1 min-w-0 font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={copied === "tailscale_url" ? "check" : "content_copy"}
|
||||
onClick={() =>
|
||||
tailscaleStatus?.apiUrl && copy(tailscaleStatus.apiUrl, "tailscale_url")
|
||||
}
|
||||
disabled={!tailscaleStatus?.apiUrl}
|
||||
className="shrink-0 self-start sm:self-auto"
|
||||
>
|
||||
{copied === "tailscale_url" ? tc("copied") : tc("copy")}
|
||||
</Button>
|
||||
</div>
|
||||
{tailscaleStatus?.binaryPath && (
|
||||
<p className="text-xs text-text-muted">
|
||||
{translateOrFallback("tailscaleBinaryPath", "Binary: {path}", {
|
||||
path: tailscaleStatus.binaryPath,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{tailscaleStatus?.lastError && (
|
||||
<p className="text-xs text-red-400">
|
||||
{translateOrFallback("tailscaleLastError", "Last error: {error}", {
|
||||
error: tailscaleStatus.lastError,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
|
||||
@@ -2730,7 +2730,7 @@ export default function ProviderDetailPage() {
|
||||
|
||||
{isCompatible && providerNode && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{isCcCompatible
|
||||
@@ -2743,7 +2743,7 @@ export default function ProviderDetailPage() {
|
||||
{getApiLabel()} · {(providerNode.baseUrl || "").replace(/\/$/, "")}/{getApiPath()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" icon="add" onClick={() => setShowAddApiKeyModal(true)}>
|
||||
{t("add")}
|
||||
</Button>
|
||||
@@ -2788,6 +2788,16 @@ export default function ProviderDetailPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{isCcCompatible && (
|
||||
<div className="mb-4 rounded-lg border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-sm text-text-muted">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="material-symbols-outlined mt-0.5 text-[18px] text-amber-500">
|
||||
warning
|
||||
</span>
|
||||
<p>{t("ccCompatibleValidationHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -3525,12 +3535,12 @@ function ModelRow({
|
||||
className={`rounded p-0.5 hover:bg-sidebar transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${testStatus === "ok" ? "text-green-500" : testStatus === "error" ? "text-red-500" : "text-text-muted hover:text-primary"}`}
|
||||
title={
|
||||
testingModel
|
||||
? t("testingModel", "Testing...")
|
||||
? t("testingModel")
|
||||
: testStatus === "ok"
|
||||
? "OK"
|
||||
: testStatus === "error"
|
||||
? "Error"
|
||||
: t("testModel", "Test Model")
|
||||
: t("testModel")
|
||||
}
|
||||
>
|
||||
{testingModel ? (
|
||||
@@ -3911,12 +3921,12 @@ function PassthroughModelRow({
|
||||
className={`rounded p-0.5 hover:bg-sidebar transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${testStatus === "ok" ? "text-green-500" : testStatus === "error" ? "text-red-500" : "text-text-muted hover:text-primary"}`}
|
||||
title={
|
||||
testingModel
|
||||
? t("testingModel", "Testing...")
|
||||
? t("testingModel")
|
||||
: testStatus === "ok"
|
||||
? "OK"
|
||||
: testStatus === "error"
|
||||
? "Error"
|
||||
: t("testModel", "Test Model")
|
||||
: t("testModel")
|
||||
}
|
||||
>
|
||||
{testingModel ? (
|
||||
@@ -5772,6 +5782,7 @@ function AddApiKeyModal({
|
||||
}
|
||||
|
||||
let isValid = false;
|
||||
let validationError: string | null = null;
|
||||
try {
|
||||
setValidating(true);
|
||||
setValidationResult(null);
|
||||
@@ -5789,6 +5800,9 @@ function AddApiKeyModal({
|
||||
});
|
||||
const data = await res.json();
|
||||
isValid = !!data.valid;
|
||||
if (!isValid && data.error) {
|
||||
validationError = data.error;
|
||||
}
|
||||
setValidationResult(isValid ? "success" : "failed");
|
||||
} catch {
|
||||
setValidationResult("failed");
|
||||
@@ -5797,8 +5811,13 @@ function AddApiKeyModal({
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
setSaveError(t("apiKeyValidationFailed"));
|
||||
return;
|
||||
if (apiKeyOptional && !formData.apiKey) {
|
||||
// Bypass validation block for local/optional providers when no key is provided
|
||||
console.debug("Validation failed but apiKey is optional; proceeding to save.");
|
||||
} else {
|
||||
setSaveError(validationError || t("apiKeyValidationFailed"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const providerSpecificData: Record<string, unknown> = {};
|
||||
@@ -5860,6 +5879,16 @@ function AddApiKeyModal({
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{isCcCompatible && (
|
||||
<div className="rounded-lg border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-sm text-text-muted">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="material-symbols-outlined mt-0.5 text-[18px] text-amber-500">
|
||||
warning
|
||||
</span>
|
||||
<p>{t("ccCompatibleValidationHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
label={t("nameLabel")}
|
||||
value={formData.name}
|
||||
@@ -5920,17 +5949,15 @@ function AddApiKeyModal({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isCompatible && (
|
||||
{isCompatible && !isCcCompatible && (
|
||||
<p className="text-xs text-text-muted">
|
||||
{isCcCompatible
|
||||
? t("ccCompatibleValidationHint")
|
||||
: isAnthropic
|
||||
? t("validationChecksAnthropicCompatible", {
|
||||
provider: providerName || t("anthropicCompatibleName"),
|
||||
})
|
||||
: t("validationChecksOpenAiCompatible", {
|
||||
provider: providerName || t("openaiCompatibleName"),
|
||||
})}
|
||||
{isAnthropic
|
||||
? t("validationChecksAnthropicCompatible", {
|
||||
provider: providerName || t("anthropicCompatibleName"),
|
||||
})
|
||||
: t("validationChecksOpenAiCompatible", {
|
||||
provider: providerName || t("openaiCompatibleName"),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
@@ -6959,6 +6986,16 @@ function EditCompatibleNodeModal({
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{isCcCompatible && (
|
||||
<div className="rounded-lg border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-sm text-text-muted">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="material-symbols-outlined mt-0.5 text-[18px] text-amber-500">
|
||||
warning
|
||||
</span>
|
||||
<p>{t("ccCompatibleValidationHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
label={t("nameLabel")}
|
||||
value={formData.name}
|
||||
|
||||
@@ -1044,7 +1044,6 @@ export default function ProvidersPage() {
|
||||
<AddCcCompatibleModal
|
||||
isOpen={showAddCcCompatibleModal}
|
||||
addLabel={addCcCompatibleLabel}
|
||||
compatibleLabel={ccCompatibleLabel}
|
||||
onClose={() => setShowAddCcCompatibleModal(false)}
|
||||
onCreated={(node) => {
|
||||
setProviderNodes((prev) => [...prev, node]);
|
||||
@@ -1752,12 +1751,12 @@ AddAnthropicCompatibleModal.propTypes = {
|
||||
onCreated: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function AddCcCompatibleModal({ isOpen, addLabel, compatibleLabel, onClose, onCreated }) {
|
||||
function AddCcCompatibleModal({ isOpen, addLabel, onClose, onCreated }) {
|
||||
const t = useTranslations("providers");
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
prefix: "",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
baseUrl: "",
|
||||
chatPath: CC_COMPATIBLE_DEFAULT_CHAT_PATH,
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -1765,6 +1764,10 @@ function AddCcCompatibleModal({ isOpen, addLabel, compatibleLabel, onClose, onCr
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState<"success" | "failed" | null>(null);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const hasRequiredFields = Boolean(
|
||||
formData.name.trim() && formData.prefix.trim() && formData.baseUrl.trim()
|
||||
);
|
||||
const canValidate = Boolean(checkKey.trim() && formData.baseUrl.trim());
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -1774,7 +1777,7 @@ function AddCcCompatibleModal({ isOpen, addLabel, compatibleLabel, onClose, onCr
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return;
|
||||
if (!hasRequiredFields) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch("/api/provider-nodes", {
|
||||
@@ -1795,7 +1798,7 @@ function AddCcCompatibleModal({ isOpen, addLabel, compatibleLabel, onClose, onCr
|
||||
setFormData({
|
||||
name: "",
|
||||
prefix: "",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
baseUrl: "",
|
||||
chatPath: CC_COMPATIBLE_DEFAULT_CHAT_PATH,
|
||||
});
|
||||
setCheckKey("");
|
||||
@@ -1835,26 +1838,34 @@ function AddCcCompatibleModal({ isOpen, addLabel, compatibleLabel, onClose, onCr
|
||||
return (
|
||||
<Modal isOpen={isOpen} title={addLabel} onClose={onClose}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded-lg border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-sm text-text-muted">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="material-symbols-outlined mt-0.5 text-[18px] text-amber-500">
|
||||
warning
|
||||
</span>
|
||||
<p>{t("ccCompatibleValidationHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
label={t("nameLabel")}
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder={t("compatibleProdPlaceholder", { type: compatibleLabel })}
|
||||
hint={t("nameHint")}
|
||||
placeholder={t("ccCompatibleNamePlaceholder")}
|
||||
hint={t("ccCompatibleNameHint")}
|
||||
/>
|
||||
<Input
|
||||
label={t("prefixLabel")}
|
||||
value={formData.prefix}
|
||||
onChange={(e) => setFormData({ ...formData, prefix: e.target.value })}
|
||||
placeholder="cc-prod"
|
||||
hint={t("prefixHint")}
|
||||
placeholder={t("ccCompatiblePrefixPlaceholder")}
|
||||
hint={t("ccCompatiblePrefixHint")}
|
||||
/>
|
||||
<Input
|
||||
label={t("baseUrlLabel")}
|
||||
value={formData.baseUrl}
|
||||
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
|
||||
placeholder="https://api.anthropic.com"
|
||||
hint={t("compatibleBaseUrlHint", { type: compatibleLabel })}
|
||||
placeholder={t("ccCompatibleBaseUrlPlaceholder")}
|
||||
hint={t("ccCompatibleBaseUrlHint")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1881,7 +1892,7 @@ function AddCcCompatibleModal({ isOpen, addLabel, compatibleLabel, onClose, onCr
|
||||
value={formData.chatPath}
|
||||
onChange={(e) => setFormData({ ...formData, chatPath: e.target.value })}
|
||||
placeholder={CC_COMPATIBLE_DEFAULT_CHAT_PATH}
|
||||
hint={t("chatPathHint")}
|
||||
hint={t("ccCompatibleChatPathHint")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -1896,7 +1907,7 @@ function AddCcCompatibleModal({ isOpen, addLabel, compatibleLabel, onClose, onCr
|
||||
<div className="pt-6">
|
||||
<Button
|
||||
onClick={handleValidate}
|
||||
disabled={!checkKey || validating || !formData.baseUrl.trim()}
|
||||
disabled={!canValidate || validating}
|
||||
variant="secondary"
|
||||
>
|
||||
{validating ? t("checking") : t("check")}
|
||||
@@ -1909,16 +1920,7 @@ function AddCcCompatibleModal({ isOpen, addLabel, compatibleLabel, onClose, onCr
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
fullWidth
|
||||
disabled={
|
||||
!formData.name.trim() ||
|
||||
!formData.prefix.trim() ||
|
||||
!formData.baseUrl.trim() ||
|
||||
submitting
|
||||
}
|
||||
>
|
||||
<Button onClick={handleSubmit} fullWidth disabled={!hasRequiredFields || submitting}>
|
||||
{submitting ? t("creating") : t("add")}
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||
@@ -1933,7 +1935,6 @@ function AddCcCompatibleModal({ isOpen, addLabel, compatibleLabel, onClose, onCr
|
||||
AddCcCompatibleModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
addLabel: PropTypes.string.isRequired,
|
||||
compatibleLabel: PropTypes.string.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onCreated: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
@@ -36,6 +36,8 @@ export default function AppearanceTab() {
|
||||
);
|
||||
const hiddenSidebarSet = new Set(hiddenSidebarItems);
|
||||
const comboConfigMode = normalizeComboConfigMode(settings[COMBO_CONFIG_MODE_SETTING_KEY]);
|
||||
const showCloudflaredTunnel = settings.hideEndpointCloudflaredTunnel !== true;
|
||||
const showTailscaleFunnel = settings.hideEndpointTailscaleFunnel !== true;
|
||||
|
||||
const getSettingsLabel = (key: string, fallback: string) =>
|
||||
typeof t.has === "function" && t.has(key) ? t(key) : fallback;
|
||||
@@ -255,6 +257,60 @@ export default function AppearanceTab() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-border">
|
||||
<div className="mb-3">
|
||||
<p className="font-medium">
|
||||
{getSettingsLabel("endpointTunnelVisibility", "Endpoint tunnel visibility")}
|
||||
</p>
|
||||
<p className="text-sm text-text-muted">
|
||||
{getSettingsLabel(
|
||||
"endpointTunnelVisibilityDesc",
|
||||
"Hide tunnel controls from the Endpoint page without changing tunnel state."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-surface/40 divide-y divide-border/70">
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-3">
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{getSettingsLabel("showCloudflareTunnel", "Cloudflare Quick Tunnel")}
|
||||
</p>
|
||||
<p className="text-sm text-text-muted">
|
||||
{getSettingsLabel(
|
||||
"showCloudflareTunnelDesc",
|
||||
"Show Cloudflare Quick Tunnel controls on the Endpoint page."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={showCloudflaredTunnel}
|
||||
onChange={(checked) => updateSetting("hideEndpointCloudflaredTunnel", !checked)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-3">
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{getSettingsLabel("showTailscaleFunnel", "Tailscale Funnel")}
|
||||
</p>
|
||||
<p className="text-sm text-text-muted">
|
||||
{getSettingsLabel(
|
||||
"showTailscaleFunnelDesc",
|
||||
"Show Tailscale Funnel controls on the Endpoint page."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={showTailscaleFunnel}
|
||||
onChange={(checked) => updateSetting("hideEndpointTailscaleFunnel", !checked)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-border">
|
||||
<div className="mb-3">
|
||||
<p className="font-medium">
|
||||
|
||||
@@ -38,6 +38,23 @@ type TestResult = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type ParsedProxyEntry = {
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
type: string;
|
||||
region: string;
|
||||
status: string;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
type ParseError = {
|
||||
line: number;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
const EMPTY_FORM = {
|
||||
id: "",
|
||||
name: "",
|
||||
@@ -51,6 +68,85 @@ const EMPTY_FORM = {
|
||||
status: "active",
|
||||
};
|
||||
|
||||
const BULK_IMPORT_TEMPLATE = `# Proxy Bulk Import
|
||||
# Format: NAME|HOST|PORT|USERNAME|PASSWORD|TYPE|REGION|STATUS|NOTES
|
||||
# Required: NAME, HOST, PORT
|
||||
# Optional: USERNAME, PASSWORD, TYPE (http|https|socks5, default: socks5), REGION, STATUS (active|inactive, default: active), NOTES
|
||||
# Lines starting with # are ignored. Existing proxies (same host+port) will be updated.
|
||||
#
|
||||
# SOCKS5 examples:
|
||||
# proxy-us|138.99.147.218|50101|myuser|mypass|socks5|US-East|active|US production proxy
|
||||
# proxy-eu|200.234.177.62|50101|myuser|mypass|socks5|EU-West
|
||||
#
|
||||
# HTTP/HTTPS examples:
|
||||
# http-proxy|10.0.0.50|8080|||http||active|Internal HTTP proxy
|
||||
# https-proxy|proxy.example.com|443|admin|secret123|https|US|active
|
||||
`;
|
||||
|
||||
const VALID_TYPES = new Set(["http", "https", "socks5"]);
|
||||
const VALID_STATUSES = new Set(["active", "inactive"]);
|
||||
|
||||
function parseBulkImportText(text: string): {
|
||||
entries: ParsedProxyEntry[];
|
||||
errors: ParseError[];
|
||||
skipped: number;
|
||||
} {
|
||||
const lines = text.split("\n");
|
||||
const entries: ParsedProxyEntry[] = [];
|
||||
const errors: ParseError[] = [];
|
||||
let skipped = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const raw = lines[i].trim();
|
||||
if (!raw || raw.startsWith("#")) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const parts = raw.split("|").map((p) => p.trim());
|
||||
const [name, host, portStr, username, password, type, region, status, notes] = parts;
|
||||
const lineNum = i + 1;
|
||||
|
||||
if (!name) {
|
||||
errors.push({ line: lineNum, reason: "Missing NAME" });
|
||||
continue;
|
||||
}
|
||||
if (!host) {
|
||||
errors.push({ line: lineNum, reason: "Missing HOST" });
|
||||
continue;
|
||||
}
|
||||
const port = Number(portStr);
|
||||
if (!portStr || isNaN(port) || port < 1 || port > 65535) {
|
||||
errors.push({ line: lineNum, reason: "Invalid PORT (must be 1-65535)" });
|
||||
continue;
|
||||
}
|
||||
const normalizedType = (type || "socks5").toLowerCase();
|
||||
if (!VALID_TYPES.has(normalizedType)) {
|
||||
errors.push({ line: lineNum, reason: `Invalid TYPE '${type}' (use http, https, or socks5)` });
|
||||
continue;
|
||||
}
|
||||
const normalizedStatus = (status || "active").toLowerCase();
|
||||
if (!VALID_STATUSES.has(normalizedStatus)) {
|
||||
errors.push({ line: lineNum, reason: `Invalid STATUS '${status}' (use active or inactive)` });
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.push({
|
||||
name,
|
||||
host,
|
||||
port,
|
||||
username: username || "",
|
||||
password: password || "",
|
||||
type: normalizedType,
|
||||
region: region || "",
|
||||
status: normalizedStatus,
|
||||
notes: notes || "",
|
||||
});
|
||||
}
|
||||
|
||||
return { entries, errors, skipped };
|
||||
}
|
||||
|
||||
export default function ProxyRegistryManager() {
|
||||
const t = useTranslations("proxyRegistry");
|
||||
const [items, setItems] = useState<ProxyItem[]>([]);
|
||||
@@ -72,6 +168,20 @@ export default function ProxyRegistryManager() {
|
||||
const [bulkScopeIds, setBulkScopeIds] = useState("");
|
||||
const [bulkProxyId, setBulkProxyId] = useState("");
|
||||
|
||||
// Bulk Import state
|
||||
const [bulkImportOpen, setBulkImportOpen] = useState(false);
|
||||
const [bulkImportText, setBulkImportText] = useState(BULK_IMPORT_TEMPLATE);
|
||||
const [bulkImportParsed, setBulkImportParsed] = useState<ParsedProxyEntry[]>([]);
|
||||
const [bulkImportErrors, setBulkImportErrors] = useState<ParseError[]>([]);
|
||||
const [bulkImportSkipped, setBulkImportSkipped] = useState(0);
|
||||
const [bulkImportParsedOnce, setBulkImportParsedOnce] = useState(false);
|
||||
const [bulkImporting, setBulkImporting] = useState(false);
|
||||
const [bulkImportResult, setBulkImportResult] = useState<{
|
||||
created: number;
|
||||
updated: number;
|
||||
failed: number;
|
||||
} | null>(null);
|
||||
|
||||
const editingId = useMemo(() => form.id || "", [form.id]);
|
||||
|
||||
const loadHealth = useCallback(async () => {
|
||||
@@ -382,6 +492,77 @@ export default function ProxyRegistryManager() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkImportParse = () => {
|
||||
const { entries, errors, skipped } = parseBulkImportText(bulkImportText);
|
||||
setBulkImportParsed(entries);
|
||||
setBulkImportErrors(errors);
|
||||
setBulkImportSkipped(skipped);
|
||||
setBulkImportParsedOnce(true);
|
||||
setBulkImportResult(null);
|
||||
};
|
||||
|
||||
const handleBulkImportExecute = async () => {
|
||||
if (bulkImportParsed.length === 0) return;
|
||||
if (bulkImportParsed.length > 100) {
|
||||
setError(t("bulkImportMaxExceeded"));
|
||||
return;
|
||||
}
|
||||
|
||||
setBulkImporting(true);
|
||||
setError(null);
|
||||
setBulkImportResult(null);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
items: bulkImportParsed.map((entry) => ({
|
||||
name: entry.name,
|
||||
type: entry.type,
|
||||
host: entry.host,
|
||||
port: entry.port,
|
||||
username: entry.username || undefined,
|
||||
password: entry.password || undefined,
|
||||
region: entry.region || null,
|
||||
notes: entry.notes || null,
|
||||
status: entry.status as "active" | "inactive",
|
||||
})),
|
||||
};
|
||||
|
||||
const res = await fetch("/api/settings/proxies/bulk-import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data?.error?.message || "Failed to import proxies");
|
||||
return;
|
||||
}
|
||||
|
||||
setBulkImportResult({
|
||||
created: data.created || 0,
|
||||
updated: data.updated || 0,
|
||||
failed: data.failed || 0,
|
||||
});
|
||||
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to import proxies");
|
||||
} finally {
|
||||
setBulkImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openBulkImport = () => {
|
||||
setBulkImportText(BULK_IMPORT_TEMPLATE);
|
||||
setBulkImportParsed([]);
|
||||
setBulkImportErrors([]);
|
||||
setBulkImportSkipped(0);
|
||||
setBulkImportParsedOnce(false);
|
||||
setBulkImportResult(null);
|
||||
setBulkImportOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="p-6">
|
||||
@@ -401,6 +582,15 @@ export default function ProxyRegistryManager() {
|
||||
>
|
||||
{t("importLegacy")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="upload_file"
|
||||
onClick={openBulkImport}
|
||||
data-testid="proxy-registry-open-bulk-import"
|
||||
>
|
||||
{t("bulkImport")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
@@ -726,6 +916,158 @@ export default function ProxyRegistryManager() {
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Bulk Import Modal */}
|
||||
<Modal
|
||||
isOpen={bulkImportOpen}
|
||||
onClose={() => {
|
||||
if (!bulkImporting) setBulkImportOpen(false);
|
||||
}}
|
||||
title={t("bulkImportTitle")}
|
||||
maxWidth="xl"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-text-muted">{t("bulkImportDescription")}</p>
|
||||
|
||||
<div>
|
||||
<textarea
|
||||
data-testid="proxy-registry-bulk-import-textarea"
|
||||
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border font-mono text-xs leading-relaxed"
|
||||
rows={14}
|
||||
value={bulkImportText}
|
||||
onChange={(e) => {
|
||||
setBulkImportText(e.target.value);
|
||||
setBulkImportParsedOnce(false);
|
||||
setBulkImportResult(null);
|
||||
}}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Parse button */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="search"
|
||||
onClick={handleBulkImportParse}
|
||||
data-testid="proxy-registry-bulk-import-parse"
|
||||
>
|
||||
{t("bulkImportParse")}
|
||||
</Button>
|
||||
|
||||
{bulkImportParsedOnce && (
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="text-emerald-400">
|
||||
{t("bulkImportParsed", { count: bulkImportParsed.length })}
|
||||
</span>
|
||||
<span className="text-text-muted">
|
||||
{t("bulkImportSkipped", { count: bulkImportSkipped })}
|
||||
</span>
|
||||
{bulkImportErrors.length > 0 && (
|
||||
<span className="text-red-400">
|
||||
{t("bulkImportParseErrors", { count: bulkImportErrors.length })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Parse errors */}
|
||||
{bulkImportErrors.length > 0 && (
|
||||
<div className="max-h-28 overflow-y-auto rounded border border-red-500/30 bg-red-500/10 p-2">
|
||||
{bulkImportErrors.map((err, idx) => (
|
||||
<div key={idx} className="text-xs text-red-400">
|
||||
{t("bulkImportErrorLine", { line: err.line, reason: err.reason })}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview table */}
|
||||
{bulkImportParsedOnce && bulkImportParsed.length > 0 && (
|
||||
<div className="overflow-x-auto max-h-48 overflow-y-auto rounded border border-border">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-left text-text-muted border-b border-border bg-bg-subtle sticky top-0">
|
||||
<th className="py-1.5 px-2">Name</th>
|
||||
<th className="py-1.5 px-2">Type</th>
|
||||
<th className="py-1.5 px-2">Host</th>
|
||||
<th className="py-1.5 px-2">Port</th>
|
||||
<th className="py-1.5 px-2">User</th>
|
||||
<th className="py-1.5 px-2">Region</th>
|
||||
<th className="py-1.5 px-2">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bulkImportParsed.map((entry, idx) => (
|
||||
<tr key={idx} className="border-b border-border/40">
|
||||
<td className="py-1 px-2 font-medium text-text-main">{entry.name}</td>
|
||||
<td className="py-1 px-2">
|
||||
<span className="px-1.5 py-0.5 rounded bg-bg-subtle border border-border text-[10px]">
|
||||
{entry.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1 px-2 font-mono text-text-muted">{entry.host}</td>
|
||||
<td className="py-1 px-2 font-mono text-text-muted">{entry.port}</td>
|
||||
<td className="py-1 px-2 text-text-muted">{entry.username || "—"}</td>
|
||||
<td className="py-1 px-2 text-text-muted">{entry.region || "—"}</td>
|
||||
<td className="py-1 px-2">
|
||||
<span
|
||||
className={
|
||||
entry.status === "active" ? "text-emerald-400" : "text-text-muted"
|
||||
}
|
||||
>
|
||||
{entry.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No valid entries warning */}
|
||||
{bulkImportParsedOnce &&
|
||||
bulkImportParsed.length === 0 &&
|
||||
bulkImportErrors.length === 0 && (
|
||||
<div className="text-sm text-amber-400">{t("bulkImportNoValidEntries")}</div>
|
||||
)}
|
||||
|
||||
{/* Import result */}
|
||||
{bulkImportResult && (
|
||||
<div className="px-3 py-2 rounded border border-emerald-500/30 bg-emerald-500/10 text-sm text-emerald-400">
|
||||
{t("bulkImportSuccess", {
|
||||
created: bulkImportResult.created,
|
||||
updated: bulkImportResult.updated,
|
||||
failed: bulkImportResult.failed,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
|
||||
<Button size="sm" variant="secondary" onClick={() => setBulkImportOpen(false)}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
icon="upload"
|
||||
onClick={handleBulkImportExecute}
|
||||
loading={bulkImporting}
|
||||
disabled={!bulkImportParsedOnce || bulkImportParsed.length === 0}
|
||||
data-testid="proxy-registry-bulk-import-execute"
|
||||
>
|
||||
{bulkImporting
|
||||
? t("bulkImportImporting")
|
||||
: bulkImportParsed.length > 0
|
||||
? t("bulkImportImport", { count: bulkImportParsed.length })
|
||||
: t("bulkImport")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getCliRuntimeStatus,
|
||||
} from "@/shared/services/cliRuntime";
|
||||
import { createBackup } from "@/shared/services/backupService";
|
||||
import { normalizeClaudeBaseUrl } from "@/shared/services/claudeCliConfig";
|
||||
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
|
||||
import { cliSettingsEnvSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
@@ -122,7 +123,7 @@ export async function POST(request: Request) {
|
||||
env.ANTHROPIC_AUTH_TOKEN = keyRecord.key as string;
|
||||
}
|
||||
} catch {
|
||||
// Non-critical: fall back to whatever value was in env (e.g. sk_omniroute)
|
||||
// Non-critical: fall back to whatever value was already provided in env.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,11 +147,9 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize ANTHROPIC_BASE_URL to ensure /v1 suffix
|
||||
// Claude Code gateway mode expects the unified root endpoint, not a forced /v1 suffix.
|
||||
if (env.ANTHROPIC_BASE_URL) {
|
||||
env.ANTHROPIC_BASE_URL = env.ANTHROPIC_BASE_URL.endsWith("/v1")
|
||||
? env.ANTHROPIC_BASE_URL
|
||||
: `${env.ANTHROPIC_BASE_URL}/v1`;
|
||||
env.ANTHROPIC_BASE_URL = normalizeClaudeBaseUrl(env.ANTHROPIC_BASE_URL);
|
||||
}
|
||||
|
||||
// Merge new env with existing settings
|
||||
@@ -186,6 +185,7 @@ export async function POST(request: Request) {
|
||||
const RESET_ENV_KEYS = [
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
|
||||
75
src/app/api/settings/proxies/bulk-import/route.ts
Normal file
75
src/app/api/settings/proxies/bulk-import/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { upsertProxy } from "@/lib/localDb";
|
||||
import { bulkImportProxiesSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return createErrorResponse({
|
||||
status: 400,
|
||||
message: "Invalid JSON body",
|
||||
type: "invalid_request",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const validation = validateBody(bulkImportProxiesSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return createErrorResponse({
|
||||
status: 400,
|
||||
message: validation.error.message,
|
||||
details: validation.error.details,
|
||||
type: "invalid_request",
|
||||
});
|
||||
}
|
||||
|
||||
const { items } = validation.data;
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let failed = 0;
|
||||
const results: Array<{
|
||||
name: string;
|
||||
success: boolean;
|
||||
action?: "created" | "updated";
|
||||
id?: string;
|
||||
error?: string;
|
||||
}> = [];
|
||||
|
||||
for (const item of items) {
|
||||
try {
|
||||
const result = await upsertProxy(item);
|
||||
if (result.proxy) {
|
||||
if (result.action === "created") created++;
|
||||
else updated++;
|
||||
results.push({
|
||||
name: item.name,
|
||||
success: true,
|
||||
action: result.action,
|
||||
id: result.proxy.id,
|
||||
});
|
||||
} else {
|
||||
failed++;
|
||||
results.push({ name: item.name, success: false, error: "Unknown error" });
|
||||
}
|
||||
} catch (error) {
|
||||
failed++;
|
||||
results.push({
|
||||
name: item.name,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({ created, updated, failed, results });
|
||||
} catch (error) {
|
||||
return createErrorResponseFromUnknown(error, "Failed to bulk import proxies");
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
launchAutoUpdate,
|
||||
validateAutoUpdateRuntime,
|
||||
} from "@/lib/system/autoUpdate";
|
||||
import { NEWS_JSON_URL, parseActiveNewsPayload } from "@/shared/utils/releaseNotes";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -50,16 +51,32 @@ function isNewer(a: string | null, b: string): boolean {
|
||||
return aPat > bPat;
|
||||
}
|
||||
|
||||
async function getNews() {
|
||||
try {
|
||||
const res = await fetch(NEWS_JSON_URL, { next: { revalidate: 3600 } });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return parseActiveNewsPayload(data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!(await isAuthenticated(req))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const current = getCurrentVersion();
|
||||
const latest = await getLatestNpmVersion();
|
||||
const updateAvailable = isNewer(latest, current);
|
||||
const config = getAutoUpdateConfig();
|
||||
const validation = await validateAutoUpdateRuntime(config);
|
||||
|
||||
const [latest, news, validation] = await Promise.all([
|
||||
getLatestNpmVersion(),
|
||||
getNews(),
|
||||
validateAutoUpdateRuntime(config),
|
||||
]);
|
||||
|
||||
const updateAvailable = isNewer(latest, current);
|
||||
|
||||
return NextResponse.json({
|
||||
current,
|
||||
@@ -68,6 +85,7 @@ export async function GET(req: NextRequest) {
|
||||
channel: config.mode,
|
||||
autoUpdateSupported: validation.supported,
|
||||
autoUpdateError: validation.reason,
|
||||
news,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { handleImageEdit } from "@omniroute/open-sse/handlers/imageGeneration.ts";
|
||||
import {
|
||||
getProviderCredentials,
|
||||
clearRecoveredProviderState,
|
||||
extractApiKey,
|
||||
isValidApiKey,
|
||||
} from "@/sse/services/auth";
|
||||
import { getProviderCredentials, clearRecoveredProviderState } from "@/sse/services/auth";
|
||||
import { parseImageModel, getImageProvider } from "@omniroute/open-sse/config/imageRegistry.ts";
|
||||
import { errorResponse, unavailableResponse } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
@@ -99,18 +94,11 @@ export async function POST(request: Request) {
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: image");
|
||||
}
|
||||
|
||||
const apiKey = extractApiKey(request);
|
||||
if (!isValidApiKey(apiKey)) {
|
||||
const policyError = enforceApiKeyPolicy(apiKey);
|
||||
if (policyError) {
|
||||
return new Response(JSON.stringify(policyError.body), {
|
||||
status: policyError.status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const fullModel = model || "cgpt-web/gpt-5.3-instant";
|
||||
|
||||
const policy = await enforceApiKeyPolicy(request, fullModel);
|
||||
if (policy.rejection) return policy.rejection;
|
||||
|
||||
const parsed = parseImageModel(fullModel);
|
||||
const providerConfig = getImageProvider(parsed.provider);
|
||||
if (!providerConfig) {
|
||||
@@ -126,7 +114,16 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const credentials = await getProviderCredentials(parsed.provider, apiKey);
|
||||
const allowedConnections =
|
||||
policy.apiKeyInfo?.allowedConnections && policy.apiKeyInfo.allowedConnections.length > 0
|
||||
? policy.apiKeyInfo.allowedConnections
|
||||
: null;
|
||||
const credentials = await getProviderCredentials(
|
||||
parsed.provider,
|
||||
null,
|
||||
allowedConnections,
|
||||
fullModel
|
||||
);
|
||||
if (!credentials) {
|
||||
return errorResponse(
|
||||
HTTP_STATUS.UNAUTHORIZED,
|
||||
|
||||
@@ -5,12 +5,19 @@ import { NextIntlClientProvider } from "next-intl";
|
||||
import { getMessages, getLocale } from "next-intl/server";
|
||||
import { RTL_LOCALES } from "@/i18n/config";
|
||||
import { getSettings } from "@/lib/db/settings";
|
||||
import type { Viewport } from "next";
|
||||
import { PwaRegister } from "@/shared/components/PwaRegister";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-inter",
|
||||
});
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#0b0f1a",
|
||||
viewportFit: "cover",
|
||||
};
|
||||
|
||||
export async function generateMetadata() {
|
||||
const settings = await getSettings();
|
||||
const instanceName = settings?.instanceName || "OmniRoute";
|
||||
@@ -20,9 +27,25 @@ export async function generateMetadata() {
|
||||
title: `${instanceName} — AI Gateway for Multi-Provider LLMs`,
|
||||
description:
|
||||
"OmniRoute is an AI gateway for multi-provider LLMs. One endpoint for all your AI providers.",
|
||||
manifest: "/manifest.webmanifest",
|
||||
applicationName: instanceName,
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
title: instanceName,
|
||||
statusBarStyle: "black-translucent",
|
||||
},
|
||||
other: {
|
||||
"mobile-web-app-capable": "yes",
|
||||
},
|
||||
icons: {
|
||||
icon: customFaviconUrl ? "/api/settings/favicon" : "/favicon.svg",
|
||||
apple: "/apple-touch-icon.svg",
|
||||
icon: customFaviconUrl
|
||||
? "/api/settings/favicon"
|
||||
: [
|
||||
{ url: "/favicon.ico", sizes: "any" },
|
||||
{ url: "/favicon.svg", type: "image/svg+xml" },
|
||||
{ url: "/icon-512.png", type: "image/png", sizes: "512x512" },
|
||||
],
|
||||
apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -67,6 +90,7 @@ export default async function RootLayout({ children }) {
|
||||
Skip to content
|
||||
</a>
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
<PwaRegister />
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
|
||||
29
src/app/manifest.ts
Normal file
29
src/app/manifest.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "OmniRoute",
|
||||
short_name: "OmniRoute",
|
||||
description:
|
||||
"OmniRoute is an AI gateway for multi-provider LLMs. One endpoint for all your AI providers.",
|
||||
start_url: "/",
|
||||
scope: "/",
|
||||
display: "fullscreen",
|
||||
orientation: "any",
|
||||
background_color: "#0b0f1a",
|
||||
theme_color: "#0b0f1a",
|
||||
icons: [
|
||||
{
|
||||
src: "/icon-512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "any maskable",
|
||||
},
|
||||
{
|
||||
src: "/apple-touch-icon.png",
|
||||
sizes: "180x180",
|
||||
type: "image/png",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "المواضيع",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Теми",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Motivy",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Temaer",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themen",
|
||||
|
||||
@@ -270,7 +270,7 @@
|
||||
"editCombo": "Edit Combo",
|
||||
"testResults": "Test Results",
|
||||
"searchQuery": "Search Query",
|
||||
"addCcCompatible": "Add Cc Compatible",
|
||||
"addCcCompatible": "Add CC Compatible",
|
||||
"duplicate": "Duplicate",
|
||||
"createCombo": "Create Combo",
|
||||
"searchTypeWeb": "Search Type Web",
|
||||
@@ -344,7 +344,7 @@
|
||||
"signatureDefaults": "Signature Defaults",
|
||||
"errorCreating": "Error Creating",
|
||||
"timeRangeYear": "Time Range Year",
|
||||
"compatibleLabel": "Compatible Label",
|
||||
"compatibleLabel": "Compatible",
|
||||
"cloudDisabledSuccess": "Cloud Disabled Success",
|
||||
"deleteConfirm": "Delete Confirm",
|
||||
"check": "Check",
|
||||
@@ -511,7 +511,7 @@
|
||||
"comboUpdated": "Combo Updated",
|
||||
"weighted": "Weighted",
|
||||
"providers": "Providers",
|
||||
"ccCompatibleLabel": "Cc Compatible Label",
|
||||
"ccCompatibleLabel": "CC Compatible",
|
||||
"noFallbackChainsDesc": "No Fallback Chains Desc",
|
||||
"yesImport": "Yes Import",
|
||||
"lockoutsAutoRefreshHint": "Lockouts Auto Refresh Hint",
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
@@ -2704,7 +2705,7 @@
|
||||
"accountIdLabel": "Account Id Label",
|
||||
"accountIdPlaceholder": "Account Id Placeholder",
|
||||
"addAnotherApiKey": "Add Another Api Key",
|
||||
"addCcCompatible": "Add Cc Compatible",
|
||||
"addCcCompatible": "Add CC Compatible",
|
||||
"aggregatorsGateways": "Aggregators Gateways",
|
||||
"apiFormatLabel": "Api Format Label",
|
||||
"apiKeyOptionalHint": "Api Key Optional Hint",
|
||||
@@ -2721,27 +2722,27 @@
|
||||
"bailianBaseUrlHint": "Bailian Base Url Hint",
|
||||
"blackboxWebCookieHint": "Blackbox Web Cookie Hint",
|
||||
"blackboxWebCookiePlaceholder": "Blackbox Web Cookie Placeholder",
|
||||
"blockClaudeExtraUsageDescription": "Block Claude Extra Usage Description",
|
||||
"blockClaudeExtraUsageLabel": "Block Claude Extra Usage Label",
|
||||
"ccCompatibleBaseUrlHint": "Cc Compatible Base Url Hint",
|
||||
"ccCompatibleBaseUrlPlaceholder": "Cc Compatible Base Url Placeholder",
|
||||
"ccCompatibleChatPathHint": "Cc Compatible Chat Path Hint",
|
||||
"ccCompatibleContext1mDescription": "Cc Compatible Context1M Description",
|
||||
"ccCompatibleContext1mLabel": "Cc Compatible Context1M Label",
|
||||
"ccCompatibleDetailsTitle": "Cc Compatible Details Title",
|
||||
"ccCompatibleLabel": "Cc Compatible Label",
|
||||
"ccCompatibleModelsDescription": "Cc Compatible Models Description",
|
||||
"ccCompatibleNameHint": "Cc Compatible Name Hint",
|
||||
"ccCompatibleNamePlaceholder": "Cc Compatible Name Placeholder",
|
||||
"ccCompatiblePrefixHint": "Cc Compatible Prefix Hint",
|
||||
"ccCompatiblePrefixPlaceholder": "Cc Compatible Prefix Placeholder",
|
||||
"ccCompatibleValidationHint": "Cc Compatible Validation Hint",
|
||||
"claudeExtraUsageShort": "Claude Extra Usage Short",
|
||||
"claudeExtraUsageToggleTitle": "Claude Extra Usage Toggle Title",
|
||||
"codex5hToggleTitle": "Codex5H Toggle Title",
|
||||
"blockClaudeExtraUsageDescription": "Hide extra Claude usage rows reported by some providers when they duplicate primary token accounting.",
|
||||
"blockClaudeExtraUsageLabel": "Block duplicate Claude usage rows",
|
||||
"ccCompatibleBaseUrlHint": "Base URL for a Claude Code-only relay. Do not include /messages.",
|
||||
"ccCompatibleBaseUrlPlaceholder": "https://relay.example.com/v1",
|
||||
"ccCompatibleChatPathHint": "Defaults to Claude Code's strict Messages API path. Change only if your relay documents a different path.",
|
||||
"ccCompatibleContext1mDescription": "Adds the context-1m beta header when the selected Claude model supports it.",
|
||||
"ccCompatibleContext1mLabel": "Enable 1M context beta",
|
||||
"ccCompatibleDetailsTitle": "CC-compatible relay details",
|
||||
"ccCompatibleLabel": "CC Compatible",
|
||||
"ccCompatibleModelsDescription": "CC-compatible relays do not expose model listing. Add the Claude model IDs your relay accepts.",
|
||||
"ccCompatibleNameHint": "Display name for this Claude Code-only relay.",
|
||||
"ccCompatibleNamePlaceholder": "CC Relay Production",
|
||||
"ccCompatiblePrefixHint": "Used in model aliases such as prefix/model-id.",
|
||||
"ccCompatiblePrefixPlaceholder": "cc",
|
||||
"ccCompatibleValidationHint": "Use this provider only for relays that serve Claude Code clients exclusively. OmniRoute rewrites any incoming request into the Claude Code-compatible wire format so those relays accept it. If you only want to use Claude Code CLI, or you are not sure what this relay type means, use a regular Anthropic-compatible provider instead.",
|
||||
"claudeExtraUsageShort": "Extra usage",
|
||||
"claudeExtraUsageToggleTitle": "Block Claude extra usage accounting for this connection",
|
||||
"codex5hToggleTitle": "Track Codex 5-hour quota for this connection",
|
||||
"codexFastServiceTierDescription": "Use the priority service tier for Codex requests when available.",
|
||||
"codexFastServiceTierLabel": "Codex fast service tier",
|
||||
"codexWeeklyToggleTitle": "Codex Weekly Toggle Title",
|
||||
"codexWeeklyToggleTitle": "Track Codex weekly quota for this connection",
|
||||
"compatUpstreamHeaderNamePlaceholder": "Compat Upstream Header Name Placeholder",
|
||||
"compatUpstreamHeaderValuePlaceholder": "Compat Upstream Header Value Placeholder",
|
||||
"compatible": "Compatible",
|
||||
@@ -2749,8 +2750,8 @@
|
||||
"consoleApiKeyOracleHint": "Console Api Key Oracle Hint",
|
||||
"consoleApiKeyOracleLabel": "Console Api Key Oracle Label",
|
||||
"consoleApiKeyOraclePlaceholder": "Console Api Key Oracle Placeholder",
|
||||
"cpaModeDisabledTitle": "Cpa Mode Disabled Title",
|
||||
"cpaModeEnabledTitle": "Cpa Mode Enabled Title",
|
||||
"cpaModeDisabledTitle": "CLIProxyAPI compatibility mode is disabled",
|
||||
"cpaModeEnabledTitle": "CLIProxyAPI compatibility mode is enabled",
|
||||
"customUserAgentHint": "Custom User Agent Hint",
|
||||
"customUserAgentLabel": "Custom User Agent Label",
|
||||
"databricksBaseUrlHint": "Databricks Base Url Hint",
|
||||
@@ -2811,18 +2812,18 @@
|
||||
"sessionCookieLabel": "Session Cookie Label",
|
||||
"showEmail": "Show Email",
|
||||
"snowflakeBaseUrlHint": "Snowflake Base Url Hint",
|
||||
"supportedEndpointAudio": "Supported Endpoint Audio",
|
||||
"supportedEndpointChat": "Supported Endpoint Chat",
|
||||
"supportedEndpointEmbeddings": "Supported Endpoint Embeddings",
|
||||
"supportedEndpointImages": "Supported Endpoint Images",
|
||||
"supportedEndpointsLabel": "Supported Endpoints Label",
|
||||
"supportedEndpointAudio": "Audio",
|
||||
"supportedEndpointChat": "Chat",
|
||||
"supportedEndpointEmbeddings": "Embeddings",
|
||||
"supportedEndpointImages": "Images",
|
||||
"supportedEndpointsLabel": "Supported endpoints",
|
||||
"tagGroupHint": "Tag Group Hint",
|
||||
"tagGroupLabel": "Tag Group Label",
|
||||
"tagGroupPlaceholder": "Tag Group Placeholder",
|
||||
"testModel": "Test Model",
|
||||
"testingModel": "Testing Model",
|
||||
"toggleOffShort": "Toggle Off Short",
|
||||
"toggleOnShort": "Toggle On Short",
|
||||
"toggleOffShort": "Off",
|
||||
"toggleOnShort": "On",
|
||||
"tokenExpiredBadge": "Token Expired Badge",
|
||||
"tokenExpiredTitle": "Token Expired Title",
|
||||
"tokenExpiresSoonTitle": "Token Expires Soon Title",
|
||||
@@ -4625,7 +4626,26 @@
|
||||
"noData": "—",
|
||||
"testSuccess": "✓ {ip}",
|
||||
"testLatency": "{latency}ms",
|
||||
"testFailure": "✗ {error}"
|
||||
"testFailure": "✗ {error}",
|
||||
"bulkImport": "Bulk Import",
|
||||
"bulkImportTitle": "Bulk Import Proxies",
|
||||
"bulkImportDescription": "Paste proxy profiles using pipe-delimited format. One proxy per line. Existing proxies (same host + port) will be updated.",
|
||||
"bulkImportParse": "Parse",
|
||||
"bulkImportImport": "Import {count} Proxies",
|
||||
"bulkImportImporting": "Importing...",
|
||||
"bulkImportParsed": "{count} proxies parsed",
|
||||
"bulkImportSkipped": "{count} lines skipped",
|
||||
"bulkImportParseErrors": "{count} errors",
|
||||
"bulkImportNoValidEntries": "No valid entries found. Check the format and try again.",
|
||||
"bulkImportSuccess": "Import complete: {created} created, {updated} updated, {failed} failed",
|
||||
"bulkImportErrorLine": "Line {line}: {reason}",
|
||||
"bulkImportMaxExceeded": "Maximum 100 proxies per import",
|
||||
"bulkImportPreview": "Preview",
|
||||
"bulkImportErrorMissingName": "Missing NAME",
|
||||
"bulkImportErrorMissingHost": "Missing HOST",
|
||||
"bulkImportErrorInvalidPort": "Invalid PORT (must be 1-65535)",
|
||||
"bulkImportErrorInvalidType": "Invalid TYPE (use http, https, or socks5)",
|
||||
"bulkImportErrorInvalidStatus": "Invalid STATUS (use active or inactive)"
|
||||
},
|
||||
"playground": {
|
||||
"title": "Model Playground",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Temas",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Teemat",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Thèmes",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Témák",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Temas",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Темы",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "ธีมส์",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Temalar",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
|
||||
@@ -270,7 +270,7 @@
|
||||
"editCombo": "Edit Combo",
|
||||
"testResults": "Test Results",
|
||||
"searchQuery": "Search Query",
|
||||
"addCcCompatible": "Add Cc Compatible",
|
||||
"addCcCompatible": "添加 CC 兼容",
|
||||
"duplicate": "Duplicate",
|
||||
"createCombo": "Create Combo",
|
||||
"searchTypeWeb": "Search Type Web",
|
||||
@@ -344,7 +344,7 @@
|
||||
"signatureDefaults": "Signature Defaults",
|
||||
"errorCreating": "Error Creating",
|
||||
"timeRangeYear": "Time Range Year",
|
||||
"compatibleLabel": "Compatible Label",
|
||||
"compatibleLabel": "兼容",
|
||||
"cloudDisabledSuccess": "Cloud Disabled Success",
|
||||
"deleteConfirm": "Delete Confirm",
|
||||
"check": "Check",
|
||||
@@ -511,7 +511,7 @@
|
||||
"comboUpdated": "Combo Updated",
|
||||
"weighted": "Weighted",
|
||||
"providers": "Providers",
|
||||
"ccCompatibleLabel": "Cc Compatible Label",
|
||||
"ccCompatibleLabel": "CC 兼容",
|
||||
"noFallbackChainsDesc": "No Fallback Chains Desc",
|
||||
"yesImport": "Yes Import",
|
||||
"lockoutsAutoRefreshHint": "Lockouts Auto Refresh Hint",
|
||||
@@ -734,7 +734,8 @@
|
||||
"logoPreview": "Logo Preview",
|
||||
"themeCustom": "Theme Custom",
|
||||
"hideHealthLogsDesc": "Hide Health Logs Desc",
|
||||
"faviconPreview": "Favicon Preview"
|
||||
"faviconPreview": "Favicon Preview",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "主题",
|
||||
@@ -2641,7 +2642,7 @@
|
||||
"accountIdLabel": "Account Id Label",
|
||||
"accountIdPlaceholder": "Account Id Placeholder",
|
||||
"addAnotherApiKey": "Add Another Api Key",
|
||||
"addCcCompatible": "Add Cc Compatible",
|
||||
"addCcCompatible": "添加 CC 兼容",
|
||||
"aggregatorsGateways": "Aggregators Gateways",
|
||||
"apiFormatLabel": "Api Format Label",
|
||||
"apiKeyOptionalHint": "Api Key Optional Hint",
|
||||
@@ -2658,27 +2659,27 @@
|
||||
"bailianBaseUrlHint": "Bailian Base Url Hint",
|
||||
"blackboxWebCookieHint": "Blackbox Web Cookie Hint",
|
||||
"blackboxWebCookiePlaceholder": "Blackbox Web Cookie Placeholder",
|
||||
"blockClaudeExtraUsageDescription": "Block Claude Extra Usage Description",
|
||||
"blockClaudeExtraUsageLabel": "Block Claude Extra Usage Label",
|
||||
"ccCompatibleBaseUrlHint": "Cc Compatible Base Url Hint",
|
||||
"ccCompatibleBaseUrlPlaceholder": "Cc Compatible Base Url Placeholder",
|
||||
"ccCompatibleChatPathHint": "Cc Compatible Chat Path Hint",
|
||||
"ccCompatibleContext1mDescription": "Cc Compatible Context1M Description",
|
||||
"ccCompatibleContext1mLabel": "Cc Compatible Context1M Label",
|
||||
"ccCompatibleDetailsTitle": "Cc Compatible Details Title",
|
||||
"ccCompatibleLabel": "Cc Compatible Label",
|
||||
"ccCompatibleModelsDescription": "Cc Compatible Models Description",
|
||||
"ccCompatibleNameHint": "Cc Compatible Name Hint",
|
||||
"ccCompatibleNamePlaceholder": "Cc Compatible Name Placeholder",
|
||||
"ccCompatiblePrefixHint": "Cc Compatible Prefix Hint",
|
||||
"ccCompatiblePrefixPlaceholder": "Cc Compatible Prefix Placeholder",
|
||||
"ccCompatibleValidationHint": "Cc Compatible Validation Hint",
|
||||
"claudeExtraUsageShort": "Claude Extra Usage Short",
|
||||
"claudeExtraUsageToggleTitle": "Claude Extra Usage Toggle Title",
|
||||
"codex5hToggleTitle": "Codex5H Toggle Title",
|
||||
"blockClaudeExtraUsageDescription": "隐藏部分 Provider 返回的重复 Claude 额外用量记录,避免和主 token 统计重复。",
|
||||
"blockClaudeExtraUsageLabel": "屏蔽重复 Claude 用量",
|
||||
"ccCompatibleBaseUrlHint": "Claude Code 专用中转站的 Base URL,不要包含 /messages。",
|
||||
"ccCompatibleBaseUrlPlaceholder": "https://relay.example.com/v1",
|
||||
"ccCompatibleChatPathHint": "默认使用 Claude Code 严格的 Messages API 路径。仅在中转站文档要求时修改。",
|
||||
"ccCompatibleContext1mDescription": "当所选 Claude 模型支持时,添加 context-1m beta header。",
|
||||
"ccCompatibleContext1mLabel": "启用 1M context beta",
|
||||
"ccCompatibleDetailsTitle": "CC 兼容中转站详情",
|
||||
"ccCompatibleLabel": "CC 兼容",
|
||||
"ccCompatibleModelsDescription": "CC 兼容中转站不提供模型列表。请添加该中转站接受的 Claude 模型 ID。",
|
||||
"ccCompatibleNameHint": "这个 Claude Code 专用中转站的显示名称。",
|
||||
"ccCompatibleNamePlaceholder": "CC 中转站生产环境",
|
||||
"ccCompatiblePrefixHint": "用于 prefix/model-id 这类模型别名。",
|
||||
"ccCompatiblePrefixPlaceholder": "cc",
|
||||
"ccCompatibleValidationHint": "这个 Provider 只适用于仅向 Claude Code 客户端提供服务的中转站。OmniRoute 会把任何进入的请求改写为 Claude Code 兼容的传输格式,以通过这些中转站的验证。如果你只是想使用 Claude Code CLI,或者不清楚这类中转站是什么,请使用普通 Anthropic-compatible Provider。",
|
||||
"claudeExtraUsageShort": "额外用量",
|
||||
"claudeExtraUsageToggleTitle": "为此连接屏蔽 Claude 额外用量统计",
|
||||
"codex5hToggleTitle": "为此连接跟踪 Codex 5 小时配额",
|
||||
"codexFastServiceTierDescription": "可用时为 Codex 请求使用 priority 服务层。",
|
||||
"codexFastServiceTierLabel": "Codex 快速服务层",
|
||||
"codexWeeklyToggleTitle": "Codex Weekly Toggle Title",
|
||||
"codexWeeklyToggleTitle": "为此连接跟踪 Codex 周配额",
|
||||
"compatUpstreamHeaderNamePlaceholder": "Compat Upstream Header Name Placeholder",
|
||||
"compatUpstreamHeaderValuePlaceholder": "Compat Upstream Header Value Placeholder",
|
||||
"compatible": "Compatible",
|
||||
@@ -2686,8 +2687,8 @@
|
||||
"consoleApiKeyOracleHint": "Console Api Key Oracle Hint",
|
||||
"consoleApiKeyOracleLabel": "Console Api Key Oracle Label",
|
||||
"consoleApiKeyOraclePlaceholder": "Console Api Key Oracle Placeholder",
|
||||
"cpaModeDisabledTitle": "Cpa Mode Disabled Title",
|
||||
"cpaModeEnabledTitle": "Cpa Mode Enabled Title",
|
||||
"cpaModeDisabledTitle": "CLIProxyAPI 兼容模式已关闭",
|
||||
"cpaModeEnabledTitle": "CLIProxyAPI 兼容模式已开启",
|
||||
"customUserAgentHint": "Custom User Agent Hint",
|
||||
"customUserAgentLabel": "Custom User Agent Label",
|
||||
"databricksBaseUrlHint": "Databricks Base Url Hint",
|
||||
@@ -2748,18 +2749,18 @@
|
||||
"sessionCookieLabel": "Session Cookie Label",
|
||||
"showEmail": "Show Email",
|
||||
"snowflakeBaseUrlHint": "Snowflake Base Url Hint",
|
||||
"supportedEndpointAudio": "Supported Endpoint Audio",
|
||||
"supportedEndpointChat": "Supported Endpoint Chat",
|
||||
"supportedEndpointEmbeddings": "Supported Endpoint Embeddings",
|
||||
"supportedEndpointImages": "Supported Endpoint Images",
|
||||
"supportedEndpointsLabel": "Supported Endpoints Label",
|
||||
"supportedEndpointAudio": "音频",
|
||||
"supportedEndpointChat": "聊天",
|
||||
"supportedEndpointEmbeddings": "Embeddings",
|
||||
"supportedEndpointImages": "图像",
|
||||
"supportedEndpointsLabel": "支持的端点",
|
||||
"tagGroupHint": "Tag Group Hint",
|
||||
"tagGroupLabel": "Tag Group Label",
|
||||
"tagGroupPlaceholder": "Tag Group Placeholder",
|
||||
"testModel": "Test Model",
|
||||
"testingModel": "Testing Model",
|
||||
"toggleOffShort": "Toggle Off Short",
|
||||
"toggleOnShort": "Toggle On Short",
|
||||
"toggleOffShort": "关",
|
||||
"toggleOnShort": "开",
|
||||
"tokenExpiredBadge": "Token Expired Badge",
|
||||
"tokenExpiredTitle": "Token Expired Title",
|
||||
"tokenExpiresSoonTitle": "Token Expires Soon Title",
|
||||
|
||||
@@ -92,6 +92,12 @@ const RENAMED_MIGRATION_COMPATIBILITY = [
|
||||
toVersion: "029",
|
||||
toName: "provider_connection_max_concurrent",
|
||||
},
|
||||
{
|
||||
fromVersion: "032",
|
||||
fromName: "create_reasoning_cache",
|
||||
toVersion: "033",
|
||||
toName: "create_reasoning_cache",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const PHYSICAL_SCHEMA_SENTINELS = [
|
||||
@@ -211,6 +217,20 @@ function applyApiKeyLifecycleMigration(db: Database.Database): void {
|
||||
`);
|
||||
}
|
||||
|
||||
function isSearchRequestTypeMigration(migration: { version: string; name: string }): boolean {
|
||||
return migration.version === "007";
|
||||
}
|
||||
|
||||
function applySearchRequestTypeMigration(db: Database.Database): void {
|
||||
ensureColumn(
|
||||
db,
|
||||
"call_logs",
|
||||
"request_type",
|
||||
"ALTER TABLE call_logs ADD COLUMN request_type TEXT DEFAULT NULL"
|
||||
);
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_call_logs_request_type ON call_logs(request_type);");
|
||||
}
|
||||
|
||||
function inferPhysicalSchemaBaseline(db: Database.Database): {
|
||||
version: string;
|
||||
description: string;
|
||||
@@ -477,6 +497,8 @@ export function runMigrations(db: Database.Database, options?: { isNewDb?: boole
|
||||
const applyMigration = db.transaction(() => {
|
||||
if (isApiKeyLifecycleMigration(migration)) {
|
||||
applyApiKeyLifecycleMigration(db);
|
||||
} else if (isSearchRequestTypeMigration(migration)) {
|
||||
applySearchRequestTypeMigration(db);
|
||||
} else {
|
||||
const sql = fs.readFileSync(migration.path, "utf-8");
|
||||
db.exec(sql);
|
||||
|
||||
@@ -192,6 +192,32 @@ export async function createProxy(payload: ProxyPayload) {
|
||||
return getProxyById(id, { includeSecrets: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a proxy by host+port.
|
||||
* If a proxy with the same host and port already exists, update it.
|
||||
* Otherwise, create a new one. Used by the bulk import feature.
|
||||
*/
|
||||
export async function upsertProxy(payload: ProxyPayload): Promise<{
|
||||
proxy: ProxyRegistryRecord | null;
|
||||
action: "created" | "updated";
|
||||
}> {
|
||||
const db = getDbInstance();
|
||||
const host = (payload.host || "").trim();
|
||||
const port = Number(payload.port);
|
||||
|
||||
const existing = db
|
||||
.prepare("SELECT id FROM proxy_registry WHERE host = ? AND port = ? LIMIT 1")
|
||||
.get(host, port) as { id?: string } | undefined;
|
||||
|
||||
if (existing?.id) {
|
||||
const updated = await updateProxy(existing.id, payload);
|
||||
return { proxy: updated, action: "updated" };
|
||||
}
|
||||
|
||||
const created = await createProxy(payload);
|
||||
return { proxy: created, action: "created" };
|
||||
}
|
||||
|
||||
export async function updateProxy(id: string, payload: Partial<ProxyPayload>) {
|
||||
const db = getDbInstance();
|
||||
const existing = await getProxyById(id, { includeSecrets: true });
|
||||
|
||||
@@ -54,6 +54,8 @@ export async function getSettings() {
|
||||
antigravitySignatureCacheMode: "enabled",
|
||||
requireLogin: true,
|
||||
hiddenSidebarItems: [],
|
||||
hideEndpointCloudflaredTunnel: false,
|
||||
hideEndpointTailscaleFunnel: false,
|
||||
comboConfigMode: "guided",
|
||||
alwaysPreserveClientCache: "auto",
|
||||
idempotencyWindowMs: 5000,
|
||||
|
||||
@@ -149,6 +149,7 @@ export {
|
||||
getProxyById,
|
||||
createProxy,
|
||||
updateProxy,
|
||||
upsertProxy,
|
||||
deleteProxyById,
|
||||
getProxyAssignments,
|
||||
getProxyWhereUsed,
|
||||
|
||||
@@ -246,7 +246,7 @@ export const CURSOR_CONFIG = {
|
||||
agentEndpoint: "https://agent.api5.cursor.sh", // Privacy mode
|
||||
agentNonPrivacyEndpoint: "https://agentn.api5.cursor.sh", // Non-privacy mode
|
||||
// Client metadata
|
||||
clientVersion: "3.1.15",
|
||||
clientVersion: "3.2.14",
|
||||
clientType: "ide",
|
||||
// Token storage locations (for user reference)
|
||||
tokenStoragePaths: {
|
||||
|
||||
17
src/shared/components/PwaRegister.tsx
Normal file
17
src/shared/components/PwaRegister.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function PwaRegister() {
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.serviceWorker.register("/sw.js").catch(() => {
|
||||
// Ignore registration failures to avoid blocking app rendering.
|
||||
});
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,4 +1,53 @@
|
||||
import { CLI_TOOLS } from "./cliTools";
|
||||
import { normalizeCliCompatProviderId } from "../utils/cliCompat";
|
||||
|
||||
export { normalizeCliCompatProviderId };
|
||||
|
||||
export const IMPLEMENTED_CLI_FINGERPRINT_PROVIDER_IDS = [
|
||||
"claude",
|
||||
"codex",
|
||||
"github",
|
||||
"antigravity",
|
||||
"qwen",
|
||||
] as const;
|
||||
|
||||
export const CLI_COMPAT_DISPLAY_PROVIDER_IDS = [
|
||||
"claude",
|
||||
"codex",
|
||||
"copilot",
|
||||
"antigravity",
|
||||
"qwen",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Known CLI/tool providers that are intentionally not exposed as CLI Fingerprint toggles yet.
|
||||
*
|
||||
* This setting controls the generic `applyFingerprint()` pipeline (header/body ordering plus
|
||||
* optional CLI User-Agent overrides). Do not expose providers here just because they have a
|
||||
* CLI Tools card or a provider integration:
|
||||
*
|
||||
* - Kiro and Cursor already apply their native parity inside custom executors, so a toggle would
|
||||
* be misleading unless it controls additional behavior.
|
||||
* - Droid, OpenClaw, Windsurf and Hermes are CLI tool setup guides/settings, not upstream provider
|
||||
* fingerprints handled by OmniRoute.
|
||||
* - Cline, Kilo Code, OpenCode and Kimi Coding have real provider/backend integrations, but no
|
||||
* captured `CLI_FINGERPRINTS` entry is wired to `applyFingerprint()` yet.
|
||||
*
|
||||
* Keep this list as documentation for intentionally omitted candidates. When adding a provider to
|
||||
* the visible toggle list, also add a real `CLI_FINGERPRINTS` entry or wire its custom executor.
|
||||
*/
|
||||
export const CLI_COMPAT_OMITTED_PROVIDER_IDS = [
|
||||
"kiro",
|
||||
"cursor",
|
||||
"droid",
|
||||
"openclaw",
|
||||
"windsurf",
|
||||
"hermes",
|
||||
"cline",
|
||||
"kilocode",
|
||||
"opencode",
|
||||
"kimi-coding",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Provider IDs toggled in Settings -> CLI Fingerprint.
|
||||
@@ -13,17 +62,12 @@ const TOOL_ID_TO_PROVIDER_ID: Record<string, string> = {
|
||||
};
|
||||
|
||||
const DERIVED_PROVIDER_IDS = Object.values(CLI_TOOLS)
|
||||
.map((tool: any) => TOOL_ID_TO_PROVIDER_ID[tool.id] ?? tool.id)
|
||||
.map((tool: any) => normalizeCliCompatProviderId(TOOL_ID_TO_PROVIDER_ID[tool.id] ?? tool.id))
|
||||
// "continue" currently has no provider id in AI_PROVIDERS
|
||||
.filter((providerId) => providerId !== "continue" && providerId !== "amp");
|
||||
|
||||
const LEGACY_PROVIDER_IDS = [
|
||||
// Keep to avoid breaking setups that saved old IDs
|
||||
"copilot",
|
||||
"kimi-coding",
|
||||
"qwen",
|
||||
];
|
||||
.filter((providerId) => IMPLEMENTED_CLI_FINGERPRINT_PROVIDER_IDS.includes(providerId as any));
|
||||
|
||||
export const CLI_COMPAT_PROVIDER_IDS = Array.from(
|
||||
new Set([...DERIVED_PROVIDER_IDS, ...LEGACY_PROVIDER_IDS])
|
||||
new Set([...DERIVED_PROVIDER_IDS, ...IMPLEMENTED_CLI_FINGERPRINT_PROVIDER_IDS])
|
||||
);
|
||||
|
||||
export const CLI_COMPAT_TOGGLE_IDS = Array.from(new Set(CLI_COMPAT_DISPLAY_PROVIDER_IDS));
|
||||
|
||||
@@ -22,6 +22,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
|
||||
"settings",
|
||||
"docs",
|
||||
"issues",
|
||||
"changelog",
|
||||
] as const;
|
||||
|
||||
export type HideableSidebarItemId = (typeof HIDEABLE_SIDEBAR_ITEM_IDS)[number];
|
||||
@@ -92,6 +93,7 @@ const HELP_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [
|
||||
icon: "bug_report",
|
||||
external: true,
|
||||
},
|
||||
{ id: "changelog", href: "/dashboard/changelog", i18nKey: "changelog", icon: "campaign" },
|
||||
];
|
||||
|
||||
export const SIDEBAR_SECTIONS: readonly SidebarSectionDefinition[] = [
|
||||
|
||||
17
src/shared/services/claudeCliConfig.ts
Normal file
17
src/shared/services/claudeCliConfig.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export function normalizeClaudeBaseUrl(value: string): string {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function getStoredClaudeAuthValue(
|
||||
env: Record<string, unknown> | null | undefined
|
||||
): string | null {
|
||||
if (!env || typeof env !== "object") return null;
|
||||
|
||||
const authValue = env.ANTHROPIC_AUTH_TOKEN ?? env.ANTHROPIC_API_KEY;
|
||||
if (typeof authValue !== "string") return null;
|
||||
|
||||
const trimmed = authValue.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
3
src/shared/utils/cliCompat.ts
Normal file
3
src/shared/utils/cliCompat.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function normalizeCliCompatProviderId(providerId: string): string {
|
||||
return providerId.toLowerCase() === "copilot" ? "github" : providerId.toLowerCase();
|
||||
}
|
||||
@@ -117,9 +117,23 @@ export async function getConsistentMachineId(salt = null) {
|
||||
const cryptoFallback = await import("crypto");
|
||||
return cryptoFallback.randomUUID();
|
||||
} catch {
|
||||
if (typeof globalThis !== "undefined" && globalThis.crypto && globalThis.crypto.randomUUID) {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c == "x" ? r : (r & 0x3) | 0x8;
|
||||
let r = 0;
|
||||
if (
|
||||
typeof globalThis !== "undefined" &&
|
||||
globalThis.crypto &&
|
||||
globalThis.crypto.getRandomValues
|
||||
) {
|
||||
const arr = new Uint8Array(1);
|
||||
globalThis.crypto.getRandomValues(arr);
|
||||
r = arr[0] % 16;
|
||||
} else {
|
||||
r = (Date.now() % 16) | 0;
|
||||
}
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
@@ -140,9 +154,23 @@ export async function getRawMachineId() {
|
||||
const cryptoFallback = await import("crypto");
|
||||
return cryptoFallback.randomUUID();
|
||||
} catch {
|
||||
if (typeof globalThis !== "undefined" && globalThis.crypto && globalThis.crypto.randomUUID) {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c == "x" ? r : (r & 0x3) | 0x8;
|
||||
let r = 0;
|
||||
if (
|
||||
typeof globalThis !== "undefined" &&
|
||||
globalThis.crypto &&
|
||||
globalThis.crypto.getRandomValues
|
||||
) {
|
||||
const arr = new Uint8Array(1);
|
||||
globalThis.crypto.getRandomValues(arr);
|
||||
r = arr[0] % 16;
|
||||
} else {
|
||||
r = (Date.now() % 16) | 0;
|
||||
}
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
53
src/shared/utils/releaseNotes.ts
Normal file
53
src/shared/utils/releaseNotes.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const NEWS_JSON_URL =
|
||||
"https://raw.githubusercontent.com/diegosouzapw/OmniRoute/main/news.json";
|
||||
export const CHANGELOG_RAW_URL =
|
||||
"https://raw.githubusercontent.com/diegosouzapw/OmniRoute/main/CHANGELOG.md";
|
||||
export const CHANGELOG_GITHUB_URL =
|
||||
"https://github.com/diegosouzapw/OmniRoute/blob/main/CHANGELOG.md";
|
||||
|
||||
const activeNewsSchema = z.object({
|
||||
active: z.literal(true),
|
||||
title: z.string().trim().min(1).max(120),
|
||||
message: z.string().trim().min(1).max(600),
|
||||
link: z.string().url().optional(),
|
||||
linkLabel: z.string().trim().min(1).max(80).optional(),
|
||||
icon: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^[a-z0-9_]+$/)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const inactiveNewsSchema = z
|
||||
.object({
|
||||
active: z.literal(false),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const newsPayloadSchema = z.discriminatedUnion("active", [activeNewsSchema, inactiveNewsSchema]);
|
||||
|
||||
export type NewsAnnouncement = z.infer<typeof activeNewsSchema>;
|
||||
|
||||
export function parseActiveNewsPayload(payload: unknown): NewsAnnouncement | null {
|
||||
const parsed = newsPayloadSchema.safeParse(payload);
|
||||
if (!parsed.success || parsed.data.active !== true) return null;
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
export function getLatestChangelogMarkdown(markdown: string, limit = 10): string {
|
||||
const parts = markdown.split(/^##\s+\[/gm);
|
||||
if (parts.length <= 1) {
|
||||
const truncated = markdown.slice(0, 5000).trimEnd();
|
||||
return markdown.length > 5000 ? `${truncated}\n\n...` : truncated;
|
||||
}
|
||||
|
||||
const header = parts[0].trimEnd();
|
||||
const versions = parts
|
||||
.slice(1, limit + 1)
|
||||
.map((part) => `## [${part.trimEnd()}`)
|
||||
.join("\n\n");
|
||||
|
||||
return [header, versions].filter(Boolean).join("\n\n");
|
||||
}
|
||||
@@ -1157,6 +1157,15 @@ export const updateProxyRegistrySchema = createProxyRegistrySchema.partial().ext
|
||||
id: z.string().trim().min(1, "id is required"),
|
||||
});
|
||||
|
||||
export const bulkImportProxiesSchema = z
|
||||
.object({
|
||||
items: z
|
||||
.array(createProxyRegistrySchema)
|
||||
.min(1, "At least one proxy is required")
|
||||
.max(100, "Maximum 100 proxies per import"),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const proxyAssignmentSchema = z
|
||||
.object({
|
||||
scope: z.enum(["global", "provider", "account", "combo", "key"]),
|
||||
|
||||
@@ -45,6 +45,8 @@ export const updateSettingsSchema = z.object({
|
||||
setupComplete: z.boolean().optional(),
|
||||
blockedProviders: z.array(z.string().max(100)).optional(),
|
||||
hideHealthCheckLogs: z.boolean().optional(),
|
||||
hideEndpointCloudflaredTunnel: z.boolean().optional(),
|
||||
hideEndpointTailscaleFunnel: z.boolean().optional(),
|
||||
debugMode: z.boolean().optional(),
|
||||
hiddenSidebarItems: z.array(z.enum(HIDEABLE_SIDEBAR_ITEM_IDS)).optional(),
|
||||
comboConfigMode: z.enum(COMBO_CONFIG_MODES).optional(),
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface Settings {
|
||||
maxRetryIntervalSec: number;
|
||||
jwtSecret?: string;
|
||||
hideHealthCheckLogs?: boolean;
|
||||
hideEndpointCloudflaredTunnel?: boolean;
|
||||
hideEndpointTailscaleFunnel?: boolean;
|
||||
hiddenSidebarItems?: HideableSidebarItemId[];
|
||||
resilienceSettings?: ResilienceSettings;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user