Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ HOST=0.0.0.0
# 默认: 7861
PORT=7861

# 反代保留路径前缀时需剥掉的前缀 (如 /AGY2API)
# 仅在反代将 /AGY2API/xxx 原样转发到本服务时配置; 反代已剥掉前缀时留空
# 默认: 空 (不剥离)
PATH_PREFIX=

# ================================================================
# 密码配置 (分离密码)
# ================================================================
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ docker run -d --name agy2api --network host -e API_PASSWORD=api_pwd -e PANEL_PAS
- `OAUTH_PROXY_URL`: OAuth 认证代理端点
- `GOOGLEAPIS_PROXY_URL`: Google APIs 代理端点
- `ANTIGRAVITY_API_URL`: Antigravity API 端点
- `PATH_PREFIX`: 反代保留路径前缀(如 `/AGY2API`)时配置,由后端在路由匹配前剥掉该前缀;默认:空(不剥离)

**性能和稳定性配置**
- `RETRY_429_ENABLED`: 启用错误自动重试(默认:true)
Expand Down
98 changes: 49 additions & 49 deletions dist/assets/index-cWGLDUMD.js → dist/assets/index-BgEyAGbN.js

Large diffs are not rendered by default.

16 changes: 13 additions & 3 deletions dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,21 @@
<html lang="zh-CN" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<link rel="icon" href="./favicon.ico" type="image/x-icon" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AGY2API - Antigravity 控制面板</title>
<script type="module" crossorigin src="/assets/index-cWGLDUMD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B569nWRy.css">
<script>
// 反代把面板挂在子路径下时,访问地址可能不带末尾斜杠(如 /AGY2API),
// 相对路径资源会因此解析到根域;此处补齐为目录形式,保证资源请求落在同一子路径内。
// 需置于模块脚本之前:模块脚本加载失败时内联脚本仍会执行,跳转后才能正确加载
if (!location.pathname.endsWith("/")) {
location.replace(
location.pathname + "/" + location.search + location.hash
);
}
</script>
<script type="module" crossorigin src="./assets/index-BgEyAGbN.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-B569nWRy.css">
</head>
<body class="dark">
<div id="root"></div>
Expand Down
1 change: 1 addition & 0 deletions docs/README_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ Import/export uses a standardized JSON format: `{"refresh_token": "...", "email"
- `PROXY`: Proxy address (supports `http://`, `https://`, `socks5://`, `socks5h://`, optionally with credentials `user:pass@host:port`)
- `OAUTH_PROXY_URL`: OAuth authentication proxy endpoint
- `GOOGLEAPIS_PROXY_URL`: Google APIs proxy endpoint
- `PATH_PREFIX`: When the reverse proxy preserves the path prefix (e.g. `/AGY2API`), set this so the backend strips it before routing; default: empty (no stripping)

**Automation Configuration**
- `AUTO_BAN`: Enable automatic credential banning (default: false)
Expand Down
1 change: 1 addition & 0 deletions docs/README_JA.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,7 @@ agy2apiは2つのストレージバックエンドに対応: **ローカルSQLit
- `PROXY`: プロキシアドレス(`http://`、`https://`、`socks5://`、`socks5h://` 対応、`user:pass@host:port` 形式の認証情報付きも可)
- `OAUTH_PROXY_URL`: OAuth認証プロキシエンドポイント
- `GOOGLEAPIS_PROXY_URL`: Google APIsプロキシエンドポイント
- `PATH_PREFIX`: リバースプロキシがパスプレフィックスを保持する場合(例: `/AGY2API`)、バックエンドがルーティング前にプレフィックスを剥がすよう設定。デフォルト: 空(剥がさない)

**自動化設定**
- `AUTO_BAN`: クレデンシャル自動BANの有効化(デフォルト: false)
Expand Down
12 changes: 11 additions & 1 deletion frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,19 @@
<html lang="zh-CN" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<link rel="icon" href="./favicon.ico" type="image/x-icon" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AGY2API - Antigravity 控制面板</title>
<script>
// 反代把面板挂在子路径下时,访问地址可能不带末尾斜杠(如 /AGY2API),
// 相对路径资源会因此解析到根域;此处补齐为目录形式,保证资源请求落在同一子路径内。
// 需置于模块脚本之前:模块脚本加载失败时内联脚本仍会执行,跳转后才能正确加载
if (!location.pathname.endsWith("/")) {
location.replace(
location.pathname + "/" + location.search + location.hash
);
}
</script>
</head>
<body class="dark">
<div id="root"></div>
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/api.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import axios from "axios";
import { getBasePath } from "./basePath.js";

const api = axios.create({
baseURL: "",
// 基径取面板页面所在路径(末尾补斜杠),反代到子路径下时请求仍落在同一子路径内
baseURL: getBasePath(),
timeout: 120000,
});

Expand All @@ -20,7 +22,7 @@ api.interceptors.response.use(
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem("panelToken");
window.location.href = "/";
window.location.href = getBasePath();
}
return Promise.reject(error);
}
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/basePath.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// 计算控制面板的基径(以 "/" 开头并以 "/" 结尾)
// 面板是单入口应用,文档地址即挂载点:根路径部署为 "/",反代子路径部署为
// "/AGY2API/" 等形式。部分反代不补齐末尾斜杠,访问地址可能为 "/AGY2API",
// 此处统一补齐,保证 API 请求与页面跳转落在同一子路径下
export function getBasePath() {
const { pathname } = window.location;
return pathname.endsWith("/") ? pathname : pathname + "/";
}
10 changes: 6 additions & 4 deletions frontend/src/pages/About.jsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { ExternalLink, Github, Info, RefreshCw, Rocket, Zap } from "lucide-react";
import { getBasePath } from "../basePath.js";

