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 (
+
+
+
+ {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" ?
+ void handleAction(() => api.stopInstance(account.id))}
+ disabled={actionLoading}
+ >
+ Stop
+
+ : void handleAction(() => api.startInstance(account.id))}
+ disabled={actionLoading}
+ >
+ {actionLoading ? "Starting..." : "Start"}
+
+ }
+ void handleDelete()}
+ disabled={actionLoading}
+ >
+ {confirmDelete ? "Confirm?" : "Delete"}
+
+
+ )
+}
+
+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 (
+ <>
+ void handleToggleUsage()} disabled={usageLoading}>
+ {getUsageLabel(usageLoading, showUsage)}
+
+ {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}
+
+ )}
+
+
+ Cancel
+
+
+
+ )
+}
+
+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 (
+
+
+ Port
+
+
+
+ onPortChange(e.target.value)}
+ placeholder="4141"
+ style={{ borderColor: getPortBorderColor(portStatus) }}
+ />
+
+ Auto
+
+
+ {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 (
+
+ )
+}
+
+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
+
+
+
+ )
+}
+
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}
+
+
setVisible(!visible)}
+ style={{ padding: "2px 8px", fontSize: 11 }}
+ >
+ {visible ? "Hide" : "Show"}
+
+
+ Regen
+
)
}
@@ -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" && (
+
+ {getUsageLabel(usageLoading, showUsage)}
+
+ )}
{status === "running" ?
void handleAction(() => api.stopInstance(account.id))}
@@ -232,7 +275,71 @@ function AccountActions({
)
}
-function UsageSection({ accountId }: { accountId: string }) {
+function EndpointsPanel({ apiKey, proxyPort }: { apiKey: string; proxyPort: number }) {
+ const proxyBase = `${window.location.protocol}//${window.location.hostname}:${proxyPort}`
+ const safeKey = apiKey ?? "YOUR_API_KEY"
+ const [copied, copy] = useCopyFeedback()
+
+ const endpoints = [
+ { label: "OpenAI", path: "/v1/chat/completions" },
+ { label: "Anthropic", path: "/v1/messages" },
+ { label: "Models", path: "/v1/models" },
+ { label: "Embeddings", path: "/v1/embeddings" },
+ ]
+
+ return (
+
+
+ Endpoints (Bearer {safeKey.slice(0, 8)}...)
+ {proxyBase}
+
+
+ {endpoints.map((ep) => {
+ const url = `${proxyBase}${ep.path}`
+ const isCopied = copied === url
+ return (
+ copy(url)}
+ style={{
+ padding: "2px 8px",
+ background: isCopied ? "var(--green)" : "var(--bg-card)",
+ color: isCopied ? "#fff" : undefined,
+ border: `1px solid ${isCopied ? "var(--green)" : "var(--border)"}`,
+ borderRadius: 4,
+ fontFamily: "monospace",
+ cursor: "pointer",
+ fontSize: 11,
+ transition: "all 0.2s",
+ }}
+ title={url}
+ >
+ {isCopied ? "Copied!" : ep.label}
+
+ )
+ })}
+
+
+ )
+}
+
+export function AccountCard({ account, proxyPort, onRefresh }: Props) {
+ const status = account.status ?? "stopped"
const [usage, setUsage] = useState(null)
const [usageLoading, setUsageLoading] = useState(false)
const [showUsage, setShowUsage] = useState(false)
@@ -244,7 +351,7 @@ function UsageSection({ accountId }: { accountId: string }) {
}
setUsageLoading(true)
try {
- const data = await api.getUsage(accountId)
+ const data = await api.getUsage(account.id)
setUsage(data)
setShowUsage(true)
} catch {
@@ -255,29 +362,16 @@ function UsageSection({ accountId }: { accountId: string }) {
}
}
- return (
- <>
- void handleToggleUsage()} disabled={usageLoading}>
- {getUsageLabel(usageLoading, showUsage)}
-
- {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 (
-
-
- Port
-
-
-
- onPortChange(e.target.value)}
- placeholder="4141"
- style={{ borderColor: getPortBorderColor(portStatus) }}
- />
-
- Auto
-
-
- {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 (
-
+
+
{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 (
+ setLocale(locale === "en" ? "zh" : "en")}
+ style={{ fontSize: 13, padding: "4px 10px" }}
+ >
+ {locale === "en" ? "中文" : "EN"}
+
+ )
+}
+
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 }) {
)}
- {loading ? "Creating..." : "Create Admin Account"}
+ {loading ? t("creating") : t("createAdmin")}
@@ -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 }) {
)}
- {loading ? "Signing in..." : "Sign In"}
+ {loading ? t("signingIn") : t("signIn")}
@@ -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 ? "Disable" : "Enable"}
+ {pool.enabled ? t("disable") : t("enable")}
{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")}
- Regen
+ {t("regen")}
- 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")}
+
+ void fetchAll()} disabled={loading}>
+ {loading ? t("refreshing") : t("queryAllUsage")}
+
+ {fetched && (
+ setOpen(!open)}>
+ {open ? t("hide") : t("show")}
+
+ )}
+
+
+
+ {open && fetched && (
+
+ {runningItems.length === 0 ? (
+
+ {t("noRunningAccounts")}
+
+ ) : (
+
+
+
+ {t("colAccount")}
+ {t("colPlan")}
+ {t("colPremium")}
+ {t("colChat")}
+ {t("colCompletions")}
+ {t("colResets")}
+
+
+
+ {runningItems.map((item) => {
+ const q = item.usage!.quota_snapshots
+ return (
+
+ {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")}
+
setShowForm(!showForm)}>
- {showForm ? "Cancel" : "+ Add Account"}
+ {showForm ? t("cancel") : t("addAccount")}
- Logout
+ {t("logout")}
+
+
{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")}
- Regen
+ {t("regen")}
)
}
-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" && (
- {getUsageLabel(usageLoading, showUsage)}
+ {usageLoading ? "..." : showUsage ? t("hideUsage") : t("usage")}
)}
{status === "running" ?
@@ -254,14 +252,14 @@ function AccountActions({
onClick={() => void handleAction(() => api.stopInstance(account.id))}
disabled={actionLoading}
>
- Stop
+ {t("stop")}
: void handleAction(() => api.startInstance(account.id))}
disabled={actionLoading}
>
- {actionLoading ? "Starting..." : "Start"}
+ {actionLoading ? t("starting") : t("start")}
}
void handleDelete()}
disabled={actionLoading}
>
- {confirmDelete ? "Confirm?" : "Delete"}
+ {confirmDelete ? t("confirmDelete") : t("delete")}
)
@@ -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")}
- Cancel
+ {t("cancel")}
@@ -155,31 +158,32 @@ function ConfigForm({
accountType: string
setAccountType: (v: string) => void
}) {
+ const t = useT()
return (
- Add Account
+ {t("addAccountTitle")}
@@ -190,10 +194,10 @@ function ConfigForm({
)}
- Cancel
+ {t("cancel")}
- {loading ? "Starting..." : "Login with GitHub"}
+ {loading ? t("starting") : t("loginWithGithub")}
@@ -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: