diff --git a/src/App.tsx b/src/App.tsx index 73e259f..d46d780 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -271,8 +271,9 @@ function AppContent() { return ( <> -
- + ) : null}
{isAlwaysAvailablePage(activePage.id) || coreRunning ? ( activePage.id === 'easy' ? ( - select('home')} /> + select('home')} + theme={theme} + setTheme={setTheme} + locale={locale} + setLocale={setLocale} + /> ) : ( ) diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 7f6afb9..7102d42 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -38,7 +38,7 @@ export const en: Record = { 'easyMode.steps.label': 'Connection wizard steps', 'easyMode.steps.current': 'Step {current} of {total}', 'easyMode.steps.step1Description': 'Sign in or connect an API', - 'easyMode.steps.step2Description': 'Select a client and model, then start', + 'easyMode.steps.step2Description': 'Select a client and model, then apply the configuration', 'easyMode.overview.coreTitle': 'How to use', 'easyMode.overview.providerTitle': 'Connect a model', 'easyMode.overview.agentTitle': 'Connect an agent', @@ -55,18 +55,18 @@ export const en: Record = { 'easyMode.step3.step2': 'Choose an installed agent client.', 'easyMode.step3.step3': 'Apply the configuration, then launch the client.', 'easyMode.guide.title': 'How to use', - 'easyMode.guide.description': 'Choose how to connect first. After the model is connected, choose the model your agent should use from “Available models”.', + 'easyMode.guide.description': 'Choose a connection method, complete the connection, then select a client and model and apply the configuration.', 'easyMode.guide.step1Title': 'Choose how to connect', 'easyMode.guide.step1Description': 'You can use OAuth to sign in to your own account and use its quota, or use API Access to connect to any platform.', 'easyMode.guide.step2Title': 'Connect the model', - 'easyMode.guide.step2Description': 'Use OAuth to authorize an account, or enter the Base URL and API key provided by a third-party platform. After saving, the model appears in “Available models”.', + 'easyMode.guide.step2Description': 'OAuth requires account authorization. API access requires a Base URL and API key, fetching models, and saving at least one model before client setup.', 'easyMode.guide.step3Title': 'Choose a model and connect an agent', 'easyMode.guide.step3Description': 'Choose a model from “Available models”, select an installed agent client, and apply the configuration.', 'easyMode.navigation.back': 'Previous', 'easyMode.navigation.next': 'Next', 'easyMode.status.ready': 'Ready', - 'easyMode.status.connectedModels': '{count} available models detected', - 'easyMode.status.noModelsYet': 'No models connected yet. Please sign in or add an API key first.', + 'easyMode.status.connectedModels': '{count} connected sources detected', + 'easyMode.status.noModelsYet': 'No source connected yet. Sign in or add an API key first.', 'easyMode.oauth.loggedIn': 'Signed In', 'easyMode.oauth.relogin': 'Re-login', 'easyMode.oauth.loggingIn': 'Waiting for browser authorization...', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index e469520..1f09fc7 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -36,7 +36,7 @@ export const zhCN = { 'easyMode.steps.label': '接入向导步骤', 'easyMode.steps.current': '第 {current} 步 / 共 {total} 步', 'easyMode.steps.step1Description': '先登录账号或接入 API', - 'easyMode.steps.step2Description': '选择客户端和模型后启动', + 'easyMode.steps.step2Description': '选择客户端和模型并应用配置', 'easyMode.overview.coreTitle': '使用指导', 'easyMode.overview.providerTitle': '接入模型', 'easyMode.overview.agentTitle': '接入智能体', @@ -53,18 +53,18 @@ export const zhCN = { 'easyMode.step3.step2': '选择一个已安装的智能体客户端。', 'easyMode.step3.step3': '应用配置修改,然后启动对应的客户端。', 'easyMode.guide.title': '使用指导', - 'easyMode.guide.description': '先确定连接方式,完成模型连接后,再从“可用模型”列表中选择智能体要使用的模型。', + 'easyMode.guide.description': '先确定连接方式并完成接入,再选择客户端和模型并应用配置。', 'easyMode.guide.step1Title': '选择连接方式', 'easyMode.guide.step1Description': '可选择两种方式接入\n1.OAuth登录自己的订阅账号,使用自己账号的额度\n2.API URL的方式接入', 'easyMode.guide.step2Title': '连接模型', - 'easyMode.guide.step2Description': '这里可以通过 OAuth 完成账号授权,也可以填写第三方平台提供的 Base URL 和 API 密钥。保存后,模型会出现在“可用模型”列表中。', + 'easyMode.guide.step2Description': 'OAuth 需要完成账号授权;API 需要填写 Base URL 和 API Key、获取模型并保存至少一个模型。完成后才能进入客户端配置。', 'easyMode.guide.step3Title': '选择模型并接入智能体', 'easyMode.guide.step3Description': '在“可用模型”列表中选定模型,再选择已安装的智能体客户端并应用配置。', 'easyMode.navigation.back': '上一步', 'easyMode.navigation.next': '下一步', 'easyMode.status.ready': '已就绪', - 'easyMode.status.connectedModels': '已检测到 {count} 个可用模型', - 'easyMode.status.noModelsYet': '尚未连接模型,请先登录账号或添加 API', + 'easyMode.status.connectedModels': '已检测到 {count} 个已接入来源', + 'easyMode.status.noModelsYet': '尚未接入来源,请先登录账号或添加 API', 'easyMode.oauth.loggedIn': '已登录', 'easyMode.oauth.relogin': '重新登录', 'easyMode.oauth.loggingIn': '等待浏览器授权...', diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 9cb2d4e..75509b4 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -631,9 +631,10 @@ function AgentModelPicker({ type AgentsPageProps = { embedded?: boolean; + onConfigurationApplied?: () => void; }; -export function AgentsPage({ embedded = false }: AgentsPageProps = {}) { +export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsPageProps = {}) { const { t } = useI18n(); const [selected, setSelected] = useState(readSelectedAgentClient); const [activeSubpage, setActiveSubpage] = useState(DEFAULT_AGENT_SUBPAGE); @@ -1206,6 +1207,7 @@ export function AgentsPage({ embedded = false }: AgentsPageProps = {}) { } await reloadStatusesAfterAction(); setOauthConfigurationDraft(null); + onConfigurationApplied?.(); } catch (requestError) { if (!handleOAuthLoginError(requestError, 'apply')) { setConfigurationError(String(requestError)); @@ -1223,6 +1225,7 @@ export function AgentsPage({ embedded = false }: AgentsPageProps = {}) { try { await invoke('install_pi_provider', { model }); await reloadStatusesAfterAction(); + onConfigurationApplied?.(); } catch (requestError) { setConfigurationError(String(requestError)); } finally { @@ -1255,6 +1258,7 @@ export function AgentsPage({ embedded = false }: AgentsPageProps = {}) { try { await invoke('repair_pi_provider', { model }); await reloadStatusesAfterAction(); + onConfigurationApplied?.(); } catch (requestError) { setConfigurationError(String(requestError)); } finally { diff --git a/src/pages/EasyModePage.tsx b/src/pages/EasyModePage.tsx index 12a30e0..34a1e92 100644 --- a/src/pages/EasyModePage.tsx +++ b/src/pages/EasyModePage.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, + useLayoutEffect, useRef, useState, } from "react"; @@ -11,13 +12,17 @@ import { ArrowRight, Check, CheckCircle2, + ChevronDown, + Languages, LoaderCircle, + Moon, + Sun, + X, } from "lucide-react"; -import { useI18n } from "../i18n"; +import appLogo from "../assets/logo.jpg"; +import { useI18n, languageOptions, type AppLocale } from "../i18n"; import { - isRecord, managementApi, - readBoolean, readString, responseList, } from "../services/managementApi"; @@ -27,6 +32,7 @@ import { type ModelOption, type ModelProvider, } from "../services/modelService"; +import { type AppTheme } from "../theme"; import { AgentsPage } from "./AgentsPage"; import codexIcon from "../assets/icons/codex.svg"; @@ -86,10 +92,18 @@ const isDeepSeekRecord = (record: Record) => { export function EasyModePage({ onExit, + theme, + setTheme, + locale, + setLocale, }: { onExit?: () => void; + theme?: AppTheme; + setTheme?: (theme: AppTheme) => void; + locale?: AppLocale; + setLocale?: (locale: AppLocale) => void; }) { - const { t } = useI18n(); + const { t, locale: currentLocale, setLocale: setI18nLocale } = useI18n(); const [activeStep, setActiveStep] = useState(1); const [authMethod, setAuthMethod] = useState("oauth"); @@ -123,6 +137,30 @@ export function EasyModePage({ const [apiSaving, setApiSaving] = useState(false); const [apiSaveNotice, setApiSaveNotice] = useState(null); + const [guideChoice, setGuideChoice] = useState(null); + const [guideOAuthProvider, setGuideOAuthProvider] = useState(null); + const [guideOAuthCompleted, setGuideOAuthCompleted] = useState(false); + const [guideApiSaved, setGuideApiSaved] = useState(false); + const [guideApiFormatSelected, setGuideApiFormatSelected] = useState(false); + const [guideApiBaseEdited, setGuideApiBaseEdited] = useState(false); + const [guideApiKeyEdited, setGuideApiKeyEdited] = useState(false); + const [guideApiModelsFetched, setGuideApiModelsFetched] = useState(false); + const [guideApiModelSelected, setGuideApiModelSelected] = useState(false); + const [guideAgentConfigured, setGuideAgentConfigured] = useState(false); + + // 新手聚焦指导状态(默认关闭,点击开启) + const [guideActive, setGuideActive] = useState(false); + const [guideStep, setGuideStep] = useState(1); + const [spotlightRect, setSpotlightRect] = useState<{ + top: number; + left: number; + width: number; + height: number; + } | null>(null); + const guideTooltipRef = useRef(null); + const [guideCardPosition, setGuideCardPosition] = useState<{ top: number; left: number } | null>(null); + const [langMenuOpen, setLangMenuOpen] = useState(false); + const refreshSourceStatus = useCallback(async () => { setLoadingSources(true); try { @@ -137,7 +175,6 @@ export function EasyModePage({ gemini: 0, codex: 0, }; - const allFetchedModels: ModelOption[] = []; const configPayload = await managementApi.get("/config"); const recordsBySection: Record[]> = { @@ -155,20 +192,8 @@ export function EasyModePage({ ? sourceList.filter((record) => !isDeepSeekRecord(record)) : sourceList; counts[section.id] = list.length; - list.forEach((item) => { - if (Array.isArray(item.models)) { - item.models.forEach((m) => { - const name = typeof m === "string" ? m : readString(m, "name", "id"); - if (name && !allFetchedModels.some((x) => x.name === name)) { - allFetchedModels.push({ name }); - } - }); - } - }); } setApiCounts(counts); - - setAvailableModels(allFetchedModels); } catch (e) { console.warn("Failed to refresh source status", e); } finally { @@ -181,7 +206,7 @@ export function EasyModePage({ return () => { if (oauthPollTimer.current) window.clearInterval(oauthPollTimer.current); }; - }, []); + }, [refreshSourceStatus]); useEffect(() => { if (!oauthNotice) return undefined; @@ -199,7 +224,34 @@ export function EasyModePage({ return () => window.clearTimeout(timer); }, [apiSaveNotice]); + 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 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); + } if (oauthPollTimer.current) window.clearInterval(oauthPollTimer.current); setOauthLoggingIn(provider); setOauthNotice(null); @@ -239,6 +291,7 @@ export function EasyModePage({ tone: "success", message: "登录成功!已成功授权并获取账号额度。", }); + setGuideOAuthCompleted(true); void refreshSourceStatus(); } else if (status === "error") { if (oauthPollTimer.current) window.clearInterval(oauthPollTimer.current); @@ -258,24 +311,41 @@ export function EasyModePage({ }); } }; + const handleApiSectionChange = (sec: ApiSection) => { setSelectedApiSection(sec); + if (guideActive && guideStep === 2) { + setGuideApiFormatSelected(true); + setGuideApiBaseEdited(false); + setGuideApiKeyEdited(false); + } const opt = apiSectionOptions.find((o) => o.id === sec); if (opt) setApiBaseUrl(opt.defaultBaseUrl); setApiTestedModels([]); setApiSelectedModels([]); setApiTestError(""); setApiSaveNotice(null); + setGuideApiSaved(false); + setGuideApiModelsFetched(false); + setGuideApiModelSelected(false); }; const handleTestApi = async () => { if (!apiBaseUrl.trim()) { - setApiTestError("请先填写 API URL"); + setApiTestError("请先填写 API Base URL"); + return; + } + if (!apiKey.trim()) { + setApiTestError("请先填写 API Key"); return; } setApiTesting(true); setApiTestError(""); setApiTestedModels([]); + setApiSelectedModels([]); + setGuideApiSaved(false); + setGuideApiModelsFetched(false); + setGuideApiModelSelected(false); const opt = apiSectionOptions.find((o) => o.id === selectedApiSection); const providerType = opt ? opt.provider : "openai"; @@ -291,9 +361,10 @@ export function EasyModePage({ ); if (models.length > 0) { setApiTestedModels(models); - setApiSelectedModels(models); + setApiSelectedModels([]); + setGuideApiModelsFetched(true); } else { - setApiTestError("未获取到任何模型,请检查 API URL 和 API 密钥"); + setApiTestError("未获取到任何模型,请检查 API Base URL 与密钥"); } } catch (err) { setApiTestError(String(err)); @@ -303,6 +374,8 @@ export function EasyModePage({ }; const handleToggleApiModel = (model: ModelOption) => { + setGuideApiSaved(false); + setGuideApiModelSelected(true); const key = model.name.trim().toLowerCase(); if (!key) return; setApiSelectedModels((current) => { @@ -315,16 +388,27 @@ export function EasyModePage({ const handleSaveApi = async () => { if (!apiBaseUrl.trim()) { - setApiTestError("请先填写 API URL"); + setApiTestError("请先填写 API Base URL"); + return; + } + if (!apiKey.trim()) { + setApiTestError("请先填写 API Key"); return; } if (apiSelectedModels.length === 0) { setApiTestError(t("easyMode.api.modelRequired")); return; } + if (guideActive && guideStep === 2 && authMethod === "api") { + if (!guideApiFormatSelected || !guideApiBaseEdited || !guideApiKeyEdited || !guideApiModelsFetched || !guideApiModelSelected) { + setApiTestError("请按引导完成接口格式、地址、密钥和模型选择后再保存"); + return; + } + } setApiSaving(true); setApiSaveNotice(null); setApiTestError(""); + setGuideApiSaved(false); try { const selectedOption = apiSectionOptions.find((option) => option.id === selectedApiSection); @@ -349,6 +433,7 @@ export function EasyModePage({ await managementApi.put(`/${managementSection}`, [...list, newEntry]); setApiSaveNotice("API 接入已保存成功!"); + setGuideApiSaved(true); void refreshSourceStatus(); } catch (err) { setApiTestError(String(err)); @@ -357,24 +442,262 @@ export function EasyModePage({ } }; - 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 p.includes(norm) || (norm === "codex" && p.includes("openai")) || (norm === "claude" && p.includes("anthropic")); - }); + // 获取当前聚焦点元素 ID (4 个稳定步骤) + 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(); + setSpotlightRect({ + top: Math.max(0, rect.top), + left: Math.max(0, rect.left), + width: rect.width, + height: rect.height, + }); + el.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } else { + setSpotlightRect(null); + } + }, [guideActive, currentTargetId]); + + useEffect(() => { + const timer = setTimeout(updateSpotlightPosition, 100); + window.addEventListener("resize", updateSpotlightPosition); + window.addEventListener("scroll", updateSpotlightPosition, true); + return () => { + clearTimeout(timer); + window.removeEventListener("resize", updateSpotlightPosition); + window.removeEventListener("scroll", updateSpotlightPosition, true); + }; + }, [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 + : guideApiFormatSelected && guideApiBaseEdited && guideApiKeyEdited && guideApiModelsFetched && guideApiModelSelected && 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 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 setupStepStatus = t("easyMode.steps.current", { - current: activeStep, - total: 2, - }); + 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); + setGuideApiFormatSelected(false); + setGuideApiBaseEdited(false); + setGuideApiKeyEdited(false); + setGuideApiModelsFetched(false); + setGuideApiModelSelected(false); + setGuideAgentConfigured(false); + setGuideActive(true); + }; + + const currentActiveLang = languageOptions.find((opt) => opt.value === (locale || currentLocale)) || languageOptions[0]; return ( -
+
+ {/* 全灰色聚焦蒙层 (Dimmed Backdrop) */} + {guideActive ?
: null} + + {/* 顶部全宽导航栏 */} +
+
+
+ +
+
+ EasyCLIProxyAPI + 新手模式 +
+ 极简智能体与模型接入向导 +
+
+
+ +
+ {/* 新手聚焦指导开关 */} + + + {/* 主题切换 */} + {setTheme ? ( +
+ + +
+ ) : null} + + {/* 语言选择 */} +
+ + {langMenuOpen ? ( +
+ {languageOptions.map((opt) => ( + + ))} +
+ ) : null} +
+ + {/* 退出新手模式返回常规控制台 */} + +
+
+ + {/* 步骤条进度指示器 */}