From 4631464de236a77eeb770e9bc11d4a6c01a40937 Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Fri, 13 Feb 2026 06:39:25 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E5=A2=9E=E5=8A=A0WebUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 3 + Dockerfile.console | 39 ++ bun.lock | 1 + src/console/account-store.ts | 78 ++++ src/console/api.ts | 257 +++++++++++++ src/console/auth-flow.ts | 139 +++++++ src/console/index.ts | 132 +++++++ src/console/instance-manager.ts | 446 ++++++++++++++++++++++ src/console/port-check.ts | 74 ++++ src/main.ts | 9 +- web/bun.lock | 257 +++++++++++++ web/index.html | 12 + web/package.json | 22 ++ web/src/App.tsx | 108 ++++++ web/src/api.ts | 126 +++++++ web/src/components/AccountCard.tsx | 334 +++++++++++++++++ web/src/components/AddAccountForm.tsx | 514 ++++++++++++++++++++++++++ web/src/index.css | 105 ++++++ web/src/main.tsx | 14 + web/tsconfig.json | 20 + web/vite.config.ts | 14 + 21 files changed, 2703 insertions(+), 1 deletion(-) create mode 100644 Dockerfile.console create mode 100644 src/console/account-store.ts create mode 100644 src/console/api.ts create mode 100644 src/console/auth-flow.ts create mode 100644 src/console/index.ts create mode 100644 src/console/instance-manager.ts create mode 100644 src/console/port-check.ts create mode 100644 web/bun.lock create mode 100644 web/index.html create mode 100644 web/package.json create mode 100644 web/src/App.tsx create mode 100644 web/src/api.ts create mode 100644 web/src/components/AccountCard.tsx create mode 100644 web/src/components/AddAccountForm.tsx create mode 100644 web/src/index.css create mode 100644 web/src/main.tsx create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/.dockerignore b/.dockerignore index 84aa78f64..988a539ee 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,3 +11,6 @@ tests/ *.md .eslintcache + +web/node_modules +web/dist diff --git a/Dockerfile.console b/Dockerfile.console new file mode 100644 index 000000000..08fb87cb9 --- /dev/null +++ b/Dockerfile.console @@ -0,0 +1,39 @@ +# Stage 1: Build frontend +FROM node:22-alpine AS web-builder +WORKDIR /app/web + +COPY web/package.json ./ +RUN npm install + +COPY web/ ./ +RUN npm run build + +# Stage 2: Build backend +FROM oven/bun:1.2.19-alpine AS builder +WORKDIR /app + +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile + +COPY . . + +# Stage 3: Runtime +FROM oven/bun:1.2.19-alpine +WORKDIR /app + +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile --production --ignore-scripts --no-cache + +COPY --from=builder /app/src ./src +COPY --from=builder /app/tsconfig.json ./ +COPY --from=web-builder /app/web/dist ./web/dist + +EXPOSE 3000 + +VOLUME /root/.local/share/copilot-api + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD wget --spider -q http://localhost:3000/api/accounts || exit 1 + +ENTRYPOINT ["bun", "run", "./src/main.ts", "console"] +CMD ["--port", "3000"] diff --git a/bun.lock b/bun.lock index 20e895e7f..9ece87578 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "copilot-api", diff --git a/src/console/account-store.ts b/src/console/account-store.ts new file mode 100644 index 000000000..660fd45c3 --- /dev/null +++ b/src/console/account-store.ts @@ -0,0 +1,78 @@ +import fs from "node:fs/promises" +import path from "node:path" + +import { PATHS } from "~/lib/paths" + +export interface Account { + id: string + name: string + githubToken: string + accountType: string + port: number + enabled: boolean + createdAt: string +} + +export interface AccountStore { + accounts: Array +} + +const STORE_PATH = path.join(PATHS.APP_DIR, "accounts.json") + +async function readStore(): Promise { + try { + const data = await fs.readFile(STORE_PATH) + return JSON.parse(data) as AccountStore + } catch { + return { accounts: [] } + } +} + +async function writeStore(store: AccountStore): Promise { + await fs.writeFile(STORE_PATH, JSON.stringify(store, null, 2)) +} + +export async function getAccounts(): Promise> { + const store = await readStore() + return store.accounts +} + +export async function getAccount(id: string): Promise { + const store = await readStore() + return store.accounts.find((a) => a.id === id) +} + +export async function addAccount( + account: Omit, +): Promise { + const store = await readStore() + const newAccount: Account = { + ...account, + id: crypto.randomUUID(), + createdAt: new Date().toISOString(), + } + store.accounts.push(newAccount) + await writeStore(store) + return newAccount +} + +export async function updateAccount( + id: string, + updates: Partial>, +): Promise { + const store = await readStore() + const index = store.accounts.findIndex((a) => a.id === id) + if (index === -1) return undefined + store.accounts[index] = { ...store.accounts[index], ...updates } + await writeStore(store) + return store.accounts[index] +} + +export async function deleteAccount(id: string): Promise { + const store = await readStore() + const index = store.accounts.findIndex((a) => a.id === id) + if (index === -1) return false + store.accounts.splice(index, 1) + await writeStore(store) + return true +} diff --git a/src/console/api.ts b/src/console/api.ts new file mode 100644 index 000000000..913cd45bb --- /dev/null +++ b/src/console/api.ts @@ -0,0 +1,257 @@ +import { Hono } from "hono" +import { cors } from "hono/cors" +import { z } from "zod" + +import { + getAccounts, + getAccount, + addAccount, + updateAccount, + deleteAccount, +} from "./account-store" +import { startDeviceFlow, getSession, cleanupSession } from "./auth-flow" +import { + startInstance, + stopInstance, + getInstanceStatus, + getInstanceError, + getInstanceUsage, + getInstanceUser, +} from "./instance-manager" +import { checkPortConflict, findAvailablePort } from "./port-check" + +const AddAccountSchema = z.object({ + name: z.string().min(1), + githubToken: z.string().min(1), + accountType: z.string().default("individual"), + port: z.number().int().min(1024).max(65535), + enabled: z.boolean().default(true), +}) + +const UpdateAccountSchema = z.object({ + name: z.string().min(1).optional(), + githubToken: z.string().min(1).optional(), + accountType: z.string().optional(), + port: z.number().int().min(1024).max(65535).optional(), + enabled: z.boolean().optional(), +}) + +const CompleteAuthSchema = z.object({ + sessionId: z.string().min(1), + name: z.string().min(1), + accountType: z.string().default("individual"), + port: z.number().int().min(1024).max(65535), +}) + +function formatZodError(err: z.ZodError): string { + return z.treeifyError(err).children ? + JSON.stringify(z.treeifyError(err)) + : err.message +} + +function portConflictMessage(conflict: { + port: number + conflict: string + accountName?: string +}): string { + return conflict.conflict === "account" ? + `Port ${conflict.port} is already used by account "${conflict.accountName}"` + : `Port ${conflict.port} is already in use by another process` +} + +export const consoleApi = new Hono() + +consoleApi.use(cors()) + +// List all accounts with status +consoleApi.get("/accounts", async (c) => { + const accounts = await getAccounts() + const result = await Promise.all( + accounts.map(async (account) => { + const status = getInstanceStatus(account.id) + const error = getInstanceError(account.id) + let user: { login: string } | null = null + if (status === "running") { + try { + user = await getInstanceUser(account.id) + } catch { + /* ignore */ + } + } + return { ...account, status, error, user } + }), + ) + return c.json(result) +}) + +// Get single account +consoleApi.get("/accounts/:id", async (c) => { + const account = await getAccount(c.req.param("id")) + if (!account) return c.json({ error: "Account not found" }, 404) + const status = getInstanceStatus(account.id) + const error = getInstanceError(account.id) + return c.json({ ...account, status, error }) +}) + +// Add account +consoleApi.post("/accounts", async (c) => { + const body: unknown = await c.req.json() + const parsed = AddAccountSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: formatZodError(parsed.error) }, 400) + } + + const conflict = await checkPortConflict(parsed.data.port) + if (conflict) { + return c.json({ error: portConflictMessage(conflict) }, 409) + } + + const account = await addAccount(parsed.data) + return c.json(account, 201) +}) + +// Update account +consoleApi.put("/accounts/:id", async (c) => { + const body: unknown = await c.req.json() + const parsed = UpdateAccountSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: formatZodError(parsed.error) }, 400) + } + + const id = c.req.param("id") + if (parsed.data.port !== undefined) { + const conflict = await checkPortConflict(parsed.data.port, id) + if (conflict) { + return c.json({ error: portConflictMessage(conflict) }, 409) + } + } + + const account = await updateAccount(id, parsed.data) + if (!account) return c.json({ error: "Account not found" }, 404) + return c.json(account) +}) + +// Delete account +consoleApi.delete("/accounts/:id", async (c) => { + const id = c.req.param("id") + await stopInstance(id) + const deleted = await deleteAccount(id) + if (!deleted) return c.json({ error: "Account not found" }, 404) + return c.json({ ok: true }) +}) + +// Start instance +consoleApi.post("/accounts/:id/start", async (c) => { + const account = await getAccount(c.req.param("id")) + if (!account) return c.json({ error: "Account not found" }, 404) + try { + await startInstance(account) + return c.json({ status: "running" }) + } catch (error) { + return c.json({ error: (error as Error).message, status: "error" }, 500) + } +}) + +// Stop instance +consoleApi.post("/accounts/:id/stop", async (c) => { + await stopInstance(c.req.param("id")) + return c.json({ status: "stopped" }) +}) + +// Get usage for account +consoleApi.get("/accounts/:id/usage", async (c) => { + const usage = await getInstanceUsage(c.req.param("id")) + if (!usage) + return c.json({ error: "Instance not running or usage unavailable" }, 404) + return c.json(usage) +}) + +// === Device Code Auth Flow === + +consoleApi.post("/auth/device-code", async (c) => { + try { + const result = await startDeviceFlow() + return c.json(result) + } catch (error) { + return c.json({ error: (error as Error).message }, 500) + } +}) + +consoleApi.get("/auth/poll/:sessionId", (c) => { + const session = getSession(c.req.param("sessionId")) + if (!session) return c.json({ error: "Session not found" }, 404) + return c.json({ + status: session.status, + accessToken: + session.status === "completed" ? session.accessToken : undefined, + error: session.error, + }) +}) + +consoleApi.post("/auth/complete", async (c) => { + const body: unknown = await c.req.json() + const parsed = CompleteAuthSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: formatZodError(parsed.error) }, 400) + } + + const session = getSession(parsed.data.sessionId) + if (!session) return c.json({ error: "Session not found" }, 404) + if (session.status !== "completed" || !session.accessToken) { + return c.json({ error: "Auth not completed yet" }, 400) + } + + const conflict = await checkPortConflict(parsed.data.port) + if (conflict) { + return c.json({ error: portConflictMessage(conflict) }, 409) + } + + const account = await addAccount({ + name: parsed.data.name, + githubToken: session.accessToken, + accountType: parsed.data.accountType, + port: parsed.data.port, + enabled: true, + }) + + cleanupSession(parsed.data.sessionId) + return c.json(account, 201) +}) + +// === Port Management === + +consoleApi.get("/port/check/:port", async (c) => { + const port = Number.parseInt(c.req.param("port"), 10) + if (Number.isNaN(port) || port < 1024 || port > 65535) { + return c.json({ error: "Invalid port" }, 400) + } + + const excludeId = c.req.query("exclude") ?? undefined + const conflict = await checkPortConflict(port, excludeId) + + if (conflict) { + return c.json({ + available: false, + conflict: conflict.conflict, + accountName: conflict.accountName, + }) + } + + return c.json({ available: true }) +}) + +consoleApi.get("/port/suggest", async (c) => { + const startRaw = c.req.query("start") ?? "4141" + const start = Number.parseInt(startRaw, 10) + const excludeId = c.req.query("exclude") ?? undefined + + try { + const port = await findAvailablePort( + Number.isNaN(start) ? 4141 : start, + excludeId, + ) + return c.json({ port }) + } catch { + return c.json({ error: "No available port found" }, 500) + } +}) diff --git a/src/console/auth-flow.ts b/src/console/auth-flow.ts new file mode 100644 index 000000000..517a5568b --- /dev/null +++ b/src/console/auth-flow.ts @@ -0,0 +1,139 @@ +import consola from "consola" + +import { + GITHUB_APP_SCOPES, + GITHUB_BASE_URL, + GITHUB_CLIENT_ID, + standardHeaders, +} from "~/lib/api-config" +import { sleep } from "~/lib/utils" + +interface AuthSession { + deviceCode: string + userCode: string + verificationUri: string + expiresAt: number + interval: number + status: "pending" | "completed" | "expired" | "error" + accessToken?: string + error?: string +} + +const sessions = new Map() + +export function getSession(sessionId: string): AuthSession | undefined { + return sessions.get(sessionId) +} + +export async function startDeviceFlow(): Promise<{ + sessionId: string + userCode: string + verificationUri: string + expiresIn: number +}> { + const response = await fetch(`${GITHUB_BASE_URL}/login/device/code`, { + method: "POST", + headers: standardHeaders(), + body: JSON.stringify({ + client_id: GITHUB_CLIENT_ID, + scope: GITHUB_APP_SCOPES, + }), + }) + + if (!response.ok) { + throw new Error(`Failed to get device code: ${response.status}`) + } + + const data = (await response.json()) as { + device_code: string + user_code: string + verification_uri: string + expires_in: number + interval: number + } + + const sessionId = crypto.randomUUID() + const session: AuthSession = { + deviceCode: data.device_code, + userCode: data.user_code, + verificationUri: data.verification_uri, + expiresAt: Date.now() + data.expires_in * 1000, + interval: data.interval, + status: "pending", + } + + sessions.set(sessionId, session) + + // Start background polling + void pollForToken(sessionId, session) + + return { + sessionId, + userCode: data.user_code, + verificationUri: data.verification_uri, + expiresIn: data.expires_in, + } +} + +async function pollForToken( + sessionId: string, + session: AuthSession, +): Promise { + const sleepDuration = (session.interval + 1) * 1000 + + while (session.status === "pending") { + if (Date.now() > session.expiresAt) { + session.status = "expired" + session.error = "Device code expired" + consola.warn(`Auth session ${sessionId} expired`) + return + } + + await sleep(sleepDuration) + + try { + const response = await fetch( + `${GITHUB_BASE_URL}/login/oauth/access_token`, + { + method: "POST", + headers: standardHeaders(), + body: JSON.stringify({ + client_id: GITHUB_CLIENT_ID, + device_code: session.deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }, + ) + + if (!response.ok) { + consola.debug(`Auth poll failed: ${response.status}`) + continue + } + + const json = (await response.json()) as { + access_token?: string + error?: string + } + + if (json.access_token) { + // eslint-disable-next-line require-atomic-updates + session.accessToken = json.access_token + // eslint-disable-next-line require-atomic-updates + session.status = "completed" + consola.success(`Auth session ${sessionId} completed`) + return + } + + // "authorization_pending" or "slow_down" are expected + if (json.error === "slow_down") { + await sleep(5000) + } + } catch (error) { + consola.debug("Auth poll error:", error) + } + } +} + +export function cleanupSession(sessionId: string): void { + sessions.delete(sessionId) +} diff --git a/src/console/index.ts b/src/console/index.ts new file mode 100644 index 000000000..863d7af51 --- /dev/null +++ b/src/console/index.ts @@ -0,0 +1,132 @@ +import { defineCommand } from "citty" +import consola from "consola" +import { Hono } from "hono" +import { cors } from "hono/cors" +import fs from "node:fs/promises" +import path from "node:path" +import { serve, type ServerHandler } from "srvx" + +import { ensurePaths } from "~/lib/paths" + +import { getAccounts } from "./account-store" +import { consoleApi } from "./api" +import { startInstance } from "./instance-manager" + +const MIME_TYPES: Record = { + ".html": "text/html", + ".js": "application/javascript", + ".css": "text/css", + ".json": "application/json", + ".png": "image/png", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", +} + +export const console_ = defineCommand({ + meta: { + name: "console", + description: "Start the multi-account management console", + }, + args: { + port: { + alias: "p", + type: "string", + default: "3000", + description: "Port for the console web UI", + }, + verbose: { + alias: "v", + type: "boolean", + default: false, + description: "Enable verbose logging", + }, + "auto-start": { + type: "boolean", + default: true, + description: "Auto-start enabled accounts on launch", + }, + }, + async run({ args }) { + if (args.verbose) { + consola.level = 5 + } + + await ensurePaths() + + const port = Number.parseInt(args.port, 10) + const app = new Hono() + + app.use(cors()) + + // Mount API routes + app.route("/api", consoleApi) + + // Serve static frontend + const webDistPath = path.resolve( + new URL(".", import.meta.url).pathname, + "../../web/dist", + ) + + let hasWebDist = false + try { + await fs.access(webDistPath) + hasWebDist = true + } catch { + /* no built frontend */ + } + + if (hasWebDist) { + app.get("/*", async (c) => { + const reqPath = c.req.path === "/" ? "/index.html" : c.req.path + const filePath = path.join(webDistPath, reqPath) + + try { + const content = await fs.readFile(filePath) + const ext = path.extname(filePath) + return c.body(content.buffer as ArrayBuffer, { + headers: { + "content-type": MIME_TYPES[ext] ?? "application/octet-stream", + }, + }) + } catch { + // SPA fallback + const indexContent = await fs.readFile( + path.join(webDistPath, "index.html"), + "utf8", + ) + return c.html(indexContent) + } + }) + } else { + app.get("/", (c) => + c.text( + "Console API running. Build the web UI with: cd web && bun install && bun run build", + ), + ) + } + + // Auto-start enabled accounts + if (args["auto-start"]) { + const accounts = await getAccounts() + for (const account of accounts) { + if (account.enabled) { + try { + await startInstance(account) + } catch (error) { + consola.error( + `Failed to auto-start account "${account.name}":`, + error, + ) + } + } + } + } + + serve({ + fetch: app.fetch as ServerHandler, + port, + }) + + consola.box(`🎛️ Console running at http://localhost:${port}`) + }, +}) diff --git a/src/console/instance-manager.ts b/src/console/instance-manager.ts new file mode 100644 index 000000000..a93aab721 --- /dev/null +++ b/src/console/instance-manager.ts @@ -0,0 +1,446 @@ +import type { Context } from "hono" + +import consola from "consola" +import { events } from "fetch-event-stream" +import { Hono } from "hono" +import { cors } from "hono/cors" +import { logger } from "hono/logger" +import { streamSSE } from "hono/streaming" +import { serve, type ServerHandler } from "srvx" + +import type { State } from "~/lib/state" +import type { AnthropicMessagesPayload } from "~/routes/messages/anthropic-types" +import type { + ChatCompletionResponse, + ChatCompletionsPayload, + ChatCompletionChunk, +} from "~/services/copilot/create-chat-completions" +import type { ModelsResponse } from "~/services/copilot/get-models" + +import { + copilotBaseUrl, + copilotHeaders, + GITHUB_API_BASE_URL, + githubHeaders, + standardHeaders, +} from "~/lib/api-config" +import { HTTPError } from "~/lib/error" +import { getTokenCount } from "~/lib/tokenizer" +import { + translateToOpenAI, + translateToAnthropic, +} from "~/routes/messages/non-stream-translation" +import { translateChunkToAnthropicEvents } from "~/routes/messages/stream-translation" +import { getVSCodeVersion } from "~/services/get-vscode-version" + +import type { Account } from "./account-store" + +interface ProxyInstance { + account: Account + state: State + server: ReturnType | null + status: "running" | "stopped" | "error" + error?: string + tokenInterval?: ReturnType +} + +const instances = new Map() + +function createState(account: Account): State { + return { + accountType: account.accountType, + githubToken: account.githubToken, + manualApprove: false, + rateLimitWait: false, + showToken: false, + } +} + +async function fetchCopilotToken( + st: State, +): Promise<{ token: string; refresh_in: number }> { + const response = await fetch( + `${GITHUB_API_BASE_URL}/copilot_internal/v2/token`, + { headers: githubHeaders(st) }, + ) + if (!response.ok) throw new HTTPError("Failed to get Copilot token", response) + return (await response.json()) as { token: string; refresh_in: number } +} + +async function fetchModels(st: State): Promise { + const response = await fetch(`${copilotBaseUrl(st)}/models`, { + headers: copilotHeaders(st), + }) + if (!response.ok) throw new HTTPError("Failed to get models", response) + return (await response.json()) as ModelsResponse +} + +async function setupInstanceToken(instance: ProxyInstance): Promise { + const { token, refresh_in } = await fetchCopilotToken(instance.state) + // eslint-disable-next-line require-atomic-updates + instance.state.copilotToken = token + + const refreshMs = (refresh_in - 60) * 1000 + // eslint-disable-next-line require-atomic-updates + instance.tokenInterval = setInterval(() => { + void (async () => { + try { + const result = await fetchCopilotToken(instance.state) + // eslint-disable-next-line require-atomic-updates + instance.state.copilotToken = result.token + consola.debug(`[${instance.account.name}] Copilot token refreshed`) + } catch (error) { + consola.error( + `[${instance.account.name}] Failed to refresh token:`, + error, + ) + } + })() + }, refreshMs) +} + +export async function startInstance(account: Account): Promise { + const existing = instances.get(account.id) + if (existing?.status === "running") { + consola.warn(`Instance for ${account.name} is already running`) + return + } + + const st = createState(account) + const instance: ProxyInstance = { + account, + state: st, + server: null, + status: "stopped", + } + + try { + st.vsCodeVersion = await getVSCodeVersion() + await setupInstanceToken(instance) + st.models = await fetchModels(st) + + const app = createInstanceServer(instance) + instance.server = serve({ + fetch: app.fetch as ServerHandler, + port: account.port, + }) + instance.status = "running" + instances.set(account.id, instance) + consola.success(`[${account.name}] Proxy started on port ${account.port}`) + } catch (error) { + instance.status = "error" + instance.error = (error as Error).message + instances.set(account.id, instance) + consola.error(`[${account.name}] Failed to start:`, error) + throw error + } +} + +export async function stopInstance(accountId: string): Promise { + const instance = instances.get(accountId) + if (!instance) return + + try { + if (instance.tokenInterval) clearInterval(instance.tokenInterval) + if (instance.server) await instance.server.close() + instance.status = "stopped" + instance.server = null + consola.info(`[${instance.account.name}] Proxy stopped`) + } catch (error) { + consola.error(`[${instance.account.name}] Error stopping:`, error) + } +} + +export function getInstanceStatus(accountId: string): ProxyInstance["status"] { + return instances.get(accountId)?.status ?? "stopped" +} + +export function getInstanceError(accountId: string): string | undefined { + return instances.get(accountId)?.error +} + +export async function getInstanceUsage(accountId: string): Promise { + const instance = instances.get(accountId) + if (!instance || instance.status !== "running") return null + try { + const response = await fetch( + `${GITHUB_API_BASE_URL}/copilot_internal/user`, + { headers: githubHeaders(instance.state) }, + ) + if (!response.ok) return null + return await response.json() + } catch { + return null + } +} + +export async function getInstanceUser( + accountId: string, +): Promise<{ login: string } | null> { + const instance = instances.get(accountId) + if (!instance) return null + try { + const response = await fetch(`${GITHUB_API_BASE_URL}/user`, { + headers: { + authorization: `token ${instance.state.githubToken}`, + ...standardHeaders(), + }, + }) + if (!response.ok) return null + return (await response.json()) as { login: string } + } catch { + return null + } +} + +// === Per-instance server === + +function createInstanceServer(instance: ProxyInstance): Hono { + const app = new Hono() + app.use(logger()) + app.use(cors()) + + const st = instance.state + + app.get("/", (c) => c.text(`Proxy running for ${instance.account.name}`)) + + app.post("/chat/completions", (c) => completionsHandler(c, st)) + app.post("/v1/chat/completions", (c) => completionsHandler(c, st)) + app.get("/models", (c) => modelsHandler(c, st)) + app.get("/v1/models", (c) => modelsHandler(c, st)) + app.post("/embeddings", (c) => embeddingsHandler(c, st)) + app.post("/v1/embeddings", (c) => embeddingsHandler(c, st)) + app.post("/v1/messages", (c) => messagesHandler(c, st)) + app.post("/v1/messages/count_tokens", (c) => countTokensHandler(c, st)) + + app.get("/usage", async (c) => { + try { + const response = await fetch( + `${GITHUB_API_BASE_URL}/copilot_internal/user`, + { headers: githubHeaders(st) }, + ) + if (!response.ok) throw new HTTPError("Failed to get usage", response) + return c.json(await response.json()) + } catch (error) { + return c.json({ error: (error as Error).message }, 500) + } + }) + + app.get("/token", (c) => c.json({ token: st.copilotToken })) + + return app +} + +interface CompletionsPayload { + messages: Array<{ + role: string + content: unknown + }> + model: string + max_tokens?: number + stream?: boolean +} + +function hasVisionContent(messages: CompletionsPayload["messages"]): boolean { + return messages.some( + (x) => + typeof x.content !== "string" + && Array.isArray(x.content) + && (x.content as Array<{ type: string }>).some( + (p) => p.type === "image_url", + ), + ) +} + +function isAgentRequest(messages: CompletionsPayload["messages"]): boolean { + return messages.some((msg) => ["assistant", "tool"].includes(msg.role)) +} + +async function completionsHandler(c: Context, st: State) { + try { + const payload = await c.req.json() + + const headers: Record = { + ...copilotHeaders(st, hasVisionContent(payload.messages)), + "X-Initiator": isAgentRequest(payload.messages) ? "agent" : "user", + } + + if (!payload.max_tokens) { + const model = st.models?.data.find((m) => m.id === payload.model) + if (model) { + payload.max_tokens = model.capabilities.limits.max_output_tokens + } + } + + const response = await fetch(`${copilotBaseUrl(st)}/chat/completions`, { + method: "POST", + headers, + body: JSON.stringify(payload), + }) + + if (!response.ok) { + const text = await response.text() + return c.json( + { error: { message: text, type: "error" } }, + response.status as 400, + ) + } + + if (payload.stream) { + return new Response(response.body, { + headers: { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }, + }) + } + + return c.json(await response.json()) + } catch (error) { + return c.json( + { error: { message: (error as Error).message, type: "error" } }, + 500, + ) + } +} + +function modelsHandler(c: Context, st: State) { + const models = + st.models?.data.map((model) => ({ + id: model.id, + object: "model", + type: "model", + created: 0, + created_at: new Date(0).toISOString(), + owned_by: model.vendor, + display_name: model.name, + })) ?? [] + return c.json({ object: "list", data: models, has_more: false }) +} + +async function embeddingsHandler(c: Context, st: State) { + try { + const payload = await c.req.json>() + const response = await fetch(`${copilotBaseUrl(st)}/embeddings`, { + method: "POST", + headers: copilotHeaders(st), + body: JSON.stringify(payload), + }) + if (!response.ok) + throw new HTTPError("Failed to create embeddings", response) + return c.json(await response.json()) + } catch (error) { + return c.json( + { error: { message: (error as Error).message, type: "error" } }, + 500, + ) + } +} + +async function messagesHandler(c: Context, st: State) { + try { + const anthropicPayload = await c.req.json() + const openAIPayload = translateToOpenAI(anthropicPayload) + + if (!openAIPayload.max_tokens) { + const model = st.models?.data.find((m) => m.id === openAIPayload.model) + if (model) { + openAIPayload.max_tokens = model.capabilities.limits.max_output_tokens + } + } + + const enableVision = openAIPayload.messages.some( + (x) => + typeof x.content !== "string" + && Array.isArray(x.content) + && x.content.some((p) => p.type === "image_url"), + ) + + const isAgentCall = openAIPayload.messages.some((msg) => + ["assistant", "tool"].includes(msg.role), + ) + + const headers: Record = { + ...copilotHeaders(st, enableVision), + "X-Initiator": isAgentCall ? "agent" : "user", + } + + const response = await fetch(`${copilotBaseUrl(st)}/chat/completions`, { + method: "POST", + headers, + body: JSON.stringify(openAIPayload), + }) + + if (!response.ok) { + const text = await response.text() + return c.json( + { error: { message: text, type: "error" } }, + response.status as 400, + ) + } + + if (openAIPayload.stream) { + return streamSSE(c, async (stream) => { + const streamState = { + messageStartSent: false, + contentBlockIndex: 0, + contentBlockOpen: false, + toolCalls: {} as Record< + number, + { id: string; name: string; anthropicBlockIndex: number } + >, + } + + const eventStream = events(response) + for await (const rawEvent of eventStream) { + if (rawEvent.data === "[DONE]") break + if (!rawEvent.data) continue + + const chunk = JSON.parse(rawEvent.data) as ChatCompletionChunk + const translated = translateChunkToAnthropicEvents(chunk, streamState) + for (const event of translated) { + await stream.writeSSE({ + event: event.type, + data: JSON.stringify(event), + }) + } + } + }) + } + + const openAIResponse = (await response.json()) as ChatCompletionResponse + return c.json(translateToAnthropic(openAIResponse)) + } catch (error) { + return c.json( + { error: { message: (error as Error).message, type: "error" } }, + 500, + ) + } +} + +async function countTokensHandler(c: Context, st: State) { + try { + const anthropicPayload = await c.req.json() + const openAIPayload: ChatCompletionsPayload = + translateToOpenAI(anthropicPayload) + + const selectedModel = st.models?.data.find( + (m) => m.id === anthropicPayload.model, + ) + if (!selectedModel) return c.json({ input_tokens: 1 }) + + const tokenCount = await getTokenCount(openAIPayload, selectedModel) + let finalTokenCount = tokenCount.input + tokenCount.output + + if (anthropicPayload.model.startsWith("claude")) { + finalTokenCount = Math.round(finalTokenCount * 1.15) + } else if (anthropicPayload.model.startsWith("grok")) { + finalTokenCount = Math.round(finalTokenCount * 1.03) + } + + return c.json({ input_tokens: finalTokenCount }) + } catch { + return c.json({ input_tokens: 1 }) + } +} diff --git a/src/console/port-check.ts b/src/console/port-check.ts new file mode 100644 index 000000000..e7281b5af --- /dev/null +++ b/src/console/port-check.ts @@ -0,0 +1,74 @@ +import { createServer } from "node:net" + +import { getAccounts, type Account } from "./account-store" + +/** + * Check if a port is available by attempting to bind to it. + */ +export function isPortAvailable(port: number): Promise { + return new Promise((resolve) => { + const server = createServer() + server.once("error", () => resolve(false)) + server.once("listening", () => { + server.close(() => resolve(true)) + }) + server.listen(port, "0.0.0.0") + }) +} + +export interface PortConflict { + port: number + conflict: "account" | "system" + accountName?: string +} + +/** + * Check a port against existing accounts and system availability. + * `excludeAccountId` allows skipping the current account when updating. + */ +export async function checkPortConflict( + port: number, + excludeAccountId?: string, +): Promise { + const accounts = await getAccounts() + const conflicting = accounts.find( + (a) => a.port === port && a.id !== excludeAccountId, + ) + + if (conflicting) { + return { port, conflict: "account", accountName: conflicting.name } + } + + const available = await isPortAvailable(port) + if (!available) { + return { port, conflict: "system" } + } + + return null +} + +/** + * Find the next available port starting from a given port. + */ +export async function findAvailablePort( + startPort: number, + excludeAccountId?: string, +): Promise { + const accounts = await getAccounts() + const usedPorts = new Set( + accounts + .filter((a: Account) => a.id !== excludeAccountId) + .map((a: Account) => a.port), + ) + + let port = startPort + while (port <= 65535) { + if (!usedPorts.has(port)) { + const available = await isPortAvailable(port) + if (available) return port + } + port++ + } + + throw new Error("No available port found") +} diff --git a/src/main.ts b/src/main.ts index 4f6ca784b..0dfc71e75 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,6 +4,7 @@ import { defineCommand, runMain } from "citty" import { auth } from "./auth" import { checkUsage } from "./check-usage" +import { console_ } from "./console/index" import { debug } from "./debug" import { start } from "./start" @@ -13,7 +14,13 @@ const main = defineCommand({ description: "A wrapper around GitHub Copilot API to make it OpenAI compatible, making it usable for other tools.", }, - subCommands: { auth, start, "check-usage": checkUsage, debug }, + subCommands: { + auth, + start, + "check-usage": checkUsage, + debug, + console: console_, + }, }) await runMain(main) diff --git a/web/bun.lock b/web/bun.lock new file mode 100644 index 000000000..ed4a70380 --- /dev/null +++ b/web/bun.lock @@ -0,0 +1,257 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "copilot-api-console", + "dependencies": { + "react": "^19.1.0", + "react-dom": "^19.1.0", + }, + "devDependencies": { + "@types/react": "^19.1.6", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.5.2", + "typescript": "^5.9.3", + "vite": "^6.3.5", + }, + }, + }, + "packages": { + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + + "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], + + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="], + + "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.57.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.57.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.57.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.57.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.57.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.57.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.57.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.57.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="], + + "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="], + + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + + "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + + "rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + } +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 000000000..d0c45037a --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + Copilot API Console + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 000000000..60c8bfff6 --- /dev/null +++ b/web/package.json @@ -0,0 +1,22 @@ +{ + "name": "copilot-api-console", + "private": true, + "description": "Web console for managing multiple GitHub Copilot proxy accounts", + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@types/react": "^19.1.6", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.5.2", + "typescript": "^5.9.3", + "vite": "^6.3.5" + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 000000000..aaf42ddec --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,108 @@ +import { useState, useEffect, useCallback } from "react" + +import { api, type Account } from "./api" +import { AccountCard } from "./components/AccountCard" +import { AddAccountForm } from "./components/AddAccountForm" + +function AccountList({ + accounts, + onRefresh, +}: { + accounts: Array + onRefresh: () => Promise +}) { + if (accounts.length === 0) { + return ( +
+

