Spaces:
Paused
Paused
icebear0828 Claude Opus 4.6 commited on
Commit ·
b7d4394
1
Parent(s): b1107bc
feat: migrate dashboard to Preact + Vite
Browse files- New web/ directory with Preact components, Tailwind CSS v3, Vite build
- i18n via Context + useT() hook (zero DOM scanning on language switch)
- Theme via ThemeProvider + useTheme() hook
- Ghost text technique for stable header layout across EN/ZH
- Backend: serve Vite build output from public/
- Docker: added frontend build step
- Build: npm run build now builds frontend first, then backend
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- .gitignore +2 -0
- Dockerfile +12 -4
- package.json +3 -1
- src/index.ts +3 -3
- src/routes/web.ts +6 -2
- web/index.html +19 -0
- web/package-lock.json +0 -0
- web/package.json +21 -0
- web/postcss.config.js +6 -0
- web/src/App.tsx +59 -0
- web/src/components/AccountCard.tsx +151 -0
- web/src/components/AccountList.tsx +39 -0
- web/src/components/AddAccount.tsx +60 -0
- web/src/components/ApiConfig.tsx +86 -0
- web/src/components/CodeExamples.tsx +209 -0
- web/src/components/CopyButton.tsx +80 -0
- web/src/components/Footer.tsx +13 -0
- web/src/components/Header.tsx +83 -0
- web/src/hooks/use-accounts.ts +156 -0
- web/src/hooks/use-status.ts +41 -0
- web/src/i18n/context.tsx +53 -0
- web/src/i18n/translations.ts +114 -0
- web/src/index.css +19 -0
- web/src/main.tsx +5 -0
- web/src/theme/context.tsx +46 -0
- web/src/utils/clipboard.ts +23 -0
- web/src/utils/format.ts +39 -0
- web/tailwind.config.ts +46 -0
- web/tsconfig.json +17 -0
- web/vite.config.ts +19 -0
.gitignore
CHANGED
|
@@ -1,6 +1,8 @@
|
|
| 1 |
node_modules/
|
| 2 |
dist/
|
| 3 |
data/
|
|
|
|
|
|
|
| 4 |
docs/
|
| 5 |
bin/
|
| 6 |
.env
|
|
|
|
| 1 |
node_modules/
|
| 2 |
dist/
|
| 3 |
data/
|
| 4 |
+
public/assets/
|
| 5 |
+
public/index.html
|
| 6 |
docs/
|
| 7 |
bin/
|
| 8 |
.env
|
Dockerfile
CHANGED
|
@@ -7,13 +7,21 @@ RUN apt-get update && \
|
|
| 7 |
|
| 8 |
WORKDIR /app
|
| 9 |
|
| 10 |
-
# Install dependencies (postinstall downloads curl-impersonate for Linux)
|
| 11 |
COPY package*.json ./
|
| 12 |
-
RUN npm ci
|
| 13 |
|
| 14 |
-
# Copy source
|
| 15 |
COPY . .
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
# Persistent data mount point
|
| 19 |
VOLUME /app/data
|
|
|
|
| 7 |
|
| 8 |
WORKDIR /app
|
| 9 |
|
| 10 |
+
# Install backend dependencies (postinstall downloads curl-impersonate for Linux)
|
| 11 |
COPY package*.json ./
|
| 12 |
+
RUN npm ci
|
| 13 |
|
| 14 |
+
# Copy source
|
| 15 |
COPY . .
|
| 16 |
+
|
| 17 |
+
# Build frontend (Vite → public/)
|
| 18 |
+
RUN cd web && npm ci && npm run build
|
| 19 |
+
|
| 20 |
+
# Build backend (TypeScript → dist/)
|
| 21 |
+
RUN npx tsc
|
| 22 |
+
|
| 23 |
+
# Prune dev dependencies
|
| 24 |
+
RUN npm prune --omit=dev
|
| 25 |
|
| 26 |
# Persistent data mount point
|
| 27 |
VOLUME /app/data
|
package.json
CHANGED
|
@@ -5,7 +5,9 @@
|
|
| 5 |
"type": "module",
|
| 6 |
"scripts": {
|
| 7 |
"dev": "tsx watch src/index.ts",
|
| 8 |
-
"
|
|
|
|
|
|
|
| 9 |
"start": "node dist/index.js",
|
| 10 |
"check-update": "tsx scripts/check-update.ts",
|
| 11 |
"check-update:watch": "tsx scripts/check-update.ts --watch",
|
|
|
|
| 5 |
"type": "module",
|
| 6 |
"scripts": {
|
| 7 |
"dev": "tsx watch src/index.ts",
|
| 8 |
+
"dev:web": "cd web && npm run dev",
|
| 9 |
+
"build:web": "cd web && npm install && npm run build",
|
| 10 |
+
"build": "npm run build:web && tsc",
|
| 11 |
"start": "node dist/index.js",
|
| 12 |
"check-update": "tsx scripts/check-update.ts",
|
| 13 |
"check-update:watch": "tsx scripts/check-update.ts --watch",
|
src/index.ts
CHANGED
|
@@ -68,14 +68,15 @@ async function main() {
|
|
| 68 |
const host = config.server.host;
|
| 69 |
|
| 70 |
const poolSummary = accountPool.getPoolSummary();
|
|
|
|
| 71 |
|
| 72 |
console.log(`
|
| 73 |
╔══════════════════════════════════════════╗
|
| 74 |
║ Codex Proxy Server ║
|
| 75 |
╠══════════════════════════════════════════╣
|
| 76 |
║ Status: ${accountPool.isAuthenticated() ? "Authenticated ✓" : "Not logged in "} ║
|
| 77 |
-
║ Listen: http://${
|
| 78 |
-
║ API: http://${
|
| 79 |
╚══════════════════════════════════════════╝
|
| 80 |
`);
|
| 81 |
|
|
@@ -86,7 +87,6 @@ async function main() {
|
|
| 86 |
console.log(` Key: ${accountPool.getProxyApiKey()}`);
|
| 87 |
console.log(` Pool: ${poolSummary.active} active / ${poolSummary.total} total accounts`);
|
| 88 |
} else {
|
| 89 |
-
const displayHost = host === "0.0.0.0" ? "localhost" : host;
|
| 90 |
console.log(` Open http://${displayHost}:${port} to login`);
|
| 91 |
}
|
| 92 |
console.log();
|
|
|
|
| 68 |
const host = config.server.host;
|
| 69 |
|
| 70 |
const poolSummary = accountPool.getPoolSummary();
|
| 71 |
+
const displayHost = (host === "0.0.0.0" || host === "::") ? "localhost" : host;
|
| 72 |
|
| 73 |
console.log(`
|
| 74 |
╔══════════════════════════════════════════╗
|
| 75 |
║ Codex Proxy Server ║
|
| 76 |
╠══════════════════════════════════════════╣
|
| 77 |
║ Status: ${accountPool.isAuthenticated() ? "Authenticated ✓" : "Not logged in "} ║
|
| 78 |
+
║ Listen: http://${displayHost}:${port} ║
|
| 79 |
+
║ API: http://${displayHost}:${port}/v1 ║
|
| 80 |
╚══════════════════════════════════════════╝
|
| 81 |
`);
|
| 82 |
|
|
|
|
| 87 |
console.log(` Key: ${accountPool.getProxyApiKey()}`);
|
| 88 |
console.log(` Pool: ${poolSummary.active} active / ${poolSummary.total} total accounts`);
|
| 89 |
} else {
|
|
|
|
| 90 |
console.log(` Open http://${displayHost}:${port} to login`);
|
| 91 |
}
|
| 92 |
console.log();
|
src/routes/web.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import { Hono } from "hono";
|
|
|
|
| 2 |
import { readFileSync, existsSync } from "fs";
|
| 3 |
import { resolve } from "path";
|
| 4 |
import type { AccountPool } from "../auth/account-pool.js";
|
|
@@ -10,14 +11,17 @@ export function createWebRoutes(accountPool: AccountPool): Hono {
|
|
| 10 |
|
| 11 |
const publicDir = resolve(process.cwd(), "public");
|
| 12 |
|
|
|
|
|
|
|
|
|
|
| 13 |
app.get("/", (c) => {
|
| 14 |
try {
|
| 15 |
-
const html = readFileSync(resolve(publicDir, "
|
| 16 |
return c.html(html);
|
| 17 |
} catch (err) {
|
| 18 |
const msg = err instanceof Error ? err.message : String(err);
|
| 19 |
console.error(`[Web] Failed to read HTML file: ${msg}`);
|
| 20 |
-
return c.html("<h1>Codex Proxy</h1><p>UI files not found. The API is still available at /v1/chat/completions</p>");
|
| 21 |
}
|
| 22 |
});
|
| 23 |
|
|
|
|
| 1 |
import { Hono } from "hono";
|
| 2 |
+
import { serveStatic } from "@hono/node-server/serve-static";
|
| 3 |
import { readFileSync, existsSync } from "fs";
|
| 4 |
import { resolve } from "path";
|
| 5 |
import type { AccountPool } from "../auth/account-pool.js";
|
|
|
|
| 11 |
|
| 12 |
const publicDir = resolve(process.cwd(), "public");
|
| 13 |
|
| 14 |
+
// Serve Vite build assets
|
| 15 |
+
app.use("/assets/*", serveStatic({ root: "./public" }));
|
| 16 |
+
|
| 17 |
app.get("/", (c) => {
|
| 18 |
try {
|
| 19 |
+
const html = readFileSync(resolve(publicDir, "index.html"), "utf-8");
|
| 20 |
return c.html(html);
|
| 21 |
} catch (err) {
|
| 22 |
const msg = err instanceof Error ? err.message : String(err);
|
| 23 |
console.error(`[Web] Failed to read HTML file: ${msg}`);
|
| 24 |
+
return c.html("<h1>Codex Proxy</h1><p>UI files not found. Run 'npm run build:web' first. The API is still available at /v1/chat/completions</p>");
|
| 25 |
}
|
| 26 |
});
|
| 27 |
|
web/index.html
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Codex Proxy Developer Dashboard</title>
|
| 7 |
+
<script>
|
| 8 |
+
try {
|
| 9 |
+
const t = localStorage.getItem('codex-proxy-theme');
|
| 10 |
+
if (t === 'dark' || (!t && window.matchMedia('(prefers-color-scheme: dark)').matches))
|
| 11 |
+
document.documentElement.classList.add('dark');
|
| 12 |
+
} catch {}
|
| 13 |
+
</script>
|
| 14 |
+
</head>
|
| 15 |
+
<body class="bg-bg-light dark:bg-bg-dark font-display text-slate-900 dark:text-text-main antialiased min-h-screen flex flex-col transition-colors">
|
| 16 |
+
<div id="app"></div>
|
| 17 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 18 |
+
</body>
|
| 19 |
+
</html>
|
web/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
web/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "codex-proxy-web",
|
| 3 |
+
"private": true,
|
| 4 |
+
"type": "module",
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "vite",
|
| 7 |
+
"build": "vite build",
|
| 8 |
+
"preview": "vite preview"
|
| 9 |
+
},
|
| 10 |
+
"dependencies": {
|
| 11 |
+
"preact": "^10.25.0"
|
| 12 |
+
},
|
| 13 |
+
"devDependencies": {
|
| 14 |
+
"@preact/preset-vite": "^2.9.0",
|
| 15 |
+
"autoprefixer": "^10.4.20",
|
| 16 |
+
"postcss": "^8.4.49",
|
| 17 |
+
"tailwindcss": "^3.4.17",
|
| 18 |
+
"typescript": "^5.5.0",
|
| 19 |
+
"vite": "^6.0.0"
|
| 20 |
+
}
|
| 21 |
+
}
|
web/postcss.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
};
|
web/src/App.tsx
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { I18nProvider } from "./i18n/context";
|
| 2 |
+
import { ThemeProvider } from "./theme/context";
|
| 3 |
+
import { Header } from "./components/Header";
|
| 4 |
+
import { AccountList } from "./components/AccountList";
|
| 5 |
+
import { AddAccount } from "./components/AddAccount";
|
| 6 |
+
import { ApiConfig } from "./components/ApiConfig";
|
| 7 |
+
import { CodeExamples } from "./components/CodeExamples";
|
| 8 |
+
import { Footer } from "./components/Footer";
|
| 9 |
+
import { useAccounts } from "./hooks/use-accounts";
|
| 10 |
+
import { useStatus } from "./hooks/use-status";
|
| 11 |
+
|
| 12 |
+
function Dashboard() {
|
| 13 |
+
const accounts = useAccounts();
|
| 14 |
+
const status = useStatus();
|
| 15 |
+
|
| 16 |
+
return (
|
| 17 |
+
<>
|
| 18 |
+
<Header onAddAccount={accounts.startAdd} />
|
| 19 |
+
<main class="flex-grow px-4 md:px-8 lg:px-40 py-8 flex justify-center">
|
| 20 |
+
<div class="flex flex-col w-full max-w-[960px] gap-6">
|
| 21 |
+
<AddAccount
|
| 22 |
+
visible={accounts.addVisible}
|
| 23 |
+
onSubmitRelay={accounts.submitRelay}
|
| 24 |
+
addInfo={accounts.addInfo}
|
| 25 |
+
addError={accounts.addError}
|
| 26 |
+
/>
|
| 27 |
+
<AccountList
|
| 28 |
+
accounts={accounts.list}
|
| 29 |
+
loading={accounts.loading}
|
| 30 |
+
onDelete={accounts.deleteAccount}
|
| 31 |
+
/>
|
| 32 |
+
<ApiConfig
|
| 33 |
+
baseUrl={status.baseUrl}
|
| 34 |
+
apiKey={status.apiKey}
|
| 35 |
+
models={status.models}
|
| 36 |
+
selectedModel={status.selectedModel}
|
| 37 |
+
onModelChange={status.setSelectedModel}
|
| 38 |
+
/>
|
| 39 |
+
<CodeExamples
|
| 40 |
+
baseUrl={status.baseUrl}
|
| 41 |
+
apiKey={status.apiKey}
|
| 42 |
+
model={status.selectedModel}
|
| 43 |
+
/>
|
| 44 |
+
</div>
|
| 45 |
+
</main>
|
| 46 |
+
<Footer />
|
| 47 |
+
</>
|
| 48 |
+
);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
export function App() {
|
| 52 |
+
return (
|
| 53 |
+
<I18nProvider>
|
| 54 |
+
<ThemeProvider>
|
| 55 |
+
<Dashboard />
|
| 56 |
+
</ThemeProvider>
|
| 57 |
+
</I18nProvider>
|
| 58 |
+
);
|
| 59 |
+
}
|
web/src/components/AccountCard.tsx
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback } from "preact/hooks";
|
| 2 |
+
import { useT } from "../i18n/context";
|
| 3 |
+
import { useI18n } from "../i18n/context";
|
| 4 |
+
import { formatNumber, formatResetTime } from "../utils/format";
|
| 5 |
+
import type { Account } from "../hooks/use-accounts";
|
| 6 |
+
|
| 7 |
+
const avatarColors = [
|
| 8 |
+
["bg-purple-100 dark:bg-[#2a1a3f]", "text-purple-600 dark:text-purple-400"],
|
| 9 |
+
["bg-amber-100 dark:bg-[#3d2c16]", "text-amber-600 dark:text-amber-500"],
|
| 10 |
+
["bg-blue-100 dark:bg-[#1a2a3f]", "text-blue-600 dark:text-blue-400"],
|
| 11 |
+
["bg-emerald-100 dark:bg-[#112a1f]", "text-emerald-600 dark:text-emerald-400"],
|
| 12 |
+
["bg-red-100 dark:bg-[#3f1a1a]", "text-red-600 dark:text-red-400"],
|
| 13 |
+
];
|
| 14 |
+
|
| 15 |
+
const statusStyles: Record<string, [string, string]> = {
|
| 16 |
+
active: [
|
| 17 |
+
"bg-green-100 text-green-700 border-green-200 dark:bg-[#11281d] dark:text-primary dark:border-[#1a442e]",
|
| 18 |
+
"active",
|
| 19 |
+
],
|
| 20 |
+
expired: [
|
| 21 |
+
"bg-red-100 text-red-600 border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800/30",
|
| 22 |
+
"expired",
|
| 23 |
+
],
|
| 24 |
+
rate_limited: [
|
| 25 |
+
"bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-900/20 dark:text-amber-400 dark:border-amber-800/30",
|
| 26 |
+
"rateLimited",
|
| 27 |
+
],
|
| 28 |
+
refreshing: [
|
| 29 |
+
"bg-blue-100 text-blue-600 border-blue-200 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-800/30",
|
| 30 |
+
"refreshing",
|
| 31 |
+
],
|
| 32 |
+
disabled: [
|
| 33 |
+
"bg-slate-100 text-slate-500 border-slate-200 dark:bg-slate-800/30 dark:text-slate-400 dark:border-slate-700/30",
|
| 34 |
+
"disabled",
|
| 35 |
+
],
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
interface AccountCardProps {
|
| 39 |
+
account: Account;
|
| 40 |
+
index: number;
|
| 41 |
+
onDelete: (id: string) => Promise<string | null>;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export function AccountCard({ account, index, onDelete }: AccountCardProps) {
|
| 45 |
+
const t = useT();
|
| 46 |
+
const { lang } = useI18n();
|
| 47 |
+
const email = account.email || "Unknown";
|
| 48 |
+
const initial = email.charAt(0).toUpperCase();
|
| 49 |
+
const [bgColor, textColor] = avatarColors[index % avatarColors.length];
|
| 50 |
+
const usage = account.usage || {};
|
| 51 |
+
const requests = usage.request_count ?? 0;
|
| 52 |
+
const tokens = (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0);
|
| 53 |
+
const plan = account.planType || t("freeTier");
|
| 54 |
+
|
| 55 |
+
const [statusCls, statusKey] = statusStyles[account.status] || statusStyles.disabled;
|
| 56 |
+
|
| 57 |
+
const handleDelete = useCallback(async () => {
|
| 58 |
+
if (!confirm(t("removeConfirm"))) return;
|
| 59 |
+
const err = await onDelete(account.id);
|
| 60 |
+
if (err) alert(err);
|
| 61 |
+
}, [account.id, onDelete, t]);
|
| 62 |
+
|
| 63 |
+
// Quota
|
| 64 |
+
const q = account.quota;
|
| 65 |
+
const rl = q?.rate_limit;
|
| 66 |
+
const pct = rl?.used_percent != null ? Math.round(rl.used_percent) : null;
|
| 67 |
+
const barColor =
|
| 68 |
+
pct == null ? "bg-primary" : pct >= 90 ? "bg-red-500" : pct >= 60 ? "bg-amber-500" : "bg-primary";
|
| 69 |
+
const pctColor =
|
| 70 |
+
pct == null
|
| 71 |
+
? "text-primary"
|
| 72 |
+
: pct >= 90
|
| 73 |
+
? "text-red-500"
|
| 74 |
+
: pct >= 60
|
| 75 |
+
? "text-amber-600 dark:text-amber-500"
|
| 76 |
+
: "text-primary";
|
| 77 |
+
const resetAt = rl?.reset_at ? formatResetTime(rl.reset_at, lang === "zh") : null;
|
| 78 |
+
|
| 79 |
+
return (
|
| 80 |
+
<div class="group bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-4 shadow-sm hover:shadow-md transition-all hover:border-primary/30 dark:hover:border-primary/50 relative">
|
| 81 |
+
{/* Delete button */}
|
| 82 |
+
<button
|
| 83 |
+
onClick={handleDelete}
|
| 84 |
+
class="absolute top-3 right-3 opacity-0 group-hover:opacity-100 p-1.5 text-slate-300 dark:text-text-dim hover:text-red-500 transition-all rounded-md hover:bg-red-50 dark:hover:bg-red-900/20"
|
| 85 |
+
title={t("deleteAccount")}
|
| 86 |
+
>
|
| 87 |
+
<svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
| 88 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
| 89 |
+
</svg>
|
| 90 |
+
</button>
|
| 91 |
+
|
| 92 |
+
{/* Header */}
|
| 93 |
+
<div class="flex justify-between items-start mb-4">
|
| 94 |
+
<div class="flex items-center gap-3">
|
| 95 |
+
<div class={`size-10 rounded-full ${bgColor} ${textColor} flex items-center justify-center font-bold text-lg`}>
|
| 96 |
+
{initial}
|
| 97 |
+
</div>
|
| 98 |
+
<div>
|
| 99 |
+
<h3 class="text-[0.82rem] font-semibold leading-tight">{email}</h3>
|
| 100 |
+
<p class="text-xs text-slate-500 dark:text-text-dim">{plan}</p>
|
| 101 |
+
</div>
|
| 102 |
+
</div>
|
| 103 |
+
<span class={`px-2.5 py-1 rounded-full ${statusCls} text-xs font-medium border`}>
|
| 104 |
+
{t(statusKey as any)}
|
| 105 |
+
</span>
|
| 106 |
+
</div>
|
| 107 |
+
|
| 108 |
+
{/* Stats */}
|
| 109 |
+
<div class="space-y-2">
|
| 110 |
+
<div class="flex justify-between text-[0.78rem]">
|
| 111 |
+
<span class="text-slate-500 dark:text-text-dim">{t("totalRequests")}</span>
|
| 112 |
+
<span class="font-medium">{formatNumber(requests)}</span>
|
| 113 |
+
</div>
|
| 114 |
+
<div class="flex justify-between text-[0.78rem]">
|
| 115 |
+
<span class="text-slate-500 dark:text-text-dim">{t("tokensUsed")}</span>
|
| 116 |
+
<span class="font-medium">{formatNumber(tokens)}</span>
|
| 117 |
+
</div>
|
| 118 |
+
</div>
|
| 119 |
+
|
| 120 |
+
{/* Quota bar */}
|
| 121 |
+
{rl && (
|
| 122 |
+
<div class="pt-3 mt-3 border-t border-slate-100 dark:border-border-dark">
|
| 123 |
+
<div class="flex justify-between text-[0.78rem] mb-1.5">
|
| 124 |
+
<span class="text-slate-500 dark:text-text-dim">{t("rateLimit")}</span>
|
| 125 |
+
{rl.limit_reached ? (
|
| 126 |
+
<span class="px-2 py-0.5 rounded-full bg-red-100 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-xs font-medium">
|
| 127 |
+
{t("limitReached")}
|
| 128 |
+
</span>
|
| 129 |
+
) : pct != null ? (
|
| 130 |
+
<span class={`font-medium ${pctColor}`}>
|
| 131 |
+
{pct}% {t("used")}
|
| 132 |
+
</span>
|
| 133 |
+
) : (
|
| 134 |
+
<span class="font-medium text-primary">{t("ok")}</span>
|
| 135 |
+
)}
|
| 136 |
+
</div>
|
| 137 |
+
{pct != null && (
|
| 138 |
+
<div class="w-full bg-slate-100 dark:bg-border-dark rounded-full h-2 overflow-hidden">
|
| 139 |
+
<div class={`${barColor} h-2 rounded-full transition-all`} style={{ width: `${pct}%` }} />
|
| 140 |
+
</div>
|
| 141 |
+
)}
|
| 142 |
+
{resetAt && (
|
| 143 |
+
<p class="text-xs text-slate-400 dark:text-text-dim mt-1">
|
| 144 |
+
{t("resetsAt")} {resetAt}
|
| 145 |
+
</p>
|
| 146 |
+
)}
|
| 147 |
+
</div>
|
| 148 |
+
)}
|
| 149 |
+
</div>
|
| 150 |
+
);
|
| 151 |
+
}
|
web/src/components/AccountList.tsx
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useT } from "../i18n/context";
|
| 2 |
+
import { AccountCard } from "./AccountCard";
|
| 3 |
+
import type { Account } from "../hooks/use-accounts";
|
| 4 |
+
|
| 5 |
+
interface AccountListProps {
|
| 6 |
+
accounts: Account[];
|
| 7 |
+
loading: boolean;
|
| 8 |
+
onDelete: (id: string) => Promise<string | null>;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export function AccountList({ accounts, loading, onDelete }: AccountListProps) {
|
| 12 |
+
const t = useT();
|
| 13 |
+
|
| 14 |
+
return (
|
| 15 |
+
<section class="flex flex-col gap-4">
|
| 16 |
+
<div class="flex items-end justify-between">
|
| 17 |
+
<div class="flex flex-col gap-1">
|
| 18 |
+
<h2 class="text-[0.95rem] font-bold tracking-tight">{t("connectedAccounts")}</h2>
|
| 19 |
+
<p class="text-slate-500 dark:text-text-dim text-[0.8rem]">{t("connectedAccountsDesc")}</p>
|
| 20 |
+
</div>
|
| 21 |
+
</div>
|
| 22 |
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 23 |
+
{loading ? (
|
| 24 |
+
<div class="md:col-span-2 text-center py-8 text-slate-400 dark:text-text-dim text-sm bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl transition-colors">
|
| 25 |
+
{t("loadingAccounts")}
|
| 26 |
+
</div>
|
| 27 |
+
) : accounts.length === 0 ? (
|
| 28 |
+
<div class="md:col-span-2 text-center py-8 text-slate-400 dark:text-text-dim text-sm bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl transition-colors">
|
| 29 |
+
{t("noAccounts")}
|
| 30 |
+
</div>
|
| 31 |
+
) : (
|
| 32 |
+
accounts.map((acct, i) => (
|
| 33 |
+
<AccountCard key={acct.id} account={acct} index={i} onDelete={onDelete} />
|
| 34 |
+
))
|
| 35 |
+
)}
|
| 36 |
+
</div>
|
| 37 |
+
</section>
|
| 38 |
+
);
|
| 39 |
+
}
|
web/src/components/AddAccount.tsx
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useCallback } from "preact/hooks";
|
| 2 |
+
import { useT } from "../i18n/context";
|
| 3 |
+
|
| 4 |
+
interface AddAccountProps {
|
| 5 |
+
visible: boolean;
|
| 6 |
+
onSubmitRelay: (callbackUrl: string) => Promise<void>;
|
| 7 |
+
addInfo: string;
|
| 8 |
+
addError: string;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export function AddAccount({ visible, onSubmitRelay, addInfo, addError }: AddAccountProps) {
|
| 12 |
+
const t = useT();
|
| 13 |
+
const [input, setInput] = useState("");
|
| 14 |
+
const [submitting, setSubmitting] = useState(false);
|
| 15 |
+
|
| 16 |
+
const handleSubmit = useCallback(async () => {
|
| 17 |
+
setSubmitting(true);
|
| 18 |
+
await onSubmitRelay(input);
|
| 19 |
+
setSubmitting(false);
|
| 20 |
+
setInput("");
|
| 21 |
+
}, [input, onSubmitRelay]);
|
| 22 |
+
|
| 23 |
+
if (!visible && !addInfo && !addError) return null;
|
| 24 |
+
|
| 25 |
+
return (
|
| 26 |
+
<>
|
| 27 |
+
{addInfo && (
|
| 28 |
+
<p class="text-sm text-primary">{t(addInfo as any)}</p>
|
| 29 |
+
)}
|
| 30 |
+
{addError && (
|
| 31 |
+
<p class="text-sm text-red-500">{t(addError as any)}</p>
|
| 32 |
+
)}
|
| 33 |
+
{visible && (
|
| 34 |
+
<section class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-5 shadow-sm transition-colors">
|
| 35 |
+
<ol class="text-sm text-slate-500 dark:text-text-dim mb-4 space-y-1.5 list-decimal list-inside">
|
| 36 |
+
<li dangerouslySetInnerHTML={{ __html: t("addStep1") }} />
|
| 37 |
+
<li dangerouslySetInnerHTML={{ __html: t("addStep2") }} />
|
| 38 |
+
<li dangerouslySetInnerHTML={{ __html: t("addStep3") }} />
|
| 39 |
+
</ol>
|
| 40 |
+
<div class="flex gap-3">
|
| 41 |
+
<input
|
| 42 |
+
type="text"
|
| 43 |
+
value={input}
|
| 44 |
+
onInput={(e) => setInput((e.target as HTMLInputElement).value)}
|
| 45 |
+
placeholder={t("pasteCallback")}
|
| 46 |
+
class="flex-1 px-3 py-2.5 bg-slate-50 dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-sm font-mono text-slate-600 dark:text-text-main focus:ring-2 focus:ring-primary/50 focus:border-primary outline-none transition-colors"
|
| 47 |
+
/>
|
| 48 |
+
<button
|
| 49 |
+
onClick={handleSubmit}
|
| 50 |
+
disabled={submitting}
|
| 51 |
+
class="px-4 py-2.5 bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-lg text-sm font-medium text-slate-700 dark:text-text-main hover:bg-slate-50 dark:hover:bg-border-dark transition-colors"
|
| 52 |
+
>
|
| 53 |
+
{submitting ? t("submitting") : t("submit")}
|
| 54 |
+
</button>
|
| 55 |
+
</div>
|
| 56 |
+
</section>
|
| 57 |
+
)}
|
| 58 |
+
</>
|
| 59 |
+
);
|
| 60 |
+
}
|
web/src/components/ApiConfig.tsx
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useT } from "../i18n/context";
|
| 2 |
+
import { CopyButton } from "./CopyButton";
|
| 3 |
+
import { useCallback } from "preact/hooks";
|
| 4 |
+
|
| 5 |
+
interface ApiConfigProps {
|
| 6 |
+
baseUrl: string;
|
| 7 |
+
apiKey: string;
|
| 8 |
+
models: string[];
|
| 9 |
+
selectedModel: string;
|
| 10 |
+
onModelChange: (model: string) => void;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export function ApiConfig({ baseUrl, apiKey, models, selectedModel, onModelChange }: ApiConfigProps) {
|
| 14 |
+
const t = useT();
|
| 15 |
+
|
| 16 |
+
const getBaseUrl = useCallback(() => baseUrl, [baseUrl]);
|
| 17 |
+
const getApiKey = useCallback(() => apiKey, [apiKey]);
|
| 18 |
+
|
| 19 |
+
return (
|
| 20 |
+
<section class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-5 shadow-sm transition-colors">
|
| 21 |
+
<div class="flex items-center justify-between mb-6 border-b border-slate-100 dark:border-border-dark pb-4">
|
| 22 |
+
<div class="flex items-center gap-2">
|
| 23 |
+
<svg class="size-5 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
| 24 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z" />
|
| 25 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
| 26 |
+
</svg>
|
| 27 |
+
<h2 class="text-[0.95rem] font-bold">{t("apiConfig")}</h2>
|
| 28 |
+
</div>
|
| 29 |
+
</div>
|
| 30 |
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 31 |
+
{/* Base URL */}
|
| 32 |
+
<div class="space-y-1.5">
|
| 33 |
+
<label class="text-xs font-semibold text-slate-700 dark:text-text-main">{t("baseProxyUrl")}</label>
|
| 34 |
+
<div class="relative flex items-center">
|
| 35 |
+
<input
|
| 36 |
+
class="w-full pl-3 pr-10 py-2.5 bg-slate-100 dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-[0.78rem] font-mono text-slate-500 dark:text-text-dim outline-none cursor-default select-all"
|
| 37 |
+
type="text"
|
| 38 |
+
value={baseUrl}
|
| 39 |
+
readOnly
|
| 40 |
+
/>
|
| 41 |
+
<CopyButton getText={getBaseUrl} class="absolute right-2" titleKey="copyUrl" />
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
+
{/* Default Model */}
|
| 45 |
+
<div class="space-y-1.5">
|
| 46 |
+
<label class="text-xs font-semibold text-slate-700 dark:text-text-main">{t("defaultModel")}</label>
|
| 47 |
+
<div class="relative">
|
| 48 |
+
<select
|
| 49 |
+
class="w-full appearance-none pl-3 pr-10 py-2.5 bg-white dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-[0.78rem] text-slate-700 dark:text-text-main font-medium focus:ring-1 focus:ring-primary focus:border-primary outline-none cursor-pointer transition-colors"
|
| 50 |
+
value={selectedModel}
|
| 51 |
+
onChange={(e) => onModelChange((e.target as HTMLSelectElement).value)}
|
| 52 |
+
>
|
| 53 |
+
{models.map((m) => (
|
| 54 |
+
<option key={m} value={m}>{m}</option>
|
| 55 |
+
))}
|
| 56 |
+
</select>
|
| 57 |
+
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-slate-500 dark:text-text-dim">
|
| 58 |
+
<svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 59 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
| 60 |
+
</svg>
|
| 61 |
+
</div>
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
{/* API Key */}
|
| 65 |
+
<div class="space-y-1.5 md:col-span-2">
|
| 66 |
+
<label class="text-xs font-semibold text-slate-700 dark:text-text-main">{t("yourApiKey")}</label>
|
| 67 |
+
<div class="relative flex items-center">
|
| 68 |
+
<div class="absolute left-3 text-slate-400 dark:text-text-dim">
|
| 69 |
+
<svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
| 70 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
|
| 71 |
+
</svg>
|
| 72 |
+
</div>
|
| 73 |
+
<input
|
| 74 |
+
class="w-full pl-10 pr-10 py-2.5 bg-slate-100 dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-[0.78rem] font-mono text-slate-500 dark:text-text-dim outline-none cursor-default select-all tracking-wider"
|
| 75 |
+
type="text"
|
| 76 |
+
value={apiKey}
|
| 77 |
+
readOnly
|
| 78 |
+
/>
|
| 79 |
+
<CopyButton getText={getApiKey} class="absolute right-2" titleKey="copyApiKey" />
|
| 80 |
+
</div>
|
| 81 |
+
<p class="text-xs text-slate-400 dark:text-text-dim mt-1">{t("apiKeyHint")}</p>
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
</section>
|
| 85 |
+
);
|
| 86 |
+
}
|
web/src/components/CodeExamples.tsx
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useMemo, useCallback } from "preact/hooks";
|
| 2 |
+
import { useT } from "../i18n/context";
|
| 3 |
+
import { CopyButton } from "./CopyButton";
|
| 4 |
+
|
| 5 |
+
type Protocol = "openai" | "anthropic" | "gemini";
|
| 6 |
+
type CodeLang = "python" | "node" | "curl";
|
| 7 |
+
|
| 8 |
+
const protocols: { id: Protocol; label: string }[] = [
|
| 9 |
+
{ id: "openai", label: "OpenAI" },
|
| 10 |
+
{ id: "anthropic", label: "Anthropic" },
|
| 11 |
+
{ id: "gemini", label: "Gemini" },
|
| 12 |
+
];
|
| 13 |
+
|
| 14 |
+
const langs: { id: CodeLang; label: string }[] = [
|
| 15 |
+
{ id: "python", label: "Python" },
|
| 16 |
+
{ id: "node", label: "Node.js" },
|
| 17 |
+
{ id: "curl", label: "cURL" },
|
| 18 |
+
];
|
| 19 |
+
|
| 20 |
+
function buildExamples(
|
| 21 |
+
baseUrl: string,
|
| 22 |
+
apiKey: string,
|
| 23 |
+
model: string,
|
| 24 |
+
origin: string
|
| 25 |
+
): Record<string, string> {
|
| 26 |
+
return {
|
| 27 |
+
"openai-python": `from openai import OpenAI
|
| 28 |
+
|
| 29 |
+
client = OpenAI(
|
| 30 |
+
base_url="${baseUrl}",
|
| 31 |
+
api_key="${apiKey}",
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
response = client.chat.completions.create(
|
| 35 |
+
model="${model}",
|
| 36 |
+
messages=[{"role": "user", "content": "Hello"}],
|
| 37 |
+
)
|
| 38 |
+
print(response.choices[0].message.content)`,
|
| 39 |
+
|
| 40 |
+
"openai-curl": `curl ${baseUrl}/chat/completions \\
|
| 41 |
+
-H "Content-Type: application/json" \\
|
| 42 |
+
-H "Authorization: Bearer ${apiKey}" \\
|
| 43 |
+
-d '{
|
| 44 |
+
"model": "${model}",
|
| 45 |
+
"messages": [{"role": "user", "content": "Hello"}]
|
| 46 |
+
}'`,
|
| 47 |
+
|
| 48 |
+
"openai-node": `import OpenAI from "openai";
|
| 49 |
+
|
| 50 |
+
const client = new OpenAI({
|
| 51 |
+
baseURL: "${baseUrl}",
|
| 52 |
+
apiKey: "${apiKey}",
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
const stream = await client.chat.completions.create({
|
| 56 |
+
model: "${model}",
|
| 57 |
+
messages: [{ role: "user", content: "Hello" }],
|
| 58 |
+
stream: true,
|
| 59 |
+
});
|
| 60 |
+
for await (const chunk of stream) {
|
| 61 |
+
process.stdout.write(chunk.choices[0]?.delta?.content || "");
|
| 62 |
+
}`,
|
| 63 |
+
|
| 64 |
+
"anthropic-python": `import anthropic
|
| 65 |
+
|
| 66 |
+
client = anthropic.Anthropic(
|
| 67 |
+
base_url="${origin}/v1",
|
| 68 |
+
api_key="${apiKey}",
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
message = client.messages.create(
|
| 72 |
+
model="claude-sonnet-4-20250514",
|
| 73 |
+
max_tokens=1024,
|
| 74 |
+
messages=[{"role": "user", "content": "Hello"}],
|
| 75 |
+
)
|
| 76 |
+
print(message.content[0].text)`,
|
| 77 |
+
|
| 78 |
+
"anthropic-curl": `curl ${origin}/v1/messages \\
|
| 79 |
+
-H "Content-Type: application/json" \\
|
| 80 |
+
-H "x-api-key: ${apiKey}" \\
|
| 81 |
+
-H "anthropic-version: 2023-06-01" \\
|
| 82 |
+
-d '{
|
| 83 |
+
"model": "claude-sonnet-4-20250514",
|
| 84 |
+
"max_tokens": 1024,
|
| 85 |
+
"messages": [{"role": "user", "content": "Hello"}]
|
| 86 |
+
}'`,
|
| 87 |
+
|
| 88 |
+
"anthropic-node": `import Anthropic from "@anthropic-ai/sdk";
|
| 89 |
+
|
| 90 |
+
const client = new Anthropic({
|
| 91 |
+
baseURL: "${origin}/v1",
|
| 92 |
+
apiKey: "${apiKey}",
|
| 93 |
+
});
|
| 94 |
+
|
| 95 |
+
const message = await client.messages.create({
|
| 96 |
+
model: "claude-sonnet-4-20250514",
|
| 97 |
+
max_tokens: 1024,
|
| 98 |
+
messages: [{ role: "user", content: "Hello" }],
|
| 99 |
+
});
|
| 100 |
+
console.log(message.content[0].text);`,
|
| 101 |
+
|
| 102 |
+
"gemini-python": `from google import genai
|
| 103 |
+
|
| 104 |
+
client = genai.Client(
|
| 105 |
+
api_key="${apiKey}",
|
| 106 |
+
http_options={"base_url": "${origin}/v1beta"},
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
response = client.models.generate_content(
|
| 110 |
+
model="gemini-2.5-pro",
|
| 111 |
+
contents="Hello",
|
| 112 |
+
)
|
| 113 |
+
print(response.text)`,
|
| 114 |
+
|
| 115 |
+
"gemini-curl": `curl "${origin}/v1beta/models/gemini-2.5-pro:generateContent?key=${apiKey}" \\
|
| 116 |
+
-H "Content-Type: application/json" \\
|
| 117 |
+
-d '{
|
| 118 |
+
"contents": [{"role": "user", "parts": [{"text": "Hello"}]}]
|
| 119 |
+
}'`,
|
| 120 |
+
|
| 121 |
+
"gemini-node": `import { GoogleGenAI } from "@google/genai";
|
| 122 |
+
|
| 123 |
+
const ai = new GoogleGenAI({
|
| 124 |
+
apiKey: "${apiKey}",
|
| 125 |
+
httpOptions: { baseUrl: "${origin}/v1beta" },
|
| 126 |
+
});
|
| 127 |
+
|
| 128 |
+
const response = await ai.models.generateContent({
|
| 129 |
+
model: "gemini-2.5-pro",
|
| 130 |
+
contents: "Hello",
|
| 131 |
+
});
|
| 132 |
+
console.log(response.text);`,
|
| 133 |
+
};
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
interface CodeExamplesProps {
|
| 137 |
+
baseUrl: string;
|
| 138 |
+
apiKey: string;
|
| 139 |
+
model: string;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
export function CodeExamples({ baseUrl, apiKey, model }: CodeExamplesProps) {
|
| 143 |
+
const t = useT();
|
| 144 |
+
const [protocol, setProtocol] = useState<Protocol>("openai");
|
| 145 |
+
const [codeLang, setCodeLang] = useState<CodeLang>("python");
|
| 146 |
+
|
| 147 |
+
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
| 148 |
+
const examples = useMemo(
|
| 149 |
+
() => buildExamples(baseUrl, apiKey, model, origin),
|
| 150 |
+
[baseUrl, apiKey, model, origin]
|
| 151 |
+
);
|
| 152 |
+
|
| 153 |
+
const currentCode = examples[`${protocol}-${codeLang}`] || "Loading...";
|
| 154 |
+
const getCode = useCallback(() => currentCode, [currentCode]);
|
| 155 |
+
|
| 156 |
+
const protoActive =
|
| 157 |
+
"px-6 py-3 text-[0.82rem] font-semibold text-primary border-b-2 border-primary bg-white dark:bg-card-dark transition-colors";
|
| 158 |
+
const protoInactive =
|
| 159 |
+
"px-6 py-3 text-[0.82rem] font-medium text-slate-500 dark:text-text-dim hover:text-slate-700 dark:hover:text-text-main hover:bg-slate-50 dark:hover:bg-[#21262d] border-b-2 border-transparent transition-colors";
|
| 160 |
+
const langActive =
|
| 161 |
+
"px-3 py-1.5 text-xs font-semibold rounded bg-white dark:bg-[#21262d] text-slate-800 dark:text-text-main shadow-sm border border-transparent dark:border-border-dark transition-all";
|
| 162 |
+
const langInactive =
|
| 163 |
+
"px-3 py-1.5 text-xs font-medium rounded text-slate-500 dark:text-text-dim hover:text-slate-700 dark:hover:text-text-main hover:bg-white/50 dark:hover:bg-[#21262d] border border-transparent transition-all";
|
| 164 |
+
|
| 165 |
+
return (
|
| 166 |
+
<section class="flex flex-col gap-4">
|
| 167 |
+
<h2 class="text-[0.95rem] font-bold">{t("integrationExamples")}</h2>
|
| 168 |
+
<div class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl overflow-hidden shadow-sm transition-colors">
|
| 169 |
+
{/* Protocol Tabs */}
|
| 170 |
+
<div class="flex border-b border-gray-200 dark:border-border-dark bg-slate-50/50 dark:bg-bg-dark/30">
|
| 171 |
+
{protocols.map((p) => (
|
| 172 |
+
<button
|
| 173 |
+
key={p.id}
|
| 174 |
+
onClick={() => setProtocol(p.id)}
|
| 175 |
+
class={protocol === p.id ? protoActive : protoInactive}
|
| 176 |
+
>
|
| 177 |
+
{p.label}
|
| 178 |
+
</button>
|
| 179 |
+
))}
|
| 180 |
+
</div>
|
| 181 |
+
{/* Language Tabs & Code */}
|
| 182 |
+
<div class="p-5">
|
| 183 |
+
<div class="flex items-center justify-between mb-4">
|
| 184 |
+
<div class="flex gap-2 p-1 bg-slate-100 dark:bg-bg-dark dark:border dark:border-border-dark rounded-lg">
|
| 185 |
+
{langs.map((l) => (
|
| 186 |
+
<button
|
| 187 |
+
key={l.id}
|
| 188 |
+
onClick={() => setCodeLang(l.id)}
|
| 189 |
+
class={codeLang === l.id ? langActive : langInactive}
|
| 190 |
+
>
|
| 191 |
+
{l.label}
|
| 192 |
+
</button>
|
| 193 |
+
))}
|
| 194 |
+
</div>
|
| 195 |
+
</div>
|
| 196 |
+
{/* Code Block */}
|
| 197 |
+
<div class="relative group rounded-lg overflow-hidden bg-[#0d1117] text-slate-300 font-mono text-xs border border-slate-800 dark:border-border-dark">
|
| 198 |
+
<div class="absolute right-2 top-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity">
|
| 199 |
+
<CopyButton getText={getCode} variant="label" />
|
| 200 |
+
</div>
|
| 201 |
+
<div class="p-4 overflow-x-auto">
|
| 202 |
+
<pre class="m-0"><code>{currentCode}</code></pre>
|
| 203 |
+
</div>
|
| 204 |
+
</div>
|
| 205 |
+
</div>
|
| 206 |
+
</div>
|
| 207 |
+
</section>
|
| 208 |
+
);
|
| 209 |
+
}
|
web/src/components/CopyButton.tsx
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useCallback } from "preact/hooks";
|
| 2 |
+
import { clipboardCopy } from "../utils/clipboard";
|
| 3 |
+
import { useT } from "../i18n/context";
|
| 4 |
+
|
| 5 |
+
interface CopyButtonProps {
|
| 6 |
+
getText: () => string;
|
| 7 |
+
class?: string;
|
| 8 |
+
titleKey?: string;
|
| 9 |
+
variant?: "icon" | "label";
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
const SVG_COPY = (
|
| 13 |
+
<svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
| 14 |
+
<rect x="9" y="9" width="13" height="13" rx="2" />
|
| 15 |
+
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
|
| 16 |
+
</svg>
|
| 17 |
+
);
|
| 18 |
+
|
| 19 |
+
const SVG_CHECK = (
|
| 20 |
+
<svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 21 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
| 22 |
+
</svg>
|
| 23 |
+
);
|
| 24 |
+
|
| 25 |
+
const SVG_FAIL = (
|
| 26 |
+
<svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 27 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
| 28 |
+
</svg>
|
| 29 |
+
);
|
| 30 |
+
|
| 31 |
+
export function CopyButton({ getText, class: className, titleKey, variant = "icon" }: CopyButtonProps) {
|
| 32 |
+
const t = useT();
|
| 33 |
+
const [state, setState] = useState<"idle" | "ok" | "fail">("idle");
|
| 34 |
+
|
| 35 |
+
const handleCopy = useCallback(async () => {
|
| 36 |
+
const ok = await clipboardCopy(getText());
|
| 37 |
+
setState(ok ? "ok" : "fail");
|
| 38 |
+
setTimeout(() => setState("idle"), 2000);
|
| 39 |
+
}, [getText]);
|
| 40 |
+
|
| 41 |
+
if (variant === "label") {
|
| 42 |
+
const bgClass =
|
| 43 |
+
state === "ok"
|
| 44 |
+
? "bg-primary hover:bg-primary-hover"
|
| 45 |
+
: state === "fail"
|
| 46 |
+
? "bg-red-600 hover:bg-red-700"
|
| 47 |
+
: "bg-slate-700 hover:bg-slate-600";
|
| 48 |
+
|
| 49 |
+
return (
|
| 50 |
+
<button
|
| 51 |
+
onClick={handleCopy}
|
| 52 |
+
class={`flex items-center gap-1.5 px-3 py-1.5 ${bgClass} text-white rounded text-xs font-medium transition-colors ${className || ""}`}
|
| 53 |
+
>
|
| 54 |
+
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
| 55 |
+
<rect x="9" y="9" width="13" height="13" rx="2" />
|
| 56 |
+
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
|
| 57 |
+
</svg>
|
| 58 |
+
<span>
|
| 59 |
+
{state === "ok" ? t("copied") : state === "fail" ? t("copyFailed") : t("copy")}
|
| 60 |
+
</span>
|
| 61 |
+
</button>
|
| 62 |
+
);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
return (
|
| 66 |
+
<button
|
| 67 |
+
onClick={handleCopy}
|
| 68 |
+
class={`p-1.5 transition-colors rounded-md hover:bg-slate-100 dark:hover:bg-border-dark ${
|
| 69 |
+
state === "ok"
|
| 70 |
+
? "text-primary"
|
| 71 |
+
: state === "fail"
|
| 72 |
+
? "text-red-500"
|
| 73 |
+
: "text-slate-400 dark:text-text-dim hover:text-primary"
|
| 74 |
+
} ${className || ""}`}
|
| 75 |
+
title={titleKey ? t(titleKey as any) : undefined}
|
| 76 |
+
>
|
| 77 |
+
{state === "ok" ? SVG_CHECK : state === "fail" ? SVG_FAIL : SVG_COPY}
|
| 78 |
+
</button>
|
| 79 |
+
);
|
| 80 |
+
}
|
web/src/components/Footer.tsx
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useT } from "../i18n/context";
|
| 2 |
+
|
| 3 |
+
export function Footer() {
|
| 4 |
+
const t = useT();
|
| 5 |
+
|
| 6 |
+
return (
|
| 7 |
+
<footer class="mt-auto border-t border-gray-200 dark:border-border-dark bg-white dark:bg-card-dark py-6 transition-colors">
|
| 8 |
+
<div class="container mx-auto px-4 text-center">
|
| 9 |
+
<p class="text-[0.8rem] text-slate-500 dark:text-text-dim">{t("footer")}</p>
|
| 10 |
+
</div>
|
| 11 |
+
</footer>
|
| 12 |
+
);
|
| 13 |
+
}
|
web/src/components/Header.tsx
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useI18n } from "../i18n/context";
|
| 2 |
+
import { useTheme } from "../theme/context";
|
| 3 |
+
|
| 4 |
+
const SVG_MOON = (
|
| 5 |
+
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
| 6 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
| 7 |
+
</svg>
|
| 8 |
+
);
|
| 9 |
+
|
| 10 |
+
const SVG_SUN = (
|
| 11 |
+
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
| 12 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
| 13 |
+
</svg>
|
| 14 |
+
);
|
| 15 |
+
|
| 16 |
+
interface HeaderProps {
|
| 17 |
+
onAddAccount: () => void;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export function Header({ onAddAccount }: HeaderProps) {
|
| 21 |
+
const { lang, toggleLang, t } = useI18n();
|
| 22 |
+
const { isDark, toggle: toggleTheme } = useTheme();
|
| 23 |
+
|
| 24 |
+
return (
|
| 25 |
+
<header class="sticky top-0 z-50 w-full bg-white dark:bg-card-dark border-b border-gray-200 dark:border-border-dark shadow-sm transition-colors">
|
| 26 |
+
<div class="px-4 md:px-8 lg:px-40 flex h-14 items-center justify-center">
|
| 27 |
+
<div class="flex w-full max-w-[960px] items-center justify-between">
|
| 28 |
+
{/* Logo & Title */}
|
| 29 |
+
<div class="flex items-center gap-3">
|
| 30 |
+
<div class="flex items-center justify-center size-8 rounded-full bg-primary/10 text-primary border border-primary/20">
|
| 31 |
+
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 32 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 33 |
+
</svg>
|
| 34 |
+
</div>
|
| 35 |
+
<h1 class="text-[0.9rem] font-bold tracking-tight">Codex Proxy</h1>
|
| 36 |
+
</div>
|
| 37 |
+
{/* Actions */}
|
| 38 |
+
<div class="flex items-center gap-3">
|
| 39 |
+
<div class="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary/10 border border-primary/20">
|
| 40 |
+
<span class="relative flex h-2.5 w-2.5">
|
| 41 |
+
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
|
| 42 |
+
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-primary" />
|
| 43 |
+
</span>
|
| 44 |
+
<span class="text-xs font-semibold text-primary inline-grid">
|
| 45 |
+
<span class="invisible col-start-1 row-start-1">Server Online</span>
|
| 46 |
+
<span class="col-start-1 row-start-1">{t("serverOnline")}</span>
|
| 47 |
+
</span>
|
| 48 |
+
</div>
|
| 49 |
+
{/* Language Toggle */}
|
| 50 |
+
<button
|
| 51 |
+
onClick={toggleLang}
|
| 52 |
+
class="p-2 rounded-lg text-slate-500 dark:text-text-dim hover:bg-slate-100 dark:hover:bg-border-dark transition-colors"
|
| 53 |
+
title="\u4e2d/EN"
|
| 54 |
+
>
|
| 55 |
+
<span class="text-xs font-bold inline-flex items-center justify-center w-5">{lang === "en" ? "EN" : "\u4e2d"}</span>
|
| 56 |
+
</button>
|
| 57 |
+
{/* Theme Toggle */}
|
| 58 |
+
<button
|
| 59 |
+
onClick={toggleTheme}
|
| 60 |
+
class="p-2 rounded-lg text-slate-500 dark:text-text-dim hover:bg-slate-100 dark:hover:bg-border-dark transition-colors"
|
| 61 |
+
title={t("toggleTheme")}
|
| 62 |
+
>
|
| 63 |
+
{isDark ? SVG_SUN : SVG_MOON}
|
| 64 |
+
</button>
|
| 65 |
+
{/* Add Account */}
|
| 66 |
+
<button
|
| 67 |
+
onClick={onAddAccount}
|
| 68 |
+
class="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white text-xs font-semibold rounded-lg transition-colors shadow-sm active:scale-95"
|
| 69 |
+
>
|
| 70 |
+
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
| 71 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
| 72 |
+
</svg>
|
| 73 |
+
<span class="inline-grid">
|
| 74 |
+
<span class="invisible col-start-1 row-start-1">Add Account</span>
|
| 75 |
+
<span class="col-start-1 row-start-1">{t("addAccount")}</span>
|
| 76 |
+
</span>
|
| 77 |
+
</button>
|
| 78 |
+
</div>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
</header>
|
| 82 |
+
);
|
| 83 |
+
}
|
web/src/hooks/use-accounts.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect, useCallback } from "preact/hooks";
|
| 2 |
+
|
| 3 |
+
export interface AccountQuota {
|
| 4 |
+
rate_limit?: {
|
| 5 |
+
used_percent?: number | null;
|
| 6 |
+
limit_reached?: boolean;
|
| 7 |
+
reset_at?: number | null;
|
| 8 |
+
};
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export interface Account {
|
| 12 |
+
id: string;
|
| 13 |
+
email: string;
|
| 14 |
+
status: string;
|
| 15 |
+
planType?: string;
|
| 16 |
+
usage?: {
|
| 17 |
+
request_count?: number;
|
| 18 |
+
input_tokens?: number;
|
| 19 |
+
output_tokens?: number;
|
| 20 |
+
};
|
| 21 |
+
quota?: AccountQuota;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export function useAccounts() {
|
| 25 |
+
const [list, setList] = useState<Account[]>([]);
|
| 26 |
+
const [loading, setLoading] = useState(true);
|
| 27 |
+
const [addVisible, setAddVisible] = useState(false);
|
| 28 |
+
const [addInfo, setAddInfo] = useState("");
|
| 29 |
+
const [addError, setAddError] = useState("");
|
| 30 |
+
|
| 31 |
+
const loadAccounts = useCallback(async () => {
|
| 32 |
+
try {
|
| 33 |
+
const resp = await fetch("/auth/accounts?quota=true");
|
| 34 |
+
const data = await resp.json();
|
| 35 |
+
setList(data.accounts || []);
|
| 36 |
+
} catch (err) {
|
| 37 |
+
setList([]);
|
| 38 |
+
} finally {
|
| 39 |
+
setLoading(false);
|
| 40 |
+
}
|
| 41 |
+
}, []);
|
| 42 |
+
|
| 43 |
+
useEffect(() => {
|
| 44 |
+
loadAccounts();
|
| 45 |
+
}, [loadAccounts]);
|
| 46 |
+
|
| 47 |
+
// Listen for OAuth callback success
|
| 48 |
+
useEffect(() => {
|
| 49 |
+
const handler = async (event: MessageEvent) => {
|
| 50 |
+
if (event.data?.type === "oauth-callback-success") {
|
| 51 |
+
setAddVisible(false);
|
| 52 |
+
setAddInfo("accountAdded");
|
| 53 |
+
await loadAccounts();
|
| 54 |
+
}
|
| 55 |
+
};
|
| 56 |
+
window.addEventListener("message", handler);
|
| 57 |
+
return () => window.removeEventListener("message", handler);
|
| 58 |
+
}, [loadAccounts]);
|
| 59 |
+
|
| 60 |
+
const startAdd = useCallback(async () => {
|
| 61 |
+
setAddInfo("");
|
| 62 |
+
setAddError("");
|
| 63 |
+
try {
|
| 64 |
+
const resp = await fetch("/auth/login-start", { method: "POST" });
|
| 65 |
+
const data = await resp.json();
|
| 66 |
+
if (!resp.ok || !data.authUrl) {
|
| 67 |
+
throw new Error(data.error || "failedStartLogin");
|
| 68 |
+
}
|
| 69 |
+
window.open(data.authUrl, "oauth_add", "width=600,height=700,scrollbars=yes");
|
| 70 |
+
setAddVisible(true);
|
| 71 |
+
|
| 72 |
+
// Poll for new account
|
| 73 |
+
const prevResp = await fetch("/auth/accounts");
|
| 74 |
+
const prevData = await prevResp.json();
|
| 75 |
+
const prevCount = prevData.accounts?.length || 0;
|
| 76 |
+
|
| 77 |
+
const pollTimer = setInterval(async () => {
|
| 78 |
+
try {
|
| 79 |
+
const r = await fetch("/auth/accounts");
|
| 80 |
+
const d = await r.json();
|
| 81 |
+
if ((d.accounts?.length || 0) > prevCount) {
|
| 82 |
+
clearInterval(pollTimer);
|
| 83 |
+
setAddVisible(false);
|
| 84 |
+
setAddInfo("accountAdded");
|
| 85 |
+
await loadAccounts();
|
| 86 |
+
}
|
| 87 |
+
} catch {}
|
| 88 |
+
}, 2000);
|
| 89 |
+
|
| 90 |
+
setTimeout(() => clearInterval(pollTimer), 5 * 60 * 1000);
|
| 91 |
+
} catch (err) {
|
| 92 |
+
setAddError(err instanceof Error ? err.message : "failedStartLogin");
|
| 93 |
+
}
|
| 94 |
+
}, [loadAccounts]);
|
| 95 |
+
|
| 96 |
+
const submitRelay = useCallback(
|
| 97 |
+
async (callbackUrl: string) => {
|
| 98 |
+
setAddInfo("");
|
| 99 |
+
setAddError("");
|
| 100 |
+
if (!callbackUrl.trim()) {
|
| 101 |
+
setAddError("pleasePassCallback");
|
| 102 |
+
return;
|
| 103 |
+
}
|
| 104 |
+
try {
|
| 105 |
+
const resp = await fetch("/auth/code-relay", {
|
| 106 |
+
method: "POST",
|
| 107 |
+
headers: { "Content-Type": "application/json" },
|
| 108 |
+
body: JSON.stringify({ callbackUrl }),
|
| 109 |
+
});
|
| 110 |
+
const data = await resp.json();
|
| 111 |
+
if (resp.ok && data.success) {
|
| 112 |
+
setAddVisible(false);
|
| 113 |
+
setAddInfo("accountAdded");
|
| 114 |
+
await loadAccounts();
|
| 115 |
+
} else {
|
| 116 |
+
setAddError(data.error || "failedExchangeCode");
|
| 117 |
+
}
|
| 118 |
+
} catch (err) {
|
| 119 |
+
setAddError(
|
| 120 |
+
"networkError" + (err instanceof Error ? err.message : String(err))
|
| 121 |
+
);
|
| 122 |
+
}
|
| 123 |
+
},
|
| 124 |
+
[loadAccounts]
|
| 125 |
+
);
|
| 126 |
+
|
| 127 |
+
const deleteAccount = useCallback(
|
| 128 |
+
async (id: string) => {
|
| 129 |
+
try {
|
| 130 |
+
const resp = await fetch("/auth/accounts/" + encodeURIComponent(id), {
|
| 131 |
+
method: "DELETE",
|
| 132 |
+
});
|
| 133 |
+
if (!resp.ok) {
|
| 134 |
+
const data = await resp.json();
|
| 135 |
+
return data.error || "failedDeleteAccount";
|
| 136 |
+
}
|
| 137 |
+
await loadAccounts();
|
| 138 |
+
return null;
|
| 139 |
+
} catch (err) {
|
| 140 |
+
return "networkError" + (err instanceof Error ? err.message : "");
|
| 141 |
+
}
|
| 142 |
+
},
|
| 143 |
+
[loadAccounts]
|
| 144 |
+
);
|
| 145 |
+
|
| 146 |
+
return {
|
| 147 |
+
list,
|
| 148 |
+
loading,
|
| 149 |
+
addVisible,
|
| 150 |
+
addInfo,
|
| 151 |
+
addError,
|
| 152 |
+
startAdd,
|
| 153 |
+
submitRelay,
|
| 154 |
+
deleteAccount,
|
| 155 |
+
};
|
| 156 |
+
}
|
web/src/hooks/use-status.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect, useCallback } from "preact/hooks";
|
| 2 |
+
|
| 3 |
+
export function useStatus() {
|
| 4 |
+
const [baseUrl, setBaseUrl] = useState("Loading...");
|
| 5 |
+
const [apiKey, setApiKey] = useState("Loading...");
|
| 6 |
+
const [models, setModels] = useState<string[]>(["codex"]);
|
| 7 |
+
const [selectedModel, setSelectedModel] = useState("codex");
|
| 8 |
+
|
| 9 |
+
const loadModels = useCallback(async () => {
|
| 10 |
+
try {
|
| 11 |
+
const resp = await fetch("/v1/models");
|
| 12 |
+
const data = await resp.json();
|
| 13 |
+
const ids: string[] = data.data.map((m: { id: string }) => m.id);
|
| 14 |
+
if (ids.length > 0) {
|
| 15 |
+
setModels(ids);
|
| 16 |
+
const preferred = ids.find((n) => n.includes("5.3-codex"));
|
| 17 |
+
if (preferred) setSelectedModel(preferred);
|
| 18 |
+
}
|
| 19 |
+
} catch {
|
| 20 |
+
setModels(["codex"]);
|
| 21 |
+
}
|
| 22 |
+
}, []);
|
| 23 |
+
|
| 24 |
+
useEffect(() => {
|
| 25 |
+
async function loadStatus() {
|
| 26 |
+
try {
|
| 27 |
+
const resp = await fetch("/auth/status");
|
| 28 |
+
const data = await resp.json();
|
| 29 |
+
if (!data.authenticated) return;
|
| 30 |
+
setBaseUrl(`${window.location.origin}/v1`);
|
| 31 |
+
setApiKey(data.proxy_api_key || "any-string");
|
| 32 |
+
await loadModels();
|
| 33 |
+
} catch (err) {
|
| 34 |
+
console.error("Status load error:", err);
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
loadStatus();
|
| 38 |
+
}, [loadModels]);
|
| 39 |
+
|
| 40 |
+
return { baseUrl, apiKey, models, selectedModel, setSelectedModel };
|
| 41 |
+
}
|
web/src/i18n/context.tsx
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createContext } from "preact";
|
| 2 |
+
import { useContext, useState, useCallback } from "preact/hooks";
|
| 3 |
+
import { translations, type LangCode, type TranslationKey } from "./translations";
|
| 4 |
+
import type { ComponentChildren } from "preact";
|
| 5 |
+
|
| 6 |
+
interface I18nContextValue {
|
| 7 |
+
lang: LangCode;
|
| 8 |
+
toggleLang: () => void;
|
| 9 |
+
t: (key: TranslationKey) => string;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
const I18nContext = createContext<I18nContextValue>(null!);
|
| 13 |
+
|
| 14 |
+
function getInitialLang(): LangCode {
|
| 15 |
+
try {
|
| 16 |
+
const saved = localStorage.getItem("codex-proxy-lang");
|
| 17 |
+
if (saved === "en" || saved === "zh") return saved;
|
| 18 |
+
} catch {}
|
| 19 |
+
return navigator.language.startsWith("zh") ? "zh" : "en";
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export function I18nProvider({ children }: { children: ComponentChildren }) {
|
| 23 |
+
const [lang, setLang] = useState<LangCode>(getInitialLang);
|
| 24 |
+
|
| 25 |
+
const toggleLang = useCallback(() => {
|
| 26 |
+
setLang((prev) => {
|
| 27 |
+
const next = prev === "en" ? "zh" : "en";
|
| 28 |
+
localStorage.setItem("codex-proxy-lang", next);
|
| 29 |
+
return next;
|
| 30 |
+
});
|
| 31 |
+
}, []);
|
| 32 |
+
|
| 33 |
+
const t = useCallback(
|
| 34 |
+
(key: TranslationKey): string => {
|
| 35 |
+
return translations[lang][key] ?? translations.en[key] ?? key;
|
| 36 |
+
},
|
| 37 |
+
[lang]
|
| 38 |
+
);
|
| 39 |
+
|
| 40 |
+
return (
|
| 41 |
+
<I18nContext.Provider value={{ lang, toggleLang, t }}>
|
| 42 |
+
{children}
|
| 43 |
+
</I18nContext.Provider>
|
| 44 |
+
);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
export function useT() {
|
| 48 |
+
return useContext(I18nContext).t;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
export function useI18n() {
|
| 52 |
+
return useContext(I18nContext);
|
| 53 |
+
}
|
web/src/i18n/translations.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const translations = {
|
| 2 |
+
en: {
|
| 3 |
+
serverOnline: "Server Online",
|
| 4 |
+
addAccount: "Add Account",
|
| 5 |
+
toggleTheme: "Toggle theme",
|
| 6 |
+
connectedAccounts: "Connected Accounts",
|
| 7 |
+
connectedAccountsDesc:
|
| 8 |
+
"Manage your AI model proxy services and usage limits.",
|
| 9 |
+
loadingAccounts: "Loading accounts...",
|
| 10 |
+
noAccounts: 'No accounts connected. Click "Add Account" to get started.',
|
| 11 |
+
deleteAccount: "Delete account",
|
| 12 |
+
removeConfirm: "Remove this account?",
|
| 13 |
+
accountAdded: "Account added successfully!",
|
| 14 |
+
active: "Active",
|
| 15 |
+
expired: "Expired",
|
| 16 |
+
rateLimited: "Rate Limited",
|
| 17 |
+
refreshing: "Refreshing",
|
| 18 |
+
disabled: "Disabled",
|
| 19 |
+
freeTier: "Free Tier",
|
| 20 |
+
totalRequests: "Total Requests",
|
| 21 |
+
tokensUsed: "Tokens Used",
|
| 22 |
+
rateLimit: "Rate Limit",
|
| 23 |
+
limitReached: "Limit Reached",
|
| 24 |
+
used: "Used",
|
| 25 |
+
ok: "OK",
|
| 26 |
+
resetsAt: "Resets at",
|
| 27 |
+
apiConfig: "API Configuration",
|
| 28 |
+
baseProxyUrl: "Base Proxy URL",
|
| 29 |
+
defaultModel: "Default Model",
|
| 30 |
+
yourApiKey: "Your API Key",
|
| 31 |
+
apiKeyHint:
|
| 32 |
+
"Use this key to authenticate requests to the proxy. Do not share it.",
|
| 33 |
+
copyUrl: "Copy URL",
|
| 34 |
+
copyApiKey: "Copy API Key",
|
| 35 |
+
integrationExamples: "Integration Examples",
|
| 36 |
+
copy: "Copy",
|
| 37 |
+
addStep1:
|
| 38 |
+
'Complete the login in the popup window (if blocked, right-click "Add Account" and open the link in a new tab).',
|
| 39 |
+
addStep2:
|
| 40 |
+
'After login, the browser will redirect to a <code class="text-xs bg-slate-100 dark:bg-bg-dark px-1.5 py-0.5 rounded">localhost:1455/auth/callback?...</code> page (it may show "unable to connect" — that\'s normal).',
|
| 41 |
+
addStep3:
|
| 42 |
+
'Copy the <strong class="text-slate-700 dark:text-text-main">full URL</strong> from the address bar and paste it below.',
|
| 43 |
+
pasteCallback: "Paste callback URL here",
|
| 44 |
+
submit: "Submit",
|
| 45 |
+
submitting: "Submitting...",
|
| 46 |
+
pleasePassCallback: "Please paste the callback URL",
|
| 47 |
+
failedStartLogin: "Failed to start login",
|
| 48 |
+
failedExchangeCode: "Failed to exchange code",
|
| 49 |
+
failedDeleteAccount: "Failed to delete account.",
|
| 50 |
+
networkError: "Network error: ",
|
| 51 |
+
copied: "Copied!",
|
| 52 |
+
copyFailed: "Failed",
|
| 53 |
+
footer: "\u00a9 2025 Codex Proxy. All rights reserved.",
|
| 54 |
+
},
|
| 55 |
+
zh: {
|
| 56 |
+
serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
|
| 57 |
+
addAccount: "\u6dfb\u52a0\u8d26\u6237",
|
| 58 |
+
toggleTheme: "\u5207\u6362\u4e3b\u9898",
|
| 59 |
+
connectedAccounts: "\u5df2\u8fde\u63a5\u8d26\u6237",
|
| 60 |
+
connectedAccountsDesc:
|
| 61 |
+
"\u7ba1\u7406\u4f60\u7684 AI \u6a21\u578b\u4ee3\u7406\u670d\u52a1\u548c\u7528\u91cf\u9650\u5236\u3002",
|
| 62 |
+
loadingAccounts: "\u6b63\u5728\u52a0\u8f7d\u8d26\u6237...",
|
| 63 |
+
noAccounts:
|
| 64 |
+
"\u6682\u65e0\u5df2\u8fde\u63a5\u7684\u8d26\u6237\u3002\u70b9\u51fb\u300c\u6dfb\u52a0\u8d26\u6237\u300d\u5f00\u59cb\u4f7f\u7528\u3002",
|
| 65 |
+
deleteAccount: "\u5220\u9664\u8d26\u6237",
|
| 66 |
+
removeConfirm:
|
| 67 |
+
"\u786e\u5b9a\u8981\u79fb\u9664\u6b64\u8d26\u6237\u5417\uff1f",
|
| 68 |
+
accountAdded: "\u8d26\u6237\u6dfb\u52a0\u6210\u529f\uff01",
|
| 69 |
+
active: "\u6d3b\u8dc3",
|
| 70 |
+
expired: "\u5df2\u8fc7\u671f",
|
| 71 |
+
rateLimited: "\u5df2\u9650\u901f",
|
| 72 |
+
refreshing: "\u5237\u65b0\u4e2d",
|
| 73 |
+
disabled: "\u5df2\u7981\u7528",
|
| 74 |
+
freeTier: "\u514d\u8d39\u7248",
|
| 75 |
+
totalRequests: "\u603b\u8bf7\u6c42\u6570",
|
| 76 |
+
tokensUsed: "Token \u7528\u91cf",
|
| 77 |
+
rateLimit: "\u901f\u7387\u9650\u5236",
|
| 78 |
+
limitReached: "\u5df2\u8fbe\u4e0a\u9650",
|
| 79 |
+
used: "\u5df2\u4f7f\u7528",
|
| 80 |
+
ok: "\u6b63\u5e38",
|
| 81 |
+
resetsAt: "\u91cd\u7f6e\u65f6\u95f4",
|
| 82 |
+
apiConfig: "API \u914d\u7f6e",
|
| 83 |
+
baseProxyUrl: "\u4ee3\u7406 URL",
|
| 84 |
+
defaultModel: "\u9ed8\u8ba4\u6a21\u578b",
|
| 85 |
+
yourApiKey: "API \u5bc6\u94a5",
|
| 86 |
+
apiKeyHint:
|
| 87 |
+
"\u4f7f\u7528\u6b64\u5bc6\u94a5\u5411\u4ee3\u7406\u53d1\u9001\u8ba4\u8bc1\u8bf7\u6c42\uff0c\u8bf7\u52ff\u6cc4\u9732\u3002",
|
| 88 |
+
copyUrl: "\u590d\u5236 URL",
|
| 89 |
+
copyApiKey: "\u590d\u5236 API \u5bc6\u94a5",
|
| 90 |
+
integrationExamples: "\u96c6\u6210\u793a\u4f8b",
|
| 91 |
+
copy: "\u590d\u5236",
|
| 92 |
+
addStep1:
|
| 93 |
+
"\u5728\u5f39\u51fa\u7684\u7a97\u53e3\u4e2d\u5b8c\u6210\u767b\u5f55\uff08\u5982\u5f39\u7a97\u88ab\u62e6\u622a\uff0c\u53f3\u952e\u300c\u6dfb\u52a0\u8d26\u6237\u300d\u6309\u94ae\u5728\u65b0\u6807\u7b7e\u9875\u6253\u5f00\u94fe\u63a5\uff09\u3002",
|
| 94 |
+
addStep2:
|
| 95 |
+
'\u767b\u5f55\u6210\u529f\u540e\uff0c\u6d4f\u89c8\u5668\u4f1a\u8df3\u8f6c\u5230 <code class="text-xs bg-slate-100 dark:bg-bg-dark px-1.5 py-0.5 rounded">localhost:1455/auth/callback?...</code> \u9875\u9762\uff08\u53ef\u80fd\u663e\u793a\u201c\u65e0\u6cd5\u8bbf\u95ee\u201d\u2014\u2014\u8fd9\u662f\u6b63\u5e38\u7684\uff09\u3002',
|
| 96 |
+
addStep3:
|
| 97 |
+
'\u590d\u5236\u5730\u5740\u680f\u4e2d\u7684<strong class="text-slate-700 dark:text-text-main">\u5b8c\u6574 URL</strong>\uff0c\u7c98\u8d34\u5230\u4e0b\u65b9\u8f93\u5165\u6846\u3002',
|
| 98 |
+
pasteCallback: "\u7c98\u8d34\u56de\u8c03 URL",
|
| 99 |
+
submit: "\u63d0\u4ea4",
|
| 100 |
+
submitting: "\u63d0\u4ea4\u4e2d...",
|
| 101 |
+
pleasePassCallback: "\u8bf7\u7c98\u8d34\u56de\u8c03 URL",
|
| 102 |
+
failedStartLogin: "\u767b\u5f55\u542f\u52a8\u5931\u8d25",
|
| 103 |
+
failedExchangeCode: "\u6388\u6743\u7801\u4ea4\u6362\u5931\u8d25",
|
| 104 |
+
failedDeleteAccount: "\u5220\u9664\u8d26\u6237\u5931\u8d25\u3002",
|
| 105 |
+
networkError: "\u7f51\u7edc\u9519\u8bef\uff1a",
|
| 106 |
+
copied: "\u5df2\u590d\u5236\uff01",
|
| 107 |
+
copyFailed: "\u5931\u8d25",
|
| 108 |
+
footer:
|
| 109 |
+
"\u00a9 2025 Codex Proxy\u3002\u4fdd\u7559\u6240\u6709\u6743\u5229\u3002",
|
| 110 |
+
},
|
| 111 |
+
} as const;
|
| 112 |
+
|
| 113 |
+
export type LangCode = keyof typeof translations;
|
| 114 |
+
export type TranslationKey = keyof (typeof translations)["en"];
|
web/src/index.css
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
:root {
|
| 6 |
+
--primary: 16 162 53;
|
| 7 |
+
--primary-hover: 14 140 46;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
.dark {
|
| 11 |
+
--primary: 16 163 127;
|
| 12 |
+
--primary-hover: 14 140 108;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
/* Dark scrollbar for code blocks */
|
| 16 |
+
pre::-webkit-scrollbar { height: 8px; }
|
| 17 |
+
pre::-webkit-scrollbar-track { background: #0d1117; }
|
| 18 |
+
pre::-webkit-scrollbar-thumb { background: #30363d; border-radius: 4px; }
|
| 19 |
+
pre::-webkit-scrollbar-thumb:hover { background: #8b949e; }
|
web/src/main.tsx
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { render } from "preact";
|
| 2 |
+
import { App } from "./App";
|
| 3 |
+
import "./index.css";
|
| 4 |
+
|
| 5 |
+
render(<App />, document.getElementById("app")!);
|
web/src/theme/context.tsx
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createContext } from "preact";
|
| 2 |
+
import { useContext, useState, useCallback } from "preact/hooks";
|
| 3 |
+
import type { ComponentChildren } from "preact";
|
| 4 |
+
|
| 5 |
+
interface ThemeContextValue {
|
| 6 |
+
isDark: boolean;
|
| 7 |
+
toggle: () => void;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
const ThemeContext = createContext<ThemeContextValue>(null!);
|
| 11 |
+
|
| 12 |
+
function getInitialDark(): boolean {
|
| 13 |
+
try {
|
| 14 |
+
const saved = localStorage.getItem("codex-proxy-theme");
|
| 15 |
+
if (saved === "dark") return true;
|
| 16 |
+
if (saved === "light") return false;
|
| 17 |
+
} catch {}
|
| 18 |
+
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export function ThemeProvider({ children }: { children: ComponentChildren }) {
|
| 22 |
+
const [isDark, setIsDark] = useState(getInitialDark);
|
| 23 |
+
|
| 24 |
+
const toggle = useCallback(() => {
|
| 25 |
+
setIsDark((prev) => {
|
| 26 |
+
const next = !prev;
|
| 27 |
+
localStorage.setItem("codex-proxy-theme", next ? "dark" : "light");
|
| 28 |
+
if (next) {
|
| 29 |
+
document.documentElement.classList.add("dark");
|
| 30 |
+
} else {
|
| 31 |
+
document.documentElement.classList.remove("dark");
|
| 32 |
+
}
|
| 33 |
+
return next;
|
| 34 |
+
});
|
| 35 |
+
}, []);
|
| 36 |
+
|
| 37 |
+
return (
|
| 38 |
+
<ThemeContext.Provider value={{ isDark, toggle }}>
|
| 39 |
+
{children}
|
| 40 |
+
</ThemeContext.Provider>
|
| 41 |
+
);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export function useTheme() {
|
| 45 |
+
return useContext(ThemeContext);
|
| 46 |
+
}
|
web/src/utils/clipboard.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
function fallbackCopy(text: string): boolean {
|
| 2 |
+
const ta = document.createElement("textarea");
|
| 3 |
+
ta.value = text;
|
| 4 |
+
ta.style.cssText = "position:fixed;left:-9999px;opacity:0";
|
| 5 |
+
document.body.appendChild(ta);
|
| 6 |
+
ta.select();
|
| 7 |
+
let ok = false;
|
| 8 |
+
try {
|
| 9 |
+
ok = document.execCommand("copy");
|
| 10 |
+
} catch {}
|
| 11 |
+
document.body.removeChild(ta);
|
| 12 |
+
return ok;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export async function clipboardCopy(text: string): Promise<boolean> {
|
| 16 |
+
if (navigator.clipboard?.writeText) {
|
| 17 |
+
try {
|
| 18 |
+
await navigator.clipboard.writeText(text);
|
| 19 |
+
return true;
|
| 20 |
+
} catch {}
|
| 21 |
+
}
|
| 22 |
+
return fallbackCopy(text);
|
| 23 |
+
}
|
web/src/utils/format.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function formatNumber(n: number): string {
|
| 2 |
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
|
| 3 |
+
if (n >= 1_000) return (n / 1_000).toFixed(1) + "k";
|
| 4 |
+
return String(n);
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
export function formatResetTime(unixSec: number, isZh: boolean): string {
|
| 8 |
+
const d = new Date(unixSec * 1000);
|
| 9 |
+
const now = new Date();
|
| 10 |
+
const time = d.toLocaleTimeString(undefined, {
|
| 11 |
+
hour: "2-digit",
|
| 12 |
+
minute: "2-digit",
|
| 13 |
+
second: "2-digit",
|
| 14 |
+
});
|
| 15 |
+
|
| 16 |
+
if (
|
| 17 |
+
d.getFullYear() === now.getFullYear() &&
|
| 18 |
+
d.getMonth() === now.getMonth() &&
|
| 19 |
+
d.getDate() === now.getDate()
|
| 20 |
+
) {
|
| 21 |
+
return time;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
const tomorrow = new Date(now);
|
| 25 |
+
tomorrow.setDate(tomorrow.getDate() + 1);
|
| 26 |
+
if (
|
| 27 |
+
d.getFullYear() === tomorrow.getFullYear() &&
|
| 28 |
+
d.getMonth() === tomorrow.getMonth() &&
|
| 29 |
+
d.getDate() === tomorrow.getDate()
|
| 30 |
+
) {
|
| 31 |
+
return (isZh ? "\u660e\u5929 " : "Tomorrow ") + time;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
const date = d.toLocaleDateString(undefined, {
|
| 35 |
+
month: "short",
|
| 36 |
+
day: "numeric",
|
| 37 |
+
});
|
| 38 |
+
return date + " " + time;
|
| 39 |
+
}
|
web/tailwind.config.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Config } from "tailwindcss";
|
| 2 |
+
|
| 3 |
+
export default {
|
| 4 |
+
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
| 5 |
+
darkMode: "class",
|
| 6 |
+
theme: {
|
| 7 |
+
extend: {
|
| 8 |
+
colors: {
|
| 9 |
+
primary: "rgb(var(--primary) / <alpha-value>)",
|
| 10 |
+
"primary-hover": "rgb(var(--primary-hover) / <alpha-value>)",
|
| 11 |
+
"bg-light": "#f6f8f6",
|
| 12 |
+
"bg-dark": "#0d1117",
|
| 13 |
+
"card-dark": "#161b22",
|
| 14 |
+
"border-dark": "#30363d",
|
| 15 |
+
"text-main": "#e6edf3",
|
| 16 |
+
"text-dim": "#8b949e",
|
| 17 |
+
},
|
| 18 |
+
fontFamily: {
|
| 19 |
+
display: [
|
| 20 |
+
"system-ui",
|
| 21 |
+
"-apple-system",
|
| 22 |
+
"BlinkMacSystemFont",
|
| 23 |
+
"Segoe UI",
|
| 24 |
+
"Roboto",
|
| 25 |
+
"sans-serif",
|
| 26 |
+
],
|
| 27 |
+
mono: [
|
| 28 |
+
"ui-monospace",
|
| 29 |
+
"SFMono-Regular",
|
| 30 |
+
"SF Mono",
|
| 31 |
+
"Menlo",
|
| 32 |
+
"Consolas",
|
| 33 |
+
"Liberation Mono",
|
| 34 |
+
"monospace",
|
| 35 |
+
],
|
| 36 |
+
},
|
| 37 |
+
borderRadius: {
|
| 38 |
+
DEFAULT: "0.25rem",
|
| 39 |
+
lg: "0.5rem",
|
| 40 |
+
xl: "0.75rem",
|
| 41 |
+
full: "9999px",
|
| 42 |
+
},
|
| 43 |
+
},
|
| 44 |
+
},
|
| 45 |
+
plugins: [],
|
| 46 |
+
} satisfies Config;
|
web/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2022",
|
| 4 |
+
"module": "ESNext",
|
| 5 |
+
"moduleResolution": "bundler",
|
| 6 |
+
"esModuleInterop": true,
|
| 7 |
+
"strict": true,
|
| 8 |
+
"noImplicitAny": true,
|
| 9 |
+
"skipLibCheck": true,
|
| 10 |
+
"forceConsistentCasingInFileNames": true,
|
| 11 |
+
"resolveJsonModule": true,
|
| 12 |
+
"isolatedModules": true,
|
| 13 |
+
"jsx": "react-jsx",
|
| 14 |
+
"jsxImportSource": "preact"
|
| 15 |
+
},
|
| 16 |
+
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
| 17 |
+
}
|
web/vite.config.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from "vite";
|
| 2 |
+
import preact from "@preact/preset-vite";
|
| 3 |
+
|
| 4 |
+
export default defineConfig({
|
| 5 |
+
plugins: [preact()],
|
| 6 |
+
build: {
|
| 7 |
+
outDir: "../public",
|
| 8 |
+
emptyOutDir: false,
|
| 9 |
+
},
|
| 10 |
+
server: {
|
| 11 |
+
port: 5173,
|
| 12 |
+
proxy: {
|
| 13 |
+
"/v1": "http://localhost:8080",
|
| 14 |
+
"/auth": "http://localhost:8080",
|
| 15 |
+
"/health": "http://localhost:8080",
|
| 16 |
+
"/debug": "http://localhost:8080",
|
| 17 |
+
},
|
| 18 |
+
},
|
| 19 |
+
});
|