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/.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/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 1265220ef..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"] +ENTRYPOINT ["bun", "run", "./src/main.ts", "console"] +CMD ["--web-port", "3000", "--proxy-port", "4141"] diff --git a/Dockerfile.console b/Dockerfile.console new file mode 100644 index 000000000..45182e161 --- /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 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/config || exit 1 + +ENTRYPOINT ["bun", "run", "./src/main.ts", "console"] +CMD ["--web-port", "3000", "--proxy-port", "4141"] 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/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: 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 new file mode 100644 index 000000000..1297c6993 --- /dev/null +++ b/src/console/account-store.ts @@ -0,0 +1,159 @@ +import crypto from "node:crypto" +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 + apiKey: string + enabled: boolean + createdAt: string + priority: number +} + +export interface PoolConfig { + enabled: boolean + strategy: "round-robin" | "priority" + apiKey: string +} + +export interface AccountStore { + accounts: Array + pool?: PoolConfig +} + +const STORE_PATH = path.join(PATHS.APP_DIR, "accounts.json") + +function generateApiKey(): string { + return `cpa-${crypto.randomBytes(16).toString("hex")}` +} + +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 getAccountByApiKey( + apiKey: string, +): Promise { + const store = await readStore() + return store.accounts.find((a) => a.apiKey === apiKey) +} + +export async function addAccount( + account: Omit & { + priority?: number + }, +): Promise { + const store = await readStore() + const newAccount: Account = { + ...account, + id: crypto.randomUUID(), + apiKey: generateApiKey(), + createdAt: new Date().toISOString(), + priority: account.priority ?? 0, + } + 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 +} + +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] +} + +export async function getPoolConfig(): Promise { + const store = await readStore() + if (!store.pool) return undefined + // Backfill apiKey for legacy stores + if (!store.pool.apiKey) { + store.pool.apiKey = generatePoolApiKey() + await writeStore(store) + } + return store.pool +} + +export async function updatePoolConfig( + config: Partial, +): Promise { + const store = await readStore() + const current: PoolConfig = store.pool ?? { + enabled: false, + strategy: "round-robin", + apiKey: generatePoolApiKey(), + } + store.pool = { ...current, ...config } + // Ensure apiKey exists for legacy stores + if (!store.pool.apiKey) { + store.pool.apiKey = generatePoolApiKey() + } + await writeStore(store) + return store.pool +} + +export async function regeneratePoolApiKey(): Promise { + const store = await readStore() + const current: PoolConfig = store.pool ?? { + enabled: false, + strategy: "round-robin", + apiKey: generatePoolApiKey(), + } + current.apiKey = generatePoolApiKey() + store.pool = current + await writeStore(store) + return store.pool +} + +function generatePoolApiKey(): string { + return `cpp-${crypto.randomBytes(16).toString("hex")}` +} diff --git a/src/console/admin-auth.ts b/src/console/admin-auth.ts new file mode 100644 index 000000000..03a09651e --- /dev/null +++ b/src/console/admin-auth.ts @@ -0,0 +1,92 @@ +import crypto from "node:crypto" +import fs from "node:fs/promises" +import path from "node:path" + +import { PATHS } from "~/lib/paths" + +interface AdminCredentials { + username: string + passwordHash: string +} + +interface AdminStore { + credentials: AdminCredentials | null + sessions: Array<{ token: string; createdAt: string }> +} + +const STORE_PATH = path.join(PATHS.APP_DIR, "admin.json") +const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000 // 7 days + +async function readStore(): Promise { + try { + const data = await fs.readFile(STORE_PATH) + return JSON.parse(data) as AdminStore + } catch { + return { credentials: null, sessions: [] } + } +} + +async function writeStore(store: AdminStore): Promise { + await fs.writeFile(STORE_PATH, JSON.stringify(store, null, 2)) +} + +export async function isSetupRequired(): Promise { + const store = await readStore() + return store.credentials === null +} + +export async function setupAdmin( + username: string, + password: string, +): Promise { + const store = await readStore() + if (store.credentials !== null) { + throw new Error("Admin already configured") + } + const passwordHash = await Bun.password.hash(password) + const token = generateSessionToken() + store.credentials = { username, passwordHash } + store.sessions = [{ token, createdAt: new Date().toISOString() }] + await writeStore(store) + return token +} + +export async function loginAdmin( + username: string, + password: string, +): Promise { + const store = await readStore() + if (!store.credentials) return null + if (store.credentials.username !== username) return null + const valid = await Bun.password.verify( + password, + store.credentials.passwordHash, + ) + if (!valid) return null + const token = generateSessionToken() + cleanExpiredSessions(store) + store.sessions.push({ token, createdAt: new Date().toISOString() }) + await writeStore(store) + return token +} + +export async function validateSession(token: string): Promise { + const store = await readStore() + const now = Date.now() + return store.sessions.some((s) => { + const age = now - new Date(s.createdAt).getTime() + return s.token === token && age < SESSION_TTL_MS + }) +} + +function generateSessionToken(): string { + return `session-${crypto.randomBytes(24).toString("hex")}` +} + +function cleanExpiredSessions(store: AdminStore): void { + const now = Date.now() + store.sessions = store.sessions.filter((s) => { + const age = now - new Date(s.createdAt).getTime() + return age < SESSION_TTL_MS + }) +} diff --git a/src/console/api.ts b/src/console/api.ts new file mode 100644 index 000000000..bba8c6011 --- /dev/null +++ b/src/console/api.ts @@ -0,0 +1,322 @@ +import { Hono } from "hono" +import { cors } from "hono/cors" +import { z } from "zod" + +import { + addAccount, + deleteAccount, + getAccount, + getAccounts, + getPoolConfig, + regenerateApiKey, + regeneratePoolApiKey, + updateAccount, + updatePoolConfig, +} from "./account-store" +import { + isSetupRequired, + loginAdmin, + setupAdmin, + validateSession, +} from "./admin-auth" +import { cleanupSession, getSession, startDeviceFlow } from "./auth-flow" +import { + getInstanceError, + getInstanceStatus, + getInstanceUsage, + getInstanceUser, + startInstance, + stopInstance, +} from "./instance-manager" + +let proxyPort = 4141 + +export function setProxyPort(port: number): void { + proxyPort = port +} + +function formatZodError(err: z.ZodError): string { + return z.treeifyError(err).children ? + JSON.stringify(z.treeifyError(err)) + : err.message +} + +export const consoleApi = new Hono() + +consoleApi.use(cors()) + +// Public endpoints (no auth required) +consoleApi.get("/config", async (c) => { + const needsSetup = await isSetupRequired() + return c.json({ proxyPort, needsSetup }) +}) + +const SetupSchema = z.object({ + username: z.string().min(1), + password: z.string().min(6), +}) + +consoleApi.post("/auth/setup", async (c) => { + const body: unknown = await c.req.json() + const parsed = SetupSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: formatZodError(parsed.error) }, 400) + } + try { + const token = await setupAdmin(parsed.data.username, parsed.data.password) + return c.json({ token }) + } catch (error) { + return c.json({ error: (error as Error).message }, 400) + } +}) + +const LoginSchema = z.object({ + username: z.string().min(1), + password: z.string().min(1), +}) + +consoleApi.post("/auth/login", async (c) => { + const body: unknown = await c.req.json() + const parsed = LoginSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: formatZodError(parsed.error) }, 400) + } + const token = await loginAdmin(parsed.data.username, parsed.data.password) + if (!token) { + return c.json({ error: "Invalid username or password" }, 401) + } + return c.json({ token }) +}) + +// Admin auth middleware (session token) +consoleApi.use("/*", async (c, next) => { + const auth = c.req.header("authorization") + const token = auth?.replace("Bearer ", "") + if (!token || !(await validateSession(token))) { + return c.json({ error: "Unauthorized" }, 401) + } + return next() +}) + +// Auth check endpoint +consoleApi.get("/auth/check", (c) => c.json({ ok: true })) + +// 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) +}) + +// 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")) + 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 }) +}) + +const AddAccountSchema = z.object({ + name: z.string().min(1), + githubToken: z.string().min(1), + accountType: z.string().default("individual"), + enabled: z.boolean().default(true), + priority: z.number().int().min(0).default(0), +}) + +// Add account +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 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(), + priority: z.number().int().min(0).optional(), +}) + +// 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 account = await updateAccount(c.req.param("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") + 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")) + 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", (c) => { + 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, + }) +}) + +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) + 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 account = await addAccount({ + name: parsed.data.name, + githubToken: session.accessToken, + accountType: parsed.data.accountType, + enabled: true, + }) + + cleanupSession(parsed.data.sessionId) + return c.json(account, 201) +}) + +// === Pool Configuration === + +consoleApi.get("/pool", async (c) => { + const config = await getPoolConfig() + return c.json(config ?? { enabled: false, strategy: "round-robin" }) +}) + +const UpdatePoolSchema = z.object({ + enabled: z.boolean().optional(), + strategy: z.enum(["round-robin", "priority"]).optional(), +}) + +consoleApi.put("/pool", async (c) => { + const body: unknown = await c.req.json() + const parsed = UpdatePoolSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: formatZodError(parsed.error) }, 400) + } + const config = await updatePoolConfig(parsed.data) + return c.json(config) +}) + +consoleApi.post("/pool/regenerate-key", async (c) => { + const config = await regeneratePoolApiKey() + return c.json(config) +}) diff --git a/src/console/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..63b118fdc --- /dev/null +++ b/src/console/index.ts @@ -0,0 +1,281 @@ +import { defineCommand } from "citty" +import consola from "consola" +import { Hono } from "hono" +import { cors } from "hono/cors" +import { logger } from "hono/logger" +import fs from "node:fs/promises" +import path from "node:path" +import { serve, type ServerHandler } from "srvx" + +import { ensurePaths } from "~/lib/paths" + +import { getAccountByApiKey, getAccounts, getPoolConfig } from "./account-store" +import { consoleApi, setProxyPort } from "./api" +import { + completionsHandler, + countTokensHandler, + embeddingsHandler, + getInstanceState, + messagesHandler, + modelsHandler, + startInstance, +} from "./instance-manager" +import { selectAccount } from "./load-balancer" + +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: { + "web-port": { + alias: "w", + type: "string", + default: "3000", + description: "Port for the web management console", + }, + "proxy-port": { + alias: "p", + type: "string", + default: "4141", + description: "Port for the proxy API endpoints", + }, + 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 webPort = Number.parseInt(args["web-port"], 10) + const proxyPort = Number.parseInt(args["proxy-port"], 10) + setProxyPort(proxyPort) + + // === Web console server === + const webApp = new Hono() + webApp.use(cors()) + webApp.use(logger()) + webApp.route("/api", consoleApi) + await mountStaticFiles(webApp) + + // === Proxy server === + const proxyApp = new Hono() + proxyApp.use(cors()) + mountProxyRoutes(proxyApp) + + // 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 }) + + consola.box( + [ + `🎛️ Console: http://localhost:${webPort}`, + `🔐 First visit to set up admin account`, + "", + `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"), + ) + }, +}) + +function mountProxyRoutes(app: Hono): void { + app.post("/chat/completions", proxyAuth, (c) => + withPoolRetry(c, completionsHandler), + ) + app.post("/v1/chat/completions", proxyAuth, (c) => + withPoolRetry(c, completionsHandler), + ) + app.get("/models", proxyAuth, (c) => modelsHandler(c, getState(c))) + app.get("/v1/models", proxyAuth, (c) => modelsHandler(c, getState(c))) + app.post("/embeddings", proxyAuth, (c) => withPoolRetry(c, embeddingsHandler)) + app.post("/v1/embeddings", proxyAuth, (c) => + withPoolRetry(c, embeddingsHandler), + ) + app.post("/v1/messages", proxyAuth, (c) => withPoolRetry(c, messagesHandler)) + app.post("/v1/messages/count_tokens", proxyAuth, (c) => + withPoolRetry(c, countTokensHandler), + ) +} + +async function mountStaticFiles(app: Hono): Promise { + const webDistPath = path.resolve(import.meta.dirname, "../../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) + 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) + } + } + } +} + +async function proxyAuth( + c: import("hono").Context, + next: () => Promise, +): Promise { + const auth = c.req.header("authorization") + const token = auth?.replace(/^Bearer\s+/i, "") + + // Try per-account key first + if (token) { + const account = await getAccountByApiKey(token) + if (account) { + const st = getInstanceState(account.id) + if (!st) { + return c.json({ error: "Account instance not running" }, 503) + } + c.set("proxyState", st) + await next() + return + } + } + + // Fall back to pool mode + const poolConfig = await getPoolConfig() + if (!poolConfig?.enabled || !token || token !== poolConfig.apiKey) { + return c.json({ error: "Invalid API key" }, 401) + } + + const result = await selectAccount(poolConfig.strategy) + if (!result) { + return c.json({ error: "No available accounts in pool" }, 503) + } + + c.set("proxyState", result.state) + c.set("poolMode", true) + c.set("poolStrategy", poolConfig.strategy) + c.set("poolAccountId", result.account.id) + await next() +} + +function getState(c: import("hono").Context): import("~/lib/state").State { + return c.get("proxyState") as import("~/lib/state").State +} + +type Handler = ( + c: import("hono").Context, + st: import("~/lib/state").State, +) => Promise | Response + +async function withPoolRetry( + c: import("hono").Context, + handler: Handler, +): Promise { + const isPool = c.get("poolMode") as boolean | undefined + if (!isPool) { + return handler(c, getState(c)) + } + + // Buffer the body for potential retries + const body: unknown = await c.req.json() + c.set("bufferedBody", body) + + const strategy = c.get("poolStrategy") as "round-robin" | "priority" + const exclude = new Set() + + // First attempt with the account already selected by proxyAuth + const firstResponse = await handler(c, getState(c)) + if (!isRetryableStatus(firstResponse.status)) { + return firstResponse + } + + // Add the first account to exclude list and retry with others + exclude.add(c.get("poolAccountId") as string) + + while (true) { + const next = await selectAccount(strategy, exclude) + if (!next) { + // No more accounts to try, return the last error response + return firstResponse + } + + exclude.add(next.account.id) + c.set("proxyState", next.state) + + const retryResponse = await handler(c, next.state) + if (!isRetryableStatus(retryResponse.status)) { + return retryResponse + } + } +} + +function isRetryableStatus(status: number): boolean { + return status === 429 || status >= 500 +} diff --git a/src/console/instance-manager.ts b/src/console/instance-manager.ts new file mode 100644 index 000000000..d10e89d2d --- /dev/null +++ b/src/console/instance-manager.ts @@ -0,0 +1,414 @@ +import type { Context } from "hono" + +import consola from "consola" +import { events } from "fetch-event-stream" +import { streamSSE } from "hono/streaming" + +import type { State } from "~/lib/state" +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 { type AnthropicMessagesPayload } from "~/routes/messages/anthropic-types" +import { + 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" + +interface ProxyInstance { + account: Account + state: State + 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, status: "stopped" } + + try { + st.vsCodeVersion = await getVSCodeVersion() + await setupInstanceToken(instance) + st.models = await fetchModels(st) + instance.status = "running" + instances.set(account.id, instance) + consola.success(`[${account.name}] Instance ready`) + } 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 function stopInstance(accountId: string): void { + const instance = instances.get(accountId) + if (!instance) return + try { + if (instance.tokenInterval) clearInterval(instance.tokenInterval) + instance.status = "stopped" + consola.info(`[${instance.account.name}] Instance 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 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 + 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 + } +} + +// === Proxy handlers (used by unified router) === + +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)) +} + +export async function completionsHandler( + c: Context, + st: State, +): Promise { + try { + const payload = + (c.get("bufferedBody") as CompletionsPayload | undefined) + ?? (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, + ) + } +} + +export function modelsHandler(c: Context, st: State): Response { + 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 }) +} + +export async function embeddingsHandler( + c: Context, + st: State, +): Promise { + try { + const payload = + (c.get("bufferedBody") as Record | undefined) + ?? (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, + ) + } +} + +export async function messagesHandler( + c: Context, + st: State, +): Promise { + try { + const anthropicPayload = + (c.get("bufferedBody") as AnthropicMessagesPayload | undefined) + ?? (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, + ) + } +} + +export async function countTokensHandler( + c: Context, + st: State, +): Promise { + try { + const anthropicPayload = + (c.get("bufferedBody") as AnthropicMessagesPayload | undefined) + ?? (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/load-balancer.ts b/src/console/load-balancer.ts new file mode 100644 index 000000000..27b32a5be --- /dev/null +++ b/src/console/load-balancer.ts @@ -0,0 +1,40 @@ +import { type Account, getAccounts } from "./account-store" +import { getInstanceState } from "./instance-manager" + +let rrIndex = 0 + +export function getRunningAccounts(accounts: Array): Array { + return accounts.filter( + (a) => a.enabled && getInstanceState(a.id) !== undefined, + ) +} + +export async function selectAccount( + strategy: "round-robin" | "priority", + exclude?: Set, +): Promise<{ account: Account; state: import("~/lib/state").State } | null> { + const all = await getAccounts() + let running = getRunningAccounts(all) + + if (exclude?.size) { + running = running.filter((a) => !exclude.has(a.id)) + } + + if (running.length === 0) return null + + let selected: Account + + if (strategy === "priority") { + running.sort((a, b) => b.priority - a.priority) + selected = running[0] + } else { + rrIndex = rrIndex % running.length + selected = running[rrIndex] + rrIndex = (rrIndex + 1) % running.length + } + + const state = getInstanceState(selected.id) + if (!state) return null + + return { account: selected, state } +} diff --git a/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..11a5e3faf --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,682 @@ +import { useCallback, useEffect, useState } from "react" + +import { api, getSessionToken, setSessionToken, type Account, type BatchUsageItem, type PoolConfig } from "./api" +import { AccountCard } from "./components/AccountCard" +import { AddAccountForm } from "./components/AddAccountForm" +import { useLocale, useT } from "./i18n" + +type AuthState = "loading" | "setup" | "login" | "authed" + +function LanguageSwitcher() { + const { locale, setLocale } = useLocale() + return ( + + ) +} + +function SetupForm({ onComplete }: { onComplete: () => void }) { + const [username, setUsername] = useState("") + const [password, setPassword] = useState("") + const [confirm, setConfirm] = useState("") + const [error, setError] = useState("") + const [loading, setLoading] = useState(false) + const t = useT() + + const handleSubmit = async (e: React.SyntheticEvent) => { + e.preventDefault() + setError("") + if (password !== confirm) { + setError(t("passwordMismatch")) + return + } + if (password.length < 6) { + setError(t("passwordTooShort")) + return + } + setLoading(true) + try { + const { token } = await api.setup(username, password) + setSessionToken(token) + onComplete() + } catch (err) { + setError((err as Error).message) + } finally { + setLoading(false) + } + } + + return ( +
+
+

+ {t("consoleTitle")} +

+ +
+

+ {t("setupSubtitle")} +

+
void handleSubmit(e)}> + setUsername(e.target.value)} + placeholder={t("usernamePlaceholder")} + autoFocus + autoComplete="username" + style={{ marginBottom: 12 }} + /> + setPassword(e.target.value)} + placeholder={t("passwordPlaceholder")} + autoComplete="new-password" + style={{ marginBottom: 12 }} + /> + setConfirm(e.target.value)} + placeholder={t("confirmPasswordPlaceholder")} + autoComplete="new-password" + style={{ marginBottom: 12 }} + /> + {error && ( +
+ {error} +
+ )} + +
+
+ ) +} + +function LoginForm({ onLogin }: { onLogin: () => void }) { + const [username, setUsername] = useState("") + const [password, setPassword] = useState("") + const [error, setError] = useState("") + const [loading, setLoading] = useState(false) + const t = useT() + + const handleSubmit = async (e: React.SyntheticEvent) => { + e.preventDefault() + setError("") + setLoading(true) + try { + const { token } = await api.login(username, password) + setSessionToken(token) + onLogin() + } catch { + setError(t("invalidCredentials")) + } finally { + setLoading(false) + } + } + + return ( +
+
+

+ {t("consoleTitle")} +

+ +
+

+ {t("loginSubtitle")} +

+
void handleSubmit(e)}> + setUsername(e.target.value)} + placeholder={t("usernamePlaceholder")} + autoFocus + autoComplete="username" + style={{ marginBottom: 12 }} + /> + setPassword(e.target.value)} + placeholder={t("passwordPlaceholder")} + autoComplete="current-password" + style={{ marginBottom: 12 }} + /> + {error && ( +
+ {error} +
+ )} + +
+
+ ) +} + +function AccountList({ + accounts, + proxyPort, + onRefresh, +}: { + accounts: Array + proxyPort: number + onRefresh: () => Promise +}) { + const t = useT() + + if (accounts.length === 0) { + return ( +
+

{t("noAccounts")}

+

{t("noAccountsHint")}

+
+ ) + } + + return ( +
+ {accounts.map((account) => ( + + ))} +
+ ) +} + +function PoolSettings({ + pool, + proxyPort, + onChange, +}: { + pool: PoolConfig + proxyPort: number + onChange: (p: PoolConfig) => void +}) { + const [saving, setSaving] = useState(false) + const [keyVisible, setKeyVisible] = useState(false) + const [copied, setCopied] = useState(false) + const t = useT() + + const toggle = async () => { + setSaving(true) + try { + const updated = await api.updatePool({ enabled: !pool.enabled }) + onChange(updated) + } finally { + setSaving(false) + } + } + + const changeStrategy = async (strategy: PoolConfig["strategy"]) => { + setSaving(true) + try { + const updated = await api.updatePool({ strategy }) + onChange(updated) + } finally { + setSaving(false) + } + } + + const regenKey = async () => { + setSaving(true) + try { + const updated = await api.regeneratePoolKey() + onChange(updated) + } finally { + setSaving(false) + } + } + + const copyKey = () => { + void navigator.clipboard.writeText(pool.apiKey) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } + + const maskedKey = + pool.apiKey?.length > 8 + ? `${pool.apiKey.slice(0, 8)}${"•".repeat(24)}` + : pool.apiKey ?? "" + + const proxyBase = `${window.location.protocol}//${window.location.hostname}:${proxyPort}` + + return ( +
+
+
+
{t("poolMode")}
+
+ {pool.enabled + ? t("poolEnabledDesc") + : t("poolDisabledDesc")} +
+
+ +
+ {pool.enabled && ( + <> +
+ {(["round-robin", "priority"] as const).map((s) => ( + + ))} + + {pool.strategy === "round-robin" + ? t("roundRobinDesc") + : t("priorityDesc")} + +
+
+ + {copied ? t("copied") : t("poolKey")} + + + {keyVisible ? pool.apiKey : maskedKey} + + + +
+
+ {t("baseUrl")} {proxyBase}  ·  Bearer {pool.apiKey?.slice(0, 8)}... +
+ + )} +
+ ) +} + +function usageColor(pct: number): string { + if (pct > 90) return "var(--red)" + if (pct > 70) return "var(--yellow)" + return "var(--green)" +} + +function UsageCell({ used, total }: { used: number; total: number }) { + const pct = total > 0 ? (used / total) * 100 : 0 + return ( + + {used} + / {total} + + ) +} + +function BatchUsagePanel() { + const [items, setItems] = useState>([]) + const [loading, setLoading] = useState(false) + const [open, setOpen] = useState(false) + const [fetched, setFetched] = useState(false) + const t = useT() + + const fetchAll = async () => { + setLoading(true) + try { + const data = await api.getAllUsage() + setItems(data) + setFetched(true) + setOpen(true) + } catch (err) { + console.error("Batch usage failed:", err) + } finally { + setLoading(false) + } + } + + const runningItems = items.filter((i) => i.usage) + + const totals = runningItems.reduce( + (acc, i) => { + const q = i.usage!.quota_snapshots + acc.premiumUsed += q.premium_interactions.entitlement - q.premium_interactions.remaining + acc.premiumTotal += q.premium_interactions.entitlement + acc.chatUsed += q.chat.entitlement - q.chat.remaining + acc.chatTotal += q.chat.entitlement + acc.compUsed += q.completions.entitlement - q.completions.remaining + acc.compTotal += q.completions.entitlement + return acc + }, + { premiumUsed: 0, premiumTotal: 0, chatUsed: 0, chatTotal: 0, compUsed: 0, compTotal: 0 }, + ) + + const thStyle: React.CSSProperties = { + padding: "8px 10px", + textAlign: "left", + fontSize: 12, + fontWeight: 600, + color: "var(--text-muted)", + borderBottom: "1px solid var(--border)", + } + + return ( +
+
+
{t("batchUsage")}
+
+ + {fetched && ( + + )} +
+
+ + {open && fetched && ( +
+ {runningItems.length === 0 ? ( +
+ {t("noRunningAccounts")} +
+ ) : ( + + + + + + + + + + + + + {runningItems.map((item) => { + const q = item.usage!.quota_snapshots + return ( + + + + + + + + + ) + })} + + + + +
{t("colAccount")}{t("colPlan")}{t("colPremium")}{t("colChat")}{t("colCompletions")}{t("colResets")}
{item.name} + {item.usage!.copilot_plan} + + {item.usage!.quota_reset_date} +
{t("totalSummary")} + + + + +
+ )} +
+ )} +
+ ) +} + +function Dashboard() { + const [accounts, setAccounts] = useState>([]) + const [showForm, setShowForm] = useState(false) + const [loading, setLoading] = useState(true) + const [proxyPort, setProxyPort] = useState(4141) + const [pool, setPool] = useState({ + enabled: false, + strategy: "round-robin", + }) + const t = useT() + + 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 api.getConfig().then((cfg) => setProxyPort(cfg.proxyPort)) + void api.getPool().then(setPool).catch(() => {}) + void refresh() + const interval = setInterval(() => void refresh(), 5000) + return () => clearInterval(interval) + }, [refresh]) + + const handleAdd = async () => { + setShowForm(false) + await refresh() + } + + const handleLogout = () => { + setSessionToken("") + window.location.reload() + } + + return ( +
+
+
+

{t("consoleTitle")}

+

+ {t("dashboardSubtitle")} +

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

+ {t("loading")} +

+ : + } +
+ ) +} + +export function App() { + const [authState, setAuthState] = useState("loading") + const t = useT() + + useEffect(() => { + void (async () => { + try { + const config = await api.getConfig() + if (config.needsSetup) { + setAuthState("setup") + return + } + const token = getSessionToken() + if (token) { + try { + await api.checkAuth() + setAuthState("authed") + return + } catch { + setSessionToken("") + } + } + setAuthState("login") + } catch { + setAuthState("login") + } + })() + }, []) + + if (authState === "loading") { + return ( +
+ {t("loading")} +
+ ) + } + + if (authState === "setup") { + return setAuthState("authed")} /> + } + + if (authState === "login") { + return setAuthState("authed")} /> + } + + return +} diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 000000000..84e155327 --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,170 @@ +const BASE = "/api" + +let sessionToken = "" + +export function setSessionToken(token: string): void { + sessionToken = token + if (token) { + localStorage.setItem("sessionToken", token) + } else { + localStorage.removeItem("sessionToken") + } +} + +export function getSessionToken(): string { + if (!sessionToken) { + sessionToken = localStorage.getItem("sessionToken") ?? "" + } + return sessionToken +} + +export interface Account { + id: string + name: string + githubToken: string + accountType: string + apiKey: string + enabled: boolean + createdAt: string + priority: number + 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 PoolConfig { + enabled: boolean + strategy: "round-robin" | "priority" + apiKey: string +} + +export interface DeviceCodeResponse { + sessionId: string + userCode: string + verificationUri: string + expiresIn: number +} + +export interface AuthPollResponse { + status: "pending" | "completed" | "expired" | "error" + accessToken?: string + error?: string +} + +export interface ConfigResponse { + proxyPort: number + needsSetup: boolean +} + +export interface BatchUsageItem { + accountId: string + name: string + status: string + usage: UsageData | null +} + +interface ErrorBody { + error?: string +} + +async function request(path: string, options?: RequestInit): Promise { + const headers: Record = { + "Content-Type": "application/json", + ...(options?.headers as Record), + } + const token = getSessionToken() + if (token) { + headers["Authorization"] = `Bearer ${token}` + } + 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}`) + } + return res.json() as Promise +} + +export const api = { + getConfig: () => request("/config"), + + setup: (username: string, password: string) => + request<{ token: string }>("/auth/setup", { + method: "POST", + body: JSON.stringify({ username, password }), + }), + + login: (username: string, password: string) => + request<{ token: string }>("/auth/login", { + method: "POST", + body: JSON.stringify({ username, password }), + }), + + checkAuth: () => request<{ ok: boolean }>("/auth/check"), + + getAccounts: () => request>("/accounts"), + + 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`), + + getAllUsage: () => request>("/accounts/usage"), + + regenerateKey: (id: string) => + request(`/accounts/${id}/regenerate-key`, { method: "POST" }), + + startDeviceCode: () => + request("/auth/device-code", { method: "POST" }), + + pollAuth: (sessionId: string) => + request(`/auth/poll/${sessionId}`), + + completeAuth: (data: { + sessionId: string + name: string + accountType: string + }) => + request("/auth/complete", { + method: "POST", + body: JSON.stringify(data), + }), + + updateAccount: (id: string, data: Record) => + 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 new file mode 100644 index 000000000..ec6df6cee --- /dev/null +++ b/web/src/components/AccountCard.tsx @@ -0,0 +1,524 @@ +import { useState } from "react" + +import { api, type Account, type UsageData } from "../api" +import { useT } from "../i18n" + +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 + const t = useT() + return ( +
+
+ {t("plan")} {usage.copilot_plan} · {t("resets")} {usage.quota_reset_date} +
+ + + +
+ ) +} + +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 t = useT() + const safeKey = apiKey ?? "" + const masked = safeKey.length > 8 ? `${safeKey.slice(0, 8)}${"•".repeat(24)}` : safeKey + const isCopied = copied === safeKey + + return ( +
+ + {isCopied ? t("copied") : t("apiKey")} + + copy(safeKey)} + style={{ + cursor: "pointer", + flex: 1, + color: isCopied ? "var(--green)" : undefined, + }} + title="Click to copy" + > + {visible ? safeKey : masked} + + + +
+ ) +} + +interface Props { + account: Account + proxyPort: number + onRefresh: () => Promise +} + +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 t = useT() + + 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 + } + await handleAction(() => api.deleteAccount(account.id)) + setConfirmDelete(false) + } + + return ( +
+ {status === "running" && ( + + )} + {status === "running" ? + + : + } + +
+ ) +} + +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 t = useT() + + const endpoints = [ + { label: "OpenAI", path: "/v1/chat/completions" }, + { label: "Anthropic", path: "/v1/messages" }, + { label: "Models", path: "/v1/models" }, + { label: "Embeddings", path: "/v1/embeddings" }, + ] + + return ( +
+
+ {t("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 ? t("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) + const [editingPriority, setEditingPriority] = useState(false) + const [priorityValue, setPriorityValue] = useState( + String(account.priority ?? 0), + ) + const t = useT() + + const handleToggleUsage = async () => { + if (showUsage) { + setShowUsage(false) + return + } + setUsageLoading(true) + try { + const data = await api.getUsage(account.id) + setUsage(data) + setShowUsage(true) + } catch { + setUsage(null) + setShowUsage(true) + } finally { + setUsageLoading(false) + } + } + + const handleRegenerate = () => { + void (async () => { + try { + await api.regenerateKey(account.id) + await onRefresh() + } catch (err) { + console.error("Regenerate failed:", err) + } + })() + } + + 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 ( +
+
+
+
+ + {account.name} + + +
+
+ {account.user?.login ? `@${account.user.login} · ` : ""} + {account.accountType} +
+ {account.error && ( +
+ {t("error")} {account.error} +
+ )} +
+ + void handleToggleUsage()} + usageLoading={usageLoading} + showUsage={showUsage} + /> +
+ +
+ {t("priorityLabel")} + {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} + + )} + + {t("priorityHint")} + +
+ + + {status === "running" && ( + + )} + {showUsage + && (usage ? + + :
+ {t("usageUnavailable")} +
)} +
+ ) +} diff --git a/web/src/components/AddAccountForm.tsx b/web/src/components/AddAccountForm.tsx new file mode 100644 index 000000000..4a057d656 --- /dev/null +++ b/web/src/components/AddAccountForm.tsx @@ -0,0 +1,339 @@ +import { useCallback, useEffect, useRef, useState } from "react" + +import { api } from "../api" +import { useT } from "../i18n" + +interface Props { + onComplete: () => Promise + onCancel: () => void +} + +type Step = "config" | "authorize" | "done" + +function DeviceCodeDisplay({ + userCode, + verificationUri, +}: { + userCode: string + verificationUri: string +}) { + const t = useT() + return ( +
+

+ {t("enterCode")} +

+
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} +
+

+ {t("clickToCopy")} +

+ + {t("openGithub")} + +
+ ) +} + +function AuthorizeStep({ + userCode, + verificationUri, + authStatus, + error, + onCancel, +}: { + userCode: string + verificationUri: string + authStatus: string + error: string + onCancel: () => void +}) { + const t = useT() + return ( +
+

+ {t("githubAuth")} +

+ +

+ + {authStatus} +

+ {error && ( +
+ {error} +
+ )} +
+ +
+
+ ) +} + +function ConfigForm({ + onSubmit, + onCancel, + loading, + error, + name, + setName, + accountType, + setAccountType, +}: { + onSubmit: (e: React.SyntheticEvent) => void + onCancel: () => void + loading: boolean + error: string + name: string + setName: (v: string) => void + accountType: string + setAccountType: (v: string) => void +}) { + const t = useT() + return ( +
+

+ {t("addAccountTitle")} +

+
+
+ + setName(e.target.value)} + placeholder={t("accountNamePlaceholder")} + /> +
+
+ + +
+
+ {error && ( +
+ {error} +
+ )} +
+ + +
+
+ ) +} + +function useAuthFlow(onComplete: () => Promise) { + const [step, setStep] = useState("config") + const [userCode, setUserCode] = useState("") + const [verificationUri, setVerificationUri] = useState("") + const [authStatus, setAuthStatus] = useState("") + const [loading, setLoading] = useState(false) + const [error, setError] = useState("") + const pollRef = useRef | null>(null) + const t = useT() + + const cleanup = useCallback(() => { + if (pollRef.current) { + clearInterval(pollRef.current) + pollRef.current = null + } + }, []) + + useEffect(() => cleanup, [cleanup]) + + const startAuth = async (name: string, accountType: string) => { + setError("") + setLoading(true) + try { + const result = await api.startDeviceCode() + setUserCode(result.userCode) + setVerificationUri(result.verificationUri) + setStep("authorize") + setAuthStatus(t("waitingAuth")) + + pollRef.current = setInterval(() => { + void (async () => { + try { + const poll = await api.pollAuth(result.sessionId) + if (poll.status === "completed") { + cleanup() + setAuthStatus(t("authorized")) + await api.completeAuth({ + sessionId: result.sessionId, + name, + accountType, + }) + setStep("done") + await onComplete() + } else if (poll.status === "expired" || poll.status === "error") { + cleanup() + setAuthStatus("") + setError(poll.error ?? t("authFailed")) + } + } 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 auth = useAuthFlow(onComplete) + const t = useT() + + const handleSubmit = (e: React.SyntheticEvent) => { + e.preventDefault() + if (!name.trim()) { + auth.setError(t("accountNameRequired")) + return + } + void auth.startAuth(name.trim(), accountType) + } + + if (auth.step === "done") return null + + return ( +
+ {auth.step === "config" && ( + + )} + {auth.step === "authorize" && ( + { + auth.cleanup() + onCancel() + }} + /> + )} + +
+ ) +} 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/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..30a6164b5 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,17 @@ +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( + + + + + , + ) +} 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..5beb39cce --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,19 @@ +import react from "@vitejs/plugin-react" +import { defineConfig } from "vite" + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + "/api": "http://localhost:3000", + }, + }, + build: { + outDir: "dist", + rollupOptions: { + output: { + entryFileNames: "assets/[name]-[hash]-v2.js", + }, + }, + }, +})