From 3bcc9ebb99a647ec573372b008b5d1d82a05cab5 Mon Sep 17 00:00:00 2001 From: lsy Date: Mon, 16 Feb 2026 00:48:49 +0800 Subject: [PATCH] =?UTF-8?q?refactor(app):=20=E7=AE=A1=E7=90=86=E5=8F=B0?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E5=8C=96=E5=B9=B6=E6=95=B4=E7=90=86=20worker?= =?UTF-8?q?=20API=20=E7=B1=BB=E5=9E=8B=20-=20=E6=8B=86=E5=88=86=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=8F=B0=E5=85=A5=E5=8F=A3=E4=B8=BA=20App/feature/cor?= =?UTF-8?q?e=20=E7=BB=93=E6=9E=84=E5=B9=B6=E6=9B=B4=E6=96=B0=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F/=E9=85=8D=E7=BD=AE=20-=20=E8=A7=84=E8=8C=83=20worker?= =?UTF-8?q?=20=E8=B7=AF=E7=94=B1/=E4=B8=AD=E9=97=B4=E4=BB=B6/=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E7=B1=BB=E5=9E=8B=E4=B8=8E=20JSON=20=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=20-=20=E5=90=8C=E6=AD=A5=E5=B7=A5=E5=85=B7=E9=93=BE?= =?UTF-8?q?=E4=B8=8E=E7=9F=A5=E8=AF=86=E5=BA=93=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 27 +- apps/admin/index.html | 2 +- apps/admin/package.json | 30 +- apps/admin/postcss.config.js | 5 + apps/admin/src/App.tsx | 566 +++++++++++++ apps/admin/src/core/api.ts | 40 + apps/admin/src/core/constants.ts | 34 + apps/admin/src/core/types.ts | 95 +++ apps/admin/src/core/utils.ts | 27 + apps/admin/src/features/AppLayout.tsx | 90 ++ apps/admin/src/features/ChannelsView.tsx | 332 ++++++++ apps/admin/src/features/DashboardView.tsx | 222 +++++ apps/admin/src/features/LoginView.tsx | 50 ++ apps/admin/src/features/ModelsView.tsx | 51 ++ apps/admin/src/features/SettingsView.tsx | 115 +++ apps/admin/src/features/TokensView.tsx | 139 +++ apps/admin/src/features/UsageView.tsx | 81 ++ apps/admin/src/main.ts | 718 ---------------- apps/admin/src/styles.css | 354 +------- apps/admin/tsconfig.json | 22 +- apps/admin/vite.config.ts | 22 +- apps/worker/package.json | 32 +- apps/worker/src/env.ts | 24 +- apps/worker/src/index.ts | 164 ++-- apps/worker/src/middleware/adminAuth.ts | 52 +- apps/worker/src/middleware/newApiAuth.ts | 89 +- apps/worker/src/middleware/tokenAuth.ts | 88 +- apps/worker/src/routes/auth.ts | 81 +- apps/worker/src/routes/channels.ts | 241 +++--- apps/worker/src/routes/dashboard.ts | 101 +-- apps/worker/src/routes/models.ts | 38 +- apps/worker/src/routes/newapiChannels.ts | 985 +++++++++++++--------- apps/worker/src/routes/newapiGroups.ts | 62 +- apps/worker/src/routes/newapiUsers.ts | 44 +- apps/worker/src/routes/proxy.ts | 346 +++++--- apps/worker/src/routes/settings.ts | 87 +- apps/worker/src/routes/tokens.ts | 198 +++-- apps/worker/src/routes/usage.ts | 74 +- apps/worker/src/services/channels.ts | 97 ++- apps/worker/src/services/newapi.ts | 476 ++++++----- apps/worker/src/services/quota.ts | 27 +- apps/worker/src/services/settings.ts | 136 ++- apps/worker/src/services/usage.ts | 99 ++- apps/worker/src/utils/crypto.ts | 22 +- apps/worker/src/utils/http.ts | 24 +- apps/worker/src/utils/json.ts | 21 +- apps/worker/src/utils/request.ts | 12 +- apps/worker/src/utils/time.ts | 8 +- apps/worker/src/utils/url.ts | 7 +- apps/worker/src/utils/usage.ts | 168 ++++ apps/worker/tsconfig.json | 20 +- apps/worker/wrangler.toml | 4 - biome.json | 42 + bun.lock | 111 +++ helloagents/CHANGELOG.md | 44 + helloagents/INDEX.md | 2 +- helloagents/context.md | 3 +- helloagents/modules/admin-ui.md | 5 + helloagents/modules/channels.md | 4 + helloagents/modules/proxy.md | 3 +- package.json | 44 +- tsconfig.json | 26 +- vitest.config.ts | 10 +- 63 files changed, 4496 insertions(+), 2647 deletions(-) create mode 100644 apps/admin/postcss.config.js create mode 100644 apps/admin/src/App.tsx create mode 100644 apps/admin/src/core/api.ts create mode 100644 apps/admin/src/core/constants.ts create mode 100644 apps/admin/src/core/types.ts create mode 100644 apps/admin/src/core/utils.ts create mode 100644 apps/admin/src/features/AppLayout.tsx create mode 100644 apps/admin/src/features/ChannelsView.tsx create mode 100644 apps/admin/src/features/DashboardView.tsx create mode 100644 apps/admin/src/features/LoginView.tsx create mode 100644 apps/admin/src/features/ModelsView.tsx create mode 100644 apps/admin/src/features/SettingsView.tsx create mode 100644 apps/admin/src/features/TokensView.tsx create mode 100644 apps/admin/src/features/UsageView.tsx delete mode 100644 apps/admin/src/main.ts create mode 100644 apps/worker/src/utils/usage.ts create mode 100644 biome.json diff --git a/README.md b/README.md index 1b83fc4..67bd9f7 100644 --- a/README.md +++ b/README.md @@ -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` 需指向管理台域名。 diff --git a/apps/admin/index.html b/apps/admin/index.html index 37c8509..ba117e7 100644 --- a/apps/admin/index.html +++ b/apps/admin/index.html @@ -12,7 +12,7 @@
diff --git a/apps/admin/package.json b/apps/admin/package.json index 95b047e..bc9b97b 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -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" + } } diff --git a/apps/admin/postcss.config.js b/apps/admin/postcss.config.js new file mode 100644 index 0000000..017b34b --- /dev/null +++ b/apps/admin/postcss.config.js @@ -0,0 +1,5 @@ +export default { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx new file mode 100644 index 0000000..06be6ff --- /dev/null +++ b/apps/admin/src/App.tsx @@ -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("#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(() => + localStorage.getItem("admin_token"), + ); + const [activeTab, setActiveTab] = useState("dashboard"); + const [loading, setLoading] = useState(false); + const [notice, setNotice] = useState(""); + const [data, setData] = useState(initialData); + const [settingsForm, setSettingsForm] = + useState(initialSettingsForm); + const [channelPage, setChannelPage] = useState(1); + const [channelPageSize, setChannelPageSize] = useState(10); + const [editingChannel, setEditingChannel] = useState(null); + const [channelForm, setChannelForm] = useState(() => ({ + ...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("/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("/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) => { + setChannelForm((prev) => ({ ...prev, ...patch })); + }, []); + + const handleSettingsFormChange = useCallback( + (patch: Partial) => { + 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 = { + 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 ( +
+ 加载中... +
+ ); + } + if (activeTab === "dashboard") { + return ; + } + if (activeTab === "channels") { + return ( + + ); + } + if (activeTab === "models") { + return ; + } + if (activeTab === "tokens") { + return ( + + ); + } + if (activeTab === "usage") { + return ; + } + if (activeTab === "settings") { + return ( + + ); + } + return ( +
+ 未知模块 +
+ ); + }; + + return ( +
+ {token ? ( + + {renderContent()} + + ) : ( + + )} +
+ ); +}; + +render(, root); diff --git a/apps/admin/src/core/api.ts b/apps/admin/src/core/api.ts new file mode 100644 index 0000000..b18fd04 --- /dev/null +++ b/apps/admin/src/core/api.ts @@ -0,0 +1,40 @@ +import { apiBase } from "./constants"; + +export type ApiFetch = (path: string, options?: RequestInit) => Promise; + +/** + * 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 (path: string, options: RequestInit = {}): Promise => { + 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; + }; +}; diff --git a/apps/admin/src/core/constants.ts b/apps/admin/src/core/constants.ts new file mode 100644 index 0000000..abade80 --- /dev/null +++ b/apps/admin/src/core/constants.ts @@ -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: "", +}; diff --git a/apps/admin/src/core/types.ts b/apps/admin/src/core/types.ts new file mode 100644 index 0000000..e66e699 --- /dev/null +++ b/apps/admin/src/core/types.ts @@ -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; +}; diff --git a/apps/admin/src/core/utils.ts b/apps/admin/src/core/utils.ts new file mode 100644 index 0000000..a99b978 --- /dev/null +++ b/apps/admin/src/core/utils.ts @@ -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"; diff --git a/apps/admin/src/features/AppLayout.tsx b/apps/admin/src/features/AppLayout.tsx new file mode 100644 index 0000000..72339ca --- /dev/null +++ b/apps/admin/src/features/AppLayout.tsx @@ -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) => ( +
+ +
+
+
+

+ {activeLabel} +

+

+ 集中管理渠道、模型、令牌与使用情况。 +

+
+
+ + {token ? "已登录" : "未登录"} + + +
+
+ {notice && ( +
+ {notice} +
+ )} + {children} +
+
+); diff --git a/apps/admin/src/features/ChannelsView.tsx b/apps/admin/src/features/ChannelsView.tsx new file mode 100644 index 0000000..7e0f753 --- /dev/null +++ b/apps/admin/src/features/ChannelsView.tsx @@ -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) => 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 ( +
+
+
+
+

+ 渠道列表 +

+

状态、权重与操作入口集中展示。

+
+
+ + + {channelTotal} 条 + + +
+
+
+
+
渠道
+
状态
+
权重
+
操作
+
+ {pagedChannels.length === 0 ? ( +
+ 暂无渠道,请先创建。 +
+ ) : ( +
+ {pagedChannels.map((channel) => { + const isActive = channel.status === "active"; + return ( +
+
+ + {channel.name} + + + {channel.base_url} + +
+
+ + {isActive ? "启用" : "禁用"} + +
+
+ {channel.weight} +
+
+ + + + +
+
+ ); + })} +
+ )} +
+
+
+ 第 {channelPage} / {channelTotalPages} 页 · 共 {channelTotal} 条 +
+
+ + +
+
+
+ {isChannelModalOpen && ( +
+
+
+
+

+ {isEditing ? "编辑渠道" : "新增渠道"} +

+

+ {isEditing + ? `正在编辑:${editingChannel?.name ?? ""}` + : "填写渠道信息并保存。"} +

+
+ +
+
+
+ + + onFormChange({ + name: (event.currentTarget as HTMLInputElement).value, + }) + } + /> +
+
+ + + onFormChange({ + base_url: (event.currentTarget as HTMLInputElement).value, + }) + } + /> +
+
+ + + onFormChange({ + api_key: (event.currentTarget as HTMLInputElement).value, + }) + } + /> +
+
+ + + onFormChange({ + weight: Number( + (event.currentTarget as HTMLInputElement).value || 0, + ), + }) + } + /> +
+
+ + +
+
+
+
+ )} +
+ ); +}; diff --git a/apps/admin/src/features/DashboardView.tsx b/apps/admin/src/features/DashboardView.tsx new file mode 100644 index 0000000..9cf53b8 --- /dev/null +++ b/apps/admin/src/features/DashboardView.tsx @@ -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 ( +
+ 暂无数据 +
+ ); + } + const errorRate = dashboard.summary.total_requests + ? Math.round( + (dashboard.summary.total_errors / dashboard.summary.total_requests) * + 100, + ) + : 0; + return ( +
+
+
+ + 总请求 + +
+ {dashboard.summary.total_requests} +
+ + 最近窗口 + +
+
+ + 总 Tokens + +
+ {dashboard.summary.total_tokens} +
+ + 累计消耗 + +
+
+ + 错误率 + +
{errorRate}%
+ + 平均延迟 {Math.round(dashboard.summary.avg_latency)}ms + +
+
+
+
+
+

+ 按日趋势 +

+
+ + + + + + + + + + {dashboard.byDay.map((row) => ( + + + + + + ))} + +
+ 日期 + + 请求 + + Tokens +
+ {row.day} + + {row.requests} + + {row.tokens} +
+
+
+
+

+ 模型排行 +

+
+ + + + + + + + + + {dashboard.byModel.map((row) => ( + + + + + + ))} + +
+ 模型 + + 请求 + + Tokens +
+ {row.model ?? "-"} + + {row.requests} + + {row.tokens} +
+
+
+
+
+
+

+ 渠道贡献 +

+
+ + + + + + + + + + {dashboard.byChannel.map((row) => ( + + + + + + ))} + +
+ 渠道 + + 请求 + + Tokens +
+ {row.channel_name ?? "-"} + + {row.requests} + + {row.tokens} +
+
+
+
+

+ 令牌贡献 +

+
+ + + + + + + + + + {dashboard.byToken.map((row) => ( + + + + + + ))} + +
+ 令牌 + + 请求 + + Tokens +
+ {row.token_name ?? "-"} + + {row.requests} + + {row.tokens} +
+
+
+
+ ); +}; diff --git a/apps/admin/src/features/LoginView.tsx b/apps/admin/src/features/LoginView.tsx new file mode 100644 index 0000000..8675de6 --- /dev/null +++ b/apps/admin/src/features/LoginView.tsx @@ -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) => ( +
+

+ api-workers +

+

请输入管理员密码登录管理台。

+
+
+ + +
+ +
+ {notice && ( +
+ {notice} +
+ )} +
+); diff --git a/apps/admin/src/features/ModelsView.tsx b/apps/admin/src/features/ModelsView.tsx new file mode 100644 index 0000000..d3b9f6e --- /dev/null +++ b/apps/admin/src/features/ModelsView.tsx @@ -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) => ( +
+
+

+ 模型广场 +

+ + {models.length} 个模型 + +
+ + + + + + + + + {models.map((model) => ( + + + + + ))} + +
+ 模型 + + 渠道 +
+ {model.id} + + {model.channels.map((channel) => channel.name).join(" / ")} +
+
+); diff --git a/apps/admin/src/features/SettingsView.tsx b/apps/admin/src/features/SettingsView.tsx new file mode 100644 index 0000000..fbb6424 --- /dev/null +++ b/apps/admin/src/features/SettingsView.tsx @@ -0,0 +1,115 @@ +import type { SettingsForm } from "../core/types"; + +type SettingsViewProps = { + settingsForm: SettingsForm; + adminPasswordSet: boolean; + onSubmit: (event: Event) => void; + onFormChange: (patch: Partial) => void; +}; + +/** + * Renders the settings view. + * + * Args: + * props: Settings view props. + * + * Returns: + * Settings JSX element. + */ +export const SettingsView = ({ + settingsForm, + adminPasswordSet, + onSubmit, + onFormChange, +}: SettingsViewProps) => ( +
+
+

+ 系统设置 +

+
+
+
+ + { + const target = event.currentTarget as HTMLInputElement | null; + onFormChange({ + log_retention_days: target?.value ?? "", + }); + }} + /> +
+
+ + { + const target = event.currentTarget as HTMLInputElement | null; + onFormChange({ + session_ttl_hours: target?.value ?? "", + }); + }} + /> +
+
+ + { + const target = event.currentTarget as HTMLInputElement | null; + onFormChange({ + admin_password: target?.value ?? "", + }); + }} + /> +

+ 密码状态:{adminPasswordSet ? "已设置" : "未设置"} +

+
+
+ +
+
+
+); diff --git a/apps/admin/src/features/TokensView.tsx b/apps/admin/src/features/TokensView.tsx new file mode 100644 index 0000000..a8866b5 --- /dev/null +++ b/apps/admin/src/features/TokensView.tsx @@ -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) => ( +
+
+
+

+ 生成令牌 +

+
+
+
+ + +
+
+ + +
+ +
+
+
+
+

+ 令牌列表 +

+
+ + + + + + + + + + + {tokens.map((tokenItem) => ( + + + + + + + ))} + +
+ 名称 + + 状态 + + 已用/额度 + + 操作 +
+ {tokenItem.name} + + {tokenItem.status} + + {tokenItem.quota_used} / {tokenItem.quota_total ?? "∞"} + +
+ + + +
+
+
+
+); diff --git a/apps/admin/src/features/UsageView.tsx b/apps/admin/src/features/UsageView.tsx new file mode 100644 index 0000000..b60227f --- /dev/null +++ b/apps/admin/src/features/UsageView.tsx @@ -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) => ( +
+
+

+ 使用日志 +

+ +
+ + + + + + + + + + + + + {usage.map((log) => ( + + + + + + + + + ))} + +
+ 时间 + + 模型 + + 渠道 + + Tokens + + 延迟 + + 状态 +
+ {formatDateTime(log.created_at)} + + {log.model ?? "-"} + + {log.channel_name ?? log.channel_id ?? "-"} + + {log.total_tokens ?? 0} + + {log.latency_ms ?? 0} ms + + {log.status} +
+
+); diff --git a/apps/admin/src/main.ts b/apps/admin/src/main.ts deleted file mode 100644 index c822b73..0000000 --- a/apps/admin/src/main.ts +++ /dev/null @@ -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('#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(path: string, options: RequestInit = {}): Promise { - 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; -} - -async function loadDashboard() { - state.data.dashboard = await apiFetch('/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('/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 ` - - `; -} - -function renderSidebar() { - return ` - - `; -} - -function renderDashboard() { - const data = state.data.dashboard; - if (!data) { - return '
暂无数据
'; - } - const errorRate = data.summary.total_requests - ? Math.round((data.summary.total_errors / data.summary.total_requests) * 100) - : 0; - return ` -
-
- 总请求 -
${data.summary.total_requests}
- 最近窗口 -
-
- 总 Tokens -
${data.summary.total_tokens}
- 累计消耗 -
-
- 错误率 -
${errorRate}%
- 平均延迟 ${Math.round(data.summary.avg_latency)}ms -
-
-
-
-
-

按日趋势

-
- - - - ${data.byDay - .map((row) => ``) - .join('')} - -
日期请求Tokens
${row.day}${row.requests}${row.tokens}
-
-
-
-

模型排行

-
- - - - ${data.byModel - .map((row) => ``) - .join('')} - -
模型请求Tokens
${row.model ?? '-'}${row.requests}${row.tokens}
-
-
-
-
-
-

渠道贡献

-
- - - - ${data.byChannel - .map((row) => ``) - .join('')} - -
渠道请求Tokens
${row.channel_name ?? '-'}${row.requests}${row.tokens}
-
-
-
-

令牌贡献

-
- - - - ${data.byToken - .map((row) => ``) - .join('')} - -
令牌请求Tokens
${row.token_name ?? '-'}${row.requests}${row.tokens}
-
-
- `; -} - -function renderChannels() { - return ` -
-
-
-

新增渠道

-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - -
-
- - -
-
- -
-
-
-
-

渠道列表

-
- - - - - - ${state.data.channels - .map( - (channel) => ` - - - - - - - - ` - ) - .join('')} - -
ID名称状态权重操作
${channel.id}${channel.name}${channel.status}${channel.weight} - - - -
-
-
- `; -} - -function renderModels() { - return ` -
-
-

模型广场

- ${state.data.models.length} 个模型 -
- - - - ${state.data.models - .map( - (model) => ` - - - - - ` - ) - .join('')} - -
模型渠道
${model.id}${model.channels.map((channel) => channel.name).join(' / ')}
-
- `; -} - -function renderTokens() { - return ` -
-
-
-

