refactor(app): 管理台模块化并整理 worker API 类型
- 拆分管理台入口为 App/feature/core 结构并更新样式/配置 - 规范 worker 路由/中间件/服务类型与 JSON 解析 - 同步工具链与知识库记录
This commit is contained in:
1 parent
dd3fe3033b
commit
3bcc9ebb99
63 files changed
+4496
-2647
No files matched your search
@@ -24,13 +24,15 @@ bun run dev:worker
|
||||
|
||||
环境变量(`apps/worker/wrangler.toml` 或 `wrangler secret put`):
|
||||
|
||||
- `ADMIN_PASSWORD` 管理员密码
|
||||
- `SESSION_TTL_HOURS` 管理会话时长(小时)
|
||||
- `LOG_RETENTION_DAYS` 日志保留天数
|
||||
- `CORS_ORIGIN` 允许的管理台来源
|
||||
- `PROXY_RETRY_ROUNDS` 代理失败轮询次数(默认 2)
|
||||
- `PROXY_RETRY_DELAY_MS` 轮询间隔(毫秒,默认 200)
|
||||
- `NEW_API_ADMIN_TOKEN` New API 兼容接口的管理员令牌
|
||||
|
||||
系统设置(管理台 → 系统设置):
|
||||
|
||||
- 日志保留天数(默认 30)
|
||||
- 会话时长(小时,默认 12)
|
||||
- 管理员密码(首次登录自动初始化,可在系统设置中修改)
|
||||
|
||||
### 3) Admin UI
|
||||
|
||||
@@ -56,7 +58,7 @@ bun run fix
|
||||
为了支持 all-api-hub 等插件同步渠道,Worker 提供 New API 风格的渠道管理接口:
|
||||
|
||||
- 路径前缀:`/api/channel`
|
||||
- 认证方式:`Authorization: Bearer {NEW_API_ADMIN_TOKEN}`(或管理台登录 token)
|
||||
- 认证方式:`Authorization: Bearer {管理员密码}`(或管理台登录 token)
|
||||
- 可选请求头:`New-Api-User: 1`
|
||||
|
||||
支持的常用接口:
|
||||
@@ -79,6 +81,14 @@ bun run fix
|
||||
bun run --filter new-api-lite-worker db:migrate
|
||||
```
|
||||
|
||||
## 忘记管理员密码
|
||||
|
||||
管理员密码存储为哈希,可通过删除设置记录来重置,下一次登录会用输入的密码重新初始化:
|
||||
|
||||
```bash
|
||||
bunx wrangler d1 execute DB --command "DELETE FROM settings WHERE key = 'admin_password_hash';"
|
||||
```
|
||||
|
||||
## 生产部署
|
||||
|
||||
### 1) 准备 Cloudflare 资源
|
||||
@@ -89,12 +99,7 @@ bun run --filter new-api-lite-worker db:migrate
|
||||
bunx wrangler d1 create new_api_lite
|
||||
```
|
||||
|
||||
2. 设置 Worker 变量与密钥(推荐用 secret):
|
||||
|
||||
```bash
|
||||
bunx wrangler secret put ADMIN_PASSWORD
|
||||
bunx wrangler secret put NEW_API_ADMIN_TOKEN
|
||||
```
|
||||
2. 设置 Worker 变量与密钥(如 CORS_ORIGIN):
|
||||
|
||||
其他变量可继续放在 `apps/worker/wrangler.toml` 的 `[vars]` 中,`CORS_ORIGIN` 需指向管理台域名。
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<div id="app"></div>
|
||||
<script
|
||||
type="module"
|
||||
src="/src/main.ts"
|
||||
src="/src/App.tsx"
|
||||
></script>
|
||||
</body>
|
||||
</html>
|
||||
+18
-12
@@ -1,14 +1,20 @@
|
||||
{
|
||||
"name": "new-api-lite-admin",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.0"
|
||||
}
|
||||
"name": "new-api-lite-admin",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.11.9"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,566 @@
|
||||
import "./styles.css";
|
||||
import {
|
||||
render,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "hono/jsx/dom";
|
||||
import { createApiFetch } from "./core/api";
|
||||
import {
|
||||
initialChannelForm,
|
||||
initialData,
|
||||
initialSettingsForm,
|
||||
tabs,
|
||||
} from "./core/constants";
|
||||
import type {
|
||||
AdminData,
|
||||
Channel,
|
||||
ChannelForm,
|
||||
DashboardData,
|
||||
Settings,
|
||||
SettingsForm,
|
||||
TabId,
|
||||
Token,
|
||||
UsageLog,
|
||||
} from "./core/types";
|
||||
import { toggleStatus } from "./core/utils";
|
||||
import { AppLayout } from "./features/AppLayout";
|
||||
import { ChannelsView } from "./features/ChannelsView";
|
||||
import { DashboardView } from "./features/DashboardView";
|
||||
import { LoginView } from "./features/LoginView";
|
||||
import { ModelsView } from "./features/ModelsView";
|
||||
import { SettingsView } from "./features/SettingsView";
|
||||
import { TokensView } from "./features/TokensView";
|
||||
import { UsageView } from "./features/UsageView";
|
||||
|
||||
const root = document.querySelector<HTMLDivElement>("#app");
|
||||
|
||||
if (!root) {
|
||||
throw new Error("Missing #app root");
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the admin console application.
|
||||
*
|
||||
* Returns:
|
||||
* Root application JSX element.
|
||||
*/
|
||||
const App = () => {
|
||||
const [token, setToken] = useState<string | null>(() =>
|
||||
localStorage.getItem("admin_token"),
|
||||
);
|
||||
const [activeTab, setActiveTab] = useState<TabId>("dashboard");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [notice, setNotice] = useState("");
|
||||
const [data, setData] = useState<AdminData>(initialData);
|
||||
const [settingsForm, setSettingsForm] =
|
||||
useState<SettingsForm>(initialSettingsForm);
|
||||
const [channelPage, setChannelPage] = useState(1);
|
||||
const [channelPageSize, setChannelPageSize] = useState(10);
|
||||
const [editingChannel, setEditingChannel] = useState<Channel | null>(null);
|
||||
const [channelForm, setChannelForm] = useState<ChannelForm>(() => ({
|
||||
...initialChannelForm,
|
||||
}));
|
||||
const [isChannelModalOpen, setChannelModalOpen] = useState(false);
|
||||
|
||||
const updateToken = useCallback((next: string | null) => {
|
||||
setToken(next);
|
||||
if (next) {
|
||||
localStorage.setItem("admin_token", next);
|
||||
} else {
|
||||
localStorage.removeItem("admin_token");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const apiFetch = useMemo(
|
||||
() => createApiFetch(token, () => updateToken(null)),
|
||||
[token, updateToken],
|
||||
);
|
||||
|
||||
const loadDashboard = useCallback(async () => {
|
||||
const dashboard = await apiFetch<DashboardData>("/api/dashboard");
|
||||
setData((prev) => ({ ...prev, dashboard }));
|
||||
}, [apiFetch]);
|
||||
|
||||
const loadChannels = useCallback(async () => {
|
||||
const result = await apiFetch<{ channels: Channel[] }>("/api/channels");
|
||||
setData((prev) => ({ ...prev, channels: result.channels }));
|
||||
}, [apiFetch]);
|
||||
|
||||
const loadModels = useCallback(async () => {
|
||||
const result = await apiFetch<{
|
||||
models: Array<{
|
||||
id: string;
|
||||
channels: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
}>("/api/models");
|
||||
setData((prev) => ({ ...prev, models: result.models }));
|
||||
}, [apiFetch]);
|
||||
|
||||
const loadTokens = useCallback(async () => {
|
||||
const result = await apiFetch<{ tokens: Token[] }>("/api/tokens");
|
||||
setData((prev) => ({ ...prev, tokens: result.tokens }));
|
||||
}, [apiFetch]);
|
||||
|
||||
const loadUsage = useCallback(async () => {
|
||||
const result = await apiFetch<{ logs: UsageLog[] }>("/api/usage?limit=50");
|
||||
setData((prev) => ({ ...prev, usage: result.logs }));
|
||||
}, [apiFetch]);
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
const settings = await apiFetch<Settings>("/api/settings");
|
||||
setData((prev) => ({ ...prev, settings }));
|
||||
}, [apiFetch]);
|
||||
|
||||
const loadTab = useCallback(
|
||||
async (tabId: TabId) => {
|
||||
setLoading(true);
|
||||
setNotice("");
|
||||
try {
|
||||
if (tabId === "dashboard") {
|
||||
await loadDashboard();
|
||||
}
|
||||
if (tabId === "channels") {
|
||||
await loadChannels();
|
||||
}
|
||||
if (tabId === "models") {
|
||||
await loadModels();
|
||||
}
|
||||
if (tabId === "tokens") {
|
||||
await loadTokens();
|
||||
}
|
||||
if (tabId === "usage") {
|
||||
await loadUsage();
|
||||
}
|
||||
if (tabId === "settings") {
|
||||
await loadSettings();
|
||||
}
|
||||
} catch (error) {
|
||||
setNotice((error as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
loadChannels,
|
||||
loadDashboard,
|
||||
loadModels,
|
||||
loadSettings,
|
||||
loadTokens,
|
||||
loadUsage,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
loadTab(activeTab);
|
||||
}
|
||||
}, [token, activeTab, loadTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data.settings) {
|
||||
return;
|
||||
}
|
||||
setSettingsForm({
|
||||
log_retention_days: String(data.settings.log_retention_days ?? 30),
|
||||
session_ttl_hours: String(data.settings.session_ttl_hours ?? 12),
|
||||
admin_password: "",
|
||||
});
|
||||
}, [data.settings]);
|
||||
|
||||
const handleLogin = useCallback(
|
||||
async (event: Event) => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget as HTMLFormElement;
|
||||
const formData = new FormData(form);
|
||||
const password = String(formData.get("password") ?? "");
|
||||
try {
|
||||
const result = await apiFetch<{ token: string }>("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
updateToken(result.token);
|
||||
setNotice("");
|
||||
} catch (error) {
|
||||
setNotice((error as Error).message);
|
||||
}
|
||||
},
|
||||
[apiFetch, updateToken],
|
||||
);
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
await apiFetch("/api/auth/logout", { method: "POST" }).catch(() => null);
|
||||
updateToken(null);
|
||||
}, [apiFetch, updateToken]);
|
||||
|
||||
const handleChannelFormChange = useCallback((patch: Partial<ChannelForm>) => {
|
||||
setChannelForm((prev) => ({ ...prev, ...patch }));
|
||||
}, []);
|
||||
|
||||
const handleSettingsFormChange = useCallback(
|
||||
(patch: Partial<SettingsForm>) => {
|
||||
setSettingsForm((prev) => ({ ...prev, ...patch }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleChannelPageChange = useCallback((next: number) => {
|
||||
setChannelPage(next);
|
||||
}, []);
|
||||
|
||||
const handleChannelPageSizeChange = useCallback((next: number) => {
|
||||
setChannelPageSize(next);
|
||||
setChannelPage(1);
|
||||
}, []);
|
||||
|
||||
const handleTabChange = useCallback((tabId: TabId) => {
|
||||
setActiveTab(tabId);
|
||||
}, []);
|
||||
|
||||
const closeChannelModal = useCallback(() => {
|
||||
setEditingChannel(null);
|
||||
setChannelForm({ ...initialChannelForm });
|
||||
setChannelModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const openChannelCreate = useCallback(() => {
|
||||
setEditingChannel(null);
|
||||
setChannelForm({ ...initialChannelForm });
|
||||
setChannelModalOpen(true);
|
||||
setNotice("");
|
||||
}, []);
|
||||
|
||||
const startChannelEdit = useCallback((channel: Channel) => {
|
||||
setEditingChannel(channel);
|
||||
setChannelForm({
|
||||
name: channel.name ?? "",
|
||||
base_url: channel.base_url ?? "",
|
||||
api_key: channel.api_key ?? "",
|
||||
weight: channel.weight ?? 1,
|
||||
});
|
||||
setChannelModalOpen(true);
|
||||
setNotice("");
|
||||
}, []);
|
||||
|
||||
const handleChannelSubmit = useCallback(
|
||||
async (event: Event) => {
|
||||
event.preventDefault();
|
||||
const channelName = channelForm.name.trim();
|
||||
const normalizedName = channelName.toLowerCase();
|
||||
const nameExists = data.channels.some(
|
||||
(channel) =>
|
||||
channel.name.trim().toLowerCase() === normalizedName &&
|
||||
channel.id !== editingChannel?.id,
|
||||
);
|
||||
if (nameExists) {
|
||||
setNotice("渠道名称已存在,请使用其他名称");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = {
|
||||
name: channelName,
|
||||
base_url: channelForm.base_url.trim(),
|
||||
api_key: channelForm.api_key.trim(),
|
||||
weight: Number(channelForm.weight),
|
||||
};
|
||||
if (editingChannel) {
|
||||
await apiFetch(`/api/channels/${editingChannel.id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
setNotice("渠道已更新");
|
||||
} else {
|
||||
await apiFetch("/api/channels", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
setNotice("渠道已创建");
|
||||
}
|
||||
closeChannelModal();
|
||||
await loadChannels();
|
||||
} catch (error) {
|
||||
setNotice((error as Error).message);
|
||||
}
|
||||
},
|
||||
[
|
||||
apiFetch,
|
||||
channelForm,
|
||||
closeChannelModal,
|
||||
data.channels,
|
||||
editingChannel,
|
||||
loadChannels,
|
||||
],
|
||||
);
|
||||
|
||||
const handleTokenSubmit = useCallback(
|
||||
async (event: Event) => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget as HTMLFormElement;
|
||||
const formData = new FormData(form);
|
||||
const payload = Object.fromEntries(formData.entries()) as Record<
|
||||
string,
|
||||
FormDataEntryValue
|
||||
>;
|
||||
try {
|
||||
const result = await apiFetch<{ token: string }>("/api/tokens", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: payload.name,
|
||||
quota_total: payload.quota_total
|
||||
? Number(payload.quota_total)
|
||||
: null,
|
||||
}),
|
||||
});
|
||||
setNotice(`新令牌: ${result.token}`);
|
||||
form.reset();
|
||||
await loadTokens();
|
||||
} catch (error) {
|
||||
setNotice((error as Error).message);
|
||||
}
|
||||
},
|
||||
[apiFetch, loadTokens],
|
||||
);
|
||||
|
||||
const handleSettingsSubmit = useCallback(
|
||||
async (event: Event) => {
|
||||
event.preventDefault();
|
||||
const retention = Number(settingsForm.log_retention_days);
|
||||
const sessionTtlHours = Number(settingsForm.session_ttl_hours);
|
||||
const payload: Record<string, number | string> = {
|
||||
log_retention_days: retention,
|
||||
session_ttl_hours: sessionTtlHours,
|
||||
};
|
||||
const password = settingsForm.admin_password.trim();
|
||||
if (password) {
|
||||
payload.admin_password = password;
|
||||
}
|
||||
try {
|
||||
await apiFetch("/api/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
await loadSettings();
|
||||
setSettingsForm((prev) => ({ ...prev, admin_password: "" }));
|
||||
setNotice("设置已更新");
|
||||
} catch (error) {
|
||||
setNotice((error as Error).message);
|
||||
}
|
||||
},
|
||||
[apiFetch, loadSettings, settingsForm],
|
||||
);
|
||||
|
||||
const handleChannelTest = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
const result = await apiFetch<{ models: Array<{ id: string }> }>(
|
||||
`/api/channels/${id}/test`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
await loadChannels();
|
||||
setNotice(`连通测试完成,模型数 ${result.models?.length ?? 0}`);
|
||||
} catch (error) {
|
||||
setNotice((error as Error).message);
|
||||
}
|
||||
},
|
||||
[apiFetch, loadChannels],
|
||||
);
|
||||
|
||||
const handleChannelDelete = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await apiFetch(`/api/channels/${id}`, { method: "DELETE" });
|
||||
await loadChannels();
|
||||
setNotice("渠道已删除");
|
||||
if (editingChannel?.id === id) {
|
||||
closeChannelModal();
|
||||
}
|
||||
} catch (error) {
|
||||
setNotice((error as Error).message);
|
||||
}
|
||||
},
|
||||
[apiFetch, closeChannelModal, editingChannel, loadChannels],
|
||||
);
|
||||
|
||||
const handleChannelToggle = useCallback(
|
||||
async (id: string, status: string) => {
|
||||
try {
|
||||
const next = toggleStatus(status);
|
||||
await apiFetch(`/api/channels/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ status: next }),
|
||||
});
|
||||
await loadChannels();
|
||||
setNotice(`渠道已${next === "active" ? "启用" : "停用"}`);
|
||||
} catch (error) {
|
||||
setNotice((error as Error).message);
|
||||
}
|
||||
},
|
||||
[apiFetch, loadChannels],
|
||||
);
|
||||
|
||||
const handleTokenDelete = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await apiFetch(`/api/tokens/${id}`, { method: "DELETE" });
|
||||
await loadTokens();
|
||||
setNotice("令牌已删除");
|
||||
} catch (error) {
|
||||
setNotice((error as Error).message);
|
||||
}
|
||||
},
|
||||
[apiFetch, loadTokens],
|
||||
);
|
||||
|
||||
const handleTokenReveal = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
const result = await apiFetch<{ token: string | null }>(
|
||||
`/api/tokens/${id}/reveal`,
|
||||
);
|
||||
setNotice(result.token ? `令牌: ${result.token}` : "未找到令牌");
|
||||
} catch (error) {
|
||||
setNotice((error as Error).message);
|
||||
}
|
||||
},
|
||||
[apiFetch],
|
||||
);
|
||||
|
||||
const handleTokenToggle = useCallback(
|
||||
async (id: string, status: string) => {
|
||||
try {
|
||||
const next = toggleStatus(status);
|
||||
await apiFetch(`/api/tokens/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ status: next }),
|
||||
});
|
||||
await loadTokens();
|
||||
setNotice(`令牌已${next === "active" ? "启用" : "停用"}`);
|
||||
} catch (error) {
|
||||
setNotice((error as Error).message);
|
||||
}
|
||||
},
|
||||
[apiFetch, loadTokens],
|
||||
);
|
||||
|
||||
const handleUsageRefresh = useCallback(async () => {
|
||||
try {
|
||||
await loadUsage();
|
||||
setNotice("日志已刷新");
|
||||
} catch (error) {
|
||||
setNotice((error as Error).message);
|
||||
}
|
||||
}, [loadUsage]);
|
||||
|
||||
const channelTotal = data.channels.length;
|
||||
const channelTotalPages = useMemo(
|
||||
() => Math.max(1, Math.ceil(channelTotal / channelPageSize)),
|
||||
[channelTotal, channelPageSize],
|
||||
);
|
||||
const pagedChannels = useMemo(() => {
|
||||
const start = (channelPage - 1) * channelPageSize;
|
||||
return data.channels.slice(start, start + channelPageSize);
|
||||
}, [channelPage, channelPageSize, data.channels]);
|
||||
|
||||
useEffect(() => {
|
||||
setChannelPage((prev) => Math.min(prev, channelTotalPages));
|
||||
}, [channelTotalPages]);
|
||||
|
||||
const activeLabel = useMemo(
|
||||
() => tabs.find((tab) => tab.id === activeTab)?.label ?? "管理台",
|
||||
[activeTab],
|
||||
);
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
加载中...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (activeTab === "dashboard") {
|
||||
return <DashboardView dashboard={data.dashboard} />;
|
||||
}
|
||||
if (activeTab === "channels") {
|
||||
return (
|
||||
<ChannelsView
|
||||
channelForm={channelForm}
|
||||
channelPage={channelPage}
|
||||
channelPageSize={channelPageSize}
|
||||
channelTotal={channelTotal}
|
||||
channelTotalPages={channelTotalPages}
|
||||
pagedChannels={pagedChannels}
|
||||
editingChannel={editingChannel}
|
||||
isChannelModalOpen={isChannelModalOpen}
|
||||
onCreate={openChannelCreate}
|
||||
onCloseModal={closeChannelModal}
|
||||
onEdit={startChannelEdit}
|
||||
onSubmit={handleChannelSubmit}
|
||||
onTest={handleChannelTest}
|
||||
onToggle={handleChannelToggle}
|
||||
onDelete={handleChannelDelete}
|
||||
onPageChange={handleChannelPageChange}
|
||||
onPageSizeChange={handleChannelPageSizeChange}
|
||||
onFormChange={handleChannelFormChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (activeTab === "models") {
|
||||
return <ModelsView models={data.models} />;
|
||||
}
|
||||
if (activeTab === "tokens") {
|
||||
return (
|
||||
<TokensView
|
||||
tokens={data.tokens}
|
||||
onSubmit={handleTokenSubmit}
|
||||
onReveal={handleTokenReveal}
|
||||
onToggle={handleTokenToggle}
|
||||
onDelete={handleTokenDelete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (activeTab === "usage") {
|
||||
return <UsageView usage={data.usage} onRefresh={handleUsageRefresh} />;
|
||||
}
|
||||
if (activeTab === "settings") {
|
||||
return (
|
||||
<SettingsView
|
||||
settingsForm={settingsForm}
|
||||
adminPasswordSet={data.settings?.admin_password_set ?? false}
|
||||
onSubmit={handleSettingsSubmit}
|
||||
onFormChange={handleSettingsFormChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
未知模块
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="min-h-screen bg-linear-to-b from-white via-stone-50 to-stone-100 font-['IBM_Plex_Sans'] text-stone-900 antialiased">
|
||||
{token ? (
|
||||
<AppLayout
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
activeLabel={activeLabel}
|
||||
token={token}
|
||||
notice={notice}
|
||||
onTabChange={handleTabChange}
|
||||
onLogout={handleLogout}
|
||||
>
|
||||
{renderContent()}
|
||||
</AppLayout>
|
||||
) : (
|
||||
<LoginView notice={notice} onSubmit={handleLogin} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render(<App />, root);
|
||||
@@ -0,0 +1,40 @@
|
||||
import { apiBase } from "./constants";
|
||||
|
||||
export type ApiFetch = <T>(path: string, options?: RequestInit) => Promise<T>;
|
||||
|
||||
/**
|
||||
* Creates a typed API fetcher bound to the current auth token.
|
||||
*
|
||||
* Args:
|
||||
* token: Bearer token string or null.
|
||||
* onUnauthorized: Callback invoked on 401 responses.
|
||||
*
|
||||
* Returns:
|
||||
* A fetch function that wraps API calls with auth headers and errors.
|
||||
*/
|
||||
export const createApiFetch = (
|
||||
token: string | null,
|
||||
onUnauthorized: () => void,
|
||||
): ApiFetch => {
|
||||
return async <T>(path: string, options: RequestInit = {}): Promise<T> => {
|
||||
const headers = new Headers(options.headers ?? {});
|
||||
headers.set("Content-Type", "application/json");
|
||||
if (token) {
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
const response = await fetch(`${apiBase}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
onUnauthorized();
|
||||
}
|
||||
const payload = (await response.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
} | null;
|
||||
throw new Error(payload?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { AdminData, ChannelForm, SettingsForm, TabItem } from "./types";
|
||||
|
||||
export const apiBase = import.meta.env.VITE_API_BASE ?? "";
|
||||
|
||||
export const tabs: TabItem[] = [
|
||||
{ id: "dashboard", label: "数据面板" },
|
||||
{ id: "channels", label: "渠道管理" },
|
||||
{ id: "models", label: "模型广场" },
|
||||
{ id: "tokens", label: "令牌管理" },
|
||||
{ id: "usage", label: "使用日志" },
|
||||
{ id: "settings", label: "系统设置" },
|
||||
];
|
||||
|
||||
export const initialData: AdminData = {
|
||||
channels: [],
|
||||
tokens: [],
|
||||
models: [],
|
||||
usage: [],
|
||||
dashboard: null,
|
||||
settings: null,
|
||||
};
|
||||
|
||||
export const initialChannelForm: ChannelForm = {
|
||||
name: "",
|
||||
base_url: "",
|
||||
api_key: "",
|
||||
weight: 1,
|
||||
};
|
||||
|
||||
export const initialSettingsForm: SettingsForm = {
|
||||
log_retention_days: "30",
|
||||
session_ttl_hours: "12",
|
||||
admin_password: "",
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
export type Channel = {
|
||||
id: string;
|
||||
name: string;
|
||||
base_url: string;
|
||||
api_key: string;
|
||||
weight: number;
|
||||
status: string;
|
||||
models_json?: string;
|
||||
};
|
||||
|
||||
export type Token = {
|
||||
id: string;
|
||||
name: string;
|
||||
key_prefix: string;
|
||||
quota_total: number | null;
|
||||
quota_used: number;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UsageLog = {
|
||||
id: string;
|
||||
model: string;
|
||||
channel_id: string;
|
||||
channel_name?: string | null;
|
||||
token_id: string;
|
||||
token_name?: string | null;
|
||||
total_tokens: number;
|
||||
latency_ms: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type DashboardData = {
|
||||
summary: {
|
||||
total_requests: number;
|
||||
total_tokens: number;
|
||||
avg_latency: number;
|
||||
total_errors: number;
|
||||
};
|
||||
byDay: Array<{ day: string; requests: number; tokens: number }>;
|
||||
byModel: Array<{ model: string; requests: number; tokens: number }>;
|
||||
byChannel: Array<{ channel_name: string; requests: number; tokens: number }>;
|
||||
byToken: Array<{ token_name: string; requests: number; tokens: number }>;
|
||||
};
|
||||
|
||||
export type Settings = {
|
||||
log_retention_days: number;
|
||||
session_ttl_hours: number;
|
||||
admin_password_set?: boolean;
|
||||
};
|
||||
|
||||
export type ModelChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type ModelItem = {
|
||||
id: string;
|
||||
channels: ModelChannel[];
|
||||
};
|
||||
|
||||
export type AdminData = {
|
||||
channels: Channel[];
|
||||
tokens: Token[];
|
||||
models: ModelItem[];
|
||||
usage: UsageLog[];
|
||||
dashboard: DashboardData | null;
|
||||
settings: Settings | null;
|
||||
};
|
||||
|
||||
export type TabId =
|
||||
| "dashboard"
|
||||
| "channels"
|
||||
| "models"
|
||||
| "tokens"
|
||||
| "usage"
|
||||
| "settings";
|
||||
|
||||
export type TabItem = {
|
||||
id: TabId;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type ChannelForm = {
|
||||
name: string;
|
||||
base_url: string;
|
||||
api_key: string;
|
||||
weight: number;
|
||||
};
|
||||
|
||||
export type SettingsForm = {
|
||||
log_retention_days: string;
|
||||
session_ttl_hours: string;
|
||||
admin_password: string;
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Formats a datetime string for display.
|
||||
*
|
||||
* Args:
|
||||
* value: ISO datetime string or nullable value.
|
||||
*
|
||||
* Returns:
|
||||
* A human-friendly datetime string or "-".
|
||||
*/
|
||||
export const formatDateTime = (value?: string | null) => {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
return value.slice(0, 19).replace("T", " ");
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggles channel or token status between active and disabled.
|
||||
*
|
||||
* Args:
|
||||
* value: Current status value.
|
||||
*
|
||||
* Returns:
|
||||
* Next status value.
|
||||
*/
|
||||
export const toggleStatus = (value: string) =>
|
||||
value === "active" ? "disabled" : "active";
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { TabId, TabItem } from "../core/types";
|
||||
|
||||
type AppLayoutProps = {
|
||||
tabs: TabItem[];
|
||||
activeTab: TabId;
|
||||
activeLabel: string;
|
||||
token: string | null;
|
||||
notice: string;
|
||||
onTabChange: (tabId: TabId) => void;
|
||||
onLogout: () => void;
|
||||
children?: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the admin app layout.
|
||||
*
|
||||
* Args:
|
||||
* props: App layout props.
|
||||
*
|
||||
* Returns:
|
||||
* App shell JSX element.
|
||||
*/
|
||||
export const AppLayout = ({
|
||||
tabs,
|
||||
activeTab,
|
||||
activeLabel,
|
||||
token,
|
||||
notice,
|
||||
onTabChange,
|
||||
onLogout,
|
||||
children,
|
||||
}: AppLayoutProps) => (
|
||||
<div class="grid min-h-screen grid-cols-1 lg:grid-cols-[260px_1fr]">
|
||||
<aside class="border-b border-stone-200 bg-white px-5 py-8 lg:sticky lg:top-0 lg:h-screen lg:border-b-0 lg:border-r">
|
||||
<div class="mb-8 flex flex-col gap-1.5">
|
||||
<h2 class="font-['Space_Grotesk'] text-lg font-semibold tracking-tight text-stone-900">
|
||||
api-workers
|
||||
</h2>
|
||||
<span class="text-xs uppercase tracking-widest text-stone-500">
|
||||
console
|
||||
</span>
|
||||
</div>
|
||||
<nav class="flex flex-col gap-2.5">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
class={`flex h-11 w-full items-center rounded-xl px-3.5 py-2.5 text-left text-sm font-medium transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white ${
|
||||
activeTab === tab.id
|
||||
? "bg-stone-100 text-stone-900"
|
||||
: "text-stone-500 hover:bg-stone-100 hover:text-stone-900"
|
||||
}`}
|
||||
type="button"
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
<main class="px-6 pt-6 pb-16 sm:px-10 sm:pt-8">
|
||||
<div class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="font-['Space_Grotesk'] text-2xl tracking-tight text-stone-900">
|
||||
{activeLabel}
|
||||
</h1>
|
||||
<p class="text-sm text-stone-500">
|
||||
集中管理渠道、模型、令牌与使用情况。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="rounded-full bg-stone-100 px-2.5 py-1 text-xs text-stone-500">
|
||||
{token ? "已登录" : "未登录"}
|
||||
</span>
|
||||
<button
|
||||
class="h-11 rounded-lg border border-stone-200 bg-transparent px-4 py-2.5 text-sm font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{notice && (
|
||||
<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
|
||||
{notice}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,332 @@
|
||||
import type { Channel, ChannelForm } from "../core/types";
|
||||
|
||||
type ChannelsViewProps = {
|
||||
channelForm: ChannelForm;
|
||||
channelPage: number;
|
||||
channelPageSize: number;
|
||||
channelTotal: number;
|
||||
channelTotalPages: number;
|
||||
pagedChannels: Channel[];
|
||||
editingChannel: Channel | null;
|
||||
isChannelModalOpen: boolean;
|
||||
onCreate: () => void;
|
||||
onCloseModal: () => void;
|
||||
onEdit: (channel: Channel) => void;
|
||||
onSubmit: (event: Event) => void;
|
||||
onTest: (id: string) => void;
|
||||
onToggle: (id: string, status: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onPageChange: (next: number) => void;
|
||||
onPageSizeChange: (next: number) => void;
|
||||
onFormChange: (patch: Partial<ChannelForm>) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the channels management view.
|
||||
*
|
||||
* Args:
|
||||
* props: Channels view props.
|
||||
*
|
||||
* Returns:
|
||||
* Channels JSX element.
|
||||
*/
|
||||
export const ChannelsView = ({
|
||||
channelForm,
|
||||
channelPage,
|
||||
channelPageSize,
|
||||
channelTotal,
|
||||
channelTotalPages,
|
||||
pagedChannels,
|
||||
editingChannel,
|
||||
isChannelModalOpen,
|
||||
onCreate,
|
||||
onCloseModal,
|
||||
onEdit,
|
||||
onSubmit,
|
||||
onTest,
|
||||
onToggle,
|
||||
onDelete,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
onFormChange,
|
||||
}: ChannelsViewProps) => {
|
||||
const isEditing = Boolean(editingChannel);
|
||||
return (
|
||||
<div class="space-y-5">
|
||||
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="mb-1 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
|
||||
渠道列表
|
||||
</h3>
|
||||
<p class="text-xs text-stone-500">状态、权重与操作入口集中展示。</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
class="h-9 rounded-full bg-stone-900 px-4 text-xs font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
type="button"
|
||||
onClick={onCreate}
|
||||
>
|
||||
新增渠道
|
||||
</button>
|
||||
<span class="rounded-full bg-stone-100 px-2.5 py-1 text-xs text-stone-500">
|
||||
{channelTotal} 条
|
||||
</span>
|
||||
<label class="flex items-center gap-2 rounded-full border border-stone-200 bg-stone-50 px-3 py-1 text-xs text-stone-500">
|
||||
每页
|
||||
<select
|
||||
class="rounded-full border border-stone-200 bg-white px-2 py-0.5 text-xs text-stone-700 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
|
||||
value={channelPageSize}
|
||||
onChange={(event) => {
|
||||
onPageSizeChange(
|
||||
Number((event.currentTarget as HTMLSelectElement).value),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="10">10</option>
|
||||
<option value="20">20</option>
|
||||
<option value="50">50</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 overflow-hidden rounded-xl border border-stone-200">
|
||||
<div class="grid grid-cols-[minmax(0,1.6fr)_minmax(0,0.7fr)_minmax(0,0.6fr)_minmax(0,1.6fr)] gap-3 bg-stone-50 px-4 py-3 text-xs uppercase tracking-widest text-stone-500">
|
||||
<div>渠道</div>
|
||||
<div>状态</div>
|
||||
<div>权重</div>
|
||||
<div>操作</div>
|
||||
</div>
|
||||
{pagedChannels.length === 0 ? (
|
||||
<div class="px-4 py-10 text-center text-sm text-stone-500">
|
||||
暂无渠道,请先创建。
|
||||
</div>
|
||||
) : (
|
||||
<div class="divide-y divide-stone-100">
|
||||
{pagedChannels.map((channel) => {
|
||||
const isActive = channel.status === "active";
|
||||
return (
|
||||
<div
|
||||
class={`grid grid-cols-[minmax(0,1.6fr)_minmax(0,0.7fr)_minmax(0,0.6fr)_minmax(0,1.6fr)] items-center gap-3 px-4 py-4 text-sm ${
|
||||
editingChannel?.id === channel.id
|
||||
? "bg-amber-50/60"
|
||||
: "bg-white"
|
||||
}`}
|
||||
key={channel.id}
|
||||
>
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<span class="truncate font-semibold text-stone-900">
|
||||
{channel.name}
|
||||
</span>
|
||||
<span
|
||||
class="truncate text-xs text-stone-500"
|
||||
title={channel.base_url}
|
||||
>
|
||||
{channel.base_url}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
class={`inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-semibold ${
|
||||
isActive
|
||||
? "border-emerald-100 bg-emerald-50 text-emerald-600"
|
||||
: "border-stone-200 bg-stone-100 text-stone-500"
|
||||
}`}
|
||||
>
|
||||
{isActive ? "启用" : "禁用"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm font-semibold text-stone-700">
|
||||
{channel.weight}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
class="h-9 rounded-full border border-stone-200 bg-stone-100 px-3 text-xs font-semibold text-stone-900 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
onClick={() => onTest(channel.id)}
|
||||
>
|
||||
连通测试
|
||||
</button>
|
||||
<button
|
||||
class="h-9 rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
onClick={() => onToggle(channel.id, channel.status)}
|
||||
>
|
||||
{isActive ? "禁用" : "启用"}
|
||||
</button>
|
||||
<button
|
||||
class="h-9 rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
onClick={() => onEdit(channel)}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
class="h-9 rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
onClick={() => onDelete(channel.id)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap items-center justify-between gap-3 text-xs text-stone-500">
|
||||
<div>
|
||||
第 {channelPage} / {channelTotalPages} 页 · 共 {channelTotal} 条
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="h-9 rounded-full border border-stone-200 bg-white px-4 text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
disabled={channelPage <= 1}
|
||||
onClick={() => onPageChange(Math.max(1, channelPage - 1))}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
class="h-9 rounded-full border border-stone-200 bg-white px-4 text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
disabled={channelPage >= channelTotalPages}
|
||||
onClick={() =>
|
||||
onPageChange(Math.min(channelTotalPages, channelPage + 1))
|
||||
}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isChannelModalOpen && (
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-stone-900/40 px-4 py-8">
|
||||
<div class="w-full max-w-xl rounded-2xl border border-stone-200 bg-white p-6 shadow-2xl">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="mb-1 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
|
||||
{isEditing ? "编辑渠道" : "新增渠道"}
|
||||
</h3>
|
||||
<p class="text-xs text-stone-500">
|
||||
{isEditing
|
||||
? `正在编辑:${editingChannel?.name ?? ""}`
|
||||
: "填写渠道信息并保存。"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="h-9 rounded-full border border-stone-200 bg-stone-50 px-3 text-xs font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
type="button"
|
||||
onClick={onCloseModal}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<form class="mt-4 grid gap-3.5" onSubmit={onSubmit}>
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
|
||||
for="channel-name"
|
||||
>
|
||||
名称
|
||||
</label>
|
||||
<input
|
||||
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
|
||||
id="channel-name"
|
||||
name="name"
|
||||
value={channelForm.name}
|
||||
required
|
||||
onInput={(event) =>
|
||||
onFormChange({
|
||||
name: (event.currentTarget as HTMLInputElement).value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
|
||||
for="channel-base"
|
||||
>
|
||||
Base URL
|
||||
</label>
|
||||
<input
|
||||
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
|
||||
id="channel-base"
|
||||
name="base_url"
|
||||
placeholder="https://api.openai.com"
|
||||
value={channelForm.base_url}
|
||||
required
|
||||
onInput={(event) =>
|
||||
onFormChange({
|
||||
base_url: (event.currentTarget as HTMLInputElement).value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
|
||||
for="channel-key"
|
||||
>
|
||||
API Key
|
||||
</label>
|
||||
<input
|
||||
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
|
||||
id="channel-key"
|
||||
name="api_key"
|
||||
value={channelForm.api_key}
|
||||
required
|
||||
onInput={(event) =>
|
||||
onFormChange({
|
||||
api_key: (event.currentTarget as HTMLInputElement).value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
|
||||
for="channel-weight"
|
||||
>
|
||||
权重
|
||||
</label>
|
||||
<input
|
||||
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
|
||||
id="channel-weight"
|
||||
name="weight"
|
||||
type="number"
|
||||
min="1"
|
||||
value={channelForm.weight}
|
||||
onInput={(event) =>
|
||||
onFormChange({
|
||||
weight: Number(
|
||||
(event.currentTarget as HTMLInputElement).value || 0,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-end gap-2 pt-2">
|
||||
<button
|
||||
class="h-10 rounded-full border border-stone-200 bg-stone-50 px-4 text-xs font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
type="button"
|
||||
onClick={onCloseModal}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="h-10 rounded-full bg-stone-900 px-5 text-xs font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
type="submit"
|
||||
>
|
||||
{isEditing ? "保存修改" : "创建渠道"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,222 @@
|
||||
import type { DashboardData } from "../core/types";
|
||||
|
||||
type DashboardViewProps = {
|
||||
dashboard: DashboardData | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the dashboard summary and charts.
|
||||
*
|
||||
* Args:
|
||||
* props: Dashboard view props.
|
||||
*
|
||||
* Returns:
|
||||
* Dashboard JSX element.
|
||||
*/
|
||||
export const DashboardView = ({ dashboard }: DashboardViewProps) => {
|
||||
if (!dashboard) {
|
||||
return (
|
||||
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
暂无数据
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const errorRate = dashboard.summary.total_requests
|
||||
? Math.round(
|
||||
(dashboard.summary.total_errors / dashboard.summary.total_requests) *
|
||||
100,
|
||||
)
|
||||
: 0;
|
||||
return (
|
||||
<div class="space-y-5">
|
||||
<div class="grid grid-cols-1 gap-5 lg:grid-cols-3">
|
||||
<div class="flex flex-col gap-1.5 rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<span class="rounded-full bg-stone-100 px-2.5 py-1 text-xs text-stone-500">
|
||||
总请求
|
||||
</span>
|
||||
<div class="text-2xl font-semibold text-stone-900">
|
||||
{dashboard.summary.total_requests}
|
||||
</div>
|
||||
<span class="font-['Space_Grotesk'] text-xs text-stone-500">
|
||||
最近窗口
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5 rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<span class="rounded-full bg-stone-100 px-2.5 py-1 text-xs text-stone-500">
|
||||
总 Tokens
|
||||
</span>
|
||||
<div class="text-2xl font-semibold text-stone-900">
|
||||
{dashboard.summary.total_tokens}
|
||||
</div>
|
||||
<span class="font-['Space_Grotesk'] text-xs text-stone-500">
|
||||
累计消耗
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5 rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<span class="rounded-full bg-stone-100 px-2.5 py-1 text-xs text-stone-500">
|
||||
错误率
|
||||
</span>
|
||||
<div class="text-2xl font-semibold text-stone-900">{errorRate}%</div>
|
||||
<span class="font-['Space_Grotesk'] text-xs text-stone-500">
|
||||
平均延迟 {Math.round(dashboard.summary.avg_latency)}ms
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
||||
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="mb-0 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
|
||||
按日趋势
|
||||
</h3>
|
||||
</div>
|
||||
<table class="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
|
||||
日期
|
||||
</th>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
|
||||
请求
|
||||
</th>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
|
||||
Tokens
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dashboard.byDay.map((row) => (
|
||||
<tr class="hover:bg-stone-50" key={row.day}>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
|
||||
{row.day}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
|
||||
{row.requests}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
|
||||
{row.tokens}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="mb-0 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
|
||||
模型排行
|
||||
</h3>
|
||||
</div>
|
||||
<table class="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
|
||||
模型
|
||||
</th>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
|
||||
请求
|
||||
</th>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
|
||||
Tokens
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dashboard.byModel.map((row) => (
|
||||
<tr class="hover:bg-stone-50" key={row.model}>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
|
||||
{row.model ?? "-"}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
|
||||
{row.requests}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
|
||||
{row.tokens}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
||||
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="mb-0 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
|
||||
渠道贡献
|
||||
</h3>
|
||||
</div>
|
||||
<table class="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
|
||||
渠道
|
||||
</th>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
|
||||
请求
|
||||
</th>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
|
||||
Tokens
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dashboard.byChannel.map((row) => (
|
||||
<tr
|
||||
class="hover:bg-stone-50"
|
||||
key={row.channel_name ?? "unknown"}
|
||||
>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
|
||||
{row.channel_name ?? "-"}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
|
||||
{row.requests}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
|
||||
{row.tokens}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="mb-0 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
|
||||
令牌贡献
|
||||
</h3>
|
||||
</div>
|
||||
<table class="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
|
||||
令牌
|
||||
</th>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
|
||||
请求
|
||||
</th>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
|
||||
Tokens
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dashboard.byToken.map((row) => (
|
||||
<tr class="hover:bg-stone-50" key={row.token_name ?? "unknown"}>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
|
||||
{row.token_name ?? "-"}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
|
||||
{row.requests}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
|
||||
{row.tokens}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
type LoginViewProps = {
|
||||
notice: string;
|
||||
onSubmit: (event: Event) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the admin login view.
|
||||
*
|
||||
* Args:
|
||||
* props: Login view props.
|
||||
*
|
||||
* Returns:
|
||||
* Login JSX element.
|
||||
*/
|
||||
export const LoginView = ({ notice, onSubmit }: LoginViewProps) => (
|
||||
<div class="mx-auto mt-24 max-w-md rounded-2xl border border-stone-200 bg-white p-8 shadow-lg">
|
||||
<h1 class="mb-2 font-['Space_Grotesk'] text-2xl tracking-tight text-stone-900">
|
||||
api-workers
|
||||
</h1>
|
||||
<p class="text-sm text-stone-500">请输入管理员密码登录管理台。</p>
|
||||
<form class="mt-6 grid gap-4" onSubmit={onSubmit}>
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
|
||||
for="password"
|
||||
>
|
||||
管理员密码
|
||||
</label>
|
||||
<input
|
||||
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="h-11 rounded-lg bg-stone-900 px-4 py-2.5 text-sm font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="submit"
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
</form>
|
||||
{notice && (
|
||||
<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
|
||||
{notice}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ModelItem } from "../core/types";
|
||||
|
||||
type ModelsViewProps = {
|
||||
models: ModelItem[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the models view.
|
||||
*
|
||||
* Args:
|
||||
* props: Models view props.
|
||||
*
|
||||
* Returns:
|
||||
* Models JSX element.
|
||||
*/
|
||||
export const ModelsView = ({ models }: ModelsViewProps) => (
|
||||
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="mb-0 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
|
||||
模型广场
|
||||
</h3>
|
||||
<span class="rounded-full bg-stone-100 px-2.5 py-1 text-xs text-stone-500">
|
||||
{models.length} 个模型
|
||||
</span>
|
||||
</div>
|
||||
<table class="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
|
||||
模型
|
||||
</th>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
|
||||
渠道
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{models.map((model) => (
|
||||
<tr class="hover:bg-stone-50" key={model.id}>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
|
||||
{model.id}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
|
||||
{model.channels.map((channel) => channel.name).join(" / ")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { SettingsForm } from "../core/types";
|
||||
|
||||
type SettingsViewProps = {
|
||||
settingsForm: SettingsForm;
|
||||
adminPasswordSet: boolean;
|
||||
onSubmit: (event: Event) => void;
|
||||
onFormChange: (patch: Partial<SettingsForm>) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the settings view.
|
||||
*
|
||||
* Args:
|
||||
* props: Settings view props.
|
||||
*
|
||||
* Returns:
|
||||
* Settings JSX element.
|
||||
*/
|
||||
export const SettingsView = ({
|
||||
settingsForm,
|
||||
adminPasswordSet,
|
||||
onSubmit,
|
||||
onFormChange,
|
||||
}: SettingsViewProps) => (
|
||||
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="mb-0 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
|
||||
系统设置
|
||||
</h3>
|
||||
</div>
|
||||
<form class="grid gap-3.5 lg:grid-cols-2" onSubmit={onSubmit}>
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
|
||||
for="retention"
|
||||
>
|
||||
日志保留天数
|
||||
</label>
|
||||
<input
|
||||
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
|
||||
id="retention"
|
||||
name="log_retention_days"
|
||||
type="number"
|
||||
min="1"
|
||||
value={settingsForm.log_retention_days}
|
||||
onInput={(event) => {
|
||||
const target = event.currentTarget as HTMLInputElement | null;
|
||||
onFormChange({
|
||||
log_retention_days: target?.value ?? "",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
|
||||
for="session-ttl"
|
||||
>
|
||||
会话时长(小时)
|
||||
</label>
|
||||
<input
|
||||
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
|
||||
id="session-ttl"
|
||||
name="session_ttl_hours"
|
||||
type="number"
|
||||
min="1"
|
||||
value={settingsForm.session_ttl_hours}
|
||||
onInput={(event) => {
|
||||
const target = event.currentTarget as HTMLInputElement | null;
|
||||
onFormChange({
|
||||
session_ttl_hours: target?.value ?? "",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="lg:col-span-2">
|
||||
<label
|
||||
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
|
||||
for="admin-password"
|
||||
>
|
||||
管理员密码
|
||||
</label>
|
||||
<input
|
||||
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
|
||||
id="admin-password"
|
||||
name="admin_password"
|
||||
type="password"
|
||||
placeholder={
|
||||
adminPasswordSet
|
||||
? "已设置,留空则不修改"
|
||||
: "未设置,保存后即为登录密码"
|
||||
}
|
||||
value={settingsForm.admin_password}
|
||||
onInput={(event) => {
|
||||
const target = event.currentTarget as HTMLInputElement | null;
|
||||
onFormChange({
|
||||
admin_password: target?.value ?? "",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<p class="mt-1 text-xs text-stone-500">
|
||||
密码状态:{adminPasswordSet ? "已设置" : "未设置"}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-end lg:col-span-2">
|
||||
<button
|
||||
class="h-11 rounded-lg bg-stone-900 px-4 py-2.5 text-sm font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="submit"
|
||||
>
|
||||
保存设置
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { Token } from "../core/types";
|
||||
|
||||
type TokensViewProps = {
|
||||
tokens: Token[];
|
||||
onSubmit: (event: Event) => void;
|
||||
onReveal: (id: string) => void;
|
||||
onToggle: (id: string, status: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the tokens management view.
|
||||
*
|
||||
* Args:
|
||||
* props: Tokens view props.
|
||||
*
|
||||
* Returns:
|
||||
* Tokens JSX element.
|
||||
*/
|
||||
export const TokensView = ({
|
||||
tokens,
|
||||
onSubmit,
|
||||
onReveal,
|
||||
onToggle,
|
||||
onDelete,
|
||||
}: TokensViewProps) => (
|
||||
<div class="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
||||
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="mb-0 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
|
||||
生成令牌
|
||||
</h3>
|
||||
</div>
|
||||
<form class="grid gap-3.5" onSubmit={onSubmit}>
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
|
||||
for="token-name"
|
||||
>
|
||||
名称
|
||||
</label>
|
||||
<input
|
||||
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
|
||||
id="token-name"
|
||||
name="name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
|
||||
for="token-quota"
|
||||
>
|
||||
额度(可选)
|
||||
</label>
|
||||
<input
|
||||
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
|
||||
id="token-quota"
|
||||
name="quota_total"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="留空表示无限"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="h-11 rounded-lg bg-stone-900 px-4 py-2.5 text-sm font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="submit"
|
||||
>
|
||||
生成令牌
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="mb-0 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
|
||||
令牌列表
|
||||
</h3>
|
||||
</div>
|
||||
<table class="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
|
||||
名称
|
||||
</th>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
|
||||
状态
|
||||
</th>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
|
||||
已用/额度
|
||||
</th>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tokens.map((tokenItem) => (
|
||||
<tr class="hover:bg-stone-50" key={tokenItem.id}>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
|
||||
{tokenItem.name}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
|
||||
{tokenItem.status}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
|
||||
{tokenItem.quota_used} / {tokenItem.quota_total ?? "∞"}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
class="h-11 rounded-lg border border-stone-200 bg-stone-100 px-4 py-2.5 text-sm font-semibold text-stone-900 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
onClick={() => onReveal(tokenItem.id)}
|
||||
>
|
||||
查看
|
||||
</button>
|
||||
<button
|
||||
class="h-11 rounded-lg border border-stone-200 bg-stone-100 px-4 py-2.5 text-sm font-semibold text-stone-900 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
onClick={() => onToggle(tokenItem.id, tokenItem.status)}
|
||||
>
|
||||
切换
|
||||
</button>
|
||||
<button
|
||||
class="h-11 rounded-lg border border-stone-200 bg-transparent px-4 py-2.5 text-sm font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
onClick={() => onDelete(tokenItem.id)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { UsageLog } from "../core/types";
|
||||
import { formatDateTime } from "../core/utils";
|
||||
|
||||
type UsageViewProps = {
|
||||
usage: UsageLog[];
|
||||
onRefresh: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the usage logs view.
|
||||
*
|
||||
* Args:
|
||||
* props: Usage view props.
|
||||
*
|
||||
* Returns:
|
||||
* Usage JSX element.
|
||||
*/
|
||||
export const UsageView = ({ usage, onRefresh }: UsageViewProps) => (
|
||||
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="mb-0 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
|
||||
使用日志
|
||||
</h3>
|
||||
<button
|
||||
class="h-11 rounded-lg border border-stone-200 bg-stone-100 px-4 py-2.5 text-sm font-semibold text-stone-900 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<table class="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
|
||||
时间
|
||||
</th>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
|
||||
模型
|
||||
</th>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
|
||||
渠道
|
||||
</th>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
|
||||
Tokens
|
||||
</th>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
|
||||
延迟
|
||||
</th>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-xs uppercase tracking-widest text-stone-500">
|
||||
状态
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{usage.map((log) => (
|
||||
<tr class="hover:bg-stone-50" key={log.id}>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
|
||||
{formatDateTime(log.created_at)}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
|
||||
{log.model ?? "-"}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
|
||||
{log.channel_name ?? log.channel_id ?? "-"}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
|
||||
{log.total_tokens ?? 0}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
|
||||
{log.latency_ms ?? 0} ms
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-sm text-stone-700">
|
||||
{log.status}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
@@ -1,718 +0,0 @@
|
||||
import './styles.css';
|
||||
|
||||
type Channel = {
|
||||
id: string;
|
||||
name: string;
|
||||
base_url: string;
|
||||
api_key: string;
|
||||
weight: number;
|
||||
status: string;
|
||||
rate_limit: number;
|
||||
models_json?: string;
|
||||
};
|
||||
|
||||
type Token = {
|
||||
id: string;
|
||||
name: string;
|
||||
key_prefix: string;
|
||||
quota_total: number | null;
|
||||
quota_used: number;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type UsageLog = {
|
||||
id: string;
|
||||
model: string;
|
||||
channel_id: string;
|
||||
channel_name?: string | null;
|
||||
token_id: string;
|
||||
token_name?: string | null;
|
||||
total_tokens: number;
|
||||
latency_ms: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
type DashboardData = {
|
||||
summary: {
|
||||
total_requests: number;
|
||||
total_tokens: number;
|
||||
avg_latency: number;
|
||||
total_errors: number;
|
||||
};
|
||||
byDay: Array<{ day: string; requests: number; tokens: number }>;
|
||||
byModel: Array<{ model: string; requests: number; tokens: number }>;
|
||||
byChannel: Array<{ channel_name: string; requests: number; tokens: number }>;
|
||||
byToken: Array<{ token_name: string; requests: number; tokens: number }>;
|
||||
};
|
||||
|
||||
type Settings = {
|
||||
log_retention_days: number;
|
||||
};
|
||||
|
||||
const root = document.querySelector<HTMLDivElement>('#app');
|
||||
const apiBase = import.meta.env.VITE_API_BASE ?? '';
|
||||
const tabs = [
|
||||
{ id: 'dashboard', label: '数据面板' },
|
||||
{ id: 'channels', label: '渠道管理' },
|
||||
{ id: 'models', label: '模型广场' },
|
||||
{ id: 'tokens', label: '令牌管理' },
|
||||
{ id: 'usage', label: '使用日志' },
|
||||
{ id: 'settings', label: '系统设置' }
|
||||
];
|
||||
|
||||
const state = {
|
||||
token: localStorage.getItem('admin_token'),
|
||||
activeTab: 'dashboard',
|
||||
loading: false,
|
||||
notice: '',
|
||||
data: {
|
||||
channels: [] as Channel[],
|
||||
tokens: [] as Token[],
|
||||
models: [] as Array<{ id: string; channels: Array<{ id: string; name: string }> }>,
|
||||
usage: [] as UsageLog[],
|
||||
dashboard: null as DashboardData | null,
|
||||
settings: null as Settings | null
|
||||
}
|
||||
};
|
||||
|
||||
if (!root) {
|
||||
throw new Error('Missing #app root');
|
||||
}
|
||||
|
||||
function setNotice(message: string) {
|
||||
state.notice = message;
|
||||
render();
|
||||
}
|
||||
|
||||
function setToken(token: string | null) {
|
||||
state.token = token;
|
||||
if (token) {
|
||||
localStorage.setItem('admin_token', token);
|
||||
} else {
|
||||
localStorage.removeItem('admin_token');
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(options.headers ?? {});
|
||||
headers.set('Content-Type', 'application/json');
|
||||
if (state.token) {
|
||||
headers.set('Authorization', `Bearer ${state.token}`);
|
||||
}
|
||||
const response = await fetch(`${apiBase}${path}`, { ...options, headers });
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
setToken(null);
|
||||
}
|
||||
const payload = await response.json().catch(() => null);
|
||||
throw new Error(payload?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
state.data.dashboard = await apiFetch<DashboardData>('/api/dashboard');
|
||||
}
|
||||
|
||||
async function loadChannels() {
|
||||
const result = await apiFetch<{ channels: Channel[] }>('/api/channels');
|
||||
state.data.channels = result.channels;
|
||||
}
|
||||
|
||||
async function loadModels() {
|
||||
const result = await apiFetch<{ models: Array<{ id: string; channels: Array<{ id: string; name: string }> }> }>('/api/models');
|
||||
state.data.models = result.models;
|
||||
}
|
||||
|
||||
async function loadTokens() {
|
||||
const result = await apiFetch<{ tokens: Token[] }>('/api/tokens');
|
||||
state.data.tokens = result.tokens;
|
||||
}
|
||||
|
||||
async function loadUsage() {
|
||||
const result = await apiFetch<{ logs: UsageLog[] }>('/api/usage?limit=50');
|
||||
state.data.usage = result.logs;
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
state.data.settings = await apiFetch<Settings>('/api/settings');
|
||||
}
|
||||
|
||||
async function loadTab(tabId: string) {
|
||||
state.loading = true;
|
||||
state.notice = '';
|
||||
render();
|
||||
try {
|
||||
if (tabId === 'dashboard') {
|
||||
await loadDashboard();
|
||||
}
|
||||
if (tabId === 'channels') {
|
||||
await loadChannels();
|
||||
}
|
||||
if (tabId === 'models') {
|
||||
await loadModels();
|
||||
}
|
||||
if (tabId === 'tokens') {
|
||||
await loadTokens();
|
||||
}
|
||||
if (tabId === 'usage') {
|
||||
await loadUsage();
|
||||
}
|
||||
if (tabId === 'settings') {
|
||||
await loadSettings();
|
||||
}
|
||||
} catch (error) {
|
||||
setNotice((error as Error).message);
|
||||
} finally {
|
||||
state.loading = false;
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
function renderLogin() {
|
||||
return `
|
||||
<div class="login-card">
|
||||
<h1>api-workers</h1>
|
||||
<p>请输入管理员密码登录管理台。</p>
|
||||
<form id="login-form" class="form-grid">
|
||||
<div>
|
||||
<label for="password">管理员密码</label>
|
||||
<input id="password" name="password" type="password" required />
|
||||
</div>
|
||||
<button class="button" type="submit">登录</button>
|
||||
</form>
|
||||
${state.notice ? `<div class="notice">${state.notice}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSidebar() {
|
||||
return `
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<h2>api-workers</h2>
|
||||
<span>console</span>
|
||||
</div>
|
||||
<nav class="nav-list">
|
||||
${tabs
|
||||
.map(
|
||||
(tab) => `
|
||||
<button class="nav-item ${state.activeTab === tab.id ? 'active' : ''}" data-tab="${tab.id}" type="button">
|
||||
${tab.label}
|
||||
</button>
|
||||
`
|
||||
)
|
||||
.join('')}
|
||||
</nav>
|
||||
</aside>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDashboard() {
|
||||
const data = state.data.dashboard;
|
||||
if (!data) {
|
||||
return '<div class="card">暂无数据</div>';
|
||||
}
|
||||
const errorRate = data.summary.total_requests
|
||||
? Math.round((data.summary.total_errors / data.summary.total_requests) * 100)
|
||||
: 0;
|
||||
return `
|
||||
<div class="grid three">
|
||||
<div class="card kpi">
|
||||
<span class="badge">总请求</span>
|
||||
<div class="value">${data.summary.total_requests}</div>
|
||||
<span class="mono">最近窗口</span>
|
||||
</div>
|
||||
<div class="card kpi">
|
||||
<span class="badge">总 Tokens</span>
|
||||
<div class="value">${data.summary.total_tokens}</div>
|
||||
<span class="mono">累计消耗</span>
|
||||
</div>
|
||||
<div class="card kpi">
|
||||
<span class="badge">错误率</span>
|
||||
<div class="value">${errorRate}%</div>
|
||||
<span class="mono">平均延迟 ${Math.round(data.summary.avg_latency)}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid two" style="margin-top: 20px;">
|
||||
<div class="card">
|
||||
<div class="section-header">
|
||||
<h3>按日趋势</h3>
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead><tr><th>日期</th><th>请求</th><th>Tokens</th></tr></thead>
|
||||
<tbody>
|
||||
${data.byDay
|
||||
.map((row) => `<tr><td>${row.day}</td><td>${row.requests}</td><td>${row.tokens}</td></tr>`)
|
||||
.join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="section-header">
|
||||
<h3>模型排行</h3>
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead><tr><th>模型</th><th>请求</th><th>Tokens</th></tr></thead>
|
||||
<tbody>
|
||||
${data.byModel
|
||||
.map((row) => `<tr><td>${row.model ?? '-'}</td><td>${row.requests}</td><td>${row.tokens}</td></tr>`)
|
||||
.join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid two" style="margin-top: 20px;">
|
||||
<div class="card">
|
||||
<div class="section-header">
|
||||
<h3>渠道贡献</h3>
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead><tr><th>渠道</th><th>请求</th><th>Tokens</th></tr></thead>
|
||||
<tbody>
|
||||
${data.byChannel
|
||||
.map((row) => `<tr><td>${row.channel_name ?? '-'}</td><td>${row.requests}</td><td>${row.tokens}</td></tr>`)
|
||||
.join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="section-header">
|
||||
<h3>令牌贡献</h3>
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead><tr><th>令牌</th><th>请求</th><th>Tokens</th></tr></thead>
|
||||
<tbody>
|
||||
${data.byToken
|
||||
.map((row) => `<tr><td>${row.token_name ?? '-'}</td><td>${row.requests}</td><td>${row.tokens}</td></tr>`)
|
||||
.join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderChannels() {
|
||||
return `
|
||||
<div class="grid two">
|
||||
<div class="card">
|
||||
<div class="section-header">
|
||||
<h3>新增渠道</h3>
|
||||
</div>
|
||||
<form id="channel-form" class="form-grid">
|
||||
<div>
|
||||
<label for="channel-id">渠道 ID(可选)</label>
|
||||
<input id="channel-id" name="id" placeholder="例如 ch_openai" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="channel-name">名称</label>
|
||||
<input id="channel-name" name="name" required />
|
||||
</div>
|
||||
<div>
|
||||
<label for="channel-base">Base URL</label>
|
||||
<input id="channel-base" name="base_url" placeholder="https://api.openai.com" required />
|
||||
</div>
|
||||
<div>
|
||||
<label for="channel-key">API Key</label>
|
||||
<input id="channel-key" name="api_key" required />
|
||||
</div>
|
||||
<div class="form-grid two">
|
||||
<div>
|
||||
<label for="channel-weight">权重</label>
|
||||
<input id="channel-weight" name="weight" type="number" min="1" value="1" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="channel-rate">限流</label>
|
||||
<input id="channel-rate" name="rate_limit" type="number" min="0" value="0" />
|
||||
</div>
|
||||
</div>
|
||||
<button class="button" type="submit">创建渠道</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="section-header">
|
||||
<h3>渠道列表</h3>
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>ID</th><th>名称</th><th>状态</th><th>权重</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${state.data.channels
|
||||
.map(
|
||||
(channel) => `
|
||||
<tr>
|
||||
<td class="mono">${channel.id}</td>
|
||||
<td>${channel.name}</td>
|
||||
<td>${channel.status}</td>
|
||||
<td>${channel.weight}</td>
|
||||
<td>
|
||||
<button class="button secondary" type="button" data-action="channel-test" data-id="${channel.id}">连通测试</button>
|
||||
<button class="button ghost" type="button" data-action="channel-toggle" data-id="${channel.id}" data-status="${channel.status}">切换状态</button>
|
||||
<button class="button ghost" type="button" data-action="channel-delete" data-id="${channel.id}">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
`
|
||||
)
|
||||
.join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderModels() {
|
||||
return `
|
||||
<div class="card">
|
||||
<div class="section-header">
|
||||
<h3>模型广场</h3>
|
||||
<span class="badge">${state.data.models.length} 个模型</span>
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead><tr><th>模型</th><th>渠道</th></tr></thead>
|
||||
<tbody>
|
||||
${state.data.models
|
||||
.map(
|
||||
(model) => `
|
||||
<tr>
|
||||
<td>${model.id}</td>
|
||||
<td>${model.channels.map((channel) => channel.name).join(' / ')}</td>
|
||||
</tr>
|
||||
`
|
||||
)
|
||||
.join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderTokens() {
|
||||
return `
|
||||
<div class="grid two">
|
||||
<div class="card">
|
||||
<div class="section-header">
|
||||
<h3>生成令牌</h3>
|
||||
</div>
|
||||
<form id="token-form" class="form-grid">
|
||||
<div>
|
||||
<label for="token-name">名称</label>
|
||||
<input id="token-name" name="name" required />
|
||||
</div>
|
||||
<div>
|
||||
<label for="token-quota">额度(可选)</label>
|
||||
<input id="token-quota" name="quota_total" type="number" min="0" placeholder="留空表示无限" />
|
||||
</div>
|
||||
<button class="button" type="submit">生成令牌</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="section-header">
|
||||
<h3>令牌列表</h3>
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>名称</th><th>状态</th><th>已用/额度</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${state.data.tokens
|
||||
.map(
|
||||
(token) => `
|
||||
<tr>
|
||||
<td>${token.name}</td>
|
||||
<td>${token.status}</td>
|
||||
<td>${token.quota_used} / ${token.quota_total ?? '∞'}</td>
|
||||
<td>
|
||||
<button class="button secondary" type="button" data-action="token-reveal" data-id="${token.id}">查看</button>
|
||||
<button class="button secondary" type="button" data-action="token-toggle" data-id="${token.id}" data-status="${token.status}">切换</button>
|
||||
<button class="button ghost" type="button" data-action="token-delete" data-id="${token.id}">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
`
|
||||
)
|
||||
.join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderUsage() {
|
||||
return `
|
||||
<div class="card">
|
||||
<div class="section-header">
|
||||
<h3>使用日志</h3>
|
||||
<button class="button secondary" type="button" data-action="usage-refresh">刷新</button>
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead><tr><th>时间</th><th>模型</th><th>渠道</th><th>Tokens</th><th>延迟</th><th>状态</th></tr></thead>
|
||||
<tbody>
|
||||
${state.data.usage
|
||||
.map(
|
||||
(log) => `
|
||||
<tr>
|
||||
<td>${log.created_at?.slice(0, 19).replace('T', ' ')}</td>
|
||||
<td>${log.model ?? '-'}</td>
|
||||
<td>${log.channel_name ?? log.channel_id ?? '-'}</td>
|
||||
<td>${log.total_tokens ?? 0}</td>
|
||||
<td>${log.latency_ms ?? 0} ms</td>
|
||||
<td>${log.status}</td>
|
||||
</tr>
|
||||
`
|
||||
)
|
||||
.join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSettings() {
|
||||
const retention = state.data.settings?.log_retention_days ?? 30;
|
||||
return `
|
||||
<div class="card">
|
||||
<div class="section-header">
|
||||
<h3>系统设置</h3>
|
||||
</div>
|
||||
<form id="settings-form" class="form-grid two">
|
||||
<div>
|
||||
<label for="retention">日志保留天数</label>
|
||||
<input id="retention" name="log_retention_days" type="number" min="1" value="${retention}" />
|
||||
</div>
|
||||
<div style="display: flex; align-items: flex-end;">
|
||||
<button class="button" type="submit">保存设置</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderContent() {
|
||||
if (state.loading) {
|
||||
return '<div class="card">加载中...</div>';
|
||||
}
|
||||
if (state.activeTab === 'dashboard') {
|
||||
return renderDashboard();
|
||||
}
|
||||
if (state.activeTab === 'channels') {
|
||||
return renderChannels();
|
||||
}
|
||||
if (state.activeTab === 'models') {
|
||||
return renderModels();
|
||||
}
|
||||
if (state.activeTab === 'tokens') {
|
||||
return renderTokens();
|
||||
}
|
||||
if (state.activeTab === 'usage') {
|
||||
return renderUsage();
|
||||
}
|
||||
if (state.activeTab === 'settings') {
|
||||
return renderSettings();
|
||||
}
|
||||
return '<div class="card">未知模块</div>';
|
||||
}
|
||||
|
||||
function renderApp() {
|
||||
return `
|
||||
<div class="app-shell">
|
||||
${renderSidebar()}
|
||||
<main class="main">
|
||||
<div class="top-bar">
|
||||
<div>
|
||||
<h1>${tabs.find((tab) => tab.id === state.activeTab)?.label ?? '管理台'}</h1>
|
||||
<p>集中管理渠道、模型、令牌与使用情况。</p>
|
||||
</div>
|
||||
<div class="top-bar-actions">
|
||||
<span class="badge">${state.token ? '已登录' : '未登录'}</span>
|
||||
<button class="button ghost" type="button" data-action="logout">退出</button>
|
||||
</div>
|
||||
</div>
|
||||
${state.notice ? `<div class="notice">${state.notice}</div>` : ''}
|
||||
${renderContent()}
|
||||
</main>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
root!.innerHTML = state.token ? renderApp() : renderLogin();
|
||||
bindEvents();
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
const loginForm = document.querySelector<HTMLFormElement>('#login-form');
|
||||
if (loginForm) {
|
||||
loginForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(loginForm);
|
||||
const password = String(formData.get('password') ?? '');
|
||||
try {
|
||||
const result = await apiFetch<{ token: string }>('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
setToken(result.token);
|
||||
state.notice = '';
|
||||
await loadTab(state.activeTab);
|
||||
} catch (error) {
|
||||
setNotice((error as Error).message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const channelForm = document.querySelector<HTMLFormElement>('#channel-form');
|
||||
if (channelForm) {
|
||||
channelForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(channelForm);
|
||||
const payload = Object.fromEntries(formData.entries());
|
||||
const channelId = String(payload.id ?? '').trim();
|
||||
try {
|
||||
const body = {
|
||||
...payload,
|
||||
weight: Number(payload.weight ?? 1),
|
||||
rate_limit: Number(payload.rate_limit ?? 0)
|
||||
} as Record<string, unknown>;
|
||||
if (channelId) {
|
||||
body.id = channelId;
|
||||
} else {
|
||||
delete body.id;
|
||||
}
|
||||
await apiFetch('/api/channels', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
channelForm.reset();
|
||||
await loadChannels();
|
||||
render();
|
||||
} catch (error) {
|
||||
setNotice((error as Error).message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const tokenForm = document.querySelector<HTMLFormElement>('#token-form');
|
||||
if (tokenForm) {
|
||||
tokenForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(tokenForm);
|
||||
const payload = Object.fromEntries(formData.entries());
|
||||
try {
|
||||
const result = await apiFetch<{ token: string }>('/api/tokens', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: payload.name,
|
||||
quota_total: payload.quota_total ? Number(payload.quota_total) : null
|
||||
})
|
||||
});
|
||||
setNotice(`新令牌: ${result.token}`);
|
||||
tokenForm.reset();
|
||||
await loadTokens();
|
||||
render();
|
||||
} catch (error) {
|
||||
setNotice((error as Error).message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const settingsForm = document.querySelector<HTMLFormElement>('#settings-form');
|
||||
if (settingsForm) {
|
||||
settingsForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(settingsForm);
|
||||
const value = Number(formData.get('log_retention_days'));
|
||||
try {
|
||||
await apiFetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ log_retention_days: value })
|
||||
});
|
||||
await loadSettings();
|
||||
setNotice('设置已更新');
|
||||
} catch (error) {
|
||||
setNotice((error as Error).message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
root!.querySelectorAll('[data-tab]').forEach((item) => {
|
||||
item.addEventListener('click', () => {
|
||||
const tab = (item as HTMLElement).dataset.tab ?? 'dashboard';
|
||||
state.activeTab = tab;
|
||||
loadTab(tab);
|
||||
});
|
||||
});
|
||||
|
||||
root!.querySelectorAll('[data-action]').forEach((item) => {
|
||||
item.addEventListener('click', async () => {
|
||||
const action = (item as HTMLElement).dataset.action ?? '';
|
||||
const id = (item as HTMLElement).dataset.id ?? '';
|
||||
try {
|
||||
if (action === 'logout') {
|
||||
await apiFetch('/api/auth/logout', { method: 'POST' }).catch(() => null);
|
||||
setToken(null);
|
||||
return;
|
||||
}
|
||||
if (action === 'channel-test') {
|
||||
const result = await apiFetch<{ models: Array<{ id: string }> }>(`/api/channels/${id}/test`, { method: 'POST' });
|
||||
await loadChannels();
|
||||
setNotice(`连通测试完成,模型数 ${result.models?.length ?? 0}`);
|
||||
render();
|
||||
}
|
||||
if (action === 'channel-delete') {
|
||||
await apiFetch(`/api/channels/${id}`, { method: 'DELETE' });
|
||||
await loadChannels();
|
||||
setNotice('渠道已删除');
|
||||
render();
|
||||
}
|
||||
if (action === 'channel-toggle') {
|
||||
const status = (item as HTMLElement).dataset.status === 'active' ? 'disabled' : 'active';
|
||||
await apiFetch(`/api/channels/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ status })
|
||||
});
|
||||
await loadChannels();
|
||||
setNotice(`渠道已${status === 'active' ? '启用' : '停用'}`);
|
||||
render();
|
||||
}
|
||||
if (action === 'token-delete') {
|
||||
await apiFetch(`/api/tokens/${id}`, { method: 'DELETE' });
|
||||
await loadTokens();
|
||||
setNotice('令牌已删除');
|
||||
render();
|
||||
}
|
||||
if (action === 'token-reveal') {
|
||||
const result = await apiFetch<{ token: string | null }>(`/api/tokens/${id}/reveal`);
|
||||
setNotice(result.token ? `令牌: ${result.token}` : '未找到令牌');
|
||||
}
|
||||
if (action === 'token-toggle') {
|
||||
const status = (item as HTMLElement).dataset.status === 'active' ? 'disabled' : 'active';
|
||||
await apiFetch(`/api/tokens/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ status })
|
||||
});
|
||||
await loadTokens();
|
||||
setNotice(`令牌已${status === 'active' ? '启用' : '停用'}`);
|
||||
render();
|
||||
}
|
||||
if (action === 'usage-refresh') {
|
||||
await loadUsage();
|
||||
setNotice('日志已刷新');
|
||||
render();
|
||||
}
|
||||
} catch (error) {
|
||||
setNotice((error as Error).message);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
render();
|
||||
|
||||
if (state.token) {
|
||||
loadTab(state.activeTab);
|
||||
}
|
||||
+1
-353
@@ -1,353 +1 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600&family=Space+Grotesk:wght@400;500;600;700&display=swap');
|
||||
|
||||
:root {
|
||||
--bg: #f6f4ef;
|
||||
--surface: #ffffff;
|
||||
--surface-alt: #f0ede7;
|
||||
--text: #1b1b19;
|
||||
--muted: #6b6760;
|
||||
--border: #e2ddd3;
|
||||
--accent: #d4af37;
|
||||
--accent-dark: #b9972a;
|
||||
--shadow: 0 12px 30px rgba(27, 27, 25, 0.08);
|
||||
--radius: 16px;
|
||||
--radius-sm: 10px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'IBM Plex Sans', sans-serif;
|
||||
background: radial-gradient(circle at top, #ffffff 0%, #f3efe8 40%, #efeae2 100%);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
margin: 0 0 0.75rem 0;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 1rem 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
padding: 32px 20px;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.brand span {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.24em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--surface-alt);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: var(--surface-alt);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 32px 40px 60px;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.top-bar-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-alt);
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.grid.two {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.grid.three {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
box-shadow: var(--shadow);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.button {
|
||||
background: var(--text);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 10px 16px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
background: var(--surface-alt);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.button.ghost {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 18px rgba(27, 27, 25, 0.12);
|
||||
}
|
||||
|
||||
.button:focus-visible,
|
||||
.nav-item:focus-visible {
|
||||
outline: 2px solid rgba(212, 175, 55, 0.6);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.table th {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.table tr:hover {
|
||||
background: #faf8f3;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.form-grid.two {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--muted);
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: 2px solid rgba(212, 175, 55, 0.4);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.notice {
|
||||
padding: 12px 16px;
|
||||
background: #fff8e1;
|
||||
border: 1px solid #f1e0b3;
|
||||
border-radius: 12px;
|
||||
color: #7a5d00;
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
max-width: 420px;
|
||||
margin: 12vh auto 0;
|
||||
background: var(--surface);
|
||||
padding: 32px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.login-card h1 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.kpi {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.kpi .value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: static;
|
||||
height: auto;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.grid.two,
|
||||
.grid.three,
|
||||
.form-grid.two {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.main {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
@import "tailwindcss";
|
||||
+12
-10
@@ -1,12 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "hono/jsx/dom",
|
||||
"strict": true,
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
+11
-11
@@ -1,15 +1,15 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
const apiTarget = process.env.VITE_API_TARGET ?? 'http://localhost:8787';
|
||||
const apiTarget = process.env.VITE_API_TARGET ?? "http://localhost:8787";
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist'
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': apiTarget,
|
||||
'/v1': apiTarget
|
||||
}
|
||||
}
|
||||
build: {
|
||||
outDir: "dist",
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": apiTarget,
|
||||
"/v1": apiTarget,
|
||||
},
|
||||
},
|
||||
});
|
||||
+16
-16
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"name": "new-api-lite-worker",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"db:migrate": "wrangler d1 migrations apply DB --local"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.2.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20240222.0",
|
||||
"typescript": "^5.4.5",
|
||||
"wrangler": "^3.48.0"
|
||||
}
|
||||
"name": "new-api-lite-worker",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"db:migrate": "wrangler d1 migrations apply DB --local"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.2.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20240222.0",
|
||||
"typescript": "^5.4.5",
|
||||
"wrangler": "^3.48.0"
|
||||
}
|
||||
}
|
||||
+14
-10
@@ -1,15 +1,19 @@
|
||||
import type { D1Database } from "@cloudflare/workers-types";
|
||||
|
||||
export type Bindings = {
|
||||
DB: D1Database;
|
||||
ADMIN_PASSWORD: string;
|
||||
SESSION_TTL_HOURS?: string;
|
||||
LOG_RETENTION_DAYS?: string;
|
||||
CORS_ORIGIN?: string;
|
||||
PROXY_RETRY_ROUNDS?: string;
|
||||
PROXY_RETRY_DELAY_MS?: string;
|
||||
NEW_API_ADMIN_TOKEN?: string;
|
||||
NEWAPI_ADMIN_TOKEN?: string;
|
||||
DB: D1Database;
|
||||
CORS_ORIGIN?: string;
|
||||
PROXY_RETRY_ROUNDS?: string;
|
||||
PROXY_RETRY_DELAY_MS?: string;
|
||||
};
|
||||
|
||||
export type Variables = {
|
||||
adminSessionId?: string;
|
||||
newApiUserId?: string | null;
|
||||
tokenRecord?: unknown;
|
||||
};
|
||||
|
||||
export type AppEnv = {
|
||||
Bindings: Bindings;
|
||||
Bindings: Bindings;
|
||||
Variables: Variables;
|
||||
};
|
||||
+105
-59
@@ -1,68 +1,114 @@
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { logger } from 'hono/logger';
|
||||
import type { AppEnv } from './env';
|
||||
import authRoutes from './routes/auth';
|
||||
import channelRoutes from './routes/channels';
|
||||
import modelRoutes from './routes/models';
|
||||
import tokenRoutes from './routes/tokens';
|
||||
import usageRoutes from './routes/usage';
|
||||
import dashboardRoutes from './routes/dashboard';
|
||||
import settingsRoutes from './routes/settings';
|
||||
import proxyRoutes from './routes/proxy';
|
||||
import newapiChannelRoutes from './routes/newapiChannels';
|
||||
import newapiUserRoutes from './routes/newapiUsers';
|
||||
import newapiGroupRoutes from './routes/newapiGroups';
|
||||
import { adminAuth } from './middleware/adminAuth';
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { logger } from "hono/logger";
|
||||
import type { AppEnv } from "./env";
|
||||
import { adminAuth } from "./middleware/adminAuth";
|
||||
import authRoutes from "./routes/auth";
|
||||
import channelRoutes from "./routes/channels";
|
||||
import dashboardRoutes from "./routes/dashboard";
|
||||
import modelRoutes from "./routes/models";
|
||||
import newapiChannelRoutes from "./routes/newapiChannels";
|
||||
import newapiGroupRoutes from "./routes/newapiGroups";
|
||||
import newapiUserRoutes from "./routes/newapiUsers";
|
||||
import proxyRoutes from "./routes/proxy";
|
||||
import settingsRoutes from "./routes/settings";
|
||||
import tokenRoutes from "./routes/tokens";
|
||||
import usageRoutes from "./routes/usage";
|
||||
|
||||
const app = new Hono<AppEnv>({ strict: false });
|
||||
|
||||
app.use('*', logger());
|
||||
app.use(
|
||||
'/api/*',
|
||||
cors({
|
||||
origin: (origin, c) => {
|
||||
const allowed = c.env.CORS_ORIGIN ?? '*';
|
||||
return allowed === '*' ? '*' : allowed.split(',').map((item) => item.trim());
|
||||
},
|
||||
allowHeaders: ['Content-Type', 'Authorization', 'x-api-key', 'x-admin-token', 'New-Api-User'],
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']
|
||||
})
|
||||
);
|
||||
app.use(
|
||||
'/v1/*',
|
||||
cors({
|
||||
origin: '*',
|
||||
allowHeaders: ['Content-Type', 'Authorization', 'x-api-key'],
|
||||
allowMethods: ['GET', 'POST', 'OPTIONS']
|
||||
})
|
||||
);
|
||||
|
||||
app.use('/api/*', async (c, next) => {
|
||||
if (
|
||||
c.req.path === '/api/auth/login' ||
|
||||
c.req.path.startsWith('/api/channel') ||
|
||||
c.req.path.startsWith('/api/user') ||
|
||||
c.req.path.startsWith('/api/group')
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
return adminAuth(c, next);
|
||||
app.use("*", async (c, next) => {
|
||||
const method = c.req.method;
|
||||
const path = c.req.path;
|
||||
const contentType = c.req.header("content-type") ?? "";
|
||||
if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
|
||||
const raw = c.req.raw.clone();
|
||||
let bodyType = "unknown";
|
||||
let bodyKeys: string[] = [];
|
||||
let bodySize = 0;
|
||||
try {
|
||||
const payload = await raw.json();
|
||||
bodyType = "json";
|
||||
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
|
||||
bodyKeys = Object.keys(payload);
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
const text = await raw.text();
|
||||
bodyType = "text";
|
||||
bodySize = text.length;
|
||||
} catch {
|
||||
bodyType = "unreadable";
|
||||
}
|
||||
}
|
||||
console.log("[request]", {
|
||||
method,
|
||||
path,
|
||||
content_type: contentType,
|
||||
body_type: bodyType,
|
||||
body_keys: bodyKeys,
|
||||
body_size: bodySize,
|
||||
});
|
||||
} else {
|
||||
console.log("[request]", { method, path });
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
app.get('/health', (c) => c.json({ ok: true }));
|
||||
app.use("*", logger());
|
||||
app.use(
|
||||
"/api/*",
|
||||
cors({
|
||||
origin: (_origin, c) => {
|
||||
const allowed = c.env.CORS_ORIGIN ?? "*";
|
||||
return allowed === "*"
|
||||
? "*"
|
||||
: allowed.split(",").map((item: string) => item.trim());
|
||||
},
|
||||
allowHeaders: [
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
"x-api-key",
|
||||
"x-admin-token",
|
||||
"New-Api-User",
|
||||
],
|
||||
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
}),
|
||||
);
|
||||
app.use(
|
||||
"/v1/*",
|
||||
cors({
|
||||
origin: "*",
|
||||
allowHeaders: ["Content-Type", "Authorization", "x-api-key"],
|
||||
allowMethods: ["GET", "POST", "OPTIONS"],
|
||||
}),
|
||||
);
|
||||
|
||||
app.route('/api/auth', authRoutes);
|
||||
app.route('/api/channels', channelRoutes);
|
||||
app.route('/api/models', modelRoutes);
|
||||
app.route('/api/tokens', tokenRoutes);
|
||||
app.route('/api/usage', usageRoutes);
|
||||
app.route('/api/dashboard', dashboardRoutes);
|
||||
app.route('/api/settings', settingsRoutes);
|
||||
app.route('/api/channel', newapiChannelRoutes);
|
||||
app.route('/api/user', newapiUserRoutes);
|
||||
app.route('/api/group', newapiGroupRoutes);
|
||||
app.use("/api/*", async (c, next) => {
|
||||
if (
|
||||
c.req.path === "/api/auth/login" ||
|
||||
c.req.path.startsWith("/api/channel") ||
|
||||
c.req.path.startsWith("/api/user") ||
|
||||
c.req.path.startsWith("/api/group")
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
return adminAuth(c, next);
|
||||
});
|
||||
|
||||
app.route('/v1', proxyRoutes);
|
||||
app.get("/health", (c) => c.json({ ok: true }));
|
||||
|
||||
app.route("/api/auth", authRoutes);
|
||||
app.route("/api/channels", channelRoutes);
|
||||
app.route("/api/models", modelRoutes);
|
||||
app.route("/api/tokens", tokenRoutes);
|
||||
app.route("/api/usage", usageRoutes);
|
||||
app.route("/api/dashboard", dashboardRoutes);
|
||||
app.route("/api/settings", settingsRoutes);
|
||||
app.route("/api/channel", newapiChannelRoutes);
|
||||
app.route("/api/user", newapiUserRoutes);
|
||||
app.route("/api/group", newapiGroupRoutes);
|
||||
|
||||
app.route("/v1", proxyRoutes);
|
||||
|
||||
export default app;
|
||||
@@ -1,36 +1,36 @@
|
||||
import { createMiddleware } from 'hono/factory';
|
||||
import { sha256Hex } from '../utils/crypto';
|
||||
import { getBearerToken } from '../utils/request';
|
||||
import { jsonError } from '../utils/http';
|
||||
import type { AppEnv } from '../env';
|
||||
import { createMiddleware } from "hono/factory";
|
||||
import type { AppEnv } from "../env";
|
||||
import { sha256Hex } from "../utils/crypto";
|
||||
import { jsonError } from "../utils/http";
|
||||
import { getBearerToken } from "../utils/request";
|
||||
|
||||
/**
|
||||
* Validates admin session tokens for management APIs.
|
||||
*/
|
||||
export const adminAuth = createMiddleware<AppEnv>(async (c, next) => {
|
||||
const token = getBearerToken(c);
|
||||
if (!token) {
|
||||
return jsonError(c, 401, 'admin_token_required', 'admin_token_required');
|
||||
}
|
||||
const token = getBearerToken(c);
|
||||
if (!token) {
|
||||
return jsonError(c, 401, "admin_token_required", "admin_token_required");
|
||||
}
|
||||
|
||||
const tokenHash = await sha256Hex(token);
|
||||
const session = await c.env.DB.prepare(
|
||||
'SELECT id, expires_at FROM admin_sessions WHERE token_hash = ?'
|
||||
)
|
||||
.bind(tokenHash)
|
||||
.first();
|
||||
const tokenHash = await sha256Hex(token);
|
||||
const session = await c.env.DB.prepare(
|
||||
"SELECT id, expires_at FROM admin_sessions WHERE token_hash = ?",
|
||||
)
|
||||
.bind(tokenHash)
|
||||
.first();
|
||||
|
||||
if (!session) {
|
||||
return jsonError(c, 401, 'invalid_admin_token', 'invalid_admin_token');
|
||||
}
|
||||
if (!session) {
|
||||
return jsonError(c, 401, "invalid_admin_token", "invalid_admin_token");
|
||||
}
|
||||
|
||||
if (new Date(String(session.expires_at)).getTime() <= Date.now()) {
|
||||
await c.env.DB.prepare('DELETE FROM admin_sessions WHERE id = ?')
|
||||
.bind(String(session.id))
|
||||
.run();
|
||||
return jsonError(c, 401, 'admin_session_expired', 'admin_session_expired');
|
||||
}
|
||||
if (new Date(String(session.expires_at)).getTime() <= Date.now()) {
|
||||
await c.env.DB.prepare("DELETE FROM admin_sessions WHERE id = ?")
|
||||
.bind(String(session.id))
|
||||
.run();
|
||||
return jsonError(c, 401, "admin_session_expired", "admin_session_expired");
|
||||
}
|
||||
|
||||
c.set('adminSessionId', String(session.id));
|
||||
await next();
|
||||
c.set("adminSessionId", String(session.id));
|
||||
await next();
|
||||
});
|
||||
@@ -1,56 +1,63 @@
|
||||
import { createMiddleware } from 'hono/factory';
|
||||
import { sha256Hex } from '../utils/crypto';
|
||||
import { getBearerToken } from '../utils/request';
|
||||
import type { AppEnv } from '../env';
|
||||
import type { Context } from "hono";
|
||||
import { createMiddleware } from "hono/factory";
|
||||
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import type { AppEnv } from "../env";
|
||||
import { getAdminPasswordHash } from "../services/settings";
|
||||
import { sha256Hex } from "../utils/crypto";
|
||||
import { getBearerToken } from "../utils/request";
|
||||
|
||||
function newApiError(c: any, status: number, message: string) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
message
|
||||
},
|
||||
status
|
||||
);
|
||||
function newApiError(
|
||||
c: Context<AppEnv>,
|
||||
status: ContentfulStatusCode,
|
||||
message: string,
|
||||
) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
message,
|
||||
},
|
||||
status,
|
||||
);
|
||||
}
|
||||
|
||||
function readNewApiUserId(c: any): string | null {
|
||||
return c.req.header('New-Api-User') ?? c.req.header('new-api-user') ?? null;
|
||||
function readNewApiUserId(c: Context<AppEnv>): string | null {
|
||||
return c.req.header("New-Api-User") ?? c.req.header("new-api-user") ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates New API admin tokens or admin sessions.
|
||||
*/
|
||||
export const newApiAuth = createMiddleware<AppEnv>(async (c, next) => {
|
||||
const token = getBearerToken(c);
|
||||
if (!token) {
|
||||
return newApiError(c, 401, 'unauthorized');
|
||||
}
|
||||
const token = getBearerToken(c);
|
||||
if (!token) {
|
||||
return newApiError(c, 401, "unauthorized");
|
||||
}
|
||||
|
||||
const envToken = c.env.NEW_API_ADMIN_TOKEN ?? c.env.NEWAPI_ADMIN_TOKEN;
|
||||
if (envToken && token === envToken) {
|
||||
c.set('newApiUserId', readNewApiUserId(c));
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
const tokenHash = await sha256Hex(token);
|
||||
const adminPasswordHash = await getAdminPasswordHash(c.env.DB);
|
||||
if (adminPasswordHash && tokenHash === adminPasswordHash) {
|
||||
c.set("newApiUserId", readNewApiUserId(c));
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenHash = await sha256Hex(token);
|
||||
const session = await c.env.DB.prepare(
|
||||
'SELECT id, expires_at FROM admin_sessions WHERE token_hash = ?'
|
||||
)
|
||||
.bind(tokenHash)
|
||||
.first<{ id: string; expires_at: string }>();
|
||||
const session = await c.env.DB.prepare(
|
||||
"SELECT id, expires_at FROM admin_sessions WHERE token_hash = ?",
|
||||
)
|
||||
.bind(tokenHash)
|
||||
.first<{ id: string; expires_at: string }>();
|
||||
|
||||
if (!session) {
|
||||
return newApiError(c, 401, 'unauthorized');
|
||||
}
|
||||
if (!session) {
|
||||
return newApiError(c, 401, "unauthorized");
|
||||
}
|
||||
|
||||
if (new Date(String(session.expires_at)).getTime() <= Date.now()) {
|
||||
await c.env.DB.prepare('DELETE FROM admin_sessions WHERE id = ?')
|
||||
.bind(String(session.id))
|
||||
.run();
|
||||
return newApiError(c, 401, 'session_expired');
|
||||
}
|
||||
if (new Date(String(session.expires_at)).getTime() <= Date.now()) {
|
||||
await c.env.DB.prepare("DELETE FROM admin_sessions WHERE id = ?")
|
||||
.bind(String(session.id))
|
||||
.run();
|
||||
return newApiError(c, 401, "session_expired");
|
||||
}
|
||||
|
||||
c.set('newApiUserId', readNewApiUserId(c));
|
||||
await next();
|
||||
c.set("newApiUserId", readNewApiUserId(c));
|
||||
await next();
|
||||
});
|
||||
@@ -1,56 +1,60 @@
|
||||
import { createMiddleware } from 'hono/factory';
|
||||
import { sha256Hex } from '../utils/crypto';
|
||||
import { getBearerToken } from '../utils/request';
|
||||
import { jsonError } from '../utils/http';
|
||||
import { canConsumeQuota, normalizeQuota } from '../services/quota';
|
||||
import type { AppEnv } from '../env';
|
||||
import { createMiddleware } from "hono/factory";
|
||||
import type { AppEnv } from "../env";
|
||||
import { canConsumeQuota, normalizeQuota } from "../services/quota";
|
||||
import { sha256Hex } from "../utils/crypto";
|
||||
import { jsonError } from "../utils/http";
|
||||
import { getBearerToken } from "../utils/request";
|
||||
|
||||
export type TokenRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
quota_total: number | null;
|
||||
quota_used: number;
|
||||
status: string;
|
||||
allowed_channels: string | null;
|
||||
id: string;
|
||||
name: string;
|
||||
quota_total: number | null;
|
||||
quota_used: number;
|
||||
status: string;
|
||||
allowed_channels: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates API tokens for the OpenAI-compatible proxy.
|
||||
*/
|
||||
export const tokenAuth = createMiddleware<AppEnv>(async (c, next) => {
|
||||
const token = getBearerToken(c);
|
||||
if (!token) {
|
||||
return jsonError(c, 401, 'token_required', 'token_required');
|
||||
}
|
||||
const token = getBearerToken(c);
|
||||
if (!token) {
|
||||
return jsonError(c, 401, "token_required", "token_required");
|
||||
}
|
||||
|
||||
const tokenHash = await sha256Hex(token);
|
||||
const record = await c.env.DB.prepare(
|
||||
'SELECT id, name, quota_total, quota_used, status, allowed_channels FROM tokens WHERE key_hash = ?'
|
||||
)
|
||||
.bind(tokenHash)
|
||||
.first<TokenRecord>();
|
||||
const tokenHash = await sha256Hex(token);
|
||||
const record = await c.env.DB.prepare(
|
||||
"SELECT id, name, quota_total, quota_used, status, allowed_channels FROM tokens WHERE key_hash = ?",
|
||||
)
|
||||
.bind(tokenHash)
|
||||
.first<TokenRecord>();
|
||||
|
||||
if (!record) {
|
||||
return jsonError(c, 401, 'invalid_token', 'invalid_token');
|
||||
}
|
||||
if (!record) {
|
||||
return jsonError(c, 401, "invalid_token", "invalid_token");
|
||||
}
|
||||
|
||||
if (record.status !== 'active') {
|
||||
return jsonError(c, 403, 'token_disabled', 'token_disabled');
|
||||
}
|
||||
if (record.status !== "active") {
|
||||
return jsonError(c, 403, "token_disabled", "token_disabled");
|
||||
}
|
||||
|
||||
const quotaTotal = record.quota_total === null || record.quota_total === undefined
|
||||
? null
|
||||
: Number(record.quota_total);
|
||||
const quotaUsed = Number(record.quota_used ?? 0);
|
||||
const normalized = normalizeQuota(Number.isNaN(quotaTotal) ? null : quotaTotal, Number.isNaN(quotaUsed) ? 0 : quotaUsed);
|
||||
if (!canConsumeQuota(normalized.quotaTotal, normalized.quotaUsed, 1)) {
|
||||
return jsonError(c, 402, 'quota_exceeded', 'quota_exceeded');
|
||||
}
|
||||
const quotaTotal =
|
||||
record.quota_total === null || record.quota_total === undefined
|
||||
? null
|
||||
: Number(record.quota_total);
|
||||
const quotaUsed = Number(record.quota_used ?? 0);
|
||||
const normalized = normalizeQuota(
|
||||
Number.isNaN(quotaTotal) ? null : quotaTotal,
|
||||
Number.isNaN(quotaUsed) ? 0 : quotaUsed,
|
||||
);
|
||||
if (!canConsumeQuota(normalized.quotaTotal, normalized.quotaUsed, 1)) {
|
||||
return jsonError(c, 402, "quota_exceeded", "quota_exceeded");
|
||||
}
|
||||
|
||||
c.set('tokenRecord', {
|
||||
...record,
|
||||
quota_total: normalized.quotaTotal,
|
||||
quota_used: normalized.quotaUsed
|
||||
});
|
||||
await next();
|
||||
c.set("tokenRecord", {
|
||||
...record,
|
||||
quota_total: normalized.quotaTotal,
|
||||
quota_used: normalized.quotaUsed,
|
||||
});
|
||||
await next();
|
||||
});
|
||||
@@ -1,53 +1,64 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { AppEnv } from '../env';
|
||||
import { addHours, nowIso } from '../utils/time';
|
||||
import { generateToken, sha256Hex } from '../utils/crypto';
|
||||
import { jsonError } from '../utils/http';
|
||||
import { adminAuth } from '../middleware/adminAuth';
|
||||
import { Hono } from "hono";
|
||||
import type { AppEnv } from "../env";
|
||||
import { adminAuth } from "../middleware/adminAuth";
|
||||
import {
|
||||
getAdminPasswordHash,
|
||||
getSessionTtlHours,
|
||||
setAdminPasswordHash,
|
||||
} from "../services/settings";
|
||||
import { generateToken, sha256Hex } from "../utils/crypto";
|
||||
import { jsonError } from "../utils/http";
|
||||
import { addHours, nowIso } from "../utils/time";
|
||||
|
||||
const auth = new Hono<AppEnv>();
|
||||
|
||||
/**
|
||||
* Logs in the admin user with a password.
|
||||
*/
|
||||
auth.post('/login', async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body?.password) {
|
||||
return jsonError(c, 400, 'password_required', 'password_required');
|
||||
}
|
||||
auth.post("/login", async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body?.password) {
|
||||
return jsonError(c, 400, "password_required", "password_required");
|
||||
}
|
||||
|
||||
if (body.password !== c.env.ADMIN_PASSWORD) {
|
||||
return jsonError(c, 401, 'invalid_password', 'invalid_password');
|
||||
}
|
||||
const password = String(body.password);
|
||||
const passwordHash = await sha256Hex(password);
|
||||
const storedHash = await getAdminPasswordHash(c.env.DB);
|
||||
|
||||
const rawToken = generateToken('admin_');
|
||||
const tokenHash = await sha256Hex(rawToken);
|
||||
const ttlHours = Number(c.env.SESSION_TTL_HOURS ?? '12');
|
||||
const expiresAt = addHours(new Date(), Number.isNaN(ttlHours) ? 12 : ttlHours).toISOString();
|
||||
if (!storedHash) {
|
||||
await setAdminPasswordHash(c.env.DB, passwordHash);
|
||||
} else if (passwordHash !== storedHash) {
|
||||
return jsonError(c, 401, "invalid_password", "invalid_password");
|
||||
}
|
||||
|
||||
await c.env.DB.prepare(
|
||||
'INSERT INTO admin_sessions (id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?)'
|
||||
)
|
||||
.bind(crypto.randomUUID(), tokenHash, expiresAt, nowIso())
|
||||
.run();
|
||||
const rawToken = generateToken("admin_");
|
||||
const tokenHash = await sha256Hex(rawToken);
|
||||
const ttlHours = await getSessionTtlHours(c.env.DB);
|
||||
const expiresAt = addHours(new Date(), ttlHours).toISOString();
|
||||
|
||||
return c.json({
|
||||
token: rawToken,
|
||||
expires_at: expiresAt
|
||||
});
|
||||
await c.env.DB.prepare(
|
||||
"INSERT INTO admin_sessions (id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(crypto.randomUUID(), tokenHash, expiresAt, nowIso())
|
||||
.run();
|
||||
|
||||
return c.json({
|
||||
token: rawToken,
|
||||
expires_at: expiresAt,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Invalidates the current admin session token.
|
||||
*/
|
||||
auth.post('/logout', adminAuth, async (c) => {
|
||||
const sessionId = c.get('adminSessionId');
|
||||
if (sessionId) {
|
||||
await c.env.DB.prepare('DELETE FROM admin_sessions WHERE id = ?')
|
||||
.bind(String(sessionId))
|
||||
.run();
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
auth.post("/logout", adminAuth, async (c) => {
|
||||
const sessionId = c.get("adminSessionId");
|
||||
if (sessionId) {
|
||||
await c.env.DB.prepare("DELETE FROM admin_sessions WHERE id = ?")
|
||||
.bind(String(sessionId))
|
||||
.run();
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
export default auth;
|
||||
+136
-105
@@ -1,13 +1,27 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { AppEnv } from '../env';
|
||||
import { nowIso } from '../utils/time';
|
||||
import { jsonError } from '../utils/http';
|
||||
import { safeJsonParse } from '../utils/json';
|
||||
import { generateToken } from '../utils/crypto';
|
||||
import { normalizeBaseUrl } from '../utils/url';
|
||||
import { Hono } from "hono";
|
||||
import type { AppEnv } from "../env";
|
||||
import type { ChannelRecord } from "../services/channels";
|
||||
import { generateToken } from "../utils/crypto";
|
||||
import { jsonError } from "../utils/http";
|
||||
import { safeJsonParse } from "../utils/json";
|
||||
import { nowIso } from "../utils/time";
|
||||
import { normalizeBaseUrl } from "../utils/url";
|
||||
|
||||
const channels = new Hono<AppEnv>();
|
||||
|
||||
type ChannelPayload = {
|
||||
id?: string | number;
|
||||
channel_id?: string | number;
|
||||
channelId?: string | number;
|
||||
name?: string;
|
||||
base_url?: string;
|
||||
api_key?: string;
|
||||
weight?: number;
|
||||
status?: string;
|
||||
rate_limit?: number;
|
||||
models?: unknown[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves a channel id from request payload.
|
||||
*
|
||||
@@ -17,141 +31,158 @@ const channels = new Hono<AppEnv>();
|
||||
* Returns:
|
||||
* Channel id if provided.
|
||||
*/
|
||||
function resolveChannelId(body: any): string | null {
|
||||
const candidate = body?.id ?? body?.channel_id ?? body?.channelId;
|
||||
if (!candidate) {
|
||||
return null;
|
||||
}
|
||||
const normalized = String(candidate).trim();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
function resolveChannelId(body: ChannelPayload | null): string | null {
|
||||
const candidate = body?.id ?? body?.channel_id ?? body?.channelId;
|
||||
if (!candidate) {
|
||||
return null;
|
||||
}
|
||||
const normalized = String(candidate).trim();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all channels.
|
||||
*/
|
||||
channels.get('/', async (c) => {
|
||||
const result = await c.env.DB.prepare('SELECT * FROM channels ORDER BY created_at DESC').all();
|
||||
return c.json({ channels: result.results ?? [] });
|
||||
channels.get("/", async (c) => {
|
||||
const result = await c.env.DB.prepare(
|
||||
"SELECT * FROM channels ORDER BY created_at DESC",
|
||||
).all();
|
||||
return c.json({ channels: result.results ?? [] });
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a new channel.
|
||||
*/
|
||||
channels.post('/', async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body?.name || !body?.base_url || !body?.api_key) {
|
||||
return jsonError(c, 400, 'missing_fields', 'missing_fields');
|
||||
}
|
||||
channels.post("/", async (c) => {
|
||||
const body = (await c.req.json().catch(() => null)) as ChannelPayload | null;
|
||||
if (!body?.name || !body?.base_url || !body?.api_key) {
|
||||
return jsonError(c, 400, "missing_fields", "missing_fields");
|
||||
}
|
||||
|
||||
const requestedId = resolveChannelId(body);
|
||||
if (requestedId) {
|
||||
const exists = await c.env.DB.prepare('SELECT id FROM channels WHERE id = ?').bind(requestedId).first();
|
||||
if (exists) {
|
||||
return jsonError(c, 409, 'channel_id_exists', 'channel_id_exists');
|
||||
}
|
||||
}
|
||||
const requestedId = resolveChannelId(body);
|
||||
if (requestedId) {
|
||||
const exists = await c.env.DB.prepare(
|
||||
"SELECT id FROM channels WHERE id = ?",
|
||||
)
|
||||
.bind(requestedId)
|
||||
.first();
|
||||
if (exists) {
|
||||
return jsonError(c, 409, "channel_id_exists", "channel_id_exists");
|
||||
}
|
||||
}
|
||||
|
||||
const id = requestedId ?? generateToken('ch_');
|
||||
const now = nowIso();
|
||||
const id = requestedId ?? generateToken("ch_");
|
||||
const now = nowIso();
|
||||
|
||||
await c.env.DB.prepare(
|
||||
'INSERT INTO channels (id, name, base_url, api_key, weight, status, rate_limit, models_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
.bind(
|
||||
id,
|
||||
body.name,
|
||||
normalizeBaseUrl(body.base_url),
|
||||
body.api_key,
|
||||
Number(body.weight ?? 1),
|
||||
body.status ?? 'active',
|
||||
body.rate_limit ?? 0,
|
||||
JSON.stringify(body.models ?? []),
|
||||
now,
|
||||
now
|
||||
)
|
||||
.run();
|
||||
await c.env.DB.prepare(
|
||||
"INSERT INTO channels (id, name, base_url, api_key, weight, status, rate_limit, models_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(
|
||||
id,
|
||||
body.name,
|
||||
normalizeBaseUrl(String(body.base_url)),
|
||||
body.api_key,
|
||||
Number(body.weight ?? 1),
|
||||
body.status ?? "active",
|
||||
body.rate_limit ?? 0,
|
||||
JSON.stringify(body.models ?? []),
|
||||
now,
|
||||
now,
|
||||
)
|
||||
.run();
|
||||
|
||||
return c.json({ id });
|
||||
return c.json({ id });
|
||||
});
|
||||
|
||||
/**
|
||||
* Updates a channel.
|
||||
*/
|
||||
channels.patch('/:id', async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const id = c.req.param('id');
|
||||
if (!body) {
|
||||
return jsonError(c, 400, 'missing_body', 'missing_body');
|
||||
}
|
||||
channels.patch("/:id", async (c) => {
|
||||
const body = (await c.req.json().catch(() => null)) as ChannelPayload | null;
|
||||
const id = c.req.param("id");
|
||||
if (!body) {
|
||||
return jsonError(c, 400, "missing_body", "missing_body");
|
||||
}
|
||||
|
||||
const current = await c.env.DB.prepare('SELECT * FROM channels WHERE id = ?').bind(id).first();
|
||||
if (!current) {
|
||||
return jsonError(c, 404, 'channel_not_found', 'channel_not_found');
|
||||
}
|
||||
const current = await c.env.DB.prepare("SELECT * FROM channels WHERE id = ?")
|
||||
.bind(id)
|
||||
.first<ChannelRecord>();
|
||||
if (!current) {
|
||||
return jsonError(c, 404, "channel_not_found", "channel_not_found");
|
||||
}
|
||||
|
||||
const models = body.models ?? safeJsonParse(current.models_json, []);
|
||||
const models = body.models ?? safeJsonParse(current.models_json, []);
|
||||
|
||||
await c.env.DB.prepare(
|
||||
'UPDATE channels SET name = ?, base_url = ?, api_key = ?, weight = ?, status = ?, rate_limit = ?, models_json = ?, updated_at = ? WHERE id = ?'
|
||||
)
|
||||
.bind(
|
||||
body.name ?? current.name,
|
||||
normalizeBaseUrl(body.base_url ?? current.base_url),
|
||||
body.api_key ?? current.api_key,
|
||||
Number(body.weight ?? current.weight ?? 1),
|
||||
body.status ?? current.status,
|
||||
body.rate_limit ?? current.rate_limit ?? 0,
|
||||
JSON.stringify(models),
|
||||
nowIso(),
|
||||
id
|
||||
)
|
||||
.run();
|
||||
await c.env.DB.prepare(
|
||||
"UPDATE channels SET name = ?, base_url = ?, api_key = ?, weight = ?, status = ?, rate_limit = ?, models_json = ?, updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(
|
||||
body.name ?? current.name,
|
||||
normalizeBaseUrl(String(body.base_url ?? current.base_url)),
|
||||
body.api_key ?? current.api_key,
|
||||
Number(body.weight ?? current.weight ?? 1),
|
||||
body.status ?? current.status,
|
||||
body.rate_limit ?? current.rate_limit ?? 0,
|
||||
JSON.stringify(models),
|
||||
nowIso(),
|
||||
id,
|
||||
)
|
||||
.run();
|
||||
|
||||
return c.json({ ok: true });
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Deletes a channel.
|
||||
*/
|
||||
channels.delete('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
await c.env.DB.prepare('DELETE FROM channels WHERE id = ?').bind(id).run();
|
||||
return c.json({ ok: true });
|
||||
channels.delete("/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
await c.env.DB.prepare("DELETE FROM channels WHERE id = ?").bind(id).run();
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Tests channel connectivity and updates model list.
|
||||
*/
|
||||
channels.post('/:id/test', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const channel = await c.env.DB.prepare('SELECT * FROM channels WHERE id = ?').bind(id).first();
|
||||
if (!channel) {
|
||||
return jsonError(c, 404, 'channel_not_found', 'channel_not_found');
|
||||
}
|
||||
channels.post("/:id/test", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const channel = await c.env.DB.prepare("SELECT * FROM channels WHERE id = ?")
|
||||
.bind(id)
|
||||
.first();
|
||||
if (!channel) {
|
||||
return jsonError(c, 404, "channel_not_found", "channel_not_found");
|
||||
}
|
||||
|
||||
const target = `${normalizeBaseUrl(String(channel.base_url))}/v1/models`;
|
||||
const response = await fetch(target, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${channel.api_key}`,
|
||||
'x-api-key': String(channel.api_key),
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
const target = `${normalizeBaseUrl(String(channel.base_url))}/v1/models`;
|
||||
const response = await fetch(target, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${channel.api_key}`,
|
||||
"x-api-key": String(channel.api_key),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
await c.env.DB.prepare('UPDATE channels SET status = ?, updated_at = ? WHERE id = ?')
|
||||
.bind('error', nowIso(), id)
|
||||
.run();
|
||||
return jsonError(c, 502, 'channel_unreachable', 'channel_unreachable');
|
||||
}
|
||||
if (!response.ok) {
|
||||
await c.env.DB.prepare(
|
||||
"UPDATE channels SET status = ?, updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind("error", nowIso(), id)
|
||||
.run();
|
||||
return jsonError(c, 502, "channel_unreachable", "channel_unreachable");
|
||||
}
|
||||
|
||||
const data = await response.json().catch(() => ({ data: [] }));
|
||||
await c.env.DB.prepare('UPDATE channels SET status = ?, models_json = ?, updated_at = ? WHERE id = ?')
|
||||
.bind('active', JSON.stringify(data), nowIso(), id)
|
||||
.run();
|
||||
const payload = (await response.json().catch(() => ({ data: [] }))) as
|
||||
| { data?: unknown[] }
|
||||
| unknown[];
|
||||
const models = Array.isArray(payload) ? payload : (payload?.data ?? []);
|
||||
await c.env.DB.prepare(
|
||||
"UPDATE channels SET status = ?, models_json = ?, updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind("active", JSON.stringify(payload), nowIso(), id)
|
||||
.run();
|
||||
|
||||
return c.json({ ok: true, models: data?.data ?? [] });
|
||||
return c.json({ ok: true, models });
|
||||
});
|
||||
|
||||
export default channels;
|
||||
@@ -1,66 +1,71 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { AppEnv } from '../env';
|
||||
import { Hono } from "hono";
|
||||
import type { AppEnv } from "../env";
|
||||
|
||||
const dashboard = new Hono<AppEnv>();
|
||||
|
||||
function buildDateFilters(query: Record<string, string>) {
|
||||
let sql = ' WHERE 1=1';
|
||||
const params: Array<string> = [];
|
||||
if (query.from) {
|
||||
sql += ' AND created_at >= ?';
|
||||
params.push(query.from);
|
||||
}
|
||||
if (query.to) {
|
||||
sql += ' AND created_at <= ?';
|
||||
params.push(query.to);
|
||||
}
|
||||
return { sql, params };
|
||||
let sql = " WHERE 1=1";
|
||||
const params: Array<string> = [];
|
||||
if (query.from) {
|
||||
sql += " AND created_at >= ?";
|
||||
params.push(query.from);
|
||||
}
|
||||
if (query.to) {
|
||||
sql += " AND created_at <= ?";
|
||||
params.push(query.to);
|
||||
}
|
||||
return { sql, params };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns aggregated usage metrics.
|
||||
*/
|
||||
dashboard.get('/', async (c) => {
|
||||
const query = c.req.query();
|
||||
const { sql, params } = buildDateFilters(query);
|
||||
dashboard.get("/", async (c) => {
|
||||
const query = c.req.query();
|
||||
const { sql, params } = buildDateFilters(query);
|
||||
|
||||
const summary = await c.env.DB.prepare(
|
||||
`SELECT COUNT(*) as total_requests, COALESCE(SUM(total_tokens), 0) as total_tokens, COALESCE(AVG(latency_ms), 0) as avg_latency, COALESCE(SUM(CASE WHEN status != 'ok' THEN 1 ELSE 0 END), 0) as total_errors FROM usage_logs${sql}`
|
||||
)
|
||||
.bind(...params)
|
||||
.first();
|
||||
const summary = await c.env.DB.prepare(
|
||||
`SELECT COUNT(*) as total_requests, COALESCE(SUM(total_tokens), 0) as total_tokens, COALESCE(AVG(latency_ms), 0) as avg_latency, COALESCE(SUM(CASE WHEN status != 'ok' THEN 1 ELSE 0 END), 0) as total_errors FROM usage_logs${sql}`,
|
||||
)
|
||||
.bind(...params)
|
||||
.first();
|
||||
|
||||
const byDay = await c.env.DB.prepare(
|
||||
`SELECT substr(created_at, 1, 10) as day, COUNT(*) as requests, COALESCE(SUM(total_tokens), 0) as tokens FROM usage_logs${sql} GROUP BY day ORDER BY day DESC LIMIT 30`
|
||||
)
|
||||
.bind(...params)
|
||||
.all();
|
||||
const byDay = await c.env.DB.prepare(
|
||||
`SELECT substr(created_at, 1, 10) as day, COUNT(*) as requests, COALESCE(SUM(total_tokens), 0) as tokens FROM usage_logs${sql} GROUP BY day ORDER BY day DESC LIMIT 30`,
|
||||
)
|
||||
.bind(...params)
|
||||
.all();
|
||||
|
||||
const byModel = await c.env.DB.prepare(
|
||||
`SELECT model, COUNT(*) as requests, COALESCE(SUM(total_tokens), 0) as tokens FROM usage_logs${sql} GROUP BY model ORDER BY requests DESC LIMIT 20`
|
||||
)
|
||||
.bind(...params)
|
||||
.all();
|
||||
const byModel = await c.env.DB.prepare(
|
||||
`SELECT model, COUNT(*) as requests, COALESCE(SUM(total_tokens), 0) as tokens FROM usage_logs${sql} GROUP BY model ORDER BY requests DESC LIMIT 20`,
|
||||
)
|
||||
.bind(...params)
|
||||
.all();
|
||||
|
||||
const byChannel = await c.env.DB.prepare(
|
||||
`SELECT channels.id as channel_id, channels.name as channel_name, COUNT(usage_logs.id) as requests, COALESCE(SUM(usage_logs.total_tokens), 0) as tokens FROM usage_logs LEFT JOIN channels ON channels.id = usage_logs.channel_id${sql} GROUP BY channels.id, channels.name ORDER BY requests DESC LIMIT 20`
|
||||
)
|
||||
.bind(...params)
|
||||
.all();
|
||||
const byChannel = await c.env.DB.prepare(
|
||||
`SELECT channels.id as channel_id, channels.name as channel_name, COUNT(usage_logs.id) as requests, COALESCE(SUM(usage_logs.total_tokens), 0) as tokens FROM usage_logs LEFT JOIN channels ON channels.id = usage_logs.channel_id${sql} GROUP BY channels.id, channels.name ORDER BY requests DESC LIMIT 20`,
|
||||
)
|
||||
.bind(...params)
|
||||
.all();
|
||||
|
||||
const byToken = await c.env.DB.prepare(
|
||||
`SELECT tokens.id as token_id, tokens.name as token_name, COUNT(usage_logs.id) as requests, COALESCE(SUM(usage_logs.total_tokens), 0) as tokens FROM usage_logs LEFT JOIN tokens ON tokens.id = usage_logs.token_id${sql} GROUP BY tokens.id, tokens.name ORDER BY requests DESC LIMIT 20`
|
||||
)
|
||||
.bind(...params)
|
||||
.all();
|
||||
const byToken = await c.env.DB.prepare(
|
||||
`SELECT tokens.id as token_id, tokens.name as token_name, COUNT(usage_logs.id) as requests, COALESCE(SUM(usage_logs.total_tokens), 0) as tokens FROM usage_logs LEFT JOIN tokens ON tokens.id = usage_logs.token_id${sql} GROUP BY tokens.id, tokens.name ORDER BY requests DESC LIMIT 20`,
|
||||
)
|
||||
.bind(...params)
|
||||
.all();
|
||||
|
||||
return c.json({
|
||||
summary: summary ?? { total_requests: 0, total_tokens: 0, avg_latency: 0, total_errors: 0 },
|
||||
byDay: byDay.results ?? [],
|
||||
byModel: byModel.results ?? [],
|
||||
byChannel: byChannel.results ?? [],
|
||||
byToken: byToken.results ?? []
|
||||
});
|
||||
return c.json({
|
||||
summary: summary ?? {
|
||||
total_requests: 0,
|
||||
total_tokens: 0,
|
||||
avg_latency: 0,
|
||||
total_errors: 0,
|
||||
},
|
||||
byDay: byDay.results ?? [],
|
||||
byModel: byModel.results ?? [],
|
||||
byChannel: byChannel.results ?? [],
|
||||
byToken: byToken.results ?? [],
|
||||
});
|
||||
});
|
||||
|
||||
export default dashboard;
|
||||
@@ -1,26 +1,34 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { AppEnv } from '../env';
|
||||
import { extractModels } from '../services/channels';
|
||||
import { Hono } from "hono";
|
||||
import type { AppEnv } from "../env";
|
||||
import { type ChannelRecord, extractModels } from "../services/channels";
|
||||
|
||||
const models = new Hono<AppEnv>();
|
||||
|
||||
/**
|
||||
* Returns aggregated models from all channels.
|
||||
*/
|
||||
models.get('/', async (c) => {
|
||||
const result = await c.env.DB.prepare('SELECT * FROM channels WHERE status = ?').bind('active').all();
|
||||
const entries = (result.results ?? []).flatMap((channel) => extractModels(channel as any));
|
||||
models.get("/", async (c) => {
|
||||
const result = await c.env.DB.prepare(
|
||||
"SELECT * FROM channels WHERE status = ?",
|
||||
)
|
||||
.bind("active")
|
||||
.all();
|
||||
const channels = (result.results ?? []) as ChannelRecord[];
|
||||
const entries = channels.flatMap((channel) => extractModels(channel));
|
||||
|
||||
const map = new Map<string, { id: string; channels: { id: string; name: string }[] }>();
|
||||
for (const entry of entries) {
|
||||
const existing = map.get(entry.id) ?? { id: entry.id, channels: [] };
|
||||
existing.channels.push({ id: entry.channelId, name: entry.channelName });
|
||||
map.set(entry.id, existing);
|
||||
}
|
||||
const map = new Map<
|
||||
string,
|
||||
{ id: string; channels: { id: string; name: string }[] }
|
||||
>();
|
||||
for (const entry of entries) {
|
||||
const existing = map.get(entry.id) ?? { id: entry.id, channels: [] };
|
||||
existing.channels.push({ id: entry.channelId, name: entry.channelName });
|
||||
map.set(entry.id, existing);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
models: Array.from(map.values())
|
||||
});
|
||||
return c.json({
|
||||
models: Array.from(map.values()),
|
||||
});
|
||||
});
|
||||
|
||||
export default models;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,37 +1,43 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { AppEnv } from '../env';
|
||||
import { newApiAuth } from '../middleware/newApiAuth';
|
||||
import { Hono } from "hono";
|
||||
import type { AppEnv } from "../env";
|
||||
import { newApiAuth } from "../middleware/newApiAuth";
|
||||
|
||||
const groups = new Hono<AppEnv>({ strict: false });
|
||||
groups.use('*', newApiAuth);
|
||||
groups.use("*", newApiAuth);
|
||||
|
||||
function collectGroups(rows: Array<{ group_name?: string | null }>): string[] {
|
||||
const names = new Set<string>();
|
||||
for (const row of rows) {
|
||||
const raw = row.group_name;
|
||||
if (!raw) {
|
||||
continue;
|
||||
}
|
||||
raw
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)
|
||||
.forEach((item) => names.add(item));
|
||||
}
|
||||
if (names.size === 0) {
|
||||
names.add('default');
|
||||
}
|
||||
return Array.from(names).sort((a, b) => a.localeCompare(b));
|
||||
const names = new Set<string>();
|
||||
for (const row of rows) {
|
||||
const raw = row.group_name;
|
||||
if (!raw) {
|
||||
continue;
|
||||
}
|
||||
raw
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)
|
||||
.forEach((item) => {
|
||||
names.add(item);
|
||||
});
|
||||
}
|
||||
if (names.size === 0) {
|
||||
names.add("default");
|
||||
}
|
||||
return Array.from(names).sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
groups.get('/', async (c) => {
|
||||
const result = await c.env.DB.prepare('SELECT group_name FROM channels').all();
|
||||
const data = collectGroups((result.results ?? []) as Array<{ group_name?: string | null }>);
|
||||
return c.json({
|
||||
success: true,
|
||||
message: '',
|
||||
data
|
||||
});
|
||||
groups.get("/", async (c) => {
|
||||
const result = await c.env.DB.prepare(
|
||||
"SELECT group_name FROM channels",
|
||||
).all();
|
||||
const data = collectGroups(
|
||||
(result.results ?? []) as Array<{ group_name?: string | null }>,
|
||||
);
|
||||
return c.json({
|
||||
success: true,
|
||||
message: "",
|
||||
data,
|
||||
});
|
||||
});
|
||||
|
||||
export default groups;
|
||||
@@ -1,27 +1,29 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { AppEnv } from '../env';
|
||||
import { newApiAuth } from '../middleware/newApiAuth';
|
||||
import { extractModelIds } from '../services/newapi';
|
||||
import { Hono } from "hono";
|
||||
import type { AppEnv } from "../env";
|
||||
import { newApiAuth } from "../middleware/newApiAuth";
|
||||
import { type ChannelRow, extractModelIds } from "../services/newapi";
|
||||
|
||||
const users = new Hono<AppEnv>({ strict: false });
|
||||
users.use('*', newApiAuth);
|
||||
users.use("*", newApiAuth);
|
||||
|
||||
users.get('/models', async (c) => {
|
||||
const result = await c.env.DB.prepare('SELECT * FROM channels WHERE status = ?')
|
||||
.bind('active')
|
||||
.all();
|
||||
const models = new Set<string>();
|
||||
for (const row of result.results ?? []) {
|
||||
for (const id of extractModelIds(row as any)) {
|
||||
models.add(id);
|
||||
}
|
||||
}
|
||||
const data = Array.from(models).map((id) => ({ id, name: id }));
|
||||
return c.json({
|
||||
success: true,
|
||||
message: '',
|
||||
data
|
||||
});
|
||||
users.get("/models", async (c) => {
|
||||
const result = await c.env.DB.prepare(
|
||||
"SELECT * FROM channels WHERE status = ?",
|
||||
)
|
||||
.bind("active")
|
||||
.all();
|
||||
const models = new Set<string>();
|
||||
for (const row of (result.results ?? []) as ChannelRow[]) {
|
||||
for (const id of extractModelIds(row)) {
|
||||
models.add(id);
|
||||
}
|
||||
}
|
||||
const data = Array.from(models).map((id) => ({ id, name: id }));
|
||||
return c.json({
|
||||
success: true,
|
||||
message: "",
|
||||
data,
|
||||
});
|
||||
});
|
||||
|
||||
export default users;
|
||||
+224
-122
@@ -1,29 +1,52 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { AppEnv } from '../env';
|
||||
import { tokenAuth, type TokenRecord } from '../middleware/tokenAuth';
|
||||
import { createWeightedOrder, extractModels, type ChannelRecord } from '../services/channels';
|
||||
import { recordUsage } from '../services/usage';
|
||||
import { jsonError } from '../utils/http';
|
||||
import { safeJsonParse } from '../utils/json';
|
||||
import { normalizeBaseUrl } from '../utils/url';
|
||||
import { Hono } from "hono";
|
||||
import type { AppEnv } from "../env";
|
||||
import { type TokenRecord, tokenAuth } from "../middleware/tokenAuth";
|
||||
import {
|
||||
type ChannelRecord,
|
||||
createWeightedOrder,
|
||||
extractModels,
|
||||
} from "../services/channels";
|
||||
import { recordUsage } from "../services/usage";
|
||||
import { jsonError } from "../utils/http";
|
||||
import { safeJsonParse } from "../utils/json";
|
||||
import { normalizeBaseUrl } from "../utils/url";
|
||||
import {
|
||||
type NormalizedUsage,
|
||||
parseUsageFromHeaders,
|
||||
parseUsageFromJson,
|
||||
parseUsageFromSse,
|
||||
} from "../utils/usage";
|
||||
|
||||
const proxy = new Hono<AppEnv>();
|
||||
|
||||
function channelSupportsModel(channel: ChannelRecord, model?: string | null): boolean {
|
||||
if (!model) {
|
||||
return true;
|
||||
}
|
||||
const models = extractModels(channel);
|
||||
return models.some((entry) => entry.id === model);
|
||||
type ExecutionContextLike = {
|
||||
waitUntil: (promise: Promise<unknown>) => void;
|
||||
};
|
||||
|
||||
function channelSupportsModel(
|
||||
channel: ChannelRecord,
|
||||
model?: string | null,
|
||||
): boolean {
|
||||
if (!model) {
|
||||
return true;
|
||||
}
|
||||
const models = extractModels(channel);
|
||||
return models.some((entry) => entry.id === model);
|
||||
}
|
||||
|
||||
function filterAllowedChannels(channels: ChannelRecord[], tokenRecord: TokenRecord): ChannelRecord[] {
|
||||
const allowed = safeJsonParse<string[] | null>(tokenRecord.allowed_channels, null);
|
||||
if (!allowed || allowed.length === 0) {
|
||||
return channels;
|
||||
}
|
||||
const allowedSet = new Set(allowed);
|
||||
return channels.filter((channel) => allowedSet.has(channel.id));
|
||||
function filterAllowedChannels(
|
||||
channels: ChannelRecord[],
|
||||
tokenRecord: TokenRecord,
|
||||
): ChannelRecord[] {
|
||||
const allowed = safeJsonParse<string[] | null>(
|
||||
tokenRecord.allowed_channels,
|
||||
null,
|
||||
);
|
||||
if (!allowed || allowed.length === 0) {
|
||||
return channels;
|
||||
}
|
||||
const allowedSet = new Set(allowed);
|
||||
return channels.filter((channel) => allowedSet.has(channel.id));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,7 +59,7 @@ function filterAllowedChannels(channels: ChannelRecord[], tokenRecord: TokenReco
|
||||
* True if the status is retryable.
|
||||
*/
|
||||
function isRetryableStatus(status: number): boolean {
|
||||
return status === 408 || status === 429 || status >= 500;
|
||||
return status === 408 || status === 429 || status >= 500;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,124 +72,203 @@ function isRetryableStatus(status: number): boolean {
|
||||
* Promise resolved after delay.
|
||||
*/
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
if (ms <= 0) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
if (ms <= 0) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI-compatible proxy handler.
|
||||
*/
|
||||
proxy.all('/*', tokenAuth, async (c) => {
|
||||
const tokenRecord = c.get('tokenRecord') as TokenRecord;
|
||||
const requestText = await c.req.text();
|
||||
const parsedBody = requestText ? safeJsonParse<any>(requestText, null) : null;
|
||||
const model = parsedBody?.model ?? null;
|
||||
const isStream = parsedBody?.stream === true;
|
||||
proxy.all("/*", tokenAuth, async (c) => {
|
||||
const tokenRecord = c.get("tokenRecord") as TokenRecord;
|
||||
let requestText = await c.req.text();
|
||||
const parsedBody = requestText
|
||||
? safeJsonParse<Record<string, unknown> | null>(requestText, null)
|
||||
: null;
|
||||
const model =
|
||||
parsedBody?.model !== undefined && parsedBody?.model !== null
|
||||
? String(parsedBody.model)
|
||||
: null;
|
||||
const isStream = parsedBody?.stream === true;
|
||||
if (isStream && parsedBody && typeof parsedBody === "object") {
|
||||
const streamOptions = (parsedBody as Record<string, unknown>)
|
||||
.stream_options;
|
||||
if (!streamOptions || typeof streamOptions !== "object") {
|
||||
(parsedBody as Record<string, unknown>).stream_options = {
|
||||
include_usage: true,
|
||||
};
|
||||
} else if (
|
||||
(streamOptions as Record<string, unknown>).include_usage !== true
|
||||
) {
|
||||
(streamOptions as Record<string, unknown>).include_usage = true;
|
||||
}
|
||||
requestText = JSON.stringify(parsedBody);
|
||||
}
|
||||
|
||||
const channelResult = await c.env.DB.prepare('SELECT * FROM channels WHERE status = ?')
|
||||
.bind('active')
|
||||
.all();
|
||||
const activeChannels = (channelResult.results ?? []) as ChannelRecord[];
|
||||
const allowedChannels = filterAllowedChannels(activeChannels, tokenRecord);
|
||||
const modelChannels = allowedChannels.filter((channel) => channelSupportsModel(channel, model));
|
||||
const candidates = modelChannels.length > 0 ? modelChannels : allowedChannels;
|
||||
const channelResult = await c.env.DB.prepare(
|
||||
"SELECT * FROM channels WHERE status = ?",
|
||||
)
|
||||
.bind("active")
|
||||
.all();
|
||||
const activeChannels = (channelResult.results ?? []) as ChannelRecord[];
|
||||
const allowedChannels = filterAllowedChannels(activeChannels, tokenRecord);
|
||||
const modelChannels = allowedChannels.filter((channel) =>
|
||||
channelSupportsModel(channel, model),
|
||||
);
|
||||
const candidates = modelChannels.length > 0 ? modelChannels : allowedChannels;
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return jsonError(c, 503, 'no_available_channels', 'no_available_channels');
|
||||
}
|
||||
if (candidates.length === 0) {
|
||||
return jsonError(c, 503, "no_available_channels", "no_available_channels");
|
||||
}
|
||||
|
||||
const ordered = createWeightedOrder(candidates);
|
||||
const targetPath = c.req.path;
|
||||
const retryRounds = Math.max(1, Number(c.env.PROXY_RETRY_ROUNDS ?? '1'));
|
||||
const retryDelayMs = Math.max(0, Number(c.env.PROXY_RETRY_DELAY_MS ?? '200'));
|
||||
let lastResponse: Response | null = null;
|
||||
let lastChannel: ChannelRecord | null = null;
|
||||
const start = Date.now();
|
||||
let selectedChannel: ChannelRecord | null = null;
|
||||
let usagePayload: any = null;
|
||||
const ordered = createWeightedOrder(candidates);
|
||||
const targetPath = c.req.path;
|
||||
const retryRounds = Math.max(1, Number(c.env.PROXY_RETRY_ROUNDS ?? "1"));
|
||||
const retryDelayMs = Math.max(0, Number(c.env.PROXY_RETRY_DELAY_MS ?? "200"));
|
||||
let lastResponse: Response | null = null;
|
||||
let lastChannel: ChannelRecord | null = null;
|
||||
const start = Date.now();
|
||||
let selectedChannel: ChannelRecord | null = null;
|
||||
|
||||
let round = 0;
|
||||
while (round < retryRounds && !selectedChannel) {
|
||||
let shouldRetry = false;
|
||||
for (const channel of ordered) {
|
||||
lastChannel = channel;
|
||||
const target = `${normalizeBaseUrl(channel.base_url)}${targetPath}${c.req.url.includes('?') ? `?${c.req.url.split('?')[1]}` : ''}`;
|
||||
const headers = new Headers(c.req.header());
|
||||
headers.set('Authorization', `Bearer ${channel.api_key}`);
|
||||
headers.set('x-api-key', String(channel.api_key));
|
||||
headers.delete('host');
|
||||
headers.delete('content-length');
|
||||
let round = 0;
|
||||
while (round < retryRounds && !selectedChannel) {
|
||||
let shouldRetry = false;
|
||||
for (const channel of ordered) {
|
||||
lastChannel = channel;
|
||||
const target = `${normalizeBaseUrl(channel.base_url)}${targetPath}${c.req.url.includes("?") ? `?${c.req.url.split("?")[1]}` : ""}`;
|
||||
const headers = new Headers(c.req.header());
|
||||
headers.set("Authorization", `Bearer ${channel.api_key}`);
|
||||
headers.set("x-api-key", String(channel.api_key));
|
||||
headers.delete("host");
|
||||
headers.delete("content-length");
|
||||
|
||||
try {
|
||||
const response = await fetch(target, {
|
||||
method: c.req.method,
|
||||
headers,
|
||||
body: requestText || undefined
|
||||
});
|
||||
try {
|
||||
const response = await fetch(target, {
|
||||
method: c.req.method,
|
||||
headers,
|
||||
body: requestText || undefined,
|
||||
});
|
||||
|
||||
lastResponse = response;
|
||||
if (response.ok) {
|
||||
selectedChannel = channel;
|
||||
if (!isStream && response.headers.get('content-type')?.includes('application/json')) {
|
||||
const data = await response.clone().json().catch(() => null);
|
||||
usagePayload = data?.usage ?? null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
lastResponse = response;
|
||||
if (response.ok) {
|
||||
selectedChannel = channel;
|
||||
break;
|
||||
}
|
||||
|
||||
if (isRetryableStatus(response.status)) {
|
||||
shouldRetry = true;
|
||||
}
|
||||
} catch {
|
||||
lastResponse = null;
|
||||
shouldRetry = true;
|
||||
}
|
||||
}
|
||||
if (isRetryableStatus(response.status)) {
|
||||
shouldRetry = true;
|
||||
}
|
||||
} catch {
|
||||
lastResponse = null;
|
||||
shouldRetry = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedChannel || !shouldRetry) {
|
||||
break;
|
||||
}
|
||||
if (selectedChannel || !shouldRetry) {
|
||||
break;
|
||||
}
|
||||
|
||||
round += 1;
|
||||
if (round < retryRounds) {
|
||||
await sleep(retryDelayMs);
|
||||
}
|
||||
}
|
||||
round += 1;
|
||||
if (round < retryRounds) {
|
||||
await sleep(retryDelayMs);
|
||||
}
|
||||
}
|
||||
|
||||
const latencyMs = Date.now() - start;
|
||||
const latencyMs = Date.now() - start;
|
||||
|
||||
if (!lastResponse) {
|
||||
await recordUsage(c.env.DB, {
|
||||
tokenId: tokenRecord.id,
|
||||
model,
|
||||
requestPath: targetPath,
|
||||
totalTokens: 0,
|
||||
latencyMs,
|
||||
status: 'error'
|
||||
});
|
||||
return jsonError(c, 502, 'upstream_unavailable', 'upstream_unavailable');
|
||||
}
|
||||
if (!lastResponse) {
|
||||
await recordUsage(c.env.DB, {
|
||||
tokenId: tokenRecord.id,
|
||||
model,
|
||||
requestPath: targetPath,
|
||||
totalTokens: 0,
|
||||
latencyMs,
|
||||
status: "error",
|
||||
});
|
||||
return jsonError(c, 502, "upstream_unavailable", "upstream_unavailable");
|
||||
}
|
||||
|
||||
const channelForUsage = selectedChannel ?? lastChannel;
|
||||
if (channelForUsage) {
|
||||
const totalTokens = Number(usagePayload?.total_tokens ?? 0);
|
||||
await recordUsage(c.env.DB, {
|
||||
tokenId: tokenRecord.id,
|
||||
channelId: channelForUsage.id,
|
||||
model,
|
||||
requestPath: targetPath,
|
||||
totalTokens: Number.isNaN(totalTokens) ? 0 : totalTokens,
|
||||
promptTokens: Number(usagePayload?.prompt_tokens ?? 0),
|
||||
completionTokens: Number(usagePayload?.completion_tokens ?? 0),
|
||||
cost: 0,
|
||||
latencyMs,
|
||||
status: lastResponse.ok ? 'ok' : 'error'
|
||||
});
|
||||
}
|
||||
const channelForUsage = selectedChannel ?? lastChannel;
|
||||
if (channelForUsage && lastResponse) {
|
||||
const record = async (usage: NormalizedUsage | null) => {
|
||||
const normalized = usage ?? {
|
||||
totalTokens: 0,
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
};
|
||||
await recordUsage(c.env.DB, {
|
||||
tokenId: tokenRecord.id,
|
||||
channelId: channelForUsage.id,
|
||||
model,
|
||||
requestPath: targetPath,
|
||||
totalTokens: normalized.totalTokens,
|
||||
promptTokens: normalized.promptTokens,
|
||||
completionTokens: normalized.completionTokens,
|
||||
cost: 0,
|
||||
latencyMs,
|
||||
status: lastResponse.ok ? "ok" : "error",
|
||||
});
|
||||
};
|
||||
const logUsage = (
|
||||
label: string,
|
||||
usage: NormalizedUsage | null,
|
||||
source: string,
|
||||
) => {
|
||||
console.log(`[usage] ${label}`, {
|
||||
source,
|
||||
total_tokens: usage?.totalTokens ?? 0,
|
||||
prompt_tokens: usage?.promptTokens ?? 0,
|
||||
completion_tokens: usage?.completionTokens ?? 0,
|
||||
stream: isStream,
|
||||
status: lastResponse.status,
|
||||
model,
|
||||
path: targetPath,
|
||||
});
|
||||
};
|
||||
|
||||
return lastResponse;
|
||||
const headerUsage = parseUsageFromHeaders(lastResponse.headers);
|
||||
let jsonUsage: NormalizedUsage | null = null;
|
||||
if (
|
||||
!isStream &&
|
||||
lastResponse.ok &&
|
||||
lastResponse.headers.get("content-type")?.includes("application/json")
|
||||
) {
|
||||
const data = await lastResponse
|
||||
.clone()
|
||||
.json()
|
||||
.catch(() => null);
|
||||
jsonUsage = parseUsageFromJson(data);
|
||||
}
|
||||
const immediateUsage = jsonUsage ?? headerUsage;
|
||||
const immediateSource = jsonUsage
|
||||
? "json"
|
||||
: headerUsage
|
||||
? "header"
|
||||
: "none";
|
||||
|
||||
if (isStream && !immediateUsage) {
|
||||
const executionCtx = (c as { executionCtx?: ExecutionContextLike })
|
||||
.executionCtx;
|
||||
const task = parseUsageFromSse(lastResponse.clone())
|
||||
.then((streamUsage) => {
|
||||
logUsage("stream", streamUsage, streamUsage ? "sse" : "sse-none");
|
||||
return record(streamUsage);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
if (executionCtx?.waitUntil) {
|
||||
executionCtx.waitUntil(task);
|
||||
} else {
|
||||
task.catch(() => undefined);
|
||||
}
|
||||
} else {
|
||||
logUsage("immediate", immediateUsage, immediateSource);
|
||||
await record(immediateUsage);
|
||||
}
|
||||
}
|
||||
|
||||
return lastResponse;
|
||||
});
|
||||
|
||||
export default proxy;
|
||||
@@ -1,33 +1,82 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { AppEnv } from '../env';
|
||||
import { getRetentionDays, setRetentionDays } from '../services/settings';
|
||||
import { jsonError } from '../utils/http';
|
||||
import { Hono } from "hono";
|
||||
import type { AppEnv } from "../env";
|
||||
import {
|
||||
getRetentionDays,
|
||||
getSessionTtlHours,
|
||||
isAdminPasswordSet,
|
||||
setAdminPasswordHash,
|
||||
setRetentionDays,
|
||||
setSessionTtlHours,
|
||||
} from "../services/settings";
|
||||
import { sha256Hex } from "../utils/crypto";
|
||||
import { jsonError } from "../utils/http";
|
||||
|
||||
const settings = new Hono<AppEnv>();
|
||||
|
||||
/**
|
||||
* Returns settings values.
|
||||
*/
|
||||
settings.get('/', async (c) => {
|
||||
const fallback = Number(c.env.LOG_RETENTION_DAYS ?? '30');
|
||||
const retention = await getRetentionDays(c.env.DB, Number.isNaN(fallback) ? 30 : fallback);
|
||||
return c.json({ log_retention_days: retention });
|
||||
settings.get("/", async (c) => {
|
||||
const retention = await getRetentionDays(c.env.DB);
|
||||
const sessionTtlHours = await getSessionTtlHours(c.env.DB);
|
||||
const adminPasswordSet = await isAdminPasswordSet(c.env.DB);
|
||||
return c.json({
|
||||
log_retention_days: retention,
|
||||
session_ttl_hours: sessionTtlHours,
|
||||
admin_password_set: adminPasswordSet,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Updates settings values.
|
||||
*/
|
||||
settings.put('/', async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body?.log_retention_days) {
|
||||
return jsonError(c, 400, 'log_retention_days_required', 'log_retention_days_required');
|
||||
}
|
||||
const days = Number(body.log_retention_days);
|
||||
if (Number.isNaN(days) || days < 1) {
|
||||
return jsonError(c, 400, 'invalid_log_retention_days', 'invalid_log_retention_days');
|
||||
}
|
||||
await setRetentionDays(c.env.DB, days);
|
||||
return c.json({ ok: true });
|
||||
settings.put("/", async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body) {
|
||||
return jsonError(c, 400, "settings_required", "settings_required");
|
||||
}
|
||||
|
||||
let touched = false;
|
||||
|
||||
if (body.log_retention_days !== undefined) {
|
||||
const days = Number(body.log_retention_days);
|
||||
if (Number.isNaN(days) || days < 1) {
|
||||
return jsonError(
|
||||
c,
|
||||
400,
|
||||
"invalid_log_retention_days",
|
||||
"invalid_log_retention_days",
|
||||
);
|
||||
}
|
||||
await setRetentionDays(c.env.DB, days);
|
||||
touched = true;
|
||||
}
|
||||
|
||||
if (body.session_ttl_hours !== undefined) {
|
||||
const hours = Number(body.session_ttl_hours);
|
||||
if (Number.isNaN(hours) || hours < 1) {
|
||||
return jsonError(
|
||||
c,
|
||||
400,
|
||||
"invalid_session_ttl_hours",
|
||||
"invalid_session_ttl_hours",
|
||||
);
|
||||
}
|
||||
await setSessionTtlHours(c.env.DB, hours);
|
||||
touched = true;
|
||||
}
|
||||
|
||||
if (typeof body.admin_password === "string" && body.admin_password.trim()) {
|
||||
const hash = await sha256Hex(body.admin_password.trim());
|
||||
await setAdminPasswordHash(c.env.DB, hash);
|
||||
touched = true;
|
||||
}
|
||||
|
||||
if (!touched) {
|
||||
return jsonError(c, 400, "settings_empty", "settings_empty");
|
||||
}
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
export default settings;
|
||||
@@ -1,127 +1,143 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { AppEnv } from '../env';
|
||||
import { generateToken, sha256Hex } from '../utils/crypto';
|
||||
import { nowIso } from '../utils/time';
|
||||
import { jsonError } from '../utils/http';
|
||||
import { safeJsonParse } from '../utils/json';
|
||||
import { Hono } from "hono";
|
||||
import type { AppEnv } from "../env";
|
||||
import { generateToken, sha256Hex } from "../utils/crypto";
|
||||
import { jsonError } from "../utils/http";
|
||||
import { safeJsonParse } from "../utils/json";
|
||||
import { nowIso } from "../utils/time";
|
||||
|
||||
const tokens = new Hono<AppEnv>();
|
||||
|
||||
type TokenRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
key_prefix: string;
|
||||
quota_total: number | null;
|
||||
quota_used: number;
|
||||
status: string;
|
||||
allowed_channels: string | null;
|
||||
token_plain?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lists API tokens.
|
||||
*/
|
||||
tokens.get('/', async (c) => {
|
||||
const result = await c.env.DB
|
||||
.prepare(
|
||||
'SELECT id, name, key_prefix, quota_total, quota_used, status, allowed_channels, created_at, updated_at FROM tokens ORDER BY created_at DESC'
|
||||
)
|
||||
.all();
|
||||
return c.json({ tokens: result.results ?? [] });
|
||||
tokens.get("/", async (c) => {
|
||||
const result = await c.env.DB.prepare(
|
||||
"SELECT id, name, key_prefix, quota_total, quota_used, status, allowed_channels, created_at, updated_at FROM tokens ORDER BY created_at DESC",
|
||||
).all();
|
||||
return c.json({ tokens: result.results ?? [] });
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a new API token.
|
||||
*/
|
||||
tokens.post('/', async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body?.name) {
|
||||
return jsonError(c, 400, 'name_required', 'name_required');
|
||||
}
|
||||
tokens.post("/", async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body?.name) {
|
||||
return jsonError(c, 400, "name_required", "name_required");
|
||||
}
|
||||
|
||||
const rawToken = generateToken('sk-');
|
||||
const tokenHash = await sha256Hex(rawToken);
|
||||
const id = crypto.randomUUID();
|
||||
const now = nowIso();
|
||||
const keyPrefix = rawToken.slice(0, 8);
|
||||
const quotaTotal = body.quota_total === null || body.quota_total === undefined
|
||||
? null
|
||||
: Number(body.quota_total);
|
||||
const rawToken = generateToken("sk-");
|
||||
const tokenHash = await sha256Hex(rawToken);
|
||||
const id = crypto.randomUUID();
|
||||
const now = nowIso();
|
||||
const keyPrefix = rawToken.slice(0, 8);
|
||||
const quotaTotal =
|
||||
body.quota_total === null || body.quota_total === undefined
|
||||
? null
|
||||
: Number(body.quota_total);
|
||||
|
||||
await c.env.DB
|
||||
.prepare(
|
||||
'INSERT INTO tokens (id, name, key_hash, key_prefix, token_plain, quota_total, quota_used, status, allowed_channels, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
.bind(
|
||||
id,
|
||||
body.name,
|
||||
tokenHash,
|
||||
keyPrefix,
|
||||
rawToken,
|
||||
Number.isNaN(quotaTotal) ? null : quotaTotal,
|
||||
0,
|
||||
body.status ?? 'active',
|
||||
JSON.stringify(body.allowed_channels ?? null),
|
||||
now,
|
||||
now
|
||||
)
|
||||
.run();
|
||||
await c.env.DB.prepare(
|
||||
"INSERT INTO tokens (id, name, key_hash, key_prefix, token_plain, quota_total, quota_used, status, allowed_channels, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(
|
||||
id,
|
||||
body.name,
|
||||
tokenHash,
|
||||
keyPrefix,
|
||||
rawToken,
|
||||
Number.isNaN(quotaTotal) ? null : quotaTotal,
|
||||
0,
|
||||
body.status ?? "active",
|
||||
JSON.stringify(body.allowed_channels ?? null),
|
||||
now,
|
||||
now,
|
||||
)
|
||||
.run();
|
||||
|
||||
return c.json({
|
||||
id,
|
||||
token: rawToken
|
||||
});
|
||||
return c.json({
|
||||
id,
|
||||
token: rawToken,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Updates an API token.
|
||||
*/
|
||||
tokens.patch('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body) {
|
||||
return jsonError(c, 400, 'missing_body', 'missing_body');
|
||||
}
|
||||
tokens.patch("/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body) {
|
||||
return jsonError(c, 400, "missing_body", "missing_body");
|
||||
}
|
||||
|
||||
const existing = await c.env.DB.prepare('SELECT * FROM tokens WHERE id = ?').bind(id).first();
|
||||
if (!existing) {
|
||||
return jsonError(c, 404, 'token_not_found', 'token_not_found');
|
||||
}
|
||||
const existing = await c.env.DB.prepare("SELECT * FROM tokens WHERE id = ?")
|
||||
.bind(id)
|
||||
.first<TokenRow>();
|
||||
if (!existing) {
|
||||
return jsonError(c, 404, "token_not_found", "token_not_found");
|
||||
}
|
||||
|
||||
const existingAllowed = safeJsonParse(existing.allowed_channels, null);
|
||||
const quotaTotalUpdate = body.quota_total === null || body.quota_total === undefined
|
||||
? existing.quota_total
|
||||
: Number(body.quota_total);
|
||||
const quotaUsedUpdate = body.quota_used === null || body.quota_used === undefined
|
||||
? existing.quota_used
|
||||
: Number(body.quota_used);
|
||||
const existingAllowed = safeJsonParse(existing.allowed_channels, null);
|
||||
const quotaTotalUpdate =
|
||||
body.quota_total === null || body.quota_total === undefined
|
||||
? existing.quota_total
|
||||
: Number(body.quota_total);
|
||||
const quotaUsedUpdate =
|
||||
body.quota_used === null || body.quota_used === undefined
|
||||
? existing.quota_used
|
||||
: Number(body.quota_used);
|
||||
|
||||
await c.env.DB
|
||||
.prepare(
|
||||
'UPDATE tokens SET name = ?, quota_total = ?, quota_used = ?, status = ?, allowed_channels = ?, updated_at = ? WHERE id = ?'
|
||||
)
|
||||
.bind(
|
||||
body.name ?? existing.name,
|
||||
Number.isNaN(quotaTotalUpdate) ? existing.quota_total : quotaTotalUpdate,
|
||||
Number.isNaN(quotaUsedUpdate) ? existing.quota_used : quotaUsedUpdate,
|
||||
body.status ?? existing.status,
|
||||
JSON.stringify(body.allowed_channels ?? existingAllowed ?? null),
|
||||
nowIso(),
|
||||
id
|
||||
)
|
||||
.run();
|
||||
await c.env.DB.prepare(
|
||||
"UPDATE tokens SET name = ?, quota_total = ?, quota_used = ?, status = ?, allowed_channels = ?, updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(
|
||||
body.name ?? existing.name,
|
||||
Number.isNaN(quotaTotalUpdate) ? existing.quota_total : quotaTotalUpdate,
|
||||
Number.isNaN(quotaUsedUpdate) ? existing.quota_used : quotaUsedUpdate,
|
||||
body.status ?? existing.status,
|
||||
JSON.stringify(body.allowed_channels ?? existingAllowed ?? null),
|
||||
nowIso(),
|
||||
id,
|
||||
)
|
||||
.run();
|
||||
|
||||
return c.json({ ok: true });
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Reveals a stored API token.
|
||||
*/
|
||||
tokens.get('/:id/reveal', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const record = await c.env.DB.prepare('SELECT token_plain FROM tokens WHERE id = ?').bind(id).first();
|
||||
if (!record) {
|
||||
return jsonError(c, 404, 'token_not_found', 'token_not_found');
|
||||
}
|
||||
return c.json({ token: record.token_plain ?? null });
|
||||
tokens.get("/:id/reveal", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const record = await c.env.DB.prepare(
|
||||
"SELECT token_plain FROM tokens WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.first<{ token_plain?: string | null }>();
|
||||
if (!record) {
|
||||
return jsonError(c, 404, "token_not_found", "token_not_found");
|
||||
}
|
||||
return c.json({ token: record.token_plain ?? null });
|
||||
});
|
||||
|
||||
/**
|
||||
* Deletes an API token.
|
||||
*/
|
||||
tokens.delete('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
await c.env.DB.prepare('DELETE FROM tokens WHERE id = ?').bind(id).run();
|
||||
return c.json({ ok: true });
|
||||
tokens.delete("/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
await c.env.DB.prepare("DELETE FROM tokens WHERE id = ?").bind(id).run();
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
export default tokens;
|
||||
@@ -1,49 +1,51 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { AppEnv } from '../env';
|
||||
import { getRetentionDays } from '../services/settings';
|
||||
import { pruneUsageLogs } from '../services/usage';
|
||||
import { Hono } from "hono";
|
||||
import type { AppEnv } from "../env";
|
||||
import { getRetentionDays } from "../services/settings";
|
||||
import { pruneUsageLogs } from "../services/usage";
|
||||
|
||||
const usage = new Hono<AppEnv>();
|
||||
|
||||
/**
|
||||
* Lists usage logs with filters.
|
||||
*/
|
||||
usage.get('/', async (c) => {
|
||||
const query = c.req.query();
|
||||
const params: Array<string | number> = [];
|
||||
let sql = 'SELECT usage_logs.*, channels.name as channel_name, tokens.name as token_name FROM usage_logs LEFT JOIN channels ON channels.id = usage_logs.channel_id LEFT JOIN tokens ON tokens.id = usage_logs.token_id WHERE 1=1';
|
||||
usage.get("/", async (c) => {
|
||||
const query = c.req.query();
|
||||
const params: Array<string | number> = [];
|
||||
let sql =
|
||||
"SELECT usage_logs.*, channels.name as channel_name, tokens.name as token_name FROM usage_logs LEFT JOIN channels ON channels.id = usage_logs.channel_id LEFT JOIN tokens ON tokens.id = usage_logs.token_id WHERE 1=1";
|
||||
|
||||
if (query.from) {
|
||||
sql += ' AND usage_logs.created_at >= ?';
|
||||
params.push(query.from);
|
||||
}
|
||||
if (query.to) {
|
||||
sql += ' AND usage_logs.created_at <= ?';
|
||||
params.push(query.to);
|
||||
}
|
||||
if (query.model) {
|
||||
sql += ' AND usage_logs.model = ?';
|
||||
params.push(query.model);
|
||||
}
|
||||
if (query.channel_id) {
|
||||
sql += ' AND usage_logs.channel_id = ?';
|
||||
params.push(query.channel_id);
|
||||
}
|
||||
if (query.token_id) {
|
||||
sql += ' AND usage_logs.token_id = ?';
|
||||
params.push(query.token_id);
|
||||
}
|
||||
if (query.from) {
|
||||
sql += " AND usage_logs.created_at >= ?";
|
||||
params.push(query.from);
|
||||
}
|
||||
if (query.to) {
|
||||
sql += " AND usage_logs.created_at <= ?";
|
||||
params.push(query.to);
|
||||
}
|
||||
if (query.model) {
|
||||
sql += " AND usage_logs.model = ?";
|
||||
params.push(query.model);
|
||||
}
|
||||
if (query.channel_id) {
|
||||
sql += " AND usage_logs.channel_id = ?";
|
||||
params.push(query.channel_id);
|
||||
}
|
||||
if (query.token_id) {
|
||||
sql += " AND usage_logs.token_id = ?";
|
||||
params.push(query.token_id);
|
||||
}
|
||||
|
||||
const limit = Math.min(Number(query.limit ?? 50), 200);
|
||||
sql += ' ORDER BY usage_logs.created_at DESC LIMIT ?';
|
||||
params.push(Number.isNaN(limit) ? 50 : limit);
|
||||
const limit = Math.min(Number(query.limit ?? 50), 200);
|
||||
sql += " ORDER BY usage_logs.created_at DESC LIMIT ?";
|
||||
params.push(Number.isNaN(limit) ? 50 : limit);
|
||||
|
||||
const fallback = Number(c.env.LOG_RETENTION_DAYS ?? '30');
|
||||
const retention = await getRetentionDays(c.env.DB, Number.isNaN(fallback) ? 30 : fallback);
|
||||
await pruneUsageLogs(c.env.DB, retention);
|
||||
const retention = await getRetentionDays(c.env.DB);
|
||||
await pruneUsageLogs(c.env.DB, retention);
|
||||
|
||||
const result = await c.env.DB.prepare(sql).bind(...params).all();
|
||||
return c.json({ logs: result.results ?? [] });
|
||||
const result = await c.env.DB.prepare(sql)
|
||||
.bind(...params)
|
||||
.all();
|
||||
return c.json({ logs: result.results ?? [] });
|
||||
});
|
||||
|
||||
export default usage;
|
||||
@@ -1,62 +1,71 @@
|
||||
import { safeJsonParse } from '../utils/json';
|
||||
import { safeJsonParse } from "../utils/json";
|
||||
|
||||
export type ChannelRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
base_url: string;
|
||||
api_key: string;
|
||||
weight: number;
|
||||
status: string;
|
||||
rate_limit?: number | null;
|
||||
models_json?: string | null;
|
||||
id: string;
|
||||
name: string;
|
||||
base_url: string;
|
||||
api_key: string;
|
||||
weight: number;
|
||||
status: string;
|
||||
rate_limit?: number | null;
|
||||
models_json?: string | null;
|
||||
};
|
||||
|
||||
export type ModelEntry = {
|
||||
id: string;
|
||||
label: string;
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
id: string;
|
||||
label: string;
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
};
|
||||
|
||||
type ModelPayload = {
|
||||
id?: string | number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns channels in a weighted random order.
|
||||
*/
|
||||
export function createWeightedOrder(channels: ChannelRecord[]): ChannelRecord[] {
|
||||
const pool = channels.map((channel) => ({
|
||||
...channel,
|
||||
weight: Math.max(1, Number(channel.weight) || 1)
|
||||
}));
|
||||
const ordered: ChannelRecord[] = [];
|
||||
while (pool.length > 0) {
|
||||
const total = pool.reduce((sum, channel) => sum + channel.weight, 0);
|
||||
let roll = Math.random() * total;
|
||||
const index = pool.findIndex((channel) => {
|
||||
roll -= channel.weight;
|
||||
return roll <= 0;
|
||||
});
|
||||
const [selected] = pool.splice(index < 0 ? 0 : index, 1);
|
||||
ordered.push(selected);
|
||||
}
|
||||
return ordered;
|
||||
export function createWeightedOrder(
|
||||
channels: ChannelRecord[],
|
||||
): ChannelRecord[] {
|
||||
const pool = channels.map((channel) => ({
|
||||
...channel,
|
||||
weight: Math.max(1, Number(channel.weight) || 1),
|
||||
}));
|
||||
const ordered: ChannelRecord[] = [];
|
||||
while (pool.length > 0) {
|
||||
const total = pool.reduce((sum, channel) => sum + channel.weight, 0);
|
||||
let roll = Math.random() * total;
|
||||
const index = pool.findIndex((channel) => {
|
||||
roll -= channel.weight;
|
||||
return roll <= 0;
|
||||
});
|
||||
const [selected] = pool.splice(index < 0 ? 0 : index, 1);
|
||||
ordered.push(selected);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts model entries from a channel record.
|
||||
*/
|
||||
export function extractModels(channel: ChannelRecord): ModelEntry[] {
|
||||
const raw = safeJsonParse<any>(channel.models_json, null);
|
||||
const models = Array.isArray(raw)
|
||||
? raw
|
||||
: Array.isArray(raw?.data)
|
||||
? raw.data
|
||||
: [];
|
||||
const raw = safeJsonParse<ModelPayload[] | { data?: ModelPayload[] } | null>(
|
||||
channel.models_json,
|
||||
null,
|
||||
);
|
||||
const models = Array.isArray(raw)
|
||||
? raw
|
||||
: Array.isArray(raw?.data)
|
||||
? raw.data
|
||||
: [];
|
||||
|
||||
return models
|
||||
.filter((model) => model?.id)
|
||||
.map((model) => ({
|
||||
id: String(model.id),
|
||||
label: String(model.id),
|
||||
channelId: channel.id,
|
||||
channelName: channel.name
|
||||
}));
|
||||
return models
|
||||
.filter((model) => model?.id)
|
||||
.map((model) => ({
|
||||
id: String(model.id),
|
||||
label: String(model.id),
|
||||
channelId: channel.id,
|
||||
channelName: channel.name,
|
||||
}));
|
||||
}
|
||||
+265
-211
@@ -1,253 +1,307 @@
|
||||
import { safeJsonParse } from '../utils/json';
|
||||
import { normalizeBaseUrl } from '../utils/url';
|
||||
import { safeJsonParse } from "../utils/json";
|
||||
import { normalizeBaseUrl } from "../utils/url";
|
||||
|
||||
export type ChannelRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
base_url: string;
|
||||
api_key: string;
|
||||
weight: number;
|
||||
status: string;
|
||||
rate_limit?: number | null;
|
||||
models_json?: string | null;
|
||||
type?: number | null;
|
||||
group_name?: string | null;
|
||||
priority?: number | null;
|
||||
metadata_json?: string | null;
|
||||
test_time?: number | string | null;
|
||||
response_time_ms?: number | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
id: string;
|
||||
name: string;
|
||||
base_url: string;
|
||||
api_key: string;
|
||||
weight: number;
|
||||
status: string;
|
||||
rate_limit?: number | null;
|
||||
models_json?: string | null;
|
||||
type?: number | null;
|
||||
group_name?: string | null;
|
||||
priority?: number | null;
|
||||
metadata_json?: string | null;
|
||||
test_time?: number | string | null;
|
||||
response_time_ms?: number | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export type NewApiChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: number;
|
||||
status: number;
|
||||
priority: number;
|
||||
weight: number;
|
||||
models: string;
|
||||
group: string;
|
||||
response_time: number;
|
||||
test_time: number;
|
||||
base_url?: string;
|
||||
key?: string;
|
||||
created_time?: number;
|
||||
updated_time?: number;
|
||||
id: string;
|
||||
name: string;
|
||||
type: number;
|
||||
status: number;
|
||||
priority: number;
|
||||
weight: number;
|
||||
models: string;
|
||||
group: string;
|
||||
response_time: number;
|
||||
test_time: number;
|
||||
base_url?: string;
|
||||
key?: string;
|
||||
created_time?: number;
|
||||
updated_time?: number;
|
||||
};
|
||||
|
||||
export type ParsedChannelInput = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
type?: number;
|
||||
base_url?: string;
|
||||
api_key?: string;
|
||||
weight?: number;
|
||||
status?: string;
|
||||
rate_limit?: number;
|
||||
models: string[];
|
||||
models_json: string;
|
||||
group_name?: string | null;
|
||||
priority?: number;
|
||||
metadata_json?: string | null;
|
||||
id?: string;
|
||||
name?: string;
|
||||
type?: number;
|
||||
base_url?: string;
|
||||
api_key?: string;
|
||||
weight?: number;
|
||||
status?: string;
|
||||
rate_limit?: number;
|
||||
models: string[];
|
||||
models_json: string;
|
||||
group_name?: string | null;
|
||||
priority?: number;
|
||||
metadata_json?: string | null;
|
||||
};
|
||||
|
||||
type ModelLike = { id?: unknown };
|
||||
|
||||
const KNOWN_KEYS = new Set([
|
||||
'id',
|
||||
'name',
|
||||
'type',
|
||||
'key',
|
||||
'api_key',
|
||||
'base_url',
|
||||
'baseUrl',
|
||||
'weight',
|
||||
'status',
|
||||
'rate_limit',
|
||||
'rateLimit',
|
||||
'models',
|
||||
'model',
|
||||
'model_list',
|
||||
'models_list',
|
||||
'group',
|
||||
'group_name',
|
||||
'groups',
|
||||
'priority'
|
||||
"id",
|
||||
"name",
|
||||
"type",
|
||||
"key",
|
||||
"api_key",
|
||||
"base_url",
|
||||
"baseUrl",
|
||||
"weight",
|
||||
"status",
|
||||
"rate_limit",
|
||||
"rateLimit",
|
||||
"models",
|
||||
"model",
|
||||
"model_list",
|
||||
"models_list",
|
||||
"group",
|
||||
"group_name",
|
||||
"groups",
|
||||
"priority",
|
||||
]);
|
||||
|
||||
function toNumber(value: unknown, fallback: number | null = null): number | null {
|
||||
if (value === null || value === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isNaN(parsed) ? fallback : parsed;
|
||||
function toModelId(item: unknown): string {
|
||||
if (item && typeof item === "object" && "id" in item) {
|
||||
const value = (item as ModelLike).id;
|
||||
return value === undefined || value === null ? "" : String(value);
|
||||
}
|
||||
if (item === undefined || item === null) {
|
||||
return "";
|
||||
}
|
||||
return String(item);
|
||||
}
|
||||
|
||||
function toNumber(
|
||||
value: unknown,
|
||||
fallback: number | null = null,
|
||||
): number | null {
|
||||
if (value === null || value === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isNaN(parsed) ? fallback : parsed;
|
||||
}
|
||||
|
||||
export function normalizeModelsInput(input: unknown): string[] {
|
||||
if (!input) {
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(input)) {
|
||||
return input.map((item) => String(item)).filter((item) => item.length > 0);
|
||||
}
|
||||
if (typeof input === 'string') {
|
||||
return input
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
if (typeof input === 'object') {
|
||||
const raw = input as { data?: unknown[] };
|
||||
if (Array.isArray(raw.data)) {
|
||||
return raw.data
|
||||
.map((item: any) => String(item?.id ?? item))
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
if (!input) {
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(input)) {
|
||||
return input.map((item) => String(item)).filter((item) => item.length > 0);
|
||||
}
|
||||
if (typeof input === "string") {
|
||||
return input
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
if (typeof input === "object") {
|
||||
const raw = input as { data?: unknown[] };
|
||||
if (Array.isArray(raw.data)) {
|
||||
return raw.data
|
||||
.map((item) => toModelId(item))
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function modelsToJson(models: string[]): string {
|
||||
const normalized = models
|
||||
.map((model) => String(model).trim())
|
||||
.filter((model) => model.length > 0);
|
||||
return JSON.stringify(normalized.map((id) => ({ id })));
|
||||
const normalized = models
|
||||
.map((model) => String(model).trim())
|
||||
.filter((model) => model.length > 0);
|
||||
return JSON.stringify(normalized.map((id) => ({ id })));
|
||||
}
|
||||
|
||||
export function extractModelIds(channel: ChannelRow): string[] {
|
||||
const raw = safeJsonParse<any>(channel.models_json, null);
|
||||
const models = Array.isArray(raw)
|
||||
? raw
|
||||
: Array.isArray(raw?.data)
|
||||
? raw.data
|
||||
: [];
|
||||
return models
|
||||
.map((model: any) => String(model?.id ?? model))
|
||||
.filter((model: string) => model.length > 0);
|
||||
const raw = safeJsonParse<ModelLike[] | { data?: ModelLike[] } | null>(
|
||||
channel.models_json,
|
||||
null,
|
||||
);
|
||||
const models = Array.isArray(raw)
|
||||
? raw
|
||||
: Array.isArray(raw?.data)
|
||||
? raw.data
|
||||
: [];
|
||||
return models
|
||||
.map((model) => toModelId(model))
|
||||
.filter((model: string) => model.length > 0);
|
||||
}
|
||||
|
||||
export function toInternalStatus(status: unknown): string {
|
||||
if (status === undefined || status === null) {
|
||||
return 'active';
|
||||
}
|
||||
if (typeof status === 'string') {
|
||||
const normalized = status.trim().toLowerCase();
|
||||
if (['1', 'true', 'enabled', 'enable', 'active'].includes(normalized)) {
|
||||
return 'active';
|
||||
}
|
||||
if (['0', '2', 'false', 'disabled', 'disable', 'inactive'].includes(normalized)) {
|
||||
return 'disabled';
|
||||
}
|
||||
}
|
||||
if (typeof status === 'number') {
|
||||
return status === 1 ? 'active' : 'disabled';
|
||||
}
|
||||
if (typeof status === 'boolean') {
|
||||
return status ? 'active' : 'disabled';
|
||||
}
|
||||
return 'active';
|
||||
if (status === undefined || status === null) {
|
||||
return "active";
|
||||
}
|
||||
if (typeof status === "string") {
|
||||
const normalized = status.trim().toLowerCase();
|
||||
if (["1", "true", "enabled", "enable", "active"].includes(normalized)) {
|
||||
return "active";
|
||||
}
|
||||
if (
|
||||
["0", "2", "false", "disabled", "disable", "inactive"].includes(
|
||||
normalized,
|
||||
)
|
||||
) {
|
||||
return "disabled";
|
||||
}
|
||||
}
|
||||
if (typeof status === "number") {
|
||||
return status === 1 ? "active" : "disabled";
|
||||
}
|
||||
if (typeof status === "boolean") {
|
||||
return status ? "active" : "disabled";
|
||||
}
|
||||
return "active";
|
||||
}
|
||||
|
||||
export function toNewApiStatus(status: string | null | undefined): number {
|
||||
return status === 'active' ? 1 : 2;
|
||||
return status === "active" ? 1 : 2;
|
||||
}
|
||||
|
||||
export function normalizeChannelInput(body: any): ParsedChannelInput {
|
||||
const hasModels =
|
||||
body &&
|
||||
(Object.prototype.hasOwnProperty.call(body, 'models') ||
|
||||
Object.prototype.hasOwnProperty.call(body, 'model') ||
|
||||
Object.prototype.hasOwnProperty.call(body, 'model_list') ||
|
||||
Object.prototype.hasOwnProperty.call(body, 'models_list'));
|
||||
const models = hasModels
|
||||
? normalizeModelsInput(body?.models ?? body?.model ?? body?.model_list ?? body?.models_list)
|
||||
: [];
|
||||
const hasGroup =
|
||||
body &&
|
||||
(Object.prototype.hasOwnProperty.call(body, 'group') ||
|
||||
Object.prototype.hasOwnProperty.call(body, 'group_name') ||
|
||||
Object.prototype.hasOwnProperty.call(body, 'groups'));
|
||||
const groupInput = body?.group ?? body?.group_name ?? null;
|
||||
const groupsInput = Array.isArray(body?.groups) ? body.groups.map((item: any) => String(item)) : null;
|
||||
const groupName = !hasGroup
|
||||
? undefined
|
||||
: groupInput
|
||||
? String(groupInput)
|
||||
: groupsInput && groupsInput.length > 0
|
||||
? groupsInput.join(',')
|
||||
: null;
|
||||
export function normalizeChannelInput(
|
||||
body: Record<string, unknown> | null,
|
||||
): ParsedChannelInput {
|
||||
const hasModels =
|
||||
body &&
|
||||
(Object.hasOwn(body, "models") ||
|
||||
Object.hasOwn(body, "model") ||
|
||||
Object.hasOwn(body, "model_list") ||
|
||||
Object.hasOwn(body, "models_list"));
|
||||
const models = hasModels
|
||||
? normalizeModelsInput(
|
||||
body?.models ?? body?.model ?? body?.model_list ?? body?.models_list,
|
||||
)
|
||||
: [];
|
||||
const hasGroup =
|
||||
body &&
|
||||
(Object.hasOwn(body, "group") ||
|
||||
Object.hasOwn(body, "group_name") ||
|
||||
Object.hasOwn(body, "groups"));
|
||||
const groupInput = body?.group ?? body?.group_name ?? null;
|
||||
const groupsInput = Array.isArray(body?.groups)
|
||||
? (body.groups as unknown[]).map((item) => String(item))
|
||||
: null;
|
||||
const groupName = !hasGroup
|
||||
? undefined
|
||||
: groupInput
|
||||
? String(groupInput)
|
||||
: groupsInput && groupsInput.length > 0
|
||||
? groupsInput.join(",")
|
||||
: null;
|
||||
|
||||
const metadata: Record<string, unknown> = {};
|
||||
if (body && typeof body === 'object') {
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
if (KNOWN_KEYS.has(key)) {
|
||||
continue;
|
||||
}
|
||||
metadata[key] = value;
|
||||
}
|
||||
}
|
||||
const metadata: Record<string, unknown> = {};
|
||||
if (body && typeof body === "object") {
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
if (KNOWN_KEYS.has(key)) {
|
||||
continue;
|
||||
}
|
||||
metadata[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const hasType = body && Object.prototype.hasOwnProperty.call(body, 'type');
|
||||
const hasWeight = body && Object.prototype.hasOwnProperty.call(body, 'weight');
|
||||
const hasStatus = body && Object.prototype.hasOwnProperty.call(body, 'status');
|
||||
const hasRateLimit =
|
||||
body &&
|
||||
(Object.prototype.hasOwnProperty.call(body, 'rate_limit') ||
|
||||
Object.prototype.hasOwnProperty.call(body, 'rateLimit'));
|
||||
const hasPriority = body && Object.prototype.hasOwnProperty.call(body, 'priority');
|
||||
const hasType = body && Object.hasOwn(body, "type");
|
||||
const hasWeight = body && Object.hasOwn(body, "weight");
|
||||
const hasStatus = body && Object.hasOwn(body, "status");
|
||||
const hasRateLimit =
|
||||
body &&
|
||||
(Object.hasOwn(body, "rate_limit") || Object.hasOwn(body, "rateLimit"));
|
||||
const hasPriority = body && Object.hasOwn(body, "priority");
|
||||
|
||||
return {
|
||||
id: body?.id !== undefined && body?.id !== null ? String(body.id) : undefined,
|
||||
name: body?.name ? String(body.name) : undefined,
|
||||
type: hasType ? toNumber(body?.type, 1) ?? 1 : undefined,
|
||||
base_url: body?.base_url ?? body?.baseUrl,
|
||||
api_key: body?.key ?? body?.api_key,
|
||||
weight: hasWeight ? toNumber(body?.weight, 1) ?? 1 : undefined,
|
||||
status: hasStatus ? toInternalStatus(body?.status) : undefined,
|
||||
rate_limit: hasRateLimit ? toNumber(body?.rate_limit ?? body?.rateLimit, 0) ?? 0 : undefined,
|
||||
models,
|
||||
models_json: modelsToJson(models),
|
||||
group_name: groupName,
|
||||
priority: hasPriority ? toNumber(body?.priority, 0) ?? 0 : undefined,
|
||||
metadata_json: Object.keys(metadata).length > 0 ? JSON.stringify(metadata) : null
|
||||
};
|
||||
return {
|
||||
id:
|
||||
body?.id !== undefined && body?.id !== null ? String(body.id) : undefined,
|
||||
name: body?.name ? String(body.name) : undefined,
|
||||
type: hasType ? (toNumber(body?.type, 1) ?? 1) : undefined,
|
||||
base_url:
|
||||
body?.base_url !== undefined && body?.base_url !== null
|
||||
? String(body.base_url)
|
||||
: body?.baseUrl !== undefined && body?.baseUrl !== null
|
||||
? String(body.baseUrl)
|
||||
: undefined,
|
||||
api_key:
|
||||
body?.key !== undefined && body?.key !== null
|
||||
? String(body.key)
|
||||
: body?.api_key !== undefined && body?.api_key !== null
|
||||
? String(body.api_key)
|
||||
: undefined,
|
||||
weight: hasWeight ? (toNumber(body?.weight, 1) ?? 1) : undefined,
|
||||
status: hasStatus ? toInternalStatus(body?.status) : undefined,
|
||||
rate_limit: hasRateLimit
|
||||
? (toNumber(body?.rate_limit ?? body?.rateLimit, 0) ?? 0)
|
||||
: undefined,
|
||||
models,
|
||||
models_json: modelsToJson(models),
|
||||
group_name: groupName,
|
||||
priority: hasPriority ? (toNumber(body?.priority, 0) ?? 0) : undefined,
|
||||
metadata_json:
|
||||
Object.keys(metadata).length > 0 ? JSON.stringify(metadata) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeMetadata(existing: string | null | undefined, incoming: string | null | undefined): string | null {
|
||||
const base = safeJsonParse<Record<string, unknown>>(existing, {});
|
||||
const updates = safeJsonParse<Record<string, unknown>>(incoming, {});
|
||||
const merged = { ...base, ...updates };
|
||||
return Object.keys(merged).length > 0 ? JSON.stringify(merged) : null;
|
||||
export function mergeMetadata(
|
||||
existing: string | null | undefined,
|
||||
incoming: string | null | undefined,
|
||||
): string | null {
|
||||
const base = safeJsonParse<Record<string, unknown>>(existing, {});
|
||||
const updates = safeJsonParse<Record<string, unknown>>(incoming, {});
|
||||
const merged = { ...base, ...updates };
|
||||
return Object.keys(merged).length > 0 ? JSON.stringify(merged) : null;
|
||||
}
|
||||
|
||||
export function toNewApiChannel(channel: ChannelRow): NewApiChannel {
|
||||
const models = extractModelIds(channel);
|
||||
const metadata = safeJsonParse<Record<string, unknown>>(channel.metadata_json, {});
|
||||
const createdTime = channel.created_at ? Date.parse(channel.created_at) : NaN;
|
||||
const updatedTime = channel.updated_at ? Date.parse(channel.updated_at) : NaN;
|
||||
const models = extractModelIds(channel);
|
||||
const metadata = safeJsonParse<Record<string, unknown>>(
|
||||
channel.metadata_json,
|
||||
{},
|
||||
);
|
||||
const createdTime = channel.created_at ? Date.parse(channel.created_at) : NaN;
|
||||
const updatedTime = channel.updated_at ? Date.parse(channel.updated_at) : NaN;
|
||||
|
||||
return {
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
type: Number(channel.type ?? 1),
|
||||
status: toNewApiStatus(channel.status),
|
||||
priority: Number(channel.priority ?? 0),
|
||||
weight: Number(channel.weight ?? 1),
|
||||
models: models.join(','),
|
||||
group: channel.group_name ?? '',
|
||||
response_time: Number(channel.response_time_ms ?? 0),
|
||||
test_time: toNumber(channel.test_time, 0) ?? 0,
|
||||
base_url: channel.base_url,
|
||||
key: channel.api_key,
|
||||
created_time: Number.isNaN(createdTime) ? undefined : Math.floor(createdTime / 1000),
|
||||
updated_time: Number.isNaN(updatedTime) ? undefined : Math.floor(updatedTime / 1000),
|
||||
...metadata
|
||||
};
|
||||
return {
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
type: Number(channel.type ?? 1),
|
||||
status: toNewApiStatus(channel.status),
|
||||
priority: Number(channel.priority ?? 0),
|
||||
weight: Number(channel.weight ?? 1),
|
||||
models: models.join(","),
|
||||
group: channel.group_name ?? "",
|
||||
response_time: Number(channel.response_time_ms ?? 0),
|
||||
test_time: toNumber(channel.test_time, 0) ?? 0,
|
||||
base_url: channel.base_url,
|
||||
key: channel.api_key,
|
||||
created_time: Number.isNaN(createdTime)
|
||||
? undefined
|
||||
: Math.floor(createdTime / 1000),
|
||||
updated_time: Number.isNaN(updatedTime)
|
||||
? undefined
|
||||
: Math.floor(updatedTime / 1000),
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeBaseUrlInput(value: string | undefined | null): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
return normalizeBaseUrl(String(value));
|
||||
export function normalizeBaseUrlInput(
|
||||
value: string | undefined | null,
|
||||
): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
return normalizeBaseUrl(String(value));
|
||||
}
|
||||
@@ -1,19 +1,26 @@
|
||||
/**
|
||||
* Determines whether a token can consume more quota.
|
||||
*/
|
||||
export function canConsumeQuota(quotaTotal: number | null, quotaUsed: number, increment: number): boolean {
|
||||
if (quotaTotal === null || quotaTotal === undefined) {
|
||||
return true;
|
||||
}
|
||||
return quotaUsed + increment <= quotaTotal;
|
||||
export function canConsumeQuota(
|
||||
quotaTotal: number | null,
|
||||
quotaUsed: number,
|
||||
increment: number,
|
||||
): boolean {
|
||||
if (quotaTotal === null || quotaTotal === undefined) {
|
||||
return true;
|
||||
}
|
||||
return quotaUsed + increment <= quotaTotal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes quota numbers to safe values.
|
||||
*/
|
||||
export function normalizeQuota(quotaTotal?: number | null, quotaUsed?: number | null) {
|
||||
return {
|
||||
quotaTotal: quotaTotal ?? null,
|
||||
quotaUsed: quotaUsed ?? 0
|
||||
};
|
||||
export function normalizeQuota(
|
||||
quotaTotal?: number | null,
|
||||
quotaUsed?: number | null,
|
||||
) {
|
||||
return {
|
||||
quotaTotal: quotaTotal ?? null,
|
||||
quotaUsed: quotaUsed ?? 0,
|
||||
};
|
||||
}
|
||||
@@ -1,41 +1,125 @@
|
||||
import type { D1Database } from '@cloudflare/workers-types';
|
||||
import { nowIso } from '../utils/time';
|
||||
import type { D1Database } from "@cloudflare/workers-types";
|
||||
import { nowIso } from "../utils/time";
|
||||
|
||||
const RETENTION_KEY = 'log_retention_days';
|
||||
const DEFAULT_LOG_RETENTION_DAYS = 30;
|
||||
const DEFAULT_SESSION_TTL_HOURS = 12;
|
||||
const RETENTION_KEY = "log_retention_days";
|
||||
const SESSION_TTL_KEY = "session_ttl_hours";
|
||||
const ADMIN_PASSWORD_HASH_KEY = "admin_password_hash";
|
||||
|
||||
async function readSetting(
|
||||
db: D1Database,
|
||||
key: string,
|
||||
): Promise<string | null> {
|
||||
const setting = await db
|
||||
.prepare("SELECT value FROM settings WHERE key = ?")
|
||||
.bind(key)
|
||||
.first<{ value?: string }>();
|
||||
return setting?.value ? String(setting.value) : null;
|
||||
}
|
||||
|
||||
async function upsertSetting(
|
||||
db: D1Database,
|
||||
key: string,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
"INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(key, value, nowIso())
|
||||
.run();
|
||||
}
|
||||
|
||||
function parsePositiveNumber(value: string | null, fallback: number): number {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
if (!Number.isNaN(parsed) && parsed > 0) {
|
||||
return parsed;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the log retention days from settings or env fallback.
|
||||
* Returns the log retention days from settings or default fallback.
|
||||
*/
|
||||
export async function getRetentionDays(db: D1Database, fallback: number): Promise<number> {
|
||||
const setting = await db.prepare('SELECT value FROM settings WHERE key = ?').bind(RETENTION_KEY).first();
|
||||
if (setting?.value) {
|
||||
const parsed = Number(setting.value);
|
||||
if (!Number.isNaN(parsed) && parsed > 0) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
export async function getRetentionDays(db: D1Database): Promise<number> {
|
||||
const value = await readSetting(db, RETENTION_KEY);
|
||||
return parsePositiveNumber(value, DEFAULT_LOG_RETENTION_DAYS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the log retention days setting.
|
||||
*/
|
||||
export async function setRetentionDays(db: D1Database, days: number): Promise<void> {
|
||||
const value = Math.max(1, Math.floor(days)).toString();
|
||||
await db
|
||||
.prepare('INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at')
|
||||
.bind(RETENTION_KEY, value, nowIso())
|
||||
.run();
|
||||
export async function setRetentionDays(
|
||||
db: D1Database,
|
||||
days: number,
|
||||
): Promise<void> {
|
||||
const value = Math.max(1, Math.floor(days)).toString();
|
||||
await upsertSetting(db, RETENTION_KEY, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the session TTL hours from settings or default fallback.
|
||||
*/
|
||||
export async function getSessionTtlHours(db: D1Database): Promise<number> {
|
||||
const value = await readSetting(db, SESSION_TTL_KEY);
|
||||
return parsePositiveNumber(value, DEFAULT_SESSION_TTL_HOURS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the session TTL hours setting.
|
||||
*/
|
||||
export async function setSessionTtlHours(
|
||||
db: D1Database,
|
||||
hours: number,
|
||||
): Promise<void> {
|
||||
const value = Math.max(1, Math.floor(hours)).toString();
|
||||
await upsertSetting(db, SESSION_TTL_KEY, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the admin password hash.
|
||||
*/
|
||||
export async function getAdminPasswordHash(
|
||||
db: D1Database,
|
||||
): Promise<string | null> {
|
||||
return readSetting(db, ADMIN_PASSWORD_HASH_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the admin password hash.
|
||||
*/
|
||||
export async function setAdminPasswordHash(
|
||||
db: D1Database,
|
||||
hash: string,
|
||||
): Promise<void> {
|
||||
if (!hash) {
|
||||
return;
|
||||
}
|
||||
await upsertSetting(db, ADMIN_PASSWORD_HASH_KEY, hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the admin password is set.
|
||||
*/
|
||||
export async function isAdminPasswordSet(db: D1Database): Promise<boolean> {
|
||||
const hash = await getAdminPasswordHash(db);
|
||||
return Boolean(hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads generic settings as a key/value map.
|
||||
*/
|
||||
export async function listSettings(db: D1Database): Promise<Record<string, string>> {
|
||||
const result = await db.prepare('SELECT key, value FROM settings').all();
|
||||
const map: Record<string, string> = {};
|
||||
for (const row of result.results ?? []) {
|
||||
map[String(row.key)] = String(row.value);
|
||||
}
|
||||
return map;
|
||||
export async function listSettings(
|
||||
db: D1Database,
|
||||
): Promise<Record<string, string>> {
|
||||
const result = await db.prepare("SELECT key, value FROM settings").all();
|
||||
const map: Record<string, string> = {};
|
||||
for (const row of result.results ?? []) {
|
||||
map[String(row.key)] = String(row.value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -1,58 +1,69 @@
|
||||
import type { D1Database } from '@cloudflare/workers-types';
|
||||
import { nowIso } from '../utils/time';
|
||||
import type { D1Database } from "@cloudflare/workers-types";
|
||||
import { nowIso } from "../utils/time";
|
||||
|
||||
export type UsageInput = {
|
||||
tokenId?: string | null;
|
||||
channelId?: string | null;
|
||||
model?: string | null;
|
||||
requestPath?: string | null;
|
||||
totalTokens?: number | null;
|
||||
promptTokens?: number | null;
|
||||
completionTokens?: number | null;
|
||||
cost?: number | null;
|
||||
latencyMs?: number | null;
|
||||
status?: string | null;
|
||||
tokenId?: string | null;
|
||||
channelId?: string | null;
|
||||
model?: string | null;
|
||||
requestPath?: string | null;
|
||||
totalTokens?: number | null;
|
||||
promptTokens?: number | null;
|
||||
completionTokens?: number | null;
|
||||
cost?: number | null;
|
||||
latencyMs?: number | null;
|
||||
status?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Inserts a usage record and updates token quota.
|
||||
*/
|
||||
export async function recordUsage(db: D1Database, input: UsageInput): Promise<void> {
|
||||
const id = crypto.randomUUID();
|
||||
const createdAt = nowIso();
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT INTO usage_logs (id, token_id, channel_id, model, request_path, total_tokens, prompt_tokens, completion_tokens, cost, latency_ms, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
.bind(
|
||||
id,
|
||||
input.tokenId ?? null,
|
||||
input.channelId ?? null,
|
||||
input.model ?? null,
|
||||
input.requestPath ?? null,
|
||||
input.totalTokens ?? 0,
|
||||
input.promptTokens ?? 0,
|
||||
input.completionTokens ?? 0,
|
||||
input.cost ?? 0,
|
||||
input.latencyMs ?? 0,
|
||||
input.status ?? 'ok',
|
||||
createdAt
|
||||
)
|
||||
.run();
|
||||
export async function recordUsage(
|
||||
db: D1Database,
|
||||
input: UsageInput,
|
||||
): Promise<void> {
|
||||
const id = crypto.randomUUID();
|
||||
const createdAt = nowIso();
|
||||
await db
|
||||
.prepare(
|
||||
"INSERT INTO usage_logs (id, token_id, channel_id, model, request_path, total_tokens, prompt_tokens, completion_tokens, cost, latency_ms, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(
|
||||
id,
|
||||
input.tokenId ?? null,
|
||||
input.channelId ?? null,
|
||||
input.model ?? null,
|
||||
input.requestPath ?? null,
|
||||
input.totalTokens ?? 0,
|
||||
input.promptTokens ?? 0,
|
||||
input.completionTokens ?? 0,
|
||||
input.cost ?? 0,
|
||||
input.latencyMs ?? 0,
|
||||
input.status ?? "ok",
|
||||
createdAt,
|
||||
)
|
||||
.run();
|
||||
|
||||
if (input.tokenId && input.totalTokens) {
|
||||
await db
|
||||
.prepare('UPDATE tokens SET quota_used = quota_used + ?, updated_at = ? WHERE id = ?')
|
||||
.bind(input.totalTokens, createdAt, input.tokenId)
|
||||
.run();
|
||||
}
|
||||
if (input.tokenId && input.totalTokens) {
|
||||
await db
|
||||
.prepare(
|
||||
"UPDATE tokens SET quota_used = quota_used + ?, updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(input.totalTokens, createdAt, input.tokenId)
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes usage logs older than the retention window.
|
||||
*/
|
||||
export async function pruneUsageLogs(db: D1Database, retentionDays: number): Promise<void> {
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - retentionDays);
|
||||
await db.prepare('DELETE FROM usage_logs WHERE created_at < ?').bind(cutoff.toISOString()).run();
|
||||
export async function pruneUsageLogs(
|
||||
db: D1Database,
|
||||
retentionDays: number,
|
||||
): Promise<void> {
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - retentionDays);
|
||||
await db
|
||||
.prepare("DELETE FROM usage_logs WHERE created_at < ?")
|
||||
.bind(cutoff.toISOString())
|
||||
.run();
|
||||
}
|
||||
@@ -4,20 +4,20 @@ const encoder = new TextEncoder();
|
||||
* Creates a SHA-256 hex digest from input text.
|
||||
*/
|
||||
export async function sha256Hex(input: string): Promise<string> {
|
||||
const data = encoder.encode(input);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
const data = encoder.encode(input);
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a URL-safe random token with an optional prefix.
|
||||
*/
|
||||
export function generateToken(prefix = ''): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(24));
|
||||
const base = btoa(String.fromCharCode(...bytes))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
return `${prefix}${base}`;
|
||||
export function generateToken(prefix = ""): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(24));
|
||||
const base = btoa(String.fromCharCode(...bytes))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
return `${prefix}${base}`;
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
import type { Context } from 'hono';
|
||||
import type { Context } from "hono";
|
||||
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
|
||||
/**
|
||||
* Sends a JSON error response with a consistent shape.
|
||||
*/
|
||||
export function jsonError(c: Context, status: number, message: string, code?: string) {
|
||||
return c.json(
|
||||
{
|
||||
error: message,
|
||||
code
|
||||
},
|
||||
status
|
||||
);
|
||||
export function jsonError(
|
||||
c: Context,
|
||||
status: ContentfulStatusCode,
|
||||
message: string,
|
||||
code?: string,
|
||||
) {
|
||||
return c.json(
|
||||
{
|
||||
error: message,
|
||||
code,
|
||||
},
|
||||
status,
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
/**
|
||||
* Safely parses JSON or returns a fallback value.
|
||||
*/
|
||||
export function safeJsonParse<T>(value: string | null | undefined, fallback: T): T {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
export function safeJsonParse<T>(
|
||||
value: string | null | undefined,
|
||||
fallback: T,
|
||||
): T {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { Context } from 'hono';
|
||||
import type { Context } from "hono";
|
||||
|
||||
/**
|
||||
* Reads a bearer token from common headers.
|
||||
*/
|
||||
export function getBearerToken(c: Context): string | null {
|
||||
const auth = c.req.header('Authorization');
|
||||
if (auth && auth.toLowerCase().startsWith('bearer ')) {
|
||||
return auth.slice(7).trim();
|
||||
}
|
||||
return c.req.header('x-api-key') ?? c.req.header('x-admin-token') ?? null;
|
||||
const auth = c.req.header("Authorization");
|
||||
if (auth?.toLowerCase().startsWith("bearer ")) {
|
||||
return auth.slice(7).trim();
|
||||
}
|
||||
return c.req.header("x-api-key") ?? c.req.header("x-admin-token") ?? null;
|
||||
}
|
||||
@@ -2,14 +2,14 @@
|
||||
* Returns the current ISO timestamp.
|
||||
*/
|
||||
export function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds hours to a date and returns a new Date.
|
||||
*/
|
||||
export function addHours(date: Date, hours: number): Date {
|
||||
const copy = new Date(date.getTime());
|
||||
copy.setHours(copy.getHours() + hours);
|
||||
return copy;
|
||||
const copy = new Date(date.getTime());
|
||||
copy.setHours(copy.getHours() + hours);
|
||||
return copy;
|
||||
}
|
||||
@@ -8,6 +8,9 @@
|
||||
* Normalized base URL without trailing slashes or /v1.
|
||||
*/
|
||||
export function normalizeBaseUrl(baseUrl: string): string {
|
||||
const trimmed = baseUrl.trim().replace(/\/+$/, '');
|
||||
return trimmed.replace(/\/v1$/i, '');
|
||||
if (!baseUrl) {
|
||||
return "";
|
||||
}
|
||||
const trimmed = baseUrl.trim().replace(/\/+$/, "");
|
||||
return trimmed.replace(/\/v1$/i, "");
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { safeJsonParse } from "./json";
|
||||
|
||||
export type NormalizedUsage = {
|
||||
totalTokens: number;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
};
|
||||
|
||||
function toNumber(value: unknown): number | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : null;
|
||||
}
|
||||
|
||||
function pickNumber(...values: Array<unknown>): number | null {
|
||||
for (const value of values) {
|
||||
const parsed = toNumber(value);
|
||||
if (parsed !== null) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeUsage(raw: unknown): NormalizedUsage | null {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return null;
|
||||
}
|
||||
const data = raw as Record<string, unknown>;
|
||||
const promptTokens = pickNumber(
|
||||
data.prompt_tokens,
|
||||
data.promptTokens,
|
||||
data.input_tokens,
|
||||
data.inputTokens,
|
||||
);
|
||||
const completionTokens = pickNumber(
|
||||
data.completion_tokens,
|
||||
data.completionTokens,
|
||||
data.output_tokens,
|
||||
data.outputTokens,
|
||||
);
|
||||
let totalTokens = pickNumber(
|
||||
data.total_tokens,
|
||||
data.totalTokens,
|
||||
data.total,
|
||||
data.tokens,
|
||||
data.token_count,
|
||||
);
|
||||
if (
|
||||
totalTokens === null &&
|
||||
(promptTokens !== null || completionTokens !== null)
|
||||
) {
|
||||
totalTokens = (promptTokens ?? 0) + (completionTokens ?? 0);
|
||||
}
|
||||
if (totalTokens === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
totalTokens,
|
||||
promptTokens: promptTokens ?? 0,
|
||||
completionTokens: completionTokens ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseUsageFromJson(payload: unknown): NormalizedUsage | null {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return null;
|
||||
}
|
||||
const data = payload as Record<string, unknown>;
|
||||
const usage =
|
||||
data.usage ??
|
||||
(data.data && typeof data.data === "object"
|
||||
? (data.data as Record<string, unknown>).usage
|
||||
: null);
|
||||
return normalizeUsage(usage);
|
||||
}
|
||||
|
||||
export function parseUsageFromHeaders(
|
||||
headers: Headers,
|
||||
): NormalizedUsage | null {
|
||||
const jsonHeader = headers.get("x-usage") ?? headers.get("x-openai-usage");
|
||||
if (jsonHeader) {
|
||||
const parsed = safeJsonParse<unknown>(jsonHeader, null);
|
||||
const normalized = normalizeUsage(parsed);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
const totalTokens = pickNumber(
|
||||
headers.get("x-usage-total-tokens"),
|
||||
headers.get("x-openai-usage-total-tokens"),
|
||||
);
|
||||
const promptTokens = pickNumber(
|
||||
headers.get("x-usage-prompt-tokens"),
|
||||
headers.get("x-openai-usage-prompt-tokens"),
|
||||
);
|
||||
const completionTokens = pickNumber(
|
||||
headers.get("x-usage-completion-tokens"),
|
||||
headers.get("x-openai-usage-completion-tokens"),
|
||||
);
|
||||
|
||||
if (
|
||||
totalTokens === null &&
|
||||
promptTokens === null &&
|
||||
completionTokens === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
totalTokens: totalTokens ?? (promptTokens ?? 0) + (completionTokens ?? 0),
|
||||
promptTokens: promptTokens ?? 0,
|
||||
completionTokens: completionTokens ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function parseUsageFromSse(
|
||||
response: Response,
|
||||
): Promise<NormalizedUsage | null> {
|
||||
if (!response.body) {
|
||||
return null;
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let usage: NormalizedUsage | null = null;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let newlineIndex = buffer.indexOf("\n");
|
||||
while (newlineIndex !== -1) {
|
||||
const line = buffer.slice(0, newlineIndex).trim();
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
if (line.startsWith("data:")) {
|
||||
const payload = line.slice(5).trim();
|
||||
if (payload && payload !== "[DONE]") {
|
||||
const parsed = safeJsonParse<unknown>(payload, null);
|
||||
const candidate = parseUsageFromJson(parsed);
|
||||
if (candidate) {
|
||||
usage = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
newlineIndex = buffer.indexOf("\n");
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = buffer.trim();
|
||||
if (remaining.startsWith("data:")) {
|
||||
const payload = remaining.slice(5).trim();
|
||||
if (payload && payload !== "[DONE]") {
|
||||
const parsed = safeJsonParse<unknown>(payload, null);
|
||||
const candidate = parseUsageFromJson(parsed);
|
||||
if (candidate) {
|
||||
usage = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return usage;
|
||||
}
|
||||
+10
-10
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -3,13 +3,9 @@ main = "src/index.ts"
|
||||
compatibility_date = "2026-02-14"
|
||||
|
||||
[vars]
|
||||
ADMIN_PASSWORD = "lsy"
|
||||
CORS_ORIGIN = "http://localhost:5173"
|
||||
SESSION_TTL_HOURS = "12"
|
||||
LOG_RETENTION_DAYS = "30"
|
||||
PROXY_RETRY_ROUNDS = "2"
|
||||
PROXY_RETRY_DELAY_MS = "200"
|
||||
NEW_API_ADMIN_TOKEN = "sk-newapi-admin"
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.15/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": [
|
||||
"**/*",
|
||||
"!bun.lock",
|
||||
"!helloagents",
|
||||
"!.agents",
|
||||
"!tests",
|
||||
"!**/node_modules"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab"
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double"
|
||||
}
|
||||
},
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
"wrangler": "4",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.3.15",
|
||||
"@types/node": "^20.11.30",
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "^1.5.3",
|
||||
@@ -15,7 +16,13 @@
|
||||
},
|
||||
"apps/admin": {
|
||||
"name": "new-api-lite-admin",
|
||||
"dependencies": {
|
||||
"hono": "^4.11.9",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.0",
|
||||
},
|
||||
@@ -33,6 +40,26 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "https://registry.npmmirror.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||
|
||||
"@biomejs/biome": ["@biomejs/biome@2.3.15", "https://registry.npmmirror.com/@biomejs/biome/-/biome-2.3.15.tgz", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.15", "@biomejs/cli-darwin-x64": "2.3.15", "@biomejs/cli-linux-arm64": "2.3.15", "@biomejs/cli-linux-arm64-musl": "2.3.15", "@biomejs/cli-linux-x64": "2.3.15", "@biomejs/cli-linux-x64-musl": "2.3.15", "@biomejs/cli-win32-arm64": "2.3.15", "@biomejs/cli-win32-x64": "2.3.15" }, "bin": { "biome": "bin/biome" } }, "sha512-u+jlPBAU2B45LDkjjNNYpc1PvqrM/co4loNommS9/sl9oSxsAQKsNZejYuUztvToB5oXi1tN/e62iNd6ESiY3g=="],
|
||||
|
||||
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.15", "https://registry.npmmirror.com/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.15.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-SDCdrJ4COim1r8SNHg19oqT50JfkI/xGZHSyC6mGzMfKrpNe/217Eq6y98XhNTc0vGWDjznSDNXdUc6Kg24jbw=="],
|
||||
|
||||
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.15", "https://registry.npmmirror.com/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.15.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-RkyeSosBtn3C3Un8zQnl9upX0Qbq4E3QmBa0qjpOh1MebRbHhNlRC16jk8HdTe/9ym5zlfnpbb8cKXzW+vlTxw=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.15", "https://registry.npmmirror.com/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.15.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-FN83KxrdVWANOn5tDmW6UBC0grojchbGmcEz6JkRs2YY6DY63sTZhwkQ56x6YtKhDVV1Unz7FJexy8o7KwuIhg=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.15", "https://registry.npmmirror.com/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.15.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-SSSIj2yMkFdSkXqASzIBdjySBXOe65RJlhKEDlri7MN19RC4cpez+C0kEwPrhXOTgJbwQR9QH1F4+VnHkC35pg=="],
|
||||
|
||||
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.15", "https://registry.npmmirror.com/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.15.tgz", { "os": "linux", "cpu": "x64" }, "sha512-T8n9p8aiIKOrAD7SwC7opiBM1LYGrE5G3OQRXWgbeo/merBk8m+uxJ1nOXMPzfYyFLfPlKF92QS06KN1UW+Zbg=="],
|
||||
|
||||
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.15", "https://registry.npmmirror.com/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.15.tgz", { "os": "linux", "cpu": "x64" }, "sha512-dbjPzTh+ijmmNwojFYbQNMFp332019ZDioBYAMMJj5Ux9d8MkM+u+J68SBJGVwVeSHMYj+T9504CoxEzQxrdNw=="],
|
||||
|
||||
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.15", "https://registry.npmmirror.com/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.15.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-puMuenu/2brQdgqtQ7geNwQlNVxiABKEZJhMRX6AGWcmrMO8EObMXniFQywy2b81qmC+q+SDvlOpspNwz0WiOA=="],
|
||||
|
||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.15", "https://registry.npmmirror.com/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.15.tgz", { "os": "win32", "cpu": "x64" }, "sha512-kDZr/hgg+igo5Emi0LcjlgfkoGZtgIpJKhnvKTRmMBv6FF/3SDyEV4khBwqNebZIyMZTzvpca9sQNSXJ39pI2A=="],
|
||||
|
||||
"@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "https://registry.npmmirror.com/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="],
|
||||
|
||||
"@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.12.1", "https://registry.npmmirror.com/@cloudflare/unenv-preset/-/unenv-preset-2.12.1.tgz", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "^1.20260115.0" }, "optionalPeers": ["workerd"] }, "sha512-tP/Wi+40aBJovonSNJSsS7aFJY0xjuckKplmzDs2Xat06BJ68B6iG7YDUWXJL8gNn0gqW7YC5WhlYhO3QbugQA=="],
|
||||
@@ -163,6 +190,10 @@
|
||||
|
||||
"@jest/schemas": ["@jest/schemas@29.6.3", "https://registry.npmmirror.com/@jest/schemas/-/schemas-29.6.3.tgz", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "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", "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", { "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", "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
@@ -231,6 +262,36 @@
|
||||
|
||||
"@speed-highlight/core": ["@speed-highlight/core@1.2.14", "https://registry.npmmirror.com/@speed-highlight/core/-/core-1.2.14.tgz", {}, "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.1.18.tgz", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.1.18.tgz", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
|
||||
|
||||
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.18", "https://registry.npmmirror.com/@tailwindcss/postcss/-/postcss-4.1.18.tgz", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "postcss": "^8.4.41", "tailwindcss": "4.1.18" } }, "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/node": ["@types/node@20.19.33", "https://registry.npmmirror.com/@types/node/-/node-20.19.33.tgz", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw=="],
|
||||
@@ -289,6 +350,8 @@
|
||||
|
||||
"diff-sequences": ["diff-sequences@29.6.3", "https://registry.npmmirror.com/diff-sequences/-/diff-sequences-29.6.3.tgz", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.19.0", "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
|
||||
|
||||
"error-stack-parser-es": ["error-stack-parser-es@1.0.5", "https://registry.npmmirror.com/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.27.3", "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.3.tgz", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
|
||||
@@ -313,6 +376,8 @@
|
||||
|
||||
"glob-to-regexp": ["glob-to-regexp@0.4.1", "https://registry.npmmirror.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"hono": ["hono@4.11.9", "https://registry.npmmirror.com/hono/-/hono-4.11.9.tgz", {}, "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ=="],
|
||||
|
||||
"human-signals": ["human-signals@5.0.0", "https://registry.npmmirror.com/human-signals/-/human-signals-5.0.0.tgz", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="],
|
||||
@@ -323,10 +388,36 @@
|
||||
|
||||
"isexe": ["isexe@2.0.0", "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "https://registry.npmmirror.com/jiti/-/jiti-2.6.1.tgz", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"js-tokens": ["js-tokens@9.0.1", "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "https://registry.npmmirror.com/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.30.2", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.30.2.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
|
||||
|
||||
"local-pkg": ["local-pkg@0.5.1", "https://registry.npmmirror.com/local-pkg/-/local-pkg-0.5.1.tgz", { "dependencies": { "mlly": "^1.7.3", "pkg-types": "^1.2.1" } }, "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ=="],
|
||||
|
||||
"loupe": ["loupe@2.3.7", "https://registry.npmmirror.com/loupe/-/loupe-2.3.7.tgz", { "dependencies": { "get-func-name": "^2.0.1" } }, "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA=="],
|
||||
@@ -423,6 +514,10 @@
|
||||
|
||||
"supports-color": ["supports-color@10.2.2", "https://registry.npmmirror.com/supports-color/-/supports-color-10.2.2.tgz", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.18", "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.1.18.tgz", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||
|
||||
"tapable": ["tapable@2.3.0", "https://registry.npmmirror.com/tapable/-/tapable-2.3.0.tgz", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||
|
||||
"tinybench": ["tinybench@2.9.0", "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||
|
||||
"tinypool": ["tinypool@0.8.4", "https://registry.npmmirror.com/tinypool/-/tinypool-0.8.4.tgz", {}, "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ=="],
|
||||
@@ -467,6 +562,22 @@
|
||||
|
||||
"zod": ["zod@3.22.3", "https://registry.npmmirror.com/zod/-/zod-3.22.3.tgz", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="],
|
||||
|
||||
"@jridgewell/gen-mapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@jridgewell/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "https://registry.npmmirror.com/@emnapi/core/-/core-1.8.1.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.8.1.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"miniflare/sharp": ["sharp@0.34.5", "https://registry.npmmirror.com/sharp/-/sharp-0.34.5.tgz", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||
|
||||
"mlly/pathe": ["pathe@2.0.3", "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
@@ -3,6 +3,21 @@
|
||||
## [Unreleased]
|
||||
|
||||
### 微调
|
||||
- **[tooling]**: 修复 bun check 脚本名称(移除尾随空格)
|
||||
- 类型: 微调(无方案包)
|
||||
- 文件: package.json
|
||||
- **[admin-ui]**: 渠道创建移除 ID 字段并校验名称唯一
|
||||
- 类型: 微调(无方案包)
|
||||
- 文件: apps/admin/src/main.tsx:250-287, 664-829
|
||||
- **[worker]**: 全局记录收到的请求概要
|
||||
- 类型: 微调(无方案包)
|
||||
- 文件: apps/worker/src/index.ts
|
||||
- **[worker]**: base_url 为空时返回空字符串避免崩溃
|
||||
- 类型: 微调(无方案包)
|
||||
- 文件: apps/worker/src/utils/url.ts
|
||||
- **[tooling]**: dev 脚本改为 Bun workspace 执行
|
||||
- 类型: 微调(无方案包)
|
||||
- 文件: package.json
|
||||
- **[admin-ui]**: 本地开发增加 Vite proxy 解决前后端端口不一致
|
||||
- 类型: 微调(无方案包)
|
||||
- 文件: apps/admin/vite.config.ts, README.md
|
||||
@@ -36,6 +51,35 @@
|
||||
- **[worker]**: 新增 `/api/group` 兼容接口并放行鉴权
|
||||
- 类型: 微调(无方案包)
|
||||
- 文件: apps/worker/src/index.ts, apps/worker/src/routes/newapiGroups.ts, tests/worker/newapi.test.ts
|
||||
- **[proxy]**: 流式请求自动补 `stream_options.include_usage` 以获取 usage
|
||||
- 类型: 微调(无方案包)
|
||||
- 文件: apps/worker/src/routes/proxy.ts
|
||||
|
||||
## [0.4.1] - 2026-02-15
|
||||
|
||||
### 变更
|
||||
- **[admin-ui]**: 管理台入口拆分为模块、扁平化 features,并将 AppShell 调整为 AppLayout
|
||||
- 方案: [202602152325_admin-ui-modularize](archive/2026-02/202602152325_admin-ui-modularize/)
|
||||
- 决策: admin-ui-modularize#D001(功能域拆分)
|
||||
|
||||
## [0.4.0] - 2026-02-15
|
||||
|
||||
### 新增
|
||||
- **[channels]**: New API 标签批量权重/启用/停用接口
|
||||
- 方案: [202602152211_newapi-tag-sync](archive/2026-02/202602152211_newapi-tag-sync/)
|
||||
|
||||
## [0.3.1] - 2026-02-15
|
||||
|
||||
### 修复
|
||||
- **[proxy]**: 增强 usage 解析以修复使用日志与数据面板 token 统计为 0
|
||||
- 方案: [202602151843_fix-usage-tokens](archive/2026-02/202602151843_fix-usage-tokens/)
|
||||
|
||||
## [0.3.0] - 2026-02-15
|
||||
|
||||
### 变更
|
||||
- **[admin-ui]**: 管理台改为 Hono + TSX DOM 渲染并接入 Tailwind v4
|
||||
- 方案: [202602151628_admin-ui-hono-tsx-tailwind](archive/2026-02/202602151628_admin-ui-hono-tsx-tailwind/)
|
||||
- 决策: admin-ui-hono-tsx-tailwind#D001(采用 Hono JSX DOM + Tailwind)
|
||||
|
||||
## [0.2.1] - 2026-02-15
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
## 知识库状态
|
||||
|
||||
```yaml
|
||||
最后更新: 2026-02-15 02:03
|
||||
最后更新: 2026-02-15 17:17
|
||||
模块数量: 9
|
||||
待执行方案: 0
|
||||
```
|
||||
|
||||
@@ -21,9 +21,10 @@
|
||||
### 主要依赖
|
||||
| 依赖 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| Hono | 待定 | Worker 路由与中间件 |
|
||||
| Hono | 待定 | Worker 路由与管理台 TSX |
|
||||
| Wrangler | 待定 | Workers 构建与部署 |
|
||||
| Vite | 待定 | 管理台构建 |
|
||||
| TailwindCSS | 待定 | 管理台样式与设计系统 |
|
||||
|
||||
## 3. 项目概述
|
||||
|
||||
|
||||
@@ -10,7 +10,12 @@
|
||||
|
||||
## 行为规范
|
||||
- Vite 构建静态文件
|
||||
- 使用 `hono/jsx/dom` + TSX 渲染管理台入口
|
||||
- 入口文件为 `apps/admin/src/App.tsx`(`main.tsx` 已移除)
|
||||
- 按 `core/`(类型/常量/API)、`features/`(业务视图 + 布局/登录)组织前端模块
|
||||
- 样式基于 Tailwind v4(结合全局主题变量)
|
||||
- 前端状态集中在单页
|
||||
- 渠道管理采用列表视图,支持分页(默认 10 条),新增/编辑使用弹窗,保留启禁/连通测试操作
|
||||
- Token 仅本地保存,不回显
|
||||
|
||||
## 依赖关系
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
- `POST /api/channel` New API 兼容新增
|
||||
- `PUT /api/channel` New API 兼容更新
|
||||
- `DELETE /api/channel/:id` New API 兼容删除
|
||||
- `PUT /api/channel/tag` New API 兼容标签批量更新
|
||||
- `POST /api/channel/tag/enabled` New API 兼容标签批量启用
|
||||
- `POST /api/channel/tag/disabled` New API 兼容标签批量停用
|
||||
- `GET /api/channel/test/:id` New API 兼容连通性测试
|
||||
- `GET /api/channel/fetch_models/:id` New API 兼容模型拉取
|
||||
- `GET /api/group` New API 兼容分组列表
|
||||
@@ -26,6 +29,7 @@
|
||||
- 连通性测试调用 `/v1/models`
|
||||
- 测试成功后更新 `models_json`
|
||||
- New API 兼容层支持 `type`/`group`/`priority` 等字段映射,并保留扩展字段到 `metadata_json`
|
||||
- New API 标签(tag)存储在 `metadata_json.tag`,标签接口按该字段批量更新
|
||||
- New API 分组列表从 `group_name` 字段解析,空时返回 `default`
|
||||
|
||||
## 依赖关系
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
## 行为规范
|
||||
- 基于令牌鉴权
|
||||
- 按渠道权重随机排序
|
||||
- 非流式响应解析 usage 字段
|
||||
- 支持从非流式 JSON、响应头与流式 SSE 解析 usage 字段
|
||||
- 流式请求自动补 `stream_options.include_usage=true` 以便上游返回 usage
|
||||
- 可配置失败重试轮询(响应 5xx/429 时触发)
|
||||
|
||||
## 依赖关系
|
||||
|
||||
+24
-20
@@ -1,22 +1,26 @@
|
||||
{
|
||||
"name": "new-api-lite",
|
||||
"private": true,
|
||||
"packageManager": "bun@1.3.9",
|
||||
"workspaces": [
|
||||
"apps/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev:worker": "npm --workspace apps/worker run dev",
|
||||
"dev:admin": "npm --workspace apps/admin run dev",
|
||||
"test": "vitest",
|
||||
"fix": "bun pm pkg fix"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.30",
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "^1.5.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"wrangler": "4"
|
||||
}
|
||||
"name": "api-workers",
|
||||
"private": true,
|
||||
"packageManager": "bun@1.3.9",
|
||||
"workspaces": [
|
||||
"apps/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev:worker": "bun --filter new-api-lite-worker dev",
|
||||
"dev:admin": "bun --filter new-api-lite-admin dev",
|
||||
"test": "vitest",
|
||||
"format": "bunx --bun biome format --write .",
|
||||
"lint": "bunx --bun biome lint --write .",
|
||||
"check": "bunx --bun biome check --write .",
|
||||
"typecheck": "bunx --bun tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.3.15",
|
||||
"@types/node": "^20.11.30",
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "^1.5.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"wrangler": "4"
|
||||
}
|
||||
}
|
||||
+18
-8
@@ -1,10 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"types": ["vitest/globals"],
|
||||
"skipLibCheck": true
|
||||
}
|
||||
"exclude": ["**/dist", "helloagents", "tests", "**/node_modules"],
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "hono/jsx/dom",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"typeRoots": [
|
||||
"./node_modules",
|
||||
"./node_modules/@types",
|
||||
"./apps/admin/node_modules",
|
||||
"./apps/worker/node_modules"
|
||||
],
|
||||
"types": ["vitest/globals", "vite/client", "@cloudflare/workers-types"],
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -1,8 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['tests/**/*.test.ts'],
|
||||
environment: 'node'
|
||||
}
|
||||
test: {
|
||||
include: ["tests/**/*.test.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user