feat(easy-mode): improve API onboarding and agent configuration flow
- Fix management API 404 errors when saving API providers - Simplify the model list and support custom model IDs - Adjust API field order and default provider selection - Reuse the full agent configuration page and restore agent detection
This commit is contained in:
1 parent
79b50b7a2b
commit
dd008d64ba
6 files changed
+1777
-160
No files matched your search
+1
-2
@@ -41,7 +41,6 @@ const pages = [
|
||||
id: 'easy',
|
||||
labelKey: 'app.nav.easy',
|
||||
icon: Sparkles,
|
||||
// The easy page is rendered separately so it can receive navigation callbacks.
|
||||
component: HomePage,
|
||||
},
|
||||
{
|
||||
@@ -419,7 +418,7 @@ function AppContent() {
|
||||
<main className="content">
|
||||
{isAlwaysAvailablePage(activePage.id) || coreRunning ? (
|
||||
activePage.id === 'easy' ? (
|
||||
<EasyModePage />
|
||||
<EasyModePage onExit={() => select('home')} />
|
||||
) : (
|
||||
<ActivePage />
|
||||
)
|
||||
|
||||
@@ -61,6 +61,45 @@ export const en: Record<MessageKey, string> = {
|
||||
'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.oauth.loggedIn': 'Signed In',
|
||||
'easyMode.oauth.relogin': 'Re-login',
|
||||
'easyMode.oauth.loggingIn': 'Waiting for browser authorization...',
|
||||
'easyMode.api.quickAdd': 'Quick Add API',
|
||||
'easyMode.api.testAndSave': 'Test & Save',
|
||||
'easyMode.api.testing': 'Testing connection...',
|
||||
'easyMode.api.fetchModels': 'Fetch model list',
|
||||
'easyMode.api.fetchingModels': 'Fetching model list...',
|
||||
'easyMode.api.customModelId': 'Custom Model ID',
|
||||
'easyMode.api.customModelPlaceholder': 'For example: gpt-5.6-sol or deepseek-v4-pro',
|
||||
'easyMode.api.addModelId': 'Add Model ID',
|
||||
'easyMode.api.credentialsRequired': 'Enter an API URL and API key first',
|
||||
'easyMode.api.modelRequired': 'Fetch and select a model, or add a Model ID manually',
|
||||
'easyMode.api.testSuccess': 'Connection successful! Found {count} models',
|
||||
'easyMode.api.testFailed': 'Connection test failed',
|
||||
'easyMode.api.platform': 'Platform',
|
||||
'easyMode.api.baseUrl': 'API Base URL',
|
||||
'easyMode.api.apiKey': 'API Key',
|
||||
'easyMode.api.name': 'Remark Name (Optional)',
|
||||
'easyMode.api.viewExisting': 'View all connected providers ({count})',
|
||||
'easyMode.api.backToQuick': 'Back to Quick Add',
|
||||
'easyMode.api.savedSuccess': 'API provider saved successfully!',
|
||||
'easyMode.scenario.title': 'Choose how to use',
|
||||
'easyMode.scenario.agentTab': 'Connect AI Agent (Recommended)',
|
||||
'easyMode.scenario.apiTab': 'Copy Local API Endpoints',
|
||||
'easyMode.agent.installedBadge': 'Installed on this machine',
|
||||
'easyMode.agent.notDetected': 'Not detected',
|
||||
'easyMode.agent.chooseModel': 'Select Model',
|
||||
'easyMode.agent.applyConfig': '⚡ Apply Configuration',
|
||||
'easyMode.agent.applySuccess': 'Configuration applied successfully!',
|
||||
'easyMode.agent.launchClient': '🚀 Launch Client / Open Terminal',
|
||||
'easyMode.agent.launching': 'Launching...',
|
||||
'easyMode.complete.title': 'Setup Complete!',
|
||||
'easyMode.complete.enterApp': 'Enter Dashboard',
|
||||
'easyMode.apiCopy.desc': 'You can also use the local proxy URL in any OpenAI / Claude / Gemini compatible tool.',
|
||||
'easyMode.apiCopy.copied': 'Copied to clipboard',
|
||||
'error.render.title': 'Something went wrong while rendering this page',
|
||||
'error.unknown': 'Unknown error',
|
||||
'error.reload': 'Reload Page',
|
||||
|
||||
@@ -37,7 +37,7 @@ export const zhCN = {
|
||||
'easyMode.overview.coreTitle': '使用指导',
|
||||
'easyMode.overview.providerTitle': '接入模型',
|
||||
'easyMode.overview.agentTitle': '接入智能体',
|
||||
'easyMode.setup.providerDescription': '可以通过 OAuth 登录自己的账号并使用账号额度,也可以通过 API 接入任意平台。',
|
||||
'easyMode.setup.providerDescription': '可选择两种方式接入\n1.OAuth登录自己的订阅账号,使用自己账号的额度\n2.API URL的方式接入',
|
||||
'easyMode.oauth.title': 'OAuth 登录',
|
||||
'easyMode.oauth.description': '通过浏览器授权登录自己账号,并使用账号的额度。',
|
||||
'easyMode.oauth.nextHint': '账号登录完成后,点击页面上方的“下一步”继续。',
|
||||
@@ -52,13 +52,52 @@ export const zhCN = {
|
||||
'easyMode.guide.title': '使用指导',
|
||||
'easyMode.guide.description': '先确定连接方式,完成模型连接后,再从“可用模型”列表中选择智能体要使用的模型。',
|
||||
'easyMode.guide.step1Title': '选择连接方式',
|
||||
'easyMode.guide.step1Description': '可以通过 OAuth 登录自己的账号并使用账号额度,也可以通过 API 接入任意平台。',
|
||||
'easyMode.guide.step1Description': '可选择两种方式接入\n1.OAuth登录自己的订阅账号,使用自己账号的额度\n2.API URL的方式接入',
|
||||
'easyMode.guide.step2Title': '连接模型',
|
||||
'easyMode.guide.step2Description': '这里可以通过 OAuth 完成账号授权,也可以填写第三方平台提供的 Base URL 和 API 密钥。保存后,模型会出现在“可用模型”列表中。',
|
||||
'easyMode.guide.step3Title': '选择模型并接入智能体',
|
||||
'easyMode.guide.step3Description': '在“可用模型”列表中选定模型,再选择已安装的智能体客户端并应用配置。',
|
||||
'easyMode.navigation.back': '上一步',
|
||||
'easyMode.navigation.next': '下一步',
|
||||
'easyMode.status.ready': '已就绪',
|
||||
'easyMode.status.connectedModels': '已检测到 {count} 个可用模型',
|
||||
'easyMode.status.noModelsYet': '尚未连接模型,请先登录账号或添加 API',
|
||||
'easyMode.oauth.loggedIn': '已登录',
|
||||
'easyMode.oauth.relogin': '重新登录',
|
||||
'easyMode.oauth.loggingIn': '等待浏览器授权...',
|
||||
'easyMode.api.quickAdd': '快速接入 API',
|
||||
'easyMode.api.testAndSave': '测试并保存',
|
||||
'easyMode.api.testing': '正在测试连接...',
|
||||
'easyMode.api.fetchModels': '获取模型列表',
|
||||
'easyMode.api.fetchingModels': '正在获取模型列表...',
|
||||
'easyMode.api.customModelId': '自定义 Model ID',
|
||||
'easyMode.api.customModelPlaceholder': '例如:gpt-5.6-sol 或 deepseek-v4-pro',
|
||||
'easyMode.api.addModelId': '添加 Model ID',
|
||||
'easyMode.api.credentialsRequired': '请先填写 API URL 和 API 密钥',
|
||||
'easyMode.api.modelRequired': '请先获取模型列表并选择模型,或手动添加 Model ID',
|
||||
'easyMode.api.testSuccess': '连接成功!发现 {count} 个模型',
|
||||
'easyMode.api.testFailed': '连接测试失败',
|
||||
'easyMode.api.platform': '平台类型',
|
||||
'easyMode.api.baseUrl': 'API Base URL',
|
||||
'easyMode.api.apiKey': 'API 密钥 (API Key)',
|
||||
'easyMode.api.name': '备注名称 (选填)',
|
||||
'easyMode.api.viewExisting': '查看全部已接入列表 ({count})',
|
||||
'easyMode.api.backToQuick': '返回快速接入',
|
||||
'easyMode.api.savedSuccess': 'API 接入已保存!',
|
||||
'easyMode.scenario.title': '选择你的使用方式',
|
||||
'easyMode.scenario.agentTab': '接入 AI 智能体客户端 (推荐)',
|
||||
'easyMode.scenario.apiTab': '复制本地 API 接口地址',
|
||||
'easyMode.agent.installedBadge': '本机已安装',
|
||||
'easyMode.agent.notDetected': '未检测到',
|
||||
'easyMode.agent.chooseModel': '选择使用的模型',
|
||||
'easyMode.agent.applyConfig': '⚡ 一键应用配置',
|
||||
'easyMode.agent.applySuccess': '配置已成功应用!',
|
||||
'easyMode.agent.launchClient': '🚀 启动客户端 / 打开终端',
|
||||
'easyMode.agent.launching': '正在启动...',
|
||||
'easyMode.complete.title': '配置就绪!',
|
||||
'easyMode.complete.enterApp': '进入完整控制台',
|
||||
'easyMode.apiCopy.desc': '你也可以直接将本地代理地址填入任何支持 OpenAI / Claude / Gemini 协议的工具中。',
|
||||
'easyMode.apiCopy.copied': '已复制到剪贴板',
|
||||
'error.render.title': '页面渲染出现异常',
|
||||
'error.unknown': '未知错误',
|
||||
'error.reload': '重新加载页面',
|
||||
|
||||
@@ -629,7 +629,11 @@ function AgentModelPicker({
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentsPage() {
|
||||
type AgentsPageProps = {
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
export function AgentsPage({ embedded = false }: AgentsPageProps = {}) {
|
||||
const { t } = useI18n();
|
||||
const [selected, setSelected] = useState<AgentClientId>(readSelectedAgentClient);
|
||||
const [activeSubpage, setActiveSubpage] = useState<AgentSubpageId>(DEFAULT_AGENT_SUBPAGE);
|
||||
@@ -1442,7 +1446,7 @@ export function AgentsPage() {
|
||||
) : oauthLoginRequiredAction ? t(`agents.oauthLoginRequired.${oauthLoginRequiredAction}Description`) : '';
|
||||
|
||||
return (
|
||||
<section className="page management-page agents-page">
|
||||
<section className={`page management-page agents-page${embedded ? ' agents-page-embedded' : ''}`}>
|
||||
<header className="management-header">
|
||||
<div>
|
||||
<span>Agent Clients</span>
|
||||
|
||||
+678
-154
@@ -1,198 +1,722 @@
|
||||
import { useState } from 'react';
|
||||
import { useI18n } from '../i18n';
|
||||
import { ApiAccessPage } from './ApiAccessPage';
|
||||
import { AgentsPage } from './AgentsPage';
|
||||
import { OAuthLoginPage } from './ManagementPages';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
LoaderCircle,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { useI18n } from "../i18n";
|
||||
import {
|
||||
isRecord,
|
||||
managementApi,
|
||||
readBoolean,
|
||||
readString,
|
||||
responseList,
|
||||
} from "../services/managementApi";
|
||||
import {
|
||||
fetchModels,
|
||||
normalizeBaseUrl,
|
||||
type ModelOption,
|
||||
type ModelProvider,
|
||||
} from "../services/modelService";
|
||||
import { AgentsPage } from "./AgentsPage";
|
||||
|
||||
type AuthMethod = 'oauth' | 'api';
|
||||
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 openaiIcon from "../assets/icons/openai-light.svg";
|
||||
import deepseekIcon from "../assets/icons/deepseek.svg";
|
||||
import geminiIcon from "../assets/icons/gemini.svg";
|
||||
|
||||
type AuthMethod = "oauth" | "api";
|
||||
type SetupStep = 1 | 2;
|
||||
type SetupView = 1 | 'oauth' | 'api' | 'agents';
|
||||
|
||||
const EASY_MODE_STATE_KEY = 'easy-cli-proxy-api.easy-mode.state';
|
||||
type OAuthProviderId = "codex" | "claude" | "antigravity" | "kimi" | "xai";
|
||||
|
||||
type SavedEasyModeState = {
|
||||
authMethod: AuthMethod;
|
||||
providerReturnView: 1 | AuthMethod | null;
|
||||
type OAuthProviderInfo = {
|
||||
id: OAuthProviderId;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const defaultEasyModeState: SavedEasyModeState = {
|
||||
authMethod: 'oauth',
|
||||
providerReturnView: null,
|
||||
const oauthProviders: OAuthProviderInfo[] = [
|
||||
{ id: "codex", name: "Codex OAuth", icon: codexIcon, description: "OpenAI / ChatGPT 账号授权登录" },
|
||||
{ id: "claude", name: "Claude OAuth", icon: claudeIcon, description: "Anthropic / Claude 账号授权登录" },
|
||||
{ id: "antigravity", name: "Antigravity OAuth", icon: antigravityIcon, description: "Antigravity 账号授权登录" },
|
||||
{ id: "kimi", name: "Kimi OAuth", icon: kimiIcon, description: "Moonshot / Kimi 账号授权登录" },
|
||||
{ id: "xai", name: "xAI OAuth", icon: grokIcon, description: "xAI / Grok 账号授权登录" },
|
||||
];
|
||||
|
||||
type ApiSection = "openai-compatibility" | "deepseek" | "claude" | "gemini" | "codex";
|
||||
type ApiManagementSection = "openai-compatibility" | "claude-api-key" | "codex-api-key" | "gemini-api-key";
|
||||
|
||||
type ApiSectionOption = {
|
||||
id: ApiSection;
|
||||
managementSection: ApiManagementSection;
|
||||
name: string;
|
||||
provider: ModelProvider;
|
||||
defaultBaseUrl: string;
|
||||
icon: string;
|
||||
};
|
||||
|
||||
function readSavedState(): SavedEasyModeState {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(EASY_MODE_STATE_KEY);
|
||||
if (!raw) return defaultEasyModeState;
|
||||
const parsed = JSON.parse(raw) as {
|
||||
authMethod?: unknown;
|
||||
providerReturnView?: unknown;
|
||||
};
|
||||
return {
|
||||
authMethod: parsed.authMethod === 'api' ? 'api' : 'oauth',
|
||||
providerReturnView: parsed.providerReturnView === 1
|
||||
|| parsed.providerReturnView === 'oauth'
|
||||
|| parsed.providerReturnView === 'api'
|
||||
? parsed.providerReturnView
|
||||
: null,
|
||||
};
|
||||
} catch {
|
||||
return defaultEasyModeState;
|
||||
}
|
||||
}
|
||||
const apiSectionOptions: ApiSectionOption[] = [
|
||||
{ id: "openai-compatibility", managementSection: "openai-compatibility", name: "OpenAI 格式", provider: "openai", defaultBaseUrl: "", icon: openaiIcon },
|
||||
{ id: "claude", managementSection: "claude-api-key", name: "Anthropic 格式", provider: "claude", defaultBaseUrl: "", icon: claudeIcon },
|
||||
{ id: "codex", managementSection: "codex-api-key", name: "Codex API", provider: "codex", defaultBaseUrl: "", icon: codexIcon },
|
||||
{ id: "gemini", managementSection: "gemini-api-key", name: "Gemini 格式", provider: "gemini", defaultBaseUrl: "", icon: geminiIcon },
|
||||
{ id: "deepseek", managementSection: "openai-compatibility", name: "DeepSeek", provider: "openai", defaultBaseUrl: "https://api.deepseek.com", icon: deepseekIcon },
|
||||
];
|
||||
|
||||
function saveState(state: SavedEasyModeState) {
|
||||
try {
|
||||
window.localStorage.setItem(EASY_MODE_STATE_KEY, JSON.stringify(state));
|
||||
} catch {
|
||||
// Keep the selection in memory when persistent storage is unavailable.
|
||||
}
|
||||
}
|
||||
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() {
|
||||
export function EasyModePage({
|
||||
onExit,
|
||||
}: {
|
||||
onExit?: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const initialState = readSavedState();
|
||||
const [authMethod, setAuthMethod] = useState<AuthMethod>(initialState.authMethod);
|
||||
const [providerReturnView, setProviderReturnView] = useState<SavedEasyModeState['providerReturnView']>(
|
||||
initialState.providerReturnView,
|
||||
);
|
||||
const [view, setView] = useState<SetupView>(1);
|
||||
|
||||
const chooseAuthMethod = (method: AuthMethod) => {
|
||||
setAuthMethod(method);
|
||||
saveState({ authMethod: method, providerReturnView });
|
||||
const [activeStep, setActiveStep] = useState<SetupStep>(1);
|
||||
const [authMethod, setAuthMethod] = useState<AuthMethod>("oauth");
|
||||
|
||||
const [loadingSources, setLoadingSources] = useState(true);
|
||||
const [authFiles, setAuthFiles] = useState<Record<string, unknown>[]>([]);
|
||||
const [apiCounts, setApiCounts] = useState<Record<ApiSection, number>>({
|
||||
"openai-compatibility": 0,
|
||||
deepseek: 0,
|
||||
claude: 0,
|
||||
gemini: 0,
|
||||
codex: 0,
|
||||
});
|
||||
const [availableModels, setAvailableModels] = useState<ModelOption[]>([]);
|
||||
|
||||
const [oauthLoggingIn, setOauthLoggingIn] = useState<OAuthProviderId | null>(null);
|
||||
const [oauthNotice, setOauthNotice] = useState<{
|
||||
tone: "success" | "error" | "info";
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const oauthPollTimer = useRef<number | null>(null);
|
||||
|
||||
const [selectedApiSection, setSelectedApiSection] = useState<ApiSection>("openai-compatibility");
|
||||
const [apiBaseUrl, setApiBaseUrl] = 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 [apiCustomModelId, setApiCustomModelId] = useState("");
|
||||
const [apiTestError, setApiTestError] = useState("");
|
||||
const [apiSaving, setApiSaving] = useState(false);
|
||||
const [apiSaveNotice, setApiSaveNotice] = useState<string | null>(null);
|
||||
|
||||
const refreshSourceStatus = useCallback(async () => {
|
||||
setLoadingSources(true);
|
||||
try {
|
||||
const authFilesPayload = await managementApi.get("/auth-files");
|
||||
const files = responseList(authFilesPayload, "files");
|
||||
setAuthFiles(files);
|
||||
|
||||
const counts: Record<ApiSection, number> = {
|
||||
"openai-compatibility": 0,
|
||||
deepseek: 0,
|
||||
claude: 0,
|
||||
gemini: 0,
|
||||
codex: 0,
|
||||
};
|
||||
const allFetchedModels: ModelOption[] = [];
|
||||
|
||||
const configPayload = await managementApi.get("/config");
|
||||
const recordsBySection: Record<ApiManagementSection, Record<string, unknown>[]> = {
|
||||
"openai-compatibility": responseList(configPayload, "openai-compatibility"),
|
||||
"claude-api-key": responseList(configPayload, "claude-api-key"),
|
||||
"codex-api-key": responseList(configPayload, "codex-api-key"),
|
||||
"gemini-api-key": responseList(configPayload, "gemini-api-key"),
|
||||
};
|
||||
|
||||
for (const section of apiSectionOptions) {
|
||||
const sourceList = recordsBySection[section.managementSection];
|
||||
const list = section.id === "deepseek"
|
||||
? sourceList.filter(isDeepSeekRecord)
|
||||
: section.id === "openai-compatibility"
|
||||
? 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 {
|
||||
setLoadingSources(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshSourceStatus();
|
||||
return () => {
|
||||
if (oauthPollTimer.current) window.clearInterval(oauthPollTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!oauthNotice) return undefined;
|
||||
const timer = window.setTimeout(() => {
|
||||
setOauthNotice(null);
|
||||
}, 4500);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [oauthNotice]);
|
||||
|
||||
const handleStartOAuth = async (provider: OAuthProviderId) => {
|
||||
if (oauthPollTimer.current) window.clearInterval(oauthPollTimer.current);
|
||||
setOauthLoggingIn(provider);
|
||||
setOauthNotice(null);
|
||||
|
||||
try {
|
||||
const result = await invoke<{
|
||||
url?: string;
|
||||
state?: string;
|
||||
opened?: boolean;
|
||||
openError?: string;
|
||||
}>("start_oauth_login", {
|
||||
provider,
|
||||
browser: "default",
|
||||
});
|
||||
|
||||
if (!result.state) {
|
||||
setOauthNotice({
|
||||
tone: "error",
|
||||
message: "获取授权状态失败,请重试",
|
||||
});
|
||||
setOauthLoggingIn(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const stateKey = result.state;
|
||||
oauthPollTimer.current = window.setInterval(async () => {
|
||||
try {
|
||||
const pollRes = await invoke<{ status: string; error?: string }>(
|
||||
"get_oauth_status",
|
||||
{ state: stateKey },
|
||||
);
|
||||
const status = (pollRes.status || "").toLowerCase();
|
||||
if (status === "ok") {
|
||||
if (oauthPollTimer.current) window.clearInterval(oauthPollTimer.current);
|
||||
setOauthLoggingIn(null);
|
||||
setOauthNotice({
|
||||
tone: "success",
|
||||
message: "登录成功!已成功授权并获取账号额度。",
|
||||
});
|
||||
void refreshSourceStatus();
|
||||
} else if (status === "error") {
|
||||
if (oauthPollTimer.current) window.clearInterval(oauthPollTimer.current);
|
||||
setOauthLoggingIn(null);
|
||||
setOauthNotice({
|
||||
tone: "error",
|
||||
message: pollRes.error ? `授权失败: ${pollRes.error}` : "授权失败,请重试",
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
setOauthLoggingIn(null);
|
||||
setOauthNotice({
|
||||
tone: "error",
|
||||
message: String(err),
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleApiSectionChange = (sec: ApiSection) => {
|
||||
setSelectedApiSection(sec);
|
||||
const opt = apiSectionOptions.find((o) => o.id === sec);
|
||||
if (opt) setApiBaseUrl(opt.defaultBaseUrl);
|
||||
setApiTestedModels([]);
|
||||
setApiSelectedModels([]);
|
||||
setApiCustomModelId("");
|
||||
setApiTestError("");
|
||||
setApiSaveNotice(null);
|
||||
};
|
||||
|
||||
const openSelectedProvider = () => {
|
||||
setView(authMethod);
|
||||
};
|
||||
const handleTestApi = async () => {
|
||||
if (!apiBaseUrl.trim()) {
|
||||
setApiTestError("请先填写 API URL");
|
||||
return;
|
||||
}
|
||||
setApiTesting(true);
|
||||
setApiTestError("");
|
||||
setApiTestedModels([]);
|
||||
|
||||
const leaveProviderForAgents = () => {
|
||||
const currentProviderView: 1 | AuthMethod = view === 1 || view === 'oauth' || view === 'api'
|
||||
? view
|
||||
: providerReturnView ?? 1;
|
||||
setProviderReturnView(currentProviderView);
|
||||
saveState({ authMethod, providerReturnView: currentProviderView });
|
||||
setView('agents');
|
||||
};
|
||||
const opt = apiSectionOptions.find((o) => o.id === selectedApiSection);
|
||||
const providerType = opt ? opt.provider : "openai";
|
||||
|
||||
const previousView: SetupView | null = view === 'oauth' || view === 'api'
|
||||
? 1
|
||||
: view === 'agents'
|
||||
? providerReturnView ?? 1
|
||||
: null;
|
||||
const continueSetup = () => {
|
||||
if (view === 1) setView(authMethod);
|
||||
else if (view === 'oauth' || view === 'api') leaveProviderForAgents();
|
||||
};
|
||||
|
||||
const openProgressStep = (step: SetupStep) => {
|
||||
if (step === 1) {
|
||||
setView(view === 'agents' ? providerReturnView ?? 1 : view === 'oauth' || view === 'api' ? view : 1);
|
||||
} else {
|
||||
leaveProviderForAgents();
|
||||
try {
|
||||
const models = await fetchModels(
|
||||
providerType,
|
||||
apiBaseUrl.trim(),
|
||||
apiKey.trim(),
|
||||
undefined,
|
||||
{},
|
||||
10000,
|
||||
);
|
||||
if (models.length > 0) {
|
||||
setApiTestedModels(models);
|
||||
setApiSelectedModels((current) => {
|
||||
const fetchedKeys = new Set(models.map((model) => model.name.trim().toLowerCase()));
|
||||
const customModels = current.filter((model) => !fetchedKeys.has(model.name.trim().toLowerCase()));
|
||||
return [...models, ...customModels];
|
||||
});
|
||||
} else {
|
||||
setApiTestError("未获取到任何模型,请检查 API URL 和 API 密钥");
|
||||
}
|
||||
} catch (err) {
|
||||
setApiTestError(String(err));
|
||||
} finally {
|
||||
setApiTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stepTitles = [
|
||||
t('easyMode.overview.providerTitle'),
|
||||
t('easyMode.overview.agentTitle'),
|
||||
];
|
||||
const handleToggleApiModel = (model: ModelOption) => {
|
||||
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 handleAddCustomModel = () => {
|
||||
const name = apiCustomModelId.trim();
|
||||
if (!name) return;
|
||||
const key = name.toLowerCase();
|
||||
setApiSelectedModels((current) => {
|
||||
if (current.some((model) => model.name.trim().toLowerCase() === key)) return current;
|
||||
const fetchedModel = apiTestedModels.find((model) => model.name.trim().toLowerCase() === key);
|
||||
return [...current, fetchedModel ?? { name }];
|
||||
});
|
||||
setApiCustomModelId("");
|
||||
setApiTestError("");
|
||||
};
|
||||
|
||||
const handleSaveApi = async () => {
|
||||
if (!apiBaseUrl.trim()) {
|
||||
setApiTestError("请先填写 API URL");
|
||||
return;
|
||||
}
|
||||
if (apiSelectedModels.length === 0) {
|
||||
setApiTestError(t("easyMode.api.modelRequired"));
|
||||
return;
|
||||
}
|
||||
setApiSaving(true);
|
||||
setApiSaveNotice(null);
|
||||
setApiTestError("");
|
||||
|
||||
try {
|
||||
const selectedOption = apiSectionOptions.find((option) => option.id === selectedApiSection);
|
||||
const managementSection = selectedOption?.managementSection ?? "openai-compatibility";
|
||||
const configPayload = await managementApi.get("/config");
|
||||
const list = responseList(configPayload, managementSection);
|
||||
const models = apiSelectedModels.map((model) => ({ name: model.name.trim() }));
|
||||
const newEntry = managementSection === "openai-compatibility"
|
||||
? {
|
||||
name: apiRemark.trim() || `${selectedApiSection} (${list.length + 1})`,
|
||||
"base-url": normalizeBaseUrl(apiBaseUrl.trim()),
|
||||
"api-key-entries": [
|
||||
{ "api-key": apiKey.trim() },
|
||||
],
|
||||
models,
|
||||
}
|
||||
: {
|
||||
"api-key": apiKey.trim(),
|
||||
"base-url": normalizeBaseUrl(apiBaseUrl.trim()),
|
||||
models,
|
||||
};
|
||||
|
||||
await managementApi.put(`/${managementSection}`, [...list, newEntry]);
|
||||
setApiSaveNotice("API 接入已保存成功!");
|
||||
void refreshSourceStatus();
|
||||
} catch (err) {
|
||||
setApiTestError(String(err));
|
||||
} finally {
|
||||
setApiSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 displayedApiModels = useMemo(() => {
|
||||
const fetchedKeys = new Set(apiTestedModels.map((model) => model.name.trim().toLowerCase()));
|
||||
return [
|
||||
...apiTestedModels,
|
||||
...apiSelectedModels.filter((model) => !fetchedKeys.has(model.name.trim().toLowerCase())),
|
||||
];
|
||||
}, [apiSelectedModels, apiTestedModels]);
|
||||
|
||||
return (
|
||||
<section className="page simple-mode-page">
|
||||
<div className="simple-mode-progress" aria-label={t('easyMode.steps.label')}>
|
||||
{stepTitles.map((title, index) => {
|
||||
const step = (index + 1) as SetupStep;
|
||||
const active = step === 1
|
||||
? view === 1 || view === 'oauth' || view === 'api'
|
||||
: view === 'agents';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={title}
|
||||
className={`simple-mode-progress-item${active ? ' active' : ''}`}
|
||||
aria-current={active ? 'step' : undefined}
|
||||
onClick={() => openProgressStep(step)}
|
||||
>
|
||||
<span>
|
||||
<strong>{title}</strong>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="simple-mode-flow-navigation">
|
||||
{previousView !== null ? (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={() => setView(previousView)}
|
||||
>
|
||||
{t('easyMode.navigation.back')}
|
||||
</button>
|
||||
) : null}
|
||||
{view !== 'agents' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
onClick={continueSetup}
|
||||
>
|
||||
{t('easyMode.navigation.next')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{view === 'oauth' || view === 'api' ? (
|
||||
<div className="simple-mode-provider-hint" role="note">
|
||||
{t(view === 'oauth' ? 'easyMode.oauth.nextHint' : 'easyMode.api.nextHint')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{view === 1 ? (
|
||||
{/* 第一步:接入模型 */}
|
||||
{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>
|
||||
<h2>{t("easyMode.overview.providerTitle")}</h2>
|
||||
<p>{t("easyMode.setup.providerDescription")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="simple-mode-choice-grid">
|
||||
<button
|
||||
type="button"
|
||||
className={`simple-mode-choice${authMethod === 'oauth' ? ' selected' : ''}`}
|
||||
aria-pressed={authMethod === 'oauth'}
|
||||
onClick={() => chooseAuthMethod('oauth')}
|
||||
className={`simple-mode-choice${authMethod === "oauth" ? " selected" : ""}`}
|
||||
aria-pressed={authMethod === "oauth"}
|
||||
onClick={() => setAuthMethod("oauth")}
|
||||
>
|
||||
<strong>{t('easyMode.oauth.title')}</strong>
|
||||
<span>{t('easyMode.oauth.description')}</span>
|
||||
<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" style={{ fontSize: "12px" }}>
|
||||
已登录 {totalLoggedInOAuth} 个账号
|
||||
</span>
|
||||
) : (
|
||||
<span className="state-pill neutral" style={{ fontSize: "12px" }}>推荐</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span>{t("easyMode.oauth.description")}</span>
|
||||
<small>支持 Codex、Claude、Antigravity、Kimi、xAI</small>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`simple-mode-choice${authMethod === 'api' ? ' selected' : ''}`}
|
||||
aria-pressed={authMethod === 'api'}
|
||||
onClick={() => chooseAuthMethod('api')}
|
||||
className={`simple-mode-choice${authMethod === "api" ? " selected" : ""}`}
|
||||
aria-pressed={authMethod === "api"}
|
||||
onClick={() => setAuthMethod("api")}
|
||||
>
|
||||
<strong>{t('easyMode.api.title')}</strong>
|
||||
<span>{t('easyMode.api.description')}</span>
|
||||
<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" style={{ fontSize: "12px" }}>
|
||||
已接入 {totalApiProviders} 个平台
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<span>{t("easyMode.api.description")}</span>
|
||||
<small>支持 OpenAI、Anthropic、Codex、Gemini、DeepSeek</small>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{authMethod === "oauth" ? (
|
||||
<div className="simple-mode-embedded-box">
|
||||
{oauthNotice ? (
|
||||
<div
|
||||
className={`config-toast ${oauthNotice.tone}`}
|
||||
>
|
||||
{oauthNotice.tone === "success" ? <CheckCircle2 size={16} /> : <AlertCircle size={16} />}
|
||||
<span>{oauthNotice.message}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<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={`client-api-card simple-mode-provider-card${loggedIn ? " connected" : ""}`}
|
||||
>
|
||||
<div className="simple-mode-provider-card-head">
|
||||
<span className="client-api-logo simple-mode-provider-logo">
|
||||
<img src={provider.icon} alt="" />
|
||||
</span>
|
||||
<div className="simple-mode-provider-copy">
|
||||
<strong>{provider.name}</strong>
|
||||
<span>
|
||||
{provider.description}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="simple-mode-provider-card-foot">
|
||||
{loggedIn ? (
|
||||
<span className="state-pill success" style={{ fontSize: "12px" }}>
|
||||
<Check size={13} style={{ marginRight: 4 }} />
|
||||
{t("easyMode.oauth.loggedIn")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="state-pill neutral" style={{ fontSize: "12px" }}>未授权</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")
|
||||
) : (
|
||||
"开始登录"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{authMethod === "api" ? (
|
||||
<div className="simple-mode-embedded-box">
|
||||
{apiSaveNotice ? (
|
||||
<div
|
||||
className="config-toast success"
|
||||
style={{ position: "static", transform: "none", margin: 0 }}
|
||||
>
|
||||
<CheckCircle2 size={16} />
|
||||
<span>{apiSaveNotice}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{apiTestError ? (
|
||||
<div
|
||||
className="config-toast error"
|
||||
style={{ position: "static", transform: "none", margin: 0 }}
|
||||
>
|
||||
<AlertCircle size={16} />
|
||||
<span>{apiTestError}</span>
|
||||
</div>
|
||||
) : 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}
|
||||
</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)}
|
||||
placeholder="例如:主力 DeepSeek V3、中转 API"
|
||||
/>
|
||||
</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)}
|
||||
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)}
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="simple-mode-api-model-card">
|
||||
<div className="simple-mode-api-model-toolbar">
|
||||
<input
|
||||
type="text"
|
||||
className="text-input"
|
||||
value={apiCustomModelId}
|
||||
onChange={(e) => setApiCustomModelId(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleAddCustomModel();
|
||||
}
|
||||
}}
|
||||
placeholder={t("easyMode.api.customModelPlaceholder")}
|
||||
aria-label={t("easyMode.api.customModelId")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
disabled={!apiCustomModelId.trim()}
|
||||
onClick={handleAddCustomModel}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("easyMode.api.addModelId")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
disabled={apiTesting || !apiBaseUrl.trim()}
|
||||
onClick={() => void handleTestApi()}
|
||||
>
|
||||
{apiTesting ? <LoaderCircle size={14} className="spin" /> : <RefreshCw size={14} />}
|
||||
{apiTesting ? t("easyMode.api.fetchingModels") : t("easyMode.api.fetchModels")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{displayedApiModels.length > 0 ? (
|
||||
<div className="simple-mode-api-model-options">
|
||||
{displayedApiModels.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}
|
||||
onChange={() => handleToggleApiModel(model)}
|
||||
/>
|
||||
<span title={model.name}>{model.name}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
|
||||
<div className="simple-mode-api-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
disabled={apiSaving || !apiBaseUrl.trim() || apiSelectedModels.length === 0}
|
||||
onClick={() => void handleSaveApi()}
|
||||
>
|
||||
{apiSaving ? <LoaderCircle size={14} className="spin" /> : <Plus size={14} />}
|
||||
保存并接入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="simple-mode-task-footer" style={{ marginTop: 10 }}>
|
||||
<div className="simple-mode-selection">
|
||||
{hasConnectedSource ? (
|
||||
<span style={{ color: "var(--ui-accent-strong)", fontWeight: 600 }}>
|
||||
{t("easyMode.status.connectedModels", { count: Math.max(availableModels.length, 1) })}
|
||||
</span>
|
||||
) : (
|
||||
<span className="muted">{t("easyMode.status.noModelsYet")}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
style={{ minHeight: 42, padding: "0 22px", fontSize: "15px" }}
|
||||
disabled={!hasConnectedSource}
|
||||
onClick={() => setActiveStep(2)}
|
||||
>
|
||||
{t("easyMode.navigation.next")}: 配置智能体
|
||||
<ArrowRight size={16} style={{ marginLeft: 6 }} />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{view === 'oauth' || view === 'api' ? (
|
||||
<section className="panel simple-mode-embedded-task">
|
||||
<div className="simple-mode-embedded-content">
|
||||
{view === 'oauth' ? <OAuthLoginPage /> : <ApiAccessPage />}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
{/* 第二步:接入智能体 */}
|
||||
{activeStep === 2 ? (
|
||||
<section className="panel simple-mode-task">
|
||||
<AgentsPage embedded />
|
||||
|
||||
{view === 'agents' ? (
|
||||
<section className="panel simple-mode-embedded-task">
|
||||
<div className="simple-mode-embedded-content">
|
||||
<AgentsPage />
|
||||
<div className="simple-mode-task-footer" style={{ marginTop: 14 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={() => setActiveStep(1)}
|
||||
>
|
||||
<ArrowLeft size={16} style={{ marginRight: 6 }} />
|
||||
{t("easyMode.navigation.back")}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
style={{ minHeight: 42, padding: "0 22px", fontSize: "15px" }}
|
||||
onClick={() => onExit?.()}
|
||||
>
|
||||
<CheckCircle2 size={16} style={{ marginRight: 6 }} />
|
||||
{t("easyMode.complete.enterApp")}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
+1012
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user