diff --git a/README.md b/README.md index c8fe067..d41edfd 100644 --- a/README.md +++ b/README.md @@ -172,3 +172,4 @@ bunx wrangler d1 migrations apply DB --remote - `CLOUDFLARE_ACCOUNT_ID`:Cloudflare 账户 ID(若 token 无法自动识别账户) 确保 `apps/worker/wrangler.toml` 中已填写 `database_id`。 +可选:仓库级 Actions 变量 `SPA_DEPLOY` 可用于控制自动部署开关(未设置时默认启用)。 diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 06be6ff..c36ebb3 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -58,11 +58,14 @@ const App = () => { useState(initialSettingsForm); const [channelPage, setChannelPage] = useState(1); const [channelPageSize, setChannelPageSize] = useState(10); + const [tokenPage, setTokenPage] = useState(1); + const [tokenPageSize, setTokenPageSize] = useState(10); const [editingChannel, setEditingChannel] = useState(null); const [channelForm, setChannelForm] = useState(() => ({ ...initialChannelForm, })); const [isChannelModalOpen, setChannelModalOpen] = useState(false); + const [isTokenModalOpen, setTokenModalOpen] = useState(false); const updateToken = useCallback((next: string | null) => { setToken(next); @@ -104,7 +107,7 @@ const App = () => { }, [apiFetch]); const loadUsage = useCallback(async () => { - const result = await apiFetch<{ logs: UsageLog[] }>("/api/usage?limit=50"); + const result = await apiFetch<{ logs: UsageLog[] }>("/api/usage?limit=200"); setData((prev) => ({ ...prev, usage: result.logs })); }, [apiFetch]); @@ -214,6 +217,15 @@ const App = () => { setChannelPage(1); }, []); + const handleTokenPageChange = useCallback((next: number) => { + setTokenPage(next); + }, []); + + const handleTokenPageSizeChange = useCallback((next: number) => { + setTokenPageSize(next); + setTokenPage(1); + }, []); + const handleTabChange = useCallback((tabId: TabId) => { setActiveTab(tabId); }, []); @@ -231,6 +243,11 @@ const App = () => { setNotice(""); }, []); + const openTokenCreate = useCallback(() => { + setTokenModalOpen(true); + setNotice(""); + }, []); + const startChannelEdit = useCallback((channel: Channel) => { setEditingChannel(channel); setChannelForm({ @@ -243,6 +260,10 @@ const App = () => { setNotice(""); }, []); + const closeTokenModal = useCallback(() => { + setTokenModalOpen(false); + }, []); + const handleChannelSubmit = useCallback( async (event: Event) => { event.preventDefault(); @@ -314,6 +335,8 @@ const App = () => { }); setNotice(`新令牌: ${result.token}`); form.reset(); + setTokenModalOpen(false); + setTokenPage(1); await loadTokens(); } catch (error) { setNotice((error as Error).message); @@ -463,11 +486,24 @@ const App = () => { const start = (channelPage - 1) * channelPageSize; return data.channels.slice(start, start + channelPageSize); }, [channelPage, channelPageSize, data.channels]); + const tokenTotal = data.tokens.length; + const tokenTotalPages = useMemo( + () => Math.max(1, Math.ceil(tokenTotal / tokenPageSize)), + [tokenTotal, tokenPageSize], + ); + const pagedTokens = useMemo(() => { + const start = (tokenPage - 1) * tokenPageSize; + return data.tokens.slice(start, start + tokenPageSize); + }, [data.tokens, tokenPage, tokenPageSize]); useEffect(() => { setChannelPage((prev) => Math.min(prev, channelTotalPages)); }, [channelTotalPages]); + useEffect(() => { + setTokenPage((prev) => Math.min(prev, tokenTotalPages)); + }, [tokenTotalPages]); + const activeLabel = useMemo( () => tabs.find((tab) => tab.id === activeTab)?.label ?? "管理台", [activeTab], @@ -514,7 +550,16 @@ const App = () => { if (activeTab === "tokens") { return ( String(value).padStart(2, "0"); + export const formatDateTime = (value?: string | null) => { if (!value) { return "-"; } - return value.slice(0, 19).replace("T", " "); + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return "-"; + } + return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2( + date.getDate(), + )} ${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2( + date.getSeconds(), + )}`; }; /** @@ -25,3 +35,25 @@ export const formatDateTime = (value?: string | null) => { */ export const toggleStatus = (value: string) => value === "active" ? "disabled" : "active"; + +export type PageItem = number | "ellipsis"; + +export const buildPageItems = (current: number, total: number): PageItem[] => { + if (total <= 6) { + return Array.from({ length: total }, (_, index) => index + 1); + } + const items: PageItem[] = [1, 2, 3]; + if (current > 3 && current < total - 1) { + items.push("ellipsis", current); + } + items.push("ellipsis", total - 1, total); + return items.filter((item, index, array) => { + if (item === "ellipsis" && array[index - 1] === "ellipsis") { + return false; + } + if (typeof item === "number") { + return array.indexOf(item) === index; + } + return true; + }); +}; diff --git a/apps/admin/src/features/ChannelsView.tsx b/apps/admin/src/features/ChannelsView.tsx index 7e0f753..3eafa56 100644 --- a/apps/admin/src/features/ChannelsView.tsx +++ b/apps/admin/src/features/ChannelsView.tsx @@ -1,4 +1,5 @@ import type { Channel, ChannelForm } from "../core/types"; +import { buildPageItems } from "../core/utils"; type ChannelsViewProps = { channelForm: ChannelForm; @@ -21,6 +22,8 @@ type ChannelsViewProps = { onFormChange: (patch: Partial) => void; }; +const pageSizeOptions = [10, 20, 50]; + /** * Renders the channels management view. * @@ -51,6 +54,7 @@ export const ChannelsView = ({ onFormChange, }: ChannelsViewProps) => { const isEditing = Boolean(editingChannel); + const pageItems = buildPageItems(channelPage, channelTotalPages); return (
@@ -69,25 +73,6 @@ export const ChannelsView = ({ > 新增渠道 - - {channelTotal} 条 - -
@@ -176,29 +161,65 @@ export const ChannelsView = ({ )}
-
- 第 {channelPage} / {channelTotalPages} 页 · 共 {channelTotal} 条 -
-
+
+ 共 {channelTotalPages} 页 + {pageItems.map((item, index) => + item === "ellipsis" ? ( + + ... + + ) : ( + + ), + )}
+
{isChannelModalOpen && ( diff --git a/apps/admin/src/features/TokensView.tsx b/apps/admin/src/features/TokensView.tsx index a8866b5..edc81f0 100644 --- a/apps/admin/src/features/TokensView.tsx +++ b/apps/admin/src/features/TokensView.tsx @@ -1,13 +1,25 @@ import type { Token } from "../core/types"; +import { buildPageItems, formatDateTime } from "../core/utils"; type TokensViewProps = { - tokens: Token[]; + pagedTokens: Token[]; + tokenPage: number; + tokenPageSize: number; + tokenTotal: number; + tokenTotalPages: number; + isTokenModalOpen: boolean; + onCreate: () => void; + onCloseModal: () => void; + onPageChange: (next: number) => void; + onPageSizeChange: (next: number) => void; onSubmit: (event: Event) => void; onReveal: (id: string) => void; onToggle: (id: string, status: string) => void; onDelete: (id: string) => void; }; +const pageSizeOptions = [10, 20, 50]; + /** * Renders the tokens management view. * @@ -18,122 +30,252 @@ type TokensViewProps = { * Tokens JSX element. */ export const TokensView = ({ - tokens, + pagedTokens, + tokenPage, + tokenPageSize, + tokenTotal, + tokenTotalPages, + isTokenModalOpen, + onCreate, + onCloseModal, + onPageChange, + onPageSizeChange, onSubmit, onReveal, onToggle, onDelete, -}: TokensViewProps) => ( -
-
-
-

- 生成令牌 -

-
-
-
- - +}: TokensViewProps) => { + const pageItems = buildPageItems(tokenPage, tokenTotalPages); + return ( +
+
+
+
+

+ 令牌列表 +

+

+ 统一管理令牌状态、额度与操作入口。 +

+
+
+ +
-
- - +
+
+
名称
+
状态
+
已用/额度
+
前缀
+
创建时间
+
操作
+
+ {pagedTokens.length === 0 ? ( +
+ 暂无令牌,请先创建。 +
+ ) : ( +
+ {pagedTokens.map((tokenItem) => { + const isActive = tokenItem.status === "active"; + return ( +
+
+ + {tokenItem.name} + +
+
+ + {isActive ? "启用" : "禁用"} + +
+
+ {tokenItem.quota_used} / {tokenItem.quota_total ?? "∞"} +
+
+ {tokenItem.key_prefix ?? "-"} +
+
+ {formatDateTime(tokenItem.created_at)} +
+
+ + + +
+
+ ); + })} +
+ )} +
+
+
+ 共 {tokenTotalPages} 页 + + {pageItems.map((item, index) => + item === "ellipsis" ? ( + + ... + + ) : ( + + ), + )} + +
+
- - -
-
-
-

- 令牌列表 -

- - - - - - - - - - - {tokens.map((tokenItem) => ( - - - - - - - ))} - -
- 名称 - - 状态 - - 已用/额度 - - 操作 -
- {tokenItem.name} - - {tokenItem.status} - - {tokenItem.quota_used} / {tokenItem.quota_total ?? "∞"} - -
- - - -
-
+ {isTokenModalOpen && ( +
+
+
+
+

+ 生成令牌 +

+

+ 创建后可在列表中查看与管理。 +

+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ )}
-
-); + ); +}; diff --git a/apps/admin/src/features/UsageView.tsx b/apps/admin/src/features/UsageView.tsx index b60227f..bb03e3a 100644 --- a/apps/admin/src/features/UsageView.tsx +++ b/apps/admin/src/features/UsageView.tsx @@ -1,11 +1,31 @@ +import { useEffect, useMemo, useState } from "hono/jsx/dom"; import type { UsageLog } from "../core/types"; -import { formatDateTime } from "../core/utils"; +import { buildPageItems, formatDateTime } from "../core/utils"; type UsageViewProps = { usage: UsageLog[]; onRefresh: () => void; }; +const pageSizeOptions = [50, 100, 200]; + +const formatTokens = (value: number | null | undefined) => + value === null || value === undefined ? "-" : value; + +const formatSeconds = (value: number | null | undefined) => { + if (value === null || value === undefined || Number.isNaN(value)) { + return "-"; + } + return `${(value / 1000).toFixed(2)} s`; +}; + +const formatStream = (value: boolean | null | undefined) => { + if (value === null || value === undefined) { + return "-"; + } + return value ? "是" : "否"; +}; + /** * Renders the usage logs view. * @@ -15,67 +35,205 @@ type UsageViewProps = { * Returns: * Usage JSX element. */ -export const UsageView = ({ usage, onRefresh }: UsageViewProps) => ( -
-
-

- 使用日志 -

- +export const UsageView = ({ usage, onRefresh }: UsageViewProps) => { + const [pageSize, setPageSize] = useState(50); + const [page, setPage] = useState(1); + const total = usage.length; + const totalPages = useMemo( + () => Math.max(1, Math.ceil(total / pageSize)), + [total, pageSize], + ); + + useEffect(() => { + setPage(1); + }, [pageSize]); + + useEffect(() => { + setPage((prev) => Math.min(prev, totalPages)); + }, [totalPages]); + + const pagedUsage = useMemo(() => { + const start = (page - 1) * pageSize; + return usage.slice(start, start + pageSize); + }, [usage, page, pageSize]); + const pageItems = useMemo( + () => buildPageItems(page, totalPages), + [page, totalPages], + ); + + return ( +
+
+
+

+ 使用日志 +

+

+ 追踪每次调用的令牌与关键性能指标。 +

+
+
+ +
+
+
+
+ + + + + + + + + + + + + + + + + + {pagedUsage.length === 0 ? ( + + + + ) : ( + pagedUsage.map((log) => ( + + + + + + + + + + + + + + )) + )} + +
+ 时间 + + 模型 + + 渠道 + + 令牌 + + 输入 Tokens + + 输出 Tokens + + 用时 + + 首 token 延迟 + + 流式 + + 推理强度 + + 状态 +
+ 暂无日志。 +
+ {formatDateTime(log.created_at)} + + {log.model ?? "-"} + + {log.channel_name ?? log.channel_id ?? "-"} + + {log.token_name ?? log.token_id ?? "-"} + + {formatTokens(log.prompt_tokens)} + + {formatTokens(log.completion_tokens)} + + {formatSeconds(log.latency_ms)} + + {formatSeconds(log.first_token_latency_ms)} + + {formatStream(log.stream)} + + {log.reasoning_effort ?? "-"} + + {log.status} +
+
+
+
+
+ 共 {totalPages} 页 + + {pageItems.map((item, index) => + item === "ellipsis" ? ( + + ... + + ) : ( + + ), + )} + +
+ +
- - - - - - - - - - - - - {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/worker/migrations/0001_init.sql b/apps/worker/migrations/0001_init.sql index 599ce32..66ef1d3 100644 --- a/apps/worker/migrations/0001_init.sql +++ b/apps/worker/migrations/0001_init.sql @@ -35,6 +35,9 @@ CREATE TABLE IF NOT EXISTS usage_logs ( completion_tokens INTEGER, cost REAL, latency_ms INTEGER, + first_token_latency_ms INTEGER, + stream INTEGER, + reasoning_effort TEXT, status TEXT, created_at TEXT NOT NULL ); diff --git a/apps/worker/migrations/0004_add_usage_log_metrics.sql b/apps/worker/migrations/0004_add_usage_log_metrics.sql new file mode 100644 index 0000000..9bdb3e0 --- /dev/null +++ b/apps/worker/migrations/0004_add_usage_log_metrics.sql @@ -0,0 +1,3 @@ +ALTER TABLE usage_logs ADD COLUMN first_token_latency_ms INTEGER; +ALTER TABLE usage_logs ADD COLUMN stream INTEGER; +ALTER TABLE usage_logs ADD COLUMN reasoning_effort TEXT; diff --git a/apps/worker/src/db/schema.sql b/apps/worker/src/db/schema.sql index 64e6a69..85c1575 100644 --- a/apps/worker/src/db/schema.sql +++ b/apps/worker/src/db/schema.sql @@ -42,6 +42,9 @@ CREATE TABLE IF NOT EXISTS usage_logs ( completion_tokens INTEGER, cost REAL, latency_ms INTEGER, + first_token_latency_ms INTEGER, + stream INTEGER, + reasoning_effort TEXT, status TEXT, created_at TEXT NOT NULL ); diff --git a/apps/worker/src/routes/proxy.ts b/apps/worker/src/routes/proxy.ts index 7cb321f..404f28a 100644 --- a/apps/worker/src/routes/proxy.ts +++ b/apps/worker/src/routes/proxy.ts @@ -92,6 +92,17 @@ proxy.all("/*", tokenAuth, async (c) => { ? String(parsedBody.model) : null; const isStream = parsedBody?.stream === true; + const reasoningEffortRaw = + parsedBody && typeof parsedBody === "object" + ? ((parsedBody as Record).reasoning_effort ?? + (parsedBody as Record).reasoningEffort ?? + null) + : null; + const reasoningEffort = + typeof reasoningEffortRaw === "string" || + typeof reasoningEffortRaw === "number" + ? reasoningEffortRaw + : null; if (isStream && parsedBody && typeof parsedBody === "object") { const streamOptions = (parsedBody as Record) .stream_options; @@ -185,6 +196,9 @@ proxy.all("/*", tokenAuth, async (c) => { requestPath: targetPath, totalTokens: 0, latencyMs, + firstTokenLatencyMs: isStream ? null : latencyMs, + stream: isStream, + reasoningEffort, status: "error", }); return jsonError(c, 502, "upstream_unavailable", "upstream_unavailable"); @@ -192,12 +206,17 @@ proxy.all("/*", tokenAuth, async (c) => { const channelForUsage = selectedChannel ?? lastChannel; if (channelForUsage && lastResponse) { - const record = async (usage: NormalizedUsage | null) => { + const record = async ( + usage: NormalizedUsage | null, + firstTokenLatencyMs?: number | null, + ) => { const normalized = usage ?? { totalTokens: 0, promptTokens: 0, completionTokens: 0, }; + const resolvedFirstTokenLatencyMs = + firstTokenLatencyMs ?? (isStream ? null : latencyMs); await recordUsage(c.env.DB, { tokenId: tokenRecord.id, channelId: channelForUsage.id, @@ -208,6 +227,9 @@ proxy.all("/*", tokenAuth, async (c) => { completionTokens: normalized.completionTokens, cost: 0, latencyMs, + firstTokenLatencyMs: resolvedFirstTokenLatencyMs, + stream: isStream, + reasoningEffort, status: lastResponse.ok ? "ok" : "error", }); }; @@ -248,13 +270,19 @@ proxy.all("/*", tokenAuth, async (c) => { ? "header" : "none"; - if (isStream && !immediateUsage) { + if (isStream) { const executionCtx = (c as { executionCtx?: ExecutionContextLike }) .executionCtx; const task = parseUsageFromSse(lastResponse.clone()) .then((streamUsage) => { - logUsage("stream", streamUsage, streamUsage ? "sse" : "sse-none"); - return record(streamUsage); + const usageValue = immediateUsage ?? streamUsage.usage; + const source = immediateUsage + ? immediateSource + : streamUsage.usage + ? "sse" + : "sse-none"; + logUsage("stream", usageValue, source); + return record(usageValue, streamUsage.firstTokenLatencyMs); }) .catch(() => undefined); if (executionCtx?.waitUntil) { @@ -264,7 +292,7 @@ proxy.all("/*", tokenAuth, async (c) => { } } else { logUsage("immediate", immediateUsage, immediateSource); - await record(immediateUsage); + await record(immediateUsage, latencyMs); } } diff --git a/apps/worker/src/services/usage.ts b/apps/worker/src/services/usage.ts index 425592f..68c1d3c 100644 --- a/apps/worker/src/services/usage.ts +++ b/apps/worker/src/services/usage.ts @@ -11,6 +11,9 @@ export type UsageInput = { completionTokens?: number | null; cost?: number | null; latencyMs?: number | null; + firstTokenLatencyMs?: number | null; + stream?: boolean | number | null; + reasoningEffort?: string | number | null; status?: string | null; }; @@ -23,9 +26,21 @@ export async function recordUsage( ): Promise { const id = crypto.randomUUID(); const createdAt = nowIso(); + const streamValue = + input.stream === null || input.stream === undefined + ? null + : typeof input.stream === "number" + ? input.stream + : input.stream + ? 1 + : 0; + const reasoningValue = + input.reasoningEffort === null || input.reasoningEffort === undefined + ? null + : String(input.reasoningEffort); 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO usage_logs (id, token_id, channel_id, model, request_path, total_tokens, prompt_tokens, completion_tokens, cost, latency_ms, first_token_latency_ms, stream, reasoning_effort, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind( id, @@ -38,6 +53,9 @@ export async function recordUsage( input.completionTokens ?? 0, input.cost ?? 0, input.latencyMs ?? 0, + input.firstTokenLatencyMs ?? null, + streamValue, + reasoningValue, input.status ?? "ok", createdAt, ) diff --git a/apps/worker/src/utils/usage.ts b/apps/worker/src/utils/usage.ts index eb7221d..d8dee3b 100644 --- a/apps/worker/src/utils/usage.ts +++ b/apps/worker/src/utils/usage.ts @@ -6,6 +6,11 @@ export type NormalizedUsage = { completionTokens: number; }; +export type StreamUsage = { + usage: NormalizedUsage | null; + firstTokenLatencyMs: number | null; +}; + function toNumber(value: unknown): number | null { if (value === null || value === undefined) { return null; @@ -119,14 +124,16 @@ export function parseUsageFromHeaders( export async function parseUsageFromSse( response: Response, -): Promise { +): Promise { if (!response.body) { - return null; + return { usage: null, firstTokenLatencyMs: null }; } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; let usage: NormalizedUsage | null = null; + const start = Date.now(); + let firstTokenLatencyMs: number | null = null; while (true) { const { done, value } = await reader.read(); @@ -141,6 +148,9 @@ export async function parseUsageFromSse( if (line.startsWith("data:")) { const payload = line.slice(5).trim(); if (payload && payload !== "[DONE]") { + if (firstTokenLatencyMs === null) { + firstTokenLatencyMs = Date.now() - start; + } const parsed = safeJsonParse(payload, null); const candidate = parseUsageFromJson(parsed); if (candidate) { @@ -156,6 +166,9 @@ export async function parseUsageFromSse( if (remaining.startsWith("data:")) { const payload = remaining.slice(5).trim(); if (payload && payload !== "[DONE]") { + if (firstTokenLatencyMs === null) { + firstTokenLatencyMs = Date.now() - start; + } const parsed = safeJsonParse(payload, null); const candidate = parseUsageFromJson(parsed); if (candidate) { @@ -164,5 +177,5 @@ export async function parseUsageFromSse( } } - return usage; + return { usage, firstTokenLatencyMs }; } diff --git a/helloagents/CHANGELOG.md b/helloagents/CHANGELOG.md index 1c6fe65..1a39620 100644 --- a/helloagents/CHANGELOG.md +++ b/helloagents/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] ### 微调 +- **[worker]**: 补齐 admin 静态目录占位以通过 wrangler assets 检查 + - 类型: 微调(无方案包) + - 文件: apps/admin/dist/.gitkeep +- **[worker]**: 修正 wrangler assets 配置并为非 API 路由回退静态资源 + - 类型: 微调(无方案包) + - 文件: apps/worker/wrangler.toml:15-19, apps/worker/src/index.ts:114-127 - **[tooling]**: 修复 bun check 脚本名称(移除尾随空格) - 类型: 微调(无方案包) - 文件: package.json @@ -12,6 +18,12 @@ - **[ci]**: 自动部署加入远程 D1 迁移步骤 - 类型: 微调(无方案包) - 文件: .github/workflows/deploy.yml +- **[ci]**: 对齐 CloudPaste 风格的 SPA 自动部署流程与数据库初始化 + - 类型: 微调(无方案包) + - 文件: .github/workflows/deploy.yml +- **[docs]**: 说明 SPA_DEPLOY 自动部署开关 + - 类型: 微调(无方案包) + - 文件: README.md - **[admin-ui]**: 渠道创建移除 ID 字段并校验名称唯一 - 类型: 微调(无方案包) - 文件: apps/admin/src/main.tsx:250-287, 664-829 @@ -61,6 +73,26 @@ - 类型: 微调(无方案包) - 文件: apps/worker/src/routes/proxy.ts +## [0.4.5] - 2026-02-16 + +### 变更 +- **[admin-ui]**: 令牌管理改为列表视图并支持分页弹窗创建 + - 方案: [202602161600_token-list-ui](archive/2026-02/202602161600_token-list-ui/) + +## [0.4.4] - 2026-02-16 + +### 修复 +- **[admin-ui]**: 使用日志默认每页 50 条并修正本地时间显示 + - 方案: [202602161433_usage-log-fixes](archive/2026-02/202602161433_usage-log-fixes/) +- **[usage/proxy]**: 使用日志补充首 token 延迟、流式与推理强度记录 + - 方案: [202602161433_usage-log-fixes](archive/2026-02/202602161433_usage-log-fixes/) + +## [0.4.3] - 2026-02-16 + +### 变更 +- **[admin-ui]**: 使用日志支持分页与指标拆分展示 + - 方案: [202602161355_usage-view-metrics](archive/2026-02/202602161355_usage-view-metrics/) + ## [0.4.2] - 2026-02-16 ### 变更 diff --git a/helloagents/modules/admin-ui.md b/helloagents/modules/admin-ui.md index b2ebc0a..79b4419 100644 --- a/helloagents/modules/admin-ui.md +++ b/helloagents/modules/admin-ui.md @@ -17,6 +17,9 @@ - 样式基于 Tailwind v4(结合全局主题变量) - 前端状态集中在单页 - 渠道管理采用列表视图,支持分页(默认 10 条),新增/编辑使用弹窗,保留启禁/连通测试操作 +- 使用日志支持本地分页(默认 50 条)与固定高度滚动,并展示令牌、输入/输出 tokens、首 token 延迟、流式与推理强度 +- 令牌管理采用列表视图(默认每页 10 条),生成令牌使用弹窗表单,并展示创建时间与前缀 +- 列表分页页脚统一使用页码导航 + 每页条数选择,并显示总页数,顶部不显示条数与每页选择 - Token 仅本地保存,不回显 ## 依赖关系 diff --git a/helloagents/modules/proxy.md b/helloagents/modules/proxy.md index b625173..118c545 100644 --- a/helloagents/modules/proxy.md +++ b/helloagents/modules/proxy.md @@ -14,6 +14,7 @@ - 支持从非流式 JSON、响应头与流式 SSE 解析 usage 字段 - 流式请求自动补 `stream_options.include_usage=true` 以便上游返回 usage - 可配置失败重试轮询(响应 5xx/429 时触发) +- 记录流式请求标记、首 token 延迟与推理强度到 usage_logs ## 依赖关系 - `channels` / `tokens` / `usage_logs` 表 diff --git a/helloagents/modules/tokens.md b/helloagents/modules/tokens.md index 82157a9..a6e6f80 100644 --- a/helloagents/modules/tokens.md +++ b/helloagents/modules/tokens.md @@ -16,6 +16,7 @@ - 令牌可通过 reveal 接口再次查看 - 默认允许所有渠道 - 额度消耗由代理调用记录 +- 管理台列表展示创建时间与 key_prefix 前缀 ## 依赖关系 - `tokens` 表 diff --git a/helloagents/modules/usage.md b/helloagents/modules/usage.md index 5adeee7..6e6c7d4 100644 --- a/helloagents/modules/usage.md +++ b/helloagents/modules/usage.md @@ -12,6 +12,7 @@ - 代理调用统一写入 `usage_logs` - 保留天数可配置,查询时触发清理 - 查询结果附带渠道与令牌名称 +- usage_logs 记录输入/输出 tokens、首 token 延迟、流式标记与推理强度 ## 依赖关系 - `usage_logs` 表