Spaces:
Sleeping
Sleeping
| import fs from "node:fs/promises"; | |
| import http from "node:http"; | |
| import path from "node:path"; | |
| import { fileURLToPath } from "node:url"; | |
| import { ConfigSwitchStore } from "./config-switch-store.mjs"; | |
| import { HistoryStore } from "./history-store.mjs"; | |
| import { ApiPoolProxyManager } from "./api-pool-proxy-manager.mjs"; | |
| import { PoolStore } from "./pool-store.mjs"; | |
| import { ProxyManager } from "./proxy-manager.mjs"; | |
| import { RunManager } from "./run-manager.mjs"; | |
| import { createToolPayload } from "./tool-registry.mjs"; | |
| const REPO_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..", ".."); | |
| const DEFAULT_HOST = "127.0.0.1"; | |
| const DEFAULT_PORT = 8788; | |
| const DEFAULT_DATA_DIR = path.join(REPO_ROOT, ".local-ui-data"); | |
| const DEFAULT_STATIC_DIR = path.join(REPO_ROOT, "dist", "ui"); | |
| function parseArgs(argv) { | |
| const args = {}; | |
| for (const item of argv) { | |
| if (!item.startsWith("--")) continue; | |
| const [key, ...rest] = item.slice(2).split("="); | |
| args[key] = rest.length > 0 ? rest.join("=") : "true"; | |
| } | |
| return args; | |
| } | |
| function json(res, statusCode, value) { | |
| res.statusCode = statusCode; | |
| res.setHeader("content-type", "application/json; charset=utf-8"); | |
| res.end(`${JSON.stringify(value, null, 2)}\n`); | |
| } | |
| async function readJsonBody(req) { | |
| const chunks = []; | |
| for await (const chunk of req) { | |
| chunks.push(chunk); | |
| } | |
| const text = Buffer.concat(chunks).toString("utf8").trim(); | |
| if (!text) return {}; | |
| return JSON.parse(text); | |
| } | |
| async function fileExists(filePath) { | |
| try { | |
| await fs.access(filePath); | |
| return true; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| function contentTypeFor(filePath) { | |
| if (filePath.endsWith(".html")) return "text/html; charset=utf-8"; | |
| if (filePath.endsWith(".js")) return "text/javascript; charset=utf-8"; | |
| if (filePath.endsWith(".css")) return "text/css; charset=utf-8"; | |
| if (filePath.endsWith(".json")) return "application/json; charset=utf-8"; | |
| if (filePath.endsWith(".svg")) return "image/svg+xml"; | |
| if (filePath.endsWith(".png")) return "image/png"; | |
| return "application/octet-stream"; | |
| } | |
| async function serveStatic(req, res, staticDir) { | |
| const url = new URL(req.url || "/", "http://localhost"); | |
| const pathname = url.pathname === "/" ? "/index.html" : url.pathname; | |
| const requested = path.join(staticDir, pathname); | |
| const resolved = path.resolve(requested); | |
| if (!resolved.startsWith(path.resolve(staticDir))) { | |
| json(res, 403, { error: "Forbidden" }); | |
| return true; | |
| } | |
| const filePath = (await fileExists(resolved)) | |
| ? resolved | |
| : path.join(staticDir, "index.html"); | |
| if (!(await fileExists(filePath))) { | |
| return false; | |
| } | |
| const body = await fs.readFile(filePath); | |
| res.statusCode = 200; | |
| res.setHeader("content-type", contentTypeFor(filePath)); | |
| res.end(body); | |
| return true; | |
| } | |
| export function createRequestListener({ | |
| staticDir, | |
| historyStore, | |
| poolStore, | |
| runManager, | |
| proxyManager, | |
| apiPoolProxyManagerCodex, | |
| apiPoolProxyManagerClaude, | |
| configSwitchStore, | |
| }) { | |
| return async (req, res) => { | |
| try { | |
| const url = new URL(req.url || "/", "http://localhost"); | |
| const pathname = url.pathname; | |
| if (req.method === "GET" && pathname === "/api/app-config") { | |
| json(res, 200, { | |
| mode: "local", | |
| apiBase: "/api", | |
| environment: "本地 Node + React", | |
| user: null, | |
| readOnly: false, | |
| readOnlyReason: "", | |
| }); | |
| return; | |
| } | |
| if (req.method === "GET" && pathname === "/api/tools") { | |
| json(res, 200, { tools: createToolPayload() }); | |
| return; | |
| } | |
| if (req.method === "GET" && pathname === "/api/history") { | |
| json(res, 200, { items: await historyStore.list() }); | |
| return; | |
| } | |
| if (req.method === "GET" && pathname === "/api/config-switch") { | |
| json(res, 200, await configSwitchStore.getConfigSwitchData()); | |
| return; | |
| } | |
| if (pathname.startsWith("/api/config-switch/")) { | |
| const parts = pathname.split("/").filter(Boolean); | |
| const provider = decodeURIComponent(parts[2] || ""); | |
| const presetId = decodeURIComponent(parts[3] || ""); | |
| if (req.method === "POST" && parts.length === 3) { | |
| const body = await readJsonBody(req); | |
| const payload = await configSwitchStore.upsertPreset(provider, body); | |
| json(res, body?.id ? 200 : 201, payload); | |
| return; | |
| } | |
| if (req.method === "DELETE" && parts.length === 4) { | |
| json(res, 200, await configSwitchStore.deletePreset(provider, presetId)); | |
| return; | |
| } | |
| if (req.method === "POST" && parts.length === 5 && parts[4] === "copy") { | |
| json(res, 201, await configSwitchStore.copyPreset(provider, presetId)); | |
| return; | |
| } | |
| if (req.method === "POST" && parts.length === 5 && parts[4] === "activate") { | |
| const body = await readJsonBody(req); | |
| json( | |
| res, | |
| 200, | |
| await configSwitchStore.activatePreset(provider, presetId, body), | |
| ); | |
| return; | |
| } | |
| } | |
| if (req.method === "GET" && pathname === "/api/pools") { | |
| json(res, 200, { items: poolStore.listPools() }); | |
| return; | |
| } | |
| if (req.method === "GET" && pathname.startsWith("/api/pools/")) { | |
| const poolId = pathname.split("/")[3]; | |
| json(res, 200, await poolStore.loadPool(poolId)); | |
| return; | |
| } | |
| if (req.method === "PUT" && pathname.startsWith("/api/pools/")) { | |
| const poolId = pathname.split("/")[3]; | |
| const body = await readJsonBody(req); | |
| json(res, 200, await poolStore.savePool(poolId, body.items || [])); | |
| return; | |
| } | |
| if ( | |
| req.method === "POST" && | |
| pathname.startsWith("/api/pools/") && | |
| pathname.endsWith("/validate") | |
| ) { | |
| const poolId = pathname.split("/")[3]; | |
| const body = await readJsonBody(req); | |
| const result = poolStore.validatePoolItems(poolId, body.items || []); | |
| json(res, result.ok ? 200 : 400, result); | |
| return; | |
| } | |
| if ( | |
| req.method === "POST" && | |
| pathname.startsWith("/api/pools/") && | |
| pathname.endsWith("/update-local-token") | |
| ) { | |
| const parts = pathname.split("/").filter(Boolean); | |
| const poolId = parts[2]; | |
| const index = parts[3]; | |
| if (poolId !== "codex-accounts") { | |
| json(res, 400, { error: "只有 Codex 账号池支持本地 auth.json 更新。" }); | |
| return; | |
| } | |
| const body = await readJsonBody(req); | |
| json( | |
| res, | |
| 200, | |
| await poolStore.updateCodexAccountFromLocalAuth(index, body || {}), | |
| ); | |
| return; | |
| } | |
| if (req.method === "POST" && pathname === "/api/runs") { | |
| const body = await readJsonBody(req); | |
| const result = await runManager.execute(body); | |
| json(res, 202, result); | |
| return; | |
| } | |
| if (req.method === "GET" && pathname.startsWith("/api/runs/") && pathname.endsWith("/logs")) { | |
| const runId = pathname.split("/")[3]; | |
| const run = runManager.getRun(runId); | |
| if (!run) { | |
| json(res, 404, { error: "Run not found" }); | |
| return; | |
| } | |
| json(res, 200, { runId, status: run.status, logs: run.logs }); | |
| return; | |
| } | |
| if (req.method === "GET" && pathname.startsWith("/api/runs/")) { | |
| const runId = pathname.split("/")[3]; | |
| const run = runManager.getRun(runId); | |
| if (!run) { | |
| json(res, 404, { error: "Run not found" }); | |
| return; | |
| } | |
| json(res, 200, { | |
| run: { | |
| id: run.id, | |
| toolId: run.toolId, | |
| status: run.status, | |
| createdAt: run.createdAt, | |
| startedAt: run.startedAt, | |
| finishedAt: run.finishedAt, | |
| exitCode: run.exitCode, | |
| error: run.error, | |
| commandPreview: run.commandPreview, | |
| params: run.params, | |
| }, | |
| }); | |
| return; | |
| } | |
| if (req.method === "POST" && pathname === "/api/proxy/start") { | |
| const body = await readJsonBody(req); | |
| const result = await proxyManager.start(body.params || {}); | |
| json(res, result.reused ? 200 : 201, result); | |
| return; | |
| } | |
| if (req.method === "POST" && pathname === "/api/proxy/stop") { | |
| json(res, 200, await proxyManager.stop()); | |
| return; | |
| } | |
| if (req.method === "GET" && pathname === "/api/proxy/status") { | |
| json(res, 200, await proxyManager.getStatus()); | |
| return; | |
| } | |
| if (req.method === "POST" && pathname === "/api/api-pool/start") { | |
| const body = await readJsonBody(req); | |
| const params = body.params || {}; | |
| const provider = params.provider || "codex"; | |
| const manager = provider === "claude-code" ? apiPoolProxyManagerClaude : apiPoolProxyManagerCodex; | |
| const result = await manager.start(params); | |
| json(res, result.reused ? 200 : 201, result); | |
| return; | |
| } | |
| if (req.method === "POST" && pathname === "/api/api-pool/stop") { | |
| const body = await readJsonBody(req); | |
| const params = body.params || {}; | |
| const provider = params.provider || "codex"; | |
| const manager = provider === "claude-code" ? apiPoolProxyManagerClaude : apiPoolProxyManagerCodex; | |
| json(res, 200, await manager.stop()); | |
| return; | |
| } | |
| if (req.method === "GET" && pathname === "/api/api-pool/status") { | |
| json(res, 200, await apiPoolProxyManagerCodex.getStatus()); | |
| return; | |
| } | |
| if (req.method === "POST" && pathname === "/api/api-pool/codex/start") { | |
| const body = await readJsonBody(req); | |
| const result = await apiPoolProxyManagerCodex.start(body.params || {}); | |
| json(res, result.reused ? 200 : 201, result); | |
| return; | |
| } | |
| if (req.method === "POST" && pathname === "/api/api-pool/codex/stop") { | |
| json(res, 200, await apiPoolProxyManagerCodex.stop()); | |
| return; | |
| } | |
| if (req.method === "GET" && pathname === "/api/api-pool/codex/status") { | |
| json(res, 200, await apiPoolProxyManagerCodex.getStatus()); | |
| return; | |
| } | |
| if (req.method === "POST" && pathname === "/api/api-pool/claude-code/start") { | |
| const body = await readJsonBody(req); | |
| const result = await apiPoolProxyManagerClaude.start(body.params || {}); | |
| json(res, result.reused ? 200 : 201, result); | |
| return; | |
| } | |
| if (req.method === "POST" && pathname === "/api/api-pool/claude-code/stop") { | |
| json(res, 200, await apiPoolProxyManagerClaude.stop()); | |
| return; | |
| } | |
| if (req.method === "GET" && pathname === "/api/api-pool/claude-code/status") { | |
| json(res, 200, await apiPoolProxyManagerClaude.getStatus()); | |
| return; | |
| } | |
| if (await serveStatic(req, res, staticDir)) { | |
| return; | |
| } | |
| json(res, 404, { error: "Not found" }); | |
| } catch (error) { | |
| json(res, error.statusCode || 500, { | |
| error: error?.message || String(error), | |
| }); | |
| } | |
| }; | |
| } | |
| export async function main() { | |
| const args = parseArgs(process.argv.slice(2)); | |
| const host = args.host || DEFAULT_HOST; | |
| const port = Number(args.port || DEFAULT_PORT); | |
| const dataDir = path.resolve(args["data-dir"] || DEFAULT_DATA_DIR); | |
| const staticDir = path.resolve(args["static-dir"] || DEFAULT_STATIC_DIR); | |
| const historyStore = new HistoryStore(dataDir); | |
| await historyStore.load(); | |
| const poolStore = new PoolStore(); | |
| const runManager = new RunManager(historyStore); | |
| const proxyManager = new ProxyManager(historyStore); | |
| const apiPoolProxyManagerCodex = new ApiPoolProxyManager(historyStore); | |
| const apiPoolProxyManagerClaude = new ApiPoolProxyManager(historyStore); | |
| const configSwitchStore = new ConfigSwitchStore(dataDir); | |
| await configSwitchStore.load(); | |
| const server = http.createServer( | |
| createRequestListener({ | |
| staticDir, | |
| historyStore, | |
| poolStore, | |
| runManager, | |
| proxyManager, | |
| apiPoolProxyManagerCodex, | |
| apiPoolProxyManagerClaude, | |
| configSwitchStore, | |
| }), | |
| ); | |
| await new Promise((resolve, reject) => { | |
| server.once("error", reject); | |
| server.listen(port, host, () => { | |
| server.off("error", reject); | |
| console.log(`Local UI server listening on http://${host}:${port}`); | |
| console.log(`Data dir: ${dataDir}`); | |
| console.log(`Static dir: ${staticDir}`); | |
| resolve(); | |
| }); | |
| }); | |
| } | |
| if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { | |
| main().catch((error) => { | |
| console.error(error?.message || error); | |
| process.exitCode = 1; | |
| }); | |
| } | |