生成令牌

-
-
-
- - -
-
- - -
- -
-
-
-
-

令牌列表

-
- - - - - - ${state.data.tokens - .map( - (token) => ` - - - - - - - ` - ) - .join('')} - -
名称状态已用/额度操作
${token.name}${token.status}${token.quota_used} / ${token.quota_total ?? '∞'} - - - -
-
-
- `; -} - -function renderUsage() { - return ` -
-
-

使用日志

- -
- - - - ${state.data.usage - .map( - (log) => ` - - - - - - - - - ` - ) - .join('')} - -
时间模型渠道Tokens延迟状态
${log.created_at?.slice(0, 19).replace('T', ' ')}${log.model ?? '-'}${log.channel_name ?? log.channel_id ?? '-'}${log.total_tokens ?? 0}${log.latency_ms ?? 0} ms${log.status}
-
- `; -} - -function renderSettings() { - const retention = state.data.settings?.log_retention_days ?? 30; - return ` -
-
-

系统设置

-
-
-
- - -
-
- -
-
-
- `; -} - -function renderContent() { - if (state.loading) { - return '
加载中...
'; - } - 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 '
未知模块
'; -} - -function renderApp() { - return ` -
- ${renderSidebar()} -
-
-
-

${tabs.find((tab) => tab.id === state.activeTab)?.label ?? '管理台'}

-

集中管理渠道、模型、令牌与使用情况。

