From 54baba28a3405e1a7912fb32b0f5a181c005e00e Mon Sep 17 00:00:00 2001 From: lsy Date: Wed, 18 Mar 2026 01:20:30 +0800 Subject: [PATCH] =?UTF-8?q?add:=E5=A2=9E=E5=8A=A0=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .helloagents/CHANGELOG.md | 4 + .helloagents/modules/admin-ui.md | 5 +- .helloagents/modules/dashboard.md | 1 + .helloagents/modules/models.md | 1 + .helloagents/modules/proxy.md | 8 +- .helloagents/modules/settings.md | 33 +- .helloagents/modules/usage.md | 2 + apps/ui/src/App.tsx | 165 ++++- apps/ui/src/components/ui/area-chart.tsx | 5 +- apps/ui/src/components/ui/column-picker.tsx | 5 +- apps/ui/src/components/ui/multi-select.tsx | 5 +- apps/ui/src/components/ui/pagination.tsx | 5 +- apps/ui/src/components/ui/utils.ts | 9 +- apps/ui/src/core/constants.ts | 15 + apps/ui/src/core/types.ts | 43 +- apps/ui/src/features/SettingsView.tsx | 398 +++++++++- .../migrations/0009_add_cache_indexes.sql | 18 + apps/worker/src/db/schema.sql | 18 +- apps/worker/src/env.ts | 1 + apps/worker/src/index.ts | 1 + apps/worker/src/middleware/tokenAuth.ts | 26 +- apps/worker/src/routes/channels.ts | 5 + apps/worker/src/routes/dashboard.ts | 132 ++-- apps/worker/src/routes/models.ts | 58 +- apps/worker/src/routes/newapiChannels.ts | 10 + apps/worker/src/routes/proxy.ts | 188 +++-- apps/worker/src/routes/settings.ts | 292 +++++++- apps/worker/src/routes/sites.ts | 4 + apps/worker/src/routes/tokens.ts | 6 +- apps/worker/src/routes/usage.ts | 294 ++++---- apps/worker/src/services/settings.ts | 688 +++++++++++++++++- apps/worker/src/services/usage-limiter.ts | 109 +++ apps/worker/src/services/usage-queue.ts | 19 +- apps/worker/src/services/usage.ts | 13 + apps/worker/src/utils/cache.ts | 137 ++++ apps/worker/wrangler.toml | 8 + 36 files changed, 2374 insertions(+), 357 deletions(-) create mode 100644 apps/worker/migrations/0009_add_cache_indexes.sql create mode 100644 apps/worker/src/services/usage-limiter.ts create mode 100644 apps/worker/src/utils/cache.ts diff --git a/.helloagents/CHANGELOG.md b/.helloagents/CHANGELOG.md index 5d7c9ea..9f37272 100644 --- a/.helloagents/CHANGELOG.md +++ b/.helloagents/CHANGELOG.md @@ -9,6 +9,10 @@ ### 变更 +- **[settings/proxy/usage/admin-ui]**: 运行时设置可在后台配置,新增队列日限额与直写比例并接入 UsageLimiter — by lsy + - 方案: [202603180031_usage-queue-simplified](archive/2026-03/202603180031_usage-queue-simplified/) +- **[cache/settings/proxy/usage/admin-ui]**: 引入分组缓存与自动失效,面板/日志/模型短期缓存可配置,清理节流并补齐索引 — by lsy + - 方案: [202603172242_cache-strategy-optimizations](plan/202603172242_cache-strategy-optimizations/) - **[deploy/proxy/settings]**: 调整 stream usage 默认值并避免部署覆盖云端变量 — by lsy - 方案: [202603170025_runtime-defaults-no-override](archive/2026-03/202603170025_runtime-defaults-no-override/) - **[admin-ui/usage]**: 使用日志渠道筛选改为搜索多选,分页条数与列偏好本地记忆 — by lsy diff --git a/.helloagents/modules/admin-ui.md b/.helloagents/modules/admin-ui.md index 1364ff8..74d7c56 100644 --- a/.helloagents/modules/admin-ui.md +++ b/.helloagents/modules/admin-ui.md @@ -38,7 +38,10 @@ - 站点管理内置一键签到入口,展示今日签到结果(基于后台写回的 `last_checkin_*`) - 系统设置增加定时签到开关与时间(中国时间) - 系统设置提供模型失败冷却分钟数配置 -- 系统设置展示运行时配置(只读),以卡片显示名称 + 环境变量名 + 当前值,并提示在部署环境中修改环境变量 +- 系统设置提供运行时配置可编辑(上游超时、流式解析模式/上限、队列开关/日限额/直写比例) +- 系统设置展示运行时配置(只读),以卡片显示名称 + 环境变量名 + 当前值,并提示设置优先生效 +- 系统设置提供缓存开关与 TTL(面板/日志/模型/鉴权/渠道/调用令牌/设置)配置 +- 系统设置展示缓存版本号并提供刷新入口 - 列表分页页脚统一使用页码导航 + 每页条数选择,并显示总页数(无数据时显示 0 页) - 每页条数选择与列显示偏好本地持久化 - 页面布局拆分为“标题区 + 筛选区 + 数据区”,避免单一大卡片堆叠 diff --git a/.helloagents/modules/dashboard.md b/.helloagents/modules/dashboard.md index 9c9333a..3502b78 100644 --- a/.helloagents/modules/dashboard.md +++ b/.helloagents/modules/dashboard.md @@ -14,6 +14,7 @@ - 可选筛选:`channel_ids` / `token_ids` / `model` - 管理台默认展示全量时间范围,并在趋势卡片内提供统计颗粒(按日/周/月) - 统计颗粒与时间范围在前端本地存储并优先复用 +- `GET /api/dashboard` 支持短时缓存(TTL 可配置,版本化失效) ## 依赖关系 - `usage_logs` 表 diff --git a/.helloagents/modules/models.md b/.helloagents/modules/models.md index a6eca4c..6137577 100644 --- a/.helloagents/modules/models.md +++ b/.helloagents/modules/models.md @@ -13,6 +13,7 @@ - 使用“模型广场测试结果”作为可用模型来源 - 模型归一化支持字符串数组与对象数组(对象取 `id` 字段) - 管理台模型广场支持搜索、渠道筛选与分页展示 +- `GET /api/models` 支持短时缓存(TTL 可配置,版本化失效) ## 依赖关系 - `channels` 表 diff --git a/.helloagents/modules/proxy.md b/.helloagents/modules/proxy.md index a9f1ba1..e7b5a20 100644 --- a/.helloagents/modules/proxy.md +++ b/.helloagents/modules/proxy.md @@ -17,6 +17,8 @@ - 调用令牌按模型列表匹配选择,未配置或无列表时回退 `channels.api_key` - 调用令牌模型列表来自渠道连通测试写回 - done-hub 仅使用 `base_url`,不再按多地址切换 +- 令牌鉴权、启用渠道与调用令牌支持短时缓存;禁用/变更会触发分组缓存版本失效 +- 失败冷却/阈值判断不做跨请求缓存,确保即时生效 - 支持从非流式 JSON、响应头与流式 SSE(含 `response.usage` 与 `usageMetadata`)解析 usage 字段 - 流式请求自动补 `stream_options.include_usage=true` 以便上游返回 usage - 流式 usage 解析支持 `full/lite/off` 模式(`lite` 仅解析包含 usage 的事件,并可限制最大扫描字节) @@ -24,6 +26,8 @@ - 可配置失败重试轮询(响应 5xx/429 时触发) - 记录流式请求标记、首 token 延迟与推理强度到 usage_logs - 使用日志与模型能力写入可通过 `USAGE_QUEUE` 异步化(队列不可用时自动回退同步写入) +- 队列写入采用“两态简化逻辑”:未超限时按比例直写/队列,超限后全部直写 +- 队列日限额由 `UsageLimiter` DO 维护,限制 `USAGE_QUEUE` 的日内发送量 - 当 usage 解析被跳过或超限时,会记录 `usage_skipped/usage_missing` 用于可观测性 - 上游类型由 `metadata_json.site_type` 决定(`openai/anthropic/gemini`,其他类型视为 openai 兼容) - 允许 `metadata_json.model_mapping` 将下游模型映射为上游模型 @@ -37,7 +41,9 @@ - 仅 429/5xx/408/timeout/exception 计入冷却,4xx 请求错误不触发冷却 - 映射模型场景失败时同时记录下游模型错误,确保冷却窗口生效 - 上游成功响应会刷新能力表,清空错误并延长可用窗口 -- CPU 保护开关(环境变量): `PROXY_STREAM_USAGE_MODE`, `PROXY_STREAM_USAGE_MAX_BYTES`, `PROXY_STREAM_USAGE_MAX_PARSERS`, `PROXY_USAGE_QUEUE_ENABLED` +- 运行时配置优先读取 settings,环境变量仅回退 +- CPU 保护相关配置: `proxy_stream_usage_mode`, `proxy_stream_usage_max_bytes`, `proxy_stream_usage_max_parsers`, `proxy_usage_queue_enabled` +- 队列策略配置: `usage_queue_daily_limit`, `usage_queue_direct_write_ratio` - 默认值: `PROXY_STREAM_USAGE_MODE=full`, `PROXY_STREAM_USAGE_MAX_BYTES=0`, `PROXY_STREAM_USAGE_MAX_PARSERS=0`, `PROXY_USAGE_QUEUE_ENABLED=true` - `PROXY_STREAM_USAGE_MAX_BYTES` 与 `PROXY_STREAM_USAGE_MAX_PARSERS` 设为 `0` 表示无限制 diff --git a/.helloagents/modules/settings.md b/.helloagents/modules/settings.md index 3f40a39..991d939 100644 --- a/.helloagents/modules/settings.md +++ b/.helloagents/modules/settings.md @@ -12,15 +12,38 @@ ## 行为规范 - 配置写入后即时生效 -- 未配置时使用环境变量默认值 +- 未配置时使用环境变量/默认值回退 - 定时签到时间使用 `HH:mm` 格式(默认 `00:10`,中国时间) - 更新签到时间后会触发 `CheckinScheduler` 重新调度 - 若更新后当前时间已超过新签到时间,当日可再次触发自动签到 - 模型失败冷却以分钟计(默认 10 分钟),用于跳过最近失败的渠道/模型 -- 代理 CPU 降载相关开关使用环境变量(非 DB 设置) -- `GET /api/settings` 额外返回 `runtime_config`(环境变量读取结果),用于展示流式 usage 解析与队列状态 -- 运行时配置默认值为 `PROXY_STREAM_USAGE_MODE=full`、`PROXY_STREAM_USAGE_MAX_BYTES=0`、`PROXY_STREAM_USAGE_MAX_PARSERS=0` -- 运行时配置中 `PROXY_STREAM_USAGE_MAX_BYTES/PROXY_STREAM_USAGE_MAX_PARSERS` 设为 `0` 表示无限制 +- 运行时配置可写入 settings(环境变量作为回退) +- `GET /api/settings` 返回: + - `runtime_settings`(可写配置值) + - `runtime_config`(生效值 + 队列绑定/生效状态) +- 运行时配置字段: + - `proxy_upstream_timeout_ms` + - `proxy_stream_usage_mode` + - `proxy_stream_usage_max_bytes` + - `proxy_stream_usage_max_parsers` + - `proxy_usage_queue_enabled` + - `usage_queue_daily_limit` + - `usage_queue_direct_write_ratio` +- 运行时配置默认值: + - `PROXY_STREAM_USAGE_MODE=full` + - `PROXY_STREAM_USAGE_MAX_BYTES=0` + - `PROXY_STREAM_USAGE_MAX_PARSERS=0` + - `PROXY_UPSTREAM_TIMEOUT_MS=30000` + - `usage_queue_daily_limit=10000` + - `usage_queue_direct_write_ratio=0.5` +- `PROXY_STREAM_USAGE_MAX_BYTES/PROXY_STREAM_USAGE_MAX_PARSERS` 设为 `0` 表示无限制 +- 缓存配置字段: + - `cache_enabled` + - `cache_ttl_dashboard_seconds` / `cache_ttl_usage_seconds` / `cache_ttl_models_seconds` + - `cache_ttl_tokens_seconds` / `cache_ttl_channels_seconds` / `cache_ttl_call_tokens_seconds` + - `cache_ttl_settings_seconds` +- `cache_config` 中包含分组版本号(dashboard/usage/models/tokens/channels/call_tokens/settings),用于精准失效 +- `POST /api/settings/cache/refresh` 会 bump 所有缓存版本(用于紧急刷新) ## 依赖关系 - `settings` 表 diff --git a/.helloagents/modules/usage.md b/.helloagents/modules/usage.md index 0823ac9..f619245 100644 --- a/.helloagents/modules/usage.md +++ b/.helloagents/modules/usage.md @@ -11,8 +11,10 @@ ## 行为规范 - 代理调用统一写入 `usage_logs`(含无可用渠道/冷却提前返回) - 使用日志可通过 `USAGE_QUEUE` 异步写入(可能存在短暂延迟) +- 队列写入受 `usage_queue_daily_limit` 限制,超限后自动回退为 Worker 直写 - 流式 usage 解析被跳过或超限时会记录 `usage_skipped/usage_missing` 以便排查 - 保留天数可配置,查询时触发清理 +- `GET /api/usage` 支持短时缓存(TTL 可配置,版本化失效) - 查询结果附带渠道与令牌名称 - `GET /api/usage` 支持 `offset/limit` 分页并返回 `total` - 支持按 `model/channel/token/status` 关键词搜索(保留 `channel_id/token_id` 精确过滤,新增 `channel_ids`/`token_ids`/`models`/`statuses` 多选过滤) diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx index 8b13dee..22ab7f8 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -305,8 +305,12 @@ const App = () => { ); const [checkinLastRun, setCheckinLastRun] = useState(null); const [, setPendingActions] = useState>(() => new Set()); - const pendingActionsRef = useRef>(new Set()); - const noticeTimersRef = useRef>(new Map()); + const pendingActionsRef = useRef>(new Set()) as { + current: Set; + }; + const noticeTimersRef = useRef>(new Map()) as { + current: Map; + }; const [confirmState, setConfirmState] = useState(null); const [confirmPending, setConfirmPending] = useState(false); @@ -649,6 +653,9 @@ const App = () => { if (!data.settings) { return; } + const cacheConfig = data.settings.cache_config; + const runtimeSettings = + data.settings.runtime_settings ?? data.settings.runtime_config; setSettingsForm({ log_retention_days: String(data.settings.log_retention_days ?? 30), session_ttl_hours: String(data.settings.session_ttl_hours ?? 12), @@ -657,6 +664,41 @@ const App = () => { model_failure_cooldown_minutes: String( data.settings.model_failure_cooldown_minutes ?? 10, ), + proxy_upstream_timeout_ms: String( + runtimeSettings?.upstream_timeout_ms ?? 30000, + ), + proxy_stream_usage_mode: + runtimeSettings?.stream_usage_mode ?? "full", + proxy_stream_usage_max_bytes: String( + runtimeSettings?.stream_usage_max_bytes ?? 0, + ), + proxy_stream_usage_max_parsers: String( + runtimeSettings?.stream_usage_max_parsers ?? 0, + ), + proxy_usage_queue_enabled: + runtimeSettings?.usage_queue_enabled ?? true, + usage_queue_daily_limit: String( + runtimeSettings?.usage_queue_daily_limit ?? 10000, + ), + usage_queue_direct_write_ratio: String( + runtimeSettings?.usage_queue_direct_write_ratio ?? 0.5, + ), + cache_enabled: cacheConfig?.enabled ?? true, + cache_ttl_dashboard_seconds: String( + cacheConfig?.dashboard_ttl_seconds ?? 30, + ), + cache_ttl_usage_seconds: String(cacheConfig?.usage_ttl_seconds ?? 15), + cache_ttl_models_seconds: String(cacheConfig?.models_ttl_seconds ?? 60), + cache_ttl_tokens_seconds: String(cacheConfig?.tokens_ttl_seconds ?? 15), + cache_ttl_channels_seconds: String( + cacheConfig?.channels_ttl_seconds ?? 15, + ), + cache_ttl_call_tokens_seconds: String( + cacheConfig?.call_tokens_ttl_seconds ?? 15, + ), + cache_ttl_settings_seconds: String( + cacheConfig?.settings_ttl_seconds ?? 30, + ), }); }, [data.settings]); @@ -1260,6 +1302,34 @@ const App = () => { const failureCooldownMinutes = Number( settingsForm.model_failure_cooldown_minutes, ); + const upstreamTimeoutMs = Number(settingsForm.proxy_upstream_timeout_ms); + const streamUsageMode = + settingsForm.proxy_stream_usage_mode.trim().toLowerCase(); + const streamUsageMaxBytes = Number( + settingsForm.proxy_stream_usage_max_bytes, + ); + const streamUsageMaxParsers = Number( + settingsForm.proxy_stream_usage_max_parsers, + ); + const usageQueueDailyLimit = Number( + settingsForm.usage_queue_daily_limit, + ); + const usageQueueDirectRatio = Number( + settingsForm.usage_queue_direct_write_ratio, + ); + const cacheDashboardTtl = Number( + settingsForm.cache_ttl_dashboard_seconds, + ); + const cacheUsageTtl = Number(settingsForm.cache_ttl_usage_seconds); + const cacheModelsTtl = Number(settingsForm.cache_ttl_models_seconds); + const cacheTokensTtl = Number(settingsForm.cache_ttl_tokens_seconds); + const cacheChannelsTtl = Number(settingsForm.cache_ttl_channels_seconds); + const cacheCallTokensTtl = Number( + settingsForm.cache_ttl_call_tokens_seconds, + ); + const cacheSettingsTtl = Number( + settingsForm.cache_ttl_settings_seconds, + ); if ( Number.isNaN(retention) || retention < 1 || @@ -1273,6 +1343,53 @@ const App = () => { pushNotice("warning", "失败冷却需为正整数"); return; } + if (Number.isNaN(upstreamTimeoutMs) || upstreamTimeoutMs < 0) { + pushNotice("warning", "上游超时需为非负整数"); + return; + } + if (!["full", "lite", "off"].includes(streamUsageMode)) { + pushNotice("warning", "流式解析模式需为 full/lite/off"); + return; + } + if (Number.isNaN(streamUsageMaxBytes) || streamUsageMaxBytes < 0) { + pushNotice("warning", "最大字节数需为非负整数"); + return; + } + if (Number.isNaN(streamUsageMaxParsers) || streamUsageMaxParsers < 0) { + pushNotice("warning", "并发上限需为非负整数"); + return; + } + if (Number.isNaN(usageQueueDailyLimit) || usageQueueDailyLimit < 0) { + pushNotice("warning", "队列日限额需为非负整数"); + return; + } + if ( + Number.isNaN(usageQueueDirectRatio) || + usageQueueDirectRatio < 0 || + usageQueueDirectRatio > 1 + ) { + pushNotice("warning", "直写比例需在 0-1 之间"); + return; + } + if ( + Number.isNaN(cacheDashboardTtl) || + cacheDashboardTtl < 0 || + Number.isNaN(cacheUsageTtl) || + cacheUsageTtl < 0 || + Number.isNaN(cacheModelsTtl) || + cacheModelsTtl < 0 || + Number.isNaN(cacheTokensTtl) || + cacheTokensTtl < 0 || + Number.isNaN(cacheChannelsTtl) || + cacheChannelsTtl < 0 || + Number.isNaN(cacheCallTokensTtl) || + cacheCallTokensTtl < 0 || + Number.isNaN(cacheSettingsTtl) || + cacheSettingsTtl < 0 + ) { + pushNotice("warning", "缓存 TTL 需为非负整数"); + return; + } startAction(actionKey); const payload: Record = { log_retention_days: retention, @@ -1280,6 +1397,21 @@ const App = () => { checkin_schedule_time: settingsForm.checkin_schedule_time.trim() || "00:10", model_failure_cooldown_minutes: failureCooldownMinutes, + proxy_upstream_timeout_ms: upstreamTimeoutMs, + proxy_stream_usage_mode: streamUsageMode, + proxy_stream_usage_max_bytes: streamUsageMaxBytes, + proxy_stream_usage_max_parsers: streamUsageMaxParsers, + proxy_usage_queue_enabled: settingsForm.proxy_usage_queue_enabled, + usage_queue_daily_limit: usageQueueDailyLimit, + usage_queue_direct_write_ratio: usageQueueDirectRatio, + cache_enabled: settingsForm.cache_enabled, + cache_ttl_dashboard_seconds: cacheDashboardTtl, + cache_ttl_usage_seconds: cacheUsageTtl, + cache_ttl_models_seconds: cacheModelsTtl, + cache_ttl_tokens_seconds: cacheTokensTtl, + cache_ttl_channels_seconds: cacheChannelsTtl, + cache_ttl_call_tokens_seconds: cacheCallTokensTtl, + cache_ttl_settings_seconds: cacheSettingsTtl, }; const password = settingsForm.admin_password.trim(); if (password) { @@ -1310,6 +1442,30 @@ const App = () => { ], ); + const handleCacheRefresh = useCallback(async () => { + const actionKey = buildActionKey("settings:cache:refresh"); + if (isActionPending(actionKey)) { + return; + } + startAction(actionKey); + try { + await apiFetch("/api/settings/cache/refresh", { method: "POST" }); + await loadSettings(); + pushNotice("success", "缓存已刷新"); + } catch (error) { + pushNotice("error", (error as Error).message); + } finally { + endAction(actionKey); + } + }, [ + apiFetch, + endAction, + isActionPending, + loadSettings, + pushNotice, + startAction, + ]); + const handleSiteDelete = useCallback( async (id: string) => { const actionKey = buildActionKey("site:delete", id); @@ -1741,10 +1897,15 @@ const App = () => { ); } diff --git a/apps/ui/src/components/ui/area-chart.tsx b/apps/ui/src/components/ui/area-chart.tsx index 7973a67..8200590 100644 --- a/apps/ui/src/components/ui/area-chart.tsx +++ b/apps/ui/src/components/ui/area-chart.tsx @@ -1,6 +1,5 @@ -import type { JSX } from "hono/jsx"; import { useMemo, useState } from "hono/jsx/dom"; -import { cx } from "./utils"; +import { cx, type ClassName } from "./utils"; export type AreaChartPoint = { label: string; @@ -13,7 +12,7 @@ export type AreaChartProps = { height?: number; valueLabel?: string; secondaryLabel?: string; - class?: JSX.ClassValue; + class?: ClassName; }; export const AreaChart = ({ diff --git a/apps/ui/src/components/ui/column-picker.tsx b/apps/ui/src/components/ui/column-picker.tsx index dcc5263..0360c59 100644 --- a/apps/ui/src/components/ui/column-picker.tsx +++ b/apps/ui/src/components/ui/column-picker.tsx @@ -1,8 +1,7 @@ -import type { JSX } from "hono/jsx"; import { useEffect, useMemo, useState } from "hono/jsx/dom"; import { Button } from "./button"; import { Popover, PopoverContent } from "./popover"; -import { cx } from "./utils"; +import { cx, type ClassName } from "./utils"; export type ColumnOption = { id: string; @@ -16,7 +15,7 @@ export type ColumnPickerProps = { columns: ColumnOption[]; value: string[]; onChange: (next: string[]) => void; - class?: JSX.ClassValue; + class?: ClassName; }; export const ColumnPicker = ({ diff --git a/apps/ui/src/components/ui/multi-select.tsx b/apps/ui/src/components/ui/multi-select.tsx index d22b67f..1c64b06 100644 --- a/apps/ui/src/components/ui/multi-select.tsx +++ b/apps/ui/src/components/ui/multi-select.tsx @@ -1,8 +1,7 @@ -import type { JSX } from "hono/jsx"; import { useEffect, useMemo, useState } from "hono/jsx/dom"; import { Input } from "./input"; import { PopoverContent } from "./popover"; -import { cx } from "./utils"; +import { cx, type ClassName } from "./utils"; export type MultiSelectOption = { value: string; @@ -16,7 +15,7 @@ export type MultiSelectProps = { placeholder?: string; searchPlaceholder?: string; emptyLabel?: string; - class?: JSX.ClassValue; + class?: ClassName; }; export const MultiSelect = ({ diff --git a/apps/ui/src/components/ui/pagination.tsx b/apps/ui/src/components/ui/pagination.tsx index 2d1eb46..6c5100c 100644 --- a/apps/ui/src/components/ui/pagination.tsx +++ b/apps/ui/src/components/ui/pagination.tsx @@ -1,6 +1,5 @@ -import type { JSX } from "hono/jsx"; import { Button } from "./button"; -import { cx } from "./utils"; +import { cx, type ClassName } from "./utils"; export type PaginationItem = number | "ellipsis"; @@ -10,7 +9,7 @@ export type PaginationProps = { items: PaginationItem[]; onPageChange: (next: number) => void; disabled?: boolean; - class?: JSX.ClassValue; + class?: ClassName; }; export const Pagination = ({ diff --git a/apps/ui/src/components/ui/utils.ts b/apps/ui/src/components/ui/utils.ts index 4701cb4..fce6a3b 100644 --- a/apps/ui/src/components/ui/utils.ts +++ b/apps/ui/src/components/ui/utils.ts @@ -1,2 +1,7 @@ -export const cx = (...classes: Array) => - classes.filter(Boolean).join(" "); +export type ClassName = string | false | null | undefined; +type ClassInput = ClassName | Promise; + +export const cx = (...classes: ClassInput[]) => + classes + .filter((value): value is string => typeof value === "string" && value.length > 0) + .join(" "); diff --git a/apps/ui/src/core/constants.ts b/apps/ui/src/core/constants.ts index 21806e6..ddbc905 100644 --- a/apps/ui/src/core/constants.ts +++ b/apps/ui/src/core/constants.ts @@ -51,6 +51,21 @@ export const initialSettingsForm: SettingsForm = { admin_password: "", checkin_schedule_time: "00:10", model_failure_cooldown_minutes: "10", + proxy_upstream_timeout_ms: "30000", + proxy_stream_usage_mode: "full", + proxy_stream_usage_max_bytes: "0", + proxy_stream_usage_max_parsers: "0", + proxy_usage_queue_enabled: true, + usage_queue_daily_limit: "10000", + usage_queue_direct_write_ratio: "0.5", + cache_enabled: true, + cache_ttl_dashboard_seconds: "30", + cache_ttl_usage_seconds: "15", + cache_ttl_models_seconds: "60", + cache_ttl_tokens_seconds: "15", + cache_ttl_channels_seconds: "15", + cache_ttl_call_tokens_seconds: "15", + cache_ttl_settings_seconds: "30", }; export const initialDashboardQuery: DashboardQuery = { diff --git a/apps/ui/src/core/types.ts b/apps/ui/src/core/types.ts index 1ccda97..5e49404 100644 --- a/apps/ui/src/core/types.ts +++ b/apps/ui/src/core/types.ts @@ -122,18 +122,44 @@ export type Settings = { admin_password_set?: boolean; checkin_schedule_time?: string; model_failure_cooldown_minutes?: number; + runtime_settings?: RuntimeProxySettings; runtime_config?: RuntimeProxyConfig; + cache_config?: CacheConfig; }; -export type RuntimeProxyConfig = { +export type RuntimeProxySettings = { + upstream_timeout_ms: number; stream_usage_mode: string; stream_usage_max_bytes: number; stream_usage_max_parsers: number; usage_queue_enabled: boolean; + usage_queue_daily_limit: number; + usage_queue_direct_write_ratio: number; +}; + +export type RuntimeProxyConfig = RuntimeProxySettings & { usage_queue_bound: boolean; usage_queue_active: boolean; }; +export type CacheConfig = { + enabled: boolean; + dashboard_ttl_seconds: number; + usage_ttl_seconds: number; + models_ttl_seconds: number; + tokens_ttl_seconds: number; + channels_ttl_seconds: number; + call_tokens_ttl_seconds: number; + settings_ttl_seconds: number; + version_dashboard: number; + version_usage: number; + version_models: number; + version_tokens: number; + version_channels: number; + version_call_tokens: number; + version_settings: number; +}; + export type ModelChannel = { id: string; name: string; @@ -191,6 +217,21 @@ export type SettingsForm = { admin_password: string; checkin_schedule_time: string; model_failure_cooldown_minutes: string; + proxy_upstream_timeout_ms: string; + proxy_stream_usage_mode: string; + proxy_stream_usage_max_bytes: string; + proxy_stream_usage_max_parsers: string; + proxy_usage_queue_enabled: boolean; + usage_queue_daily_limit: string; + usage_queue_direct_write_ratio: string; + cache_enabled: boolean; + cache_ttl_dashboard_seconds: string; + cache_ttl_usage_seconds: string; + cache_ttl_models_seconds: string; + cache_ttl_tokens_seconds: string; + cache_ttl_channels_seconds: string; + cache_ttl_call_tokens_seconds: string; + cache_ttl_settings_seconds: string; }; export type TokenForm = { diff --git a/apps/ui/src/features/SettingsView.tsx b/apps/ui/src/features/SettingsView.tsx index 91fde3f..1d3619a 100644 --- a/apps/ui/src/features/SettingsView.tsx +++ b/apps/ui/src/features/SettingsView.tsx @@ -1,13 +1,16 @@ -import { Button, Card, Input } from "../components/ui"; -import type { RuntimeProxyConfig, SettingsForm } from "../core/types"; +import { Button, Card, Input, Select, Switch } from "../components/ui"; +import type { CacheConfig, RuntimeProxyConfig, SettingsForm } from "../core/types"; type SettingsViewProps = { settingsForm: SettingsForm; adminPasswordSet: boolean; isSaving: boolean; + cacheConfig?: CacheConfig | null; + isRefreshingCache?: boolean; runtimeConfig?: RuntimeProxyConfig | null; onSubmit: (event: Event) => void; onFormChange: (patch: Partial) => void; + onRefreshCache: () => void; }; /** @@ -23,24 +26,32 @@ export const SettingsView = ({ settingsForm, adminPasswordSet, isSaving, + cacheConfig, + isRefreshingCache, runtimeConfig, onSubmit, onFormChange, + onRefreshCache, }: SettingsViewProps) => { const runtimeItems = [ + { + label: "上游超时(毫秒)", + value: runtimeConfig?.upstream_timeout_ms ?? 30000, + env: "PROXY_UPSTREAM_TIMEOUT_MS", + }, { label: "流式 usage 解析模式", - value: runtimeConfig?.stream_usage_mode ?? "lite", + value: runtimeConfig?.stream_usage_mode ?? "full", env: "PROXY_STREAM_USAGE_MODE", }, { label: "流式解析最大字节", - value: runtimeConfig?.stream_usage_max_bytes ?? 262144, + value: runtimeConfig?.stream_usage_max_bytes ?? 0, env: "PROXY_STREAM_USAGE_MAX_BYTES", }, { label: "流式解析并发上限", - value: runtimeConfig?.stream_usage_max_parsers ?? 2, + value: runtimeConfig?.stream_usage_max_parsers ?? 0, env: "PROXY_STREAM_USAGE_MAX_PARSERS", }, { @@ -48,6 +59,19 @@ export const SettingsView = ({ value: runtimeConfig?.usage_queue_enabled ? "启用" : "关闭", env: "PROXY_USAGE_QUEUE_ENABLED", }, + { + label: "队列日限额", + value: runtimeConfig?.usage_queue_daily_limit ?? 10000, + env: "usage_queue_daily_limit", + }, + { + label: "直写比例", + value: + runtimeConfig?.usage_queue_direct_write_ratio !== undefined + ? `${Math.round(runtimeConfig.usage_queue_direct_write_ratio * 100)}%` + : "50%", + env: "usage_queue_direct_write_ratio", + }, { label: "队列绑定", value: runtimeConfig?.usage_queue_bound ? "已绑定" : "未绑定", @@ -181,6 +205,366 @@ export const SettingsView = ({ 密码状态:{adminPasswordSet ? "已设置" : "未设置"}

+
+
运行时配置
+

+ 以下配置可在后台修改,环境变量仅作为回退值。 +

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

+ 设置为 0 表示不限制超时。 +

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

+ 0 表示不限制。 +

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

+ 0 表示不限制。 +

+
+
+
+
+
+ 启用用量队列 +
+

+ 启用后将按比例把 usage 写入队列。 +

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

+ 达到上限后自动切回 Worker 直写。 +

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

+ 示例:0.5 表示 50% 直写。 +

+
+
+
+
+
+ 启用面板缓存 +
+

+ 对数据面板、日志列表与模型列表启用短时缓存,降低 D1 读取量。 +

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

+ 0 表示关闭该接口缓存。 +

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

+ 日志查询建议设置较短 TTL。 +

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

+ 禁用令牌会自动失效,不影响即时生效。 +

+
+
+ + { + const target = event.currentTarget as HTMLInputElement | null; + onFormChange({ + cache_ttl_channels_seconds: target?.value ?? "", + }); + }} + /> +
+
+ + { + const target = event.currentTarget as HTMLInputElement | null; + onFormChange({ + cache_ttl_call_tokens_seconds: target?.value ?? "", + }); + }} + /> +
+
+ + { + const target = event.currentTarget as HTMLInputElement | null; + onFormChange({ + cache_ttl_settings_seconds: target?.value ?? "", + }); + }} + /> +
+
+
+ 缓存版本: + {cacheConfig + ? `tokens ${cacheConfig.version_tokens}, channels ${cacheConfig.version_channels}, call_tokens ${cacheConfig.version_call_tokens}, models ${cacheConfig.version_models}, dashboard ${cacheConfig.version_dashboard}, usage ${cacheConfig.version_usage}, settings ${cacheConfig.version_settings}` + : "-"} +
+ +