export default function About({ version, onCheckUpdate, checkingUpdate }) {
const apiBase = `${window.location.origin}${getBasePath()}`;
const apiEndpoints = [
{ label: "OpenAI 格式", value: `${window.location.origin}/v1/chat/completions` },
{ label: "Gemini 格式", value: `${window.location.origin}/v1beta/models` },
{ label: "Claude 格式", value: `${window.location.origin}/v1/messages` },
{ label: "OpenAI 格式", value: `${apiBase}v1/chat/completions` },
{ label: "Gemini 格式", value: `${apiBase}v1beta/models` },
{ label: "Claude 格式", value: `${apiBase}v1/messages` },
];

const copyText = (text) => {
Expand Down Expand Up @@ -134,7 +136,7 @@ export default function About({ version, onCheckUpdate, checkingUpdate }) {
<p className="text-gray-400 text-sm mb-3">欢迎加入 QQ 群交流讨论!</p>
<div className="inline-block bg-white rounded-xl p-3">
<img
src="/docs/qq-group.jpg"
src={`${getBasePath()}docs/qq-group.jpg`}
alt="QQ 群二维码"
className="w-40 h-40 rounded-lg"
/>
Expand Down
3 changes: 3 additions & 0 deletions frontend/vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

export default defineConfig({
// 相对基径:构建产物中的 JS/CSS 等资源引用相对于页面地址解析,
// 支持把面板反向代理到任意子路径(如 /AGY2API/)下访问
base: "./",
plugins: [react()],
server: {
port: 3000,
Expand Down
127 changes: 127 additions & 0 deletions tests/frontend/base_path.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { getBasePath } from "../../frontend/src/basePath.js";

function withLocation(pathname, fn) {
const previous = globalThis.window;
globalThis.window = {
location: {
pathname,
href: `http://panel.example${pathname}`,
},
};
try {
fn();
} finally {
globalThis.window = previous;
}
}

test("getBasePath 面板挂在根路径", () => {
withLocation("/", () => {
assert.equal(getBasePath(), "/");
});
});

test("getBasePath 面板挂在子路径且带末尾斜杠", () => {
withLocation("/AGY2API/", () => {
assert.equal(getBasePath(), "/AGY2API/");
});
});

test("getBasePath 面板挂在子路径但不带末尾斜杠", () => {
withLocation("/AGY2API", () => {
assert.equal(getBasePath(), "/AGY2API/");
});
});

test("getBasePath 多级嵌套路径", () => {
withLocation("/api/v1/panel/", () => {
assert.equal(getBasePath(), "/api/v1/panel/");
});
withLocation("/api/v1/panel", () => {
assert.equal(getBasePath(), "/api/v1/panel/");
});
});

// api.js 在模块加载时读取 window.location 计算 baseURL,
// 每个用例通过查询串导入独立模块实例,避免实例间串扰
async function importApi(pathname) {
globalThis.window = {
location: {
pathname,
href: `http://panel.example${pathname}`,
},
};
return import(`../../frontend/src/api.js?case=${encodeURIComponent(pathname)}`);
}

const URL_COMPOSITION_CASES = [
{ pathname: "/", url: "/auth/login", expected: "/auth/login" },
{ pathname: "/AGY2API/", url: "/auth/login", expected: "/AGY2API/auth/login" },
{ pathname: "/AGY2API", url: "/auth/login", expected: "/AGY2API/auth/login" },
{ pathname: "/api/v1/panel/", url: "/version/info", expected: "/api/v1/panel/version/info" },
{ pathname: "/AGY2API/", url: "/usage-logs/list", expected: "/AGY2API/usage-logs/list" },
];

for (const testCase of URL_COMPOSITION_CASES) {
test(`api URL 组合 页面 ${testCase.pathname} 请求 ${testCase.url}`, async () => {
const api = (await importApi(testCase.pathname)).default;
assert.equal(
api.getUri({ url: testCase.url }),
testCase.expected,
"请求 URL 应与页面所在基径拼合",
);
});
}

test("api 401 响应后跳转到面板基径而非站点根", async () => {
const api = (await importApi("/AGY2API/")).default;
globalThis.localStorage = {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
};
api.defaults.adapter = async () => {
const error = new Error("Request failed with status code 401");
error.response = { status: 401, data: { detail: "密码错误" } };
throw error;
};

await assert.rejects(api.post("/auth/login", { password: "wrong" }));
assert.equal(globalThis.window.location.href, "/AGY2API/");
});

test("api 401 响应后根路径部署跳转到根", async () => {
const api = (await importApi("/")).default;
globalThis.localStorage = {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
};
api.defaults.adapter = async () => {
const error = new Error("Request failed with status code 401");
error.response = { status: 401, data: { detail: "密码错误" } };
throw error;
};

await assert.rejects(api.get("/config/get"));
assert.equal(globalThis.window.location.href, "/");
});

test("api 请求拦截器附加登录 token", async () => {
const api = (await importApi("/AGY2API/")).default;
globalThis.localStorage = {
getItem: (key) => (key === "panelToken" ? "test-token" : null),
setItem: () => {},
removeItem: () => {},
};
let capturedConfig = null;
api.defaults.adapter = async (config) => {
capturedConfig = config;
return { data: {}, status: 200, statusText: "OK", headers: {}, config };
};

await api.get("/version/info");
assert.equal(capturedConfig.headers.Authorization, "Bearer test-token");
});
Loading
Loading