No accounts configured

+

Add a GitHub account to get started

+
+ ) + } + + return ( +
+ {accounts.map((account) => ( + + ))} +
+ ) +} + +export function App() { + const [accounts, setAccounts] = useState>([]) + const [showForm, setShowForm] = useState(false) + const [loading, setLoading] = useState(true) + + const refresh = useCallback(async () => { + try { + const data = await api.getAccounts() + setAccounts(data) + } catch (err) { + console.error("Failed to fetch accounts:", err) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + void refresh() + const interval = setInterval(() => void refresh(), 5000) + return () => clearInterval(interval) + }, [refresh]) + + const handleAdd = async () => { + setShowForm(false) + await refresh() + } + + return ( +
+
+
+

Copilot API Console

+

+ Manage multiple GitHub Copilot proxy accounts +

+
+ +
+ + {showForm && ( + setShowForm(false)} + /> + )} + + {loading ? +

+ Loading... +

+ : } +
+ ) +} diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 000000000..4a5ba5c85 --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,126 @@ +const BASE = "/api" + +export interface Account { + id: string + name: string + githubToken: string + accountType: string + port: number + enabled: boolean + createdAt: string + status?: "running" | "stopped" | "error" + error?: string + user?: { login: string } | null +} + +export interface UsageData { + copilot_plan: string + quota_reset_date: string + quota_snapshots: { + premium_interactions: QuotaDetail + chat: QuotaDetail + completions: QuotaDetail + } +} + +interface QuotaDetail { + entitlement: number + remaining: number + percent_remaining: number +} + +export interface DeviceCodeResponse { + sessionId: string + userCode: string + verificationUri: string + expiresIn: number +} + +export interface AuthPollResponse { + status: "pending" | "completed" | "expired" | "error" + accessToken?: string + error?: string +} + +interface ErrorBody { + error?: string +} + +async function request(path: string, options?: RequestInit): Promise { + const res = await fetch(`${BASE}${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + ...(options?.headers as Record), + }, + }) + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as ErrorBody + throw new Error(body.error ?? `HTTP ${res.status}`) + } + return res.json() as Promise +} + +export const api = { + getAccounts: () => request>("/accounts"), + + addAccount: (data: { + name: string + githubToken: string + accountType: string + port: number + }) => + request("/accounts", { + method: "POST", + body: JSON.stringify(data), + }), + + updateAccount: (id: string, data: Partial) => + request(`/accounts/${id}`, { + method: "PUT", + body: JSON.stringify(data), + }), + + deleteAccount: (id: string) => + request<{ ok: boolean }>(`/accounts/${id}`, { method: "DELETE" }), + + startInstance: (id: string) => + request<{ status: string }>(`/accounts/${id}/start`, { method: "POST" }), + + stopInstance: (id: string) => + request<{ status: string }>(`/accounts/${id}/stop`, { method: "POST" }), + + getUsage: (id: string) => request(`/accounts/${id}/usage`), + + startDeviceCode: () => + request("/auth/device-code", { method: "POST" }), + + pollAuth: (sessionId: string) => + request(`/auth/poll/${sessionId}`), + + completeAuth: (data: { + sessionId: string + name: string + accountType: string + port: number + }) => + request("/auth/complete", { + method: "POST", + body: JSON.stringify(data), + }), + + checkPort: (port: number, excludeId?: string) => + request<{ + available: boolean + conflict?: string + accountName?: string + }>(`/port/check/${port}${excludeId ? `?exclude=${excludeId}` : ""}`), + + suggestPort: (start?: number, excludeId?: string) => { + const params = new URLSearchParams() + if (start !== undefined) params.set("start", String(start)) + if (excludeId) params.set("exclude", excludeId) + const qs = params.toString() + return request<{ port: number }>(`/port/suggest${qs ? `?${qs}` : ""}`) + }, +} diff --git a/web/src/components/AccountCard.tsx b/web/src/components/AccountCard.tsx new file mode 100644 index 000000000..7f7c1ddd9 --- /dev/null +++ b/web/src/components/AccountCard.tsx @@ -0,0 +1,334 @@ +import { useState } from "react" + +import { api, type Account, type UsageData } from "../api" + +function StatusBadge({ status }: { status: string }) { + const colorMap: Record = { + running: "var(--green)", + stopped: "var(--text-muted)", + error: "var(--red)", + } + const color = colorMap[status] ?? "var(--text-muted)" + return ( + + + {status} + + ) +} + +function QuotaBar({ + label, + used, + total, +}: { + label: string + used: number + total: number +}) { + const pct = total > 0 ? (used / total) * 100 : 0 + let color = "var(--green)" + if (pct > 90) color = "var(--red)" + else if (pct > 70) color = "var(--yellow)" + + return ( +
+
+ {label} + + {used} / {total} ({pct.toFixed(1)}%) + +
+
+
+
+
+ ) +} + +function UsagePanel({ usage }: { usage: UsageData }) { + const q = usage.quota_snapshots + return ( +
+
+ Plan: {usage.copilot_plan} · Resets: {usage.quota_reset_date} +
+ + + +
+ ) +} + +function EndpointsPanel({ port }: { port: number }) { + return ( +
+
+ Endpoints +
+
OpenAI: http://localhost:{port}/v1/chat/completions
+
Anthropic: http://localhost:{port}/v1/messages
+
Models: http://localhost:{port}/v1/models
+
+ ) +} + +function getUsageLabel(loading: boolean, visible: boolean): string { + if (loading) return "..." + if (visible) return "Hide Usage" + return "Usage" +} + +interface Props { + account: Account + onRefresh: () => Promise +} + +function AccountActions({ + account, + status, + onRefresh, +}: { + account: Account + status: string + onRefresh: () => Promise +}) { + const [actionLoading, setActionLoading] = useState(false) + const [confirmDelete, setConfirmDelete] = useState(false) + + const handleAction = async (action: () => Promise) => { + setActionLoading(true) + try { + await action() + await onRefresh() + } catch (err) { + console.error("Action failed:", err) + } finally { + setActionLoading(false) + } + } + + const handleDelete = async () => { + if (!confirmDelete) { + setConfirmDelete(true) + setTimeout(() => setConfirmDelete(false), 3000) + return + } + setActionLoading(true) + try { + await api.deleteAccount(account.id) + await onRefresh() + } catch (err) { + console.error("Delete failed:", err) + } finally { + setActionLoading(false) + setConfirmDelete(false) + } + } + + return ( +
+ {status === "running" ? + + : + } + +
+ ) +} + +function UsageSection({ accountId }: { accountId: string }) { + const [usage, setUsage] = useState(null) + const [usageLoading, setUsageLoading] = useState(false) + const [showUsage, setShowUsage] = useState(false) + + const handleToggleUsage = async () => { + if (showUsage) { + setShowUsage(false) + return + } + setUsageLoading(true) + try { + const data = await api.getUsage(accountId) + setUsage(data) + setShowUsage(true) + } catch { + setUsage(null) + setShowUsage(true) + } finally { + setUsageLoading(false) + } + } + + return ( + <> + + {showUsage + && (usage ? + + :
+ Usage data unavailable. Make sure the instance is running. +
)} + + ) +} + +export function AccountCard({ account, onRefresh }: Props) { + const status = account.status ?? "stopped" + + return ( +
+
+
+
+ + {account.name} + + +
+
+ {account.user?.login ? `@${account.user.login} · ` : ""} + Port {account.port} · {account.accountType} +
+ {account.error && ( +
+ Error: {account.error} +
+ )} +
+ + +
+ + {status === "running" && } + {status === "running" && } +
+ ) +} diff --git a/web/src/components/AddAccountForm.tsx b/web/src/components/AddAccountForm.tsx new file mode 100644 index 000000000..324c0b04d --- /dev/null +++ b/web/src/components/AddAccountForm.tsx @@ -0,0 +1,514 @@ +import { useState, useEffect, useRef, useCallback } from "react" + +import { api } from "../api" + +interface Props { + onComplete: () => Promise + onCancel: () => void +} + +type Step = "config" | "authorize" | "done" +type PortStatus = "idle" | "checking" | "ok" | "conflict" + +function PortIndicator({ status }: { status: PortStatus }) { + if (status === "idle") return null + const colorMap: Record = { + ok: "var(--green)", + conflict: "var(--red)", + checking: "var(--yellow)", + } + return ( + + ) +} + +function DeviceCodeDisplay({ + userCode, + verificationUri, +}: { + userCode: string + verificationUri: string +}) { + return ( +
+

+ Enter this code on GitHub: +

+
void navigator.clipboard.writeText(userCode)} + style={{ + display: "inline-block", + padding: "12px 24px", + background: "var(--bg)", + border: "2px solid var(--accent)", + borderRadius: "var(--radius)", + fontSize: 28, + fontWeight: 700, + fontFamily: "monospace", + letterSpacing: 4, + cursor: "pointer", + userSelect: "all", + marginBottom: 8, + }} + title="Click to copy" + > + {userCode} +
+

+ Click the code to copy +

+ + Open GitHub + +
+ ) +} + +function AuthorizeStep({ + userCode, + verificationUri, + authStatus, + error, + onCancel, +}: { + userCode: string + verificationUri: string + authStatus: string + error: string + onCancel: () => void +}) { + return ( +
+

+ GitHub Authorization +

+ +

+ + {authStatus} +

+ {error && ( +
+ {error} +
+ )} +
+ +
+
+ ) +} + +function usePortCheck() { + const [portStatus, setPortStatus] = useState("idle") + const [portMessage, setPortMessage] = useState("") + const timerRef = useRef | null>(null) + + const check = useCallback((value: string) => { + if (timerRef.current) clearTimeout(timerRef.current) + const portNum = Number.parseInt(value, 10) + if (Number.isNaN(portNum) || portNum < 1024 || portNum > 65535) { + setPortStatus("conflict") + setPortMessage("Port must be 1024–65535") + return + } + setPortStatus("checking") + setPortMessage("") + timerRef.current = setTimeout(() => { + void (async () => { + try { + const result = await api.checkPort(portNum) + if (result.available) { + setPortStatus("ok") + setPortMessage("") + } else { + setPortStatus("conflict") + setPortMessage( + result.conflict === "account" ? + `Used by "${result.accountName}"` + : "Occupied by another process", + ) + } + } catch { + setPortStatus("idle") + } + })() + }, 400) + }, []) + + return { portStatus, portMessage, check, setPortStatus, setPortMessage } +} + +function getPortBorderColor(status: PortStatus): string | undefined { + if (status === "conflict") return "var(--red)" + if (status === "ok") return "var(--green)" + return undefined +} + +function PortField({ + port, + portStatus, + portMessage, + onPortChange, + onAutoPort, +}: { + port: string + portStatus: PortStatus + portMessage: string + onPortChange: (v: string) => void + onAutoPort: () => void +}) { + return ( +
+ +
+ onPortChange(e.target.value)} + placeholder="4141" + style={{ borderColor: getPortBorderColor(portStatus) }} + /> + +
+ {portMessage && ( +
+ {portMessage} +
+ )} +
+ ) +} + +interface ConfigFormProps { + onSubmit: (e: React.SyntheticEvent) => void + onCancel: () => void + loading: boolean + error: string + portStatus: PortStatus + portMessage: string + name: string + setName: (v: string) => void + accountType: string + setAccountType: (v: string) => void + port: string + onPortChange: (v: string) => void + onAutoPort: () => void +} + +function ConfigForm(props: ConfigFormProps) { + const isDisabled = + props.loading + || props.portStatus === "conflict" + || props.portStatus === "checking" + + return ( +
+

+ Add Account +

+
+
+ + props.setName(e.target.value)} + placeholder="e.g. Personal" + /> +
+ +
+
+ + +
+ {props.error && ( +
+ {props.error} +
+ )} +
+ + +
+
+ ) +} + +function useAuthFlow(onComplete: () => Promise) { + const [step, setStep] = useState("config") + const [userCode, setUserCode] = useState("") + const [verificationUri, setVerificationUri] = useState("") + const [authStatus, setAuthStatus] = useState("") + const [loading, setLoading] = useState(false) + const [error, setError] = useState("") + const pollRef = useRef | null>(null) + + const cleanup = useCallback(() => { + if (pollRef.current) { + clearInterval(pollRef.current) + pollRef.current = null + } + }, []) + + useEffect(() => cleanup, [cleanup]) + + const startAuth = async ( + name: string, + accountType: string, + portNum: number, + ) => { + setError("") + setLoading(true) + try { + const result = await api.startDeviceCode() + setUserCode(result.userCode) + setVerificationUri(result.verificationUri) + setStep("authorize") + setAuthStatus("Waiting for authorization...") + + pollRef.current = setInterval(() => { + void (async () => { + try { + const poll = await api.pollAuth(result.sessionId) + if (poll.status === "completed") { + cleanup() + setAuthStatus("Authorized! Creating account...") + await api.completeAuth({ + sessionId: result.sessionId, + name, + accountType, + port: portNum, + }) + setStep("done") + await onComplete() + } else if (poll.status === "expired" || poll.status === "error") { + cleanup() + setAuthStatus("") + setError(poll.error ?? "Authorization failed or expired") + } + } catch { + // poll error, keep trying + } + })() + }, 3000) + } catch (err) { + setError((err as Error).message) + } finally { + setLoading(false) + } + } + + return { + step, + userCode, + verificationUri, + authStatus, + loading, + error, + setError, + cleanup, + startAuth, + } +} + +export function AddAccountForm({ onComplete, onCancel }: Props) { + const [name, setName] = useState("") + const [accountType, setAccountType] = useState("individual") + const [port, setPort] = useState("") + const { portStatus, portMessage, check, setPortStatus, setPortMessage } = + usePortCheck() + const auth = useAuthFlow(onComplete) + + useEffect(() => { + void api.suggestPort(4141).then((res) => { + setPort(String(res.port)) + setPortStatus("ok") + setPortMessage("") + }) + }, [setPortStatus, setPortMessage]) + + const handlePortChange = (value: string) => { + setPort(value) + check(value) + } + + const handleAutoPort = () => { + void (async () => { + try { + const res = await api.suggestPort(Number.parseInt(port, 10) || 4141) + setPort(String(res.port)) + setPortStatus("ok") + setPortMessage("") + } catch { + auth.setError("Failed to find available port") + } + })() + } + + const handleSubmit = (e: React.SyntheticEvent) => { + e.preventDefault() + if (!name.trim()) { + auth.setError("Account name is required") + return + } + const portNum = Number.parseInt(port, 10) + if (Number.isNaN(portNum) || portNum < 1024 || portNum > 65535) { + auth.setError("Port must be between 1024 and 65535") + return + } + if (portStatus === "conflict") { + auth.setError("Please resolve the port conflict first") + return + } + void auth.startAuth(name.trim(), accountType, portNum) + } + + if (auth.step === "done") return null + + const wrapperStyle = { + background: "var(--bg-card)", + border: "1px solid var(--border)", + borderRadius: "var(--radius)", + padding: 20, + marginBottom: 16, + } + + return ( +
+ {auth.step === "config" && ( + + )} + {auth.step === "authorize" && ( + { + auth.cleanup() + onCancel() + }} + /> + )} + +
+ ) +} diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 000000000..e720b8f9b --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,105 @@ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --bg: #0d1117; + --bg-card: #161b22; + --bg-input: #0d1117; + --border: #30363d; + --text: #e6edf3; + --text-muted: #8b949e; + --accent: #58a6ff; + --accent-hover: #79c0ff; + --green: #3fb950; + --red: #f85149; + --yellow: #d29922; + --radius: 8px; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, + sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.5; +} + +button { + cursor: pointer; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 6px 16px; + font-size: 14px; + background: var(--bg-card); + color: var(--text); + transition: background 0.15s; +} + +button:hover { + background: var(--border); +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +button.primary { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} + +button.primary:hover { + background: var(--accent-hover); +} + +button.danger { + color: var(--red); + border-color: var(--red); +} + +button.danger:hover { + background: rgba(248, 81, 73, 0.15); +} + +input, +select { + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 8px 12px; + color: var(--text); + font-size: 14px; + width: 100%; +} + +input:focus, +select:focus { + outline: none; + border-color: var(--accent); +} + +label { + display: block; + font-size: 13px; + color: var(--text-muted); + margin-bottom: 4px; +} + +/* Hide number input spinners */ +input[type="number"]::-webkit-outer-spin-button, +input[type="number"]::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +input[type="number"] { + -moz-appearance: textfield; + appearance: textfield; +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 000000000..c047c907c --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,14 @@ +import { StrictMode } from "react" +import { createRoot } from "react-dom/client" + +import { App } from "./App" +import "./index.css" + +const root = document.querySelector("#root") +if (root) { + createRoot(root).render( + + + , + ) +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 000000000..109f0ac28 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 000000000..d9b9c19d8 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,14 @@ +import react from "@vitejs/plugin-react" +import { defineConfig } from "vite" + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + "/api": "http://localhost:3000", + }, + }, + build: { + outDir: "dist", + }, +}) From edee63fa41ae412c1364a3412500f3765bece0f6 Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Fri, 13 Feb 2026 19:09:42 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E9=89=B4=E6=9D=83?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8Dbug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/.gitignore | 10 + .idea/codeStyles/Project.xml | 59 ++++ .idea/codeStyles/codeStyleConfig.xml | 5 + .idea/copilot-api.iml | 8 + .idea/inspectionProfiles/Project_Default.xml | 6 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + Dockerfile.console | 6 +- eslint.config.js | 1 + src/console/account-store.ts | 30 ++- src/console/api.ts | 165 +++++------- src/console/index.ts | 235 +++++++++++----- src/console/instance-manager.ts | 112 +++----- src/console/port-check.ts | 74 ----- web/src/App.tsx | 72 ++++- web/src/api.ts | 65 ++--- web/src/components/AccountCard.tsx | 213 +++++++++++---- web/src/components/AddAccountForm.tsx | 267 +++---------------- web/vite.config.ts | 5 + 19 files changed, 698 insertions(+), 649 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/codeStyles/Project.xml create mode 100644 .idea/codeStyles/codeStyleConfig.xml create mode 100644 .idea/copilot-api.iml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml delete mode 100644 src/console/port-check.ts diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 000000000..b6b1ecf10 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 已忽略包含查询文件的默认文件夹 +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 000000000..238b56b02 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 000000000..79ee123c2 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/copilot-api.iml b/.idea/copilot-api.iml new file mode 100644 index 000000000..c956989b2 --- /dev/null +++ b/.idea/copilot-api.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 000000000..03d9549ea --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 000000000..b5e6487a4 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 000000000..35eb1ddfb --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Dockerfile.console b/Dockerfile.console index 08fb87cb9..858a8f7b7 100644 --- a/Dockerfile.console +++ b/Dockerfile.console @@ -28,12 +28,12 @@ COPY --from=builder /app/src ./src COPY --from=builder /app/tsconfig.json ./ COPY --from=web-builder /app/web/dist ./web/dist -EXPOSE 3000 +EXPOSE 3000 4141 VOLUME /root/.local/share/copilot-api HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ - CMD wget --spider -q http://localhost:3000/api/accounts || exit 1 + CMD wget --spider -q http://localhost:3000/api/auth/check || exit 1 ENTRYPOINT ["bun", "run", "./src/main.ts", "console"] -CMD ["--port", "3000"] +CMD ["--web-port", "3000", "--proxy-port", "4141"] diff --git a/eslint.config.js b/eslint.config.js index c9f79bea5..d4aa43065 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -4,4 +4,5 @@ export default config({ prettier: { plugins: ["prettier-plugin-packagejson"], }, + ignores: ["web/**"], }) diff --git a/src/console/account-store.ts b/src/console/account-store.ts index 660fd45c3..35b55e360 100644 --- a/src/console/account-store.ts +++ b/src/console/account-store.ts @@ -1,3 +1,4 @@ +import crypto from "node:crypto" import fs from "node:fs/promises" import path from "node:path" @@ -8,7 +9,7 @@ export interface Account { name: string githubToken: string accountType: string - port: number + apiKey: string enabled: boolean createdAt: string } @@ -19,6 +20,10 @@ export interface AccountStore { const STORE_PATH = path.join(PATHS.APP_DIR, "accounts.json") +function generateApiKey(): string { + return `cpa-${crypto.randomBytes(16).toString("hex")}` +} + async function readStore(): Promise { try { const data = await fs.readFile(STORE_PATH) @@ -42,13 +47,21 @@ export async function getAccount(id: string): Promise { return store.accounts.find((a) => a.id === id) } +export async function getAccountByApiKey( + apiKey: string, +): Promise { + const store = await readStore() + return store.accounts.find((a) => a.apiKey === apiKey) +} + export async function addAccount( - account: Omit, + account: Omit, ): Promise { const store = await readStore() const newAccount: Account = { ...account, id: crypto.randomUUID(), + apiKey: generateApiKey(), createdAt: new Date().toISOString(), } store.accounts.push(newAccount) @@ -58,7 +71,7 @@ export async function addAccount( export async function updateAccount( id: string, - updates: Partial>, + updates: Partial>, ): Promise { const store = await readStore() const index = store.accounts.findIndex((a) => a.id === id) @@ -76,3 +89,14 @@ export async function deleteAccount(id: string): Promise { await writeStore(store) return true } + +export async function regenerateApiKey( + id: string, +): Promise { + const store = await readStore() + const index = store.accounts.findIndex((a) => a.id === id) + if (index === -1) return undefined + store.accounts[index] = { ...store.accounts[index], apiKey: generateApiKey() } + await writeStore(store) + return store.accounts[index] +} diff --git a/src/console/api.ts b/src/console/api.ts index 913cd45bb..bea4775ac 100644 --- a/src/console/api.ts +++ b/src/console/api.ts @@ -3,45 +3,33 @@ import { cors } from "hono/cors" import { z } from "zod" import { - getAccounts, - getAccount, addAccount, - updateAccount, deleteAccount, + getAccount, + getAccounts, + regenerateApiKey, + updateAccount, } from "./account-store" -import { startDeviceFlow, getSession, cleanupSession } from "./auth-flow" +import { cleanupSession, getSession, startDeviceFlow } from "./auth-flow" import { - startInstance, - stopInstance, - getInstanceStatus, getInstanceError, + getInstanceStatus, getInstanceUsage, getInstanceUser, + startInstance, + stopInstance, } from "./instance-manager" -import { checkPortConflict, findAvailablePort } from "./port-check" -const AddAccountSchema = z.object({ - name: z.string().min(1), - githubToken: z.string().min(1), - accountType: z.string().default("individual"), - port: z.number().int().min(1024).max(65535), - enabled: z.boolean().default(true), -}) +let adminKey = "" -const UpdateAccountSchema = z.object({ - name: z.string().min(1).optional(), - githubToken: z.string().min(1).optional(), - accountType: z.string().optional(), - port: z.number().int().min(1024).max(65535).optional(), - enabled: z.boolean().optional(), -}) +export function setAdminKey(key: string): void { + adminKey = key +} +let proxyPort = 4141 -const CompleteAuthSchema = z.object({ - sessionId: z.string().min(1), - name: z.string().min(1), - accountType: z.string().default("individual"), - port: z.number().int().min(1024).max(65535), -}) +export function setProxyPort(port: number): void { + proxyPort = port +} function formatZodError(err: z.ZodError): string { return z.treeifyError(err).children ? @@ -49,20 +37,27 @@ function formatZodError(err: z.ZodError): string { : err.message } -function portConflictMessage(conflict: { - port: number - conflict: string - accountName?: string -}): string { - return conflict.conflict === "account" ? - `Port ${conflict.port} is already used by account "${conflict.accountName}"` - : `Port ${conflict.port} is already in use by another process` -} - export const consoleApi = new Hono() consoleApi.use(cors()) +// Public config (no auth required) +consoleApi.get("/config", (c) => c.json({ proxyPort })) + +// Admin auth middleware +consoleApi.use("/*", async (c, next) => { + if (!adminKey) return next() + const auth = c.req.header("authorization") + const token = auth?.replace("Bearer ", "") + if (token !== adminKey) { + return c.json({ error: "Unauthorized" }, 401) + } + return next() +}) + +// Auth check endpoint (for frontend login) +consoleApi.get("/auth/check", (c) => c.json({ ok: true })) + // List all accounts with status consoleApi.get("/accounts", async (c) => { const accounts = await getAccounts() @@ -93,6 +88,13 @@ consoleApi.get("/accounts/:id", async (c) => { return c.json({ ...account, status, error }) }) +const AddAccountSchema = z.object({ + name: z.string().min(1), + githubToken: z.string().min(1), + accountType: z.string().default("individual"), + enabled: z.boolean().default(true), +}) + // Add account consoleApi.post("/accounts", async (c) => { const body: unknown = await c.req.json() @@ -100,16 +102,17 @@ consoleApi.post("/accounts", async (c) => { if (!parsed.success) { return c.json({ error: formatZodError(parsed.error) }, 400) } - - const conflict = await checkPortConflict(parsed.data.port) - if (conflict) { - return c.json({ error: portConflictMessage(conflict) }, 409) - } - const account = await addAccount(parsed.data) return c.json(account, 201) }) +const UpdateAccountSchema = z.object({ + name: z.string().min(1).optional(), + githubToken: z.string().min(1).optional(), + accountType: z.string().optional(), + enabled: z.boolean().optional(), +}) + // Update account consoleApi.put("/accounts/:id", async (c) => { const body: unknown = await c.req.json() @@ -117,16 +120,7 @@ consoleApi.put("/accounts/:id", async (c) => { if (!parsed.success) { return c.json({ error: formatZodError(parsed.error) }, 400) } - - const id = c.req.param("id") - if (parsed.data.port !== undefined) { - const conflict = await checkPortConflict(parsed.data.port, id) - if (conflict) { - return c.json({ error: portConflictMessage(conflict) }, 409) - } - } - - const account = await updateAccount(id, parsed.data) + const account = await updateAccount(c.req.param("id"), parsed.data) if (!account) return c.json({ error: "Account not found" }, 404) return c.json(account) }) @@ -134,12 +128,19 @@ consoleApi.put("/accounts/:id", async (c) => { // Delete account consoleApi.delete("/accounts/:id", async (c) => { const id = c.req.param("id") - await stopInstance(id) + stopInstance(id) const deleted = await deleteAccount(id) if (!deleted) return c.json({ error: "Account not found" }, 404) return c.json({ ok: true }) }) +// Regenerate API key +consoleApi.post("/accounts/:id/regenerate-key", async (c) => { + const account = await regenerateApiKey(c.req.param("id")) + if (!account) return c.json({ error: "Account not found" }, 404) + return c.json(account) +}) + // Start instance consoleApi.post("/accounts/:id/start", async (c) => { const account = await getAccount(c.req.param("id")) @@ -153,8 +154,8 @@ consoleApi.post("/accounts/:id/start", async (c) => { }) // Stop instance -consoleApi.post("/accounts/:id/stop", async (c) => { - await stopInstance(c.req.param("id")) +consoleApi.post("/accounts/:id/stop", (c) => { + stopInstance(c.req.param("id")) return c.json({ status: "stopped" }) }) @@ -188,6 +189,12 @@ consoleApi.get("/auth/poll/:sessionId", (c) => { }) }) +const CompleteAuthSchema = z.object({ + sessionId: z.string().min(1), + name: z.string().min(1), + accountType: z.string().default("individual"), +}) + consoleApi.post("/auth/complete", async (c) => { const body: unknown = await c.req.json() const parsed = CompleteAuthSchema.safeParse(body) @@ -201,57 +208,13 @@ consoleApi.post("/auth/complete", async (c) => { return c.json({ error: "Auth not completed yet" }, 400) } - const conflict = await checkPortConflict(parsed.data.port) - if (conflict) { - return c.json({ error: portConflictMessage(conflict) }, 409) - } - const account = await addAccount({ name: parsed.data.name, githubToken: session.accessToken, accountType: parsed.data.accountType, - port: parsed.data.port, enabled: true, }) cleanupSession(parsed.data.sessionId) return c.json(account, 201) }) - -// === Port Management === - -consoleApi.get("/port/check/:port", async (c) => { - const port = Number.parseInt(c.req.param("port"), 10) - if (Number.isNaN(port) || port < 1024 || port > 65535) { - return c.json({ error: "Invalid port" }, 400) - } - - const excludeId = c.req.query("exclude") ?? undefined - const conflict = await checkPortConflict(port, excludeId) - - if (conflict) { - return c.json({ - available: false, - conflict: conflict.conflict, - accountName: conflict.accountName, - }) - } - - return c.json({ available: true }) -}) - -consoleApi.get("/port/suggest", async (c) => { - const startRaw = c.req.query("start") ?? "4141" - const start = Number.parseInt(startRaw, 10) - const excludeId = c.req.query("exclude") ?? undefined - - try { - const port = await findAvailablePort( - Number.isNaN(start) ? 4141 : start, - excludeId, - ) - return c.json({ port }) - } catch { - return c.json({ error: "No available port found" }, 500) - } -}) diff --git a/src/console/index.ts b/src/console/index.ts index 863d7af51..b55fb242e 100644 --- a/src/console/index.ts +++ b/src/console/index.ts @@ -2,15 +2,25 @@ import { defineCommand } from "citty" import consola from "consola" import { Hono } from "hono" import { cors } from "hono/cors" +import { logger } from "hono/logger" +import crypto from "node:crypto" import fs from "node:fs/promises" import path from "node:path" import { serve, type ServerHandler } from "srvx" import { ensurePaths } from "~/lib/paths" -import { getAccounts } from "./account-store" -import { consoleApi } from "./api" -import { startInstance } from "./instance-manager" +import { getAccountByApiKey, getAccounts } from "./account-store" +import { consoleApi, setAdminKey, setProxyPort } from "./api" +import { + completionsHandler, + countTokensHandler, + embeddingsHandler, + getInstanceState, + messagesHandler, + modelsHandler, + startInstance, +} from "./instance-manager" const MIME_TYPES: Record = { ".html": "text/html", @@ -28,11 +38,21 @@ export const console_ = defineCommand({ description: "Start the multi-account management console", }, args: { - port: { - alias: "p", + "web-port": { + alias: "w", type: "string", default: "3000", - description: "Port for the console web UI", + description: "Port for the web management console", + }, + "proxy-port": { + alias: "p", + type: "string", + default: "4141", + description: "Port for the proxy API endpoints", + }, + "admin-key": { + type: "string", + description: "Admin key for console access (auto-generated if not set)", }, verbose: { alias: "v", @@ -46,6 +66,7 @@ export const console_ = defineCommand({ description: "Auto-start enabled accounts on launch", }, }, + async run({ args }) { if (args.verbose) { consola.level = 5 @@ -53,80 +74,150 @@ export const console_ = defineCommand({ await ensurePaths() - const port = Number.parseInt(args.port, 10) - const app = new Hono() + const webPort = Number.parseInt(args["web-port"], 10) + const proxyPort = Number.parseInt(args["proxy-port"], 10) + const adminKeyArg = args["admin-key"] as string | undefined + const generatedAdminKey = + adminKeyArg ?? `admin-${crypto.randomBytes(8).toString("hex")}` + setAdminKey(generatedAdminKey) + setProxyPort(proxyPort) + + // === Web console server === + const webApp = new Hono() + webApp.use(cors()) + webApp.use(logger()) + webApp.route("/api", consoleApi) + await mountStaticFiles(webApp) - app.use(cors()) + // === Proxy server === + const proxyApp = new Hono() + proxyApp.use(cors()) + mountProxyRoutes(proxyApp) - // Mount API routes - app.route("/api", consoleApi) + // Auto-start enabled accounts + if (args["auto-start"]) { + await autoStartAccounts() + } + + serve({ fetch: webApp.fetch as ServerHandler, port: webPort }) + serve({ fetch: proxyApp.fetch as ServerHandler, port: proxyPort }) - // Serve static frontend - const webDistPath = path.resolve( - new URL(".", import.meta.url).pathname, - "../../web/dist", + consola.box( + [ + `🎛️ Console: http://localhost:${webPort}`, + `🔑 Admin Key: ${generatedAdminKey}`, + "", + `Proxy (port ${proxyPort}) — use account API key as Bearer token:`, + ` OpenAI: http://localhost:${proxyPort}/v1/chat/completions`, + ` Anthropic: http://localhost:${proxyPort}/v1/messages`, + ` Models: http://localhost:${proxyPort}/v1/models`, + ].join("\n"), ) + }, +}) - let hasWebDist = false - try { - await fs.access(webDistPath) - hasWebDist = true - } catch { - /* no built frontend */ - } +function mountProxyRoutes(app: Hono): void { + app.post("/chat/completions", proxyAuth, (c) => + completionsHandler(c, getState(c)), + ) + app.post("/v1/chat/completions", proxyAuth, (c) => + completionsHandler(c, getState(c)), + ) + app.get("/models", proxyAuth, (c) => modelsHandler(c, getState(c))) + app.get("/v1/models", proxyAuth, (c) => modelsHandler(c, getState(c))) + app.post("/embeddings", proxyAuth, (c) => embeddingsHandler(c, getState(c))) + app.post("/v1/embeddings", proxyAuth, (c) => + embeddingsHandler(c, getState(c)), + ) + app.post("/v1/messages", proxyAuth, (c) => messagesHandler(c, getState(c))) + app.post("/v1/messages/count_tokens", proxyAuth, (c) => + countTokensHandler(c, getState(c)), + ) +} - if (hasWebDist) { - app.get("/*", async (c) => { - const reqPath = c.req.path === "/" ? "/index.html" : c.req.path - const filePath = path.join(webDistPath, reqPath) - - try { - const content = await fs.readFile(filePath) - const ext = path.extname(filePath) - return c.body(content.buffer as ArrayBuffer, { - headers: { - "content-type": MIME_TYPES[ext] ?? "application/octet-stream", - }, - }) - } catch { - // SPA fallback - const indexContent = await fs.readFile( - path.join(webDistPath, "index.html"), - "utf8", - ) - return c.html(indexContent) - } - }) - } else { - app.get("/", (c) => - c.text( - "Console API running. Build the web UI with: cd web && bun install && bun run build", - ), - ) - } +async function mountStaticFiles(app: Hono): Promise { + const webDistPath = path.resolve( + new URL(".", import.meta.url).pathname, + "../../web/dist", + ) - // Auto-start enabled accounts - if (args["auto-start"]) { - const accounts = await getAccounts() - for (const account of accounts) { - if (account.enabled) { - try { - await startInstance(account) - } catch (error) { - consola.error( - `Failed to auto-start account "${account.name}":`, - error, - ) - } - } + let hasWebDist = false + try { + await fs.access(webDistPath) + hasWebDist = true + } catch { + /* no built frontend */ + } + + if (hasWebDist) { + app.get("/*", async (c) => { + const reqPath = c.req.path === "/" ? "/index.html" : c.req.path + const filePath = path.join(webDistPath, reqPath) + try { + const content = await fs.readFile(filePath) + const ext = path.extname(filePath) + const contentType = MIME_TYPES[ext] ?? "application/octet-stream" + const cacheControl = + reqPath.startsWith("/assets/") ? + "public, max-age=31536000, immutable" + : "no-cache" + return c.body(content.buffer as ArrayBuffer, { + headers: { + "content-type": contentType, + "cache-control": cacheControl, + }, + }) + } catch { + const indexContent = await fs.readFile( + path.join(webDistPath, "index.html"), + "utf8", + ) + return c.html(indexContent) + } + }) + } else { + app.get("/", (c) => + c.text( + "Console API running. Build the web UI with: cd web && bun install && bun run build", + ), + ) + } +} + +async function autoStartAccounts(): Promise { + const accounts = await getAccounts() + for (const account of accounts) { + if (account.enabled) { + try { + await startInstance(account) + } catch (error) { + consola.error(`Failed to auto-start account "${account.name}":`, error) } } + } +} - serve({ - fetch: app.fetch as ServerHandler, - port, - }) +async function proxyAuth( + c: import("hono").Context, + next: () => Promise, +): Promise { + const auth = c.req.header("authorization") + const token = auth?.replace(/^Bearer\s+/i, "") + if (!token) { + return c.json({ error: "Missing API key" }, 401) + } + const account = await getAccountByApiKey(token) + if (!account) { + return c.json({ error: "Invalid API key" }, 401) + } + const st = getInstanceState(account.id) + if (!st) { + return c.json({ error: "Account instance not running" }, 503) + } + c.set("proxyState", st) + return next() +} - consola.box(`🎛️ Console running at http://localhost:${port}`) - }, -}) +function getState(c: import("hono").Context): import("~/lib/state").State { + return c.get("proxyState") as import("~/lib/state").State +} diff --git a/src/console/instance-manager.ts b/src/console/instance-manager.ts index a93aab721..86014edcc 100644 --- a/src/console/instance-manager.ts +++ b/src/console/instance-manager.ts @@ -2,19 +2,9 @@ import type { Context } from "hono" import consola from "consola" import { events } from "fetch-event-stream" -import { Hono } from "hono" -import { cors } from "hono/cors" -import { logger } from "hono/logger" import { streamSSE } from "hono/streaming" -import { serve, type ServerHandler } from "srvx" import type { State } from "~/lib/state" -import type { AnthropicMessagesPayload } from "~/routes/messages/anthropic-types" -import type { - ChatCompletionResponse, - ChatCompletionsPayload, - ChatCompletionChunk, -} from "~/services/copilot/create-chat-completions" import type { ModelsResponse } from "~/services/copilot/get-models" import { @@ -26,11 +16,17 @@ import { } from "~/lib/api-config" import { HTTPError } from "~/lib/error" import { getTokenCount } from "~/lib/tokenizer" +import { type AnthropicMessagesPayload } from "~/routes/messages/anthropic-types" import { - translateToOpenAI, translateToAnthropic, + translateToOpenAI, } from "~/routes/messages/non-stream-translation" import { translateChunkToAnthropicEvents } from "~/routes/messages/stream-translation" +import { + type ChatCompletionChunk, + type ChatCompletionResponse, + type ChatCompletionsPayload, +} from "~/services/copilot/create-chat-completions" import { getVSCodeVersion } from "~/services/get-vscode-version" import type { Account } from "./account-store" @@ -38,7 +34,6 @@ import type { Account } from "./account-store" interface ProxyInstance { account: Account state: State - server: ReturnType | null status: "running" | "stopped" | "error" error?: string tokenInterval?: ReturnType @@ -107,26 +102,15 @@ export async function startInstance(account: Account): Promise { } const st = createState(account) - const instance: ProxyInstance = { - account, - state: st, - server: null, - status: "stopped", - } + const instance: ProxyInstance = { account, state: st, status: "stopped" } try { st.vsCodeVersion = await getVSCodeVersion() await setupInstanceToken(instance) st.models = await fetchModels(st) - - const app = createInstanceServer(instance) - instance.server = serve({ - fetch: app.fetch as ServerHandler, - port: account.port, - }) instance.status = "running" instances.set(account.id, instance) - consola.success(`[${account.name}] Proxy started on port ${account.port}`) + consola.success(`[${account.name}] Instance ready`) } catch (error) { instance.status = "error" instance.error = (error as Error).message @@ -136,16 +120,13 @@ export async function startInstance(account: Account): Promise { } } -export async function stopInstance(accountId: string): Promise { +export function stopInstance(accountId: string): void { const instance = instances.get(accountId) if (!instance) return - try { if (instance.tokenInterval) clearInterval(instance.tokenInterval) - if (instance.server) await instance.server.close() instance.status = "stopped" - instance.server = null - consola.info(`[${instance.account.name}] Proxy stopped`) + consola.info(`[${instance.account.name}] Instance stopped`) } catch (error) { consola.error(`[${instance.account.name}] Error stopping:`, error) } @@ -159,6 +140,12 @@ export function getInstanceError(accountId: string): string | undefined { return instances.get(accountId)?.error } +export function getInstanceState(accountId: string): State | undefined { + const instance = instances.get(accountId) + if (!instance || instance.status !== "running") return undefined + return instance.state +} + export async function getInstanceUsage(accountId: string): Promise { const instance = instances.get(accountId) if (!instance || instance.status !== "running") return null @@ -193,49 +180,10 @@ export async function getInstanceUser( } } -// === Per-instance server === - -function createInstanceServer(instance: ProxyInstance): Hono { - const app = new Hono() - app.use(logger()) - app.use(cors()) - - const st = instance.state - - app.get("/", (c) => c.text(`Proxy running for ${instance.account.name}`)) - - app.post("/chat/completions", (c) => completionsHandler(c, st)) - app.post("/v1/chat/completions", (c) => completionsHandler(c, st)) - app.get("/models", (c) => modelsHandler(c, st)) - app.get("/v1/models", (c) => modelsHandler(c, st)) - app.post("/embeddings", (c) => embeddingsHandler(c, st)) - app.post("/v1/embeddings", (c) => embeddingsHandler(c, st)) - app.post("/v1/messages", (c) => messagesHandler(c, st)) - app.post("/v1/messages/count_tokens", (c) => countTokensHandler(c, st)) - - app.get("/usage", async (c) => { - try { - const response = await fetch( - `${GITHUB_API_BASE_URL}/copilot_internal/user`, - { headers: githubHeaders(st) }, - ) - if (!response.ok) throw new HTTPError("Failed to get usage", response) - return c.json(await response.json()) - } catch (error) { - return c.json({ error: (error as Error).message }, 500) - } - }) - - app.get("/token", (c) => c.json({ token: st.copilotToken })) - - return app -} +// === Proxy handlers (used by unified router) === interface CompletionsPayload { - messages: Array<{ - role: string - content: unknown - }> + messages: Array<{ role: string; content: unknown }> model: string max_tokens?: number stream?: boolean @@ -256,7 +204,10 @@ function isAgentRequest(messages: CompletionsPayload["messages"]): boolean { return messages.some((msg) => ["assistant", "tool"].includes(msg.role)) } -async function completionsHandler(c: Context, st: State) { +export async function completionsHandler( + c: Context, + st: State, +): Promise { try { const payload = await c.req.json() @@ -305,7 +256,7 @@ async function completionsHandler(c: Context, st: State) { } } -function modelsHandler(c: Context, st: State) { +export function modelsHandler(c: Context, st: State): Response { const models = st.models?.data.map((model) => ({ id: model.id, @@ -319,7 +270,10 @@ function modelsHandler(c: Context, st: State) { return c.json({ object: "list", data: models, has_more: false }) } -async function embeddingsHandler(c: Context, st: State) { +export async function embeddingsHandler( + c: Context, + st: State, +): Promise { try { const payload = await c.req.json>() const response = await fetch(`${copilotBaseUrl(st)}/embeddings`, { @@ -338,7 +292,10 @@ async function embeddingsHandler(c: Context, st: State) { } } -async function messagesHandler(c: Context, st: State) { +export async function messagesHandler( + c: Context, + st: State, +): Promise { try { const anthropicPayload = await c.req.json() const openAIPayload = translateToOpenAI(anthropicPayload) @@ -419,7 +376,10 @@ async function messagesHandler(c: Context, st: State) { } } -async function countTokensHandler(c: Context, st: State) { +export async function countTokensHandler( + c: Context, + st: State, +): Promise { try { const anthropicPayload = await c.req.json() const openAIPayload: ChatCompletionsPayload = diff --git a/src/console/port-check.ts b/src/console/port-check.ts deleted file mode 100644 index e7281b5af..000000000 --- a/src/console/port-check.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { createServer } from "node:net" - -import { getAccounts, type Account } from "./account-store" - -/** - * Check if a port is available by attempting to bind to it. - */ -export function isPortAvailable(port: number): Promise { - return new Promise((resolve) => { - const server = createServer() - server.once("error", () => resolve(false)) - server.once("listening", () => { - server.close(() => resolve(true)) - }) - server.listen(port, "0.0.0.0") - }) -} - -export interface PortConflict { - port: number - conflict: "account" | "system" - accountName?: string -} - -/** - * Check a port against existing accounts and system availability. - * `excludeAccountId` allows skipping the current account when updating. - */ -export async function checkPortConflict( - port: number, - excludeAccountId?: string, -): Promise { - const accounts = await getAccounts() - const conflicting = accounts.find( - (a) => a.port === port && a.id !== excludeAccountId, - ) - - if (conflicting) { - return { port, conflict: "account", accountName: conflicting.name } - } - - const available = await isPortAvailable(port) - if (!available) { - return { port, conflict: "system" } - } - - return null -} - -/** - * Find the next available port starting from a given port. - */ -export async function findAvailablePort( - startPort: number, - excludeAccountId?: string, -): Promise { - const accounts = await getAccounts() - const usedPorts = new Set( - accounts - .filter((a: Account) => a.id !== excludeAccountId) - .map((a: Account) => a.port), - ) - - let port = startPort - while (port <= 65535) { - if (!usedPorts.has(port)) { - const available = await isPortAvailable(port) - if (available) return port - } - port++ - } - - throw new Error("No available port found") -} diff --git a/web/src/App.tsx b/web/src/App.tsx index aaf42ddec..c33dba51a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,14 +1,67 @@ -import { useState, useEffect, useCallback } from "react" +import { useCallback, useEffect, useState } from "react" -import { api, type Account } from "./api" +import { api, getAdminKey, setAdminKey, type Account } from "./api" import { AccountCard } from "./components/AccountCard" import { AddAccountForm } from "./components/AddAccountForm" +function LoginForm({ onLogin }: { onLogin: () => void }) { + const [key, setKey] = useState("") + const [error, setError] = useState("") + const [loading, setLoading] = useState(false) + + const handleSubmit = async (e: React.SyntheticEvent) => { + e.preventDefault() + setError("") + setLoading(true) + setAdminKey(key.trim()) + try { + await api.checkAuth() + onLogin() + } catch { + setAdminKey("") + setError("Invalid admin key") + } finally { + setLoading(false) + } + } + + return ( +
+

+ Copilot API Console +

+

+ Enter admin key to continue +

+
void handleSubmit(e)}> + setKey(e.target.value)} + placeholder="Admin key" + autoFocus + style={{ marginBottom: 12 }} + /> + {error && ( +
+ {error} +
+ )} + +
+
+ ) +} + function AccountList({ accounts, + proxyPort, onRefresh, }: { accounts: Array + proxyPort: number onRefresh: () => Promise }) { if (accounts.length === 0) { @@ -31,16 +84,17 @@ function AccountList({ return (
{accounts.map((account) => ( - + ))}
) } -export function App() { +function Dashboard() { const [accounts, setAccounts] = useState>([]) const [showForm, setShowForm] = useState(false) const [loading, setLoading] = useState(true) + const [proxyPort, setProxyPort] = useState(4141) const refresh = useCallback(async () => { try { @@ -54,6 +108,7 @@ export function App() { }, []) useEffect(() => { + void api.getConfig().then((cfg) => setProxyPort(cfg.proxyPort)) void refresh() const interval = setInterval(() => void refresh(), 5000) return () => clearInterval(interval) @@ -102,7 +157,14 @@ export function App() { > Loading...

- : } + : }
) } + +export function App() { + const [authed, setAuthed] = useState(Boolean(getAdminKey())) + + if (!authed) return setAuthed(true)} /> + return +} diff --git a/web/src/api.ts b/web/src/api.ts index 4a5ba5c85..755987d3a 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1,11 +1,21 @@ const BASE = "/api" +let adminKey = "" + +export function setAdminKey(key: string): void { + adminKey = key +} + +export function getAdminKey(): string { + return adminKey +} + export interface Account { id: string name: string githubToken: string accountType: string - port: number + apiKey: string enabled: boolean createdAt: string status?: "running" | "stopped" | "error" @@ -47,13 +57,14 @@ interface ErrorBody { } async function request(path: string, options?: RequestInit): Promise { - const res = await fetch(`${BASE}${path}`, { - ...options, - headers: { - "Content-Type": "application/json", - ...(options?.headers as Record), - }, - }) + const headers: Record = { + "Content-Type": "application/json", + ...(options?.headers as Record), + } + if (adminKey) { + headers["Authorization"] = `Bearer ${adminKey}` + } + const res = await fetch(`${BASE}${path}`, { ...options, headers }) if (!res.ok) { const body = (await res.json().catch(() => ({}))) as ErrorBody throw new Error(body.error ?? `HTTP ${res.status}`) @@ -62,24 +73,11 @@ async function request(path: string, options?: RequestInit): Promise { } export const api = { - getAccounts: () => request>("/accounts"), + checkAuth: () => request<{ ok: boolean }>("/auth/check"), - addAccount: (data: { - name: string - githubToken: string - accountType: string - port: number - }) => - request("/accounts", { - method: "POST", - body: JSON.stringify(data), - }), + getConfig: () => request<{ proxyPort: number }>("/config"), - updateAccount: (id: string, data: Partial) => - request(`/accounts/${id}`, { - method: "PUT", - body: JSON.stringify(data), - }), + getAccounts: () => request>("/accounts"), deleteAccount: (id: string) => request<{ ok: boolean }>(`/accounts/${id}`, { method: "DELETE" }), @@ -92,6 +90,9 @@ export const api = { getUsage: (id: string) => request(`/accounts/${id}/usage`), + regenerateKey: (id: string) => + request(`/accounts/${id}/regenerate-key`, { method: "POST" }), + startDeviceCode: () => request("/auth/device-code", { method: "POST" }), @@ -102,25 +103,9 @@ export const api = { sessionId: string name: string accountType: string - port: number }) => request("/auth/complete", { method: "POST", body: JSON.stringify(data), }), - - checkPort: (port: number, excludeId?: string) => - request<{ - available: boolean - conflict?: string - accountName?: string - }>(`/port/check/${port}${excludeId ? `?exclude=${excludeId}` : ""}`), - - suggestPort: (start?: number, excludeId?: string) => { - const params = new URLSearchParams() - if (start !== undefined) params.set("start", String(start)) - if (excludeId) params.set("exclude", excludeId) - const qs = params.toString() - return request<{ port: number }>(`/port/suggest${qs ? `?${qs}` : ""}`) - }, } diff --git a/web/src/components/AccountCard.tsx b/web/src/components/AccountCard.tsx index 7f7c1ddd9..da7ba8038 100644 --- a/web/src/components/AccountCard.tsx +++ b/web/src/components/AccountCard.tsx @@ -121,7 +121,29 @@ function UsagePanel({ usage }: { usage: UsageData }) { ) } -function EndpointsPanel({ port }: { port: number }) { +function useCopyFeedback(): [string | null, (text: string) => void] { + const [copied, setCopied] = useState(null) + const copy = (text: string) => { + void navigator.clipboard.writeText(text) + setCopied(text) + setTimeout(() => setCopied(null), 1500) + } + return [copied, copy] +} + +function ApiKeyPanel({ + apiKey, + onRegenerate, +}: { + apiKey: string + onRegenerate: () => void +}) { + const [visible, setVisible] = useState(false) + const [copied, copy] = useCopyFeedback() + const safeKey = apiKey ?? "" + const masked = safeKey.length > 8 ? `${safeKey.slice(0, 8)}${"•".repeat(24)}` : safeKey + const isCopied = copied === safeKey + return (
-
+ {isCopied ? "Copied!" : "API Key:"} + + copy(safeKey)} style={{ - marginBottom: 4, - color: "var(--text)", - fontWeight: 500, - fontSize: 13, + cursor: "pointer", + flex: 1, + color: isCopied ? "var(--green)" : undefined, }} + title="Click to copy" > - Endpoints -
-
OpenAI: http://localhost:{port}/v1/chat/completions
-
Anthropic: http://localhost:{port}/v1/messages
-
Models: http://localhost:{port}/v1/models
+ {visible ? safeKey : masked} + + +
) } @@ -159,6 +198,7 @@ function getUsageLabel(loading: boolean, visible: boolean): string { interface Props { account: Account + proxyPort: number onRefresh: () => Promise } @@ -166,15 +206,21 @@ function AccountActions({ account, status, onRefresh, + onToggleUsage, + usageLoading, + showUsage, }: { account: Account status: string onRefresh: () => Promise + onToggleUsage: () => void + usageLoading: boolean + showUsage: boolean }) { const [actionLoading, setActionLoading] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false) - const handleAction = async (action: () => Promise) => { + const handleAction = async (action: () => Promise) => { setActionLoading(true) try { await action() @@ -192,20 +238,17 @@ function AccountActions({ setTimeout(() => setConfirmDelete(false), 3000) return } - setActionLoading(true) - try { - await api.deleteAccount(account.id) - await onRefresh() - } catch (err) { - console.error("Delete failed:", err) - } finally { - setActionLoading(false) - setConfirmDelete(false) - } + await handleAction(() => api.deleteAccount(account.id)) + setConfirmDelete(false) } return (
+ {status === "running" && ( + + )} {status === "running" ? - {showUsage - && (usage ? - - :
- Usage data unavailable. Make sure the instance is running. -
)} - - ) -} - -export function AccountCard({ account, onRefresh }: Props) { - const status = account.status ?? "stopped" + const handleRegenerate = () => { + void (async () => { + try { + await api.regenerateKey(account.id) + await onRefresh() + } catch (err) { + console.error("Regenerate failed:", err) + } + })() + } return (
{account.user?.login ? `@${account.user.login} · ` : ""} - Port {account.port} · {account.accountType} + {account.accountType}
{account.error && (
@@ -324,11 +418,28 @@ export function AccountCard({ account, onRefresh }: Props) { account={account} status={status} onRefresh={onRefresh} + onToggleUsage={() => void handleToggleUsage()} + usageLoading={usageLoading} + showUsage={showUsage} />
- {status === "running" && } - {status === "running" && } + + {status === "running" && ( + + )} + {showUsage + && (usage ? + + :
+ Usage data unavailable. Make sure the instance is running. +
)}
) } diff --git a/web/src/components/AddAccountForm.tsx b/web/src/components/AddAccountForm.tsx index 324c0b04d..73723673c 100644 --- a/web/src/components/AddAccountForm.tsx +++ b/web/src/components/AddAccountForm.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from "react" +import { useCallback, useEffect, useRef, useState } from "react" import { api } from "../api" @@ -8,30 +8,6 @@ interface Props { } type Step = "config" | "authorize" | "done" -type PortStatus = "idle" | "checking" | "ok" | "conflict" - -function PortIndicator({ status }: { status: PortStatus }) { - if (status === "idle") return null - const colorMap: Record = { - ok: "var(--green)", - conflict: "var(--red)", - checking: "var(--yellow)", - } - return ( - - ) -} function DeviceCodeDisplay({ userCode, @@ -160,173 +136,64 @@ function AuthorizeStep({ ) } -function usePortCheck() { - const [portStatus, setPortStatus] = useState("idle") - const [portMessage, setPortMessage] = useState("") - const timerRef = useRef | null>(null) - - const check = useCallback((value: string) => { - if (timerRef.current) clearTimeout(timerRef.current) - const portNum = Number.parseInt(value, 10) - if (Number.isNaN(portNum) || portNum < 1024 || portNum > 65535) { - setPortStatus("conflict") - setPortMessage("Port must be 1024–65535") - return - } - setPortStatus("checking") - setPortMessage("") - timerRef.current = setTimeout(() => { - void (async () => { - try { - const result = await api.checkPort(portNum) - if (result.available) { - setPortStatus("ok") - setPortMessage("") - } else { - setPortStatus("conflict") - setPortMessage( - result.conflict === "account" ? - `Used by "${result.accountName}"` - : "Occupied by another process", - ) - } - } catch { - setPortStatus("idle") - } - })() - }, 400) - }, []) - - return { portStatus, portMessage, check, setPortStatus, setPortMessage } -} - -function getPortBorderColor(status: PortStatus): string | undefined { - if (status === "conflict") return "var(--red)" - if (status === "ok") return "var(--green)" - return undefined -} - -function PortField({ - port, - portStatus, - portMessage, - onPortChange, - onAutoPort, +function ConfigForm({ + onSubmit, + onCancel, + loading, + error, + name, + setName, + accountType, + setAccountType, }: { - port: string - portStatus: PortStatus - portMessage: string - onPortChange: (v: string) => void - onAutoPort: () => void -}) { - return ( -
- -
- onPortChange(e.target.value)} - placeholder="4141" - style={{ borderColor: getPortBorderColor(portStatus) }} - /> - -
- {portMessage && ( -
- {portMessage} -
- )} -
- ) -} - -interface ConfigFormProps { onSubmit: (e: React.SyntheticEvent) => void onCancel: () => void loading: boolean error: string - portStatus: PortStatus - portMessage: string name: string setName: (v: string) => void accountType: string setAccountType: (v: string) => void - port: string - onPortChange: (v: string) => void - onAutoPort: () => void -} - -function ConfigForm(props: ConfigFormProps) { - const isDisabled = - props.loading - || props.portStatus === "conflict" - || props.portStatus === "checking" - +}) { return ( -
+

Add Account

-
+
props.setName(e.target.value)} + value={name} + onChange={(e) => setName(e.target.value)} placeholder="e.g. Personal" />
- -
-
- - +
+ + +
- {props.error && ( + {error && (
- {props.error} + {error}
)}
- -
@@ -351,11 +218,7 @@ function useAuthFlow(onComplete: () => Promise) { useEffect(() => cleanup, [cleanup]) - const startAuth = async ( - name: string, - accountType: string, - portNum: number, - ) => { + const startAuth = async (name: string, accountType: string) => { setError("") setLoading(true) try { @@ -376,7 +239,6 @@ function useAuthFlow(onComplete: () => Promise) { sessionId: result.sessionId, name, accountType, - port: portNum, }) setStep("done") await onComplete() @@ -413,82 +275,39 @@ function useAuthFlow(onComplete: () => Promise) { export function AddAccountForm({ onComplete, onCancel }: Props) { const [name, setName] = useState("") const [accountType, setAccountType] = useState("individual") - const [port, setPort] = useState("") - const { portStatus, portMessage, check, setPortStatus, setPortMessage } = - usePortCheck() const auth = useAuthFlow(onComplete) - useEffect(() => { - void api.suggestPort(4141).then((res) => { - setPort(String(res.port)) - setPortStatus("ok") - setPortMessage("") - }) - }, [setPortStatus, setPortMessage]) - - const handlePortChange = (value: string) => { - setPort(value) - check(value) - } - - const handleAutoPort = () => { - void (async () => { - try { - const res = await api.suggestPort(Number.parseInt(port, 10) || 4141) - setPort(String(res.port)) - setPortStatus("ok") - setPortMessage("") - } catch { - auth.setError("Failed to find available port") - } - })() - } - const handleSubmit = (e: React.SyntheticEvent) => { e.preventDefault() if (!name.trim()) { auth.setError("Account name is required") return } - const portNum = Number.parseInt(port, 10) - if (Number.isNaN(portNum) || portNum < 1024 || portNum > 65535) { - auth.setError("Port must be between 1024 and 65535") - return - } - if (portStatus === "conflict") { - auth.setError("Please resolve the port conflict first") - return - } - void auth.startAuth(name.trim(), accountType, portNum) + void auth.startAuth(name.trim(), accountType) } if (auth.step === "done") return null - const wrapperStyle = { - background: "var(--bg-card)", - border: "1px solid var(--border)", - borderRadius: "var(--radius)", - padding: 20, - marginBottom: 16, - } - return ( -
+
{auth.step === "config" && ( )} {auth.step === "authorize" && ( diff --git a/web/vite.config.ts b/web/vite.config.ts index d9b9c19d8..5beb39cce 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -10,5 +10,10 @@ export default defineConfig({ }, build: { outDir: "dist", + rollupOptions: { + output: { + entryFileNames: "assets/[name]-[hash]-v2.js", + }, + }, }, }) From d584cd03ab7feb43e8c28a9ee3814a586e743603 Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Sat, 14 Feb 2026 04:26:20 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E9=89=B4=E6=9D=83?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8Dbug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile.console | 2 +- src/console/admin-auth.ts | 92 ++++++++++++++++++ src/console/api.ts | 62 ++++++++++--- src/console/index.ts | 13 +-- web/src/App.tsx | 190 ++++++++++++++++++++++++++++++++++---- web/src/api.ts | 46 +++++++-- 6 files changed, 353 insertions(+), 52 deletions(-) create mode 100644 src/console/admin-auth.ts diff --git a/Dockerfile.console b/Dockerfile.console index 858a8f7b7..45182e161 100644 --- a/Dockerfile.console +++ b/Dockerfile.console @@ -33,7 +33,7 @@ EXPOSE 3000 4141 VOLUME /root/.local/share/copilot-api HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ - CMD wget --spider -q http://localhost:3000/api/auth/check || exit 1 + CMD wget --spider -q http://localhost:3000/api/config || exit 1 ENTRYPOINT ["bun", "run", "./src/main.ts", "console"] CMD ["--web-port", "3000", "--proxy-port", "4141"] diff --git a/src/console/admin-auth.ts b/src/console/admin-auth.ts new file mode 100644 index 000000000..03a09651e --- /dev/null +++ b/src/console/admin-auth.ts @@ -0,0 +1,92 @@ +import crypto from "node:crypto" +import fs from "node:fs/promises" +import path from "node:path" + +import { PATHS } from "~/lib/paths" + +interface AdminCredentials { + username: string + passwordHash: string +} + +interface AdminStore { + credentials: AdminCredentials | null + sessions: Array<{ token: string; createdAt: string }> +} + +const STORE_PATH = path.join(PATHS.APP_DIR, "admin.json") +const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000 // 7 days + +async function readStore(): Promise { + try { + const data = await fs.readFile(STORE_PATH) + return JSON.parse(data) as AdminStore + } catch { + return { credentials: null, sessions: [] } + } +} + +async function writeStore(store: AdminStore): Promise { + await fs.writeFile(STORE_PATH, JSON.stringify(store, null, 2)) +} + +export async function isSetupRequired(): Promise { + const store = await readStore() + return store.credentials === null +} + +export async function setupAdmin( + username: string, + password: string, +): Promise { + const store = await readStore() + if (store.credentials !== null) { + throw new Error("Admin already configured") + } + const passwordHash = await Bun.password.hash(password) + const token = generateSessionToken() + store.credentials = { username, passwordHash } + store.sessions = [{ token, createdAt: new Date().toISOString() }] + await writeStore(store) + return token +} + +export async function loginAdmin( + username: string, + password: string, +): Promise { + const store = await readStore() + if (!store.credentials) return null + if (store.credentials.username !== username) return null + const valid = await Bun.password.verify( + password, + store.credentials.passwordHash, + ) + if (!valid) return null + const token = generateSessionToken() + cleanExpiredSessions(store) + store.sessions.push({ token, createdAt: new Date().toISOString() }) + await writeStore(store) + return token +} + +export async function validateSession(token: string): Promise { + const store = await readStore() + const now = Date.now() + return store.sessions.some((s) => { + const age = now - new Date(s.createdAt).getTime() + return s.token === token && age < SESSION_TTL_MS + }) +} + +function generateSessionToken(): string { + return `session-${crypto.randomBytes(24).toString("hex")}` +} + +function cleanExpiredSessions(store: AdminStore): void { + const now = Date.now() + store.sessions = store.sessions.filter((s) => { + const age = now - new Date(s.createdAt).getTime() + return age < SESSION_TTL_MS + }) +} diff --git a/src/console/api.ts b/src/console/api.ts index bea4775ac..08bfed125 100644 --- a/src/console/api.ts +++ b/src/console/api.ts @@ -10,6 +10,12 @@ import { regenerateApiKey, updateAccount, } from "./account-store" +import { + isSetupRequired, + loginAdmin, + setupAdmin, + validateSession, +} from "./admin-auth" import { cleanupSession, getSession, startDeviceFlow } from "./auth-flow" import { getInstanceError, @@ -20,11 +26,6 @@ import { stopInstance, } from "./instance-manager" -let adminKey = "" - -export function setAdminKey(key: string): void { - adminKey = key -} let proxyPort = 4141 export function setProxyPort(port: number): void { @@ -41,21 +42,60 @@ export const consoleApi = new Hono() consoleApi.use(cors()) -// Public config (no auth required) -consoleApi.get("/config", (c) => c.json({ proxyPort })) +// Public endpoints (no auth required) +consoleApi.get("/config", async (c) => { + const needsSetup = await isSetupRequired() + return c.json({ proxyPort, needsSetup }) +}) + +const SetupSchema = z.object({ + username: z.string().min(1), + password: z.string().min(6), +}) + +consoleApi.post("/auth/setup", async (c) => { + const body: unknown = await c.req.json() + const parsed = SetupSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: formatZodError(parsed.error) }, 400) + } + try { + const token = await setupAdmin(parsed.data.username, parsed.data.password) + return c.json({ token }) + } catch (error) { + return c.json({ error: (error as Error).message }, 400) + } +}) + +const LoginSchema = z.object({ + username: z.string().min(1), + password: z.string().min(1), +}) + +consoleApi.post("/auth/login", async (c) => { + const body: unknown = await c.req.json() + const parsed = LoginSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: formatZodError(parsed.error) }, 400) + } + const token = await loginAdmin(parsed.data.username, parsed.data.password) + if (!token) { + return c.json({ error: "Invalid username or password" }, 401) + } + return c.json({ token }) +}) -// Admin auth middleware +// Admin auth middleware (session token) consoleApi.use("/*", async (c, next) => { - if (!adminKey) return next() const auth = c.req.header("authorization") const token = auth?.replace("Bearer ", "") - if (token !== adminKey) { + if (!token || !(await validateSession(token))) { return c.json({ error: "Unauthorized" }, 401) } return next() }) -// Auth check endpoint (for frontend login) +// Auth check endpoint consoleApi.get("/auth/check", (c) => c.json({ ok: true })) // List all accounts with status diff --git a/src/console/index.ts b/src/console/index.ts index b55fb242e..eeefefa74 100644 --- a/src/console/index.ts +++ b/src/console/index.ts @@ -3,7 +3,6 @@ import consola from "consola" import { Hono } from "hono" import { cors } from "hono/cors" import { logger } from "hono/logger" -import crypto from "node:crypto" import fs from "node:fs/promises" import path from "node:path" import { serve, type ServerHandler } from "srvx" @@ -11,7 +10,7 @@ import { serve, type ServerHandler } from "srvx" import { ensurePaths } from "~/lib/paths" import { getAccountByApiKey, getAccounts } from "./account-store" -import { consoleApi, setAdminKey, setProxyPort } from "./api" +import { consoleApi, setProxyPort } from "./api" import { completionsHandler, countTokensHandler, @@ -50,10 +49,6 @@ export const console_ = defineCommand({ default: "4141", description: "Port for the proxy API endpoints", }, - "admin-key": { - type: "string", - description: "Admin key for console access (auto-generated if not set)", - }, verbose: { alias: "v", type: "boolean", @@ -76,10 +71,6 @@ export const console_ = defineCommand({ const webPort = Number.parseInt(args["web-port"], 10) const proxyPort = Number.parseInt(args["proxy-port"], 10) - const adminKeyArg = args["admin-key"] as string | undefined - const generatedAdminKey = - adminKeyArg ?? `admin-${crypto.randomBytes(8).toString("hex")}` - setAdminKey(generatedAdminKey) setProxyPort(proxyPort) // === Web console server === @@ -105,7 +96,7 @@ export const console_ = defineCommand({ consola.box( [ `🎛️ Console: http://localhost:${webPort}`, - `🔑 Admin Key: ${generatedAdminKey}`, + `🔐 First visit to set up admin account`, "", `Proxy (port ${proxyPort}) — use account API key as Bearer token:`, ` OpenAI: http://localhost:${proxyPort}/v1/chat/completions`, diff --git a/web/src/App.tsx b/web/src/App.tsx index c33dba51a..ee6c5eb1b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,11 +1,91 @@ import { useCallback, useEffect, useState } from "react" -import { api, getAdminKey, setAdminKey, type Account } from "./api" +import { api, getSessionToken, setSessionToken, type Account } from "./api" import { AccountCard } from "./components/AccountCard" import { AddAccountForm } from "./components/AddAccountForm" +type AuthState = "loading" | "setup" | "login" | "authed" + +function SetupForm({ onComplete }: { onComplete: () => void }) { + const [username, setUsername] = useState("") + const [password, setPassword] = useState("") + const [confirm, setConfirm] = useState("") + const [error, setError] = useState("") + const [loading, setLoading] = useState(false) + + const handleSubmit = async (e: React.SyntheticEvent) => { + e.preventDefault() + setError("") + if (password !== confirm) { + setError("Passwords do not match") + return + } + if (password.length < 6) { + setError("Password must be at least 6 characters") + return + } + setLoading(true) + try { + const { token } = await api.setup(username, password) + setSessionToken(token) + onComplete() + } catch (err) { + setError((err as Error).message) + } finally { + setLoading(false) + } + } + + return ( +
+

+ Copilot API Console +

+

+ Create your admin account to get started +

+
void handleSubmit(e)}> + setUsername(e.target.value)} + placeholder="Username" + autoFocus + autoComplete="username" + style={{ marginBottom: 12 }} + /> + setPassword(e.target.value)} + placeholder="Password (min 6 chars)" + autoComplete="new-password" + style={{ marginBottom: 12 }} + /> + setConfirm(e.target.value)} + placeholder="Confirm password" + autoComplete="new-password" + style={{ marginBottom: 12 }} + /> + {error && ( +
+ {error} +
+ )} + +
+
+ ) +} + function LoginForm({ onLogin }: { onLogin: () => void }) { - const [key, setKey] = useState("") + const [username, setUsername] = useState("") + const [password, setPassword] = useState("") const [error, setError] = useState("") const [loading, setLoading] = useState(false) @@ -13,13 +93,12 @@ function LoginForm({ onLogin }: { onLogin: () => void }) { e.preventDefault() setError("") setLoading(true) - setAdminKey(key.trim()) try { - await api.checkAuth() + const { token } = await api.login(username, password) + setSessionToken(token) onLogin() } catch { - setAdminKey("") - setError("Invalid admin key") + setError("Invalid username or password") } finally { setLoading(false) } @@ -31,15 +110,24 @@ function LoginForm({ onLogin }: { onLogin: () => void }) { Copilot API Console

- Enter admin key to continue + Sign in to continue

void handleSubmit(e)}> setKey(e.target.value)} - placeholder="Admin key" + type="text" + value={username} + onChange={(e) => setUsername(e.target.value)} + placeholder="Username" autoFocus + autoComplete="username" + style={{ marginBottom: 12 }} + /> + setPassword(e.target.value)} + placeholder="Password" + autoComplete="current-password" style={{ marginBottom: 12 }} /> {error && ( @@ -48,7 +136,7 @@ function LoginForm({ onLogin }: { onLogin: () => void }) {
)}
@@ -84,7 +172,12 @@ function AccountList({ return (
{accounts.map((account) => ( - + ))}
) @@ -119,6 +212,11 @@ function Dashboard() { await refresh() } + const handleLogout = () => { + setSessionToken("") + window.location.reload() + } + return (
- +
+ + +
{showForm && ( @@ -157,14 +258,65 @@ function Dashboard() { > Loading...

- : } + : + }
) } export function App() { - const [authed, setAuthed] = useState(Boolean(getAdminKey())) + const [authState, setAuthState] = useState("loading") + + useEffect(() => { + void (async () => { + try { + const config = await api.getConfig() + if (config.needsSetup) { + setAuthState("setup") + return + } + const token = getSessionToken() + if (token) { + try { + await api.checkAuth() + setAuthState("authed") + return + } catch { + setSessionToken("") + } + } + setAuthState("login") + } catch { + setAuthState("login") + } + })() + }, []) + + if (authState === "loading") { + return ( +
+ Loading... +
+ ) + } + + if (authState === "setup") { + return setAuthState("authed")} /> + } + + if (authState === "login") { + return setAuthState("authed")} /> + } - if (!authed) return setAuthed(true)} /> return } diff --git a/web/src/api.ts b/web/src/api.ts index 755987d3a..baf013222 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1,13 +1,21 @@ const BASE = "/api" -let adminKey = "" - -export function setAdminKey(key: string): void { - adminKey = key +let sessionToken = "" + +export function setSessionToken(token: string): void { + sessionToken = token + if (token) { + localStorage.setItem("sessionToken", token) + } else { + localStorage.removeItem("sessionToken") + } } -export function getAdminKey(): string { - return adminKey +export function getSessionToken(): string { + if (!sessionToken) { + sessionToken = localStorage.getItem("sessionToken") ?? "" + } + return sessionToken } export interface Account { @@ -52,6 +60,11 @@ export interface AuthPollResponse { error?: string } +export interface ConfigResponse { + proxyPort: number + needsSetup: boolean +} + interface ErrorBody { error?: string } @@ -61,8 +74,9 @@ async function request(path: string, options?: RequestInit): Promise { "Content-Type": "application/json", ...(options?.headers as Record), } - if (adminKey) { - headers["Authorization"] = `Bearer ${adminKey}` + const token = getSessionToken() + if (token) { + headers["Authorization"] = `Bearer ${token}` } const res = await fetch(`${BASE}${path}`, { ...options, headers }) if (!res.ok) { @@ -73,9 +87,21 @@ async function request(path: string, options?: RequestInit): Promise { } export const api = { - checkAuth: () => request<{ ok: boolean }>("/auth/check"), + getConfig: () => request("/config"), - getConfig: () => request<{ proxyPort: number }>("/config"), + setup: (username: string, password: string) => + request<{ token: string }>("/auth/setup", { + method: "POST", + body: JSON.stringify({ username, password }), + }), + + login: (username: string, password: string) => + request<{ token: string }>("/auth/login", { + method: "POST", + body: JSON.stringify({ username, password }), + }), + + checkAuth: () => request<{ ok: boolean }>("/auth/check"), getAccounts: () => request>("/accounts"), From 629bcdc9fe901052b51dd28780827470efa6f980 Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Mon, 16 Feb 2026 07:16:03 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E8=BD=AE=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/console/account-store.ts | 59 ++++++++- src/console/api.ts | 32 +++++ src/console/index.ts | 102 +++++++++++++--- src/console/instance-manager.ts | 16 ++- src/console/load-balancer.ts | 40 +++++++ web/src/App.tsx | 185 ++++++++++++++++++++++++++++- web/src/api.ts | 24 ++++ web/src/components/AccountCard.tsx | 79 ++++++++++++ 8 files changed, 515 insertions(+), 22 deletions(-) create mode 100644 src/console/load-balancer.ts diff --git a/src/console/account-store.ts b/src/console/account-store.ts index 35b55e360..1297c6993 100644 --- a/src/console/account-store.ts +++ b/src/console/account-store.ts @@ -12,10 +12,18 @@ export interface Account { apiKey: string enabled: boolean createdAt: string + priority: number +} + +export interface PoolConfig { + enabled: boolean + strategy: "round-robin" | "priority" + apiKey: string } export interface AccountStore { accounts: Array + pool?: PoolConfig } const STORE_PATH = path.join(PATHS.APP_DIR, "accounts.json") @@ -55,7 +63,9 @@ export async function getAccountByApiKey( } export async function addAccount( - account: Omit, + account: Omit & { + priority?: number + }, ): Promise { const store = await readStore() const newAccount: Account = { @@ -63,6 +73,7 @@ export async function addAccount( id: crypto.randomUUID(), apiKey: generateApiKey(), createdAt: new Date().toISOString(), + priority: account.priority ?? 0, } store.accounts.push(newAccount) await writeStore(store) @@ -100,3 +111,49 @@ export async function regenerateApiKey( await writeStore(store) return store.accounts[index] } + +export async function getPoolConfig(): Promise { + const store = await readStore() + if (!store.pool) return undefined + // Backfill apiKey for legacy stores + if (!store.pool.apiKey) { + store.pool.apiKey = generatePoolApiKey() + await writeStore(store) + } + return store.pool +} + +export async function updatePoolConfig( + config: Partial, +): Promise { + const store = await readStore() + const current: PoolConfig = store.pool ?? { + enabled: false, + strategy: "round-robin", + apiKey: generatePoolApiKey(), + } + store.pool = { ...current, ...config } + // Ensure apiKey exists for legacy stores + if (!store.pool.apiKey) { + store.pool.apiKey = generatePoolApiKey() + } + await writeStore(store) + return store.pool +} + +export async function regeneratePoolApiKey(): Promise { + const store = await readStore() + const current: PoolConfig = store.pool ?? { + enabled: false, + strategy: "round-robin", + apiKey: generatePoolApiKey(), + } + current.apiKey = generatePoolApiKey() + store.pool = current + await writeStore(store) + return store.pool +} + +function generatePoolApiKey(): string { + return `cpp-${crypto.randomBytes(16).toString("hex")}` +} diff --git a/src/console/api.ts b/src/console/api.ts index 08bfed125..4a2ce3746 100644 --- a/src/console/api.ts +++ b/src/console/api.ts @@ -7,8 +7,11 @@ import { deleteAccount, getAccount, getAccounts, + getPoolConfig, regenerateApiKey, + regeneratePoolApiKey, updateAccount, + updatePoolConfig, } from "./account-store" import { isSetupRequired, @@ -133,6 +136,7 @@ const AddAccountSchema = z.object({ githubToken: z.string().min(1), accountType: z.string().default("individual"), enabled: z.boolean().default(true), + priority: z.number().int().min(0).default(0), }) // Add account @@ -151,6 +155,7 @@ const UpdateAccountSchema = z.object({ githubToken: z.string().min(1).optional(), accountType: z.string().optional(), enabled: z.boolean().optional(), + priority: z.number().int().min(0).optional(), }) // Update account @@ -258,3 +263,30 @@ consoleApi.post("/auth/complete", async (c) => { cleanupSession(parsed.data.sessionId) return c.json(account, 201) }) + +// === Pool Configuration === + +consoleApi.get("/pool", async (c) => { + const config = await getPoolConfig() + return c.json(config ?? { enabled: false, strategy: "round-robin" }) +}) + +const UpdatePoolSchema = z.object({ + enabled: z.boolean().optional(), + strategy: z.enum(["round-robin", "priority"]).optional(), +}) + +consoleApi.put("/pool", async (c) => { + const body: unknown = await c.req.json() + const parsed = UpdatePoolSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: formatZodError(parsed.error) }, 400) + } + const config = await updatePoolConfig(parsed.data) + return c.json(config) +}) + +consoleApi.post("/pool/regenerate-key", async (c) => { + const config = await regeneratePoolApiKey() + return c.json(config) +}) diff --git a/src/console/index.ts b/src/console/index.ts index eeefefa74..76178d98d 100644 --- a/src/console/index.ts +++ b/src/console/index.ts @@ -9,7 +9,7 @@ import { serve, type ServerHandler } from "srvx" import { ensurePaths } from "~/lib/paths" -import { getAccountByApiKey, getAccounts } from "./account-store" +import { getAccountByApiKey, getAccounts, getPoolConfig } from "./account-store" import { consoleApi, setProxyPort } from "./api" import { completionsHandler, @@ -20,6 +20,7 @@ import { modelsHandler, startInstance, } from "./instance-manager" +import { selectAccount } from "./load-balancer" const MIME_TYPES: Record = { ".html": "text/html", @@ -109,20 +110,20 @@ export const console_ = defineCommand({ function mountProxyRoutes(app: Hono): void { app.post("/chat/completions", proxyAuth, (c) => - completionsHandler(c, getState(c)), + withPoolRetry(c, completionsHandler), ) app.post("/v1/chat/completions", proxyAuth, (c) => - completionsHandler(c, getState(c)), + withPoolRetry(c, completionsHandler), ) app.get("/models", proxyAuth, (c) => modelsHandler(c, getState(c))) app.get("/v1/models", proxyAuth, (c) => modelsHandler(c, getState(c))) - app.post("/embeddings", proxyAuth, (c) => embeddingsHandler(c, getState(c))) + app.post("/embeddings", proxyAuth, (c) => withPoolRetry(c, embeddingsHandler)) app.post("/v1/embeddings", proxyAuth, (c) => - embeddingsHandler(c, getState(c)), + withPoolRetry(c, embeddingsHandler), ) - app.post("/v1/messages", proxyAuth, (c) => messagesHandler(c, getState(c))) + app.post("/v1/messages", proxyAuth, (c) => withPoolRetry(c, messagesHandler)) app.post("/v1/messages/count_tokens", proxyAuth, (c) => - countTokensHandler(c, getState(c)), + withPoolRetry(c, countTokensHandler), ) } @@ -194,21 +195,90 @@ async function proxyAuth( ): Promise { const auth = c.req.header("authorization") const token = auth?.replace(/^Bearer\s+/i, "") - if (!token) { - return c.json({ error: "Missing API key" }, 401) + + // Try per-account key first + if (token) { + const account = await getAccountByApiKey(token) + if (account) { + const st = getInstanceState(account.id) + if (!st) { + return c.json({ error: "Account instance not running" }, 503) + } + c.set("proxyState", st) + await next() + return + } } - const account = await getAccountByApiKey(token) - if (!account) { + + // Fall back to pool mode + const poolConfig = await getPoolConfig() + if (!poolConfig?.enabled || !token || token !== poolConfig.apiKey) { return c.json({ error: "Invalid API key" }, 401) } - const st = getInstanceState(account.id) - if (!st) { - return c.json({ error: "Account instance not running" }, 503) + + const result = await selectAccount(poolConfig.strategy) + if (!result) { + return c.json({ error: "No available accounts in pool" }, 503) } - c.set("proxyState", st) - return next() + + c.set("proxyState", result.state) + c.set("poolMode", true) + c.set("poolStrategy", poolConfig.strategy) + c.set("poolAccountId", result.account.id) + await next() } function getState(c: import("hono").Context): import("~/lib/state").State { return c.get("proxyState") as import("~/lib/state").State } + +type Handler = ( + c: import("hono").Context, + st: import("~/lib/state").State, +) => Promise | Response + +async function withPoolRetry( + c: import("hono").Context, + handler: Handler, +): Promise { + const isPool = c.get("poolMode") as boolean | undefined + if (!isPool) { + return handler(c, getState(c)) + } + + // Buffer the body for potential retries + const body: unknown = await c.req.json() + c.set("bufferedBody", body) + + const strategy = c.get("poolStrategy") as "round-robin" | "priority" + const exclude = new Set() + + // First attempt with the account already selected by proxyAuth + const firstResponse = await handler(c, getState(c)) + if (!isRetryableStatus(firstResponse.status)) { + return firstResponse + } + + // Add the first account to exclude list and retry with others + exclude.add(c.get("poolAccountId") as string) + + while (true) { + const next = await selectAccount(strategy, exclude) + if (!next) { + // No more accounts to try, return the last error response + return firstResponse + } + + exclude.add(next.account.id) + c.set("proxyState", next.state) + + const retryResponse = await handler(c, next.state) + if (!isRetryableStatus(retryResponse.status)) { + return retryResponse + } + } +} + +function isRetryableStatus(status: number): boolean { + return status === 429 || status >= 500 +} diff --git a/src/console/instance-manager.ts b/src/console/instance-manager.ts index 86014edcc..d10e89d2d 100644 --- a/src/console/instance-manager.ts +++ b/src/console/instance-manager.ts @@ -209,7 +209,9 @@ export async function completionsHandler( st: State, ): Promise { try { - const payload = await c.req.json() + const payload = + (c.get("bufferedBody") as CompletionsPayload | undefined) + ?? (await c.req.json()) const headers: Record = { ...copilotHeaders(st, hasVisionContent(payload.messages)), @@ -275,7 +277,9 @@ export async function embeddingsHandler( st: State, ): Promise { try { - const payload = await c.req.json>() + const payload = + (c.get("bufferedBody") as Record | undefined) + ?? (await c.req.json>()) const response = await fetch(`${copilotBaseUrl(st)}/embeddings`, { method: "POST", headers: copilotHeaders(st), @@ -297,7 +301,9 @@ export async function messagesHandler( st: State, ): Promise { try { - const anthropicPayload = await c.req.json() + const anthropicPayload = + (c.get("bufferedBody") as AnthropicMessagesPayload | undefined) + ?? (await c.req.json()) const openAIPayload = translateToOpenAI(anthropicPayload) if (!openAIPayload.max_tokens) { @@ -381,7 +387,9 @@ export async function countTokensHandler( st: State, ): Promise { try { - const anthropicPayload = await c.req.json() + const anthropicPayload = + (c.get("bufferedBody") as AnthropicMessagesPayload | undefined) + ?? (await c.req.json()) const openAIPayload: ChatCompletionsPayload = translateToOpenAI(anthropicPayload) diff --git a/src/console/load-balancer.ts b/src/console/load-balancer.ts new file mode 100644 index 000000000..27b32a5be --- /dev/null +++ b/src/console/load-balancer.ts @@ -0,0 +1,40 @@ +import { type Account, getAccounts } from "./account-store" +import { getInstanceState } from "./instance-manager" + +let rrIndex = 0 + +export function getRunningAccounts(accounts: Array): Array { + return accounts.filter( + (a) => a.enabled && getInstanceState(a.id) !== undefined, + ) +} + +export async function selectAccount( + strategy: "round-robin" | "priority", + exclude?: Set, +): Promise<{ account: Account; state: import("~/lib/state").State } | null> { + const all = await getAccounts() + let running = getRunningAccounts(all) + + if (exclude?.size) { + running = running.filter((a) => !exclude.has(a.id)) + } + + if (running.length === 0) return null + + let selected: Account + + if (strategy === "priority") { + running.sort((a, b) => b.priority - a.priority) + selected = running[0] + } else { + rrIndex = rrIndex % running.length + selected = running[rrIndex] + rrIndex = (rrIndex + 1) % running.length + } + + const state = getInstanceState(selected.id) + if (!state) return null + + return { account: selected, state } +} diff --git a/web/src/App.tsx b/web/src/App.tsx index ee6c5eb1b..b6df82c7c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from "react" -import { api, getSessionToken, setSessionToken, type Account } from "./api" +import { api, getSessionToken, setSessionToken, type Account, type PoolConfig } from "./api" import { AccountCard } from "./components/AccountCard" import { AddAccountForm } from "./components/AddAccountForm" @@ -183,11 +183,191 @@ function AccountList({ ) } +function PoolSettings({ + pool, + proxyPort, + onChange, +}: { + pool: PoolConfig + proxyPort: number + onChange: (p: PoolConfig) => void +}) { + const [saving, setSaving] = useState(false) + const [keyVisible, setKeyVisible] = useState(false) + const [copied, setCopied] = useState(false) + + const toggle = async () => { + setSaving(true) + try { + const updated = await api.updatePool({ enabled: !pool.enabled }) + onChange(updated) + } finally { + setSaving(false) + } + } + + const changeStrategy = async (strategy: PoolConfig["strategy"]) => { + setSaving(true) + try { + const updated = await api.updatePool({ strategy }) + onChange(updated) + } finally { + setSaving(false) + } + } + + const regenKey = async () => { + setSaving(true) + try { + const updated = await api.regeneratePoolKey() + onChange(updated) + } finally { + setSaving(false) + } + } + + const copyKey = () => { + void navigator.clipboard.writeText(pool.apiKey) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } + + const maskedKey = + pool.apiKey?.length > 8 + ? `${pool.apiKey.slice(0, 8)}${"•".repeat(24)}` + : pool.apiKey ?? "" + + const proxyBase = `${window.location.protocol}//${window.location.hostname}:${proxyPort}` + + return ( +
+
+
+
Pool Mode
+
+ {pool.enabled + ? "Requests with pool key are load-balanced across running accounts" + : "Enable to auto-distribute requests across accounts"} +
+
+ +
+ {pool.enabled && ( + <> +
+ {(["round-robin", "priority"] as const).map((s) => ( + + ))} + + {pool.strategy === "round-robin" + ? "Evenly distribute across accounts" + : "Prefer higher-priority accounts first"} + +
+
+ + {copied ? "Copied!" : "Pool Key:"} + + + {keyVisible ? pool.apiKey : maskedKey} + + + +
+
+ Base URL: {proxyBase}  ·  Bearer {pool.apiKey?.slice(0, 8)}... +
+ + )} +
+ ) +} + function Dashboard() { const [accounts, setAccounts] = useState>([]) const [showForm, setShowForm] = useState(false) const [loading, setLoading] = useState(true) const [proxyPort, setProxyPort] = useState(4141) + const [pool, setPool] = useState({ + enabled: false, + strategy: "round-robin", + }) const refresh = useCallback(async () => { try { @@ -202,6 +382,7 @@ function Dashboard() { useEffect(() => { void api.getConfig().then((cfg) => setProxyPort(cfg.proxyPort)) + void api.getPool().then(setPool).catch(() => {}) void refresh() const interval = setInterval(() => void refresh(), 5000) return () => clearInterval(interval) @@ -241,6 +422,8 @@ function Dashboard() {
+ + {showForm && ( ) => + request(`/accounts/${id}`, { + method: "PUT", + body: JSON.stringify(data), + }), + + getPool: () => request("/pool"), + + updatePool: (data: Partial) => + request("/pool", { + method: "PUT", + body: JSON.stringify(data), + }), + + regeneratePoolKey: () => + request("/pool/regenerate-key", { method: "POST" }), } diff --git a/web/src/components/AccountCard.tsx b/web/src/components/AccountCard.tsx index da7ba8038..2d8d81c7e 100644 --- a/web/src/components/AccountCard.tsx +++ b/web/src/components/AccountCard.tsx @@ -343,6 +343,10 @@ export function AccountCard({ account, proxyPort, onRefresh }: Props) { const [usage, setUsage] = useState(null) const [usageLoading, setUsageLoading] = useState(false) const [showUsage, setShowUsage] = useState(false) + const [editingPriority, setEditingPriority] = useState(false) + const [priorityValue, setPriorityValue] = useState( + String(account.priority ?? 0), + ) const handleToggleUsage = async () => { if (showUsage) { @@ -373,6 +377,27 @@ export function AccountCard({ account, proxyPort, onRefresh }: Props) { })() } + const handlePrioritySave = () => { + const num = parseInt(priorityValue, 10) + if (isNaN(num) || num < 0) { + setPriorityValue(String(account.priority ?? 0)) + setEditingPriority(false) + return + } + setEditingPriority(false) + if (num !== (account.priority ?? 0)) { + void (async () => { + try { + await api.updateAccount(account.id, { priority: num }) + await onRefresh() + } catch (err) { + console.error("Priority update failed:", err) + setPriorityValue(String(account.priority ?? 0)) + } + })() + } + } + return (
+
+ Priority: + {editingPriority ? ( + setPriorityValue(e.target.value)} + onBlur={handlePrioritySave} + onKeyDown={(e) => { + if (e.key === "Enter") handlePrioritySave() + if (e.key === "Escape") { + setPriorityValue(String(account.priority ?? 0)) + setEditingPriority(false) + } + }} + autoFocus + min={0} + style={{ + width: 60, + padding: "2px 6px", + fontSize: 13, + display: "inline-block", + }} + /> + ) : ( + setEditingPriority(true)} + style={{ + cursor: "pointer", + padding: "2px 10px", + background: "var(--bg)", + border: "1px solid var(--border)", + borderRadius: 4, + fontFamily: "monospace", + fontSize: 13, + }} + title="Click to edit" + > + {account.priority ?? 0} + + )} + + Higher value = higher priority + +
+ {status === "running" && ( From c361511f73c5872b72fdd8fecb79fd406bcc1827 Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Mon, 16 Feb 2026 18:30:55 +0800 Subject: [PATCH 5/7] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E8=BD=AE=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 1265220ef..05cb8abcf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,4 +22,4 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh -ENTRYPOINT ["/entrypoint.sh"] +ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file From 486bab857af078b10ddf58e3d1e5a3c113efbef2 Mon Sep 17 00:00:00 2001 From: Elysia <1628615876@qq.com> Date: Mon, 16 Feb 2026 23:01:02 +0800 Subject: [PATCH 6/7] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 55 ++++++ Dockerfile | 36 ++-- src/console/api.ts | 30 +++ src/console/index.ts | 5 +- web/src/App.tsx | 253 +++++++++++++++++++++---- web/src/api.ts | 9 + web/src/components/AccountCard.tsx | 46 ++--- web/src/components/AddAccountForm.tsx | 42 +++-- web/src/i18n.tsx | 256 ++++++++++++++++++++++++++ web/src/main.tsx | 5 +- 10 files changed, 642 insertions(+), 95 deletions(-) create mode 100644 CLAUDE.md create mode 100644 web/src/i18n.tsx diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..84aa310cd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,55 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +copilot-api is a reverse proxy that exposes GitHub Copilot as OpenAI and Anthropic-compatible API endpoints. Built with Bun, TypeScript, Hono, and citty (CLI framework). + +## Commands + +- **Build:** `bun run build` (tsdown bundler, outputs to `dist/`) +- **Dev:** `bun run dev` (watch mode via `bun run --watch`) +- **Start:** `bun run start` (production mode) +- **Lint:** `bun run lint` (ESLint with `@echristian/eslint-config`) +- **Typecheck:** `bun run typecheck` (tsc, no emit) +- **Test all:** `bun test` +- **Test single file:** `bun test tests/.test.ts` +- **Unused code detection:** `bun run knip` + +## Architecture + +### CLI Layer (`src/main.ts`) +Entry point uses citty to define subcommands: `start`, `auth`, `check-usage`, `debug`, `console`. + +### Server (`src/server.ts`) +Hono app with routes mounted at both `/` and `/v1/` prefixes for compatibility: +- `POST /v1/chat/completions` — OpenAI-compatible chat completions +- `POST /v1/messages` — Anthropic-compatible messages API +- `POST /v1/messages/count_tokens` — Token counting +- `GET /v1/models` — Model listing +- `POST /v1/embeddings` — Embeddings +- `GET /usage`, `GET /token` — Monitoring endpoints + +### Key Directories +- `src/routes/` — Route handlers, each in its own directory with `route.ts` + `handler.ts` +- `src/services/copilot/` — GitHub Copilot API calls (completions, embeddings, models) +- `src/services/github/` — GitHub API calls (auth, tokens, usage) +- `src/lib/` — Shared utilities (state, tokens, rate limiting, error handling, proxy) +- `src/console/` — Multi-account management mode with load balancing and web UI +- `web/` — React + Vite frontend for the console mode +- `tests/` — Bun test runner, files named `*.test.ts` + +### Global State (`src/lib/state.ts`) +Mutable singleton `state` object holds runtime config: GitHub/Copilot tokens, account type, cached models, rate limit settings. + +### Anthropic Translation Layer (`src/routes/messages/`) +Converts between Anthropic message format and Copilot's OpenAI-style API. Handles both streaming (`stream-translation.ts`) and non-streaming (`non-stream-translation.ts`) responses. + +## Code Conventions + +- ESM only, strict TypeScript — no `any`, no unused variables/imports +- Path alias: `~/*` maps to `src/*` (e.g., `import { state } from "~/lib/state"`) +- camelCase for variables/functions, PascalCase for types/classes +- Zod v4 for runtime validation +- Pre-commit hook runs lint-staged via simple-git-hooks \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 05cb8abcf..45182e161 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,25 +1,39 @@ +# Stage 1: Build frontend +FROM node:22-alpine AS web-builder +WORKDIR /app/web + +COPY web/package.json ./ +RUN npm install + +COPY web/ ./ +RUN npm run build + +# Stage 2: Build backend FROM oven/bun:1.2.19-alpine AS builder WORKDIR /app -COPY ./package.json ./bun.lock ./ +COPY package.json bun.lock ./ RUN bun install --frozen-lockfile COPY . . -RUN bun run build -FROM oven/bun:1.2.19-alpine AS runner +# Stage 3: Runtime +FROM oven/bun:1.2.19-alpine WORKDIR /app -COPY ./package.json ./bun.lock ./ +COPY package.json bun.lock ./ RUN bun install --frozen-lockfile --production --ignore-scripts --no-cache -COPY --from=builder /app/dist ./dist +COPY --from=builder /app/src ./src +COPY --from=builder /app/tsconfig.json ./ +COPY --from=web-builder /app/web/dist ./web/dist + +EXPOSE 3000 4141 -EXPOSE 4141 +VOLUME /root/.local/share/copilot-api -HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD wget --spider -q http://localhost:4141/ || exit 1 +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD wget --spider -q http://localhost:3000/api/config || exit 1 -COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh -ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file +ENTRYPOINT ["bun", "run", "./src/main.ts", "console"] +CMD ["--web-port", "3000", "--proxy-port", "4141"] diff --git a/src/console/api.ts b/src/console/api.ts index 4a2ce3746..bba8c6011 100644 --- a/src/console/api.ts +++ b/src/console/api.ts @@ -122,6 +122,36 @@ consoleApi.get("/accounts", async (c) => { return c.json(result) }) +// Batch usage for all running accounts +consoleApi.get("/accounts/usage", async (c) => { + const accounts = await getAccounts() + const results = await Promise.all( + accounts.map(async (account) => { + const status = getInstanceStatus(account.id) + if (status !== "running") { + return { + accountId: account.id, + name: account.name, + status, + usage: null, + } + } + try { + const usage = await getInstanceUsage(account.id) + return { accountId: account.id, name: account.name, status, usage } + } catch { + return { + accountId: account.id, + name: account.name, + status, + usage: null, + } + } + }), + ) + return c.json(results) +}) + // Get single account consoleApi.get("/accounts/:id", async (c) => { const account = await getAccount(c.req.param("id")) diff --git a/src/console/index.ts b/src/console/index.ts index 76178d98d..63b118fdc 100644 --- a/src/console/index.ts +++ b/src/console/index.ts @@ -128,10 +128,7 @@ function mountProxyRoutes(app: Hono): void { } async function mountStaticFiles(app: Hono): Promise { - const webDistPath = path.resolve( - new URL(".", import.meta.url).pathname, - "../../web/dist", - ) + const webDistPath = path.resolve(import.meta.dirname, "../../web/dist") let hasWebDist = false try { diff --git a/web/src/App.tsx b/web/src/App.tsx index b6df82c7c..11a5e3faf 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,27 +1,41 @@ import { useCallback, useEffect, useState } from "react" -import { api, getSessionToken, setSessionToken, type Account, type PoolConfig } from "./api" +import { api, getSessionToken, setSessionToken, type Account, type BatchUsageItem, type PoolConfig } from "./api" import { AccountCard } from "./components/AccountCard" import { AddAccountForm } from "./components/AddAccountForm" +import { useLocale, useT } from "./i18n" type AuthState = "loading" | "setup" | "login" | "authed" +function LanguageSwitcher() { + const { locale, setLocale } = useLocale() + return ( + + ) +} + function SetupForm({ onComplete }: { onComplete: () => void }) { const [username, setUsername] = useState("") const [password, setPassword] = useState("") const [confirm, setConfirm] = useState("") const [error, setError] = useState("") const [loading, setLoading] = useState(false) + const t = useT() const handleSubmit = async (e: React.SyntheticEvent) => { e.preventDefault() setError("") if (password !== confirm) { - setError("Passwords do not match") + setError(t("passwordMismatch")) return } if (password.length < 6) { - setError("Password must be at least 6 characters") + setError(t("passwordTooShort")) return } setLoading(true) @@ -38,18 +52,21 @@ function SetupForm({ onComplete }: { onComplete: () => void }) { return (
-

- Copilot API Console -

+
+

+ {t("consoleTitle")} +

+ +

- Create your admin account to get started + {t("setupSubtitle")}

void handleSubmit(e)}> setUsername(e.target.value)} - placeholder="Username" + placeholder={t("usernamePlaceholder")} autoFocus autoComplete="username" style={{ marginBottom: 12 }} @@ -58,7 +75,7 @@ function SetupForm({ onComplete }: { onComplete: () => void }) { type="password" value={password} onChange={(e) => setPassword(e.target.value)} - placeholder="Password (min 6 chars)" + placeholder={t("passwordPlaceholder")} autoComplete="new-password" style={{ marginBottom: 12 }} /> @@ -66,7 +83,7 @@ function SetupForm({ onComplete }: { onComplete: () => void }) { type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} - placeholder="Confirm password" + placeholder={t("confirmPasswordPlaceholder")} autoComplete="new-password" style={{ marginBottom: 12 }} /> @@ -76,7 +93,7 @@ function SetupForm({ onComplete }: { onComplete: () => void }) {
)} @@ -88,6 +105,7 @@ function LoginForm({ onLogin }: { onLogin: () => void }) { const [password, setPassword] = useState("") const [error, setError] = useState("") const [loading, setLoading] = useState(false) + const t = useT() const handleSubmit = async (e: React.SyntheticEvent) => { e.preventDefault() @@ -98,7 +116,7 @@ function LoginForm({ onLogin }: { onLogin: () => void }) { setSessionToken(token) onLogin() } catch { - setError("Invalid username or password") + setError(t("invalidCredentials")) } finally { setLoading(false) } @@ -106,18 +124,21 @@ function LoginForm({ onLogin }: { onLogin: () => void }) { return (
-

- Copilot API Console -

+
+

+ {t("consoleTitle")} +

+ +

- Sign in to continue + {t("loginSubtitle")}

void handleSubmit(e)}> setUsername(e.target.value)} - placeholder="Username" + placeholder={t("usernamePlaceholder")} autoFocus autoComplete="username" style={{ marginBottom: 12 }} @@ -126,7 +147,7 @@ function LoginForm({ onLogin }: { onLogin: () => void }) { type="password" value={password} onChange={(e) => setPassword(e.target.value)} - placeholder="Password" + placeholder={t("passwordPlaceholder")} autoComplete="current-password" style={{ marginBottom: 12 }} /> @@ -136,7 +157,7 @@ function LoginForm({ onLogin }: { onLogin: () => void }) {
)} @@ -152,6 +173,8 @@ function AccountList({ proxyPort: number onRefresh: () => Promise }) { + const t = useT() + if (accounts.length === 0) { return (
-

No accounts configured

-

Add a GitHub account to get started

+

{t("noAccounts")}

+

{t("noAccountsHint")}

) } @@ -195,6 +218,7 @@ function PoolSettings({ const [saving, setSaving] = useState(false) const [keyVisible, setKeyVisible] = useState(false) const [copied, setCopied] = useState(false) + const t = useT() const toggle = async () => { setSaving(true) @@ -257,11 +281,11 @@ function PoolSettings({ }} >
-
Pool Mode
+
{t("poolMode")}
{pool.enabled - ? "Requests with pool key are load-balanced across running accounts" - : "Enable to auto-distribute requests across accounts"} + ? t("poolEnabledDesc") + : t("poolDisabledDesc")}
{pool.enabled && ( @@ -284,7 +308,7 @@ function PoolSettings({ disabled={saving || pool.strategy === s} style={{ fontSize: 13 }} > - {s === "round-robin" ? "Round Robin" : "Priority"} + {s === "round-robin" ? t("roundRobin") : t("priority")} ))} {pool.strategy === "round-robin" - ? "Evenly distribute across accounts" - : "Prefer higher-priority accounts first"} + ? t("roundRobinDesc") + : t("priorityDesc")}
- {copied ? "Copied!" : "Pool Key:"} + {copied ? t("copied") : t("poolKey")} setKeyVisible(!keyVisible)} style={{ padding: "2px 8px", fontSize: 11 }} > - {keyVisible ? "Hide" : "Show"} + {keyVisible ? t("hide") : t("show")}
- Base URL: {proxyBase}  ·  Bearer {pool.apiKey?.slice(0, 8)}... + {t("baseUrl")} {proxyBase}  ·  Bearer {pool.apiKey?.slice(0, 8)}...
)} @@ -359,6 +383,154 @@ function PoolSettings({ ) } +function usageColor(pct: number): string { + if (pct > 90) return "var(--red)" + if (pct > 70) return "var(--yellow)" + return "var(--green)" +} + +function UsageCell({ used, total }: { used: number; total: number }) { + const pct = total > 0 ? (used / total) * 100 : 0 + return ( + + {used} + / {total} + + ) +} + +function BatchUsagePanel() { + const [items, setItems] = useState>([]) + const [loading, setLoading] = useState(false) + const [open, setOpen] = useState(false) + const [fetched, setFetched] = useState(false) + const t = useT() + + const fetchAll = async () => { + setLoading(true) + try { + const data = await api.getAllUsage() + setItems(data) + setFetched(true) + setOpen(true) + } catch (err) { + console.error("Batch usage failed:", err) + } finally { + setLoading(false) + } + } + + const runningItems = items.filter((i) => i.usage) + + const totals = runningItems.reduce( + (acc, i) => { + const q = i.usage!.quota_snapshots + acc.premiumUsed += q.premium_interactions.entitlement - q.premium_interactions.remaining + acc.premiumTotal += q.premium_interactions.entitlement + acc.chatUsed += q.chat.entitlement - q.chat.remaining + acc.chatTotal += q.chat.entitlement + acc.compUsed += q.completions.entitlement - q.completions.remaining + acc.compTotal += q.completions.entitlement + return acc + }, + { premiumUsed: 0, premiumTotal: 0, chatUsed: 0, chatTotal: 0, compUsed: 0, compTotal: 0 }, + ) + + const thStyle: React.CSSProperties = { + padding: "8px 10px", + textAlign: "left", + fontSize: 12, + fontWeight: 600, + color: "var(--text-muted)", + borderBottom: "1px solid var(--border)", + } + + return ( +
+
+
{t("batchUsage")}
+
+ + {fetched && ( + + )} +
+
+ + {open && fetched && ( +
+ {runningItems.length === 0 ? ( +
+ {t("noRunningAccounts")} +
+ ) : ( + + + + + + + + + + + + + {runningItems.map((item) => { + const q = item.usage!.quota_snapshots + return ( + + + + + + + + + ) + })} + + + + +
{t("colAccount")}{t("colPlan")}{t("colPremium")}{t("colChat")}{t("colCompletions")}{t("colResets")}
{item.name} + {item.usage!.copilot_plan} + + {item.usage!.quota_reset_date} +
{t("totalSummary")} + + + + +
+ )} +
+ )} +
+ ) +} + function Dashboard() { const [accounts, setAccounts] = useState>([]) const [showForm, setShowForm] = useState(false) @@ -368,6 +540,7 @@ function Dashboard() { enabled: false, strategy: "round-robin", }) + const t = useT() const refresh = useCallback(async () => { try { @@ -409,21 +582,24 @@ function Dashboard() { }} >
-

Copilot API Console

+

{t("consoleTitle")}

- Manage multiple GitHub Copilot proxy accounts + {t("dashboardSubtitle")}

+ - +
+ + {showForm && ( - Loading... + {t("loading")}

: ("loading") + const t = useT() useEffect(() => { void (async () => { @@ -488,7 +665,7 @@ export function App() { padding: 120, }} > - Loading... + {t("loading")} ) } diff --git a/web/src/api.ts b/web/src/api.ts index 2a3aa9830..84e155327 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -72,6 +72,13 @@ export interface ConfigResponse { needsSetup: boolean } +export interface BatchUsageItem { + accountId: string + name: string + status: string + usage: UsageData | null +} + interface ErrorBody { error?: string } @@ -123,6 +130,8 @@ export const api = { getUsage: (id: string) => request(`/accounts/${id}/usage`), + getAllUsage: () => request>("/accounts/usage"), + regenerateKey: (id: string) => request(`/accounts/${id}/regenerate-key`, { method: "POST" }), diff --git a/web/src/components/AccountCard.tsx b/web/src/components/AccountCard.tsx index 2d8d81c7e..ec6df6cee 100644 --- a/web/src/components/AccountCard.tsx +++ b/web/src/components/AccountCard.tsx @@ -1,6 +1,7 @@ import { useState } from "react" import { api, type Account, type UsageData } from "../api" +import { useT } from "../i18n" function StatusBadge({ status }: { status: string }) { const colorMap: Record = { @@ -86,6 +87,7 @@ function QuotaBar({ function UsagePanel({ usage }: { usage: UsageData }) { const q = usage.quota_snapshots + const t = useT() return (
- Plan: {usage.copilot_plan} · Resets: {usage.quota_reset_date} + {t("plan")} {usage.copilot_plan} · {t("resets")} {usage.quota_reset_date}
@@ -140,6 +142,7 @@ function ApiKeyPanel({ }) { const [visible, setVisible] = useState(false) const [copied, copy] = useCopyFeedback() + const t = useT() const safeKey = apiKey ?? "" const masked = safeKey.length > 8 ? `${safeKey.slice(0, 8)}${"•".repeat(24)}` : safeKey const isCopied = copied === safeKey @@ -159,7 +162,7 @@ function ApiKeyPanel({ }} > - {isCopied ? "Copied!" : "API Key:"} + {isCopied ? t("copied") : t("apiKey")} copy(safeKey)} @@ -177,25 +180,19 @@ function ApiKeyPanel({ onClick={() => setVisible(!visible)} style={{ padding: "2px 8px", fontSize: 11 }} > - {visible ? "Hide" : "Show"} + {visible ? t("hide") : t("show")} ) } -function getUsageLabel(loading: boolean, visible: boolean): string { - if (loading) return "..." - if (visible) return "Hide Usage" - return "Usage" -} - interface Props { account: Account proxyPort: number @@ -219,6 +216,7 @@ function AccountActions({ }) { const [actionLoading, setActionLoading] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false) + const t = useT() const handleAction = async (action: () => Promise) => { setActionLoading(true) @@ -246,7 +244,7 @@ function AccountActions({
{status === "running" && ( )} {status === "running" ? @@ -254,14 +252,14 @@ function AccountActions({ onClick={() => void handleAction(() => api.stopInstance(account.id))} disabled={actionLoading} > - Stop + {t("stop")} : }
) @@ -279,6 +277,7 @@ function EndpointsPanel({ apiKey, proxyPort }: { apiKey: string; proxyPort: numb const proxyBase = `${window.location.protocol}//${window.location.hostname}:${proxyPort}` const safeKey = apiKey ?? "YOUR_API_KEY" const [copied, copy] = useCopyFeedback() + const t = useT() const endpoints = [ { label: "OpenAI", path: "/v1/chat/completions" }, @@ -305,7 +304,7 @@ function EndpointsPanel({ apiKey, proxyPort }: { apiKey: string; proxyPort: numb justifyContent: "space-between", }} > - Endpoints (Bearer {safeKey.slice(0, 8)}...) + {t("endpoints")} (Bearer {safeKey.slice(0, 8)}...) {proxyBase}
@@ -329,7 +328,7 @@ function EndpointsPanel({ apiKey, proxyPort }: { apiKey: string; proxyPort: numb }} title={url} > - {isCopied ? "Copied!" : ep.label} + {isCopied ? t("copied") : ep.label} ) })} @@ -347,6 +346,7 @@ export function AccountCard({ account, proxyPort, onRefresh }: Props) { const [priorityValue, setPriorityValue] = useState( String(account.priority ?? 0), ) + const t = useT() const handleToggleUsage = async () => { if (showUsage) { @@ -434,7 +434,7 @@ export function AccountCard({ account, proxyPort, onRefresh }: Props) {
{account.error && (
- Error: {account.error} + {t("error")} {account.error}
)} @@ -458,7 +458,7 @@ export function AccountCard({ account, proxyPort, onRefresh }: Props) { fontSize: 13, }} > - Priority: + {t("priorityLabel")} {editingPriority ? ( )} - Higher value = higher priority + {t("priorityHint")} @@ -517,7 +517,7 @@ export function AccountCard({ account, proxyPort, onRefresh }: Props) { color: "var(--text-muted)", }} > - Usage data unavailable. Make sure the instance is running. + {t("usageUnavailable")} )} ) diff --git a/web/src/components/AddAccountForm.tsx b/web/src/components/AddAccountForm.tsx index 73723673c..4a057d656 100644 --- a/web/src/components/AddAccountForm.tsx +++ b/web/src/components/AddAccountForm.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react" import { api } from "../api" +import { useT } from "../i18n" interface Props { onComplete: () => Promise @@ -16,6 +17,7 @@ function DeviceCodeDisplay({ userCode: string verificationUri: string }) { + const t = useT() return (

- Enter this code on GitHub: + {t("enterCode")}

void navigator.clipboard.writeText(userCode)} @@ -48,7 +50,7 @@ function DeviceCodeDisplay({ {userCode}

- Click the code to copy + {t("clickToCopy")}

- Open GitHub + {t("openGithub")}
) @@ -83,10 +85,11 @@ function AuthorizeStep({ error: string onCancel: () => void }) { + const t = useT() return (

- GitHub Authorization + {t("githubAuth")}

@@ -155,31 +158,32 @@ function ConfigForm({ accountType: string setAccountType: (v: string) => void }) { + const t = useT() return (

- Add Account + {t("addAccountTitle")}

- + setName(e.target.value)} - placeholder="e.g. Personal" + placeholder={t("accountNamePlaceholder")} />
- +
@@ -190,10 +194,10 @@ function ConfigForm({ )}
@@ -208,6 +212,7 @@ function useAuthFlow(onComplete: () => Promise) { const [loading, setLoading] = useState(false) const [error, setError] = useState("") const pollRef = useRef | null>(null) + const t = useT() const cleanup = useCallback(() => { if (pollRef.current) { @@ -226,7 +231,7 @@ function useAuthFlow(onComplete: () => Promise) { setUserCode(result.userCode) setVerificationUri(result.verificationUri) setStep("authorize") - setAuthStatus("Waiting for authorization...") + setAuthStatus(t("waitingAuth")) pollRef.current = setInterval(() => { void (async () => { @@ -234,7 +239,7 @@ function useAuthFlow(onComplete: () => Promise) { const poll = await api.pollAuth(result.sessionId) if (poll.status === "completed") { cleanup() - setAuthStatus("Authorized! Creating account...") + setAuthStatus(t("authorized")) await api.completeAuth({ sessionId: result.sessionId, name, @@ -245,7 +250,7 @@ function useAuthFlow(onComplete: () => Promise) { } else if (poll.status === "expired" || poll.status === "error") { cleanup() setAuthStatus("") - setError(poll.error ?? "Authorization failed or expired") + setError(poll.error ?? t("authFailed")) } } catch { // poll error, keep trying @@ -276,11 +281,12 @@ export function AddAccountForm({ onComplete, onCancel }: Props) { const [name, setName] = useState("") const [accountType, setAccountType] = useState("individual") const auth = useAuthFlow(onComplete) + const t = useT() const handleSubmit = (e: React.SyntheticEvent) => { e.preventDefault() if (!name.trim()) { - auth.setError("Account name is required") + auth.setError(t("accountNameRequired")) return } void auth.startAuth(name.trim(), accountType) diff --git a/web/src/i18n.tsx b/web/src/i18n.tsx new file mode 100644 index 000000000..b40714775 --- /dev/null +++ b/web/src/i18n.tsx @@ -0,0 +1,256 @@ +import { createContext, useCallback, useContext, useState, type ReactNode } from "react" + +export type Locale = "en" | "zh" + +const STORAGE_KEY = "copilot-api-locale" + +const en = { + // Common + loading: "Loading...", + cancel: "Cancel", + copied: "Copied!", + hide: "Hide", + show: "Show", + regen: "Regen", + + // Auth + consoleTitle: "Copilot API Console", + setupSubtitle: "Create your admin account to get started", + loginSubtitle: "Sign in to continue", + usernamePlaceholder: "Username", + passwordPlaceholder: "Password (min 6 chars)", + confirmPasswordPlaceholder: "Confirm password", + passwordMismatch: "Passwords do not match", + passwordTooShort: "Password must be at least 6 characters", + creating: "Creating...", + createAdmin: "Create Admin Account", + signingIn: "Signing in...", + signIn: "Sign In", + invalidCredentials: "Invalid username or password", + logout: "Logout", + + // Dashboard + dashboardSubtitle: "Manage multiple GitHub Copilot proxy accounts", + addAccount: "+ Add Account", + noAccounts: "No accounts configured", + noAccountsHint: "Add a GitHub account to get started", + + // Pool + poolMode: "Pool Mode", + poolEnabledDesc: "Requests with pool key are load-balanced across running accounts", + poolDisabledDesc: "Enable to auto-distribute requests across accounts", + disable: "Disable", + enable: "Enable", + roundRobin: "Round Robin", + priority: "Priority", + roundRobinDesc: "Evenly distribute across accounts", + priorityDesc: "Prefer higher-priority accounts first", + poolKey: "Pool Key:", + baseUrl: "Base URL:", + + // Account Card + apiKey: "API Key:", + endpoints: "Endpoints", + priorityLabel: "Priority:", + priorityHint: "Higher value = higher priority", + usageUnavailable: "Usage data unavailable. Make sure the instance is running.", + usage: "Usage", + hideUsage: "Hide Usage", + stop: "Stop", + start: "Start", + starting: "Starting...", + delete: "Delete", + confirmDelete: "Confirm?", + plan: "Plan:", + resets: "Resets:", + premium: "Premium", + chat: "Chat", + completions: "Completions", + error: "Error:", + + // Add Account + addAccountTitle: "Add Account", + accountName: "Account Name", + accountNamePlaceholder: "e.g. Personal", + accountType: "Account Type", + individual: "Individual", + business: "Business", + enterprise: "Enterprise", + loginWithGithub: "Login with GitHub", + accountNameRequired: "Account name is required", + + // GitHub Auth + githubAuth: "GitHub Authorization", + enterCode: "Enter this code on GitHub:", + clickToCopy: "Click the code to copy", + openGithub: "Open GitHub", + waitingAuth: "Waiting for authorization...", + authorized: "Authorized! Creating account...", + authFailed: "Authorization failed or expired", + + // Batch Usage + batchUsage: "Batch Usage Query", + queryAllUsage: "Query All Usage", + refreshing: "Querying...", + colAccount: "Account", + colStatus: "Status", + colPlan: "Plan", + colPremium: "Premium", + colChat: "Chat", + colCompletions: "Completions", + colResets: "Resets", + totalSummary: "Total", + noRunningAccounts: "No running accounts", +} as const + +export type TranslationKey = keyof typeof en +type Translations = Record + +const zh: Translations = { + // Common + loading: "加载中...", + cancel: "取消", + copied: "已复制!", + hide: "隐藏", + show: "显示", + regen: "重新生成", + + // Auth + consoleTitle: "Copilot API 控制台", + setupSubtitle: "创建管理员账户以开始使用", + loginSubtitle: "登录以继续", + usernamePlaceholder: "用户名", + passwordPlaceholder: "密码(至少 6 位)", + confirmPasswordPlaceholder: "确认密码", + passwordMismatch: "两次输入的密码不一致", + passwordTooShort: "密码至少需要 6 个字符", + creating: "创建中...", + createAdmin: "创建管理员账户", + signingIn: "登录中...", + signIn: "登录", + invalidCredentials: "用户名或密码错误", + logout: "退出登录", + + // Dashboard + dashboardSubtitle: "管理多个 GitHub Copilot 代理账户", + addAccount: "+ 添加账户", + noAccounts: "暂无账户", + noAccountsHint: "添加一个 GitHub 账户以开始使用", + + // Pool + poolMode: "池模式", + poolEnabledDesc: "使用池密钥的请求将在运行中的账户间负载均衡", + poolDisabledDesc: "启用后可自动分配请求到各账户", + disable: "禁用", + enable: "启用", + roundRobin: "轮询", + priority: "优先级", + roundRobinDesc: "均匀分配到各账户", + priorityDesc: "优先使用高优先级账户", + poolKey: "池密钥:", + baseUrl: "基础 URL:", + + // Account Card + apiKey: "API 密钥:", + endpoints: "接口端点", + priorityLabel: "优先级:", + priorityHint: "数值越大优先级越高", + usageUnavailable: "用量数据不可用,请确保实例正在运行。", + usage: "用量", + hideUsage: "隐藏用量", + stop: "停止", + start: "启动", + starting: "启动中...", + delete: "删除", + confirmDelete: "确认?", + plan: "计划:", + resets: "重置:", + premium: "高级", + chat: "对话", + completions: "补全", + error: "错误:", + + // Add Account + addAccountTitle: "添加账户", + accountName: "账户名称", + accountNamePlaceholder: "例如:个人", + accountType: "账户类型", + individual: "个人", + business: "商业", + enterprise: "企业", + loginWithGithub: "使用 GitHub 登录", + accountNameRequired: "请输入账户名称", + + // GitHub Auth + githubAuth: "GitHub 授权", + enterCode: "在 GitHub 上输入此代码:", + clickToCopy: "点击代码即可复制", + openGithub: "打开 GitHub", + waitingAuth: "等待授权中...", + authorized: "已授权!正在创建账户...", + authFailed: "授权失败或已过期", + + // Batch Usage + batchUsage: "批量额度查询", + queryAllUsage: "查询所有额度", + refreshing: "查询中...", + colAccount: "账户", + colStatus: "状态", + colPlan: "计划", + colPremium: "高级", + colChat: "对话", + colCompletions: "补全", + colResets: "重置日期", + totalSummary: "合计", + noRunningAccounts: "暂无运行中的账户", +} as const + +interface I18nContextValue { + locale: Locale + setLocale: (locale: Locale) => void + t: (key: TranslationKey) => string +} + +const I18nContext = createContext(null) + +function getInitialLocale(): Locale { + const saved = localStorage.getItem(STORAGE_KEY) + if (saved === "en" || saved === "zh") return saved + const lang = navigator.language + if (lang.startsWith("zh")) return "zh" + return "en" +} + +const translations: Record = { en, zh } + +export function I18nProvider({ children }: { children: ReactNode }) { + const [locale, setLocaleState] = useState(getInitialLocale) + + const setLocale = useCallback((l: Locale) => { + setLocaleState(l) + localStorage.setItem(STORAGE_KEY, l) + }, []) + + const t = useCallback( + (key: TranslationKey) => translations[locale][key], + [locale], + ) + + return ( + + {children} + + ) +} + +export function useT() { + const ctx = useContext(I18nContext) + if (!ctx) throw new Error("useT must be used within I18nProvider") + return ctx.t +} + +export function useLocale() { + const ctx = useContext(I18nContext) + if (!ctx) throw new Error("useLocale must be used within I18nProvider") + return { locale: ctx.locale, setLocale: ctx.setLocale } +} diff --git a/web/src/main.tsx b/web/src/main.tsx index c047c907c..30a6164b5 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -2,13 +2,16 @@ import { StrictMode } from "react" import { createRoot } from "react-dom/client" import { App } from "./App" +import { I18nProvider } from "./i18n" import "./index.css" const root = document.querySelector("#root") if (root) { createRoot(root).render( - + + + , ) } From 7f07915912e98d68bd33fcf60a04e69740a0847d Mon Sep 17 00:00:00 2001 From: Elysia <1628615876@qq.com> Date: Tue, 17 Feb 2026 10:15:14 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 docker-compose.yml diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..6f3cf7c0b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + copilot-api: + image: copilot-api + ports: + - "3000:3000" + - "4141:4141" + volumes: + - copilot-api-data:/root/.local/share/copilot-api + restart: unless-stopped + +volumes: + copilot-api-data: