feat(usage): add SQLite usage records and portable tooling

This commit is contained in:
lzt404 committed 2026-07-18 15:16:52 +08:00
1 parent 41efeebcbb
commit c6ea0fecd9
13 files changed
+3630 -83

No files matched your search

+94
View File
@@ -0,0 +1,94 @@
name: Build portable releases
on:
workflow_dispatch:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-24.04
os: linux
arch: amd64
suffix: tar.gz
- runner: ubuntu-24.04-arm
os: linux
arch: aarch64
suffix: tar.gz
- runner: macos-15-intel
os: darwin
arch: amd64
suffix: tar.gz
- runner: macos-15
os: darwin
arch: aarch64
suffix: tar.gz
- runner: windows-2025
os: windows
arch: amd64
suffix: zip
- runner: windows-11-arm
os: windows
arch: aarch64
suffix: zip
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- uses: dtolnay/rust-toolchain@stable
- name: Install Linux dependencies
if: matrix.os == 'linux'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: Build GUI
run: |
bun install --frozen-lockfile
bun tauri build --no-bundle
shell: bash
- name: Prepare portable directory
run: >-
node scripts/prepare-portable.mjs
--binary "src-tauri/target/release/cpa-gui${{ matrix.os == 'windows' && '.exe' || '' }}"
--output "release/cpa-gui-${{ matrix.os }}-${{ matrix.arch }}"
--os "${{ matrix.os }}"
--arch "${{ matrix.arch }}"
--download true
--clean-core true
shell: bash
- name: Archive Unix release
if: matrix.os != 'windows'
run: tar -czf "cpa-gui-${{ matrix.os }}-${{ matrix.arch }}.tar.gz" -C release "cpa-gui-${{ matrix.os }}-${{ matrix.arch }}"
- name: Archive Windows release
if: matrix.os == 'windows'
shell: pwsh
run: Compress-Archive -Path "release/cpa-gui-${{ matrix.os }}-${{ matrix.arch }}" -DestinationPath "cpa-gui-${{ matrix.os }}-${{ matrix.arch }}.zip"
- uses: actions/upload-artifact@v4
with:
name: cpa-gui-${{ matrix.os }}-${{ matrix.arch }}
path: cpa-gui-${{ matrix.os }}-${{ matrix.arch }}.${{ matrix.suffix }}
if-no-files-found: error
publish:
if: startsWith(github.ref, 'refs/tags/')
needs: build
runs-on: ubuntu-24.04
steps:
- uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: Publish GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: gh release create "${GITHUB_REF_NAME}" artifacts/* --generate-notes --verify-tag
+3 -1
View File
@@ -24,7 +24,9 @@ if [ ! -x "$APP_BIN" ]; then
fi
mkdir -p "$BIN_DIR"
cp -f "$APP_BIN" "$BIN_OUT"
bun "$ROOT_DIR/scripts/prepare-portable.mjs" \
--binary "$APP_BIN" \
--output "$BIN_DIR"
chmod +x "$BIN_OUT"
echo "Built: $APP_BIN"
+1
View File
@@ -0,0 +1 @@
7.2.83
+83
View File
@@ -0,0 +1,83 @@
import { createHash } from 'node:crypto';
import { existsSync } from 'node:fs';
import { chmod, copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { basename, join, resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
const args = new Map();
for (let index = 2; index < process.argv.length; index += 2) {
args.set(process.argv[index], process.argv[index + 1]);
}
const root = resolve(import.meta.dirname, '..');
const output = resolve(args.get('--output') ?? join(root, 'bin-work'));
const binary = resolve(args.get('--binary') ?? join(root, 'src-tauri', 'target', 'release', process.platform === 'win32' ? 'cpa-gui.exe' : 'cpa-gui'));
const targetOS = args.get('--os') ?? ({ linux: 'linux', darwin: 'darwin', win32: 'windows' })[process.platform];
const targetArch = args.get('--arch') ?? ({ x64: 'amd64', arm64: 'aarch64' })[process.arch];
const shouldDownload = args.get('--download') === 'true';
const cleanCore = args.get('--clean-core') === 'true';
if (!targetOS || !targetArch) throw new Error(`Unsupported target: ${process.platform}/${process.arch}`);
if (!existsSync(binary)) throw new Error(`GUI binary not found: ${binary}`);
const rawVersion = (await readFile(join(root, 'core-version.txt'), 'utf8')).trim();
if (!/^v?\d+(?:\.\d+)+$/.test(rawVersion)) throw new Error(`Invalid core-version.txt: ${rawVersion}`);
const version = rawVersion.replace(/^v/i, '');
const extension = targetOS === 'windows' ? 'zip' : 'tar.gz';
const assetName = `CLIProxyAPI_${version}_${targetOS}_${targetArch}.${extension}`;
const sourceDir = join(root, 'cpa-core');
const sourceArchive = join(sourceDir, assetName);
const checksumsPath = join(sourceDir, 'checksums.txt');
await mkdir(sourceDir, { recursive: true });
if (!existsSync(sourceArchive)) {
if (!shouldDownload) {
console.warn(`Built-in core archive not found; portable output keeps the existing core: ${sourceArchive}`);
await mkdir(output, { recursive: true });
const outputBinary = join(output, targetOS === 'windows' ? 'cpa-gui.exe' : 'cpa-gui');
await copyFile(binary, outputBinary);
if (targetOS !== 'windows') await chmod(outputBinary, 0o755);
await copyFile(join(root, 'core-version.txt'), join(output, 'core-version.txt'));
process.exit(0);
}
const tag = `v${version}`;
const releaseBase = `https://github.com/router-for-me/CLIProxyAPI/releases/download/${tag}`;
const [archiveResponse, checksumsResponse] = await Promise.all([
fetch(`${releaseBase}/${assetName}`),
fetch(`${releaseBase}/checksums.txt`),
]);
if (!archiveResponse.ok) throw new Error(`Download ${assetName} failed: HTTP ${archiveResponse.status}`);
if (!checksumsResponse.ok) throw new Error(`Download checksums.txt failed: HTTP ${checksumsResponse.status}`);
await writeFile(sourceArchive, Buffer.from(await archiveResponse.arrayBuffer()));
await writeFile(checksumsPath, await checksumsResponse.text());
}
if (existsSync(checksumsPath)) {
const checksums = await readFile(checksumsPath, 'utf8');
const expected = checksums.split(/\r?\n/).map((line) => line.trim().split(/\s+/)).find((parts) => parts[1]?.replace(/^\*/, '') === assetName)?.[0]?.toLowerCase();
if (!expected) throw new Error(`checksums.txt does not contain ${assetName}`);
const actual = createHash('sha256').update(await readFile(sourceArchive)).digest('hex');
if (actual !== expected) throw new Error(`SHA-256 mismatch for ${assetName}`);
}
await mkdir(output, { recursive: true });
const outputBinary = join(output, targetOS === 'windows' ? 'cpa-gui.exe' : 'cpa-gui');
await copyFile(binary, outputBinary);
if (targetOS !== 'windows') await chmod(outputBinary, 0o755);
await copyFile(join(root, 'core-version.txt'), join(output, 'core-version.txt'));
const coreOutput = join(output, 'cpa-core');
if (cleanCore) await rm(coreOutput, { recursive: true, force: true });
await mkdir(coreOutput, { recursive: true });
const extraction = spawnSync('tar', ['-xf', sourceArchive, '-C', coreOutput], { stdio: 'inherit' });
if (extraction.status !== 0) throw new Error(`Failed to extract ${assetName}`);
await copyFile(sourceArchive, join(coreOutput, assetName));
if (existsSync(checksumsPath)) await copyFile(checksumsPath, join(coreOutput, 'checksums.txt'));
await writeFile(join(coreOutput, 'cpa-gui-meta.json'), `${JSON.stringify({
version: `v${version}`,
assetName,
installedAtUnix: Math.floor(Date.now() / 1000),
}, null, 2)}\n`);
console.log(`Prepared portable directory: ${output}`);
console.log(`Bundled core: ${basename(sourceArchive)}`);
+91
View File
@@ -8,6 +8,18 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -342,8 +354,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link 0.2.1",
]
@@ -417,11 +431,13 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
name = "cpa-gui"
version = "0.1.0"
dependencies = [
"chrono",
"flate2",
"futures-util",
"json5",
"libc",
"reqwest 0.12.28",
"rusqlite",
"serde",
"serde_json",
"serde_norway",
@@ -807,6 +823,18 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.4.1"
@@ -1284,6 +1312,9 @@ name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
@@ -1291,6 +1322,15 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "hashlink"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
"hashbrown 0.14.5",
]
[[package]]
name = "heck"
version = "0.4.1"
@@ -1784,6 +1824,17 @@ dependencies = [
"libc",
]
[[package]]
name = "libsqlite3-sys"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -2736,6 +2787,20 @@ dependencies = [
"text-size",
]
[[package]]
name = "rusqlite"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
dependencies = [
"bitflags 2.13.0",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -4098,6 +4163,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.1"
@@ -4892,6 +4963,26 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zerocopy"
version = "0.8.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "zerofrom"
version = "0.1.8"
+2
View File
@@ -9,10 +9,12 @@ edition = "2021"
tauri-build = { version = "2", features = [] }
[dependencies]
chrono = { version = "0.4", features = ["serde"] }
flate2 = "1"
futures-util = "0.3"
json5 = "0.4"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
rusqlite = { version = "0.32.1", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_norway = "0.9"
+842 -58
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+8 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Bot, FileKey, Gauge, GitFork, LogIn, Network, ServerCog, Settings } from 'lucide-react';
import { Bot, FileKey, Gauge, GitFork, History, LogIn, Network, ServerCog, Settings } from 'lucide-react';
import appLogo from './assets/logo.jpg';
import { CoreRuntimeProvider, useCoreRuntime } from './coreRuntime';
import { ConfigPanelPage } from './pages/ConfigPanel';
@@ -10,6 +10,7 @@ import { OAuthLoginPage } from './pages/ManagementPages';
import { QuotaPage } from './pages/QuotaPage';
import { AgentsPage } from './pages/AgentsPage';
import { ThinkingAliasesPage } from './pages/ThinkingAliasesPage';
import { UsageRecordsPage } from './pages/UsageRecordsPage';
const pages = [
{
@@ -54,6 +55,12 @@ const pages = [
icon: Gauge,
component: QuotaPage,
},
{
id: 'usage-records',
label: '使用记录',
icon: History,
component: UsageRecordsPage,
},
{
id: 'agents',
label: '智能体',
+47 -14
View File
@@ -96,7 +96,7 @@ const agentDefinitions: AgentDefinition[] = [
id: 'claude-desktop',
name: 'Claude Desktop',
icon: claudeIcon,
description: '使用 CPA 3P 推理网关,仅支持 Windows 和 macOS',
description: '使用 CPA 3P 推理网关,支持 Windows、macOS 和 Linux Beta',
},
{
id: 'codex',
@@ -125,6 +125,29 @@ const agentDefinitions: AgentDefinition[] = [
];
const AGENT_MODEL_SELECTIONS_KEY = 'cpa-gui.agent-model-selections.v1';
const AGENT_SELECTED_CLIENT_KEY = 'cpa-gui.agent-selected-client.v1';
const readSelectedAgentClient = (): AgentClientId => {
const fallback = agentDefinitions[0].id;
if (typeof window === 'undefined') return fallback;
try {
const saved = window.localStorage.getItem(AGENT_SELECTED_CLIENT_KEY);
return agentDefinitions.some((agent) => agent.id === saved)
? (saved as AgentClientId)
: fallback;
} catch {
return fallback;
}
};
const writeSelectedAgentClient = (client: AgentClientId) => {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(AGENT_SELECTED_CLIENT_KEY, client);
} catch {
// Keep the current in-memory selection when persistent storage is unavailable.
}
};
const readAgentModelSelections = (): Partial<Record<AgentClientId, string>> => {
if (typeof window === 'undefined') return {};
@@ -365,7 +388,7 @@ function AgentModelPicker({
}
export function AgentsPage() {
const [selected, setSelected] = useState<AgentClientId>('claude-code');
const [selected, setSelected] = useState<AgentClientId>(readSelectedAgentClient);
const [statuses, setStatuses] = useState<AgentConfigStatus[]>([]);
const [models, setModels] = useState<ModelOption[]>([]);
const [modelByClient, setModelByClient] = useState<Partial<Record<AgentClientId, string>>>(
@@ -419,6 +442,10 @@ export function AgentsPage() {
void refresh();
}, [refresh]);
useEffect(() => {
writeSelectedAgentClient(selected);
}, [selected]);
useEffect(() => {
setLaunchTargetByClient((current) => agentDefinitions.reduce<Partial<Record<AgentClientId, string>>>(
(next, definition) => {
@@ -460,6 +487,8 @@ export function AgentsPage() {
const canLaunch = Boolean(
activeStatus?.supportedPlatform
&& activeStatus.installed
&& activeStatus.modificationEnabled
&& activeStatus.modificationState === 'active'
&& selectedLaunchTarget,
);
@@ -513,7 +542,7 @@ export function AgentsPage() {
}
setRestoreConflict(null);
setNotice(result.enabled
? `${activeDefinition.name} 已启用配置修改,请重启客户端`
? `${activeDefinition.name} 已启用配置修改;退出软件前建议关闭“修改智能体配置”,恢复原配置文件`
: `${activeDefinition.name} 的原配置已恢复,请重启客户端`);
await loadStatuses();
} catch (requestError) {
@@ -529,10 +558,10 @@ export function AgentsPage() {
setNotice('');
try {
let appliedBeforeLaunch = false;
const shouldSyncCodexCatalog = Boolean(
selected === 'codex' && activeStatus?.modificationEnabled && selectedModelOption,
const shouldSyncModelCatalog = Boolean(
activeStatus?.modificationEnabled && selectedModelOption,
);
if (draftChanged || shouldSyncCodexCatalog) {
if (draftChanged || shouldSyncModelCatalog) {
if (activeStatus?.modificationState !== 'active') {
throw new Error('当前配置存在冲突或恢复任务,暂时不能应用新模型');
}
@@ -547,9 +576,11 @@ export function AgentsPage() {
await invoke('launch_agent', { client: selected, target: selectedLaunchTarget?.id });
setNotice(appliedBeforeLaunch
? draftChanged
? `${activeDefinition.name} 已应用所选模型并启动`
: `${activeDefinition.name} 已同步模型目录并启动`
: `${activeDefinition.name} 已启动`);
? `${activeDefinition.name} 已应用模型并启动;退出软件前建议关闭“修改智能体配置”,恢复原配置文件`
: `${activeDefinition.name} 已同步模型目录并启动;退出软件前建议关闭“修改智能体配置”,恢复原配置文件`
: activeStatus?.modificationEnabled
? `${activeDefinition.name} 已启动;退出软件前建议关闭“修改智能体配置”,恢复原配置文件`
: `${activeDefinition.name} 已启动`);
if (appliedBeforeLaunch) await loadStatuses();
} catch (requestError) {
setError(String(requestError));
@@ -655,7 +686,7 @@ export function AgentsPage() {
<section className="agent-model-section">
<div className="agent-section-heading">
<div><strong>使用模型</strong><span>只能选择内核当前实际开放的模型</span></div>
<div><strong>使用模型</strong><span>同步内核当前开放的模型,并将所选项设为默认模型</span></div>
{draftChanged ? <span className="agent-pending-badge">尚未应用</span> : null}
</div>
<AgentModelPicker
@@ -689,7 +720,7 @@ export function AgentsPage() {
: activeStatus.modificationState === 'recovery'
? '上次操作未完成,关闭开关可尝试恢复原配置'
: '原配置已备份,当前由 CPA 管理'
: '开启后先备份原配置,再写入 CPA 配置'}</span>
: '启动客户端前必须开启;程序会先备份原配置,再写入 CPA 配置'}</span>
</div>
<label className="switch-control" title="修改智能体配置">
<input
@@ -708,7 +739,7 @@ export function AgentsPage() {
<span>{activeStatus?.modificationEnabled
? draftChanged
? '模型选择已变化,启动时会先应用新模型'
: '关闭开关会恢复开启前的原始配置;可直接启动智能体'
: '退出软件前建议关闭“修改智能体配置”,恢复开启前的原配置文件'
: !activeStatus?.supportedPlatform
? '当前系统无法配置该客户端'
: !activeStatus.installed
@@ -717,7 +748,7 @@ export function AgentsPage() {
? '检测到配置文件,但没有找到客户端命令'
: !activeStatus.configValid
? '原配置格式异常,无法安全修改'
: '未开启修改配置时,将使用客户端原配置启动'}</span>
: '请先开启“修改智能体配置”,确保 CPA 模型配置生效后再启动客户端'}</span>
</div>
<div className="agent-launch-actions">
{activeLaunchTargets.length > 1 ? (
@@ -748,7 +779,9 @@ export function AgentsPage() {
|| !canLaunch
|| (draftChanged && activeStatus?.modificationState !== 'active')
}
title={selectedLaunchTarget?.detail}
title={activeStatus?.modificationEnabled
? selectedLaunchTarget?.detail
: '请先开启“修改智能体配置”'}
>
{busy ? <LoaderCircle size={16} className="spin" /> : <Play size={16} />}
{busy ? '启动中' : selectedLaunchTarget ? `启动 ${selectedLaunchTarget.label}` : '无法启动'}
+101 -7
View File
@@ -21,6 +21,12 @@ type CoreLatest = {
assetName: string;
};
type BundledCoreInfo = {
version: string;
assetName: string;
sizeBytes: number;
};
type CoreInstallResult = {
version: string;
assetName: string;
@@ -76,6 +82,17 @@ function requestLatestCore() {
return latestCheckPromise;
}
const compareVersions = (left: string, right: string) => {
const parse = (value: string) => value.replace(/^v/i, '').split('.').map((part) => Number(part) || 0);
const leftParts = parse(left);
const rightParts = parse(right);
for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) {
const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
if (difference !== 0) return difference;
}
return 0;
};
export function KernelPage() {
const { publishStatus } = useCoreRuntime();
const [coreStatus, setCoreStatus] = useState<CoreStatus | null>(null);
@@ -84,6 +101,8 @@ export function KernelPage() {
const [platformError, setPlatformError] = useState('');
const [latest, setLatest] = useState<CoreLatest | null>(cachedLatest);
const [latestError, setLatestError] = useState(cachedLatestError);
const [bundledCore, setBundledCore] = useState<BundledCoreInfo | null>(null);
const [bundledCoreError, setBundledCoreError] = useState('');
const [checkingLatest, setCheckingLatest] = useState(Boolean(latestCheckPromise));
const [allowLanAccess, setAllowLanAccess] = useState(false);
const [customPort, setCustomPort] = useState('8317');
@@ -179,6 +198,7 @@ export function KernelPage() {
loadCoreStatus();
loadPlatform();
loadBundledCore();
loadInstallTask();
loadGuiSettings();
@@ -394,6 +414,17 @@ export function KernelPage() {
}
};
const loadBundledCore = async () => {
try {
const result = await invoke<BundledCoreInfo | null>('detect_bundled_core');
setBundledCore(result);
setBundledCoreError('');
} catch (error) {
setBundledCore(null);
setBundledCoreError(String(error));
}
};
const checkLatest = async () => {
setCheckingLatest(true);
setLatestError('');
@@ -452,7 +483,7 @@ export function KernelPage() {
message: `${result.version} 安装完成`,
result,
});
await loadCoreStatus();
await Promise.all([loadCoreStatus(), loadBundledCore()]);
} catch (error) {
const errorMessage = String(error);
setMessage(errorMessage);
@@ -472,6 +503,55 @@ export function KernelPage() {
}
};
const installBundledCore = async () => {
if (!bundledCore) return;
const comparison = currentVersion ? compareVersions(currentVersion, bundledCore.version) : 0;
const prompt = comparison > 0
? `当前内核 ${currentVersion} 高于内置版本 ${bundledCore.version},继续会降级。是否确认?`
: currentVersion === bundledCore.version
? `将使用内置压缩包重新安装 ${bundledCore.version},是否继续?`
: `将安装内置内核 ${bundledCore.version},是否继续?`;
if (!window.confirm(prompt)) return;
setInstalling(true);
setMessage(`正在安装内置内核 ${bundledCore.version}`);
setMessageType('info');
setCancellingInstall(false);
setInstallDialogOpen(true);
setProgress({
running: true,
cancellable: false,
phase: '准备内置内核',
downloaded: 0,
total: bundledCore.sizeBytes,
percent: 0,
message: null,
result: null,
});
try {
const result = await invoke<CoreInstallResult>('install_bundled_core');
setMessage(`${result.version} 内置内核安装完成`);
setMessageType('success');
await Promise.all([loadCoreStatus(), loadBundledCore()]);
} catch (error) {
const errorMessage = String(error);
setMessage(errorMessage);
setMessageType('error');
setProgress((current) => ({
running: false,
cancellable: false,
phase: '安装失败',
downloaded: current?.downloaded ?? 0,
total: current?.total ?? null,
percent: current?.percent ?? null,
message: errorMessage,
result: null,
}));
} finally {
setInstalling(false);
}
};
const cancelInstall = async () => {
if (cancellingInstall || !progress?.running || !progress.cancellable) {
return;
@@ -750,8 +830,11 @@ export function KernelPage() {
<div className="panel version-panel">
<div className="panel-heading">
<div>
<h2>版本管理</h2>
<div className="version-heading-inline">
<h2>内核版本管理</h2>
<span className="version-offline-hint">
GitHub 无法连接时,可使用离线安装使用内置内核。
</span>
</div>
</div>
@@ -765,12 +848,14 @@ export function KernelPage() {
<dd>{latestLabel}</dd>
</div>
<div className="panel-detail-row">
<dt>操作系统</dt>
<dd title={platformError || undefined}>{platformOsLabel}</dd>
<dt>内置版本</dt>
<dd title={bundledCoreError || bundledCore?.assetName}>
{bundledCore?.version ?? (bundledCoreError ? '检测失败' : '未包含')}
</dd>
</div>
<div className="panel-detail-row">
<dt>系统架构</dt>
<dd title={platformError || undefined}>{platformArchLabel}</dd>
<dt>运行平台</dt>
<dd title={platformError || undefined}>{platformOsLabel} / {platformArchLabel}</dd>
</div>
<div className="panel-detail-row">
<dt>更新状态</dt>
@@ -807,6 +892,15 @@ export function KernelPage() {
>
重新安装
</button>
<button
type="button"
className="secondary-button"
title={(bundledCore?.assetName ?? bundledCoreError) || '当前发行包未包含内置内核'}
disabled={!bundledCore || installDisabled}
onClick={() => void installBundledCore()}
>
离线安装
</button>
</div>
</div>
+385
View File
@@ -0,0 +1,385 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { Activity, BarChart3, Clock3, Database, List, ShieldCheck, Sparkles, TriangleAlert } from 'lucide-react';
type UsageTab = 'overview' | 'analysis' | 'events';
type UsageRange = '4h' | '24h' | 'today' | '7d' | '30d' | 'all' | 'custom';
type CollectorStatus = {
state: 'waiting-core' | 'collecting' | 'error';
message: string;
lastCollectedAt: string | null;
totalRecords: number;
};
type TimelinePoint = {
hour: string;
requests: number;
success: number;
failure: number;
tokens: number;
};
type UsageOverview = {
totalRequests: number;
successCount: number;
failureCount: number;
successRate: number;
inputTokens: number;
outputTokens: number;
reasoningTokens: number;
cacheReadTokens: number;
cacheCreationTokens: number;
totalTokens: number;
rpm: number;
tpm: number;
averageLatencyMs: number;
timeline: TimelinePoint[];
};
type UsageCategory = {
key: string;
label: string;
requests: number;
failures: number;
tokens: number;
};
type UsageAnalysis = {
models: UsageCategory[];
providers: UsageCategory[];
sources: UsageCategory[];
apiKeys: UsageCategory[];
};
type UsageRecord = {
id: string;
timestamp: string;
latency_ms: number;
ttft_ms: number | null;
source: string;
failed: boolean;
provider: string;
model: string;
alias: string;
reasoning_effort: string;
endpoint: string;
api_key_hash: string;
api_key_display: string;
api_key_remark: string;
tokens: {
input_tokens: number;
output_tokens: number;
reasoning_tokens: number;
cache_read_tokens: number;
cache_creation_tokens: number;
total_tokens: number;
};
};
type UsageEventPage = {
items: UsageRecord[];
total: number;
page: number;
pageSize: number;
totalPages: number;
};
type UsageQuery = {
start?: string;
end?: string;
model?: string;
provider?: string;
source?: string;
api_key_hash?: string;
failed?: boolean;
page?: number;
page_size?: number;
};
const TAB_KEY = 'cpa-gui.usage-records-tab.v1';
const RANGE_KEY = 'cpa-gui.usage-records-range.v1';
const emptyAnalysis: UsageAnalysis = { models: [], providers: [], sources: [], apiKeys: [] };
const loadTab = (): UsageTab => {
try {
const saved = localStorage.getItem(TAB_KEY);
return saved === 'analysis' || saved === 'events' ? saved : 'overview';
} catch {
return 'overview';
}
};
const loadRange = (): UsageRange => {
try {
const saved = localStorage.getItem(RANGE_KEY) as UsageRange | null;
return ['4h', '24h', 'today', '7d', '30d', 'all', 'custom'].includes(saved ?? '')
? saved as UsageRange
: '24h';
} catch {
return '24h';
}
};
const rangeQuery = (range: UsageRange, customStart: string, customEnd: string): Pick<UsageQuery, 'start' | 'end'> => {
const now = new Date();
if (range === 'all') return {};
if (range === 'custom') {
const start = customStart ? new Date(customStart) : null;
const end = customEnd ? new Date(customEnd) : null;
return {
start: start && !Number.isNaN(start.getTime()) ? start.toISOString() : undefined,
end: end && !Number.isNaN(end.getTime()) ? end.toISOString() : undefined,
};
}
if (range === 'today') {
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
return { start: start.toISOString(), end: now.toISOString() };
}
const hours = range === '4h' ? 4 : range === '24h' ? 24 : range === '7d' ? 24 * 7 : 24 * 30;
return { start: new Date(now.getTime() - hours * 3_600_000).toISOString(), end: now.toISOString() };
};
const compactNumber = (value: number) => new Intl.NumberFormat('zh-CN', {
notation: value >= 10_000 ? 'compact' : 'standard',
maximumFractionDigits: 1,
}).format(Number.isFinite(value) ? value : 0);
const formatTime = (value: string) => {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat('zh-CN', {
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
}).format(date);
};
const filterOptions = (items: UsageCategory[]) => items.filter((item) => item.key && item.label);
export function UsageRecordsPage() {
const [activeTab, setActiveTab] = useState<UsageTab>(loadTab);
const [range, setRange] = useState<UsageRange>(loadRange);
const [customStart, setCustomStart] = useState('');
const [customEnd, setCustomEnd] = useState('');
const [model, setModel] = useState('');
const [provider, setProvider] = useState('');
const [source, setSource] = useState('');
const [apiKeyHash, setApiKeyHash] = useState('');
const [result, setResult] = useState('all');
const [page, setPage] = useState(1);
const [status, setStatus] = useState<CollectorStatus | null>(null);
const [overview, setOverview] = useState<UsageOverview | null>(null);
const [analysis, setAnalysis] = useState<UsageAnalysis>(emptyAnalysis);
const [optionsAnalysis, setOptionsAnalysis] = useState<UsageAnalysis>(emptyAnalysis);
const [events, setEvents] = useState<UsageEventPage | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const requestIdRef = useRef(0);
useEffect(() => {
try { localStorage.setItem(TAB_KEY, activeTab); } catch { /* Keep the in-memory tab. */ }
}, [activeTab]);
useEffect(() => {
try { localStorage.setItem(RANGE_KEY, range); } catch { /* Keep the in-memory range. */ }
}, [range]);
const buildQueries = useCallback(() => {
const nextTimeQuery = rangeQuery(range, customStart, customEnd);
return {
timeQuery: nextTimeQuery,
query: {
...nextTimeQuery,
model: model || undefined,
provider: provider || undefined,
source: source || undefined,
api_key_hash: apiKeyHash || undefined,
failed: result === 'failed' ? true : result === 'success' ? false : undefined,
} satisfies UsageQuery,
};
}, [apiKeyHash, customEnd, customStart, model, provider, range, result, source]);
const loadData = useCallback(async (quiet = false) => {
const requestId = ++requestIdRef.current;
const { timeQuery, query } = buildQueries();
if (!quiet) setLoading(true);
try {
const statusRequest = invoke<CollectorStatus>('get_usage_collector_status');
const optionsRequest = invoke<UsageAnalysis>('get_usage_analysis', { query: timeQuery });
if (activeTab === 'overview') {
const [nextStatus, nextOptions, nextOverview] = await Promise.all([
statusRequest,
optionsRequest,
invoke<UsageOverview>('get_usage_overview', { query }),
]);
if (requestId !== requestIdRef.current) return;
setStatus(nextStatus);
setOptionsAnalysis(nextOptions);
setOverview(nextOverview);
} else if (activeTab === 'analysis') {
const [nextStatus, nextOptions, nextOverview, nextAnalysis] = await Promise.all([
statusRequest,
optionsRequest,
invoke<UsageOverview>('get_usage_overview', { query }),
invoke<UsageAnalysis>('get_usage_analysis', { query }),
]);
if (requestId !== requestIdRef.current) return;
setStatus(nextStatus);
setOptionsAnalysis(nextOptions);
setOverview(nextOverview);
setAnalysis(nextAnalysis);
} else {
const [nextStatus, nextOptions, nextEvents] = await Promise.all([
statusRequest,
optionsRequest,
invoke<UsageEventPage>('get_usage_events', {
query: { ...query, page, page_size: 50 },
}),
]);
if (requestId !== requestIdRef.current) return;
setStatus(nextStatus);
setOptionsAnalysis(nextOptions);
setEvents(nextEvents);
}
setError('');
} catch (requestError) {
if (requestId === requestIdRef.current) setError(String(requestError));
} finally {
if (requestId === requestIdRef.current) setLoading(false);
}
}, [activeTab, buildQueries, page]);
useEffect(() => {
void loadData();
}, [loadData]);
useEffect(() => {
let disposed = false;
let unlisten: (() => void) | null = null;
const refresh = () => {
if (!disposed && !document.hidden) void loadData(true);
};
listen('usage-records-updated', refresh).then((stop) => {
if (disposed) stop(); else unlisten = stop;
}).catch(() => {});
const timer = window.setInterval(refresh, 5_000);
const refreshWhenVisible = () => {
if (!document.hidden) refresh();
};
window.addEventListener('focus', refresh);
document.addEventListener('visibilitychange', refreshWhenVisible);
return () => {
disposed = true;
unlisten?.();
window.clearInterval(timer);
window.removeEventListener('focus', refresh);
document.removeEventListener('visibilitychange', refreshWhenVisible);
};
}, [loadData]);
const changeFilter = (setter: (value: string) => void, value: string) => {
setter(value);
setPage(1);
};
const collectorTone = status?.state === 'error' ? 'error' : status?.state === 'collecting' ? 'success' : '';
const showInitialLoading = loading && (
(activeTab === 'overview' && !overview)
|| (activeTab === 'analysis' && !overview)
|| (activeTab === 'events' && !events)
);
return (
<section className="page management-page usage-records-page">
<header className="management-header usage-records-header">
<div>
<span>Local Usage</span>
<h1>使用记录</h1>
</div>
<div className={`usage-collector-state ${collectorTone}`} title={status?.message}>
<span className="status-dot" />
<div>
<strong>{status?.state === 'collecting' ? '本地采集中' : status?.state === 'error' ? '采集异常' : '等待内核'}</strong>
<span>{compactNumber(status?.totalRecords ?? 0)} 条长期记录</span>
</div>
</div>
</header>
{error ? <div className="management-alert error">{error}</div> : null}
<div className="usage-tabs" role="tablist" aria-label="使用记录页面">
<button type="button" className={activeTab === 'overview' ? 'active' : ''} onClick={() => setActiveTab('overview')}><Activity size={15} />总览</button>
<button type="button" className={activeTab === 'analysis' ? 'active' : ''} onClick={() => setActiveTab('analysis')}><BarChart3 size={15} />分析</button>
<button type="button" className={activeTab === 'events' ? 'active' : ''} onClick={() => setActiveTab('events')}><List size={15} />请求明细</button>
</div>
<section className="panel usage-filter-panel">
<select value={range} onChange={(event) => { setRange(event.currentTarget.value as UsageRange); setPage(1); }} aria-label="时间范围">
<option value="4h">最近 4 小时</option><option value="24h">最近 24 小时</option><option value="today">今天</option><option value="7d">最近 7 天</option><option value="30d">最近 30 天</option><option value="all">全部时间</option><option value="custom">自定义</option>
</select>
<select value={model} onChange={(event) => changeFilter(setModel, event.currentTarget.value)} aria-label="模型"><option value="">全部模型</option>{filterOptions(optionsAnalysis.models).map((item) => <option value={item.key} key={item.key}>{item.label}</option>)}</select>
<select value={provider} onChange={(event) => changeFilter(setProvider, event.currentTarget.value)} aria-label="Provider"><option value="">全部 Provider</option>{filterOptions(optionsAnalysis.providers).map((item) => <option value={item.key} key={item.key}>{item.label}</option>)}</select>
<select value={source} onChange={(event) => changeFilter(setSource, event.currentTarget.value)} aria-label="来源"><option value="">全部来源</option>{filterOptions(optionsAnalysis.sources).map((item) => <option value={item.key} key={item.key}>{item.label}</option>)}</select>
<select value={apiKeyHash} onChange={(event) => changeFilter(setApiKeyHash, event.currentTarget.value)} aria-label="API Key"><option value="">全部密钥</option>{filterOptions(optionsAnalysis.apiKeys).map((item) => <option value={item.key} key={item.key}>{item.label}</option>)}</select>
<select value={result} onChange={(event) => changeFilter(setResult, event.currentTarget.value)} aria-label="请求结果"><option value="all">全部结果</option><option value="success">成功</option><option value="failed">失败</option></select>
{range === 'custom' ? <div className="usage-custom-range"><input type="datetime-local" value={customStart} onChange={(event) => setCustomStart(event.currentTarget.value)} aria-label="开始时间" /><span>至</span><input type="datetime-local" value={customEnd} onChange={(event) => setCustomEnd(event.currentTarget.value)} aria-label="结束时间" /></div> : null}
</section>
{showInitialLoading ? <div className="usage-initial-loading"><Database size={22} /><span>正在读取本地 SQLite 使用记录</span></div> : null}
{activeTab === 'overview' && overview ? <OverviewView overview={overview} /> : null}
{activeTab === 'analysis' ? <AnalysisView analysis={analysis} overview={overview} /> : null}
{activeTab === 'events' && events ? <EventsView events={events} onPage={setPage} /> : null}
</section>
);
}
function OverviewView({ overview }: { overview: UsageOverview }) {
const cards = [
{ icon: Activity, label: '请求总数', value: compactNumber(overview.totalRequests), meta: `${overview.successCount} 成功 · ${overview.failureCount} 失败` },
{ icon: Sparkles, label: 'Token 总量', value: compactNumber(overview.totalTokens), meta: `输入 ${compactNumber(overview.inputTokens)} · 输出 ${compactNumber(overview.outputTokens)}` },
{ icon: ShieldCheck, label: '成功率', value: `${overview.successRate.toFixed(1)}%`, meta: `思考 ${compactNumber(overview.reasoningTokens)} Token` },
{ icon: Clock3, label: '平均延迟', value: `${Math.round(overview.averageLatencyMs)} ms`, meta: `${overview.rpm.toFixed(2)} RPM · ${compactNumber(overview.tpm)} TPM` },
];
return <div className="usage-overview-layout">
<div className="usage-stat-grid">{cards.map(({ icon: Icon, label, value, meta }) => <article className="panel usage-stat-card" key={label}><span><Icon size={16} />{label}</span><strong>{value}</strong><small>{meta}</small></article>)}</div>
<section className="panel usage-trend-panel"><div className="usage-section-heading"><div><strong>请求与 Token 趋势</strong><span>按系统本地小时聚合</span></div></div>{overview.timeline.length ? <UsageTrend points={overview.timeline} /> : <UsageEmpty />}</section>
<section className="panel usage-health-panel"><div className="usage-section-heading"><div><strong>Token 构成</strong><span>缓存、思考与生成消耗</span></div></div><div className="usage-token-breakdown"><TokenMetric label="输入" value={overview.inputTokens} total={overview.totalTokens} /><TokenMetric label="输出" value={overview.outputTokens} total={overview.totalTokens} /><TokenMetric label="思考" value={overview.reasoningTokens} total={overview.totalTokens} /><TokenMetric label="缓存读取" value={overview.cacheReadTokens} total={overview.totalTokens} /><TokenMetric label="缓存创建" value={overview.cacheCreationTokens} total={overview.totalTokens} /></div></section>
</div>;
}
function UsageTrend({ points }: { points: TimelinePoint[] }) {
const recent = points.slice(-48);
const max = Math.max(...recent.map((point) => point.requests), 1);
const polyline = recent.map((point, index) => `${recent.length === 1 ? 50 : index * 100 / (recent.length - 1)},${28 - point.requests * 24 / max}`).join(' ');
return <div className="usage-trend"><svg viewBox="0 0 100 32" preserveAspectRatio="none" aria-label="请求趋势"><polyline points={polyline} fill="none" vectorEffect="non-scaling-stroke" /></svg><div className="usage-trend-labels"><span>{recent[0]?.hour ?? ''}</span><strong>{compactNumber(recent.reduce((sum, point) => sum + point.tokens, 0))} Token</strong><span>{recent[recent.length - 1]?.hour ?? ''}</span></div></div>;
}
function TokenMetric({ label, value, total }: { label: string; value: number; total: number }) {
const percent = total ? Math.min(value * 100 / total, 100) : 0;
return <div><span><strong>{label}</strong><small>{compactNumber(value)}</small></span><i><b style={{ width: `${percent}%` }} /></i></div>;
}
function AnalysisView({ analysis, overview }: { analysis: UsageAnalysis; overview: UsageOverview | null }) {
const hours = (overview?.timeline ?? []).map((point) => ({
key: point.hour,
label: point.hour,
requests: point.requests,
failures: point.failure,
tokens: point.tokens,
})).sort((left, right) => right.tokens - left.tokens);
return <div className="usage-analysis-grid"><CategoryPanel title="模型使用" items={analysis.models} /><CategoryPanel title="Provider" items={analysis.providers} /><CategoryPanel title="请求来源" items={analysis.sources} /><CategoryPanel title="鉴权密钥" items={analysis.apiKeys} /><CategoryPanel title="时段分布" items={hours} /></div>;
}
function CategoryPanel({ title, items }: { title: string; items: UsageCategory[] }) {
const max = Math.max(...items.map((item) => item.tokens), 1);
const total = items.reduce((sum, item) => sum + item.tokens, 0);
return <section className="panel usage-category-panel"><div className="usage-section-heading"><div><strong>{title}</strong><span>按 Token 排序</span></div></div>{items.length ? <div className="usage-category-list">{items.slice(0, 10).map((item) => <div key={item.key}><span><strong title={item.label}>{item.label}</strong><small>{compactNumber(item.requests)} 次 · {total ? (item.tokens * 100 / total).toFixed(1) : '0.0'}% · {compactNumber(item.tokens)} Token</small></span><i><b style={{ width: `${item.tokens * 100 / max}%` }} /></i></div>)}</div> : <UsageEmpty />}</section>;
}
function EventsView({ events, onPage }: { events: UsageEventPage; onPage: (page: number) => void }) {
return <section className="panel usage-events-panel"><div className="usage-events-summary"><span>共 {compactNumber(events.total)} 条记录</span><span>第 {events.page} / {events.totalPages} 页</span></div>{events.items.length ? <div className="usage-table-wrap"><table className="usage-events-table"><thead><tr><th>时间</th><th>模型</th><th>Provider</th><th>来源</th><th>API Key</th><th>结果</th><th>延迟</th><th>TTFT</th><th>输入</th><th>输出</th><th>思考</th><th>缓存</th><th>总计</th></tr></thead><tbody>{events.items.map((record) => <tr key={record.id}><td>{formatTime(record.timestamp)}</td><td className="usage-stacked-cell"><strong title={record.alias || record.model}>{record.alias || record.model}</strong>{record.alias || record.reasoning_effort ? <small title={record.model}>{record.alias ? record.model : ''}{record.alias && record.reasoning_effort ? ' · ' : ''}{record.reasoning_effort}</small> : null}</td><td title={record.provider}>{record.provider || '—'}</td><td title={record.source}>{record.source || '—'}</td><td className="usage-stacked-cell"><strong title={record.api_key_remark}>{record.api_key_remark || '未备注'}</strong><small>{record.api_key_display || '—'}</small></td><td><span className={`usage-result ${record.failed ? 'failed' : 'success'}`}>{record.failed ? '失败' : '成功'}</span></td><td>{record.latency_ms} ms</td><td>{record.ttft_ms == null ? '—' : `${record.ttft_ms} ms`}</td><td>{compactNumber(record.tokens.input_tokens)}</td><td>{compactNumber(record.tokens.output_tokens)}</td><td>{compactNumber(record.tokens.reasoning_tokens)}</td><td>{compactNumber(record.tokens.cache_read_tokens)}</td><td><strong>{compactNumber(record.tokens.total_tokens)}</strong></td></tr>)}</tbody></table></div> : <UsageEmpty />}<div className="usage-pagination"><button type="button" className="secondary-button" disabled={events.page <= 1} onClick={() => onPage(events.page - 1)}>上一页</button><button type="button" className="secondary-button" disabled={events.page >= events.totalPages} onClick={() => onPage(events.page + 1)}>下一页</button></div></section>;
}
function UsageEmpty() {
return <div className="usage-empty"><TriangleAlert size={18} /><span>当前筛选范围没有使用记录</span></div>;
}
+478 -2
View File
@@ -388,6 +388,32 @@ input:focus-visible {
grid-column: span 2;
}
.version-heading-inline {
display: flex;
min-width: 0;
align-items: baseline;
gap: 10px;
}
.version-heading-inline h2 {
flex: none;
}
.version-offline-hint {
min-width: 0;
overflow: hidden;
color: #8a847c;
font-size: 10px;
font-weight: 400;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.kernel-layout > .version-panel > .panel-heading {
padding-bottom: 10px;
}
.client-api-panel {
margin-top: 18px;
}
@@ -610,7 +636,7 @@ input:focus-visible {
}
.version-action-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.info-list {
@@ -3790,6 +3816,413 @@ input:focus-visible {
min-width: 120px;
}
.usage-records-page {
display: grid;
gap: 14px;
align-content: start;
}
.usage-records-header {
min-height: 54px;
}
.usage-collector-state {
display: flex;
align-items: center;
gap: 10px;
min-width: 164px;
padding: 8px 11px;
border: 1px solid #e3e1db;
border-radius: 8px;
background: #fffdf8;
}
.usage-collector-state .status-dot {
width: 9px;
height: 9px;
box-shadow: 0 0 0 4px rgba(155, 149, 140, 0.12);
}
.usage-collector-state.success .status-dot {
background: #2f6b3f;
box-shadow: 0 0 0 4px rgba(47, 107, 63, 0.12);
}
.usage-collector-state.error .status-dot {
background: #9f2d20;
box-shadow: 0 0 0 4px rgba(159, 45, 32, 0.12);
}
.usage-collector-state > div {
display: grid;
gap: 2px;
}
.usage-collector-state strong {
color: #2d2a26;
font-size: 11px;
}
.usage-collector-state span {
margin: 0;
overflow: visible;
color: #8a847c;
font-size: 10px;
font-weight: 400;
text-transform: none;
white-space: normal;
}
.usage-tabs {
display: inline-grid;
grid-auto-flow: column;
justify-self: start;
gap: 3px;
padding: 3px;
border: 1px solid #d5d2cb;
border-radius: 8px;
background: #eeece6;
}
.usage-tabs button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 104px;
height: 34px;
padding: 0 12px;
border: 0;
border-radius: 6px;
background: transparent;
color: #7a746c;
font-size: 11px;
font-weight: 700;
}
.usage-tabs button.active {
background: #fffdf8;
color: #2d2a26;
box-shadow: 0 1px 4px rgba(45, 42, 38, 0.14);
}
.usage-filter-panel {
display: grid;
grid-template-columns: repeat(6, minmax(110px, 1fr));
gap: 8px;
padding: 10px;
}
.usage-filter-panel select,
.usage-filter-panel input {
min-width: 0;
height: 36px;
padding: 0 9px;
border: 1px solid #d5d2cb;
border-radius: 6px;
background: #fff;
color: #2d2a26;
font-size: 11px;
}
.usage-custom-range {
display: grid;
grid-column: 1 / -1;
grid-template-columns: minmax(170px, 1fr) auto minmax(170px, 1fr);
align-items: center;
gap: 8px;
color: #8a847c;
font-size: 11px;
}
.usage-initial-loading,
.usage-empty {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 120px;
color: #8a847c;
font-size: 11px;
}
.usage-overview-layout {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(260px, 0.6fr);
gap: 12px;
}
.usage-stat-grid {
display: grid;
grid-column: 1 / -1;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.usage-stat-card {
display: grid;
min-width: 0;
gap: 8px;
padding: 14px;
}
.usage-stat-card > span {
display: flex;
align-items: center;
gap: 7px;
color: #7a746c;
font-size: 11px;
}
.usage-stat-card > strong {
overflow: hidden;
color: #2d2a26;
font-size: 24px;
text-overflow: ellipsis;
white-space: nowrap;
}
.usage-stat-card > small {
overflow: hidden;
color: #8a847c;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.usage-trend-panel,
.usage-health-panel,
.usage-category-panel,
.usage-events-panel {
min-width: 0;
}
.usage-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 36px;
margin-bottom: 10px;
}
.usage-section-heading > div {
display: grid;
gap: 3px;
}
.usage-section-heading strong {
color: #2d2a26;
font-size: 13px;
}
.usage-section-heading span {
color: #8a847c;
font-size: 10px;
}
.usage-trend {
display: grid;
gap: 8px;
}
.usage-trend svg {
width: 100%;
height: 180px;
overflow: visible;
border-bottom: 1px solid #e3e1db;
background: repeating-linear-gradient(to bottom, transparent 0, transparent 43px, #eeeae2 44px);
}
.usage-trend polyline {
stroke: #3f6f98;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.usage-trend-labels {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
color: #8a847c;
font-size: 9px;
}
.usage-trend-labels strong {
color: #5f5a53;
font-size: 10px;
}
.usage-token-breakdown,
.usage-category-list {
display: grid;
gap: 12px;
}
.usage-token-breakdown > div,
.usage-category-list > div {
display: grid;
gap: 6px;
}
.usage-token-breakdown span,
.usage-category-list span {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.usage-token-breakdown strong,
.usage-category-list strong {
min-width: 0;
overflow: hidden;
color: #5f5a53;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.usage-token-breakdown small,
.usage-category-list small {
flex: none;
color: #8a847c;
font-size: 9px;
}
.usage-token-breakdown i,
.usage-category-list i {
display: block;
overflow: hidden;
height: 5px;
border-radius: 999px;
background: #eeece6;
}
.usage-token-breakdown b,
.usage-category-list b {
display: block;
height: 100%;
border-radius: inherit;
background: #6684a1;
}
.usage-analysis-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.usage-category-panel {
min-height: 290px;
}
.usage-events-panel {
display: grid;
gap: 10px;
padding: 12px;
}
.usage-events-summary,
.usage-pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
color: #8a847c;
font-size: 10px;
}
.usage-table-wrap {
overflow: auto;
min-height: 360px;
border: 1px solid #e3e1db;
border-radius: 7px;
}
.usage-events-table {
width: 100%;
min-width: 1320px;
border-collapse: collapse;
font-size: 10px;
}
.usage-events-table th,
.usage-events-table td {
height: 42px;
padding: 7px 9px;
border-bottom: 1px solid #eeeae2;
color: #5f5a53;
text-align: right;
white-space: nowrap;
}
.usage-events-table th {
position: sticky;
z-index: 1;
top: 0;
background: #f4f1ea;
color: #7a746c;
font-weight: 700;
}
.usage-events-table th:first-child,
.usage-events-table th:nth-child(2),
.usage-events-table th:nth-child(3),
.usage-events-table th:nth-child(4),
.usage-events-table th:nth-child(5),
.usage-events-table td:first-child,
.usage-events-table td:nth-child(2),
.usage-events-table td:nth-child(3),
.usage-events-table td:nth-child(4),
.usage-events-table td:nth-child(5) {
text-align: left;
}
.usage-events-table .usage-stacked-cell {
display: grid;
align-content: center;
max-width: 180px;
}
.usage-events-table td strong,
.usage-events-table td small {
overflow: hidden;
text-overflow: ellipsis;
}
.usage-events-table td small {
color: #8a847c;
font-size: 9px;
}
.usage-result {
display: inline-flex;
padding: 3px 7px;
border-radius: 999px;
font-weight: 700;
}
.usage-result.success {
background: #eef7f0;
color: #2f6b3f;
}
.usage-result.failed {
background: #fff1ee;
color: #9f2d20;
}
.usage-pagination {
justify-content: flex-end;
}
.usage-pagination button {
min-width: 78px;
min-height: 32px;
}
.real-auth-files-panel {
display: block;
height: auto;
@@ -4388,6 +4821,14 @@ input:focus-visible {
grid-template-columns: 200px minmax(0, 1fr);
}
.usage-filter-panel {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.usage-stat-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.config-side-stack {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: 206px;
@@ -4503,6 +4944,15 @@ input:focus-visible {
grid-template-columns: 1fr;
}
.usage-overview-layout,
.usage-analysis-grid {
grid-template-columns: 1fr;
}
.usage-filter-panel {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.thinking-alias-workbench {
grid-template-columns: 1fr;
}
@@ -4614,7 +5064,14 @@ input:focus-visible {
}
.kernel-layout .version-action-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-flow: row;
height: auto;
}
.kernel-layout > .version-panel {
grid-template-rows: auto 232px auto;
height: auto;
}
.management-header {
@@ -4622,6 +5079,25 @@ input:focus-visible {
flex-direction: column;
}
.usage-tabs {
width: 100%;
grid-auto-columns: 1fr;
}
.usage-tabs button {
min-width: 0;
padding: 0 7px;
}
.usage-filter-panel,
.usage-stat-grid {
grid-template-columns: 1fr;
}
.usage-custom-range {
grid-template-columns: 1fr;
}
.provider-category-panel {
grid-template-columns: 1fr;
}