-
-
- ${state.token ? '已登录' : '未登录'} - -
-
- ${state.notice ? `
${state.notice}
` : ''} - ${renderContent()} -
-
- `; -} - -function render() { - root!.innerHTML = state.token ? renderApp() : renderLogin(); - bindEvents(); -} - -function bindEvents() { - const loginForm = document.querySelector('#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('#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; - 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('#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('#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); -} diff --git a/apps/admin/src/styles.css b/apps/admin/src/styles.css index 8704f65..f1d8c73 100644 --- a/apps/admin/src/styles.css +++ b/apps/admin/src/styles.css @@ -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"; diff --git a/apps/admin/tsconfig.json b/apps/admin/tsconfig.json index 457e12a..eee290a 100644 --- a/apps/admin/tsconfig.json +++ b/apps/admin/tsconfig.json @@ -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"] } diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts index 0b16cf9..40e6f6d 100644 --- a/apps/admin/vite.config.ts +++ b/apps/admin/vite.config.ts @@ -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, + }, + }, }); diff --git a/apps/worker/package.json b/apps/worker/package.json index 4a2e4f3..703dd85 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -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" + } } diff --git a/apps/worker/src/env.ts b/apps/worker/src/env.ts index fe89a30..65a0548 100644 --- a/apps/worker/src/env.ts +++ b/apps/worker/src/env.ts @@ -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; }; diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index f8b69d2..9440f78 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -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({ 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; diff --git a/apps/worker/src/middleware/adminAuth.ts b/apps/worker/src/middleware/adminAuth.ts index 5732b17..a6645a0 100644 --- a/apps/worker/src/middleware/adminAuth.ts +++ b/apps/worker/src/middleware/adminAuth.ts @@ -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(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(); }); diff --git a/apps/worker/src/middleware/newApiAuth.ts b/apps/worker/src/middleware/newApiAuth.ts index 1c44b45..30fc238 100644 --- a/apps/worker/src/middleware/newApiAuth.ts +++ b/apps/worker/src/middleware/newApiAuth.ts @@ -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, + 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): 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(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(); }); diff --git a/apps/worker/src/middleware/tokenAuth.ts b/apps/worker/src/middleware/tokenAuth.ts index 7e326da..bb9ed8b 100644 --- a/apps/worker/src/middleware/tokenAuth.ts +++ b/apps/worker/src/middleware/tokenAuth.ts @@ -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(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(); + 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(); - 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(); }); diff --git a/apps/worker/src/routes/auth.ts b/apps/worker/src/routes/auth.ts index 1d6a54e..29a320c 100644 --- a/apps/worker/src/routes/auth.ts +++ b/apps/worker/src/routes/auth.ts @@ -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(); /** * 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; diff --git a/apps/worker/src/routes/channels.ts b/apps/worker/src/routes/channels.ts index 3ec3811..abf1d17 100644 --- a/apps/worker/src/routes/channels.ts +++ b/apps/worker/src/routes/channels.ts @@ -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(); +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(); * 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(); + 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; diff --git a/apps/worker/src/routes/dashboard.ts b/apps/worker/src/routes/dashboard.ts index 1bc4582..5dd5b80 100644 --- a/apps/worker/src/routes/dashboard.ts +++ b/apps/worker/src/routes/dashboard.ts @@ -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(); function buildDateFilters(query: Record) { - let sql = ' WHERE 1=1'; - const params: Array = []; - 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 = []; + 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; diff --git a/apps/worker/src/routes/models.ts b/apps/worker/src/routes/models.ts index a207d4a..c309d33 100644 --- a/apps/worker/src/routes/models.ts +++ b/apps/worker/src/routes/models.ts @@ -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(); /** * 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(); - 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; diff --git a/apps/worker/src/routes/newapiChannels.ts b/apps/worker/src/routes/newapiChannels.ts index 6ed8115..0d774c9 100644 --- a/apps/worker/src/routes/newapiChannels.ts +++ b/apps/worker/src/routes/newapiChannels.ts @@ -1,466 +1,669 @@ -import { Hono } from 'hono'; -import type { AppEnv } from '../env'; -import { newApiAuth } from '../middleware/newApiAuth'; -import { nowIso } from '../utils/time'; -import { generateToken } from '../utils/crypto'; -import { normalizeBaseUrl } from '../utils/url'; +import { type Context, Hono } from "hono"; +import type { ContentfulStatusCode } from "hono/utils/http-status"; +import type { AppEnv } from "../env"; +import { newApiAuth } from "../middleware/newApiAuth"; import { - extractModelIds, - mergeMetadata, - modelsToJson, - normalizeChannelInput, - normalizeModelsInput, - normalizeBaseUrlInput, - toNewApiChannel -} from '../services/newapi'; -import { safeJsonParse } from '../utils/json'; + type ChannelRow, + extractModelIds, + mergeMetadata, + modelsToJson, + normalizeBaseUrlInput, + normalizeChannelInput, + normalizeModelsInput, + toNewApiChannel, +} from "../services/newapi"; +import { generateToken } from "../utils/crypto"; +import { safeJsonParse } from "../utils/json"; +import { nowIso } from "../utils/time"; +import { normalizeBaseUrl } from "../utils/url"; const newapi = new Hono({ strict: false }); -newapi.use('*', newApiAuth); +newapi.use("*", newApiAuth); -function success(c: any, data?: any, message = '') { - return c.json( - { - success: true, - message, - ...(data !== undefined ? { data } : {}) - }, - 200 - ); +function success(c: Context, data?: T, message = "") { + return c.json( + { + success: true, + message, + ...(data !== undefined ? { data } : {}), + }, + 200, + ); } -function failure(c: any, status: number, message: string) { - return c.json( - { - success: false, - message - }, - status - ); +function failure( + c: Context, + status: ContentfulStatusCode, + message: string, +) { + return c.json( + { + success: false, + message, + }, + status, + ); } -function normalizePage(value: string | undefined | null, fallback: number): number { - const parsed = Number(value); - if (Number.isNaN(parsed) || parsed <= 0) { - return fallback; - } - return Math.floor(parsed); +function normalizePage( + value: string | undefined | null, + fallback: number, +): number { + const parsed = Number(value); + if (Number.isNaN(parsed) || parsed <= 0) { + return fallback; + } + return Math.floor(parsed); } -function normalizePageSize(value: string | undefined | null, fallback: number): number { - const parsed = Number(value); - if (Number.isNaN(parsed) || parsed <= 0) { - return fallback; - } - return Math.min(200, Math.floor(parsed)); +function normalizePageSize( + value: string | undefined | null, + fallback: number, +): number { + const parsed = Number(value); + if (Number.isNaN(parsed) || parsed <= 0) { + return fallback; + } + return Math.min(200, Math.floor(parsed)); } -function normalizeStatusFilter(value: string | undefined | null): string | null { - if (!value) { - return null; - } - const normalized = value.trim().toLowerCase(); - if (normalized === 'all') { - return null; - } - if (['enabled', 'enable', '1', 'active'].includes(normalized)) { - return 'active'; - } - if (['disabled', 'disable', '0', '2', 'inactive'].includes(normalized)) { - return 'disabled'; - } - return null; +function normalizeStatusFilter( + value: string | undefined | null, +): string | null { + if (!value) { + return null; + } + const normalized = value.trim().toLowerCase(); + if (normalized === "all") { + return null; + } + if (["enabled", "enable", "1", "active"].includes(normalized)) { + return "active"; + } + if (["disabled", "disable", "0", "2", "inactive"].includes(normalized)) { + return "disabled"; + } + return null; } function normalizeBoolean(value: string | undefined | null): boolean { - if (!value) { - return false; - } - const normalized = value.trim().toLowerCase(); - return normalized === '1' || normalized === 'true' || normalized === 'yes'; + if (!value) { + return false; + } + const normalized = value.trim().toLowerCase(); + return normalized === "1" || normalized === "true" || normalized === "yes"; +} + +function readTag(metadataJson: string | null | undefined): string | null { + const metadata = safeJsonParse>(metadataJson, {}); + const tag = metadata.tag; + if (tag === undefined || tag === null) { + return null; + } + return String(tag); } async function fetchModels(baseUrl: string, apiKey: string) { - const target = `${normalizeBaseUrl(baseUrl)}/v1/models`; - const start = Date.now(); - const response = await fetch(target, { - method: 'GET', - headers: { - Authorization: `Bearer ${apiKey}`, - 'x-api-key': apiKey, - 'Content-Type': 'application/json' - } - }); + const target = `${normalizeBaseUrl(baseUrl)}/v1/models`; + const start = Date.now(); + const response = await fetch(target, { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + "x-api-key": apiKey, + "Content-Type": "application/json", + }, + }); - const elapsed = Date.now() - start; - if (!response.ok) { - return { ok: false, elapsed, models: [] as string[] }; - } + const elapsed = Date.now() - start; + if (!response.ok) { + return { ok: false, elapsed, models: [] as string[] }; + } - const data = await response.json().catch(() => ({ data: [] })); - const models = normalizeModelsInput(data?.data ?? data); - return { ok: true, elapsed, models }; + const payload = (await response.json().catch(() => ({ data: [] }))) as + | { data?: unknown[] } + | unknown[]; + const models = normalizeModelsInput( + Array.isArray(payload) ? payload : (payload.data ?? payload), + ); + return { ok: true, elapsed, models }; } async function updateChannelTestResult( - c: any, - id: string, - ok: boolean, - elapsed: number, - models?: string[] + c: Context, + id: string, + ok: boolean, + elapsed: number, + models?: string[], ) { - const now = Math.floor(Date.now() / 1000); - const status = ok ? 'active' : 'error'; - const modelsJson = models ? modelsToJson(models) : undefined; - const sql = modelsJson - ? 'UPDATE channels SET status = ?, models_json = ?, test_time = ?, response_time_ms = ?, updated_at = ? WHERE id = ?' - : 'UPDATE channels SET status = ?, test_time = ?, response_time_ms = ?, updated_at = ? WHERE id = ?'; + const now = Math.floor(Date.now() / 1000); + const status = ok ? "active" : "error"; + const modelsJson = models ? modelsToJson(models) : undefined; + const sql = modelsJson + ? "UPDATE channels SET status = ?, models_json = ?, test_time = ?, response_time_ms = ?, updated_at = ? WHERE id = ?" + : "UPDATE channels SET status = ?, test_time = ?, response_time_ms = ?, updated_at = ? WHERE id = ?"; - const stmt = c.env.DB.prepare(sql); - if (modelsJson) { - await stmt.bind(status, modelsJson, now, elapsed, nowIso(), id).run(); - } else { - await stmt.bind(status, now, elapsed, nowIso(), id).run(); - } + const stmt = c.env.DB.prepare(sql); + if (modelsJson) { + await stmt.bind(status, modelsJson, now, elapsed, nowIso(), id).run(); + } else { + await stmt.bind(status, now, elapsed, nowIso(), id).run(); + } } -async function loadChannels(c: any, where: string[], bindings: Array) { - const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''; - const rows = await c.env.DB.prepare( - `SELECT * FROM channels ${whereSql} ORDER BY priority DESC, created_at DESC` - ) - .bind(...bindings) - .all(); - return rows.results ?? []; +async function loadChannels( + c: Context, + where: string[], + bindings: Array, +): Promise { + const whereSql = where.length > 0 ? `WHERE ${where.join(" AND ")}` : ""; + const rows = await c.env.DB.prepare( + `SELECT * FROM channels ${whereSql} ORDER BY priority DESC, created_at DESC`, + ) + .bind(...bindings) + .all(); + return rows.results ?? []; } -newapi.get('/', async (c) => { - const page = normalizePage(c.req.query('p') ?? c.req.query('page'), 1); - const pageSize = normalizePageSize(c.req.query('page_size') ?? c.req.query('limit'), 20); - const idSort = normalizeBoolean(c.req.query('id_sort')); - const statusFilter = normalizeStatusFilter(c.req.query('status')); - const typeFilter = c.req.query('type'); +newapi.get("/", async (c) => { + const page = normalizePage(c.req.query("p") ?? c.req.query("page"), 1); + const pageSize = normalizePageSize( + c.req.query("page_size") ?? c.req.query("limit"), + 20, + ); + const idSort = normalizeBoolean(c.req.query("id_sort")); + const statusFilter = normalizeStatusFilter(c.req.query("status")); + const typeFilter = c.req.query("type"); - const where: string[] = []; - const bindings: Array = []; + const where: string[] = []; + const bindings: Array = []; - if (statusFilter) { - where.push('status = ?'); - bindings.push(statusFilter); - } - if (typeFilter) { - where.push('type = ?'); - bindings.push(Number(typeFilter)); - } + if (statusFilter) { + where.push("status = ?"); + bindings.push(statusFilter); + } + if (typeFilter) { + where.push("type = ?"); + bindings.push(Number(typeFilter)); + } - const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''; - const orderSql = idSort ? 'ORDER BY id ASC' : 'ORDER BY priority DESC, created_at DESC'; - const offset = (page - 1) * pageSize; + const whereSql = where.length > 0 ? `WHERE ${where.join(" AND ")}` : ""; + const orderSql = idSort + ? "ORDER BY id ASC" + : "ORDER BY priority DESC, created_at DESC"; + const offset = (page - 1) * pageSize; - const totalRow = await c.env.DB.prepare( - `SELECT COUNT(*) as count FROM channels ${whereSql}` - ) - .bind(...bindings) - .first<{ count: number }>(); + const totalRow = await c.env.DB.prepare( + `SELECT COUNT(*) as count FROM channels ${whereSql}`, + ) + .bind(...bindings) + .first<{ count: number }>(); - const rows = await c.env.DB.prepare( - `SELECT * FROM channels ${whereSql} ${orderSql} LIMIT ? OFFSET ?` - ) - .bind(...bindings, pageSize, offset) - .all(); + const rows = await c.env.DB.prepare( + `SELECT * FROM channels ${whereSql} ${orderSql} LIMIT ? OFFSET ?`, + ) + .bind(...bindings, pageSize, offset) + .all(); - const counts = await c.env.DB.prepare( - `SELECT type, COUNT(*) as count FROM channels ${whereSql} GROUP BY type` - ) - .bind(...bindings) - .all(); + const counts = await c.env.DB.prepare( + `SELECT type, COUNT(*) as count FROM channels ${whereSql} GROUP BY type`, + ) + .bind(...bindings) + .all(); - const typeCounts: Record = { all: Number(totalRow?.count ?? 0) }; - for (const entry of counts.results ?? []) { - typeCounts[String(entry.type)] = Number(entry.count ?? 0); - } + const typeCounts: Record = { + all: Number(totalRow?.count ?? 0), + }; + for (const entry of counts.results ?? []) { + typeCounts[String(entry.type)] = Number(entry.count ?? 0); + } - const items = (rows.results ?? []).map((row) => { - const channel = toNewApiChannel(row as any); - delete (channel as any).key; - return channel; - }); + const items = (rows.results ?? []).map((row) => { + const { key: _key, ...rest } = toNewApiChannel(row); + return rest; + }); - return success(c, { - items, - total: Number(totalRow?.count ?? 0), - page, - page_size: pageSize, - type_counts: typeCounts - }); + return success(c, { + items, + total: Number(totalRow?.count ?? 0), + page, + page_size: pageSize, + type_counts: typeCounts, + }); }); -newapi.get('/search', async (c) => { - const page = normalizePage(c.req.query('p') ?? c.req.query('page'), 1); - const pageSize = normalizePageSize(c.req.query('page_size') ?? c.req.query('limit'), 20); - const statusFilter = normalizeStatusFilter(c.req.query('status')); - const typeFilter = c.req.query('type'); - const keyword = c.req.query('keyword') ?? ''; - const group = c.req.query('group') ?? ''; - const model = c.req.query('model') ?? ''; +newapi.get("/search", async (c) => { + const page = normalizePage(c.req.query("p") ?? c.req.query("page"), 1); + const pageSize = normalizePageSize( + c.req.query("page_size") ?? c.req.query("limit"), + 20, + ); + const statusFilter = normalizeStatusFilter(c.req.query("status")); + const typeFilter = c.req.query("type"); + const keyword = c.req.query("keyword") ?? ""; + const group = c.req.query("group") ?? ""; + const model = c.req.query("model") ?? ""; - const where: string[] = []; - const bindings: Array = []; + const where: string[] = []; + const bindings: Array = []; - if (statusFilter) { - where.push('status = ?'); - bindings.push(statusFilter); - } - if (typeFilter) { - where.push('type = ?'); - bindings.push(Number(typeFilter)); - } + if (statusFilter) { + where.push("status = ?"); + bindings.push(statusFilter); + } + if (typeFilter) { + where.push("type = ?"); + bindings.push(Number(typeFilter)); + } - const rows = await loadChannels(c, where, bindings); - const filtered = rows.filter((row) => { - const channel = row as any; - const models = extractModelIds(channel as any); - if (keyword && !String(channel.name).includes(keyword) && !String(channel.id).includes(keyword)) { - return false; - } - if (group && !String(channel.group_name ?? '').includes(group)) { - return false; - } - if (model && !models.includes(model)) { - return false; - } - return true; - }); + const rows = await loadChannels(c, where, bindings); + const filtered = rows.filter((row) => { + const channel = row; + const models = extractModelIds(channel); + if ( + keyword && + !String(channel.name).includes(keyword) && + !String(channel.id).includes(keyword) + ) { + return false; + } + if (group && !String(channel.group_name ?? "").includes(group)) { + return false; + } + if (model && !models.includes(model)) { + return false; + } + return true; + }); - const total = filtered.length; - const offset = (page - 1) * pageSize; - const items = filtered.slice(offset, offset + pageSize).map((row) => { - const channel = toNewApiChannel(row as any); - delete (channel as any).key; - return channel; - }); + const total = filtered.length; + const offset = (page - 1) * pageSize; + const items = filtered.slice(offset, offset + pageSize).map((row) => { + const { key: _key, ...rest } = toNewApiChannel(row); + return rest; + }); - return success(c, { - items, - total, - page, - page_size: pageSize - }); + return success(c, { + items, + total, + page, + page_size: pageSize, + }); }); -newapi.get('/models', async (c) => { - const result = await c.env.DB.prepare('SELECT * FROM channels WHERE status = ?') - .bind('active') - .all(); - const models = new Set(); - 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 success(c, data); +newapi.put("/tag", async (c) => { + const body = await c.req.json().catch(() => null); + if (!body?.tag) { + return failure(c, 400, "tag不能为空"); + } + + const tag = String(body.tag).trim(); + const nextTag = + body.new_tag !== undefined && body.new_tag !== null + ? String(body.new_tag).trim() + : null; + const nextWeight = + body.weight !== undefined && body.weight !== null + ? Number(body.weight) + : null; + const nextPriority = + body.priority !== undefined && body.priority !== null + ? Number(body.priority) + : null; + + const rows = await c.env.DB.prepare( + "SELECT * FROM channels", + ).all(); + const targets = (rows.results ?? []).filter( + (row) => readTag(row.metadata_json) === tag, + ); + + for (const row of targets) { + const metadata = safeJsonParse>( + row.metadata_json, + {}, + ); + if (nextTag && nextTag.length > 0) { + metadata.tag = nextTag; + } else if (metadata.tag === undefined || metadata.tag === null) { + metadata.tag = tag; + } + const mergedMetadata = + Object.keys(metadata).length > 0 ? JSON.stringify(metadata) : null; + const weight = + nextWeight !== null && !Number.isNaN(nextWeight) + ? nextWeight + : (row.weight ?? 1); + const priority = + nextPriority !== null && !Number.isNaN(nextPriority) + ? nextPriority + : (row.priority ?? 0); + + await c.env.DB.prepare( + "UPDATE channels SET weight = ?, priority = ?, metadata_json = ?, updated_at = ? WHERE id = ?", + ) + .bind(weight, priority, mergedMetadata, nowIso(), row.id) + .run(); + } + + return success(c); }); -newapi.get('/models_enabled', async (c) => { - const result = await c.env.DB.prepare('SELECT * FROM channels WHERE status = ?') - .bind('active') - .all(); - const models = new Set(); - 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 success(c, data); +newapi.post("/tag/enabled", async (c) => { + const body = await c.req.json().catch(() => null); + if (!body?.tag) { + return failure(c, 400, "参数错误"); + } + const tag = String(body.tag).trim(); + const rows = await c.env.DB.prepare( + "SELECT * FROM channels", + ).all(); + const targets = (rows.results ?? []).filter( + (row) => readTag(row.metadata_json) === tag, + ); + + for (const row of targets) { + await c.env.DB.prepare( + "UPDATE channels SET status = ?, updated_at = ? WHERE id = ?", + ) + .bind("active", nowIso(), row.id) + .run(); + } + + return success(c); }); -newapi.post('/', async (c) => { - const body = await c.req.json().catch(() => null); - if (!body) { - return failure(c, 400, '请求体为空'); - } +newapi.post("/tag/disabled", async (c) => { + const body = await c.req.json().catch(() => null); + if (!body?.tag) { + return failure(c, 400, "参数错误"); + } + const tag = String(body.tag).trim(); + const rows = await c.env.DB.prepare( + "SELECT * FROM channels", + ).all(); + const targets = (rows.results ?? []).filter( + (row) => readTag(row.metadata_json) === tag, + ); - const mode = body.mode ?? 'single'; - if (mode !== 'single') { - return failure(c, 400, '仅支持单渠道添加'); - } + for (const row of targets) { + await c.env.DB.prepare( + "UPDATE channels SET status = ?, updated_at = ? WHERE id = ?", + ) + .bind("disabled", nowIso(), row.id) + .run(); + } - const payload = body.channel ?? body; - const parsed = normalizeChannelInput(payload); - if (!parsed.name || !parsed.base_url || !parsed.api_key) { - return failure(c, 400, '缺少必要参数'); - } - - const existingId = parsed.id ?? generateToken('ch_'); - const exists = await c.env.DB.prepare('SELECT id FROM channels WHERE id = ?').bind(existingId).first(); - if (exists) { - return failure(c, 409, '渠道已存在'); - } - - const now = nowIso(); - const baseUrl = normalizeBaseUrlInput(parsed.base_url); - await c.env.DB.prepare( - 'INSERT INTO channels (id, name, base_url, api_key, weight, status, rate_limit, models_json, type, group_name, priority, metadata_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' - ) - .bind( - existingId, - parsed.name, - baseUrl ?? normalizeBaseUrl(String(parsed.base_url)), - parsed.api_key, - parsed.weight ?? 1, - parsed.status ?? 'active', - parsed.rate_limit ?? 0, - parsed.models_json, - parsed.type ?? 1, - parsed.group_name, - parsed.priority ?? 0, - parsed.metadata_json, - now, - now - ) - .run(); - - return success(c); + return success(c); }); -newapi.put('/', async (c) => { - const body = await c.req.json().catch(() => null); - if (!body) { - return failure(c, 400, '请求体为空'); - } - const payload = body.channel ?? body; - const id = payload?.id ?? body?.id; - if (!id) { - return failure(c, 400, '缺少渠道ID'); - } - - const current = await c.env.DB.prepare('SELECT * FROM channels WHERE id = ?').bind(String(id)).first(); - if (!current) { - return failure(c, 404, '渠道不存在'); - } - - const parsed = normalizeChannelInput(payload); - const models = parsed.models.length > 0 ? parsed.models : extractModelIds(current as any); - const mergedMetadata = mergeMetadata(current.metadata_json, parsed.metadata_json); - const nextBaseUrl = normalizeBaseUrlInput(parsed.base_url ?? current.base_url) ?? String(current.base_url); - - await c.env.DB.prepare( - 'UPDATE channels SET name = ?, base_url = ?, api_key = ?, weight = ?, status = ?, rate_limit = ?, models_json = ?, type = ?, group_name = ?, priority = ?, metadata_json = ?, updated_at = ? WHERE id = ?' - ) - .bind( - parsed.name ?? current.name, - nextBaseUrl, - parsed.api_key ?? current.api_key, - parsed.weight ?? current.weight ?? 1, - parsed.status ?? current.status, - parsed.rate_limit ?? current.rate_limit ?? 0, - modelsToJson(models), - parsed.type ?? current.type ?? 1, - parsed.group_name ?? current.group_name, - parsed.priority ?? current.priority ?? 0, - mergedMetadata, - nowIso(), - String(id) - ) - .run(); - - return success(c); +newapi.get("/models", async (c) => { + const result = await c.env.DB.prepare( + "SELECT * FROM channels WHERE status = ?", + ) + .bind("active") + .all(); + const models = new Set(); + for (const row of result.results ?? []) { + for (const id of extractModelIds(row)) { + models.add(id); + } + } + const data = Array.from(models).map((id) => ({ id, name: id })); + return success(c, data); }); -newapi.delete('/:id', async (c) => { - const id = c.req.param('id'); - const existing = await c.env.DB.prepare('SELECT id FROM channels WHERE id = ?').bind(id).first(); - if (!existing) { - return failure(c, 404, '渠道不存在'); - } - await c.env.DB.prepare('DELETE FROM channels WHERE id = ?').bind(id).run(); - return success(c); +newapi.get("/models_enabled", async (c) => { + const result = await c.env.DB.prepare( + "SELECT * FROM channels WHERE status = ?", + ) + .bind("active") + .all(); + const models = new Set(); + for (const row of result.results ?? []) { + for (const id of extractModelIds(row)) { + models.add(id); + } + } + const data = Array.from(models).map((id) => ({ id, name: id })); + return success(c, data); }); -newapi.get('/test/:id', 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 failure(c, 404, '渠道不存在'); - } +newapi.post("/", async (c) => { + const body = await c.req.json().catch(() => null); + if (!body) { + return failure(c, 400, "请求体为空"); + } - const result = await fetchModels(String(channel.base_url), String(channel.api_key)); - if (!result.ok) { - await updateChannelTestResult(c, id, false, result.elapsed); - return failure(c, 502, '渠道测试失败'); - } + const mode = body.mode ?? "single"; + if (mode !== "single") { + return failure(c, 400, "仅支持单渠道添加"); + } - await updateChannelTestResult(c, id, true, result.elapsed); + const payload = body.channel ?? body; + const parsed = normalizeChannelInput(payload); + if (!parsed.name || !parsed.base_url || !parsed.api_key) { + return failure(c, 400, "缺少必要参数"); + } - return success(c, undefined, '测试成功'); + const existingId = parsed.id ?? generateToken("ch_"); + const exists = await c.env.DB.prepare("SELECT id FROM channels WHERE id = ?") + .bind(existingId) + .first(); + if (exists) { + return failure(c, 409, "渠道已存在"); + } + + const now = nowIso(); + const baseUrl = normalizeBaseUrlInput(parsed.base_url); + await c.env.DB.prepare( + "INSERT INTO channels (id, name, base_url, api_key, weight, status, rate_limit, models_json, type, group_name, priority, metadata_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind( + existingId, + parsed.name, + baseUrl ?? normalizeBaseUrl(String(parsed.base_url)), + parsed.api_key, + parsed.weight ?? 1, + parsed.status ?? "active", + parsed.rate_limit ?? 0, + parsed.models_json, + parsed.type ?? 1, + parsed.group_name, + parsed.priority ?? 0, + parsed.metadata_json, + now, + now, + ) + .run(); + + return success(c); }); -newapi.post('/test', async (c) => { - const body = await c.req.json().catch(() => null); - const id = body?.id; - if (!id) { - return failure(c, 400, '缺少渠道ID'); - } - const channel = await c.env.DB.prepare('SELECT * FROM channels WHERE id = ?').bind(String(id)).first(); - if (!channel) { - return failure(c, 404, '渠道不存在'); - } - const result = await fetchModels(String(channel.base_url), String(channel.api_key)); - if (!result.ok) { - await updateChannelTestResult(c, String(id), false, result.elapsed); - return failure(c, 502, '渠道测试失败'); - } - await updateChannelTestResult(c, String(id), true, result.elapsed); - return success(c, undefined, '测试成功'); +newapi.put("/", async (c) => { + const body = await c.req.json().catch(() => null); + if (!body) { + return failure(c, 400, "请求体为空"); + } + const payload = body.channel ?? body; + const id = payload?.id ?? body?.id; + if (!id) { + return failure(c, 400, "缺少渠道ID"); + } + + const current = await c.env.DB.prepare("SELECT * FROM channels WHERE id = ?") + .bind(String(id)) + .first(); + if (!current) { + return failure(c, 404, "渠道不存在"); + } + + const parsed = normalizeChannelInput(payload); + const models = + parsed.models.length > 0 ? parsed.models : extractModelIds(current); + const mergedMetadata = mergeMetadata( + current.metadata_json, + parsed.metadata_json, + ); + const nextBaseUrl = + normalizeBaseUrlInput(parsed.base_url ?? current.base_url) ?? + String(current.base_url); + + await c.env.DB.prepare( + "UPDATE channels SET name = ?, base_url = ?, api_key = ?, weight = ?, status = ?, rate_limit = ?, models_json = ?, type = ?, group_name = ?, priority = ?, metadata_json = ?, updated_at = ? WHERE id = ?", + ) + .bind( + parsed.name ?? current.name, + nextBaseUrl, + parsed.api_key ?? current.api_key, + parsed.weight ?? current.weight ?? 1, + parsed.status ?? current.status, + parsed.rate_limit ?? current.rate_limit ?? 0, + modelsToJson(models), + parsed.type ?? current.type ?? 1, + parsed.group_name ?? current.group_name, + parsed.priority ?? current.priority ?? 0, + mergedMetadata, + nowIso(), + String(id), + ) + .run(); + + return success(c); }); -newapi.get('/fetch_models/:id', 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 failure(c, 404, '渠道不存在'); - } - - const result = await fetchModels(String(channel.base_url), String(channel.api_key)); - if (!result.ok) { - await updateChannelTestResult(c, id, false, result.elapsed); - return failure(c, 502, '获取模型失败'); - } - - await updateChannelTestResult(c, id, true, result.elapsed, result.models); - - return success(c, result.models); +newapi.delete("/:id", async (c) => { + const id = c.req.param("id"); + const existing = await c.env.DB.prepare( + "SELECT id FROM channels WHERE id = ?", + ) + .bind(id) + .first(); + if (!existing) { + return failure(c, 404, "渠道不存在"); + } + await c.env.DB.prepare("DELETE FROM channels WHERE id = ?").bind(id).run(); + return success(c); }); -newapi.post('/fetch_models', async (c) => { - const body = await c.req.json().catch(() => null); - if (!body?.base_url || !body?.key) { - return failure(c, 400, '缺少必要参数'); - } +newapi.get("/test/:id", 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 failure(c, 404, "渠道不存在"); + } - const result = await fetchModels(String(body.base_url), String(body.key)); - if (!result.ok) { - return failure(c, 502, '获取模型失败'); - } + const result = await fetchModels( + String(channel.base_url), + String(channel.api_key), + ); + if (!result.ok) { + await updateChannelTestResult(c, id, false, result.elapsed); + return failure(c, 502, "渠道测试失败"); + } - return success(c, result.models); + await updateChannelTestResult(c, id, true, result.elapsed); + + return success(c, undefined, "测试成功"); }); -newapi.get('/:id', 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 failure(c, 404, '渠道不存在'); - } - const metadata = safeJsonParse>(channel.metadata_json, {}); - const output = toNewApiChannel(channel as any); - return success(c, { - ...output, - model_mapping: metadata.model_mapping ?? '{}', - channel_info: metadata.channel_info ?? { is_multi_key: false, multi_key_mode: 'random' } - }); +newapi.post("/test", async (c) => { + const body = await c.req.json().catch(() => null); + const id = body?.id; + if (!id) { + return failure(c, 400, "缺少渠道ID"); + } + const channel = await c.env.DB.prepare("SELECT * FROM channels WHERE id = ?") + .bind(String(id)) + .first(); + if (!channel) { + return failure(c, 404, "渠道不存在"); + } + const result = await fetchModels( + String(channel.base_url), + String(channel.api_key), + ); + if (!result.ok) { + await updateChannelTestResult(c, String(id), false, result.elapsed); + return failure(c, 502, "渠道测试失败"); + } + await updateChannelTestResult(c, String(id), true, result.elapsed); + return success(c, undefined, "测试成功"); +}); + +newapi.get("/fetch_models/:id", 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 failure(c, 404, "渠道不存在"); + } + + const result = await fetchModels( + String(channel.base_url), + String(channel.api_key), + ); + if (!result.ok) { + await updateChannelTestResult(c, id, false, result.elapsed); + return failure(c, 502, "获取模型失败"); + } + + await updateChannelTestResult(c, id, true, result.elapsed, result.models); + + return success(c, result.models); +}); + +newapi.post("/fetch_models", async (c) => { + const body = await c.req.json().catch(() => null); + if (!body?.base_url || !body?.key) { + return failure(c, 400, "缺少必要参数"); + } + + const result = await fetchModels(String(body.base_url), String(body.key)); + if (!result.ok) { + return failure(c, 502, "获取模型失败"); + } + + return success(c, result.models); +}); + +newapi.get("/:id", 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 failure(c, 404, "渠道不存在"); + } + const metadata = safeJsonParse>( + channel.metadata_json, + {}, + ); + const modelMapping = + metadata.model_mapping === undefined || metadata.model_mapping === null + ? "{}" + : String(metadata.model_mapping); + const channelInfo = + metadata.channel_info && + typeof metadata.channel_info === "object" && + !Array.isArray(metadata.channel_info) + ? (metadata.channel_info as { + is_multi_key?: boolean; + multi_key_mode?: string; + }) + : { + is_multi_key: false, + multi_key_mode: "random", + }; + const output = toNewApiChannel(channel); + return success(c, { + ...output, + model_mapping: modelMapping, + channel_info: channelInfo, + }); }); export default newapi; diff --git a/apps/worker/src/routes/newapiGroups.ts b/apps/worker/src/routes/newapiGroups.ts index 17e083f..555a0fc 100644 --- a/apps/worker/src/routes/newapiGroups.ts +++ b/apps/worker/src/routes/newapiGroups.ts @@ -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({ strict: false }); -groups.use('*', newApiAuth); +groups.use("*", newApiAuth); function collectGroups(rows: Array<{ group_name?: string | null }>): string[] { - const names = new Set(); - 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(); + 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; diff --git a/apps/worker/src/routes/newapiUsers.ts b/apps/worker/src/routes/newapiUsers.ts index 75d60ba..0059850 100644 --- a/apps/worker/src/routes/newapiUsers.ts +++ b/apps/worker/src/routes/newapiUsers.ts @@ -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({ 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(); - 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(); + 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; diff --git a/apps/worker/src/routes/proxy.ts b/apps/worker/src/routes/proxy.ts index 743c2d7..7cb321f 100644 --- a/apps/worker/src/routes/proxy.ts +++ b/apps/worker/src/routes/proxy.ts @@ -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(); -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) => 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(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( + 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 { - 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(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 | 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) + .stream_options; + if (!streamOptions || typeof streamOptions !== "object") { + (parsedBody as Record).stream_options = { + include_usage: true, + }; + } else if ( + (streamOptions as Record).include_usage !== true + ) { + (streamOptions as Record).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; diff --git a/apps/worker/src/routes/settings.ts b/apps/worker/src/routes/settings.ts index 5553e3a..f64addc 100644 --- a/apps/worker/src/routes/settings.ts +++ b/apps/worker/src/routes/settings.ts @@ -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(); /** * 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; diff --git a/apps/worker/src/routes/tokens.ts b/apps/worker/src/routes/tokens.ts index 1ed68a6..26f9189 100644 --- a/apps/worker/src/routes/tokens.ts +++ b/apps/worker/src/routes/tokens.ts @@ -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(); +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(); + 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; diff --git a/apps/worker/src/routes/usage.ts b/apps/worker/src/routes/usage.ts index 4b39325..fa79f88 100644 --- a/apps/worker/src/routes/usage.ts +++ b/apps/worker/src/routes/usage.ts @@ -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(); /** * Lists usage logs with filters. */ -usage.get('/', async (c) => { - const query = c.req.query(); - const params: Array = []; - 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 = []; + 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; diff --git a/apps/worker/src/services/channels.ts b/apps/worker/src/services/channels.ts index 9d6fad4..ad0dd84 100644 --- a/apps/worker/src/services/channels.ts +++ b/apps/worker/src/services/channels.ts @@ -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(channel.models_json, null); - const models = Array.isArray(raw) - ? raw - : Array.isArray(raw?.data) - ? raw.data - : []; + const raw = safeJsonParse( + 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, + })); } diff --git a/apps/worker/src/services/newapi.ts b/apps/worker/src/services/newapi.ts index 8fd1d5b..f609efc 100644 --- a/apps/worker/src/services/newapi.ts +++ b/apps/worker/src/services/newapi.ts @@ -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(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( + 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 | 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 = {}; - 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 = {}; + 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>(existing, {}); - const updates = safeJsonParse>(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>(existing, {}); + const updates = safeJsonParse>(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>(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>( + 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)); } diff --git a/apps/worker/src/services/quota.ts b/apps/worker/src/services/quota.ts index 601a84f..56609fb 100644 --- a/apps/worker/src/services/quota.ts +++ b/apps/worker/src/services/quota.ts @@ -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, + }; } diff --git a/apps/worker/src/services/settings.ts b/apps/worker/src/services/settings.ts index 65d78dc..f9d4679 100644 --- a/apps/worker/src/services/settings.ts +++ b/apps/worker/src/services/settings.ts @@ -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 { + 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 { + 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 { - 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 { + 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 { - 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 { + 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 { + 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 { + 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 { + return readSetting(db, ADMIN_PASSWORD_HASH_KEY); +} + +/** + * Updates the admin password hash. + */ +export async function setAdminPasswordHash( + db: D1Database, + hash: string, +): Promise { + 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 { + const hash = await getAdminPasswordHash(db); + return Boolean(hash); } /** * Loads generic settings as a key/value map. */ -export async function listSettings(db: D1Database): Promise> { - const result = await db.prepare('SELECT key, value FROM settings').all(); - const map: Record = {}; - for (const row of result.results ?? []) { - map[String(row.key)] = String(row.value); - } - return map; +export async function listSettings( + db: D1Database, +): Promise> { + const result = await db.prepare("SELECT key, value FROM settings").all(); + const map: Record = {}; + for (const row of result.results ?? []) { + map[String(row.key)] = String(row.value); + } + return map; } diff --git a/apps/worker/src/services/usage.ts b/apps/worker/src/services/usage.ts index bf8a9fa..425592f 100644 --- a/apps/worker/src/services/usage.ts +++ b/apps/worker/src/services/usage.ts @@ -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 { - 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 { + 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 { - 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 { + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - retentionDays); + await db + .prepare("DELETE FROM usage_logs WHERE created_at < ?") + .bind(cutoff.toISOString()) + .run(); } diff --git a/apps/worker/src/utils/crypto.ts b/apps/worker/src/utils/crypto.ts index 30d10f8..c63632c 100644 --- a/apps/worker/src/utils/crypto.ts +++ b/apps/worker/src/utils/crypto.ts @@ -4,20 +4,20 @@ const encoder = new TextEncoder(); * Creates a SHA-256 hex digest from input text. */ export async function sha256Hex(input: string): Promise { - 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}`; } diff --git a/apps/worker/src/utils/http.ts b/apps/worker/src/utils/http.ts index a09321c..ceae573 100644 --- a/apps/worker/src/utils/http.ts +++ b/apps/worker/src/utils/http.ts @@ -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, + ); } diff --git a/apps/worker/src/utils/json.ts b/apps/worker/src/utils/json.ts index 1818a63..865991d 100644 --- a/apps/worker/src/utils/json.ts +++ b/apps/worker/src/utils/json.ts @@ -1,13 +1,16 @@ /** * Safely parses JSON or returns a fallback value. */ -export function safeJsonParse(value: string | null | undefined, fallback: T): T { - if (!value) { - return fallback; - } - try { - return JSON.parse(value) as T; - } catch { - return fallback; - } +export function safeJsonParse( + value: string | null | undefined, + fallback: T, +): T { + if (!value) { + return fallback; + } + try { + return JSON.parse(value) as T; + } catch { + return fallback; + } } diff --git a/apps/worker/src/utils/request.ts b/apps/worker/src/utils/request.ts index c386502..774b217 100644 --- a/apps/worker/src/utils/request.ts +++ b/apps/worker/src/utils/request.ts @@ -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; } diff --git a/apps/worker/src/utils/time.ts b/apps/worker/src/utils/time.ts index 9174ddc..b35bb25 100644 --- a/apps/worker/src/utils/time.ts +++ b/apps/worker/src/utils/time.ts @@ -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; } diff --git a/apps/worker/src/utils/url.ts b/apps/worker/src/utils/url.ts index f82d268..8e2b43b 100644 --- a/apps/worker/src/utils/url.ts +++ b/apps/worker/src/utils/url.ts @@ -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, ""); } diff --git a/apps/worker/src/utils/usage.ts b/apps/worker/src/utils/usage.ts new file mode 100644 index 0000000..eb7221d --- /dev/null +++ b/apps/worker/src/utils/usage.ts @@ -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): 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; + 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; + const usage = + data.usage ?? + (data.data && typeof data.data === "object" + ? (data.data as Record).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(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 { + 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(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(payload, null); + const candidate = parseUsageFromJson(parsed); + if (candidate) { + usage = candidate; + } + } + } + + return usage; +} diff --git a/apps/worker/tsconfig.json b/apps/worker/tsconfig.json index ec193bf..28fdb12 100644 --- a/apps/worker/tsconfig.json +++ b/apps/worker/tsconfig.json @@ -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"] } diff --git a/apps/worker/wrangler.toml b/apps/worker/wrangler.toml index ad18309..47519d3 100644 --- a/apps/worker/wrangler.toml +++ b/apps/worker/wrangler.toml @@ -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" diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..dfa5028 --- /dev/null +++ b/biome.json @@ -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" + } + } + } +} diff --git a/bun.lock b/bun.lock index 427fb35..dc78c6d 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/helloagents/CHANGELOG.md b/helloagents/CHANGELOG.md index eaf96e4..add62c2 100644 --- a/helloagents/CHANGELOG.md +++ b/helloagents/CHANGELOG.md @@ -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 diff --git a/helloagents/INDEX.md b/helloagents/INDEX.md index 2ac0a4c..69f30c8 100644 --- a/helloagents/INDEX.md +++ b/helloagents/INDEX.md @@ -16,7 +16,7 @@ ## 知识库状态 ```yaml -最后更新: 2026-02-15 02:03 +最后更新: 2026-02-15 17:17 模块数量: 9 待执行方案: 0 ``` diff --git a/helloagents/context.md b/helloagents/context.md index 42915b5..ff42def 100644 --- a/helloagents/context.md +++ b/helloagents/context.md @@ -21,9 +21,10 @@ ### 主要依赖 | 依赖 | 版本 | 用途 | |------|------|------| -| Hono | 待定 | Worker 路由与中间件 | +| Hono | 待定 | Worker 路由与管理台 TSX | | Wrangler | 待定 | Workers 构建与部署 | | Vite | 待定 | 管理台构建 | +| TailwindCSS | 待定 | 管理台样式与设计系统 | ## 3. 项目概述 diff --git a/helloagents/modules/admin-ui.md b/helloagents/modules/admin-ui.md index bd8d846..a367665 100644 --- a/helloagents/modules/admin-ui.md +++ b/helloagents/modules/admin-ui.md @@ -10,7 +10,12 @@ ## 行为规范 - Vite 构建静态文件 +- 使用 `hono/jsx/dom` + TSX 渲染管理台入口 +- 入口文件为 `apps/admin/src/App.tsx`(`main.tsx` 已移除) +- 按 `core/`(类型/常量/API)、`features/`(业务视图 + 布局/登录)组织前端模块 +- 样式基于 Tailwind v4(结合全局主题变量) - 前端状态集中在单页 +- 渠道管理采用列表视图,支持分页(默认 10 条),新增/编辑使用弹窗,保留启禁/连通测试操作 - Token 仅本地保存,不回显 ## 依赖关系 diff --git a/helloagents/modules/channels.md b/helloagents/modules/channels.md index 9dda324..bc46ca8 100644 --- a/helloagents/modules/channels.md +++ b/helloagents/modules/channels.md @@ -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` ## 依赖关系 diff --git a/helloagents/modules/proxy.md b/helloagents/modules/proxy.md index 40eb96c..b625173 100644 --- a/helloagents/modules/proxy.md +++ b/helloagents/modules/proxy.md @@ -11,7 +11,8 @@ ## 行为规范 - 基于令牌鉴权 - 按渠道权重随机排序 -- 非流式响应解析 usage 字段 +- 支持从非流式 JSON、响应头与流式 SSE 解析 usage 字段 +- 流式请求自动补 `stream_options.include_usage=true` 以便上游返回 usage - 可配置失败重试轮询(响应 5xx/429 时触发) ## 依赖关系 diff --git a/package.json b/package.json index 081c17d..0a84e4d 100644 --- a/package.json +++ b/package.json @@ -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" + } } diff --git a/tsconfig.json b/tsconfig.json index e680d00..be1aa03 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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 + } } diff --git a/vitest.config.ts b/vitest.config.ts index 49d9552..7c2677b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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", + }, });