Files
EasyCLIProxyAPI/src/pages/EasyModePage.tsx
T
lzt404 c89e2fb020 feat(config): support CLIProxyAPI v8.0.10 template settings
- Add structured editors for request, OAuth, plugin, and concurrency settings
- Expand advanced configuration for all eight provider families and credentials
- Preserve YAML comments, secrets, unknown fields, and explicit overrides
- Add validation, conflict detection, default restoration, and restart notices
- Improve responsive layouts and localize configuration controls
- Fix provider health checks and extend mocks and integration tests
2026-10-04 17:31:52 +08:00

1309 lines
52 KiB
TypeScript

import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { invoke } from "@tauri-apps/api/core";
import {
ArrowLeft,
ArrowRight,
Check,
ChevronDown,
Languages,
LoaderCircle,
Monitor,
Moon,
RefreshCw,
Sun,
X,
} from "lucide-react";
import appLogo from "../assets/logo.jpg";
import { useI18n, languageOptions, type AppLocale } from "../i18n";
import type { MessageKey } from "../i18n/resources";
import { MessageNotice, FloatingNotice, useAppNotice, type NoticeMessage } from "../appNotice";
import {
managementApi,
providerGroupsApi,
readString,
responseList,
} from "../services/managementApi";
import { appendNativeProviderGroup } from '../services/providerGroups';
import {
DEEPSEEK_BASE_URL,
fetchModels,
mergeModelOptions,
normalizeBaseUrl,
reconcileModelSelection,
type ModelOption,
type ModelProvider,
} from "../services/modelService";
import { normalizeProviderProxyUrl } from "../services/providerProxy";
import { type ThemePreference } from "../theme";
import { AgentsPage } from "./AgentsPage";
import codexIcon from "../assets/icons/codex.svg";
import claudeIcon from "../assets/icons/claude.svg";
import antigravityIcon from "../assets/icons/antigravity.svg";
import kimiIcon from "../assets/icons/kimi-light.svg";
import grokIcon from "../assets/icons/grok.svg";
import devinIcon from "../assets/icons/devin.svg";
import openaiIcon from "../assets/icons/openai-light.svg";
import deepseekIcon from "../assets/icons/deepseek.svg";
import geminiIcon from "../assets/icons/gemini.svg";
import vertexIcon from '../assets/icons/vertex.svg';
type AuthMethod = "oauth" | "api";
type SetupStep = 1 | 2;
type OAuthProviderId = "codex" | "claude" | "antigravity" | "kimi" | "xai" | "devin";
type OAuthProviderInfo = {
id: OAuthProviderId;
name: string;
icon: string;
descriptionKey: MessageKey;
};
const oauthProviders: OAuthProviderInfo[] = [
{ id: "codex", name: "Codex OAuth", icon: codexIcon, descriptionKey: "easyMode.oauth.providerDesc.codex" },
{ id: "claude", name: "Claude OAuth", icon: claudeIcon, descriptionKey: "easyMode.oauth.providerDesc.claude" },
{ id: "antigravity", name: "Antigravity OAuth", icon: antigravityIcon, descriptionKey: "easyMode.oauth.providerDesc.antigravity" },
{ id: "kimi", name: "Kimi OAuth", icon: kimiIcon, descriptionKey: "easyMode.oauth.providerDesc.kimi" },
{ id: "xai", name: "xAI OAuth", icon: grokIcon, descriptionKey: "easyMode.oauth.providerDesc.xai" },
{ id: "devin", name: "Devin OAuth", icon: devinIcon, descriptionKey: "easyMode.oauth.providerDesc.devin" },
];
type ApiSection = "openai-compatibility" | "deepseek" | "claude" | "gemini" | "codex" | 'interactions' | 'vertex' | 'xai' | 'meta';
type ApiManagementSection = "openai-compatibility" | "claude-api-key" | "codex-api-key" | "gemini-api-key" | 'interactions-api-key' | 'vertex-api-key' | 'xai-api-key' | 'meta-api-key';
type ApiSectionOption = {
id: ApiSection;
managementSection: ApiManagementSection;
nameKey: MessageKey;
name?: string;
provider: ModelProvider;
defaultBaseUrl: string;
icon: string;
};
const apiSectionOptions: ApiSectionOption[] = [
{ id: "openai-compatibility", managementSection: "openai-compatibility", nameKey: "easyMode.api.platformName.openai", provider: "openai", defaultBaseUrl: "", icon: openaiIcon },
{ id: "claude", managementSection: "claude-api-key", nameKey: "easyMode.api.platformName.claude", provider: "claude", defaultBaseUrl: "", icon: claudeIcon },
{ id: "codex", managementSection: "codex-api-key", nameKey: "easyMode.api.platformName.codex", provider: "codex", defaultBaseUrl: "", icon: codexIcon },
{ id: "gemini", managementSection: "gemini-api-key", nameKey: "easyMode.api.platformName.gemini", provider: "gemini", defaultBaseUrl: "", icon: geminiIcon },
{ id: "deepseek", managementSection: "codex-api-key", nameKey: "easyMode.api.platformName.deepseek", provider: "deepseek", defaultBaseUrl: DEEPSEEK_BASE_URL, icon: deepseekIcon },
{ id: 'interactions', managementSection: 'interactions-api-key', nameKey: 'easyMode.api.platformName.gemini', name: 'Gemini Interactions', provider: 'interactions', defaultBaseUrl: 'https://generativelanguage.googleapis.com', icon: geminiIcon },
{ id: 'vertex', managementSection: 'vertex-api-key', nameKey: 'easyMode.api.platformName.gemini', name: 'Vertex AI', provider: 'vertex', defaultBaseUrl: 'https://aiplatform.googleapis.com', icon: vertexIcon },
{ id: 'xai', managementSection: 'xai-api-key', nameKey: 'easyMode.api.platformName.openai', name: 'xAI', provider: 'xai', defaultBaseUrl: 'https://api.x.ai/v1', icon: grokIcon },
{ id: 'meta', managementSection: 'meta-api-key', nameKey: 'easyMode.api.platformName.openai', name: 'Meta', provider: 'meta', defaultBaseUrl: 'https://api.meta.ai/v1', icon: openaiIcon },
];
const isDeepSeekRecord = (record: Record<string, unknown>) => {
const name = readString(record, "name").trim().toLowerCase();
const baseUrl = readString(record, "base-url", "baseUrl").trim().toLowerCase();
return name.includes("deepseek") || /^https?:\/\/api\.deepseek\.com(?:\/|$)/i.test(baseUrl);
};
export function EasyModePage({
onExit,
theme,
setTheme,
locale,
setLocale,
}: {
onExit?: () => void;
theme?: ThemePreference;
setTheme?: (theme: ThemePreference) => void;
locale?: AppLocale;
setLocale?: (locale: AppLocale) => void;
}) {
const { t, locale: currentLocale, setLocale: setI18nLocale } = useI18n();
const [activeStep, setActiveStep] = useState<SetupStep>(1);
const [authMethod, setAuthMethod] = useState<AuthMethod>("oauth");
const [authFiles, setAuthFiles] = useState<Record<string, unknown>[]>([]);
const [apiCounts, setApiCounts] = useState<Record<ApiSection, number>>({
interactions: 0, vertex: 0, xai: 0, meta: 0,
"openai-compatibility": 0,
deepseek: 0,
claude: 0,
gemini: 0,
codex: 0,
});
const [oauthLoggingIn, setOauthLoggingIn] = useState<OAuthProviderId | null>(null);
const oauthFeedback = useAppNotice();
const { showNotice: showOAuthNotice, clearNotice: clearOAuthNotice } = oauthFeedback;
const oauthPollTimer = useRef<number | null>(null);
const oauthGeneration = useRef(0);
const [selectedApiSection, setSelectedApiSection] = useState<ApiSection>("openai-compatibility");
const [apiBaseUrl, setApiBaseUrl] = useState("");
const [apiProxyUrl, setApiProxyUrl] = useState("");
const [apiKey, setApiKey] = useState("");
const [apiRemark, setApiRemark] = useState("");
const [apiTesting, setApiTesting] = useState(false);
const [apiTestedModels, setApiTestedModels] = useState<ModelOption[]>([]);
const [apiSelectedModels, setApiSelectedModels] = useState<ModelOption[]>([]);
const [apiModelsReady, setApiModelsReady] = useState(false);
const [apiErrorMessage, setApiTestError] = useState<NoticeMessage>("");
const apiTestError = typeof apiErrorMessage === "string"
? apiErrorMessage
: t(apiErrorMessage.key, apiErrorMessage.variables);
const [apiSaving, setApiSaving] = useState(false);
const apiFeedback = useAppNotice();
const { showNotice: showApiNotice, clearNotice: clearApiNotice } = apiFeedback;
const [guideChoice, setGuideChoice] = useState<AuthMethod | null>(null);
const [guideOAuthProvider, setGuideOAuthProvider] = useState<OAuthProviderId | null>(null);
const [guideOAuthCompleted, setGuideOAuthCompleted] = useState(false);
const [guideApiSaved, setGuideApiSaved] = useState(false);
const [guideApiModelsFetched, setGuideApiModelsFetched] = useState(false);
const [guideAgentConfigured, setGuideAgentConfigured] = useState(false);
const [guideActive, setGuideActive] = useState(false);
const [guideStep, setGuideStep] = useState<number>(1);
const [spotlightRect, setSpotlightRect] = useState<{
top: number;
left: number;
width: number;
height: number;
} | null>(null);
const guideTooltipRef = useRef<HTMLElement | null>(null);
const [guideCardPosition, setGuideCardPosition] = useState<{ top: number; left: number } | null>(null);
const [langMenuOpen, setLangMenuOpen] = useState(false);
const langMenuRef = useRef<HTMLDivElement>(null);
const langButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!langMenuOpen) return undefined;
const closeFromOutside = (event: PointerEvent) => {
if (!langMenuRef.current?.contains(event.target as Node)) setLangMenuOpen(false);
};
const closeFromKeyboard = (event: globalThis.KeyboardEvent) => {
if (event.key !== 'Escape') return;
setLangMenuOpen(false);
langButtonRef.current?.focus();
};
document.addEventListener('pointerdown', closeFromOutside);
document.addEventListener('keydown', closeFromKeyboard);
return () => {
document.removeEventListener('pointerdown', closeFromOutside);
document.removeEventListener('keydown', closeFromKeyboard);
};
}, [langMenuOpen]);
const refreshSourceStatus = useCallback(async () => {
try {
const authFilesPayload = await managementApi.get("/auth-files");
const files = responseList(authFilesPayload, "files");
setAuthFiles(files);
const counts: Record<ApiSection, number> = {
interactions: 0, vertex: 0, xai: 0, meta: 0,
"openai-compatibility": 0,
deepseek: 0,
claude: 0,
gemini: 0,
codex: 0,
};
const sections = [...new Set(apiSectionOptions.map((option) => option.managementSection))];
const recordsBySection = Object.fromEntries(await Promise.all(sections.map(async (section) => [section, await providerGroupsApi.get(section)]))) as Record<ApiManagementSection, Record<string, unknown>[]>;
for (const section of apiSectionOptions) {
const sourceList = recordsBySection[section.managementSection];
const list = section.id === "deepseek"
? sourceList.filter(isDeepSeekRecord)
: section.id === "codex"
? sourceList.filter((record) => !isDeepSeekRecord(record))
: sourceList;
counts[section.id] = list.length;
}
setApiCounts(counts);
} catch (e) {
console.warn("Failed to refresh source status", e);
}
}, []);
useEffect(() => {
void refreshSourceStatus();
return () => {
++oauthGeneration.current;
if (oauthPollTimer.current !== null) window.clearTimeout(oauthPollTimer.current);
};
}, [refreshSourceStatus]);
const isOAuthLoggedIn = (providerId: OAuthProviderId) => {
const norm = providerId === "claude" ? "claude" : providerId === "codex" ? "codex" : providerId;
return authFiles.some((f) => {
const p = readString(f, "provider", "type").toLowerCase();
return (norm === "devin" && p === "cognition") || p.includes(norm) || (norm === "codex" && p.includes("openai")) || (norm === "claude" && p.includes("anthropic"));
});
};
const totalLoggedInOAuth = oauthProviders.filter((p) => isOAuthLoggedIn(p.id)).length;
const totalApiProviders = Object.values(apiCounts).reduce((a, b) => a + b, 0);
const hasConnectedSource = totalLoggedInOAuth > 0 || totalApiProviders > 0;
const connectedSourceCount = totalLoggedInOAuth + totalApiProviders;
const guideConnectedSourceCount = Math.max(connectedSourceCount, guideOAuthCompleted || guideApiSaved ? 1 : 0);
const setupStepStatus = t("easyMode.steps.current", {
current: activeStep,
total: 2,
});
const handleAuthMethodSelect = (method: AuthMethod) => {
setAuthMethod(method);
if (guideActive && guideStep === 1) setGuideChoice(method);
};
const handleStartOAuth = async (provider: OAuthProviderId) => {
if (guideActive && guideStep === 2) {
setGuideOAuthProvider(provider);
setGuideOAuthCompleted(false);
}
const generation = ++oauthGeneration.current;
if (oauthPollTimer.current !== null) window.clearTimeout(oauthPollTimer.current);
oauthPollTimer.current = null;
setOauthLoggingIn(provider);
clearOAuthNotice();
try {
const result = await invoke<{
url?: string;
state?: string;
opened?: boolean;
openError?: string;
}>("start_oauth_login", {
provider,
browser: "default",
});
if (generation !== oauthGeneration.current) return;
if (!result.state) {
showOAuthNotice({ key: "easyMode.notice.oauthStateFailed" }, "error");
setOauthLoggingIn(null);
return;
}
const stateKey = result.state;
const deadline = Date.now() + 10 * 60_000;
let failures = 0;
const poll = async () => {
if (generation !== oauthGeneration.current) return;
if (Date.now() >= deadline) {
setOauthLoggingIn(null);
showOAuthNotice({ key: "easyMode.notice.oauthTimeout" }, "error");
return;
}
try {
const pollRes = await invoke<{ status: string; error?: string }>(
"get_oauth_status",
{ state: stateKey },
);
if (generation !== oauthGeneration.current) return;
failures = 0;
const status = (pollRes.status || "").toLowerCase();
if (status === "ok") {
setOauthLoggingIn(null);
showOAuthNotice({ key: "easyMode.notice.oauthSuccess" }, "success");
setGuideOAuthCompleted(true);
void refreshSourceStatus();
} else if (status === "error") {
setOauthLoggingIn(null);
showOAuthNotice(pollRes.error
? { key: "easyMode.notice.oauthFailedWithReason", variables: { error: pollRes.error } }
: { key: "easyMode.notice.oauthFailed" }, "error");
}
if (status === "ok" || status === "error") return;
} catch (error) {
if (generation !== oauthGeneration.current) return;
failures += 1;
if (failures >= 3) {
setOauthLoggingIn(null);
showOAuthNotice(String(error), "error");
return;
}
}
if (generation === oauthGeneration.current) {
oauthPollTimer.current = window.setTimeout(poll, Math.min(1500 * 2 ** failures, 10_000));
}
};
oauthPollTimer.current = window.setTimeout(poll, 1500);
} catch (err) {
if (generation !== oauthGeneration.current) return;
setOauthLoggingIn(null);
showOAuthNotice(String(err), "error");
}
};
const handleApiSectionChange = (sec: ApiSection) => {
setSelectedApiSection(sec);
const opt = apiSectionOptions.find((o) => o.id === sec);
if (opt) setApiBaseUrl(opt.defaultBaseUrl);
setApiTestedModels([]);
setApiSelectedModels([]);
setApiModelsReady(false);
setApiTestError("");
clearApiNotice();
setGuideApiSaved(false);
setGuideApiModelsFetched(false);
};
const handleTestApi = async () => {
if (!apiBaseUrl.trim()) {
setApiTestError({ key: "easyMode.api.baseUrlRequired" });
return;
}
if (!apiKey.trim()) {
setApiTestError({ key: "easyMode.api.apiKeyRequired" });
return;
}
setApiTesting(true);
setApiTestError("");
setGuideApiSaved(false);
const opt = apiSectionOptions.find((o) => o.id === selectedApiSection);
const providerType = opt ? opt.provider : "openai";
try {
const models = await fetchModels(
providerType,
apiBaseUrl.trim(),
apiKey.trim(),
undefined,
{},
10000,
);
if (models.length > 0) {
const mergedModels = mergeModelOptions(models);
const selectedNames = reconcileModelSelection(
mergedModels,
[],
apiSelectedModels.map((model) => model.name),
apiModelsReady ? "refresh" : "initial",
);
setApiTestedModels(mergedModels);
setApiSelectedModels(
mergedModels.filter((model) => selectedNames.has(model.name.trim().toLowerCase())),
);
setApiModelsReady(true);
setGuideApiModelsFetched(true);
} else {
setApiTestError({ key: "easyMode.api.noModelsFound" });
}
} catch (err) {
setApiTestError(String(err));
} finally {
setApiTesting(false);
}
};
const handleToggleApiModel = (model: ModelOption) => {
setGuideApiSaved(false);
const key = model.name.trim().toLowerCase();
if (!key) return;
setApiSelectedModels((current) => {
const selected = current.some((item) => item.name.trim().toLowerCase() === key);
return selected
? current.filter((item) => item.name.trim().toLowerCase() !== key)
: [...current, model];
});
};
const handleSaveApi = async () => {
if (!apiBaseUrl.trim()) {
setApiTestError({ key: "easyMode.api.baseUrlRequired" });
return;
}
if (!apiKey.trim()) {
setApiTestError({ key: "easyMode.api.apiKeyRequired" });
return;
}
if (selectedApiSection === "deepseek" && !apiModelsReady) {
setApiTestError({ key: "easyMode.api.fetchListFirst" });
return;
}
if (apiSelectedModels.length === 0) {
setApiTestError({ key: "easyMode.api.modelRequired" });
return;
}
if (guideActive && guideStep === 2 && authMethod === "api" && !guideApiModelsFetched) {
setApiTestError({ key: "easyMode.api.fetchListFirst" });
return;
}
let proxyUrl: string;
try {
proxyUrl = normalizeProviderProxyUrl(apiProxyUrl);
} catch {
setApiTestError({ key: "apiAccess.error.proxyUrlInvalid" });
return;
}
setApiSaving(true);
clearApiNotice();
setApiTestError("");
setGuideApiSaved(false);
try {
const selectedOption = apiSectionOptions.find((option) => option.id === selectedApiSection);
const managementSection = selectedOption?.managementSection ?? "openai-compatibility";
const list = await providerGroupsApi.get(managementSection);
const selectedModels = apiSelectedModels.map((model) => ({ name: model.name.trim() }));
const models = selectedModels;
const newEntry = {
name: apiRemark.trim() || (selectedApiSection === 'deepseek' ? 'DeepSeek' : selectedApiSection),
'base-url': normalizeBaseUrl(apiBaseUrl.trim()),
keys: [{ 'api-key': apiKey.trim(), ...(proxyUrl ? { 'proxy-url': proxyUrl } : {}) }],
models,
};
await providerGroupsApi.put(managementSection, appendNativeProviderGroup(list, newEntry));
showApiNotice({ key: "easyMode.notice.apiSaveSuccess" });
setGuideApiSaved(true);
void refreshSourceStatus();
} catch (err) {
setApiTestError(String(err));
} finally {
setApiSaving(false);
}
};
const currentTargetId = (() => {
if (!guideActive) return null;
if (activeStep === 1) {
if (guideStep === 1) return "easy-guide-choice-grid";
if (guideStep === 2) return authMethod === "oauth" ? "easy-guide-oauth-box" : "easy-guide-api-box";
if (guideStep >= 3) return "easy-guide-footer-action";
} else if (activeStep === 2) {
return "easy-guide-agents-panel";
}
return null;
})();
const updateSpotlightPosition = useCallback(() => {
if (!guideActive || !currentTargetId) {
setSpotlightRect(null);
return;
}
const el = document.getElementById(currentTargetId);
if (el) {
const rect = el.getBoundingClientRect();
const next = {
top: Math.max(0, rect.top),
left: Math.max(0, rect.left),
width: rect.width,
height: rect.height,
};
setSpotlightRect((previous) => previous && previous.top === next.top && previous.left === next.left
&& previous.width === next.width && previous.height === next.height ? previous : next);
} else {
setSpotlightRect(null);
}
}, [guideActive, currentTargetId]);
useEffect(() => {
let frame: number | null = null;
const schedulePosition = () => {
if (frame !== null) return;
frame = window.requestAnimationFrame(() => {
frame = null;
updateSpotlightPosition();
});
};
const timer = setTimeout(() => {
if (currentTargetId) document.getElementById(currentTargetId)?.scrollIntoView({ behavior: "smooth", block: "nearest" });
schedulePosition();
}, 100);
window.addEventListener("resize", schedulePosition);
window.addEventListener("scroll", schedulePosition, { capture: true, passive: true });
return () => {
clearTimeout(timer);
if (frame !== null) window.cancelAnimationFrame(frame);
window.removeEventListener("resize", schedulePosition);
window.removeEventListener("scroll", schedulePosition, true);
};
}, [currentTargetId, updateSpotlightPosition]);
useEffect(() => {
setGuideCardPosition(null);
}, [currentTargetId]);
useLayoutEffect(() => {
if (!guideActive || !spotlightRect || !guideTooltipRef.current) {
setGuideCardPosition(null);
return;
}
const cardRect = guideTooltipRef.current.getBoundingClientRect();
const viewportPadding = 16;
const gap = 18;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const cardWidth = cardRect.width;
const cardHeight = cardRect.height;
const targetTop = spotlightRect.top;
const targetBottom = spotlightRect.top + spotlightRect.height;
const targetRight = spotlightRect.left + spotlightRect.width;
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), Math.max(min, max));
const centeredLeft = clamp(spotlightRect.left, viewportPadding, viewportWidth - cardWidth - viewportPadding);
const belowTop = targetBottom + gap;
const aboveTop = targetTop - cardHeight - gap;
let top: number;
let left = centeredLeft;
if (belowTop + cardHeight <= viewportHeight - viewportPadding) {
top = belowTop;
} else if (aboveTop >= viewportPadding) {
top = aboveTop;
} else if (targetRight + gap + cardWidth <= viewportWidth - viewportPadding) {
left = targetRight + gap;
top = clamp(targetTop, viewportPadding, viewportHeight - cardHeight - viewportPadding);
} else if (spotlightRect.left - gap - cardWidth >= viewportPadding) {
left = spotlightRect.left - cardWidth - gap;
top = clamp(targetTop, viewportPadding, viewportHeight - cardHeight - viewportPadding);
} else {
top = clamp(belowTop, viewportPadding, viewportHeight - cardHeight - viewportPadding);
}
setGuideCardPosition({ top, left });
}, [
authMethod,
currentTargetId,
guideActive,
guideAgentConfigured,
guideApiSaved,
guideOAuthCompleted,
guideStep,
spotlightRect,
]);
const guideCanAdvance = guideStep === 1
? guideChoice !== null
: guideStep === 2
? authMethod === "oauth"
? guideOAuthProvider !== null && guideOAuthCompleted
: guideApiSaved
: guideStep === 3
? hasConnectedSource || guideOAuthCompleted || guideApiSaved
: guideAgentConfigured;
const handleNextGuideStep = () => {
if (!guideCanAdvance) return;
if (guideStep === 1) {
setGuideStep(2);
} else if (guideStep === 2) {
setGuideStep(3);
} else if (guideStep === 3) {
setActiveStep(2);
setGuideStep(4);
} else if (guideStep === 4) {
setGuideActive(false);
}
};
const handlePrevGuideStep = () => {
if (guideStep === 4) {
setActiveStep(1);
setGuideStep(3);
} else if (guideStep > 1) {
setGuideStep(guideStep - 1);
}
};
const handleGuideToggle = () => {
if (guideActive) {
setGuideActive(false);
return;
}
setActiveStep(1);
setGuideStep(1);
setGuideChoice(null);
setGuideOAuthProvider(null);
setGuideOAuthCompleted(false);
setGuideApiSaved(false);
setGuideApiModelsFetched(false);
setGuideAgentConfigured(false);
setGuideActive(true);
};
const currentActiveLang = languageOptions.find((opt) => opt.value === (locale || currentLocale)) || languageOptions[0];
const handleLanguageListKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
const options = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="option"]'));
if (!options.length) return;
const current = options.indexOf(document.activeElement as HTMLButtonElement);
const next = event.key === 'Home'
? 0
: event.key === 'End'
? options.length - 1
: event.key === 'ArrowDown'
? (Math.max(current, -1) + 1) % options.length
: (current <= 0 ? options.length : current) - 1;
event.preventDefault();
options[next]?.focus();
};
return (
<section className="page simple-mode-page simple-mode-expanded">
<h1 className="sr-only">{t('app.nav.easy')}</h1>
{guideActive ? <div className="guide-dimmed-overlay" /> : null}
<header className="simple-mode-topbar">
<div className="simple-mode-topbar-left">
<div className="simple-mode-brand">
<img src={appLogo} alt="" className="brand-mark brand-logo" />
<div className="simple-mode-brand-text">
<div className="simple-mode-brand-title">
<strong>EasyCLIProxyAPI</strong>
<span className="simple-mode-badge">{t("easyMode.badge")}</span>
</div>
<span className="simple-mode-brand-sub">{t("easyMode.brandSub")}</span>
</div>
</div>
<button
type="button"
className={`simple-mode-guide-toggle simple-mode-highlight-button${guideActive ? " active" : ""}`}
title={guideActive ? t("easyMode.guide.toggleClose") : t("easyMode.guide.toggleOpen")}
onClick={() => {
handleGuideToggle();
}}
>
<span>{guideActive ? t("easyMode.guide.buttonRunning") : t("easyMode.guide.button")}</span>
</button>
</div>
<div className="simple-mode-topbar-right">
{setTheme ? (
<div className="simple-mode-theme-group" role="group" aria-label={t("app.theme.label")}>
<button
type="button"
className={theme === "light" ? "active" : ""}
title={t("easyMode.theme.light")}
aria-label={t("easyMode.theme.light")}
aria-pressed={theme === "light"}
onClick={() => setTheme("light")}
>
<Sun size={15} />
</button>
<button
type="button"
className={theme === "dark" ? "active" : ""}
title={t("easyMode.theme.dark")}
aria-label={t("easyMode.theme.dark")}
aria-pressed={theme === "dark"}
onClick={() => setTheme("dark")}
>
<Moon size={15} />
</button>
<button
type="button"
className={theme === "system" ? "active" : ""}
title={t("app.theme.switchToSystem")}
aria-label={t("app.theme.system")}
aria-pressed={theme === "system"}
onClick={() => setTheme("system")}
>
<Monitor size={15} />
</button>
</div>
) : null}
<div ref={langMenuRef} className="simple-mode-lang-dropdown">
<button
ref={langButtonRef}
type="button"
className="simple-mode-lang-btn"
onClick={() => setLangMenuOpen(!langMenuOpen)}
title={t("easyMode.language.switch")}
aria-label={t("easyMode.language.switch")}
aria-haspopup="listbox"
aria-expanded={langMenuOpen}
aria-controls="simple-mode-language-list"
onKeyDown={(event) => {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
event.preventDefault();
setLangMenuOpen(true);
window.requestAnimationFrame(() => {
const options = langMenuRef.current?.querySelectorAll<HTMLButtonElement>('[role="option"]');
options?.[event.key === 'ArrowUp' ? options.length - 1 : 0]?.focus();
});
}}
>
<Languages size={15} />
<span>{currentActiveLang.nativeLabel}</span>
<ChevronDown size={13} />
</button>
{langMenuOpen ? (
<div id="simple-mode-language-list" className="simple-mode-lang-menu" role="listbox" aria-label={t("easyMode.language.switch")} onKeyDown={handleLanguageListKeyDown}>
{languageOptions.map((opt) => (
<button
key={opt.value}
type="button"
className={opt.value === (locale || currentLocale) ? "selected" : ""}
role="option"
aria-selected={opt.value === (locale || currentLocale)}
onClick={() => {
if (setLocale) setLocale(opt.value);
else setI18nLocale(opt.value);
setLangMenuOpen(false);
window.requestAnimationFrame(() => langButtonRef.current?.focus());
}}
>
<span>{opt.nativeLabel}</span>
{opt.value === (locale || currentLocale) ? <Check size={14} /> : null}
</button>
))}
</div>
) : null}
</div>
<button
type="button"
className="secondary-button simple-mode-exit-btn"
title={t("easyMode.exitTitle")}
onClick={() => onExit?.()}
>
<span>{t("easyMode.exit")}</span>
</button>
</div>
</header>
<nav className="simple-mode-step-status" aria-label={t("easyMode.steps.label")}>
<div className="simple-mode-step-status-heading">
<span>{t("easyMode.steps.label")}</span>
<strong>{setupStepStatus}</strong>
</div>
<div className="simple-mode-step-status-track">
<button
type="button"
className={`simple-mode-step-status-item${activeStep === 1 ? " active" : " complete"}`}
aria-current={activeStep === 1 ? "step" : undefined}
aria-pressed={activeStep === 1}
disabled={guideActive}
onClick={() => setActiveStep(1)}
>
<span className="simple-mode-step-status-number">
{activeStep > 1 ? <Check size={14} aria-hidden="true" /> : "1"}
</span>
<span className="simple-mode-step-status-copy">
<strong>{t("easyMode.overview.providerTitle")}</strong>
<small>{t("easyMode.steps.step1Description")}</small>
</span>
</button>
<span className={`simple-mode-step-status-connector${activeStep > 1 ? " complete" : ""}`} aria-hidden="true" />
<button
type="button"
className={`simple-mode-step-status-item${activeStep === 2 ? " active" : ""}`}
aria-current={activeStep === 2 ? "step" : undefined}
aria-pressed={activeStep === 2}
disabled={guideActive || !hasConnectedSource}
onClick={() => setActiveStep(2)}
>
<span className="simple-mode-step-status-number">2</span>
<span className="simple-mode-step-status-copy">
<strong>{t("easyMode.overview.agentTitle")}</strong>
<small>{t("easyMode.steps.step2Description")}</small>
</span>
</button>
</div>
</nav>
{activeStep === 1 ? (
<section className="panel simple-mode-task">
<div className="simple-mode-task-heading">
<div>
<h2>{t("easyMode.overview.providerTitle")}</h2>
<p>{t("easyMode.setup.providerDescription")}</p>
</div>
</div>
<div
id="easy-guide-choice-grid"
className={`simple-mode-choice-grid${guideActive && guideStep === 1 ? " guide-focus-highlight" : ""}`}
>
<button
type="button"
className={`simple-mode-choice${authMethod === "oauth" ? " selected" : ""}`}
aria-pressed={authMethod === "oauth"}
onClick={() => handleAuthMethodSelect("oauth")}
disabled={guideActive && guideStep !== 1}
>
<div className="simple-mode-choice-heading">
<div className="simple-mode-choice-title">
<strong>{t("easyMode.oauth.title")}</strong>
{totalLoggedInOAuth > 0 ? (
<span className="state-pill success">
{t("easyMode.oauth.accountsLoggedIn", { count: totalLoggedInOAuth })}
</span>
) : (
<span className="state-pill neutral">{t("easyMode.oauth.recommended")}</span>
)}
</div>
</div>
<span>{t("easyMode.oauth.description")}</span>
<small>{t("easyMode.oauth.supportedProviders")}</small>
</button>
<button
type="button"
className={`simple-mode-choice${authMethod === "api" ? " selected" : ""}`}
aria-pressed={authMethod === "api"}
onClick={() => handleAuthMethodSelect("api")}
disabled={guideActive && guideStep !== 1}
>
<div className="simple-mode-choice-heading">
<div className="simple-mode-choice-title">
<strong>{t("easyMode.api.title")}</strong>
{totalApiProviders > 0 ? (
<span className="state-pill success">
{t("easyMode.api.platformsConnected", { count: totalApiProviders })}
</span>
) : null}
</div>
</div>
<span>{t("easyMode.api.description")}</span>
<small>{t("easyMode.api.supportedPlatforms")}</small>
</button>
</div>
{authMethod === "oauth" ? (
<div
id="easy-guide-oauth-box"
className={`simple-mode-embedded-box${guideActive && guideStep === 2 ? " guide-focus-highlight" : ""}`}
>
<FloatingNotice key={oauthFeedback.revision} notice={oauthFeedback.notice} onDismiss={clearOAuthNotice} />
<div className="simple-mode-provider-grid">
{oauthProviders.map((provider) => {
const loggedIn = isOAuthLoggedIn(provider.id);
const isLogging = oauthLoggingIn === provider.id;
return (
<article
key={provider.id}
className={`panel simple-mode-provider-card${loggedIn ? " connected" : ""}`}
>
<div className="simple-mode-provider-card-head">
<div className="simple-mode-provider-logo">
<img src={provider.icon} alt="" className={provider.id === "devin" ? "devin-logo" : undefined} />
</div>
<div className="simple-mode-provider-copy">
<strong>{provider.name}</strong>
<span>{t(provider.descriptionKey)}</span>
</div>
</div>
<div className="simple-mode-provider-card-foot">
{loggedIn ? (
<span className="state-pill success">
<Check size={12} style={{ marginRight: 4 }} />
{t("easyMode.oauth.loggedIn")}
</span>
) : (
<span className="state-pill neutral">{t("easyMode.status.notLoggedIn")}</span>
)}
<button
type="button"
className={loggedIn ? "secondary-button" : "primary-button"}
disabled={isLogging}
onClick={() => void handleStartOAuth(provider.id)}
>
{isLogging ? (
<>
<LoaderCircle size={14} className="spin" style={{ marginRight: 6 }} />
{t("easyMode.oauth.loggingIn")}
</>
) : loggedIn ? (
t("easyMode.oauth.relogin")
) : (
t("oauth.startLogin")
)}
</button>
</div>
</article>
);
})}
</div>
</div>
) : null}
{authMethod === "api" ? (
<div
id="easy-guide-api-box"
className={`simple-mode-embedded-box${guideActive && guideStep === 2 ? " guide-focus-highlight" : ""}`}
>
<FloatingNotice key={apiFeedback.revision} notice={apiFeedback.notice} onDismiss={clearApiNotice} />
{apiTestError ? (
<MessageNotice message={apiTestError} onDismiss={() => setApiTestError("")} />
) : null}
<div className="simple-mode-api-form">
<div className="simple-mode-api-platforms">
{apiSectionOptions.map((opt) => (
<button
type="button"
key={opt.id}
className={`secondary-button simple-mode-api-platform${selectedApiSection === opt.id ? " active" : ""}`}
onClick={() => handleApiSectionChange(opt.id)}
>
<img className="simple-mode-api-platform-icon" src={opt.icon} alt="" />
{opt.name ?? t(opt.nameKey)}
</button>
))}
</div>
<div className="simple-mode-field">
<label>{t("easyMode.api.name")}</label>
<input
type="text"
className="text-input"
value={apiRemark}
onChange={(e) => { setApiRemark(e.target.value); setGuideApiSaved(false); }}
placeholder={t("easyMode.api.namePlaceholder")}
/>
</div>
<div className="simple-mode-api-fields">
<div className="simple-mode-field">
<label>{t("easyMode.api.baseUrl")}</label>
<input
type="text"
className="text-input"
value={apiBaseUrl}
onChange={(e) => {
setApiBaseUrl(e.target.value);
setApiModelsReady(false);
setGuideApiModelsFetched(false);
setGuideApiSaved(false);
}}
placeholder="https://..."
/>
</div>
<div className="simple-mode-field">
<label>{t("easyMode.api.apiKey")}</label>
<input
type="password"
className="text-input"
value={apiKey}
onChange={(e) => {
setApiKey(e.target.value);
setApiModelsReady(false);
setGuideApiModelsFetched(false);
setGuideApiSaved(false);
}}
placeholder="sk-..."
/>
</div>
</div>
<div className="simple-mode-field">
<label>{t("apiAccess.field.proxyUrl")}</label>
<input
type="text"
className="text-input"
value={apiProxyUrl}
onChange={(event) => { setApiProxyUrl(event.target.value); setGuideApiSaved(false); }}
placeholder="socks5://127.0.0.1:1080"
/>
</div>
<div className="simple-mode-api-model-card">
{apiTestedModels.length === 0 ? (
<div className="simple-mode-api-model-fetch">
<button
type="button"
className="secondary-button simple-mode-api-fetch-button"
disabled={apiTesting || !apiBaseUrl.trim() || !apiKey.trim()}
onClick={() => void handleTestApi()}
>
{apiTesting ? (
<>
<LoaderCircle size={14} className="spin" style={{ marginRight: 6 }} />
{t("easyMode.api.fetchingModels")}
</>
) : (
t("easyMode.api.fetchModels")
)}
</button>
</div>
) : (
<>
<div className="simple-mode-api-model-heading">
<strong>{t("easyMode.api.modelListTitle")}</strong>
<button
type="button"
className="secondary-button compact-button"
disabled={apiTesting || !apiBaseUrl.trim() || !apiKey.trim()}
onClick={() => void handleTestApi()}
>
<RefreshCw size={14} className={apiTesting ? "spin" : ""} />
{t("common.refresh")}
</button>
</div>
<div className="simple-mode-api-model-selection">
<div className="simple-mode-api-model-selection-heading">
<span>
{selectedApiSection === "deepseek" && !apiModelsReady
? t("easyMode.api.modelListStale")
: t("easyMode.api.modelListHint")}
</span>
</div>
<div className="simple-mode-api-model-options">
{apiTestedModels.map((model) => {
const key = model.name.trim().toLowerCase();
const selected = apiSelectedModels.some(
(item) => item.name.trim().toLowerCase() === key,
);
return (
<label
className={`simple-mode-api-model-option${selected ? " selected" : ""}`}
key={model.name}
>
<input
type="checkbox"
checked={selected}
disabled={apiTesting}
onChange={() => handleToggleApiModel(model)}
/>
<span title={model.name}>{model.name}</span>
</label>
);
})}
</div>
</div>
<div className="simple-mode-api-actions">
<button
type="button"
className="primary-button"
disabled={
apiSaving
|| !apiBaseUrl.trim()
|| !apiKey.trim()
|| apiSelectedModels.length === 0
|| (selectedApiSection === "deepseek" && !apiModelsReady)
}
onClick={() => void handleSaveApi()}
>
{t("easyMode.api.saveAndConnect")}
</button>
</div>
</>
)}
</div>
</div>
</div>
) : null}
<div
id="easy-guide-footer-action"
className={`simple-mode-task-footer${guideActive && guideStep === 3 ? " guide-focus-highlight" : ""}`}
style={{ marginTop: 10 }}
>
<div className="simple-mode-selection">
{hasConnectedSource ? (
<span style={{ color: "var(--ui-accent-strong)", fontWeight: 600 }}>
{t("easyMode.status.connectedModels", { count: connectedSourceCount })}
</span>
) : (
<span className="muted">{t("easyMode.status.noModelsYet")}</span>
)}
</div>
<button
type="button"
className="primary-button"
style={{ minHeight: 42, padding: "0 22px" }}
disabled={!hasConnectedSource}
onClick={() => {
setActiveStep(2);
if (guideActive) setGuideStep(4);
}}
>
{t("easyMode.navigation.nextAgent")}
<ArrowRight size={16} style={{ marginLeft: 6 }} />
</button>
</div>
</section>
) : null}
{activeStep === 2 ? (
<section
id="easy-guide-agents-panel"
className={`panel simple-mode-task${guideActive && guideStep === 4 ? " guide-focus-highlight" : ""}`}
>
<AgentsPage embedded onConfigurationApplied={() => setGuideAgentConfigured(true)} />
<div className="simple-mode-task-footer" style={{ marginTop: 14 }}>
<button
type="button"
className="secondary-button"
onClick={() => {
setActiveStep(1);
if (guideActive) setGuideStep(3);
}}
>
<ArrowLeft size={16} style={{ marginRight: 6 }} />
{t("easyMode.navigation.back")}
</button>
</div>
</section>
) : null}
{guideActive && spotlightRect ? (
<aside
ref={guideTooltipRef}
className="guide-interactive-card"
style={{
top: guideCardPosition ? `${guideCardPosition.top}px` : "0px",
left: guideCardPosition ? `${guideCardPosition.left}px` : "0px",
visibility: guideCardPosition ? "visible" : "hidden",
}}
>
<div className="guide-tooltip-header">
<div className="guide-tooltip-badge">
<span>
{guideStep === 1 && t("easyMode.guide.badgeStep1")}
{guideStep === 2 && (authMethod === "oauth" ? t("easyMode.guide.badgeStep2OAuth") : t("easyMode.guide.badgeStep2Api"))}
{guideStep === 3 && t("easyMode.guide.badgeStep3")}
{guideStep === 4 && t("easyMode.guide.badgeStep4")}
</span>
</div>
<button
type="button"
className="guide-tooltip-close"
title={t("easyMode.guide.close")}
onClick={() => setGuideActive(false)}
>
<X size={15} />
</button>
</div>
<div className="guide-tooltip-body">
{guideStep === 1 ? (
<>
<h4>{t("easyMode.guide.cardStep1Title")}</h4>
<p>
<strong>{t("easyMode.guide.cardStep1OAuth")}</strong>{t("easyMode.guide.cardStep1OAuthDesc")}<br />
<strong>{t("easyMode.guide.cardStep1Api")}</strong>{t("easyMode.guide.cardStep1ApiDesc")}
</p>
<div className="guide-tooltip-tip">
{guideChoice ? t("easyMode.guide.cardStep1TipSelected") : t("easyMode.guide.cardStep1TipUnselected")}
</div>
</>
) : null}
{guideStep === 2 && authMethod === "oauth" ? (
<>
<h4>{t("easyMode.guide.cardStep2OAuthTitle")}</h4>
<p>
{t("easyMode.guide.oauthInstructions", { start: t("oauth.startLogin"), relogin: t("easyMode.oauth.relogin") })}<br />
{t("easyMode.guide.oauthCompletion", { signedIn: t("easyMode.oauth.loggedIn") })}
</p>
<div className="guide-tooltip-tip">
{guideOAuthCompleted && guideOAuthProvider ? (
<strong>{t("easyMode.guide.cardStep2OAuthSuccessTip", { provider: oauthProviders.find((provider) => provider.id === guideOAuthProvider)?.name ?? "" })}</strong>
) : (
t("easyMode.guide.cardStep2OAuthWaitTip")
)}
</div>
</>
) : null}
{guideStep === 2 && authMethod === "api" ? (
<>
<h4>{t("easyMode.guide.cardStep2ApiTitle")}</h4>
<p>
{t("easyMode.guide.cardStep2Api1")}<br />
{t("easyMode.guide.apiCredentials", { baseUrl: t("easyMode.api.baseUrl"), apiKey: t("easyMode.api.apiKey") })}<br />
{t("easyMode.guide.apiModels", { fetch: t("easyMode.api.fetchModels"), save: t("easyMode.api.saveAndConnect") })}
</p>
<div className="guide-tooltip-tip">
{guideApiSaved ? (
<strong>{t("easyMode.guide.cardStep2ApiSavedTip")}</strong>
) : !apiBaseUrl.trim() || !apiKey.trim() ? (
t("easyMode.guide.cardStep2ApiFillTip")
) : !guideApiModelsFetched || apiTestedModels.length === 0 ? (
t("easyMode.guide.cardStep2ApiFetchTip")
) : apiSelectedModels.length === 0 ? (
t("easyMode.guide.cardStep2ApiSelectTip")
) : (
<span>{t("easyMode.guide.cardStep2ApiSelectedTip", { count: apiSelectedModels.length })}</span>
)}
</div>
</>
) : null}
{guideStep === 3 ? (
<>
<h4>{t("easyMode.guide.cardStep3Title")}</h4>
<p>
{t("easyMode.guide.sourcesSummary", { count: guideConnectedSourceCount })}<br />
{t("easyMode.guide.sourcesNext", { next: t("easyMode.navigation.nextAgent") })}
</p>
<div className="guide-tooltip-tip">
{guideCanAdvance ? t("easyMode.guide.cardStep3TipReady") : t("easyMode.guide.cardStep3TipPending")}
</div>
</>
) : null}
{guideStep === 4 ? (
<>
<h4>{t("easyMode.guide.cardStep4Title")}</h4>
<p>
{t("easyMode.guide.clientSelection")}<br />
{t("easyMode.guide.clientApply")}
</p>
<div className="guide-tooltip-tip">
{guideAgentConfigured ? t("easyMode.guide.cardStep4TipConfigured") : t("easyMode.guide.cardStep4TipPending")}
</div>
</>
) : null}
</div>
<div className="guide-tooltip-footer">
<div className="guide-tooltip-step-dots">
{[1, 2, 3, 4].map((step) => (
<span
key={step}
className={`guide-step-dot${guideStep === step ? " active" : ""}`}
aria-hidden="true"
/>
))}
</div>
<div className="guide-tooltip-actions">
{guideStep > 1 ? (
<button
type="button"
className="secondary-button guide-btn-sm"
onClick={handlePrevGuideStep}
>
{t("easyMode.navigation.back")}
</button>
) : null}
<button
type="button"
className="primary-button guide-btn-sm"
onClick={handleNextGuideStep}
disabled={!guideCanAdvance}
>
{guideStep === 4 ? t("easyMode.guide.finish") : t("easyMode.navigation.next")}
</button>
</div>
</div>
</aside>
) : null}
</section>
);
}