Spaces:
Sleeping
Sleeping
| import { lazy, Suspense, useEffect, useMemo, useState } from "react"; | |
| import { Alert, Button, Form, InputNumber, Layout, Menu, Segmented, Select, Space, Spin, Switch, Tabs, Tag, Tooltip, Typography, message } from "antd"; | |
| import { | |
| ApiOutlined, | |
| AppstoreOutlined, | |
| BugOutlined, | |
| DatabaseOutlined, | |
| RadarChartOutlined, | |
| SettingOutlined, | |
| MenuFoldOutlined, | |
| MenuUnfoldOutlined, | |
| } from "@ant-design/icons"; | |
| import { PoolEditorDrawer, PoolImportModal, ProbeLogModal } from "./components/UiShared.jsx"; | |
| import { | |
| API_POOL_SUBTABS, | |
| TOOL_ORDER, | |
| buildPreview, | |
| collectDefaults, | |
| copyPoolItem, | |
| friendlyToolName, | |
| formatTime, | |
| inferScheduledSwitchPreset, | |
| makeNewPoolItem, | |
| presetValueToIntervalMs, | |
| SCHEDULED_SWITCH_PRESET_OPTIONS, | |
| summarizeApiPoolEndpoints, | |
| summarizeProxyAccounts, | |
| } from "./view-helpers.js"; | |
| const PoolManagePage = lazy(() => import("./pages/PoolManagePage.jsx")); | |
| const ProxyPage = lazy(() => import("./pages/ProxyPage.jsx")); | |
| const ProbePage = lazy(() => import("./pages/ProbePage.jsx")); | |
| const ConfigSwitchPage = lazy(() => import("./pages/ConfigSwitchPage.jsx")); | |
| const RemoteServicePage = lazy(() => import("./pages/RemoteServicePage.jsx")); | |
| const { Header, Sider, Content } = Layout; | |
| const { Title, Text } = Typography; | |
| function iconForTool(toolId) { | |
| if (toolId === "pool.manage") return <DatabaseOutlined />; | |
| if (toolId === "api-pool.start") return <ApiOutlined />; | |
| if (toolId === "proxy.start") return <RadarChartOutlined />; | |
| if (toolId === "llm.probe") return <BugOutlined />; | |
| if (toolId === "config.switch") return <SettingOutlined />; | |
| return <AppstoreOutlined />; | |
| } | |
| function contentFallback() { | |
| return ( | |
| <div className="page-loading"> | |
| <Spin size="large" /> | |
| </div> | |
| ); | |
| } | |
| function inferApiBase() { | |
| if (typeof window !== "undefined" && window.location.pathname.startsWith("/admin")) { | |
| return "/admin/api"; | |
| } | |
| return "/api"; | |
| } | |
| function mergeSecretPreservingDraft(poolId, previous, next) { | |
| if (!previous) return copyPoolItem(next); | |
| if (poolId === "codex-accounts") { | |
| return { | |
| ...previous, | |
| ...next, | |
| OPENAI_API_KEY: | |
| next?.OPENAI_API_KEY || previous?.OPENAI_API_KEY || "", | |
| OPENAI_API_KEY_MASKED: | |
| previous?.OPENAI_API_KEY_MASKED || next?.OPENAI_API_KEY_MASKED || "", | |
| tokens: { | |
| ...(previous?.tokens || {}), | |
| ...(next?.tokens || {}), | |
| access_token: | |
| next?.tokens?.access_token || previous?.tokens?.access_token || "", | |
| id_token: | |
| next?.tokens?.id_token || previous?.tokens?.id_token || "", | |
| refresh_token: | |
| next?.tokens?.refresh_token || previous?.tokens?.refresh_token || "", | |
| access_token_masked: | |
| previous?.tokens?.access_token_masked || next?.tokens?.access_token_masked || "", | |
| id_token_masked: | |
| previous?.tokens?.id_token_masked || next?.tokens?.id_token_masked || "", | |
| refresh_token_masked: | |
| previous?.tokens?.refresh_token_masked || next?.tokens?.refresh_token_masked || "", | |
| }, | |
| }; | |
| } | |
| return { | |
| ...previous, | |
| ...next, | |
| apiKey: next?.apiKey || previous?.apiKey || "", | |
| apiKeyMasked: previous?.apiKeyMasked || next?.apiKeyMasked || "", | |
| }; | |
| } | |
| export default function App() { | |
| const defaultApiBase = inferApiBase(); | |
| const [messageApi, contextHolder] = message.useMessage(); | |
| const [tools, setTools] = useState([]); | |
| const [activeTab, setActiveTab] = useState("pool.manage"); | |
| const [appConfig, setAppConfig] = useState({ | |
| mode: "local", | |
| apiBase: defaultApiBase, | |
| environment: "Local Node + React", | |
| user: null, | |
| readOnly: false, | |
| readOnlyReason: "", | |
| }); | |
| const [forms, setForms] = useState({}); | |
| const [history, setHistory] = useState([]); | |
| const [runState, setRunState] = useState({}); | |
| const [proxyState, setProxyState] = useState({ running: false, recentLogs: [] }); | |
| const [activeApiPoolSubTab, setActiveApiPoolSubTab] = useState("codex"); | |
| const [apiPoolStateCodex, setApiPoolStateCodex] = useState({ running: false, recentLogs: [] }); | |
| const [apiPoolStateClaude, setApiPoolStateClaude] = useState({ running: false, recentLogs: [] }); | |
| const [apiPoolRemoteConfig, setApiPoolRemoteConfig] = useState({ | |
| enableScheduledSwitch: true, | |
| scheduledSwitchIntervalMs: 900000, | |
| scheduledSwitchPreset: "custom", | |
| }); | |
| const [pools, setPools] = useState({}); | |
| const [activePoolCategory, setActivePoolCategory] = useState("accounts"); | |
| const [activePoolId, setActivePoolId] = useState("codex-accounts"); | |
| const [editingPool, setEditingPool] = useState(null); | |
| const [editingDraft, setEditingDraft] = useState(null); | |
| const [poolValidationErrors, setPoolValidationErrors] = useState([]); | |
| const [poolSaveBusy, setPoolSaveBusy] = useState(false); | |
| const [poolProbeModal, setPoolProbeModal] = useState({ | |
| open: false, | |
| title: "", | |
| runId: null, | |
| status: "", | |
| logs: [], | |
| error: "", | |
| }); | |
| const [poolImportModal, setPoolImportModal] = useState({ | |
| open: false, | |
| poolId: "", | |
| text: "", | |
| busy: false, | |
| error: "", | |
| }); | |
| const [busy, setBusy] = useState({}); | |
| const [errors, setErrors] = useState({}); | |
| const [navCollapsed, setNavCollapsed] = useState(true); | |
| const apiBase = appConfig.apiBase || defaultApiBase; | |
| const isRemoteMode = appConfig.mode === "remote"; | |
| function apiPath(relativePath) { | |
| return `${apiBase}${relativePath}`; | |
| } | |
| async function loadPool(poolId) { | |
| const response = await fetch(apiPath(`/pools/${poolId}`)); | |
| const payload = await response.json(); | |
| if (!response.ok) throw new Error(payload.error || "加载池失败"); | |
| setPools((current) => ({ ...current, [poolId]: payload })); | |
| return payload; | |
| } | |
| useEffect(() => { | |
| let cancelled = false; | |
| async function loadInitialData() { | |
| const baseRequests = [ | |
| fetch(`${defaultApiBase}/app-config`), | |
| fetch(`${defaultApiBase}/tools`), | |
| fetch(`${defaultApiBase}/history`), | |
| fetch(`${defaultApiBase}/proxy/status`), | |
| fetch(`${defaultApiBase}/api-pool/codex/status`), | |
| fetch(`${defaultApiBase}/api-pool/claude-code/status`), | |
| fetch(`${defaultApiBase}/pools`), | |
| ]; | |
| const remoteConfigRequest = defaultApiBase.startsWith("/admin") | |
| ? fetch(`${defaultApiBase}/api-pool/config`) | |
| : Promise.resolve(null); | |
| const [configRes, toolsRes, historyRes, proxyRes, apiPoolCodexRes, apiPoolClaudeRes, poolsRes, apiPoolConfigRes] = | |
| await Promise.all([ | |
| ...baseRequests, | |
| remoteConfigRequest, | |
| ]); | |
| const configData = await configRes.json(); | |
| const toolsData = await toolsRes.json(); | |
| const historyData = await historyRes.json(); | |
| const proxyData = await proxyRes.json(); | |
| const apiPoolCodexData = await apiPoolCodexRes.json(); | |
| const apiPoolClaudeData = await apiPoolClaudeRes.json(); | |
| const poolsData = await poolsRes.json(); | |
| const apiPoolConfigData = apiPoolConfigRes ? await apiPoolConfigRes.json() : null; | |
| const poolDetails = await Promise.all( | |
| (poolsData.items || []).map((item) => | |
| fetch(`${defaultApiBase}/pools/${item.id}`).then((res) => res.json()), | |
| ), | |
| ); | |
| if (cancelled) return; | |
| const sortedTools = [...toolsData.tools].sort( | |
| (left, right) => TOOL_ORDER.indexOf(left.id) - TOOL_ORDER.indexOf(right.id), | |
| ); | |
| const baseForms = collectDefaults(sortedTools); | |
| if (baseForms["api-pool.start"]) { | |
| baseForms["api-pool.start"] = { | |
| ...baseForms["api-pool.start"], | |
| provider: "codex", | |
| port: 8790, | |
| poolDir: "api_pool/codex", | |
| scheduledSwitchPreset: inferScheduledSwitchPreset( | |
| baseForms["api-pool.start"].scheduledSwitchIntervalMs, | |
| ), | |
| }; | |
| } | |
| const nextPools = {}; | |
| for (const item of poolDetails) { | |
| nextPools[item.pool.id] = item; | |
| } | |
| setAppConfig((current) => ({ | |
| ...current, | |
| ...configData, | |
| apiBase: configData.apiBase || defaultApiBase, | |
| })); | |
| setTools(sortedTools); | |
| setForms(baseForms); | |
| setHistory(historyData.items || []); | |
| setProxyState(proxyData); | |
| setApiPoolStateCodex(apiPoolCodexData); | |
| setApiPoolStateClaude(apiPoolClaudeData); | |
| if (apiPoolConfigData && !apiPoolConfigData.error) { | |
| setApiPoolRemoteConfig({ | |
| ...apiPoolConfigData, | |
| scheduledSwitchPreset: inferScheduledSwitchPreset( | |
| apiPoolConfigData.scheduledSwitchIntervalMs, | |
| ), | |
| }); | |
| } | |
| setPools(nextPools); | |
| } | |
| loadInitialData().catch((error) => { | |
| if (!cancelled) setErrors((current) => ({ ...current, global: error.message })); | |
| }); | |
| return () => { | |
| cancelled = true; | |
| }; | |
| }, []); | |
| useEffect(() => { | |
| const timer = setInterval(async () => { | |
| if (isRemoteMode) { | |
| const [proxyRes, apiPoolCodexRes, apiPoolClaudeRes] = await Promise.all([ | |
| fetch(apiPath("/proxy/status")), | |
| fetch(apiPath("/api-pool/codex/status")), | |
| fetch(apiPath("/api-pool/claude-code/status")), | |
| ]); | |
| setProxyState(await proxyRes.json()); | |
| setApiPoolStateCodex(await apiPoolCodexRes.json()); | |
| setApiPoolStateClaude(await apiPoolClaudeRes.json()); | |
| return; | |
| } | |
| const activeRuns = Object.entries(runState).filter(([, value]) => | |
| value?.runId && (value.status === "queued" || value.status === "running"), | |
| ); | |
| for (const [toolId, item] of activeRuns) { | |
| const [runRes, logsRes] = await Promise.all([ | |
| fetch(apiPath(`/runs/${item.runId}`)), | |
| fetch(apiPath(`/runs/${item.runId}/logs`)), | |
| ]); | |
| const runData = await runRes.json(); | |
| const logsData = await logsRes.json(); | |
| setRunState((current) => ({ | |
| ...current, | |
| [toolId]: { | |
| ...current[toolId], | |
| status: runData.run.status, | |
| exitCode: runData.run.exitCode, | |
| error: runData.run.error, | |
| logs: logsData.logs || [], | |
| }, | |
| })); | |
| if (runData.run.status === "succeeded" || runData.run.status === "failed") { | |
| const historyRes = await fetch(apiPath("/history")); | |
| const historyData = await historyRes.json(); | |
| setHistory(historyData.items || []); | |
| } | |
| } | |
| const [proxyRes, apiPoolCodexRes, apiPoolClaudeRes] = await Promise.all([ | |
| fetch(apiPath("/proxy/status")), | |
| fetch(apiPath("/api-pool/codex/status")), | |
| fetch(apiPath("/api-pool/claude-code/status")), | |
| ]); | |
| setProxyState(await proxyRes.json()); | |
| setApiPoolStateCodex(await apiPoolCodexRes.json()); | |
| setApiPoolStateClaude(await apiPoolClaudeRes.json()); | |
| if ( | |
| poolProbeModal.open && | |
| poolProbeModal.runId && | |
| (poolProbeModal.status === "queued" || poolProbeModal.status === "running") | |
| ) { | |
| const [runRes, logsRes] = await Promise.all([ | |
| fetch(apiPath(`/runs/${poolProbeModal.runId}`)), | |
| fetch(apiPath(`/runs/${poolProbeModal.runId}/logs`)), | |
| ]); | |
| const runData = await runRes.json(); | |
| const logsData = await logsRes.json(); | |
| setPoolProbeModal((current) => ({ | |
| ...current, | |
| status: runData.run.status, | |
| error: runData.run.error || "", | |
| logs: logsData.logs || [], | |
| })); | |
| } | |
| }, 1500); | |
| return () => clearInterval(timer); | |
| }, [apiBase, isRemoteMode, runState, poolProbeModal.open, poolProbeModal.runId, poolProbeModal.status]); | |
| const activeTool = tools.find((tool) => tool.id === activeTab); | |
| const activeForm = forms[activeTab] || {}; | |
| const activeRun = runState[activeTab] || { logs: [] }; | |
| const currentApiPoolState = | |
| activeApiPoolSubTab === "claude-code" ? apiPoolStateClaude : apiPoolStateCodex; | |
| const apiPoolEndpoints = summarizeApiPoolEndpoints(currentApiPoolState); | |
| const activeApiPoolEndpoint = currentApiPoolState?.proxyStatus?.body?.active || null; | |
| const apiPoolSchedule = currentApiPoolState?.proxyStatus?.body || {}; | |
| const proxyAccounts = summarizeProxyAccounts(proxyState); | |
| const activeProxyAccount = proxyState?.proxyStatus?.body?.active || null; | |
| const activeHistory = history | |
| .filter((item) => { | |
| if (activeTab === "pool.manage") return false; | |
| if (activeTab === "api-pool.start") { | |
| return ( | |
| item.toolId === "api-pool.start" && | |
| String(item.paramsSummary || "").includes(`provider=${activeApiPoolSubTab}`) | |
| ); | |
| } | |
| return item.toolId === activeTab; | |
| }) | |
| .slice(0, 8); | |
| const menuItems = useMemo( | |
| () => | |
| tools.map((tool) => ({ | |
| key: tool.id, | |
| icon: iconForTool(tool.id), | |
| label: tool.tabTitle, | |
| })), | |
| [tools], | |
| ); | |
| function updateField(toolId, fieldName, value) { | |
| if (toolId === "api-pool.start" && fieldName === "provider") { | |
| const nextPoolDir = value === "claude-code" ? "api_pool/claude-code" : "api_pool/codex"; | |
| setForms((current) => ({ | |
| ...current, | |
| [toolId]: { | |
| ...current[toolId], | |
| provider: value, | |
| poolDir: nextPoolDir, | |
| }, | |
| })); | |
| return; | |
| } | |
| setForms((current) => ({ | |
| ...current, | |
| [toolId]: { | |
| ...current[toolId], | |
| [fieldName]: value, | |
| }, | |
| })); | |
| } | |
| async function refreshHistory() { | |
| const response = await fetch(apiPath("/history")); | |
| const data = await response.json(); | |
| setHistory(data.items || []); | |
| } | |
| async function runTool(tool) { | |
| setBusy((current) => ({ ...current, [tool.id]: true })); | |
| setErrors((current) => ({ ...current, [tool.id]: "" })); | |
| try { | |
| const response = await fetch(apiPath("/runs"), { | |
| method: "POST", | |
| headers: { "content-type": "application/json" }, | |
| body: JSON.stringify({ | |
| toolId: tool.id, | |
| params: forms[tool.id] || {}, | |
| confirmed: false, | |
| }), | |
| }); | |
| const payload = await response.json(); | |
| if (!response.ok) throw new Error(payload.error || "运行失败"); | |
| setRunState((current) => ({ | |
| ...current, | |
| [tool.id]: { | |
| toolId: tool.id, | |
| runId: payload.runId, | |
| status: payload.status, | |
| logs: [], | |
| exitCode: null, | |
| error: null, | |
| }, | |
| })); | |
| await refreshHistory(); | |
| } catch (error) { | |
| setErrors((current) => ({ ...current, [tool.id]: error.message })); | |
| } finally { | |
| setBusy((current) => ({ ...current, [tool.id]: false })); | |
| } | |
| } | |
| async function startProxy() { | |
| setBusy((current) => ({ ...current, "proxy.start": true })); | |
| setErrors((current) => ({ ...current, "proxy.start": "" })); | |
| try { | |
| const response = await fetch(apiPath("/proxy/start"), { | |
| method: "POST", | |
| headers: { "content-type": "application/json" }, | |
| body: JSON.stringify({ params: forms["proxy.start"] || {} }), | |
| }); | |
| const payload = await response.json(); | |
| if (!response.ok) throw new Error(payload.error || "启动代理失败"); | |
| setProxyState(payload.status); | |
| await refreshHistory(); | |
| } catch (error) { | |
| setErrors((current) => ({ ...current, "proxy.start": error.message })); | |
| } finally { | |
| setBusy((current) => ({ ...current, "proxy.start": false })); | |
| } | |
| } | |
| async function stopProxy() { | |
| setBusy((current) => ({ ...current, "proxy.start": true })); | |
| try { | |
| const response = await fetch(apiPath("/proxy/stop"), { method: "POST" }); | |
| const payload = await response.json(); | |
| if (!response.ok) throw new Error(payload.error || "停止代理失败"); | |
| setProxyState(payload.status); | |
| await refreshHistory(); | |
| } catch (error) { | |
| setErrors((current) => ({ ...current, "proxy.start": error.message })); | |
| } finally { | |
| setBusy((current) => ({ ...current, "proxy.start": false })); | |
| } | |
| } | |
| async function startApiPoolProxy(provider) { | |
| setBusy((current) => ({ ...current, "api-pool.start": true })); | |
| setErrors((current) => ({ ...current, "api-pool.start": "" })); | |
| try { | |
| const params = { | |
| ...(forms["api-pool.start"] || {}), | |
| provider, | |
| port: provider === "claude-code" ? 8789 : 8790, | |
| poolDir: provider === "claude-code" ? "api_pool/claude-code" : "api_pool/codex", | |
| }; | |
| const response = await fetch(apiPath("/api-pool/start"), { | |
| method: "POST", | |
| headers: { "content-type": "application/json" }, | |
| body: JSON.stringify({ params }), | |
| }); | |
| const payload = await response.json(); | |
| if (!response.ok) throw new Error(payload.error || "启动 API 池代理失败"); | |
| if (provider === "claude-code") setApiPoolStateClaude(payload.status); | |
| else setApiPoolStateCodex(payload.status); | |
| await refreshHistory(); | |
| } catch (error) { | |
| setErrors((current) => ({ ...current, "api-pool.start": error.message })); | |
| } finally { | |
| setBusy((current) => ({ ...current, "api-pool.start": false })); | |
| } | |
| } | |
| async function stopApiPoolProxy(provider) { | |
| setBusy((current) => ({ ...current, "api-pool.start": true })); | |
| try { | |
| const response = await fetch(apiPath("/api-pool/stop"), { | |
| method: "POST", | |
| headers: { "content-type": "application/json" }, | |
| body: JSON.stringify({ params: { provider } }), | |
| }); | |
| const payload = await response.json(); | |
| if (!response.ok) throw new Error(payload.error || "停止 API 池代理失败"); | |
| if (provider === "claude-code") setApiPoolStateClaude(payload.status); | |
| else setApiPoolStateCodex(payload.status); | |
| await refreshHistory(); | |
| } catch (error) { | |
| setErrors((current) => ({ ...current, "api-pool.start": error.message })); | |
| } finally { | |
| setBusy((current) => ({ ...current, "api-pool.start": false })); | |
| } | |
| } | |
| async function loadPoolAndStore(poolId) { | |
| try { | |
| const payload = await loadPool(poolId); | |
| const poolLabel = payload?.pool?.label || pools[poolId]?.pool?.label || "当前池"; | |
| messageApi.success(`${poolLabel} 已重新加载`); | |
| } catch (error) { | |
| setErrors((current) => ({ ...current, poolManage: error.message })); | |
| } | |
| } | |
| function switchApiPoolSubTab(nextTab) { | |
| setActiveApiPoolSubTab(nextTab); | |
| setForms((current) => ({ | |
| ...current, | |
| "api-pool.start": { | |
| ...(current["api-pool.start"] || {}), | |
| provider: nextTab, | |
| port: nextTab === "claude-code" ? 8789 : 8790, | |
| poolDir: nextTab === "claude-code" ? "api_pool/claude-code" : "api_pool/codex", | |
| }, | |
| })); | |
| } | |
| function openEditor(poolId, index = null) { | |
| const poolData = pools[poolId]; | |
| const item = index == null ? makeNewPoolItem(poolId) : copyPoolItem(poolData.items[index]); | |
| setEditingPool({ poolId, index }); | |
| setEditingDraft(item); | |
| } | |
| async function persistPoolItems(poolId, items, successMessage = "已保存") { | |
| setPoolSaveBusy(true); | |
| setPoolValidationErrors([]); | |
| setErrors((current) => ({ ...current, poolManage: "" })); | |
| try { | |
| const validationRes = await fetch(apiPath(`/pools/${poolId}/validate`), { | |
| method: "POST", | |
| headers: { "content-type": "application/json" }, | |
| body: JSON.stringify({ items }), | |
| }); | |
| const validation = await validationRes.json(); | |
| if (!validationRes.ok) { | |
| setPoolValidationErrors(validation.errors || []); | |
| throw new Error("校验未通过,请先修正条目。"); | |
| } | |
| const saveRes = await fetch(apiPath(`/pools/${poolId}`), { | |
| method: "PUT", | |
| headers: { "content-type": "application/json" }, | |
| body: JSON.stringify({ items }), | |
| }); | |
| const saved = await saveRes.json(); | |
| if (!saveRes.ok) throw new Error(saved.error || "保存失败"); | |
| setPools((current) => ({ ...current, [poolId]: saved })); | |
| const poolLabel = saved?.pool?.label || pools[poolId]?.pool?.label || "当前池"; | |
| if (successMessage) { | |
| messageApi.success(`${poolLabel}${successMessage}`); | |
| } | |
| return saved; | |
| } catch (error) { | |
| setErrors((current) => ({ ...current, poolManage: error.message })); | |
| throw error; | |
| } finally { | |
| setPoolSaveBusy(false); | |
| } | |
| } | |
| async function applyDraft(values) { | |
| if (!editingPool) return; | |
| const { poolId, index } = editingPool; | |
| const target = pools[poolId]; | |
| const items = [...(target?.items || [])]; | |
| if (index == null) { | |
| items.push(copyPoolItem(values)); | |
| } else { | |
| const previous = items[index]; | |
| items[index] = mergeSecretPreservingDraft(poolId, previous, values); | |
| } | |
| await persistPoolItems(poolId, items, index == null ? " 已新增" : " 已更新"); | |
| setEditingPool(null); | |
| setEditingDraft(null); | |
| } | |
| async function deletePoolItem(poolId, index) { | |
| const target = pools[poolId]; | |
| const items = [...(target?.items || [])]; | |
| items.splice(index, 1); | |
| await persistPoolItems(poolId, items, " 已更新"); | |
| } | |
| async function probePoolItem(poolId, index) { | |
| const item = pools[poolId]?.items?.[index]; | |
| if (!item?.baseUrl || !item?.apiKey) { | |
| setPoolProbeModal({ | |
| open: true, | |
| title: "LLM 探测日志", | |
| runId: null, | |
| status: "failed", | |
| logs: [], | |
| error: "该条目缺少 baseUrl 或 apiKey,无法探测。", | |
| }); | |
| return; | |
| } | |
| try { | |
| const response = await fetch(apiPath("/runs"), { | |
| method: "POST", | |
| headers: { "content-type": "application/json" }, | |
| body: JSON.stringify({ | |
| toolId: "llm.probe", | |
| params: { | |
| baseUrl: item.baseUrl, | |
| key: item.apiKey, | |
| skipAnthropic: false, | |
| skipOpenAI: false, | |
| skipPublic: false, | |
| }, | |
| confirmed: false, | |
| }), | |
| }); | |
| const payload = await response.json(); | |
| if (!response.ok) { | |
| throw new Error(payload.error || "启动 LLM 探测失败"); | |
| } | |
| setPoolProbeModal({ | |
| open: true, | |
| title: `${item.name || item.baseUrl} · LLM 探测日志`, | |
| runId: payload.runId, | |
| status: payload.status, | |
| logs: [], | |
| error: "", | |
| }); | |
| } catch (error) { | |
| setPoolProbeModal({ | |
| open: true, | |
| title: `${item.name || item.baseUrl} · LLM 探测日志`, | |
| runId: null, | |
| status: "failed", | |
| logs: [], | |
| error: error.message, | |
| }); | |
| } | |
| } | |
| async function savePool(poolId) { | |
| const items = pools[poolId]?.items || []; | |
| await persistPoolItems(poolId, items, " 已保存"); | |
| } | |
| async function updatePoolItemFromLocalAuth(poolId, index) { | |
| setErrors((current) => ({ ...current, poolManage: "" })); | |
| try { | |
| const response = await fetch(apiPath(`/pools/${poolId}/${index}/update-local-token`), { | |
| method: "POST", | |
| headers: { "content-type": "application/json" }, | |
| body: JSON.stringify({}), | |
| }); | |
| const payload = await response.json(); | |
| if (!response.ok) throw new Error(payload.error || "更新本地 token 失败"); | |
| setPools((current) => ({ ...current, [poolId]: payload })); | |
| const targetAccount = payload?.items?.[index]; | |
| messageApi.success( | |
| `已从 ~/.codex/auth.json 更新 ${targetAccount?.email || targetAccount?.tokens?.account_id || "当前条目"} 的 token`, | |
| ); | |
| } catch (error) { | |
| setErrors((current) => ({ ...current, poolManage: error.message })); | |
| } | |
| } | |
| function openImportPool(poolId) { | |
| setPoolImportModal({ | |
| open: true, | |
| poolId, | |
| text: "", | |
| busy: false, | |
| error: "", | |
| }); | |
| } | |
| async function importPool() { | |
| if (!poolImportModal.poolId) return; | |
| setPoolImportModal((current) => ({ ...current, busy: true, error: "" })); | |
| try { | |
| const parsed = JSON.parse(poolImportModal.text || "[]"); | |
| if (!Array.isArray(parsed)) { | |
| throw new Error("导入内容必须是 JSON 数组。"); | |
| } | |
| const response = await fetch(apiPath(`/pools/${poolImportModal.poolId}/import`), { | |
| method: "POST", | |
| headers: { "content-type": "application/json" }, | |
| body: JSON.stringify({ items: parsed }), | |
| }); | |
| const payload = await response.json(); | |
| if (!response.ok) throw new Error(payload.error || "导入失败"); | |
| setPools((current) => ({ ...current, [poolImportModal.poolId]: payload })); | |
| setPoolImportModal({ | |
| open: false, | |
| poolId: "", | |
| text: "", | |
| busy: false, | |
| error: "", | |
| }); | |
| } catch (error) { | |
| setPoolImportModal((current) => ({ ...current, busy: false, error: error.message })); | |
| } | |
| } | |
| async function reloadRemoteServices() { | |
| const busyKey = activeTab === "api-pool.start" ? "api-pool.start" : "proxy.start"; | |
| setBusy((current) => ({ ...current, [busyKey]: true })); | |
| setErrors((current) => ({ ...current, [busyKey]: "" })); | |
| try { | |
| const response = await fetch(apiPath("/reload"), { | |
| method: "POST", | |
| }); | |
| const payload = await response.json(); | |
| if (!response.ok) throw new Error(payload.error || "reload 失败"); | |
| if (payload.results?.["codex-account"]) { | |
| setProxyState(payload.results["codex-account"]); | |
| } | |
| if (payload.results?.["codex-api"]) { | |
| setApiPoolStateCodex(payload.results["codex-api"]); | |
| } | |
| if (payload.results?.["claude-api"]) { | |
| setApiPoolStateClaude(payload.results["claude-api"]); | |
| } | |
| } catch (error) { | |
| setErrors((current) => ({ ...current, [busyKey]: error.message })); | |
| } finally { | |
| setBusy((current) => ({ ...current, [busyKey]: false })); | |
| } | |
| } | |
| async function saveRemoteApiPoolConfig() { | |
| setBusy((current) => ({ ...current, "api-pool.start": true })); | |
| setErrors((current) => ({ ...current, "api-pool.start": "" })); | |
| try { | |
| const { scheduledSwitchPreset, ...configToSave } = apiPoolRemoteConfig; | |
| const response = await fetch(apiPath("/api-pool/config"), { | |
| method: "PUT", | |
| headers: { | |
| "content-type": "application/json", | |
| }, | |
| body: JSON.stringify({ | |
| ...configToSave, | |
| provider: activeApiPoolSubTab, | |
| }), | |
| }); | |
| const payload = await response.json(); | |
| if (!response.ok) throw new Error(payload.error || "保存远端 API 池配置失败"); | |
| setApiPoolRemoteConfig((current) => ({ | |
| ...current, | |
| ...(payload.config || configToSave), | |
| scheduledSwitchPreset: inferScheduledSwitchPreset( | |
| (payload.config || configToSave).scheduledSwitchIntervalMs, | |
| ), | |
| })); | |
| if (activeApiPoolSubTab === "claude-code") setApiPoolStateClaude(payload.status); | |
| else setApiPoolStateCodex(payload.status); | |
| } catch (error) { | |
| setErrors((current) => ({ ...current, "api-pool.start": error.message })); | |
| } finally { | |
| setBusy((current) => ({ ...current, "api-pool.start": false })); | |
| } | |
| } | |
| const activePoolError = errors.poolManage || ""; | |
| const poolTool = activeTool; | |
| const proxySummaryItems = [ | |
| { title: "运行状态", value: proxyState.running ? "运行中" : "未运行" }, | |
| { title: "监听地址", value: proxyState.endpoint || "-" }, | |
| { title: "账号总数", value: proxyAccounts.total }, | |
| { title: "健康账号", value: proxyAccounts.healthy }, | |
| ]; | |
| const apiSummaryItems = [ | |
| { title: "运行状态", value: currentApiPoolState.running ? "运行中" : "未运行" }, | |
| { title: "监听地址", value: currentApiPoolState.endpoint || "-" }, | |
| { title: "节点总数", value: apiPoolEndpoints.total }, | |
| { title: "健康节点", value: apiPoolEndpoints.healthy }, | |
| { title: "在途请求", value: apiPoolSchedule.inflightRequests ?? 0 }, | |
| { | |
| title: "定时切换", | |
| value: apiPoolSchedule.scheduledSwitchEnabled === false ? "关闭" : "开启", | |
| }, | |
| { | |
| title: "下次切换", | |
| value: formatTime(apiPoolSchedule.nextScheduledSwitchAt), | |
| plain: true, | |
| singleLine: true, | |
| }, | |
| { | |
| title: "最近切换", | |
| value: formatTime(apiPoolSchedule.lastScheduledSwitchAt), | |
| plain: true, | |
| singleLine: true, | |
| }, | |
| ]; | |
| return ( | |
| <Layout className="dashboard-layout"> | |
| {contextHolder} | |
| <Sider | |
| width={280} | |
| collapsedWidth={88} | |
| collapsible | |
| trigger={null} | |
| collapsed={navCollapsed} | |
| className="dashboard-sider" | |
| > | |
| <div className="brand-panel"> | |
| <div className="brand-mark">AI</div> | |
| <div className={navCollapsed ? "brand-copy brand-copy-hidden" : "brand-copy"}> | |
| <Title level={3} style={{ margin: 0 }}>AI 控制台</Title> | |
| <Text type="secondary">Local Ops Console</Text> | |
| </div> | |
| </div> | |
| <Menu | |
| mode="inline" | |
| selectedKeys={[activeTab]} | |
| items={menuItems} | |
| onClick={({ key }) => setActiveTab(key)} | |
| className="dashboard-menu" | |
| /> | |
| <div className="sider-footer"> | |
| <Tooltip title={navCollapsed ? "展开导航" : "收起导航"} placement="right"> | |
| <Button | |
| type="text" | |
| className={navCollapsed ? "nav-toggle nav-toggle-sider nav-toggle-collapsed" : "nav-toggle nav-toggle-sider"} | |
| icon={navCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />} | |
| onClick={() => setNavCollapsed((current) => !current)} | |
| > | |
| {navCollapsed ? null : "收起导航"} | |
| </Button> | |
| </Tooltip> | |
| </div> | |
| </Sider> | |
| <Layout> | |
| <Header className="dashboard-header"> | |
| <div className="dashboard-header-main"> | |
| <div className="header-title-center"> | |
| <Title level={3} style={{ margin: 10 }}>{friendlyToolName(activeTab)}</Title> | |
| <Text type="secondary"> | |
| {isRemoteMode ? "统一管理远端加密池和常驻代理" : "统一管理池文件、代理和探测"} | |
| </Text> | |
| </div> | |
| </div> | |
| <Space size={16}> | |
| <Tag color="cyan">{appConfig.environment || "Local Node + React"}</Tag> | |
| {isRemoteMode ? ( | |
| <Tag color="gold">管理员 {appConfig.user?.displayName || appConfig.user?.username || "-"}</Tag> | |
| ) : ( | |
| <Tag color="geekblue">最近 {history.length} 条记录</Tag> | |
| )} | |
| </Space> | |
| </Header> | |
| <Content className="dashboard-content"> | |
| {errors.global ? <Alert type="error" showIcon message={errors.global} style={{ marginBottom: 20 }} /> : null} | |
| {!poolTool ? ( | |
| contentFallback() | |
| ) : ( | |
| <Suspense fallback={contentFallback()}> | |
| {activeTab === "pool.manage" ? ( | |
| <PoolManagePage | |
| pools={pools} | |
| activePoolCategory={activePoolCategory} | |
| activePoolId={activePoolId} | |
| setActivePoolCategory={(nextCategory) => { | |
| setActivePoolCategory(nextCategory); | |
| setActivePoolId(nextCategory === "accounts" ? "codex-accounts" : "codex-api"); | |
| }} | |
| setActivePoolId={setActivePoolId} | |
| onReload={loadPoolAndStore} | |
| onAddItem={openEditor} | |
| onEditItem={openEditor} | |
| onDeleteItem={deletePoolItem} | |
| onProbeItem={probePoolItem} | |
| onUpdateLocalToken={updatePoolItemFromLocalAuth} | |
| onImportPool={isRemoteMode ? openImportPool : null} | |
| poolError={activePoolError} | |
| validationErrors={poolValidationErrors} | |
| readOnly={Boolean(appConfig.readOnly)} | |
| readOnlyReason={appConfig.readOnlyReason} | |
| remoteMode={isRemoteMode} | |
| importBusy={poolImportModal.busy} | |
| allowProbe={!isRemoteMode} | |
| /> | |
| ) : isRemoteMode && activeTab === "proxy.start" ? ( | |
| <RemoteServicePage | |
| title={poolTool.tabTitle} | |
| description={poolTool.description} | |
| onReload={reloadRemoteServices} | |
| reloadBusy={busy["proxy.start"]} | |
| error={errors["proxy.start"] || proxyState.lastError} | |
| summaryItems={proxySummaryItems} | |
| activeInfo={{ | |
| "公开路径": proxyState.endpoint || "/proxy/codex-account", | |
| "鉴权 Key": proxyState.authEnvName || "CODEX_ACCOUNT_PROXY_KEY", | |
| "当前账号": activeProxyAccount?.email || activeProxyAccount?.id || "-", | |
| "Account ID": activeProxyAccount?.accountId || "-", | |
| "最近验证时间": formatTime(activeProxyAccount?.lastValidation), | |
| "最近失败原因": activeProxyAccount?.lastFailureReason || "-", | |
| }} | |
| logs={proxyState.recentLogs || []} | |
| note="客户端 base URL 应指向 /proxy/codex-account,对应 Bearer Key 单独配置。" | |
| /> | |
| ) : activeTab === "proxy.start" ? ( | |
| <ProxyPage | |
| tool={poolTool} | |
| formValues={activeForm} | |
| onFieldChange={(field, value) => updateField(poolTool.id, field, value)} | |
| onStart={startProxy} | |
| onStop={stopProxy} | |
| runningState={proxyState} | |
| summaryItems={proxySummaryItems} | |
| activeInfo={{ | |
| "账号文件": activeProxyAccount?.id || "-", | |
| "Account ID": activeProxyAccount?.accountId || "-", | |
| "最近验证时间": formatTime(activeProxyAccount?.lastValidation), | |
| "最近失败原因": activeProxyAccount?.lastFailureReason || "-", | |
| "池文件": pools["codex-accounts"]?.pool?.filePath || "-", | |
| }} | |
| actionBusy={busy["proxy.start"]} | |
| error={errors["proxy.start"]} | |
| logs={proxyState.recentLogs || []} | |
| historyItems={activeHistory} | |
| /> | |
| ) : isRemoteMode && activeTab === "api-pool.start" ? ( | |
| <RemoteServicePage | |
| title={poolTool.tabTitle} | |
| description={poolTool.description} | |
| onReload={reloadRemoteServices} | |
| reloadBusy={busy["api-pool.start"]} | |
| error={errors["api-pool.start"] || currentApiPoolState.lastError} | |
| summaryItems={apiSummaryItems} | |
| activeInfo={{ | |
| "公开路径": currentApiPoolState.endpoint || (activeApiPoolSubTab === "claude-code" ? "/proxy/claude-api" : "/proxy/codex-api"), | |
| "鉴权 Key": currentApiPoolState.authEnvName || (activeApiPoolSubTab === "claude-code" ? "CLAUDE_API_PROXY_KEY" : "CODEX_API_PROXY_KEY"), | |
| "当前池": activeApiPoolSubTab === "claude-code" ? "Claude Code API 池" : "Codex API 池", | |
| "节点名": activeApiPoolEndpoint?.name || "-", | |
| "Base URL": activeApiPoolEndpoint?.baseUrl || "-", | |
| "定时切换间隔": apiPoolSchedule.scheduledSwitchIntervalMs ? `${apiPoolSchedule.scheduledSwitchIntervalMs} ms` : "-", | |
| "最近跳过原因": apiPoolSchedule.lastScheduledSwitchReason || "-", | |
| "最近验证时间": formatTime(activeApiPoolEndpoint?.lastValidation), | |
| "最近失败原因": activeApiPoolEndpoint?.lastFailureReason || "-", | |
| }} | |
| subHeader={ | |
| <Tabs | |
| activeKey={activeApiPoolSubTab} | |
| items={API_POOL_SUBTABS.map((item) => ({ key: item.id, label: item.label }))} | |
| onChange={switchApiPoolSubTab} | |
| /> | |
| } | |
| logs={currentApiPoolState.recentLogs || []} | |
| operations={ | |
| <Form | |
| layout="vertical" | |
| style={{ maxWidth: 420 }} | |
| > | |
| <Form.Item label="启用定时切换" style={{ marginBottom: 12 }}> | |
| <Switch | |
| checked={apiPoolRemoteConfig.enableScheduledSwitch !== false} | |
| onChange={(checked) => | |
| setApiPoolRemoteConfig((current) => ({ | |
| ...current, | |
| enableScheduledSwitch: checked, | |
| })) | |
| } | |
| /> | |
| </Form.Item> | |
| <Form.Item label="定时切换间隔" style={{ marginBottom: 12 }}> | |
| <Space direction="vertical" size={12} style={{ width: "100%" }}> | |
| <Select | |
| value={ | |
| apiPoolRemoteConfig.scheduledSwitchPreset || | |
| inferScheduledSwitchPreset(apiPoolRemoteConfig.scheduledSwitchIntervalMs) | |
| } | |
| options={SCHEDULED_SWITCH_PRESET_OPTIONS.map((option) => ({ | |
| label: option.label, | |
| value: option.value, | |
| }))} | |
| onChange={(value) => | |
| setApiPoolRemoteConfig((current) => ({ | |
| ...current, | |
| scheduledSwitchPreset: value, | |
| scheduledSwitchIntervalMs: | |
| value === "custom" | |
| ? Number(current.scheduledSwitchIntervalMs || 900000) | |
| : presetValueToIntervalMs(value, current.scheduledSwitchIntervalMs), | |
| })) | |
| } | |
| /> | |
| {(apiPoolRemoteConfig.scheduledSwitchPreset || | |
| inferScheduledSwitchPreset(apiPoolRemoteConfig.scheduledSwitchIntervalMs)) === "custom" ? ( | |
| <InputNumber | |
| min={1000} | |
| step={1000} | |
| style={{ width: "100%" }} | |
| addonAfter="毫秒" | |
| value={apiPoolRemoteConfig.scheduledSwitchIntervalMs} | |
| onChange={(value) => | |
| setApiPoolRemoteConfig((current) => ({ | |
| ...current, | |
| scheduledSwitchPreset: "custom", | |
| scheduledSwitchIntervalMs: Number(value || 900000), | |
| })) | |
| } | |
| /> | |
| ) : null} | |
| </Space> | |
| </Form.Item> | |
| <Button type="primary" onClick={saveRemoteApiPoolConfig} loading={busy["api-pool.start"]}> | |
| 保存并重载当前服务 | |
| </Button> | |
| </Form> | |
| } | |
| note={ | |
| activeApiPoolSubTab === "claude-code" | |
| ? "Claude 客户端请指向 /proxy/claude-api,并使用 CLAUDE_API_PROXY_KEY。" | |
| : "OpenAI/Codex 客户端请指向 /proxy/codex-api,并使用 CODEX_API_PROXY_KEY。" | |
| } | |
| /> | |
| ) : activeTab === "api-pool.start" ? ( | |
| <ProxyPage | |
| tool={poolTool} | |
| formValues={activeForm} | |
| onFieldChange={(field, value) => updateField(poolTool.id, field, value)} | |
| onStart={() => startApiPoolProxy(activeApiPoolSubTab)} | |
| onStop={() => stopApiPoolProxy(activeApiPoolSubTab)} | |
| runningState={currentApiPoolState} | |
| summaryItems={apiSummaryItems} | |
| activeInfo={{ | |
| "当前池": activeApiPoolSubTab === "claude-code" ? "Claude Code API 池" : "Codex API 池", | |
| "节点名": activeApiPoolEndpoint?.name || "-", | |
| "Base URL": activeApiPoolEndpoint?.baseUrl || "-", | |
| "定时切换间隔": apiPoolSchedule.scheduledSwitchIntervalMs ? `${apiPoolSchedule.scheduledSwitchIntervalMs} ms` : "-", | |
| "最近跳过原因": apiPoolSchedule.lastScheduledSwitchReason || "-", | |
| "最近验证时间": formatTime(activeApiPoolEndpoint?.lastValidation), | |
| "最近失败原因": activeApiPoolEndpoint?.lastFailureReason || "-", | |
| }} | |
| actionBusy={busy["api-pool.start"]} | |
| error={errors["api-pool.start"]} | |
| subHeader={ | |
| <Tabs | |
| activeKey={activeApiPoolSubTab} | |
| items={API_POOL_SUBTABS.map((item) => ({ key: item.id, label: item.label }))} | |
| onChange={switchApiPoolSubTab} | |
| /> | |
| } | |
| extraControls={ | |
| <Segmented | |
| value={activeApiPoolSubTab} | |
| options={API_POOL_SUBTABS.map((item) => ({ label: item.label, value: item.id }))} | |
| onChange={(value) => switchApiPoolSubTab(String(value))} | |
| /> | |
| } | |
| logs={currentApiPoolState.recentLogs || []} | |
| historyItems={activeHistory} | |
| /> | |
| ) : activeTab === "config.switch" ? ( | |
| <ConfigSwitchPage apiBase={apiBase} /> | |
| ) : ( | |
| <ProbePage | |
| tool={poolTool} | |
| formValues={activeForm} | |
| onFieldChange={(field, value) => updateField(poolTool.id, field, value)} | |
| onRun={() => runTool(poolTool)} | |
| busy={busy[poolTool.id]} | |
| error={errors[poolTool.id]} | |
| logs={activeRun.logs || []} | |
| historyItems={activeHistory} | |
| activeRun={activeRun} | |
| /> | |
| )} | |
| </Suspense> | |
| )} | |
| </Content> | |
| </Layout> | |
| <PoolEditorDrawer | |
| poolId={editingPool?.poolId} | |
| item={editingDraft} | |
| visible={Boolean(editingDraft)} | |
| remoteMode={isRemoteMode} | |
| onClose={() => { | |
| setEditingPool(null); | |
| setEditingDraft(null); | |
| }} | |
| onSave={applyDraft} | |
| /> | |
| <ProbeLogModal | |
| open={poolProbeModal.open} | |
| title={poolProbeModal.title} | |
| status={poolProbeModal.status} | |
| logs={poolProbeModal.logs} | |
| error={poolProbeModal.error} | |
| onClose={() => | |
| setPoolProbeModal({ | |
| open: false, | |
| title: "", | |
| runId: null, | |
| status: "", | |
| logs: [], | |
| error: "", | |
| }) | |
| } | |
| /> | |
| <PoolImportModal | |
| open={poolImportModal.open} | |
| poolLabel={pools[poolImportModal.poolId]?.pool?.label} | |
| importText={poolImportModal.text} | |
| onChange={(text) => setPoolImportModal((current) => ({ ...current, text, error: "" }))} | |
| onClose={() => | |
| setPoolImportModal({ | |
| open: false, | |
| poolId: "", | |
| text: "", | |
| busy: false, | |
| error: "", | |
| }) | |
| } | |
| onImport={importPool} | |
| busy={poolImportModal.busy} | |
| error={poolImportModal.error} | |
| /> | |
| </Layout> | |
| ); | |
| } | |